From d771736a0d033ec1b6c368c1de49798b58f432dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 18:39:38 +0000 Subject: [PATCH] the preview panel follows the app's navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/dev-proxy/html-rewrite.test.ts | 102 +++++++++++++++++- src/servers/api/dev-proxy/html-rewrite.ts | 20 ++++ .../src/apps/Preview/PreviewApp.tsx | 69 +++++++++--- .../src/apps/Preview/PreviewHeader.tsx | 18 ++-- .../src/apps/Preview/use-preview.test.ts | 59 +++++++++- .../src/apps/Preview/use-preview.ts | 88 ++++++++++++++- 6 files changed, 331 insertions(+), 25 deletions(-) diff --git a/src/servers/api/dev-proxy/html-rewrite.test.ts b/src/servers/api/dev-proxy/html-rewrite.test.ts index c053d027..4d255e19 100644 --- a/src/servers/api/dev-proxy/html-rewrite.test.ts +++ b/src/servers/api/dev-proxy/html-rewrite.test.ts @@ -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(/^`; /** diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx index 4a4323f5..390643bf 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -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 ; + return ; }; /** @@ -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
Connecting…
; } - return