From 5f9b086819dd7f7d03ddac996ccdb8e0ce30709b Mon Sep 17 00:00:00 2001 From: Norbert Gocht Date: Wed, 14 Oct 2026 18:46:07 +0200 Subject: [PATCH] fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each runtime preview build POSTs the whole site document and the server re-validates it, re-bundles the page scripts and renders the page. The Preview overlay and the Site Explorer hover card rebuilt on every `site` change, so typing or a collaborator's edits aborted the in-flight build and started another — production logs show bursts of 8 cancelled requests within two seconds — and the preview blanked to "Building…" each time. - useRuntimePreviewDocument builds from the draft only once it has stayed unchanged for 400ms (PREVIEW_SITE_SETTLE_MS). - While a newer build runs, `html` keeps the previous build for the same page and context; both callers show the loading state only when there is nothing to show yet. Co-Authored-By: Claude Opus 5.5 --- .../publisher/runtimePreviewSettle.test.tsx | 64 +++++++++++++++++++ .../SiteExplorerHoverPreview.tsx | 4 +- .../pages/site/preview/PreviewOverlay.tsx | 4 +- .../site/preview/useRuntimePreviewDocument.ts | 37 ++++++++--- 4 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/publisher/runtimePreviewSettle.test.tsx diff --git a/src/__tests__/publisher/runtimePreviewSettle.test.tsx b/src/__tests__/publisher/runtimePreviewSettle.test.tsx new file mode 100644 index 000000000..e2b2bc299 --- /dev/null +++ b/src/__tests__/publisher/runtimePreviewSettle.test.tsx @@ -0,0 +1,64 @@ +/** + * Every runtime preview build uploads the whole site and re-bundles + renders + * the page server-side. Rebuilding on each draft change aborted the in-flight + * build per keystroke or remote collab update (bursts of cancelled requests in + * production), and blanked the preview each time. Builds wait for the draft to + * settle, and the previous build stays on screen while the next one runs. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import type { SiteDocument } from '@core/page-tree' +import { + PREVIEW_SITE_SETTLE_MS, + useRuntimePreviewDocument, +} from '@site/preview/useRuntimePreviewDocument' +import { makePage, makeSite } from '../fixtures' + +const originalFetch = globalThis.fetch +let builds = 0 + +beforeEach(() => { + builds = 0 + globalThis.fetch = async () => { + builds += 1 + return new Response(JSON.stringify({ + html: `build ${builds}`, + assets: [], + runtimeAssets: { scripts: [] }, + diagnostics: [], + }), { status: 200 }) + } +}) + +afterEach(() => { + cleanup() + globalThis.fetch = originalFetch +}) + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('useRuntimePreviewDocument', () => { + it('builds once for a burst of draft changes and keeps the last build meanwhile', async () => { + const page = makePage({ id: 'home' }) + let site: SiteDocument = makeSite({ name: 'v0', pages: [page] }) + const { result, rerender } = renderHook( + ({ current }: { current: SiteDocument }) => useRuntimePreviewDocument(current, page, undefined), + { initialProps: { current: site } }, + ) + await waitFor(() => expect(result.current.html).toContain('build 1')) + + // Five edits in quick succession, each well inside the settle window. + for (let i = 1; i <= 5; i++) { + site = makeSite({ name: `v${i}`, pages: [page] }) + act(() => rerender({ current: site })) + await act(() => sleep(PREVIEW_SITE_SETTLE_MS / 5)) + expect(result.current.html).toContain('build 1') + } + expect(builds).toBe(1) + + await waitFor(() => expect(result.current.html).toContain('build 2'), { + timeout: PREVIEW_SITE_SETTLE_MS * 4, + }) + expect(builds).toBe(2) + }) +}) diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx index 94b248589..c79017276 100644 --- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx +++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx @@ -121,7 +121,7 @@ function HoverPreviewDocument({ templatePreviewContext, }: HoverPreviewDocumentProps) { const iframeRef = useRef(null) - const { html, loading, error, refresh } = useRuntimePreviewDocument( + const { html, error, refresh } = useRuntimePreviewDocument( site, preview.page, templatePreviewContext, @@ -138,7 +138,7 @@ function HoverPreviewDocument({ ) } - if (loading || !html) return + if (!html) return function handlePreviewWheel(event: WheelEvent) { const previewWindow = iframeRef.current?.contentWindow diff --git a/src/admin/pages/site/preview/PreviewOverlay.tsx b/src/admin/pages/site/preview/PreviewOverlay.tsx index 81d752eb5..bdb51fdfc 100644 --- a/src/admin/pages/site/preview/PreviewOverlay.tsx +++ b/src/admin/pages/site/preview/PreviewOverlay.tsx @@ -39,7 +39,7 @@ interface PreviewDocumentProps { function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocumentProps) { const reportedErrorRef = useRef(null) - const { html, loading, error, refresh } = useRuntimePreviewDocument( + const { html, error, refresh } = useRuntimePreviewDocument( site, page, templatePreviewContext, @@ -73,7 +73,7 @@ function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocument ) } - if (loading || !html) { + if (!html) { return ( ( async (signal) => { try { const preview = await buildCmsRuntimePreview( { - site, + site: settledSite, pageId: page.id, templateContext: templatePreviewContext, }, { signal }, ) return { - site, pageId: page.id, contextKey, html: preview.html, @@ -55,14 +68,22 @@ export function useRuntimePreviewDocument( throw err } }, - [site, page.id, contextKey], + [settledSite, page.id, contextKey], { fallbackError: 'Preview build failed' }, ) - const html = - data?.site === site && data.pageId === page.id && data.contextKey === contextKey - ? data.html - : null + const html = data?.pageId === page.id && data.contextKey === contextKey ? data.html : null return { html, loading, error, refresh } } + +/** `site`, updated only once it has stayed unchanged for PREVIEW_SITE_SETTLE_MS. */ +function useSettledSite(site: SiteDocument): SiteDocument { + const [settled, setSettled] = useState(site) + useEffect(() => { + if (settled === site) return undefined + const timer = setTimeout(() => setSettled(site), PREVIEW_SITE_SETTLE_MS) + return () => clearTimeout(timer) + }, [site, settled]) + return settled +}