the builder draws the sections it finds
An empty section renders as nothing. That is correct on the public site and useless in an editor: somebody who has just chosen "two columns" sees a blank page and has to take it on trust. So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines the columns inside it, because a one-column band and a two-column band are identical until something is in them, which is exactly when it is too late to notice the wrong choice. The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column. Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that has never heard of this file, and read as what they are. That is the whole contract — one attribute on the public site buys the entire editor. Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came from and nothing else; inferring the layout from its classes would be the admin reading the site's stylesheet, which is the coupling all of this exists to avoid. ── The race, and why the fix is not another ref ── Creating a section starts two things that do not finish in a fixed order: the frame fetches a new document, and the panel fetches the sections to draw on it. Painting only on load meant painting with whatever had arrived by then, which after a create was usually the data from before it existed. So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a dependency, and whichever arrives second triggers the paint that counts. The caller then controls repainting through its own useCallback dependencies, which is what those are for. The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and would repaint on every one; the sections are the thing being drawn and should be exactly what decides when to redraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-17
@@ -66,6 +66,12 @@ export const SitePreview = ({
|
||||
*
|
||||
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
|
||||
* document it was injected into.
|
||||
*
|
||||
* ── Give it a stable identity, and change that identity to REPAINT ──
|
||||
*
|
||||
* It is re-run whenever it changes, so a `useCallback` whose dependencies are the data being drawn is
|
||||
* exactly right: new data, new function, redrawn. An inline arrow is a new function every render and
|
||||
* would reinject on every one.
|
||||
*/
|
||||
onDocument?: (document: Document) => void | (() => void);
|
||||
/**
|
||||
@@ -167,26 +173,28 @@ export const SitePreview = ({
|
||||
}, [applyTheme, ready]);
|
||||
|
||||
/*
|
||||
Hand the loaded document to whoever asked for it.
|
||||
Hand the loaded document to whoever asked for it — on load, and again whenever they change.
|
||||
|
||||
Through a ref so a caller's inline arrow — which is a new function every render — does not re-run
|
||||
the injection on every render. Same trap as `useClient`, one level along.
|
||||
Two things have to happen and they race: the frame fetches a new document, and the caller fetches
|
||||
whatever it means to draw on it. Neither reliably finishes first. Running the injection ONLY on load
|
||||
meant painting with whatever data had arrived by then, which after creating a section was usually
|
||||
the data from before it existed.
|
||||
|
||||
So `loaded` is state rather than a callback: the effect below depends on both it and `onDocument`,
|
||||
and runs again when either moves. Whichever arrives second triggers the paint that counts.
|
||||
*/
|
||||
const onDocumentRef = useRef(onDocument);
|
||||
onDocumentRef.current = onDocument;
|
||||
const cleanupRef = useRef<(() => void) | void>(undefined);
|
||||
|
||||
const handOverDocument = useCallback(() => {
|
||||
// Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so
|
||||
// a cleanup that runs after the fact is cleaning something that no longer exists.
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = undefined;
|
||||
const [loaded, setLoaded] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const frameDocument = frame.current?.contentDocument;
|
||||
if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument);
|
||||
}, []);
|
||||
if (!frameDocument) return;
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
const cleanup = onDocument?.(frameDocument);
|
||||
// Runs before the next paint and on unmount, so nothing injected outlives its document — and two
|
||||
// paints never leave two sets of controls.
|
||||
return () => cleanup?.();
|
||||
}, [loaded, onDocument]);
|
||||
|
||||
const message =
|
||||
!port ? 'No development server is configured — set its port under Administration.'
|
||||
@@ -236,12 +244,17 @@ export const SitePreview = ({
|
||||
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
|
||||
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
|
||||
key={reloadToken ?? 0}
|
||||
ref={frame}
|
||||
// A remount is a NEW document, and the effect above must not paint into the old one on
|
||||
// the strength of a `loaded` that referred to it.
|
||||
ref={(node) => {
|
||||
frame.current = node;
|
||||
if (!node) setLoaded(0);
|
||||
}}
|
||||
// Applied on load as well as on change: the document is replaced on a reload, and the
|
||||
// properties go with it.
|
||||
onLoad={() => {
|
||||
applyTheme();
|
||||
handOverDocument();
|
||||
setLoaded((n) => n + 1);
|
||||
}}
|
||||
src={`/api/dev-proxy/${port}/${path}`}
|
||||
title={`The site at /${path}`}
|
||||
|
||||
Reference in New Issue
Block a user