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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user