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/__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/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/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}
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..ef83fcd3 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
}
>
-
>
);
@@ -50,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);
@@ -61,5 +64,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}
+ >
+ );
}