Skip to content
Open
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
64 changes: 64 additions & 0 deletions src/__tests__/publisher/runtimePreviewSettle.test.tsx
Original file line number Diff line number Diff line change
@@ -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: `<html><body>build ${builds}</body></html>`,
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function HoverPreviewDocument({
templatePreviewContext,
}: HoverPreviewDocumentProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { html, loading, error, refresh } = useRuntimePreviewDocument(
const { html, error, refresh } = useRuntimePreviewDocument(
site,
preview.page,
templatePreviewContext,
Expand All @@ -138,7 +138,7 @@ function HoverPreviewDocument({
)
}

if (loading || !html) return <HoverPreviewStatus label="Building desktop preview…" />
if (!html) return <HoverPreviewStatus label="Building desktop preview…" />

function handlePreviewWheel(event: WheelEvent<HTMLDivElement>) {
const previewWindow = iframeRef.current?.contentWindow
Expand Down
4 changes: 2 additions & 2 deletions src/admin/pages/site/preview/PreviewOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface PreviewDocumentProps {

function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocumentProps) {
const reportedErrorRef = useRef<string | null>(null)
const { html, loading, error, refresh } = useRuntimePreviewDocument(
const { html, error, refresh } = useRuntimePreviewDocument(
site,
page,
templatePreviewContext,
Expand Down Expand Up @@ -73,7 +73,7 @@ function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocument
)
}

if (loading || !html) {
if (!html) {
return (
<EmptyState
variant="centered"
Expand Down
37 changes: 29 additions & 8 deletions src/admin/pages/site/preview/useRuntimePreviewDocument.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { useEffect, useState } from 'react'
import type { Page, SiteDocument } from '@core/page-tree'
import { isAbortError } from '@core/http'
import { buildCmsRuntimePreview } from '@core/persistence'
import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
import { useAsyncResource } from '@admin/lib/useAsyncResource'

/**
* How long the draft must stay unchanged before the preview rebuilds. Every
* build uploads the whole site document and re-bundles and renders the page on
* the server; without this, each keystroke or remote collab update aborted the
* in-flight build and started another (bursts of cancelled requests).
*/
export const PREVIEW_SITE_SETTLE_MS = 400

interface LoadedPreviewDocument {
site: SiteDocument
pageId: string
contextKey: string
html: string
}

interface RuntimePreviewDocumentState {
/**
* The newest built document for this page and context. While a rebuild for
* a newer draft is in flight it stays the previous build (`loading` is true)
* rather than blanking the preview.
*/
html: string | null
loading: boolean
error: string | null
Expand All @@ -31,19 +44,19 @@ export function useRuntimePreviewDocument(
templatePreviewContext: TemplateRenderDataContext | undefined,
): RuntimePreviewDocumentState {
const contextKey = JSON.stringify(templatePreviewContext ?? null)
const settledSite = useSettledSite(site)
const { data, loading, error, refresh } = useAsyncResource<LoadedPreviewDocument>(
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,
Expand All @@ -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
}
Loading