mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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, "&").replace(/"/g, """);
|
||||
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*$/, "");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" } }],
|
||||
});
|
||||
Reference in New Issue
Block a user