diff --git a/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts b/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts index 343f4acd41..5848a485d0 100644 --- a/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts +++ b/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts @@ -174,8 +174,15 @@ describe("pollSubCompositionTimelines when a script failed on a page nothing can describe("authorScriptsMayRegisterTimelines", () => { const RUNTIME_ATTR = "data-hyperframes-preview-runtime"; - async function scan(html: string, files: Record = {}): Promise { + async function scan( + html: string, + files: Record = {}, + registered: string[] = [], + ): Promise { const { document } = parseHTML(html); + vi.stubGlobal("window", { + __timelines: Object.fromEntries(registered.map((id) => [id, {}])), + }); Object.defineProperty(document, "readyState", { value: "complete" }); vi.stubGlobal("document", document); vi.stubGlobal("location", new URL("http://localhost:3000/index.html")); @@ -193,7 +200,7 @@ describe("authorScriptsMayRegisterTimelines", () => { AUTHORED_SOURCE_END, ); } finally { - delete (globalThis as { __hfAuthorScan?: boolean }).__hfAuthorScan; + delete (globalThis as { __hfAuthorScan?: unknown }).__hfAuthorScan; vi.unstubAllGlobals(); } } @@ -210,7 +217,8 @@ describe("authorScriptsMayRegisterTimelines", () => { const wrapped = (source: string) => ``; expect(await scan(wrapped("draw()"))).toBe(false); - expect(await scan(wrapped("window.__timelines['a'] = tl"))).toBe(true); + const host = `
`; + expect(await scan(host + wrapped("window.__timelines['a'] = tl"))).toBe(true); }); it("ignores the runtime, which mentions __timelines itself", async () => { @@ -220,9 +228,14 @@ describe("authorScriptsMayRegisterTimelines", () => { }); it("is true when an inline script or a template script registers one", async () => { - expect(await scan(``)).toBe(true); + const host = (id: string) => `
`; expect( - await scan(``), + await scan(`${host("a")}`), + ).toBe(true); + expect( + await scan( + `${host("b")}`, + ), ).toBe(true); }); @@ -244,7 +257,7 @@ describe("authorScriptsMayRegisterTimelines", () => { false, ); expect( - await scan(``, { + await scan(`
`, { "/a.js": "window.__timelines.x = 1", }), ).toBe(true); @@ -253,4 +266,47 @@ describe("authorScriptsMayRegisterTimelines", () => { true, ); }); + + it("is false when no script registers an id that is still waited on", async () => { + const page = (scripts: string, registered: string[] = []) => + scan( + `
${scripts}`, + {}, + registered, + ); + const main = ``; + expect(await page(main, ["main"])).toBe(false); + expect(await page(main)).toBe(true); + expect(await page(`${main}`, ["main"])).toBe( + true, + ); + }); + + it("answers true for a registration key it cannot read, so a late id is still waited on", async () => { + const hosts = `
`; + expect( + await scan(`${hosts}`, {}, []), + ).toBe(true); + expect( + await scan( + `${hosts}`, + ), + ).toBe(true); + }); + + it("waits on a sub-composition mounted twice, whose hosts carry the authored id", async () => { + const html = `
`; + expect(await scan(html, {}, ["main"])).toBe(true); + }); + + it("answers true for a template-literal registration key", async () => { + const html = + '
'; + expect(await scan(html)).toBe(true); + }); + + it("does not wait on a host that opted out or is in the skipped list", async () => { + const html = `
`; + expect(await scan(html)).toBe(false); + }); }); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 9b367979f1..8e91a69069 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2069,15 +2069,18 @@ export interface IsolatedComposition { elementId: string | null; } -// Runs in the page. False once every inline, template and same-origin script is read and none -// mentions __timelines or imports code; an unreadable or module script, or a page still loading, answers true. +// Runs in the page. False once every script is read and none can register an id still waited on; +// an unreadable, module or importing script, a computed key, or a page still loading answers true. export async function authorScriptsMayRegisterTimelines( runtimeAttr: string, authoredBegin: string, authoredEnd: string, + skipIds: readonly string[] = [], ): Promise { if (document.readyState !== "complete") return true; - const memo = globalThis as { __hfAuthorScan?: boolean }; + const memo = globalThis as { + __hfAuthorScan?: { opaque: boolean; mentions: boolean; ids: string[] }; + }; memo.__hfAuthorScan ??= await (async () => { const scripts = [ ...Array.from(document.querySelectorAll("script")), @@ -2085,34 +2088,62 @@ export async function authorScriptsMayRegisterTimelines( Array.from(t.content.querySelectorAll("script")), ), ].filter((script) => !script.hasAttribute(runtimeAttr)); + const scan = { opaque: false, mentions: false, ids: [] as string[] }; // Code that imports another file may register through it, which is not read here. - const mayRegister = (code: string) => - code.includes("__timelines") || /\bimport\s*\(|^\s*import\s*[\w{*"']/m.test(code); + const mayImport = (code: string) => /\bimport\s*\(|^\s*import\s*[\w{*"']/m.test(code); + const read = (code: string) => { + if (mayImport(code)) scan.opaque = true; + if (!code.includes("__timelines")) return; + scan.mentions = true; + const literalKey = /__timelines\s*(?:\[\s*(["'])((?:(?!\1).)+)\1\s*\]|\.([A-Za-z_$][\w$]*))/g; + const rest = code + .replace(/__timelines\s*=\s*(?:window\.__timelines\s*\|\|\s*)?\{\s*\}/g, "") + .replace(literalKey, (_m, _q, bracketId, dotId) => { + scan.ids.push(bracketId ?? dotId); + return ""; + }); + if (rest.includes("__timelines")) scan.opaque = true; + }; for (const script of scripts) { - if (script.type === "module") return true; + if (script.type === "module") return { ...scan, opaque: true }; if (!script.src) { const text = script.textContent ?? ""; - const authored = text.includes(authoredBegin) - ? text - .split(authoredBegin) - .slice(1) - .map((region) => region.split(authoredEnd)[0]) - .join("\n") - : text; - if (mayRegister(authored)) return true; + read( + text.includes(authoredBegin) + ? text + .split(authoredBegin) + .slice(1) + .map((region) => region.split(authoredEnd)[0]) + .join("\n") + : text, + ); continue; } const url = new URL(script.src, location.href); - if (url.origin !== location.origin) return true; + if (url.origin !== location.origin) return { ...scan, opaque: true }; try { - if (mayRegister(await (await fetch(url)).text())) return true; + read(await (await fetch(url)).text()); } catch { - return true; + return { ...scan, opaque: true }; } } - return false; + return scan; })(); - return memo.__hfAuthorScan; + const { opaque, mentions, ids } = memo.__hfAuthorScan; + if (opaque) return true; + if (!mentions) return false; + const timelines = (window as { __timelines?: Record }).__timelines ?? {}; + return Array.from(document.querySelectorAll("[data-composition-id]")).some((host) => { + const id = host.getAttribute("data-composition-id"); + const authoredId = host.getAttribute("data-hf-original-composition-id") ?? id; + return ( + id && + !host.hasAttribute("data-no-timeline") && + !timelines[id] && + !skipIds.includes(id) && + ids.includes(authoredId ?? id) + ); + }); } export interface SubTimelinePollOptions { @@ -2182,6 +2213,7 @@ export async function pollSubCompositionTimelines( RUNTIME_BOOTSTRAP_ATTR, AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END, + knownUnregisteredIds, ))); if (ready || shouldStop?.()) break; const now = Date.now();