MUL-5741: fix(desktop): preserve HTML attachment tab scroll position across tab switches (#6825)

Bridge the sandboxed HTML-attachment iframe's scroll position into the
per-tab scroll memento so a long report returns to where you left it after
an in-app tab switch. The preview scrolls inside
`sandbox="allow-scripts"` (no `allow-same-origin`), so the parent cannot
read its `scrollY`; an injected shim reports it via `postMessage` and a
content fingerprint invalidates a stale offset when the attachment changes.

Verified: typecheck 6/6 tasks, views Vitest 36/36, desktop Vitest 85/85,
iframe-scroll-bridge Playwright spec 6/6 on both system Chrome and bundled
Chromium.

Known follow-up: a tab switch during the attachment's loading window still
erases the saved offset (equivalent to pre-fix behavior, not a regression).

Fixes #6405
This commit is contained in:
Frank Li
2026-08-17 15:17:22 +08:00
committed by GitHub
parent 3b08423684
commit 2a30b01b6d
17 changed files with 1887 additions and 28 deletions
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
__resetTabCoordinatorForTests,
createScrollRestorationAdapter,
initTabCoordinator,
registerActiveHostElement,
} from "./tab-coordinator";
import { getActiveTab, useTabStore } from "@/stores/tab-store";
describe("tab-coordinator external scroll sources", () => {
beforeEach(() => {
__resetTabCoordinatorForTests();
useTabStore.getState().reset();
useTabStore.getState().switchWorkspace("acme");
initTabCoordinator();
});
function seedActiveTab(path: string, title: string): string {
return useTabStore.getState().openTab(path, title, { activate: true });
}
it("captures external sources registered by the outgoing tab alongside DOM scroll roots", () => {
const tabId = seedActiveTab("/acme/attachments/abc/preview", "Report");
const adapter = createScrollRestorationAdapter(tabId);
// Set up an outer DOM scroll root to confirm both paths merge.
const host = document.createElement("div");
const list = document.createElement("div");
list.setAttribute("data-tab-scroll-root", "list");
Object.defineProperty(list, "scrollTop", { value: 250, configurable: true });
Object.defineProperty(list, "scrollHeight", { value: 1000, configurable: true });
host.appendChild(list);
registerActiveHostElement(host);
// Register an iframe-style external source with a contentKey.
adapter.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 512, height: 4000, contentKey: "v1hash" }),
});
// Switch to another tab — the Coordinator's store subscription fires
// synchronously during the set() and captures the outgoing tab's state.
const otherId = useTabStore
.getState()
.openTab("/acme/issues", "Issues", { activate: true });
void otherId;
const outgoing = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === tabId)!;
const scroll = outgoing.memento.scroll;
// DOM root was captured (top > 0 rule).
expect(scroll["/acme/attachments/abc/preview::list"]).toEqual({
top: 250,
height: 1000,
});
// External source was captured with its contentKey.
expect(scroll["/acme/attachments/abc/preview::html-iframe"]).toEqual({
top: 512,
height: 4000,
contentKey: "v1hash",
});
registerActiveHostElement(null);
});
it("writes external-source entries even at top:0 so a contentKey change clears stale offsets", () => {
const tabId = seedActiveTab("/acme/attachments/abc/preview", "Report");
const adapter = createScrollRestorationAdapter(tabId);
registerActiveHostElement(document.createElement("div"));
// Positive offset first.
adapter.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 800, height: 5000, contentKey: "old" }),
});
useTabStore
.getState()
.openTab("/acme/issues", "Issues", { activate: true });
// Re-activate the original tab, then switch again with new content but
// no scroll yet (top:0). capture() should produce top:0 with the new
// contentKey and the old offset must be replaced, not left behind.
useTabStore.getState().setActiveTab(tabId);
const adapter2 = createScrollRestorationAdapter(tabId);
adapter2.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 0, height: 0, contentKey: "new" }),
});
useTabStore
.getState()
.openTab("/acme/issues", "Issues", { activate: true });
const entry = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === tabId)!.memento.scroll[
"/acme/attachments/abc/preview::html-iframe"
];
expect(entry).toEqual({ top: 0, height: 0, contentKey: "new" });
registerActiveHostElement(null);
});
it("isolates external sources per tab: an incoming tab cannot read another tab's source", () => {
const tabA = seedActiveTab("/acme/attachments/a/preview", "A");
const adapterA = createScrollRestorationAdapter(tabA);
adapterA.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 111, height: 1000, contentKey: "a" }),
});
const tabB = useTabStore
.getState()
.openTab("/acme/attachments/b/preview", "B", { activate: true });
// tab B is now incoming; its adapter registers its own source. tab A's
// capture must NOT be attributed to B when B leaves.
const adapterB = createScrollRestorationAdapter(tabB);
adapterB.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 222, height: 2000, contentKey: "b" }),
});
useTabStore
.getState()
.openTab("/acme/issues", "Issues", { activate: true });
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
const aEntry = tabs.find((t) => t.id === tabA)!.memento.scroll[
"/acme/attachments/a/preview::html-iframe"
];
const bEntry = tabs.find((t) => t.id === tabB)!.memento.scroll[
"/acme/attachments/b/preview::html-iframe"
];
expect(aEntry).toEqual({ top: 111, height: 1000, contentKey: "a" });
expect(bEntry).toEqual({ top: 222, height: 2000, contentKey: "b" });
});
it("get() on the per-tab adapter only returns entries when that tab is active", () => {
const tabId = seedActiveTab("/acme/attachments/abc/preview", "Report");
const adapter = createScrollRestorationAdapter(tabId);
registerActiveHostElement(document.createElement("div"));
adapter.registerExternalSource?.("html-iframe", {
capture: () => ({ top: 321, height: 900, contentKey: "v" }),
});
useTabStore
.getState()
.openTab("/acme/issues", "Issues", { activate: true });
// While another tab is active, get() returns undefined even though the
// entry exists in the memento (prevents a stale view pulling another
// tab's offset).
expect(adapter.get("html-iframe")).toBeUndefined();
useTabStore.getState().setActiveTab(tabId);
expect(getActiveTab(useTabStore.getState())?.id).toBe(tabId);
// A fresh adapter for the now-active tab sees the memento.
const adapterAgain = createScrollRestorationAdapter(tabId);
expect(adapterAgain.get("html-iframe")).toEqual({
top: 321,
height: 900,
contentKey: "v",
});
registerActiveHostElement(null);
});
});
@@ -1,6 +1,10 @@
import type { DataRouter } from "react-router-dom";
import type { QueryClient } from "@tanstack/react-query";
import type { ScrollRestorationAdapter } from "@multica/views/platform";
import type {
ExternalScrollSource,
ScrollRestorationAdapter,
ScrollRestorationEntry,
} from "@multica/views/platform";
import { createAppRouter } from "@/routes";
import {
useTabStore,
@@ -46,6 +50,45 @@ let activeHostElement: HTMLElement | null = null;
/** Query client for reload()'s current-page-scope invalidation. */
let queryClient: QueryClient | null = null;
/**
* Scroll sources outside the outer DOM that the Coordinator cannot scan for
* itself — currently the document inside a sandboxed HTML-attachment iframe
* (opaque origin → `contentWindow.scrollY` throws). Keyed by tab id so each
* tab's sources are captured only when that tab is outgoing; sources are
* registered/unregistered by the per-tab view via the adapter's
* `registerExternalSource` and vanish naturally when the tab's ActiveTabHost
* unmounts. We still prune on capture so a tab id is never leaked after its
* subtree is gone.
*/
const externalScrollSources = new Map<
string,
Map<string, ExternalScrollSource>
>();
/**
* Register (or unregister, when `source` is null) an external scroll source
* for a tab. The view's ScrollRestorationAdapter is per-tab, so it passes its
* own tabId here. Safe to call with null on cleanup.
*/
export function registerExternalScrollSource(
tabId: string,
containerKey: string,
source: ExternalScrollSource | null,
): void {
let perTab = externalScrollSources.get(tabId);
if (!source) {
if (!perTab) return;
perTab.delete(containerKey);
if (perTab.size === 0) externalScrollSources.delete(tabId);
return;
}
if (!perTab) {
perTab = new Map();
externalScrollSources.set(tabId, perTab);
}
perTab.set(containerKey, source);
}
/** Identity of what the host currently shows: slug:tabId:generation. */
let lastIdentity: string | null = null;
let lastActiveTabId: string | null = null;
@@ -74,6 +117,11 @@ export function registerCoordinatorQueryClient(qc: QueryClient): void {
* The generic view-state entries ride the same channel: reads resolve
* against the memento's `view` map, writes commit through the store — no
* capture pass, the view pushes at the moment its restorable state changes.
*
* The adapter is constructed per ActiveTabHost (memoized on tabId in
* tab-content.tsx), so `registerExternalSource` records this tab's
* out-of-DOM scroll sources (iframe documents) under that tabId; the
* Coordinator captures them when this tab is the outgoing one.
*/
export function createScrollRestorationAdapter(
tabId: string,
@@ -102,6 +150,9 @@ export function createScrollRestorationAdapter(
if (routeKey === null) return;
useTabStore.getState().commitViewState(tabId, routeKey, entryKey, value);
},
registerExternalSource(containerKey, source) {
registerExternalScrollSource(tabId, containerKey, source);
},
};
}
@@ -136,22 +187,40 @@ function reconcile(): void {
* React re-renders — so the outgoing DOM (previous tab, or previous route
* of the same tab) is still mounted.
*
* Scroll containers self-mark with `data-tab-scroll-root` (the attribute
* value is the container key, "main" when bare). Containers sitting at 0
* are simply absent from the result — the store's per-route REPLACE
* Plain scroll containers self-mark with `data-tab-scroll-root` (the
* attribute value is the container key, "main" when bare). Containers sitting
* at 0 are simply absent from the result — the store's per-route REPLACE
* semantics turn that absence into "clear the stale offset".
*
* External sources (sandboxed iframe documents) are merged in for the
* outgoing tab ONLY; unlike plain containers they are ALWAYS written,
* including at top 0, because their entry carries a `contentKey` and a
* top:0 write with the new key is how a content change replaces a stale
* positive offset rather than resurrecting it.
*/
function captureScrollEntries(): Record<string, { top: number; height: number }> {
const entries: Record<string, { top: number; height: number }> = {};
if (!activeHostElement) return entries;
const els = activeHostElement.querySelectorAll<HTMLElement>(
"[data-tab-scroll-root]",
);
els.forEach((el) => {
if (el.scrollTop <= 0) return;
const key = el.getAttribute("data-tab-scroll-root") || "main";
entries[key] = { top: el.scrollTop, height: el.scrollHeight };
});
function captureScrollEntries(
outgoingTabId: string | null,
): Record<string, ScrollRestorationEntry> {
const entries: Record<string, ScrollRestorationEntry> = {};
if (activeHostElement) {
const els = activeHostElement.querySelectorAll<HTMLElement>(
"[data-tab-scroll-root]",
);
els.forEach((el) => {
if (el.scrollTop <= 0) return;
const key = el.getAttribute("data-tab-scroll-root") || "main";
entries[key] = { top: el.scrollTop, height: el.scrollHeight };
});
}
if (outgoingTabId) {
const sources = externalScrollSources.get(outgoingTabId);
if (sources) {
for (const [key, source] of sources) {
const entry = source.capture();
if (entry) entries[key] = entry;
}
}
}
return entries;
}
@@ -182,7 +251,19 @@ function handleStoreChange(): void {
// scrolled back to 0; the store skips the write when nothing changed.
useTabStore
.getState()
.commitScrollMemento(outgoingTabId, routeKey, captureScrollEntries());
.commitScrollMemento(
outgoingTabId,
routeKey,
captureScrollEntries(outgoingTabId),
);
}
// The outgoing tab's ActiveTabHost is about to unmount; its views'
// cleanup will unregister their sources, but a host switch that tears
// down the whole tree can drop those cleanups without running them.
// Prune proactively so a future re-mount starts clean and a closed tab
// never leaks sources.
if (outgoingTabId && hostSwitching) {
externalScrollSources.delete(outgoingTabId);
}
}
@@ -270,4 +351,5 @@ export function __resetTabCoordinatorForTests(): void {
lastIdentity = null;
lastActiveTabId = null;
lastActiveUrl = null;
externalScrollSources.clear();
}
@@ -577,6 +577,64 @@ describe("commitScrollMemento", () => {
view: { "/acme/inbox::highlight:i1": "c1" },
});
});
it("persists contentKey on external-source entries (iframe scroll)", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.commitScrollMemento(tabId, "/acme/attachments/x/preview", {
"html-iframe": { top: 400, height: 9000, contentKey: "abc123" },
});
expect(
useTabStore.getState().byWorkspace.acme.tabs[0].memento.scroll[
"/acme/attachments/x/preview::html-iframe"
],
).toEqual({ top: 400, height: 9000, contentKey: "abc123" });
});
it("treats top:0 with a new contentKey as a change so it REPLACES a stale positive offset", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const route = "/acme/attachments/x/preview";
store.commitScrollMemento(tabId, route, {
"html-iframe": { top: 800, height: 9000, contentKey: "v1" },
});
// Content changed (re-upload): the new document hasn't scrolled yet so
// capture reports top:0 with the NEW key. This must overwrite the old
// positive offset, otherwise the next visit would wrongly restore 800.
store.commitScrollMemento(tabId, route, {
"html-iframe": { top: 0, height: 0, contentKey: "v2" },
});
expect(
useTabStore.getState().byWorkspace.acme.tabs[0].memento.scroll[
`${route}::html-iframe`
],
).toEqual({ top: 0, height: 0, contentKey: "v2" });
});
it("does NOT skip the write when contentKey changes even with identical top/height", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const route = "/acme/attachments/x/preview";
store.commitScrollMemento(tabId, route, {
"html-iframe": { top: 100, height: 5000, contentKey: "v1" },
});
const before = useTabStore.getState().byWorkspace.acme.tabs[0].memento;
store.commitScrollMemento(tabId, route, {
"html-iframe": { top: 100, height: 5000, contentKey: "v2" },
});
const after = useTabStore.getState().byWorkspace.acme.tabs[0].memento;
expect(after).not.toBe(before);
expect(after.scroll[`${route}::html-iframe`].contentKey).toBe("v2");
});
});
describe("commitViewState", () => {
@@ -17,6 +17,22 @@ import { isReservedSlug } from "@multica/core/paths";
// router to `activeSession.url` with a navigation token. Nothing in this
// file touches react-router.
/**
* One captured scroll position.
*
* `contentKey` is an optional fingerprint of the rendered content, used by
* out-of-DOM scroll sources (the sandboxed HTML-attachment iframe — see
* multica-ai#6405) to invalidate restoration when the content changed
* (re-upload to the same attachment id). Plain `[data-tab-scroll-root]`
* containers leave it undefined; identity comparison (undefined ===
* undefined) means their behavior is unchanged.
*/
export interface ScrollMementoEntry {
top: number;
height: number;
contentKey?: string;
}
export interface TabMemento {
/**
* Scroll offsets keyed `${routePathname}::${containerKey}` where the
@@ -27,7 +43,7 @@ export interface TabMemento {
* tab). `height` is the container's scrollHeight at capture time, kept
* for diagnostics and future pre-sizing heuristics.
*/
scroll: Record<string, { top: number; height: number }>;
scroll: Record<string, ScrollMementoEntry>;
/**
* Generic restorable view-state entries, keyed like `scroll`
* (`${routePathname}::${entryKey}`). The memento's second kind of cargo:
@@ -196,12 +212,15 @@ interface TabStore {
* deactivate / before in-tab navigation). REPLACE semantics per route: all
* of the route's previous entries are dropped and the given ones written —
* so a container scrolled back to 0 (captured as "no entry") clears its
* stale offset instead of resurrecting it on the next visit.
* stale offset instead of resurrecting it on the next visit. External
* sources (iframe documents) always write an entry, including top:0, so a
* contentKey change can replace a stale positive offset rather than leave
* it behind.
*/
commitScrollMemento: (
tabId: string,
routeKey: string,
entries: Record<string, { top: number; height: number }>,
entries: Record<string, ScrollMementoEntry>,
) => void;
/**
* Write (string) or clear (undefined) one generic view-state entry for one
@@ -773,7 +792,12 @@ export const useTabStore = create<TabStore>()(
nextKeys.every((k) => {
const prev = current.memento.scroll[k];
const next = nextScroll[k];
return prev !== undefined && prev.top === next.top && prev.height === next.height;
return (
prev !== undefined &&
prev.top === next.top &&
prev.height === next.height &&
prev.contentKey === next.contentKey
);
});
if (unchanged) return;
+257
View File
@@ -0,0 +1,257 @@
import { expect, test, type Frame } from "@playwright/test";
import { buildScrollBridge } from "../packages/views/editor/utils/iframe-scroll-bridge";
/**
* Self-contained Chromium tests for the in-iframe scroll bridge
* (multica-ai#6405). No app server: `page.setContent` with a real
* `sandbox="allow-scripts"` srcdoc iframe, injecting the exact bridge
* script that ships in the bundle. These pin facts jsdom cannot verify:
* sandboxed srcdoc throws on sessionStorage, and a real ResizeObserver
* re-positions after a delayed content append.
*/
const TOKEN = "chrometok";
function pageWithIframe(bodyHtml: string): string {
// p{height:1200px} plus a 5000px bottom spacer so scroll targets like 600
// are reachable regardless of the test viewport's height.
const userHtml = `<style>body{margin:0} p{height:1200px}</style>${bodyHtml}<div style="height:5000px"></div>`;
const srcDoc = userHtml + buildScrollBridge(TOKEN);
const attr = srcDoc.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
return `<!doctype html><html><body style="margin:0">
<iframe id="f" sandbox="allow-scripts" srcdoc="${attr}" style="width:100vw;height:100vh;border:0"></iframe>
<script>
window.__messages = [];
window.addEventListener("message", (e) => {
if (e.data && e.data.__multica === "__multica") window.__messages.push(e.data);
});
</script></body></html>`;
}
async function attachPage(page: import("@playwright/test").Page): Promise<Frame> {
await page.setContent(pageWithIframe("<p>loaded</p>"));
// Wait until the iframe's document is parsed (the bridge announces ready).
await expect
.poll(async () => {
const msgs = await page.evaluate(() =>
(window as unknown as { __messages: unknown[] }).__messages.filter(
(m) => (m as { kind?: string }).kind === "ready",
),
);
return msgs.length;
})
.toBeGreaterThan(0);
const handle = await page.$("#f");
const frame = await handle!.contentFrame();
expect(frame).toBeTruthy();
return frame!;
}
test.describe("iframe scroll bridge (real Chromium, sandboxed srcdoc)", () => {
test("sandbox without allow-same-origin blocks sessionStorage (documents why we cannot use it)", async ({
page,
}) => {
await page.setContent(
`<!doctype html><body><iframe id=f sandbox="allow-scripts" srcdoc="<script>try{sessionStorage.getItem('x');parent.postMessage('ok','*')}catch(e){parent.postMessage('SECURITY_ERROR','*')}</scr` +
`ipt>"></iframe><script>window.__result=null;window.addEventListener('message',e=>{window.__result=e.data})</script></body>`,
);
await expect
.poll(async () => page.evaluate(() => (window as unknown as { __result: unknown }).__result), {
timeout: 10000,
})
.toBe("SECURITY_ERROR");
});
test("reports scroll position to the parent with the correct token", async ({
page,
}) => {
const frame = await attachPage(page);
await frame.evaluate(() => window.scrollTo(0, 400));
await expect
.poll(
async () => {
const all = await page.evaluate(() =>
(window as unknown as { __messages: Record<string, unknown>[] }).__messages,
);
return all.find((m) => m.kind === "scroll" && (m.y as number) >= 400);
},
{ timeout: 5000 },
)
.toMatchObject({ y: 400, token: TOKEN, kind: "scroll" });
});
test("restores scroll position on a parent restore message", async ({
page,
}) => {
const frame = await attachPage(page);
await page.evaluate(
([token]) => {
const w = document.querySelector<HTMLIFrameElement>("#f")!.contentWindow!;
w.postMessage(
{ __multica: "__multica", kind: "restore", y: 600, token },
"*",
);
},
[TOKEN],
);
await expect
.poll(async () => frame.evaluate(() => window.scrollY), { timeout: 5000 })
.toBe(600);
});
test("holds position across a 1s-delayed content append, then yields on user wheel (v4 guarantee)", async ({
page,
}) => {
const frame = await attachPage(page);
await page.evaluate(
([token]) => {
const w = document.querySelector<HTMLIFrameElement>("#f")!.contentWindow!;
w.postMessage(
{ __multica: "__multica", kind: "restore", y: 500, token },
"*",
);
},
[TOKEN],
);
// Wait > 33ms so a "2 stable frames then end session" (v3 bug) would
// already have terminated the restoring session.
await page.waitForTimeout(250);
// Append content 1s after restore; ResizeObserver must re-apply target.
await frame.evaluate(() =>
window.setTimeout(() => {
const div = document.createElement("div");
div.style.height = "3000px";
document.body.appendChild(div);
}, 1000),
);
// Allow the append + RO callback to run.
await page.waitForTimeout(1500);
expect(await frame.evaluate(() => window.scrollY)).toBe(500);
// User takeover: dispatch a real wheel event over the iframe.
await page.mouse.move(50, 50);
await page.mouse.wheel(0, 120);
await page.waitForTimeout(150);
// After takeover, further growth + programmatic scroll must not be
// forced back to 500.
await frame.evaluate(() => {
const div = document.createElement("div");
div.style.height = "2000px";
document.body.appendChild(div);
window.scrollTo(0, 900);
});
await page.waitForTimeout(200);
expect(await frame.evaluate(() => window.scrollY)).toBe(900);
});
test("ignores restore messages with a mismatched token", async ({
page,
}) => {
const frame = await attachPage(page);
await page.evaluate(() => {
const w = document.querySelector<HTMLIFrameElement>("#f")!.contentWindow!;
w.postMessage(
{ __multica: "__multica", kind: "restore", y: 777, token: "old" },
"*",
);
});
await page.waitForTimeout(200);
expect(await frame.evaluate(() => window.scrollY)).toBe(0);
});
test("parent handshake is bounded (no ready→request-sync→ready loop) and restores once", async ({
page,
}) => {
// Faithful in-page port of the PARENT side of useHtmlPreviewScrollRestore:
// - request-sync is sent only from the ref/onLoad handshake points;
// - on `ready` the parent records position and issues restore AT MOST
// ONCE per token — it never replies with another request-sync.
// Before the fix, ready → request-sync → ready looped forever.
const targetTop = 540;
await page.setContent(pageWithIframe("<p>loaded</p>"));
const counts = await page.evaluate(
([token, target]) =>
new Promise<{
ready: number;
requestSync: number;
restore: number;
scroll: number;
}>((resolve) => {
const MARK = "__multica";
const iframe = document.querySelector<HTMLIFrameElement>("#f")!;
let restoreIssued = false;
let requestSync = 0;
let restore = 0;
let ready = 0;
let scroll = 0;
function post(msg: Record<string, unknown>) {
iframe.contentWindow!.postMessage({ [MARK]: MARK, token, ...msg }, "*");
}
function sendRequestSync() {
requestSync++;
post({ kind: "request-sync" });
}
function sendRestore() {
if (restoreIssued) return;
if (target <= 0) return;
restoreIssued = true;
restore++;
post({ kind: "restore", y: target });
}
window.addEventListener("message", (e) => {
if (e.source !== iframe.contentWindow) return;
const d = e.data as
| { __multica?: string; token?: string; kind?: string }
| undefined;
if (!d || d.__multica !== MARK || d.token !== token) return;
if (d.kind === "ready") {
ready++;
// THE FIX: record + restore, but do NOT send request-sync here.
sendRestore();
} else if (d.kind === "scroll") {
scroll++;
}
});
// Ref callback (element attached) and onLoad each fire exactly one
// request-sync — the only two legitimate request-sync moments.
sendRequestSync();
iframe.addEventListener("load", () => {
sendRequestSync();
sendRestore();
});
// Let any would-be loop run; then snapshot the counts twice.
window.setTimeout(() => {
const first = { ready, requestSync, restore, scroll };
window.setTimeout(() => {
resolve({
ready: ready - first.ready,
requestSync: requestSync - first.requestSync,
restore: restore - first.restore,
scroll: scroll - first.scroll,
});
// We resolve with the DELTA — it must be all zeros (no loop).
}, 400);
}, 600);
}),
[TOKEN, targetTop],
);
// No messages were added in the 400ms observation window → no loop.
expect(counts).toEqual({ ready: 0, requestSync: 0, restore: 0, scroll: 0 });
// Restore actually happened in the iframe (proves the handshake still
// completes, not just that it went quiet).
const handle = await page.$("#f");
const frame = await handle!.contentFrame();
await expect
.poll(async () => frame!.evaluate(() => window.scrollY), { timeout: 5000 })
.toBe(targetTop);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import type { ScrollRestorationAdapter } from "../platform";
import { ScrollRestorationProvider } from "../platform";
import { AttachmentPreviewPage } from "./attachment-preview-page";
import { hashString } from "../editor/utils/hash-string";
import { buildScrollBridge } from "../editor/utils/iframe-scroll-bridge";
const htmlText = "<html><body><h1>Report</h1></body></html>";
vi.mock("../editor/hooks/use-attachment-html-text", () => ({
useAttachmentHtmlText: () => ({
data: { text: htmlText },
isLoading: false,
error: null,
}),
}));
vi.mock("../i18n", () => ({
useT: () => (fn: (d: Record<string, unknown>) => string) =>
fn({ attachment: { preview_loading: "l", preview_failed: "f" } }),
}));
describe("AttachmentPreviewPage", () => {
it("injects the scroll bridge under the desktop adapter and keys the iframe on contentKey", () => {
const adapter: ScrollRestorationAdapter = {
get: () => undefined,
registerExternalSource: vi.fn(),
};
const { container } = render(
<ScrollRestorationProvider adapter={adapter}>
<AttachmentPreviewPage attachmentId="att-1" />
</ScrollRestorationProvider>,
);
const iframe = container.querySelector("iframe")!;
expect(iframe).toBeTruthy();
expect(iframe.getAttribute("srcdoc")).toContain(
buildScrollBridge(hashString(htmlText)),
);
// React only exposes `key` via the reconciled element, but we can assert
// the bridge was built for the expected token — that's the signal the
// component derived contentKey from the text.
expect(adapter.registerExternalSource).toHaveBeenCalledWith(
"html-iframe",
expect.objectContaining({ capture: expect.any(Function) }),
);
});
it("does NOT inject the bridge or register a source on web (adapter without registerExternalSource)", () => {
const webAdapter: ScrollRestorationAdapter = { get: () => undefined };
const { container } = render(
<ScrollRestorationProvider adapter={webAdapter}>
<AttachmentPreviewPage attachmentId="att-1" />
</ScrollRestorationProvider>,
);
const iframe = container.querySelector("iframe")!;
expect(iframe.getAttribute("srcdoc")).not.toContain("__multica");
expect(iframe.getAttribute("srcdoc")).not.toContain(buildScrollBridge("x"));
// Still applies the fragment-nav shim on both surfaces.
expect(iframe.getAttribute("srcdoc")).toContain("scrollIntoView");
});
});
@@ -21,7 +21,7 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
import { withFragmentNavShim } from "../editor/utils/iframe-fragment-nav";
import { useHtmlPreviewScrollRestore } from "./use-html-preview-scroll-restore";
interface AttachmentPreviewPageProps {
attachmentId: string;
@@ -37,13 +37,22 @@ export function AttachmentPreviewPage({
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
const text = query.data?.text;
// Scroll-position restoration across desktop tab switches (multica-ai#6405).
// No-op on web (no desktop adapter). The iframe is keyed on contentKey so a
// content change (re-upload) structurally remounts a fresh document; the
// hook reports y=0 with the new key until that document scrolls, and
// messages from the previous document are dropped by token.
const { contentKey, buildSrcDoc, iframeRef, onLoad } =
useHtmlPreviewScrollRestore(text);
// Set document.title so desktop's MutationObserver-based tab title picks
// up the filename. Web shows the same string in the browser tab.
useEffect(() => {
if (filename) document.title = filename;
}, [filename]);
const text = query.data?.text;
const isLoading = query.isLoading;
const isError = !isLoading && (!!query.error || !text);
@@ -62,7 +71,10 @@ export function AttachmentPreviewPage({
</div>
) : (
<iframe
srcDoc={withFragmentNavShim(text)}
key={contentKey}
ref={iframeRef}
onLoad={onLoad}
srcDoc={buildSrcDoc(text as string)}
sandbox="allow-scripts"
title={filename ?? "HTML attachment"}
className="flex-1 w-full border-0 bg-background"
@@ -0,0 +1,304 @@
import { describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import type {
ExternalScrollSource,
ScrollRestorationAdapter,
ScrollRestorationEntry,
} from "../platform";
import { ScrollRestorationProvider } from "../platform";
import { useHtmlPreviewScrollRestore } from "./use-html-preview-scroll-restore";
import { buildScrollBridge } from "../editor/utils/iframe-scroll-bridge";
import { hashString } from "../editor/utils/hash-string";
function makeAdapter(initial?: ScrollRestorationEntry): {
adapter: ScrollRestorationAdapter;
registered: Map<string, ExternalScrollSource | null>;
setEntry: (e: ScrollRestorationEntry | undefined) => void;
} {
const registered = new Map<string, ExternalScrollSource | null>();
let entry = initial;
return {
registered,
setEntry: (e) => {
entry = e;
},
adapter: {
get: () => entry,
registerExternalSource: (key, source) => {
registered.set(key, source);
},
},
};
}
function renderWith(
text: string | undefined,
adapter: ScrollRestorationAdapter,
) {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ScrollRestorationProvider adapter={adapter}>{children}</ScrollRestorationProvider>
);
return renderHook(() => useHtmlPreviewScrollRestore(text), { wrapper });
}
describe("useHtmlPreviewScrollRestore", () => {
it("registers an external scroll source on mount and unregisters on unmount", () => {
const { adapter, registered } = makeAdapter();
const { unmount } = renderWith("<p>hi</p>", adapter);
const source = registered.get("html-iframe");
expect(source).toBeTruthy();
expect(typeof source?.capture).toBe("function");
unmount();
expect(registered.get("html-iframe")).toBeNull();
});
it("capture() returns 0 + the current contentKey before the document reports anything", () => {
const text = "<p>first version, long enough to hash</p>";
const { adapter, registered } = makeAdapter();
renderWith(text, adapter);
const source = registered.get("html-iframe")!;
const captured = source.capture();
expect(captured).toEqual({
top: 0,
height: 0,
contentKey: hashString(text),
});
});
it("capture() returns the latest reported y only when the token matches", () => {
const text = "<p>v1</p>";
const { adapter, registered } = makeAdapter();
const { result } = renderWith(text, adapter);
const source = registered.get("html-iframe")!;
const token = result.current.contentKey;
// Attach the iframe ref so event.source validation passes.
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
(result.current.iframeRef as (el: HTMLIFrameElement | null) => void)(iframe);
const postSpy = vi
.spyOn(iframe.contentWindow!, "postMessage")
.mockImplementation(() => {});
window.dispatchEvent(
new MessageEvent("message", {
source: iframe.contentWindow,
data: {
__multica: "__multica",
kind: "scroll",
y: 512,
height: 4000,
token,
},
}),
);
expect(source.capture()).toEqual({
top: 512,
height: 4000,
contentKey: token,
});
// A stale document's late message (old token) must be ignored.
window.dispatchEvent(
new MessageEvent("message", {
source: iframe.contentWindow,
data: {
__multica: "__multica",
kind: "scroll",
y: 9999,
height: 1,
token: "old-token",
},
}),
);
expect(source.capture()!.top).toBe(512);
postSpy.mockRestore();
document.body.removeChild(iframe);
});
it("resets capture() to 0 with the new contentKey when content changes (no stale carryover)", () => {
const { adapter, registered } = makeAdapter();
const { result, rerender } = renderHook(
({ text }: { text: string }) => useHtmlPreviewScrollRestore(text),
{
initialProps: { text: "<p>v1 long enough content body</p>" },
wrapper: ({ children }) => (
<ScrollRestorationProvider adapter={adapter}>
{children}
</ScrollRestorationProvider>
),
},
);
const source = registered.get("html-iframe")!;
// Simulate v1 document having reported a positive scroll.
const v1Token = result.current.contentKey;
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
(result.current.iframeRef as (el: HTMLIFrameElement | null) => void)(iframe);
window.dispatchEvent(
new MessageEvent("message", {
source: iframe.contentWindow,
data: {
__multica: "__multica",
kind: "scroll",
y: 800,
height: 5000,
token: v1Token,
},
}),
);
expect(source.capture()!.top).toBe(800);
// Now the content changes (re-upload). capture() must report 0 with the
// NEW contentKey — never y=800 under the new key.
const v2 = "<p>v2 completely different replacement content body</p>";
rerender({ text: v2 });
const captured = source.capture();
expect(captured).toEqual({
top: 0,
height: 0,
contentKey: hashString(v2),
});
document.body.removeChild(iframe);
});
it("posts a restore message only when saved contentKey matches and top > 0", () => {
const text = "<p>matching content body for restore test</p>";
const contentKey = hashString(text);
const { adapter } = makeAdapter({ top: 300, height: 9000, contentKey });
const { result } = renderWith(text, adapter);
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const postSpy = vi
.spyOn(iframe.contentWindow!, "postMessage")
.mockImplementation(() => {});
(result.current.iframeRef as (el: HTMLIFrameElement | null) => void)(iframe);
result.current.onLoad();
const restoreCalls = postSpy.mock.calls.filter(
(c) => c[0]?.kind === "restore",
);
expect(restoreCalls.length).toBeGreaterThan(0);
expect(restoreCalls[0]![0]).toMatchObject({
kind: "restore",
y: 300,
token: contentKey,
});
postSpy.mockRestore();
document.body.removeChild(iframe);
});
it("does NOT post restore when saved contentKey differs from current content", () => {
const text = "<p>current body that does not match saved key</p>";
const { adapter } = makeAdapter({
top: 300,
height: 9000,
contentKey: "some-other-content-key",
});
const { result } = renderWith(text, adapter);
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const postSpy = vi
.spyOn(iframe.contentWindow!, "postMessage")
.mockImplementation(() => {});
(result.current.iframeRef as (el: HTMLIFrameElement | null) => void)(iframe);
result.current.onLoad();
expect(
postSpy.mock.calls.some((c) => c[0]?.kind === "restore"),
).toBe(false);
postSpy.mockRestore();
document.body.removeChild(iframe);
});
it("is fully inert on web (no registerExternalSource on adapter): no source, no bridge", () => {
const text = "<p>web shareable page, no desktop adapter</p>";
const webAdapter: ScrollRestorationAdapter = { get: () => undefined };
const { result } = renderWith(text, webAdapter);
expect(result.current.restoreActive).toBe(false);
const srcDoc = result.current.buildSrcDoc(text);
expect(srcDoc).not.toContain("__multica");
expect(srcDoc).not.toContain(buildScrollBridge("x"));
});
it("injects the bridge into srcDoc only when restoreActive (desktop)", () => {
const text = "<p>desktop body</p>";
const { adapter } = makeAdapter();
const { result } = renderWith(text, adapter);
expect(result.current.restoreActive).toBe(true);
const srcDoc = result.current.buildSrcDoc(text);
expect(srcDoc).toContain(buildScrollBridge(result.current.contentKey));
// Fragment-nav shim is still applied too.
expect(srcDoc).toContain("scrollIntoView");
});
it("handshake is bounded: ready never triggers request-sync and restore is sent at most once", () => {
// Regression for the P0 ready → request-sync → ready infinite loop.
const text = "<p>bounded handshake body content here</p>";
const contentKey = hashString(text);
const { adapter } = makeAdapter({ top: 420, height: 9000, contentKey });
const { result } = renderWith(text, adapter);
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const postSpy = vi
.spyOn(iframe.contentWindow!, "postMessage")
.mockImplementation(() => {});
// The two legitimate handshake moments: ref attach and load.
(result.current.iframeRef as (el: HTMLIFrameElement | null) => void)(iframe);
result.current.onLoad();
const requestSyncAfterHandshake = postSpy.mock.calls.filter(
(c) => c[0]?.kind === "request-sync",
).length;
const restoreAfterHandshake = postSpy.mock.calls.filter(
(c) => c[0]?.kind === "restore",
).length;
// One request-sync from the ref callback, one from onLoad — never more.
expect(requestSyncAfterHandshake).toBe(2);
// Restore issued once despite both handshake points trying.
expect(restoreAfterHandshake).toBe(1);
postSpy.mockClear();
// Simulate the bridge replying with several ready messages (it announces
// on DOMContentLoaded + load AND answers each request-sync). NONE of these
// may cause the parent to send another request-sync (that was the loop).
for (let i = 0; i < 5; i++) {
window.dispatchEvent(
new MessageEvent("message", {
source: iframe.contentWindow,
data: {
__multica: "__multica",
kind: "ready",
y: 100 + i,
height: 9000,
token: contentKey,
},
}),
);
}
const requestSyncAfterReady = postSpy.mock.calls.filter(
(c) => c[0]?.kind === "request-sync",
).length;
const restoreAfterReady = postSpy.mock.calls.filter(
(c) => c[0]?.kind === "restore",
).length;
expect(requestSyncAfterReady).toBe(0);
expect(restoreAfterReady).toBe(0);
postSpy.mockRestore();
document.body.removeChild(iframe);
});
});
@@ -0,0 +1,258 @@
"use client";
/**
* useHtmlPreviewScrollRestore — wires the full-page HTML attachment iframe
* into the desktop tab Coordinator's scroll-capture/restore channel
* (multica-ai#6405).
*
* Why this exists: the full-page preview renders user HTML inside
* `<iframe sandbox="allow-scripts" srcDoc>` with no `allow-same-origin`, so
* the outer document cannot read `iframe.contentWindow.scrollY` (opaque
* origin → SecurityError) and the Coordinator's `[data-tab-scroll-root]`
* scan can't see it. The in-iframe bridge (see `iframe-scroll-bridge.ts`)
* reports `scroll`/`ready` via postMessage; this hook holds the latest value
* in a ref, registers it as an `ExternalScrollSource` so the Coordinator
* captures it synchronously at tab-switch, and sends `restore` back when the
* iframe remounts for the same content.
*
* Content-change isolation:
* - `contentKey` = DJB2 hash of the rendered text.
* - The iframe is keyed on it, so new content structurally remounts a
* fresh iframe element + srcdoc document — the old document's bridge,
* listeners, and `event.source` are gone with the element.
* - Every bridge message carries the token; late messages from a previous
* document are dropped.
* - `capture()` returns top 0 with the NEW token until the new document
* reports, so a content change followed by an immediate tab switch can
* never resurrect the old position.
*
* Scope: only the full-page `AttachmentPreviewPage` calls this. The inline
* 480px card / modal path renders via a separate component
* (`HtmlPreviewBody` → `CodeBlockIframe`) and never touches this hook, so it
* cannot register an external source against the current tab. On web
* (no provider / adapter without `registerExternalSource`) `restoreActive`
* is false: no bridge is injected, no source is registered, behavior is
* byte-identical to before.
*/
import { useCallback, useLayoutEffect, useRef } from "react";
import {
useRestoredScrollEntry,
useScrollRestorationAdapter,
type ExternalScrollSource,
} from "../platform";
import { withFragmentNavShim } from "../editor/utils/iframe-fragment-nav";
import { buildScrollBridge } from "../editor/utils/iframe-scroll-bridge";
import { hashString } from "../editor/utils/hash-string";
/** Container key the Coordinator stores this under in the tab memento. */
export const HTML_IFRAME_SCROLL_KEY = "html-iframe";
const BRIDGE_MARK = "__multica";
type BridgeMessage =
| { kind: "scroll"; y: number; height: number }
| { kind: "ready"; y: number; height: number };
export interface HtmlPreviewScrollRestore {
/** Apply as the iframe's React `key` so content changes remount it. */
contentKey: string;
/** True only under the desktop adapter that registers external sources. */
restoreActive: boolean;
/** Build the iframe's srcDoc, appending the bridge when active. */
buildSrcDoc: (html: string) => string;
/** iframe ref callback — attaches the parent message listener. */
iframeRef: (el: HTMLIFrameElement | null) => void;
/** iframe onLoad handler — kicks off request-sync + restore. */
onLoad: () => void;
}
export function useHtmlPreviewScrollRestore(
text: string | undefined,
): HtmlPreviewScrollRestore {
const adapter = useScrollRestorationAdapter();
const savedEntry = useRestoredScrollEntry(HTML_IFRAME_SCROLL_KEY);
const restoreActive = typeof adapter?.registerExternalSource === "function";
const contentKey = hashString(text ?? "");
// Restore only when the saved offset belongs to the exact content we are
// about to render. A missing/mismatched contentKey means "new content" —
// start at 0, never post restore.
const targetTop =
savedEntry &&
savedEntry.contentKey === contentKey &&
savedEntry.top > 0
? savedEntry.top
: 0;
const iframeElRef = useRef<HTMLIFrameElement | null>(null);
const expectedTokenRef = useRef<string>(contentKey);
const lastReportRef = useRef<{ token: string; y: number; height: number }>({
token: contentKey,
y: 0,
height: 0,
});
const targetTopRef = useRef<number>(targetTop);
// Token for which we have already issued a `restore` this document's
// lifetime. Reset implicitly when contentKey changes (the value no longer
// matches expectedTokenRef), so the next document gets exactly one restore.
const restoreIssuedForRef = useRef<string | null>(null);
// Update "latest value" refs DURING RENDER (not in an effect) so that the
// iframe's ref callback — which runs in commit BEFORE useLayoutEffect —
// already sees the new token when contentKey changes and the iframe element
// is replaced via `key={contentKey}`. React allows mutating refs during
// render as long as the mutation is idempotent across re-renders.
expectedTokenRef.current = contentKey;
targetTopRef.current = targetTop;
if (lastReportRef.current.token !== contentKey) {
lastReportRef.current = { token: contentKey, y: 0, height: 0 };
}
// A content change must allow a fresh restore for the new document.
if (restoreIssuedForRef.current !== contentKey) {
restoreIssuedForRef.current = null;
}
const postToIframe = useCallback(
(message: Record<string, unknown>) => {
const win = iframeElRef.current?.contentWindow;
if (!win) return;
try {
win.postMessage(message, "*");
} catch {
// iframe gone / cross-origin teardown — nothing to do.
}
},
[],
);
const sendRequestSync = useCallback(() => {
if (!restoreActive) return;
postToIframe({
[BRIDGE_MARK]: BRIDGE_MARK,
kind: "request-sync",
token: expectedTokenRef.current,
});
}, [postToIframe, restoreActive]);
const sendRestore = useCallback(() => {
if (!restoreActive) return;
const token = expectedTokenRef.current;
// At most one restore per document. The in-iframe bridge's restoring
// session (ResizeObserver) holds the target across later content growth,
// so re-sending restore is unnecessary — and re-sending it on every
// `ready` (which the bridge emits in reply to every `request-sync`) would
// recreate the restoring session in a loop.
if (restoreIssuedForRef.current === token) return;
const target = targetTopRef.current;
if (target <= 0) return;
restoreIssuedForRef.current = token;
postToIframe({
[BRIDGE_MARK]: BRIDGE_MARK,
kind: "restore",
y: target,
token,
});
}, [postToIframe, restoreActive]);
// Register the external scroll source with the desktop adapter for the
// page's lifetime. The adapter is itself per-tab (createScrollRestorationAdapter
// is memoized on tabId in tab-content.tsx), so on tab unmount both the
// adapter and its registry go away; this cleanup is belt-and-braces.
useLayoutEffect(() => {
if (!restoreActive || !adapter?.registerExternalSource) return;
const source: ExternalScrollSource = {
capture() {
const report = lastReportRef.current;
// If the current document hasn't reported yet (new content before
// any scroll/ready), its token won't match — report 0 with the NEW
// contentKey so the stale positive offset is replaced, never the
// old document's y.
const matched = report.token === expectedTokenRef.current;
return {
top: matched ? report.y : 0,
height: matched ? report.height : 0,
contentKey: expectedTokenRef.current,
};
},
};
adapter.registerExternalSource(HTML_IFRAME_SCROLL_KEY, source);
return () => {
adapter.registerExternalSource?.(HTML_IFRAME_SCROLL_KEY, null);
};
}, [adapter, restoreActive]);
// Stable message handler so add/removeEventListener pair exactly.
const handleMessage = useCallback(
(event: MessageEvent) => {
const iframe = iframeElRef.current;
if (!iframe) return;
if (event.source !== iframe.contentWindow) return;
const data = event.data as
| (BridgeMessage & { [BRIDGE_MARK]?: string; token?: string })
| null
| undefined;
if (!data || data[BRIDGE_MARK] !== BRIDGE_MARK) return;
if (data.token !== expectedTokenRef.current) return;
if (data.kind !== "scroll" && data.kind !== "ready") return;
const y = typeof data.y === "number" ? data.y : 0;
const height = typeof data.height === "number" ? data.height : 0;
lastReportRef.current = { token: expectedTokenRef.current, y, height };
if (data.kind === "ready") {
// The document has parsed and announced its position. This is the
// right moment to restore (it can accept scrollTo now). Do NOT reply
// with another `request-sync`: the bridge answers every request-sync
// with a ready, so ready → request-sync → ready would loop forever.
// request-sync is sent only from the ref/onLoad handshake, each of
// which gets exactly one ready in reply.
sendRestore();
}
},
[sendRequestSync, sendRestore],
);
// Ref callback runs during commit, BEFORE the iframe fires `load` — so the
// window message listener is already attached when the bridge's initial
// ready arrives.
const iframeRef = useCallback(
(el: HTMLIFrameElement | null) => {
if (iframeElRef.current) {
try {
window.removeEventListener("message", handleMessage);
} catch {
// noop
}
}
iframeElRef.current = el;
if (el) {
window.addEventListener("message", handleMessage);
// Defense in depth: ask the new document to announce itself even if
// its initial ready somehow raced this callback.
sendRequestSync();
}
},
[handleMessage, sendRequestSync],
);
const onLoad = useCallback(() => {
sendRequestSync();
sendRestore();
}, [sendRequestSync, sendRestore]);
const buildSrcDoc = useCallback(
(html: string) => {
const base = withFragmentNavShim(html);
if (!restoreActive) return base;
return base + buildScrollBridge(contentKey);
},
[contentKey, restoreActive],
);
return {
contentKey,
restoreActive,
buildSrcDoc,
iframeRef,
onLoad,
};
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { hashString } from "./hash-string";
describe("hashString", () => {
it("returns a stable base36 digest for identical input", () => {
expect(hashString("hello world")).toBe(hashString("hello world"));
});
it("differs for different inputs (collision-resistant enough for contentKey)", () => {
expect(hashString("<p>a</p>")).not.toBe(hashString("<p>b</p>"));
});
it("produces [a-z0-9]+ tokens (safe to inline as a JS string without escaping)", () => {
const token = hashString("<h1>标题 😀</h1>\n<script>alert(1)</script>");
expect(token).toMatch(/^[a-z0-9]+$/);
});
it("hashes empty string without throwing", () => {
expect(hashString("")).toMatch(/^[a-z0-9]+$/);
});
});
@@ -0,0 +1,16 @@
/**
* DJB2 string hash — 32-bit unsigned, rendered as base36.
*
* Extracted from `rich-content/mounted-block-registry.ts` (same cheap,
* synchronous hash used by the Mermaid layout cache). Used by the HTML
* attachment preview to derive a `contentKey` fingerprint of the rendered
* text so that scroll restoration can detect when the attachment's content
* changed (re-upload to the same id) and refuse to restore a stale offset.
*/
export function hashString(source: string): string {
let hash = 5381;
for (let i = 0; i < source.length; i++) {
hash = ((hash << 5) + hash) ^ source.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__scrollBridgeInnerScript__,
buildScrollBridge,
} from "./iframe-scroll-bridge";
const TOKEN = "abc123";
// The bridge ships as a <script> string injected into a srcdoc iframe. Like
// iframe-fragment-nav.test.ts we evaluate the inner script against the current
// jsdom document and stub the bits jsdom doesn't implement (scrollTo,
// ResizeObserver, parent.postMessage).
function loadBridge(token = TOKEN) {
// jsdom runs the script in the same window whose `parent` is itself; the
// bridge posts to `parent`, which is reachable. Stub postMessage.
const postMessage = vi.fn();
Object.defineProperty(window, "parent", {
configurable: true,
value: { postMessage },
});
const scrollTo = vi.fn();
Object.defineProperty(window, "scrollTo", {
configurable: true,
writable: true,
value: scrollTo,
});
const inner = __scrollBridgeInnerScript__(token);
new Function(inner)();
return { postMessage, scrollTo };
}
describe("buildScrollBridge", () => {
it("wraps the script in <script> tags", () => {
const out = buildScrollBridge(TOKEN);
expect(out.startsWith("<script>")).toBe(true);
expect(out.endsWith("</script>")).toBe(true);
});
it("burns the token into the script source", () => {
const out = buildScrollBridge("deadbeef");
expect(out).toContain('"deadbeef"');
});
it("rejects tokens that are not [a-z0-9]+ and substitutes an empty token", () => {
// hashString always produces base36 so this is defense in depth.
const out = buildScrollBridge('not"; alert(1); var x="');
expect(out).toContain('TOKEN = ""');
});
});
describe("scroll bridge runtime", () => {
let originalRAF: typeof window.requestAnimationFrame;
beforeEach(() => {
originalRAF = window.requestAnimationFrame;
// Make rAF synchronous so the "settled resend" path runs inline.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
});
afterEach(() => {
window.requestAnimationFrame = originalRAF;
});
it("announces ready on load with y and height", () => {
const { postMessage } = loadBridge();
const ready = postMessage.mock.calls.find(
(c) => c[0]?.kind === "ready",
);
expect(ready).toBeTruthy();
expect(ready?.[0]).toMatchObject({
__multica: "__multica",
token: TOKEN,
y: expect.any(Number),
height: expect.any(Number),
});
});
it("reports scroll with the correct token and y", () => {
const { postMessage } = loadBridge();
postMessage.mockClear();
Object.defineProperty(window, "scrollY", {
configurable: true,
writable: true,
value: 432,
});
window.dispatchEvent(new Event("scroll"));
const report = postMessage.mock.calls.find(
(c) => c[0]?.kind === "scroll",
);
expect(report?.[0]).toMatchObject({
kind: "scroll",
token: TOKEN,
y: 432,
});
});
it("answers request-sync with a ready message", () => {
const { postMessage } = loadBridge();
postMessage.mockClear();
window.dispatchEvent(
new MessageEvent("message", {
data: { __multica: "__multica", kind: "request-sync", token: TOKEN },
}),
);
expect(
postMessage.mock.calls.some((c) => c[0]?.kind === "ready"),
).toBe(true);
});
it("ignores parent messages with a mismatched token", () => {
const { postMessage, scrollTo } = loadBridge();
scrollTo.mockClear();
window.dispatchEvent(
new MessageEvent("message", {
data: { __multica: "__multica", kind: "restore", y: 100, token: "old" },
}),
);
expect(scrollTo).not.toHaveBeenCalled();
// And it must not have posted anything in response either.
expect(
postMessage.mock.calls.some(
(c) => c[0]?.kind === "ready" && c[0]?.token === TOKEN,
),
).toBe(true); // only the initial announce
});
describe("restoring session", () => {
let roCallback: (() => void) | null;
let observeSpy: ReturnType<typeof vi.fn>;
let disconnectSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
roCallback = null;
observeSpy = vi.fn();
disconnectSpy = vi.fn();
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
constructor(cb: () => void) {
roCallback = cb;
}
observe = observeSpy;
disconnect = disconnectSpy;
unobserve = vi.fn();
};
});
afterEach(() => {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
});
function restoreTo(y: number) {
const { scrollTo } = loadBridge();
scrollTo.mockClear();
window.dispatchEvent(
new MessageEvent("message", {
data: {
__multica: "__multica",
kind: "restore",
y,
token: TOKEN,
},
}),
);
return scrollTo;
}
it("scrolls to the target on restore and connects a ResizeObserver", () => {
const scrollTo = restoreTo(500);
expect(scrollTo).toHaveBeenCalledWith(0, 500);
expect(observeSpy).toHaveBeenCalledWith(document.documentElement);
});
it("re-positions to the target when content grows (RO fires) even much later", () => {
const scrollTo = restoreTo(500);
scrollTo.mockClear();
// A ResizeObserver tick after the initial restore — in v3 the session
// would have ended ~33ms after "2 stable frames" and this call would
// NOT re-position; v4 keeps the session alive until a user takeover.
// (No fake timers needed: firing the RO callback directly proves the
// session is still connected; the real delay is exercised by the
// Playwright spec with a 1s setTimeout.)
roCallback?.();
expect(scrollTo).toHaveBeenCalledWith(0, 500);
});
it("ends the session on a wheel event and stops re-positioning", () => {
const scrollTo = restoreTo(500);
window.dispatchEvent(new Event("wheel"));
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).not.toHaveBeenCalled();
expect(disconnectSpy).toHaveBeenCalled();
});
it("ends the session on keydown (PageDown) and stops re-positioning", () => {
const scrollTo = restoreTo(500);
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "PageDown", bubbles: true }),
);
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).not.toHaveBeenCalled();
});
it("ends the session on pointerdown (scrollbar drag)", () => {
const scrollTo = restoreTo(500);
document.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true }),
);
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).not.toHaveBeenCalled();
});
it("ends the session on an in-page anchor click (capture, before fragment shim)", () => {
const scrollTo = restoreTo(500);
const anchor = document.createElement("a");
anchor.setAttribute("href", "#later");
document.body.appendChild(anchor);
anchor.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).not.toHaveBeenCalled();
});
it("does not end the session on a non-anchor click", () => {
const scrollTo = restoreTo(500);
const div = document.createElement("div");
div.textContent = "x";
document.body.appendChild(div);
div.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).toHaveBeenCalledWith(0, 500);
});
it("does not end the session on a bare '#' click", () => {
const scrollTo = restoreTo(500);
const anchor = document.createElement("a");
anchor.setAttribute("href", "#");
document.body.appendChild(anchor);
anchor.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).toHaveBeenCalledWith(0, 500);
});
it("ends the session on hashchange", () => {
const scrollTo = restoreTo(500);
window.dispatchEvent(new HashChangeEvent("hashchange"));
scrollTo.mockClear();
roCallback?.();
expect(scrollTo).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,183 @@
/**
* Scroll-position bridge for sandboxed HTML attachment iframes (multica-ai#6405).
*
* The full-page `AttachmentPreviewPage` mounts user HTML inside
* `<iframe sandbox="allow-scripts" srcDoc={...}>` — WITHOUT
* `allow-same-origin`, so the parent cannot read `iframe.contentWindow.scrollY`
* (opaque origin → SecurityError) and `captureScrollEntries()` in the desktop
* tab Coordinator cannot see the iframe's scrolling element.
*
* This bridge is an in-iframe `<script>` (same shape as the fragment-nav shim
* in `iframe-fragment-nav.ts`) that:
*
* - reports the iframe's scroll position to the parent on every scroll via
* `postMessage`, so the parent can capture it synchronously when the tab
* is about to unmount (bounded last-known-value semantics — see the v4
* plan);
* - answers the parent's `request-sync` with a `ready` carrying current y;
* - on `restore(y)`, holds a *restoring session*: a ResizeObserver keeps
* re-scrolling to the target while user-authored content grows the
* document (images/scripts that append after load would otherwise clamp
* the position back to top). The session ends ONLY on a user/navigation
* takeover signal (wheel, touchmove, scroll keys, pointerdown on the
* document, hashchange, or a capture-phase click on an in-page anchor —
* so the existing fragment-nav shim's `scrollIntoView` wins) or on
* iframe unload. It does NOT end on "height stable for 2 frames" — that
* contradicted the "arbitrarily delayed append keeps position" guarantee
* in v3; see v4 §1.
*
* The bridge never touches sessionStorage/localStorage/cookies/parent.document
* — sandbox `allow-scripts` without `allow-same-origin` blocks sessionStorage
* anyway (Chromium throws SecurityError), and we don't need it: the parent
* holds the last-known value.
*
* `token` is the content fingerprint (DJB2 base36 of the rendered HTML),
* burned into the script and echoed on every message so the parent can drop
* late messages from a previous document when `srcDoc` changes. React also
* remounts the iframe via `key={contentKey}`, so a stale document's event
* source is gone; the token is defense in depth.
*/
// Tokens come from hashString() — base36, [a-z0-9]+, no quotes/backslashes —
// so inlining them into the script source as a bare JS string literal is
// safe. We still run a sanity check and fall back to an empty token rather
// than emit a broken script.
const TOKEN_PATTERN = /^[a-z0-9]+$/;
function buildScript(token: string): string {
const safeToken = TOKEN_PATTERN.test(token) ? token : "";
return `<script>
(function(){
var TOKEN = ${JSON.stringify(safeToken)};
var MARK = "__multica";
function docHeight() {
var de = document.documentElement;
return Math.max(de.scrollHeight, document.body ? document.body.scrollHeight : 0);
}
function post(msg) {
msg.__multica = MARK;
msg.token = TOKEN;
try { parent.postMessage(msg, "*"); } catch (_) {}
}
function reportScroll() {
post({ kind: "scroll", y: window.scrollY || 0, height: docHeight() });
}
// -- scroll reporting: no throttle; one rAF-coalesced settled resend -----
var rafScheduled = false;
window.addEventListener("scroll", function () {
reportScroll();
if (!rafScheduled) {
rafScheduled = true;
window.requestAnimationFrame(function () {
rafScheduled = false;
reportScroll();
});
}
}, { passive: true });
// -- restoring session (v4): hold position across content growth --------
var restoring = false;
var restoreTarget = 0;
var ro = null;
var takeoverListeners = [];
function onTakeover(target, type, fn, opts) {
// For removeEventListener only the capture bit of the options bag
// matters; record it so cleanup detaches exactly.
var capture = !!(opts && opts.capture);
target.addEventListener(type, fn, opts);
takeoverListeners.push([target, type, fn, capture]);
}
function endSession() {
if (!restoring) return;
restoring = false;
if (ro) { try { ro.disconnect(); } catch (_) {} ro = null; }
for (var i = 0; i < takeoverListeners.length; i++) {
var t = takeoverListeners[i];
try { t[0].removeEventListener(t[1], t[2], t[3]); } catch (_) {}
}
takeoverListeners = [];
}
function applyTarget() {
// scrollTo is clamped by max scroll; when content grows later the RO
// re-applies the same target.
window.scrollTo(0, restoreTarget);
}
function beginRestore(y) {
restoreTarget = y | 0;
endSession();
restoring = true;
applyTarget();
if (typeof ResizeObserver === "function") {
ro = new ResizeObserver(function () {
if (!restoring) return;
applyTarget();
});
try { ro.observe(document.documentElement); } catch (_) { ro = null; }
}
// User/navigation takeover — capture phase so we see events before the
// fragment-nav shim (which calls preventDefault + scrollIntoView on
// anchor clicks). We do NOT treat a bare "scroll" event as takeover:
// our own applyTarget() and the ResizeObserver callbacks produce scroll
// events asynchronously and must not end the session they belong to.
onTakeover(window, "wheel", endSession, { passive: true });
onTakeover(window, "touchmove", endSession, { passive: true });
onTakeover(window, "keydown", function (e) {
var k = e.key;
if (k === " " || k === "PageUp" || k === "PageDown" ||
k === "Home" || k === "End" ||
k === "ArrowUp" || k === "ArrowDown" ||
k === "ArrowLeft" || k === "ArrowRight") {
endSession();
}
});
onTakeover(document, "pointerdown", endSession, true);
onTakeover(window, "hashchange", endSession);
onTakeover(document, "click", function (e) {
var t = e.target;
if (!t || typeof t.closest !== "function") return;
var a = t.closest('a[href^="#"]');
if (a && a.getAttribute("href") !== "#") endSession();
}, true);
}
// -- parent -> child messages -------------------------------------------
window.addEventListener("message", function (e) {
var d = e.data;
if (!d || d.__multica !== MARK || d.token !== TOKEN) return;
if (d.kind === "restore" && typeof d.y === "number") {
beginRestore(d.y);
} else if (d.kind === "request-sync") {
post({ kind: "ready", y: window.scrollY || 0, height: docHeight() });
}
});
window.addEventListener("pagehide", function () {
reportScroll();
endSession();
});
// Announce on load (and immediately if the script runs after DOMContentLoaded).
function announce() {
post({ kind: "ready", y: window.scrollY || 0, height: docHeight() });
}
if (document.readyState === "complete" || document.readyState === "interactive") {
announce();
} else {
window.addEventListener("DOMContentLoaded", announce);
window.addEventListener("load", announce);
}
})();
</script>`;
}
export function buildScrollBridge(token: string): string {
return buildScript(token);
}
/** Exposed for tests so they can evaluate the inner script in jsdom. */
export function __scrollBridgeInnerScript__(token: string): string {
return buildScript(token)
.replace(/^<script>/, "")
.replace(/<\/script>\s*$/, "");
}
+4
View File
@@ -15,9 +15,13 @@ export {
} from "./use-local-daemon-status";
export {
ScrollRestorationProvider,
useScrollRestorationAdapter,
useRestoredScrollEntry,
useRestoredScrollOffset,
useRestoredScrollRef,
useRestoredViewState,
useViewStateWriter,
type ExternalScrollSource,
type ScrollRestorationAdapter,
type ScrollRestorationEntry,
} from "./scroll-restoration";
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import {
ScrollRestorationProvider,
useRestoredScrollEntry,
useRestoredScrollOffset,
useScrollRestorationAdapter,
type ScrollRestorationAdapter,
} from "./scroll-restoration";
function makeAdapter(get: ScrollRestorationAdapter["get"]): ScrollRestorationAdapter {
return { get };
}
describe("scroll-restoration public surface", () => {
it("useScrollRestorationAdapter returns null without a provider", () => {
const { result } = renderHook(() => useScrollRestorationAdapter());
expect(result.current).toBeNull();
});
it("useScrollRestorationAdapter returns the adapter when provided", () => {
const adapter = makeAdapter(() => undefined);
const { result } = renderHook(() => useScrollRestorationAdapter(), {
wrapper: ({ children }) => (
<ScrollRestorationProvider adapter={adapter}>
{children}
</ScrollRestorationProvider>
),
});
expect(result.current).toBe(adapter);
});
it("useRestoredScrollEntry returns the full entry including contentKey", () => {
const adapter = makeAdapter(() => ({
top: 321,
height: 900,
contentKey: "ck",
}));
const { result } = renderHook(() => useRestoredScrollEntry("html-iframe"), {
wrapper: ({ children }) => (
<ScrollRestorationProvider adapter={adapter}>
{children}
</ScrollRestorationProvider>
),
});
expect(result.current).toEqual({ top: 321, height: 900, contentKey: "ck" });
});
it("useRestoredScrollOffset remains a thin wrapper returning only top (back-compat)", () => {
const adapter = makeAdapter(() => ({
top: 123,
height: 456,
contentKey: "ck",
}));
const { result } = renderHook(() => useRestoredScrollOffset("main"), {
wrapper: ({ children }) => (
<ScrollRestorationProvider adapter={adapter}>
{children}
</ScrollRestorationProvider>
),
});
expect(result.current).toBe(123);
});
it("all hooks return undefined without a provider (web no-op path)", () => {
const entry = renderHook(() => useRestoredScrollEntry("x"));
const offset = renderHook(() => useRestoredScrollOffset("x"));
expect(entry.result.current).toBeUndefined();
expect(offset.result.current).toBeUndefined();
});
});
+67 -5
View File
@@ -1,6 +1,11 @@
"use client";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
type ReactNode,
} from "react";
/**
* Pull-based scroll restoration channel (MUL-4741 state-restoration
@@ -22,12 +27,49 @@ import { createContext, useCallback, useContext, type ReactNode } from "react";
* capture side scans for; the platform scopes them per route + tab. On web
* (no provider) every lookup returns undefined and views behave as before.
*/
/**
* One captured scroll entry. `contentKey` is an optional fingerprint of the
* rendered content (multica-ai#6405): views that render untrusted HTML inside
* a sandboxed iframe cannot be scrolled externally and register an external
* capture source instead; they tag the entry with a hash of the rendered HTML
* so a re-upload to the same attachment id refuses to restore a stale offset.
* Plain containers leave it undefined and get identity comparison (undefined
* === undefined), so they are unaffected.
*/
export interface ScrollRestorationEntry {
top: number;
height: number;
contentKey?: string;
}
/**
* A scroll source that lives outside the outer DOM — e.g. the document inside
* a sandboxed iframe, which the platform cannot query for `scrollTop` due to
* the opaque-origin boundary. The owning view reports latest values into a
* ref and registers a source whose `capture()` reads it synchronously during
* the pre-unmount store tick.
*/
export interface ExternalScrollSource {
capture(): ScrollRestorationEntry | undefined;
}
export interface ScrollRestorationAdapter {
/**
* The saved offset for a container key on the current route, or undefined
* The saved entry for a container key on the current route, or undefined
* when there is nothing to restore.
*/
get(containerKey: string): { top: number; height: number } | undefined;
get(containerKey: string): ScrollRestorationEntry | undefined;
/**
* Register/unregister a scroll source that is not a `[data-tab-scroll-root]`
* element (currently: a sandboxed iframe's internal document). Desktop
* implements this; web does not and the method stays absent — views detect
* that and no-op without importing any app code.
*/
registerExternalSource?(
containerKey: string,
source: ExternalScrollSource | null,
): void;
/**
* Generic restorable view-state entries — the memento's second kind of
* cargo, for state that must survive the view's unmount but is not a
@@ -62,14 +104,34 @@ export function ScrollRestorationProvider({
);
}
/**
* The active adapter, or null on web (no provider). Prefer the narrower hooks
* below; reach for this when a view needs to call `registerExternalSource` or
* inspect whether restoration is active at all.
*/
export function useScrollRestorationAdapter(): ScrollRestorationAdapter | null {
return useContext(ScrollRestorationContext);
}
/**
* The full saved entry for a container key on the current route, including
* `contentKey` — use this when a view must decide whether a saved offset is
* still applicable to the currently rendered content (multica-ai#6405).
*/
export function useRestoredScrollEntry(
containerKey: string,
): ScrollRestorationEntry | undefined {
const adapter = useContext(ScrollRestorationContext);
return adapter?.get(containerKey);
}
/**
* The saved scroll offset for a container key on the current route, or
* undefined. Read it once at mount (e.g. into a Virtuoso `initialScrollTop`)
* — it reflects the state captured when this route was last left.
*/
export function useRestoredScrollOffset(containerKey: string): number | undefined {
const adapter = useContext(ScrollRestorationContext);
return adapter?.get(containerKey)?.top;
return useRestoredScrollEntry(containerKey)?.top;
}
/**
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "@playwright/test";
/**
* Local-only config for the self-contained iframe-scroll-bridge spec: it uses
* page.setContent and needs no app server, and it runs against the system
* Google Chrome (channel: "chrome") so the Playwright-bundled headless shell
* does not need to be downloaded in offline/sandboxed environments. Not used
* by the main `make check` pipeline.
*/
export default defineConfig({
testDir: "./e2e",
testMatch: /iframe-scroll-bridge\.spec\.ts/,
timeout: 60000,
workers: 1,
retries: 0,
use: {
channel: "chrome",
headless: true,
},
projects: [{ name: "chrome", use: { browserName: "chromium", channel: "chrome" } }],
});