The frame carried key={reloadToken}, so a reload was a NEW element: React
unmounted the old one and the panel went white until the next document arrived.
After a crop change that is not instant — the site is asking av-club for a fresh
ladder — so every framing adjustment flashed an empty panel, which reads as the
preview breaking.
A browser keeps painting the current document while the next is being fetched. So
the element stays and the frame is navigated instead. Same origin through the dev
proxy, which is what makes contentWindow reachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
395 lines
17 KiB
TypeScript
395 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from './errors';
|
|
import { DEVICES, usePreviewable, useViewMode } from './ViewMode';
|
|
import { useFofosChanged } from './channel';
|
|
|
|
/**
|
|
* A page of the real site, embedded.
|
|
*
|
|
* ── Not a drawing of it ──
|
|
*
|
|
* The Layout section began with a React sketch of the header and footer. It looked plausible and it
|
|
* was wrong, and worse it was a second implementation that would go on being wrong differently every
|
|
* time either side changed. This renders the site's own page instead, so a change made in code shows
|
|
* up here with no change here at all.
|
|
*
|
|
* ── Through the PLATFORM's dev proxy ──
|
|
*
|
|
* `/api/dev-proxy/:port/*` reaches any loopback port, rewrites the HTML, and injects a script that
|
|
* patches fetch, XHR and WebSocket so URLs the page builds at RUNTIME are moved too. This plugin had
|
|
* its own proxy for an afternoon and it was a worse copy of that.
|
|
*
|
|
* Same origin, which is what lets `theme` work at all — and what will let the page builder draw
|
|
* handles on top of the real page when it gets there.
|
|
*/
|
|
|
|
/** Where the site's development server is. Asked once; it is a setting, not a live value. */
|
|
export const useDevPort = (): number | null => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
const [port, setPort] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
void clientRef.current
|
|
.get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
|
|
.then((servers) => setPort(servers.find((server) => server.name === 'development')?.port ?? null))
|
|
.catch(() => setPort(null));
|
|
}, []);
|
|
|
|
return port;
|
|
};
|
|
|
|
export const SitePreview = ({
|
|
port,
|
|
path,
|
|
theme,
|
|
onDocument,
|
|
reloadToken,
|
|
}: {
|
|
port: number | null;
|
|
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
|
|
path: string;
|
|
/**
|
|
* CSS custom properties to push into the frame, live.
|
|
*
|
|
* Optional because most previews have nothing to override. The Layout section is the exception: it
|
|
* is editing the variables the page reads, so it can show a change without a save or a reload.
|
|
*/
|
|
theme?: Record<string, string | null>;
|
|
/**
|
|
* Called with the frame's own document once it has loaded, and again after every reload.
|
|
*
|
|
* This is the door the page builder goes through. The frame is same-origin, so a caller can read the
|
|
* rendered page's DOM and put its own elements inside it — chrome that belongs to the ADMIN, drawn on
|
|
* the real page, without a line of it existing in the website's source.
|
|
*
|
|
* 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);
|
|
/**
|
|
* Change this to fetch the page again.
|
|
*
|
|
* The site renders from the database, so anything written there only appears when the document is
|
|
* requested anew. The admin cannot draw the change itself — it does not know how to render a page,
|
|
* and if it did it would be a second implementation of the one thing this preview exists to avoid.
|
|
*
|
|
* Remounts the frame rather than calling `contentWindow.location.reload()`. Both work while the frame
|
|
* is same-origin, and the remount is one mechanism that would still work if it ever were not.
|
|
*/
|
|
reloadToken?: number;
|
|
}) => {
|
|
const client = useClient();
|
|
/*
|
|
Through a ref, and this is the whole reason the preview hung.
|
|
|
|
`useClient()` rebuilds its verbs on EVERY render, so `get` has a new identity each time. With it in
|
|
the dependency list the effect re-ran on every render, and its first act is `setReady(false)` —
|
|
which renders again, which makes another `get`, which re-runs the effect. The frame never mounted
|
|
and the panel said "Connecting…" forever.
|
|
|
|
It is a known trap here: the same thing silently disabled every playback report in the Jellyfin
|
|
player. The platform's own Preview panel avoids it by depending on neither the client nor the URL.
|
|
*/
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const { device } = useViewMode();
|
|
/*
|
|
`null` means fluid — Desktop takes the panel's width. See DEVICES.
|
|
|
|
Everything below reads `frame`: the real number once the panel has been measured. Until then it is
|
|
the panel's own width, which is what a fluid frame is anyway.
|
|
*/
|
|
const { width: deviceWidth } = DEVICES[device];
|
|
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState<string | null>(null);
|
|
|
|
/*
|
|
How much room there is. The frame is always the real device width and is NEVER scaled to fit.
|
|
|
|
It used to shrink with a transform, and that was worse than it sounds: the preview changed apparent
|
|
size as the panel was dragged, so the same page looked different at every panel width and nothing
|
|
could be judged against anything. A preview that is 1:1 or nothing is a preview you can trust.
|
|
|
|
So it stays 390, 834 or 1440 and OVERFLOWS when there is not room, which is a scrollbar rather than
|
|
a lie. The width was never negotiable anyway: the site's media queries answer it, so a frame given
|
|
the panel's width would render the tablet layout and call it desktop.
|
|
|
|
Still measured, because the height is whatever the panel has.
|
|
*/
|
|
const box = useRef<HTMLDivElement | null>(null);
|
|
const [available, setAvailable] = useState({ width: 0, height: 0 });
|
|
|
|
// The width the frame is actually rendering at, for the caption and the centring maths.
|
|
const frameWidth = deviceWidth ?? Math.round(available.width);
|
|
|
|
useEffect(() => {
|
|
const element = box.current;
|
|
if (!element) return;
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
if (entry) setAvailable({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
});
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!port) return;
|
|
let alive = true;
|
|
setReady(false);
|
|
setFailed(null);
|
|
void clientRef.current
|
|
.get<{ ok: boolean }>('/dev-proxy/session')
|
|
.then(() => alive && setReady(true))
|
|
.catch((failure: unknown) => alive && setFailed(readableError(failure)));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [port]);
|
|
|
|
/*
|
|
The draft, pushed into the frame's own document.
|
|
|
|
`setProperty` where there is a value and `removeProperty` where there is not — an empty string would
|
|
set the variable to nothing rather than unsetting it, and the page's `var(--site-bg, #fff)` fallback
|
|
would never come back. Choosing "default" has to actually restore the default.
|
|
|
|
Keyed on the serialised theme rather than the object, which is a new identity every render. That is
|
|
the same trap that hung this component on "Connecting…", one level down.
|
|
*/
|
|
const frame = useRef<HTMLIFrameElement | null>(null);
|
|
const serialised = JSON.stringify(theme ?? {});
|
|
|
|
const applyTheme = useCallback(() => {
|
|
const root = frame.current?.contentDocument?.documentElement;
|
|
if (!root) return;
|
|
for (const [property, value] of Object.entries(JSON.parse(serialised) as Record<string, string | null>)) {
|
|
if (value) root.style.setProperty(property, value);
|
|
else root.style.removeProperty(property);
|
|
}
|
|
}, [serialised]);
|
|
|
|
// On every change, and again whenever the frame is (re)mounted — switching device remounts nothing,
|
|
// but a reconnect or a section change does, and the draft has to survive that.
|
|
useEffect(() => {
|
|
applyTheme();
|
|
}, [applyTheme, ready]);
|
|
|
|
/*
|
|
Hand the loaded document to whoever asked for it — on load, and again whenever they change.
|
|
|
|
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 [loaded, setLoaded] = useState(0);
|
|
|
|
/*
|
|
A remount is a NEW document, so `loaded` has to go back to nothing before it arrives.
|
|
|
|
In an effect keyed on the token, and NOT in the iframe's ref. It was a ref callback, written inline
|
|
— which React treats as a different ref every render, so it detaches and reattaches on each one,
|
|
calling the callback with null and then the node. That reset `loaded` to 0 every render, which
|
|
re-rendered, which reset it again: the paint never survived long enough to happen and the page came
|
|
up with no controls at all.
|
|
|
|
The lesson is the same one as `useClient` two files along: a value rebuilt every render must not be
|
|
the thing something else keys on.
|
|
*/
|
|
/*
|
|
A change anywhere on the site reloads every preview of it.
|
|
|
|
`reloadToken` is the panel's own — the page builder bumps it after its own writes. This is the
|
|
other half: something ELSE changed the site, and a frame showing the old one is a frame lying. The
|
|
nav reordering the menu is the case that made it necessary; the Layout preview draws that menu and
|
|
had no token at all.
|
|
|
|
Its purpose is exactly this — "anyone reading it should read it again" — and a preview is a reader.
|
|
*/
|
|
const [changed] = useFofosChanged();
|
|
|
|
useEffect(() => {
|
|
setLoaded(0);
|
|
}, [reloadToken, changed]);
|
|
|
|
/*
|
|
Reload by NAVIGATING the frame, not by replacing it.
|
|
|
|
The frame used to carry `key={reloadToken}`, so a reload was a new element: React unmounted the old
|
|
one and the panel went white until the new document arrived. On a page whose crop has just changed
|
|
that is not instant — the site is asking av-club for a fresh ladder — so every framing adjustment
|
|
flashed an empty panel for a second or two, which reads as the preview breaking.
|
|
|
|
A browser keeps painting the current document while the next one is being fetched. So the same
|
|
element stays, and the old page is on screen until the new one commits.
|
|
|
|
Same origin, through the dev proxy, which is what makes `contentWindow` reachable at all.
|
|
|
|
Skipped on the first run: the element has only just mounted and is already loading its document.
|
|
*/
|
|
const first = useRef(true);
|
|
|
|
useEffect(() => {
|
|
if (first.current) {
|
|
first.current = false;
|
|
return;
|
|
}
|
|
frame.current?.contentWindow?.location.reload();
|
|
}, [reloadToken, changed]);
|
|
|
|
useEffect(() => {
|
|
if (!loaded) return;
|
|
const frameDocument = frame.current?.contentDocument;
|
|
if (!frameDocument) return;
|
|
|
|
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.'
|
|
: failed ? `Could not authorise the preview: ${failed}`
|
|
: !ready ? 'Connecting…'
|
|
: null;
|
|
|
|
|
|
/*
|
|
The page's own height, measured inside the frame.
|
|
|
|
── Why it is measured and not just "the room" ──
|
|
|
|
It was `available.height` — all the room the panel had — which is right for a page longer than the
|
|
panel and wrong for every other one: a short page left a band of white below the footer with the
|
|
measure guides running down through it. Nothing was broken; the frame was simply taller than the
|
|
document, and an iframe has no `height: fit-content`.
|
|
|
|
── The WRAPPER, not the body ──
|
|
|
|
The site's `<body>` is `min-h-screen`, so its height is the FRAME's height and measuring it would
|
|
ask the frame how tall the frame is. Its one child is the page — header, main, footer — and that is
|
|
a real content height. It stopped being `min-h-dvh` on the same day this changed, for the same
|
|
reason: the stretch belonged to nothing anybody could see or set.
|
|
*/
|
|
const [contentHeight, setContentHeight] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!loaded) return;
|
|
const frameDocument = frame.current?.contentDocument;
|
|
const page = frameDocument?.body.firstElementChild as HTMLElement | null;
|
|
const view = frameDocument?.defaultView;
|
|
if (!page || !view) return;
|
|
|
|
const measure = () => setContentHeight(Math.ceil(page.getBoundingClientRect().height));
|
|
measure();
|
|
|
|
/*
|
|
Observed, because the page changes height without reloading: choosing a spacing rewrites classes
|
|
in this document, and the device buttons reflow it at a new width.
|
|
|
|
No feedback loop — the frame's height is set from this, and the wrapper's height does not depend
|
|
on the frame's.
|
|
*/
|
|
const observer = new view.ResizeObserver(measure);
|
|
observer.observe(page);
|
|
return () => observer.disconnect();
|
|
}, [loaded]);
|
|
|
|
/*
|
|
As tall as the page, or as tall as the room, whichever is less.
|
|
|
|
A long page still fills the panel and scrolls, exactly as it did. WIDTH is the device and height is
|
|
not: media queries answer width, so the layout at 390 is the phone's layout whatever the height.
|
|
*/
|
|
const shownHeight = contentHeight ? Math.min(contentHeight, Math.max(0, available.height)) : Math.max(0, available.height);
|
|
|
|
/*
|
|
When the frame is wider than the room, show its MIDDLE.
|
|
|
|
Left-anchored, a narrow panel showed the left margin and the start of the content — the least
|
|
informative part of a centred layout, and never the part anybody is looking at. The page is centred
|
|
on a real screen, so the preview should be centred too; the scrollbar is still there for the rest.
|
|
|
|
Set on the scroller rather than with a transform, because a transform is the scaling this just
|
|
stopped doing.
|
|
*/
|
|
const scroller = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const element = scroller.current;
|
|
if (!element) return;
|
|
element.scrollLeft = Math.max(0, (frameWidth - element.clientWidth) / 2);
|
|
}, [frameWidth, available.width, loaded]);
|
|
|
|
return (
|
|
<section className="flex min-h-0 flex-1 flex-col gap-2">
|
|
<div ref={box} className="min-h-0 flex-1">
|
|
<div
|
|
ref={scroller}
|
|
/*
|
|
`overflow-auto`, not hidden: at Desktop in a narrow panel the frame is wider than the room,
|
|
and scrolling to the rest of it is the honest answer where scaling was not.
|
|
|
|
THE SCROLLBARS ARE HIDDEN, and that is not cosmetic. A classic scrollbar — Linux, Windows —
|
|
is laid out INSIDE the element, so it takes width from the frame: the preview then shows
|
|
less than the device width it claims to be, and the last menu item runs off the edge. Worse,
|
|
it cascades: a horizontal bar costs height, which can force a vertical bar, which costs more
|
|
width.
|
|
|
|
The same trade the site makes in its own stylesheet, for the same reason and with the same
|
|
cost: wheel, trackpad and touch still scroll, and the visual position indicator is gone on
|
|
the platforms where scrollbars are always classic.
|
|
*/
|
|
className="mx-auto overflow-auto rounded-lg border border-border bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
// Fluid fills the panel; a fixed device is capped by it so a 1440 frame in a 900 panel
|
|
// scrolls rather than overflowing the workspace.
|
|
style={{ width: deviceWidth === null ? '100%' : Math.min(frameWidth, available.width || frameWidth), height: shownHeight }}
|
|
>
|
|
{message ? (
|
|
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
|
) : (
|
|
/*
|
|
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
|
|
allow-same-origin has an opaque origin and therefore no storage, and one WITH
|
|
allow-same-origin and allow-scripts is worth nothing, because the frame can reach up and
|
|
remove the attribute. This is the owner's own development server on this machine.
|
|
*/
|
|
<iframe
|
|
// No key. See the reload effect above: replacing the element is what made the panel go
|
|
// white between documents.
|
|
ref={frame}
|
|
// Applied on load as well as on change: the document is replaced on a reload, and the
|
|
// properties go with it.
|
|
onLoad={() => {
|
|
applyTheme();
|
|
setLoaded((n) => n + 1);
|
|
}}
|
|
src={`/api/dev-proxy/${port}/${path}`}
|
|
title={`The site at /${path}`}
|
|
className="border-0"
|
|
// The device's real width, and the room's real height. No transform: see the note above.
|
|
style={{ width: deviceWidth === null ? '100%' : frameWidth, height: shownHeight }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</section>
|
|
);
|
|
};
|