mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user