Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-hounds-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@optimizely/cms-sdk': minor
---

Fix slow and dropped preview updates in `PreviewComponent` and `NextPreviewComponent`
4 changes: 2 additions & 2 deletions __test__/test-website/src/app/preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -37,7 +37,7 @@ export default async function Page({ searchParams }: Props) {
).href
}
></Script>
<PreviewComponent />
<NextPreviewComponent />
<OptimizelyComponent content={response} />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const MONOREPO_ROOT = path.resolve(ROOT, '..', '..');

const EXCLUDE = new Set([
'node_modules',
'__test__',
'.next',
'.tanstack',
'certificates',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<PreviewComponent refreshTimeout={300} onNavigate={(u, s) => onNavigate(u, s)} />
);
}

render(<Parent />);
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(<PreviewComponent onNavigate={onNavigate} />);

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(<PreviewComponent onNavigate={onNavigate} />);

// 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(<PreviewComponent refreshTimeout={false} onNavigate={onNavigate} />);

await act(async () => {
save('page');
save('page');
});
expect(onNavigate).toHaveBeenCalledTimes(1);
});

it('keeps the loading indicator up while `busy` is set', async () => {
const mask = <div>loading</div>;
const props = { refreshTimeout: 100, onNavigate: () => undefined };

const { container, rerender } = render(
<PreviewComponent {...props} busy={false}>
{mask}
</PreviewComponent>,
);

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(
<PreviewComponent {...props} busy={true}>
{mask}
</PreviewComponent>,
);
});
expect(container.textContent).toBe('loading');

rerender(
<PreviewComponent {...props} busy={false}>
{mask}
</PreviewComponent>,
);
expect(container.textContent).toBe('');
});
});
52 changes: 35 additions & 17 deletions packages/optimizely-cms-sdk/src/react/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,45 @@ 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;

/**
* 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<PreviewComponentProps>
> = ({ onNavigate, refreshTimeout = 300, children }) => {
> = ({ onNavigate, refreshTimeout = 50, children, busy = false }) => {
const [showMask, setShowMask] = useState<boolean>(false);
const reloadDelay = useRef<NodeJS.Timeout | undefined>(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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
};
34 changes: 21 additions & 13 deletions packages/optimizely-cms-sdk/src/react/nextjs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,28 +28,36 @@ export interface NextPreviewComponentProps {
* import { NextPreviewComponent } from '@optimizely/cms-sdk/react/nextjs';
*
* export default function PreviewPage() {
* return <NextPreviewComponent refreshTimeout={300} />;
* return <NextPreviewComponent refreshTimeout={50} />;
* }
* ```
*/
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 (
<PreviewComponent
refreshTimeout={refreshTimeout}
busy={isPending}
onNavigate={(url: string, isSameUrl: boolean) => {
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}
Expand Down
4 changes: 2 additions & 2 deletions samples/fx-integration/src/app/preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -32,7 +32,7 @@ async function Page({ searchParams }: Props) {
).href
}
></Script>
<PreviewComponent />
<NextPreviewComponent />
<OptimizelyComponent content={content} />
</>
);
Expand Down
4 changes: 2 additions & 2 deletions samples/hello-world/src/app/preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -23,7 +23,7 @@ async function Page({ searchParams }: Props) {
).href
}
></Script>
<PreviewComponent />
<NextPreviewComponent />
<OptimizelyComponent content={content} />
</>
);
Expand Down
4 changes: 2 additions & 2 deletions samples/nextjs-template/src/app/preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -26,7 +26,7 @@ async function Page({ searchParams }: Props) {
).href
}
></Script>
<PreviewComponent />
<NextPreviewComponent />
<OptimizelyComponent content={content} />
</>
);
Expand Down
Loading
Loading