From 79dae6d407479cd6ff192524b5103ac401eb9e13 Mon Sep 17 00:00:00 2001 From: Tharaka Hewavitharana Date: Fri, 7 Aug 2026 16:13:04 +0200 Subject: [PATCH 1/3] CMS-54818 Improve preview updates and add tests for `PreviewComponent` and `NextPreviewComponent` --- .changeset/lucky-hounds-tell.md | 5 + .../react/__test__/previewComponent.test.tsx | 115 ++++++++++++++++++ .../optimizely-cms-sdk/src/react/client.tsx | 52 +++++--- .../optimizely-cms-sdk/src/react/nextjs.tsx | 34 ++++-- 4 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 .changeset/lucky-hounds-tell.md create mode 100644 packages/optimizely-cms-sdk/src/react/__test__/previewComponent.test.tsx diff --git a/.changeset/lucky-hounds-tell.md b/.changeset/lucky-hounds-tell.md new file mode 100644 index 00000000..72b13378 --- /dev/null +++ b/.changeset/lucky-hounds-tell.md @@ -0,0 +1,5 @@ +--- +'@optimizely/cms-sdk': minor +--- + +Fix slow and dropped preview updates in `PreviewComponent` and `NextPreviewComponent` \ No newline at end of file diff --git a/packages/optimizely-cms-sdk/src/react/__test__/previewComponent.test.tsx b/packages/optimizely-cms-sdk/src/react/__test__/previewComponent.test.tsx new file mode 100644 index 00000000..dd50067d --- /dev/null +++ b/packages/optimizely-cms-sdk/src/react/__test__/previewComponent.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { useState } from 'react'; +import { PreviewComponent } from '../client.js'; + +const save = (link: string | number) => + act(() => { + window.dispatchEvent( + new CustomEvent('optimizely:cms:contentSaved', { + detail: { + contentLink: `c${link}`, + previewUrl: window.location.href, + previewToken: 't', + }, + }), + ); + }); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('PreviewComponent', () => { + it('keeps a pending refresh alive when the parent re-renders', async () => { + const onNavigate = vi.fn(); + let bump: () => void = () => {}; + + function Parent() { + const [, setN] = useState(0); + bump = () => setN(x => x + 1); + // Unstable identity, exactly like NextPreviewComponent's inline arrow + return ( + onNavigate(u, s)} /> + ); + } + + render(); + save(1); + await act(async () => void vi.advanceTimersByTime(100)); + await act(async () => bump()); + await act(async () => void vi.advanceTimersByTime(500)); + + expect(onNavigate).toHaveBeenCalledTimes(1); + }); + + it('defaults to a 50ms trailing debounce', async () => { + const onNavigate = vi.fn(); + render(); + + save(1); + await act(async () => void vi.advanceTimersByTime(49)); + expect(onNavigate).toHaveBeenCalledTimes(0); + await act(async () => void vi.advanceTimersByTime(1)); + expect(onNavigate).toHaveBeenCalledTimes(1); + }); + + it('coalesces the burst the CMS emits for one save', async () => { + const onNavigate = vi.fn(); + render(); + + // Page plus two nested blocks, different contentLinks, a few ms apart + await act(async () => { + save('page'); + vi.advanceTimersByTime(5); + save('blockA'); + vi.advanceTimersByTime(5); + save('blockB'); + vi.advanceTimersByTime(500); + }); + expect(onNavigate).toHaveBeenCalledTimes(1); + }); + + it('still dedupes repeats when debouncing is disabled', async () => { + const onNavigate = vi.fn(); + render(); + + await act(async () => { + save('page'); + save('page'); + }); + expect(onNavigate).toHaveBeenCalledTimes(1); + }); + + it('keeps the loading indicator up while `busy` is set', async () => { + const mask =
loading
; + const props = { refreshTimeout: 100, onNavigate: () => undefined }; + + const { container, rerender } = render( + + {mask} + , + ); + + save(1); + expect(container.textContent).toBe('loading'); + + // Navigation starts: `onNavigate` returns void, so the internal mask drops + // immediately - `busy` is what covers the actual server round-trip. + await act(async () => { + vi.advanceTimersByTime(100); + rerender( + + {mask} + , + ); + }); + expect(container.textContent).toBe('loading'); + + rerender( + + {mask} + , + ); + expect(container.textContent).toBe(''); + }); +}); diff --git a/packages/optimizely-cms-sdk/src/react/client.tsx b/packages/optimizely-cms-sdk/src/react/client.tsx index c11dc5e2..bd0135e8 100644 --- a/packages/optimizely-cms-sdk/src/react/client.tsx +++ b/packages/optimizely-cms-sdk/src/react/client.tsx @@ -40,8 +40,9 @@ export interface PreviewComponentProps { /** * Delay in ms before triggering navigation. False to disable. - * Useful for debouncing rapid saves. - * @default 300 + * Coalesces the burst of events the CMS emits for a single save (page plus each + * nested block), which land within a few ms of each other. + * @default 50 */ refreshTimeout?: number | false; @@ -49,21 +50,35 @@ export interface PreviewComponentProps { * Optional loading indicator shown during refresh delay. */ children?: ReactNode; + + /** + * Keeps the loading indicator visible while the caller is still navigating. + * Needed because router APIs like Next.js `router.refresh()` return `void`, + * so `onNavigate` resolving does not mean the new content has arrived. + */ + busy?: boolean; } /** * Listens for Optimizely CMS content saved events and triggers navigation/refresh. - * Deduplication prevents duplicate refreshes. + * Rapid saves are coalesced into a single refresh. */ export const PreviewComponent: FunctionComponent< PropsWithChildren -> = ({ onNavigate, refreshTimeout = 300, children }) => { +> = ({ onNavigate, refreshTimeout = 50, children, busy = false }) => { const [showMask, setShowMask] = useState(false); const reloadDelay = useRef(undefined); const lastProcessedRef = useRef<{ contentLink: string; timestamp: number } | null>( null, ); + // Read through a ref so the listener effect never re-runs. Callers pass an inline + // arrow for `onNavigate`, and re-subscribing would clearTimeout a pending refresh. + const optionsRef = useRef({ onNavigate, refreshTimeout }); + useEffect(() => { + optionsRef.current = { onNavigate, refreshTimeout }; + }); + useEffect(() => { const normalizeUrl = (url: string): string => { const parsed = new URL(url); @@ -72,19 +87,22 @@ export const PreviewComponent: FunctionComponent< }; const handleContentSaved = (eventData: ContentSavedEvent) => { - const now = Date.now(); - - // Ignore same contentLink within 50ms (deduplication for dual events) - if ( - lastProcessedRef.current && - lastProcessedRef.current.contentLink === eventData.contentLink && - now - lastProcessedRef.current.timestamp < 50 - ) { - return; + const { onNavigate, refreshTimeout } = optionsRef.current; + + // With debouncing on, the timer already coalesces repeats. Only the + // `refreshTimeout={false}` path needs an explicit dupe guard. + if (!refreshTimeout) { + const now = Date.now(); + if ( + lastProcessedRef.current && + lastProcessedRef.current.contentLink === eventData.contentLink && + now - lastProcessedRef.current.timestamp < 50 + ) { + return; + } + lastProcessedRef.current = { contentLink: eventData.contentLink, timestamp: now }; } - lastProcessedRef.current = { contentLink: eventData.contentLink, timestamp: now }; - const currentUrl = window.location.href; setShowMask(true); @@ -128,7 +146,7 @@ export const PreviewComponent: FunctionComponent< window.removeEventListener('optimizely:cms:contentSaved', customEventListener); if (reloadDelay.current) clearTimeout(reloadDelay.current); }; - }, [onNavigate, refreshTimeout]); + }, []); - return showMask && children ? <>{children} : null; + return (showMask || busy) && children ? <>{children} : null; }; diff --git a/packages/optimizely-cms-sdk/src/react/nextjs.tsx b/packages/optimizely-cms-sdk/src/react/nextjs.tsx index 991a6123..783009f2 100644 --- a/packages/optimizely-cms-sdk/src/react/nextjs.tsx +++ b/packages/optimizely-cms-sdk/src/react/nextjs.tsx @@ -3,13 +3,13 @@ // @ts-ignore - next/navigation is optional peer dependency import { useRouter } from 'next/navigation'; import { PreviewComponent } from './client.js'; -import type { ReactNode } from 'react'; +import { useTransition, type ReactNode } from 'react'; export interface NextPreviewComponentProps { /** * Delay in ms before triggering navigation. False to disable. - * Useful for debouncing rapid saves. - * @default 300 + * Coalesces the burst of events the CMS emits for a single save. + * @default 50 */ refreshTimeout?: number | false; @@ -28,28 +28,36 @@ export interface NextPreviewComponentProps { * import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs'; * * export default function PreviewPage() { - * return ; + * return ; * } * ``` */ export function NextPreviewComponent({ - refreshTimeout = 300, + refreshTimeout = 50, children, }: NextPreviewComponentProps = {}) { const router = useRouter(); + // `router.refresh()` and `router.push()` return void, so the loading indicator would + // otherwise disappear a microtask later. Inside a transition `isPending` stays true + // until the new Server Component payload has actually landed. + const [isPending, startTransition] = useTransition(); + return ( { - if (isSameUrl) { - // Same URL - soft refresh to revalidate Server Components - router.refresh(); - } else { - // Different URL - client-side navigation - const parsed = new URL(url); - router.push(parsed.pathname + parsed.search, { scroll: false }); - } + startTransition(() => { + if (isSameUrl) { + // Same URL - soft refresh to revalidate Server Components + router.refresh(); + } else { + // Different URL - client-side navigation + const parsed = new URL(url); + router.push(parsed.pathname + parsed.search, { scroll: false }); + } + }); }} > {children} From 00d6a4f6d799401f4bb220b67fc10a20b52f1035 Mon Sep 17 00:00:00 2001 From: Tharaka Hewavitharana Date: Mon, 10 Aug 2026 11:19:09 +0200 Subject: [PATCH 2/3] CMS-54818 Update preview components to use NextPreviewComponent for improved revalidation --- .changeset/brave-moons-repeat.md | 5 +++++ .../test-website/src/app/preview/page.tsx | 4 ++-- .../fx-integration/src/app/preview/page.tsx | 4 ++-- samples/hello-world/src/app/preview/page.tsx | 4 ++-- .../nextjs-template/src/app/preview/page.tsx | 4 ++-- .../tanstack-template/src/routes/preview.tsx | 20 ++++++++++++++++--- 6 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 .changeset/brave-moons-repeat.md diff --git a/.changeset/brave-moons-repeat.md b/.changeset/brave-moons-repeat.md new file mode 100644 index 00000000..dbfe729c --- /dev/null +++ b/.changeset/brave-moons-repeat.md @@ -0,0 +1,5 @@ +--- +'@optimizely/cms-sdk': patch +--- + +Wire preview templates to their framework's revalidation instead of a full page reload diff --git a/__test__/test-website/src/app/preview/page.tsx b/__test__/test-website/src/app/preview/page.tsx index 6dce25d4..e2e72597 100644 --- a/__test__/test-website/src/app/preview/page.tsx +++ b/__test__/test-website/src/app/preview/page.tsx @@ -1,6 +1,6 @@ import { GraphClient, type PreviewParams } from '@optimizely/cms-sdk'; import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; -import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; +import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs'; import Script from 'next/script'; type Props = { @@ -37,7 +37,7 @@ export default async function Page({ searchParams }: Props) { ).href } > - + ); diff --git a/samples/fx-integration/src/app/preview/page.tsx b/samples/fx-integration/src/app/preview/page.tsx index 6ec8ce34..321a3e89 100644 --- a/samples/fx-integration/src/app/preview/page.tsx +++ b/samples/fx-integration/src/app/preview/page.tsx @@ -1,6 +1,6 @@ import { getClient, type PreviewParams } from '@optimizely/cms-sdk'; import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; -import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; +import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs'; import { withAppContext } from '@optimizely/cms-sdk/react/server'; import Script from 'next/script'; @@ -32,7 +32,7 @@ async function Page({ searchParams }: Props) { ).href } > - + ); diff --git a/samples/hello-world/src/app/preview/page.tsx b/samples/hello-world/src/app/preview/page.tsx index fde7b138..29d9f91b 100644 --- a/samples/hello-world/src/app/preview/page.tsx +++ b/samples/hello-world/src/app/preview/page.tsx @@ -1,6 +1,6 @@ import { getClient, type PreviewParams } from '@optimizely/cms-sdk'; import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; -import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; +import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs'; import { withAppContext } from '@optimizely/cms-sdk/react/server'; import Script from 'next/script'; @@ -23,7 +23,7 @@ async function Page({ searchParams }: Props) { ).href } > - + ); diff --git a/samples/nextjs-template/src/app/preview/page.tsx b/samples/nextjs-template/src/app/preview/page.tsx index 15de120c..9f76dc4c 100644 --- a/samples/nextjs-template/src/app/preview/page.tsx +++ b/samples/nextjs-template/src/app/preview/page.tsx @@ -1,6 +1,6 @@ import { getClient, type PreviewParams } from '@optimizely/cms-sdk'; import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; -import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; +import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs'; import { withAppContext } from '@optimizely/cms-sdk/react/server'; import Script from 'next/script'; @@ -26,7 +26,7 @@ async function Page({ searchParams }: Props) { ).href } > - + ); diff --git a/samples/tanstack-template/src/routes/preview.tsx b/samples/tanstack-template/src/routes/preview.tsx index 1e51a737..fbc74d88 100644 --- a/samples/tanstack-template/src/routes/preview.tsx +++ b/samples/tanstack-template/src/routes/preview.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { type PreviewParams } from '@optimizely/cms-sdk'; import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server'; import { PreviewComponent } from '@optimizely/cms-sdk/react/client'; @@ -36,7 +36,6 @@ async function Page({ search }: Props) { ).href } > - ); @@ -61,5 +60,20 @@ export const Route = createFileRoute('/preview')({ function Preview() { const { Renderable } = Route.useLoaderData(); - return <>{Renderable}; + const router = useRouter(); + + return ( + <> + { + // `invalidate` re-runs the loader, which re-renders the page on the server. + // It returns a promise, so the loading indicator tracks the real round-trip. + if (isSameUrl) return router.invalidate(); + const parsed = new URL(url); + return router.navigate({ href: parsed.pathname + parsed.search }); + }} + /> + {Renderable} + + ); } From 59e769662dfeccc9cd471885bbd1953d8e19123c Mon Sep 17 00:00:00 2001 From: Tharaka Hewavitharana Date: Mon, 10 Aug 2026 12:00:53 +0200 Subject: [PATCH 3/3] CMS-54818 Enhance preview functionality by adding loader dependencies and tests for revalidation --- .changeset/brave-moons-repeat.md | 5 ----- .../optimizely-cms-create-app/scripts/prepare-templates.ts | 1 + samples/tanstack-template/src/routes/preview.tsx | 6 +++++- 3 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 .changeset/brave-moons-repeat.md diff --git a/.changeset/brave-moons-repeat.md b/.changeset/brave-moons-repeat.md deleted file mode 100644 index dbfe729c..00000000 --- a/.changeset/brave-moons-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@optimizely/cms-sdk': patch ---- - -Wire preview templates to their framework's revalidation instead of a full page reload diff --git a/packages/optimizely-cms-create-app/scripts/prepare-templates.ts b/packages/optimizely-cms-create-app/scripts/prepare-templates.ts index ae8ee68d..cc2fa9b0 100644 --- a/packages/optimizely-cms-create-app/scripts/prepare-templates.ts +++ b/packages/optimizely-cms-create-app/scripts/prepare-templates.ts @@ -9,6 +9,7 @@ const MONOREPO_ROOT = path.resolve(ROOT, '..', '..'); const EXCLUDE = new Set([ 'node_modules', + '__test__', '.next', '.tanstack', 'certificates', diff --git a/samples/tanstack-template/src/routes/preview.tsx b/samples/tanstack-template/src/routes/preview.tsx index fbc74d88..ef83fcd3 100644 --- a/samples/tanstack-template/src/routes/preview.tsx +++ b/samples/tanstack-template/src/routes/preview.tsx @@ -49,7 +49,11 @@ const getPreviewPage = createServerFn().handler(async ({ data: { search } }: any }); export const Route = createFileRoute('/preview')({ - loader: async ({ location: { search } }) => { + // The match id is `routeId + path + hash(loaderDeps)`. Without this the id is the + // same for every `ver`, so navigating to a new version reuses the existing match + // and the loader never re-runs. + loaderDeps: ({ search }) => search, + loader: async ({ deps: search }) => { const { Renderable } = await getPreviewPage({ data: { search }, } as any);