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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user