the preview panel follows the app's navigation

The panel knew one URL: the one you typed. Navigate three pages into an app and the
header still showed the front door, "open in a new tab" still went there, and a
reload — which fires by itself whenever a dev server restarts — threw away where you
were and started again at `/`.

Now the app reports where it is, and a reload composes it back.

── How the panel finds out ──

The frame is same-origin for a proxied preview, so `contentWindow.location` is
readable. It is PUSHED rather than read, because a dev server is an SPA: a route
change is `history.pushState`, which fires no event and never reloads the document.
Polling would have to be constant and would still lag. The script the proxy already
injects now patches `pushState`/`replaceState`, listens for `popstate`/`hashchange`,
and posts the path — with the proxy prefix stripped, since that is plumbing and the
panel should not have to know about it to show a URL.

Only proxied previews can do this. A cross-origin frame reports nothing, and grafting
a remembered path onto someone else's host would be inventing a URL, so `targetUrl`
composes only when there is a port.

── Two fields, and the loop that forced them ──

`url` is what you pointed the panel at; `current` is where it has got to. The obvious
design is one field — a browser has one address bar — and it feeds itself: the stored
value IS the iframe's `src`, so recording a route change hands the frame a new `src`
for the page it is already showing, which browsers treat as a navigation. Every click
would cost a reload.

Belt and braces, because one of the two would be enough only until someone changed
the other: the fields are separate, AND `PreviewFrame` captures its `src` at mount
and ignores later changes. Remount-by-key is still how a reload happens.

`ev.source` identifies which frame is talking. `message` is a window-level event and
two same-origin previews hear each other; comparing the posting window to this
iframe's `contentWindow` is exact, where matching on URL would break the moment two
panels pointed at the same port.

Writes are debounced 800ms. `useDashboardState.setValue` PATCHes the server on every
call and an SPA emits a route change per click; the value is only read at mount, so
being a second behind costs nothing.

Also: the header shows `localhost:3001/settings` rather than
`api/dev-proxy/3001/settings`, and editing starts from that — one is a thing you can
retype, the other is not.

── Tests ──

The reporter is RUN, not string-matched: `new Function` against a fake window, which
also means a syntax error in the injected script fails the suite rather than silently
breaking every proxied page. Covers prefix stripping, the app root reporting `/`
rather than empty, query and hash surviving, the report on load, pushState, and
popstate. Plus the composition helpers, including that an ordinary URL is never
composed with a path.

── One stale comment corrected while in there ──

The file claimed `allow-same-origin` "is present, and has to be". There is no
`sandbox` attribute at all, so nothing was present — and a sandbox with
`allow-same-origin` plus `allow-scripts` is worth roughly nothing anyway, since the
frame can remove its own sandbox attribute. Now says that.

