fix(engine): a render no longer waits for a sub-composition timeline no script can register (#6764)

* fix(engine): a render no longer waits for a sub-composition timeline no script can register

* fix(engine): the timeline wait matches a twice-mounted id and a template-literal key
This commit is contained in:
Miguel Ángel
2026-10-30 02:56:22 +00:00
committed by GitHub
parent 0a18d8c350
commit 8fb08f2a7c
2 changed files with 113 additions and 25 deletions
@@ -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<string, string> = {}): Promise<boolean> {
async function scan(
html: string,
files: Record<string, string> = {},
registered: string[] = [],
): Promise<boolean> {
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) =>
`<body><script>(function(){ window.__timelines = window.__timelines || {};\n${AUTHORED_SOURCE_BEGIN}\n${source}\n${AUTHORED_SOURCE_END}\n})();</script></body>`;
expect(await scan(wrapped("draw()"))).toBe(false);
expect(await scan(wrapped("window.__timelines['a'] = tl"))).toBe(true);
const host = `<div data-composition-id="a"></div>`;
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(`<body><script>window.__timelines["a"] = tl</script></body>`)).toBe(true);
const host = (id: string) => `<div data-composition-id="${id}"></div>`;
expect(
await scan(`<body><template><script>window.__timelines.b = tl</script></template></body>`),
await scan(`<body>${host("a")}<script>window.__timelines["a"] = tl</script></body>`),
).toBe(true);
expect(
await scan(
`<body>${host("b")}<template><script>window.__timelines.b = tl</script></template></body>`,
),
).toBe(true);
});
@@ -244,7 +257,7 @@ describe("authorScriptsMayRegisterTimelines", () => {
false,
);
expect(
await scan(`<body><script src="/a.js"></script></body>`, {
await scan(`<body><div data-composition-id="x"></div><script src="/a.js"></script></body>`, {
"/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(
`<body><div data-composition-id="main"><div data-composition-id="still"></div></div>${scripts}</body>`,
{},
registered,
);
const main = `<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = tl</script>`;
expect(await page(main, ["main"])).toBe(false);
expect(await page(main)).toBe(true);
expect(await page(`<script>window.__timelines["still"] = tl</script>${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 = `<div data-composition-id="still"></div>`;
expect(
await scan(`<body>${hosts}<script>window.__timelines[compId] = tl</script></body>`, {}, []),
).toBe(true);
expect(
await scan(
`<body>${hosts}<script>Object.assign(window.__timelines, { still: tl })</script></body>`,
),
).toBe(true);
});
it("waits on a sub-composition mounted twice, whose hosts carry the authored id", async () => {
const html = `<body><div data-composition-id="main"><div data-composition-id="lt__hf1" data-hf-original-composition-id="lt"></div><div data-composition-id="lt__hf2" data-hf-original-composition-id="lt"></div></div><script>window.__timelines["main"] = tl</script><script>fetch("d.json").then(function(){ window.__timelines["lt"] = tl; })</script></body>`;
expect(await scan(html, {}, ["main"])).toBe(true);
});
it("answers true for a template-literal registration key", async () => {
const html =
'<body><div data-composition-id="still"></div><script>var id = "still"; setTimeout(function(){ window.__timelines[`${id}`] = tl; })</script></body>';
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 = `<body><div data-composition-id="x" data-no-timeline></div><script>window.__timelines["x"] = tl</script></body>`;
expect(await scan(html)).toBe(false);
});
});
+51 -19
View File
@@ -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<boolean> {
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<string, unknown> }).__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();