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