Not verified against a live dev server through the real proxy — that needs the server
restarted with this code. tsgo clean, 934 pass / 7 fail unchanged, frontend builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:39:38 +00:00
co-authored by Claude Opus 5
parent 430346e14b
commit d771736a0d
6 changed files with 331 additions and 25 deletions
+101 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite';
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
const BASE = '/api/dev-proxy/3001';
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
@@ -75,3 +75,103 @@ describe('rewriteAssetPaths', () => {
expect(rewriteAssetPaths(js, BASE)).toBe(js);
});
});
// ── The location reporter ──
//
// The injected script tells the panel where the app has navigated to, so a reload can come back to the same
// page. Asserting on the script's TEXT would prove only that a string contains a substring, so it is run
// here against a fake window — which is what catches the part that can actually be wrong: stripping the
// proxy prefix back off to recover the app's own path.
type Posted = { path: string };
const runReporter = (base: string, startAt: string) => {
const origin = 'https://platform.officer.dev';
const body = proxyOverrideScript(base)
.replace(/^<script>/, '')
.replace(/<\/script>$/, '');
const posted: Posted[] = [];
const listeners: Record<string, (() => void)[]> = {};
let url = new URL(startAt, origin);
const location = {
get origin() {
return origin;
},
get pathname() {
return url.pathname;
},
get search() {
return url.search;
},
get hash() {
return url.hash;
},
get href() {
return url.href;
},
};
const history = { pushState: () => {}, replaceState: () => {} };
const parent = { postMessage: (msg: Posted) => posted.push(msg) };
const addEventListener = (name: string, fn: () => void) => {
(listeners[name] ??= []).push(fn);
};
const win = { fetch: () => {}, WebSocket: class {} };
const xhr = { prototype: { open: () => {} } };
// eslint-disable-next-line no-new-func
new Function('window', 'location', 'history', 'parent', 'addEventListener', 'XMLHttpRequest', body)(
win,
location,
history,
parent,
addEventListener,
xhr,
);
return {
posted,
// What an SPA does on a route change: change the URL, then call the patched pushState.
navigate: (to: string) => {
url = new URL(to, origin);
history.pushState(); // the PATCHED one: the script replaced it on the object above
},
fire: (event: string) => listeners[event]?.forEach((fn) => fn()),
};
};
describe('the injected location reporter', () => {
test('reports the app path with the proxy prefix stripped off', () => {
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/settings/profile');
expect(posted.at(-1)?.path).toBe('/settings/profile');
});
test('the app root reports `/`, not an empty string', () => {
expect(runReporter(BASE, '/api/dev-proxy/3001/').posted.at(-1)?.path).toBe('/');
// No trailing slash at all — the shape a bare `localhost:3001` produces.
expect(runReporter(BASE, '/api/dev-proxy/3001').posted.at(-1)?.path).toBe('/');
});
test('query and hash are carried, since a route can live in either', () => {
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/search?q=bun#hit');
expect(posted.at(-1)?.path).toBe('/search?q=bun#hit');
});
test('it reports once on load, without waiting for a navigation', () => {
expect(runReporter(BASE, '/api/dev-proxy/3001/x').posted).toHaveLength(1);
});
test('a pushState route change is reported — the case no browser event covers', () => {
const r = runReporter(BASE, '/api/dev-proxy/3001/');
r.navigate('/api/dev-proxy/3001/deep/page');
expect(r.posted.at(-1)?.path).toBe('/deep/page');
expect(r.posted).toHaveLength(2);
});
test('the back button reports too', () => {
const r = runReporter(BASE, '/api/dev-proxy/3001/a');
r.fire('popstate');
expect(r.posted).toHaveLength(2);
});
});
+20
View File
@@ -44,6 +44,26 @@ export const proxyOverrideScript = (base: string) =>
`return p!==undefined?new NWS(u,p):new NWS(u)};` +
`window.WebSocket.prototype=NWS.prototype;window.WebSocket.CONNECTING=NWS.CONNECTING;` +
`window.WebSocket.OPEN=NWS.OPEN;window.WebSocket.CLOSING=NWS.CLOSING;window.WebSocket.CLOSED=NWS.CLOSED;` +
// ── Tell the panel where the app has navigated to ──
//
// The panel is same-origin with this document, so it COULD read `contentWindow.location` directly. It is
// pushed instead of polled because a modern dev server is an SPA: a route change is `history.pushState`,
// which fires no event of its own and never reloads the document. Polling would have to be constant and
// would still lag; patching the two methods reports the moment it happens.
//
// `popstate` and `hashchange` cover the back button and anchors, and the bare call at the end covers the
// ordinary case of a full page load — including the one after a reload, which is what lets the panel
// reopen on the page you were on rather than at the app's root.
//
// The path is reported WITHOUT the proxy prefix: it is the app's own path, and the panel should not have
// to know about the plumbing to display it.
`function rp(){try{var p=location.pathname;` +
`if(p.indexOf(b)===0)p=p.slice(b.length);` +
`if(!p||p.charAt(0)!=="/")p="/"+p;` +
`parent.postMessage({officerPreview:1,path:p+location.search+location.hash},o)}catch(e){}}` +
`var HP=history.pushState;history.pushState=function(){var r=HP.apply(this,arguments);rp();return r};` +
`var HR=history.replaceState;history.replaceState=function(){var r=HR.apply(this,arguments);rp();return r};` +
`addEventListener("popstate",rp);addEventListener("hashchange",rp);rp();` +
`})()</script>`;
/**
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useWorkspace } from '../../components/Workspace';
@@ -7,19 +7,20 @@ import { normalizeUrl, usePreview } from './use-preview';
/**
* A live app, in a panel.
*
* The iframe is remounted by `key` on reload rather than reaching for `contentWindow.location.reload()`:
* the frame is a different origin, so touching its location object throws a cross-origin error. Replacing
* the element re-requests the URL, which is the same thing from the user's side and the only thing the
* parent is allowed to do.
* The iframe is remounted by `key` on reload rather than reaching for `contentWindow.location.reload()`.
* For an ordinary URL that is the only option — the frame is a different origin, and touching its location
* object throws. A PROXIED preview is same-origin and could be reloaded in place, but remounting is kept
* for both: one mechanism, and the remount is also what re-runs the dev-proxy session request below.
*
* `allow-same-origin` is present, and has to be: without it the framed app gets an opaque origin, which
* means no `localStorage` — and it authenticates with a JWT it keeps there, so it could never stay logged
* in. It is combined with `allow-scripts`, which the two together do weaken — but the alternative is a
* panel that cannot run the app it exists to show, and the URL is one the owner typed.
* There is no `sandbox` attribute, deliberately. A sandboxed frame without `allow-same-origin` gets an
* opaque origin and therefore no `localStorage` — and an app that keeps a JWT there could never stay logged
* in — while a sandbox WITH `allow-same-origin` and `allow-scripts` is worth roughly nothing, since the
* frame can reach up and remove its own sandbox attribute. The URL is one the owner typed on their own
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
*/
export const PreviewApp = ({ panelId }: { panelId: string }) => {
const { workspace } = useWorkspace();
const { url, setUrl, reloadToken } = usePreview(panelId, workspace);
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
const [draft, setDraft] = useState('');
if (!url) {
@@ -55,7 +56,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
);
}
return <DevProxyFrame url={url} reloadToken={reloadToken} />;
return <DevProxyFrame url={target} reloadToken={reloadToken} onLocation={reportLocation} />;
};
/**
@@ -70,7 +71,9 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
*
* An ordinary URL mounts immediately — nothing to arrange, and no reason to make it wait.
*/
const DevProxyFrame = ({ url, reloadToken }: { url: string; reloadToken: number }) => {
type DevProxyFrameProps = { url: string; reloadToken: number; onLocation: (path: string) => void };
const DevProxyFrame = ({ url, reloadToken, onLocation }: DevProxyFrameProps) => {
const { get } = useClient();
const needsSession = url.startsWith('/api/dev-proxy/');
const [ready, setReady] = useState(!needsSession);
@@ -104,5 +107,45 @@ const DevProxyFrame = ({ url, reloadToken }: { url: string; reloadToken: number
if (!ready) {
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
}
return <iframe key={reloadToken} src={url} title="Preview" className="h-full w-full border-none bg-white" />;
return <PreviewFrame key={reloadToken} initialSrc={url} onLocation={onLocation} />;
};
/**
* The frame itself, and why its `src` is captured once.
*
* `initialSrc` composes the entry URL with wherever the app had navigated to — so a reload, or a dev server
* restarting underneath, comes back to the page you were on rather than to the app's front door.
*
* It is read into state at mount and then ignored, which is the load-bearing part. That composed value
* CHANGES as the app navigates, and letting it flow into the `src` prop would set the attribute to the page
* the frame is already showing — which browsers treat as a navigation. Every click would cost a reload, and
* a preview that reloads whenever you use it is worse than one that never follows along at all.
*
* Remounting is still how a reload works: `key={reloadToken}` above throws this component away, and the new
* one captures the composed URL afresh.
*
* ── Which frame is talking ──
*
* Two preview panels on the same dashboard receive each other's messages — `message` is a window-level
* event and both frames are same-origin. `ev.source` is the posting window itself, so comparing it to this
* iframe's `contentWindow` answers "is this mine" exactly, rather than by matching on the URL and hoping
* two panels are never pointed at the same port.
*/
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
const [src] = useState(initialSrc);
const frame = useRef<HTMLIFrameElement | null>(null);
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
if (ev.origin !== window.location.origin) return;
if (!frame.current || ev.source !== frame.current.contentWindow) return;
const data = ev.data as { officerPreview?: unknown; path?: unknown } | null;
if (data?.officerPreview !== 1 || typeof data.path !== 'string') return;
onLocation(data.path);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [onLocation]);
return <iframe ref={frame} src={src} title="Preview" className="h-full w-full border-none bg-white" />;
};
@@ -14,15 +14,17 @@ import { normalizeUrl, usePreview } from './use-preview';
*/
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
const { workspace } = useWorkspace();
const { url, setUrl, reload } = usePreview(panelId, workspace);
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(url);
const [draft, setDraft] = useState(display);
// Follow the stored value while not editing, so a URL set from elsewhere shows up here.
// Follow the stored value while not editing, so a URL set from elsewhere — or a page the app navigated to
// by itself — shows up here. Editing starts from what is displayed rather than from the proxy path,
// because `localhost:3001/settings` is a thing you can retype and `/api/dev-proxy/3001/settings` is not.
useEffect(() => {
if (!editing) setDraft(url);
}, [url, editing]);
if (!editing) setDraft(display);
}, [display, editing]);
const commit = () => {
const next = normalizeUrl(draft);
@@ -63,8 +65,8 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0">
<RefreshCw className="h-3 w-3" />
</button>
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60">
{url.replace(/^https?:\/\//, '')}
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
{display}
</span>
<button
onClick={() => setEditing(true)}
@@ -74,7 +76,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
<Pencil className="h-3 w-3" />
</button>
<a
href={url}
href={target}
target="_blank"
rel="noreferrer"
title="Open in a new tab"
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { normalizeUrl } from './use-preview';
import { displayUrl, normalizeUrl, proxyPort, targetUrl } from './use-preview';
// The panel puts this string straight into an iframe `src`, so what it accepts matters more than it looks.
@@ -76,3 +76,60 @@ describe('loopback URLs', () => {
// panel now asks `/dev-proxy/session` with the authenticated client and mounts the frame afterwards,
// so the URL carries nothing and there is no ordering to get wrong.
});
// ── Following the app's own navigation ──
//
// The panel keeps two things: the URL you pointed it at, and where the app has got to since. These compose
// them. The split exists because storing one value would feed the iframe its own `src` on every route
// change — see the note on `PreviewPanelState`.
describe('proxyPort', () => {
test('finds the port of a proxied preview, with or without a path', () => {
expect(proxyPort('/api/dev-proxy/3001/')).toBe('3001');
expect(proxyPort('/api/dev-proxy/3001/settings/profile')).toBe('3001');
expect(proxyPort('/api/dev-proxy/80')).toBe('80');
});
test('an ordinary URL has none — which is what stops the composing below', () => {
expect(proxyPort('https://jesper.edge.pertento.ai/')).toBeNull();
expect(proxyPort('')).toBeNull();
// Not the proxy, however much it looks like it.
expect(proxyPort('https://example.com/api/dev-proxy/3001/')).toBeNull();
});
});
describe('targetUrl', () => {
test('a reload goes back to the page you were on, not the front door', () => {
expect(targetUrl('/api/dev-proxy/3001/', '/settings/profile')).toBe('/api/dev-proxy/3001/settings/profile');
});
test('query and hash survive, since a route can live in either', () => {
expect(targetUrl('/api/dev-proxy/3001/', '/search?q=x#hit')).toBe('/api/dev-proxy/3001/search?q=x#hit');
});
test('no navigation reported yet means the entry URL, unchanged', () => {
expect(targetUrl('/api/dev-proxy/3001/', undefined)).toBe('/api/dev-proxy/3001/');
expect(targetUrl('/api/dev-proxy/3001/', '')).toBe('/api/dev-proxy/3001/');
});
// A cross-origin frame reports nothing, so there is never a path to compose. Grafting one on would be
// inventing a URL on someone else's host.
test('an ordinary URL is never composed with a path', () => {
expect(targetUrl('https://jesper.edge.pertento.ai/', '/settings')).toBe('https://jesper.edge.pertento.ai/');
});
});
describe('displayUrl', () => {
test('shows what you typed, not the plumbing', () => {
expect(displayUrl('/api/dev-proxy/3001/', '/settings')).toBe('localhost:3001/settings');
expect(displayUrl('/api/dev-proxy/3001/', undefined)).toBe('localhost:3001');
});
test('the root path adds nothing — `localhost:3001/` reads as a stray slash', () => {
expect(displayUrl('/api/dev-proxy/3001/', '/')).toBe('localhost:3001');
});
test('an ordinary URL loses only its scheme, as before', () => {
expect(displayUrl('https://jesper.edge.pertento.ai/', undefined)).toBe('jesper.edge.pertento.ai/');
});
});
@@ -30,10 +30,54 @@ import { useBackgroundJobChanged } from '../../channels';
* The URL persists (dashboard state, keyed per panel). The reload counter does NOT: it is a signal, and a
* reload that survived a refresh would be meaningless.
*/
export type PreviewPanelState = { url: string };
/**
* `url` is what you pointed the panel at. `current` is where the app has navigated to since.
*
* They are separate on purpose. Collapsing them into one field reads well — a browser has one address bar
* — but the panel would then feed its own iframe: the stored value is the `src`, so recording a route
* change would hand the frame a new `src` and re-navigate it to the page it is already on. Once round that
* loop per click.
*
* So `url` changes only when a person types one, and `current` follows the app. A reload composes them.
*/
export type PreviewPanelState = { url: string; current?: string };
const EMPTY: Record<string, PreviewPanelState> = {};
/** How long to sit on a route change before writing it down. */
const REPORT_DEBOUNCE_MS = 800;
const DEV_PROXY = /^\/api\/dev-proxy\/([0-9]{1,5})(?:\/|$)/;
/** The port a proxied preview is pointing at, or null for an ordinary URL. */
export function proxyPort(url: string): string | null {
return url.match(DEV_PROXY)?.[1] ?? null;
}
/**
* What a fresh mount should load: where you pointed it, plus wherever it had got to.
*
* Only proxied previews can compose, because only they report their location — a cross-origin frame will
* not tell us anything, and guessing a path onto someone else's host would be inventing a URL.
*/
export function targetUrl(url: string, current: string | undefined): string {
const port = proxyPort(url);
if (!port || !current) return url;
return `/api/dev-proxy/${port}${current.startsWith('/') ? current : `/${current}`}`;
}
/**
* What to show a human.
*
* `api/dev-proxy/3001/settings` is the plumbing; `localhost:3001/settings` is the thing you asked for and
* the thing you would type again.
*/
export function displayUrl(url: string, current: string | undefined): string {
const port = proxyPort(url);
if (!port) return url.replace(/^https?:\/\//, '');
return `localhost:${port}${current && current !== '/' ? current : ''}`;
}
export function usePreview(panelId: string | undefined, workspace: WorkspaceIdentity | null) {
const { value: bag, setValue: setBag } = useDashboardState<Record<string, PreviewPanelState>>(
panelStateKey('preview', workspace),
@@ -41,18 +85,47 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
);
const url = panelId ? (bag[panelId]?.url ?? '') : '';
const current = panelId ? bag[panelId]?.current : undefined;
const setUrl = useCallback(
(next: string) => {
if (!panelId) return;
setBag((prev) => {
if (prev[panelId]?.url === next) return prev;
// A new address starts at that address: keeping the old `current` would compose someone's typed URL
// with a path from the app that used to be there.
return { ...prev, [panelId]: { url: next } };
});
},
[panelId, setBag],
);
/**
* Record where the app has navigated to, debounced.
*
* `useDashboardState.setValue` PATCHes the server on every call, and an SPA emits a route change per
* click — so writing straight through would turn browsing a preview into a stream of requests. The value
* is only ever read when the frame is (re)mounted, so being a second behind costs nothing.
*/
const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
const reportLocation = useCallback(
(path: string) => {
if (!panelId) return;
if (pending.current) clearTimeout(pending.current);
pending.current = setTimeout(() => {
setBag((prev) => {
const panel = prev[panelId];
// No entry for this panel means nothing pointed it anywhere; a location without one is noise.
if (!panel || panel.current === path) return prev;
return { ...prev, [panelId]: { ...panel, current: path } };
});
}, REPORT_DEBOUNCE_MS);
},
[panelId, setBag],
);
useEffect(() => () => void (pending.current && clearTimeout(pending.current)), []);
// Keyed per panel so two previews reload independently. Same shape the terminal uses for its connection
// state, and deliberately not persisted.
const [reloadToken, setReloadToken] = useGlobal<number>(`preview-reload-${panelId ?? 'none'}`, 0);
@@ -79,7 +152,18 @@ export function usePreview(panelId: string | undefined, workspace: WorkspaceIden
reload();
}, [jobSignal]);
return { url, setUrl, reloadToken, reload };
return {
url,
current,
// What to load: the entry point for a plain URL, the current page for a proxied one. This is what makes
// a reload — and a dev server restarting under you — come back to the page you were on.
target: targetUrl(url, current),
display: displayUrl(url, current),
setUrl,
reportLocation,
reloadToken,
reload,
};
}
/**