mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(studio): tag each scene's parts in the preview so one scene can be swapped (#4503)
* perf(studio): tag each scene's parts in the preview so one can be swapped With sceneParts (Studio preview only) the bundler tags each top-level scene's host, styles and inline scripts with data-hf-scene; nested scenes belong to their top-level scene. The preview route then adds a manifest: a hash of everything outside the scenes and one hash per scene, so the preview can tell when a rebuilt document differs only inside some scenes. Renders are unchanged. * fix(core): keep scene order in the preview manifest and mark unswappable scenes A moved or reordered scene now changes the shared hash, a nested scene keeps its @import first, and a scene whose script leaves work running is marked so edits to it take the full reload. * refactor(core): keep the scene swap side-effect check in the bundler * fix(core): refuse swapping a scene that runs its own script file A swap never re-runs external scripts, so a scene file keeps driving the replaced nodes. Library URLs stay swappable. * fix(core): refuse swaps for async scene setup and keep the shared @import first Scenes that finish building after an await or a .then, animate elements directly or touch the page head and body now take the full reload, and splitting out scene styles no longer leaves the shared import late. * fix(core): refuse swaps for scene scripts that throw when run again Custom element and CSS property registration, sockets, draggables and infinite tweens now refuse too, and ticker or animate inside a name no longer does. * fix(core): refuse swapping a scene that runs a module script or import map * fix(core): run a scene's local script file in source order in the preview * fix(core): refuse a scene swap only for defineProperty on a global * refactor(core): cut scene swap comments to what names and types do not say * fix(core): find the head tag for the scene manifest in linear time * fix(core): leave a document with an unclosed head tag without a scene manifest * fix(core): keep preview scene parts in render order and refuse scene-only script URLs * fix(core): keep render @import precedence in the preview and swap only known libraries * fix(core): keep preview scene styles where a render merges them and resolve library URLs * fix(core): scene swap refuses standalone gsap tweens and root scripts that hold scene nodes * fix(core): refuse to swap exactly the scenes a script outside them selects * perf(core): the preview scene scan looks up ids and classes once instead of querying per string * docs(core): trim three scene-parts comments to the facts a reader needs * fix(core): refuse to swap a scene a script outside it reaches by tag name * fix(core): keep scenes swappable when a script outside them only creates elements by tag * test(core): check scene tags on elements, not in the inlined runtime's text * fix(core): refuse swaps for chained scene timelines and any-case or universal root selectors * fix(core): refuse swaps for root selectors through tags above the scenes * fix(core): put the scene manifest inside the head when a head attribute holds a quoted > * fix(core): place the scene manifest with the shared head scanner instead of a regex
This commit is contained in:
@@ -2036,6 +2036,439 @@ describe("bundleToSingleHtml script order", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleToSingleHtml sceneParts", () => {
|
||||
const film = () =>
|
||||
makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="2" data-duration="2"></div>
|
||||
</div>
|
||||
<script>window.__rootRan = true;</script>
|
||||
</body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a">
|
||||
<style>.a-text { color: red; }</style><p class="a-text">A</p>
|
||||
<div data-composition-id="n" data-composition-src="compositions/n.html"></div>
|
||||
<script>window.__aRan = true;</script>
|
||||
</div></template>`,
|
||||
"compositions/n.html": `<template id="n-template"><div data-composition-id="n">
|
||||
<style>.n-text { color: blue; }</style><p class="n-text">N</p><script>window.__nRan = true;</script>
|
||||
</div></template>`,
|
||||
"compositions/b.html": `<template id="b-template"><div data-composition-id="b">
|
||||
<style>.b-text { color: green; }</style><p class="b-text">B</p><script>window.__bRan = true;</script>
|
||||
</div></template>`,
|
||||
});
|
||||
const partsOf = (doc: Document, scene: string) =>
|
||||
[...doc.querySelectorAll(`[data-hf-scene="${scene}"]`)].map((el) => el.tagName.toLowerCase());
|
||||
|
||||
it("tags each top-level scene's host, styles and scripts, with nested scenes in their parent's parts", async () => {
|
||||
const doc = parseHTML(await bundleToSingleHtml(film(), { sceneParts: true })).document;
|
||||
// The nested scene is reached after b, so a's parts come in two runs around b's.
|
||||
expect(partsOf(doc, "a").sort()).toEqual(["div", "script", "script", "style", "style"]);
|
||||
expect(partsOf(doc, "b").sort()).toEqual(["div", "script", "style"]);
|
||||
expect(partsOf(doc, "n")).toEqual([]);
|
||||
const textOf = (selector: string) =>
|
||||
[...doc.querySelectorAll(selector)].map((el) => el.textContent ?? "").join("\n");
|
||||
const aStyle = textOf('style[data-hf-scene="a"]');
|
||||
const aScript = textOf('script[data-hf-scene="a"]');
|
||||
expect(aStyle).toContain("a-text");
|
||||
expect(aStyle).toContain("n-text");
|
||||
expect(aScript).toContain("__aRan");
|
||||
expect(aScript).toContain("__nRan");
|
||||
expect(aScript).not.toContain("__bRan");
|
||||
const shared = [
|
||||
...doc.querySelectorAll("style:not([data-hf-scene]), script:not([data-hf-scene])"),
|
||||
]
|
||||
.map((el) => el.textContent ?? "")
|
||||
.join("\n");
|
||||
expect(shared).not.toMatch(/a-text|b-text|__aRan|__bRan/);
|
||||
expect(shared).toContain("__rootRan");
|
||||
});
|
||||
|
||||
it("emits styles and scripts in render order, a nested scene, an @import and root variables included", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head><style>.root-text { color: black; }</style></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"
|
||||
data-variable-values='{"accent":"blue"}'></div>
|
||||
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="2" data-duration="2"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"compositions/a.html": `<html data-composition-variables='[{"id":"accent","type":"string","label":"Accent","default":"red"}]'><body>
|
||||
<template id="a-template"><div data-composition-id="a">
|
||||
<style>@keyframes pulse { to { opacity: 0.1; } }</style><p>A</p>
|
||||
<div data-composition-id="n" data-composition-src="compositions/n.html"></div>
|
||||
<script>window.__order = ["a"];</script>
|
||||
</div></template></body></html>`,
|
||||
"compositions/n.html": `<template id="n-template"><div data-composition-id="n">
|
||||
<style>@keyframes pulse { to { opacity: 0.3; } }</style><p>N</p><script>window.__order.push("n");</script>
|
||||
</div></template>`,
|
||||
"compositions/b.html": `<template id="b-template"><div data-composition-id="b">
|
||||
<link rel="stylesheet" href="https://fonts.example.com/b.css">
|
||||
<style>@import url("data:text/css,@keyframes%20pulse%7Bto%7Bopacity:0.9%7D%7D");
|
||||
@keyframes pulse { to { opacity: 0.2; } }</style><p>B</p><script>window.__order.push("b");</script>
|
||||
</div></template>`,
|
||||
});
|
||||
const order = (html: string) => {
|
||||
const doc = parseHTML(html).document;
|
||||
const text = (sel: string) =>
|
||||
[...doc.querySelectorAll(sel)].map((el) => el.textContent ?? "");
|
||||
const js = text("script").join("\n");
|
||||
const head = [...doc.head.children].map((el) => el.tagName.toLowerCase());
|
||||
return {
|
||||
head: head.filter((tag, i) => tag !== head[i - 1]),
|
||||
css: text("style").join("").replace(/\s+/g, ""),
|
||||
scripts: ['["a"]', 'push("n")', 'push("b")'].sort((x, y) => js.indexOf(x) - js.indexOf(y)),
|
||||
};
|
||||
};
|
||||
expect(order(await bundleToSingleHtml(dir, { sceneParts: true }))).toEqual(
|
||||
order(await bundleToSingleHtml(dir)),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves renders untagged", async () => {
|
||||
const html = await bundleToSingleHtml(film());
|
||||
expect(
|
||||
parseHTML(html).document.querySelector("[data-hf-scene], [data-hf-scene-no-swap]"),
|
||||
).toBeNull();
|
||||
expect(html).toContain("__aRan");
|
||||
});
|
||||
|
||||
it("puts every @import once at the front of the head, as a render's merged sheet does", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a">
|
||||
<style>.t { color: red; }</style>
|
||||
<div data-composition-id="n" data-composition-src="compositions/n.html"></div></div></template>`,
|
||||
"compositions/n.html": `<template id="n-template"><div data-composition-id="n">
|
||||
<style>@import url("https://fonts.example.com/inter.css"); .n { color: blue; }</style></div></template>`,
|
||||
});
|
||||
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
|
||||
const first = doc.querySelector(
|
||||
"head style:not([data-hf-scene]):not([data-hyperframes-text-rendering])",
|
||||
);
|
||||
expect(
|
||||
first?.textContent?.startsWith('@import url("https://fonts.example.com/inter.css")'),
|
||||
).toBe(true);
|
||||
expect(doc.querySelector('style[data-hf-scene="a"]')?.textContent).not.toContain("@import");
|
||||
expect(styleText(doc.documentElement.outerHTML).match(/@import/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks a scene whose own script leaves work running as not swappable, and only that scene", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.example.com/d-scene.js"></script></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="2" data-duration="2"></div>
|
||||
<div data-composition-id="c" data-composition-src="compositions/c.html" data-start="0" data-duration="2"></div>
|
||||
<div data-composition-id="d" data-composition-src="compositions/d.html" data-start="2" data-duration="2"></div>
|
||||
<div data-composition-id="e" data-composition-src="compositions/e.html" data-start="2" data-duration="2"></div>
|
||||
<div data-composition-id="f" data-composition-src="compositions/f.html" data-start="2" data-duration="2"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/e.html": `<template id="e-template"><div data-composition-id="e"><p>E</p>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/../lottie-web@5/build/player/lottie.min.js"></script></div></template>`,
|
||||
"compositions/f.html": `<template id="f-template"><div data-composition-id="f"><p>F</p>
|
||||
<script>gsap.timeline().to(window.__sharedState, { x: 1 });</script></div></template>`,
|
||||
"compositions/d.html": `<template id="d-template"><div data-composition-id="d"><p>D</p>
|
||||
<script src="https://cdn.example.com/d-scene.js"></script></div></template>`,
|
||||
"compositions/c.html": `<template id="c-template"><div data-composition-id="c"><p>C</p>
|
||||
<script src="c.js"></script></div></template>`,
|
||||
"compositions/c.js": `document.querySelector("p").animate([], 1000);`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><p>A</p>
|
||||
<div data-composition-id="n" data-composition-src="compositions/n.html"></div>
|
||||
<script>window.__timelines = window.__timelines || {};</script></div></template>`,
|
||||
"compositions/n.html": `<template id="n-template"><div data-composition-id="n">
|
||||
<script>window.addEventListener("hf-seek", () => {});</script></div></template>`,
|
||||
"compositions/b.html": `<template id="b-template"><div data-composition-id="b"><p>B</p>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>gsap.timeline({ onComplete: () => {} });</script></div></template>`,
|
||||
});
|
||||
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
|
||||
const host = (id: string) => doc.querySelector(`div[data-hf-scene="${id}"]`);
|
||||
expect(host("a")?.getAttribute("data-hf-scene-no-swap")).toBe(
|
||||
"its script uses addEventListener",
|
||||
);
|
||||
expect(host("b")?.hasAttribute("data-hf-scene-no-swap")).toBe(false);
|
||||
expect(host("c")?.getAttribute("data-hf-scene-no-swap")).toBe(
|
||||
"it runs a script that is not a known library",
|
||||
);
|
||||
// The root loading the same URL does not make a scene's own initializer a library.
|
||||
expect(host("d")?.getAttribute("data-hf-scene-no-swap")).toBe(
|
||||
"it runs a script that is not a known library",
|
||||
);
|
||||
expect(host("e")?.getAttribute("data-hf-scene-no-swap")).toBe(
|
||||
"it runs a script that is not a known library",
|
||||
);
|
||||
// A timeline built and chained in one expression is never registered, so a swap leaves it running.
|
||||
expect(host("f")?.getAttribute("data-hf-scene-no-swap")).toBe(
|
||||
"its script uses gsap.timeline().to(",
|
||||
);
|
||||
const rendered = await bundleToSingleHtml(dir);
|
||||
expect(parseHTML(rendered).document.querySelector("[data-hf-scene-no-swap]")).toBeNull();
|
||||
});
|
||||
|
||||
const rootProject = (root: string, extra: Record<string, string> = {}) =>
|
||||
makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1"></div>
|
||||
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="1" data-duration="1"></div>
|
||||
</div>${root}</body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><button class="go">Go</button><span id="count">0</span><em>!</em></div></template>`,
|
||||
"compositions/b.html": `<template id="b-template"><div data-composition-id="b"><p>B</p></div></template>`,
|
||||
...extra,
|
||||
});
|
||||
const swapMarks = async (dir: string) => {
|
||||
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
|
||||
return ["a", "b"].map((id) =>
|
||||
doc.querySelector(`div[data-hf-scene="${id}"]`)?.getAttribute("data-hf-scene-no-swap"),
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
"binds a listener, inline",
|
||||
`<script>document.querySelector(".go").addEventListener("click", () => {});</script>`,
|
||||
{},
|
||||
".go",
|
||||
],
|
||||
[
|
||||
"binds a listener from a local file",
|
||||
`<script src="root.js"></script>`,
|
||||
{ "root.js": `document.querySelector(".go").onclick = () => {};` },
|
||||
".go",
|
||||
],
|
||||
[
|
||||
"tweens a scene node from the root timeline",
|
||||
`<script>gsap.timeline({ paused: true }).to("#count", { opacity: 0 }, 1);</script>`,
|
||||
{},
|
||||
"#count",
|
||||
],
|
||||
[
|
||||
"binds listeners by tag name",
|
||||
`<script>document.querySelectorAll("em").forEach((e) => e.addEventListener("click", () => {}));</script>`,
|
||||
{},
|
||||
"em",
|
||||
],
|
||||
[
|
||||
"reaches a node by a tag selector",
|
||||
`<script>document.querySelector("div button").onclick = () => {};</script>`,
|
||||
{},
|
||||
"div button",
|
||||
],
|
||||
[
|
||||
"updates a scene node from a timer",
|
||||
`<script>setTimeout(function tick() { document.getElementById("count").textContent++; setTimeout(tick, 100); }, 100);</script>`,
|
||||
{},
|
||||
"count",
|
||||
],
|
||||
[
|
||||
"binds a listener from an inline template's script",
|
||||
`<template id="t-template"><div data-composition-id="t"><script>document.querySelector(".go").addEventListener("click", () => {});</script></div></template><div data-composition-id="t" data-start="0" data-duration="1"></div>`,
|
||||
{},
|
||||
".go",
|
||||
],
|
||||
])("marks the scene a script outside it reaches when it %s", async (_, root, extra, selected) => {
|
||||
expect(await swapMarks(rootProject(root, extra))).toEqual([
|
||||
`a script outside the scene selects ${selected}`,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
const selects = (selector: string) => `a script outside the scene selects ${selector}`;
|
||||
it.each([
|
||||
["an upper-case tag name", "BUTTON", [selects("BUTTON"), null]],
|
||||
["everything", "*", [selects("*"), selects("*")]],
|
||||
["everything, padded", " * ", [selects(" * "), selects(" * ")]],
|
||||
["a tag above the scenes", "body button", [selects("body button"), null]],
|
||||
["everything below a tag above the scenes", "body *", [selects("body *"), selects("body *")]],
|
||||
] as const)(
|
||||
"marks the scenes a script outside them reaches by %s",
|
||||
async (_, selector, marks) => {
|
||||
const root = `<script>document.querySelectorAll("${selector}").forEach((el) => el.normalize());</script>`;
|
||||
expect(await swapMarks(rootProject(root))).toEqual(marks);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps scenes swappable when a script outside them never names their nodes", async () => {
|
||||
const root = `<script>document.addEventListener("click", () => {}); requestAnimationFrame(() => {}); parent.postMessage({ at: Date.now() },
|
||||
"*"); document.querySelectorAll("NAV"); document.getElementById("Count");
|
||||
document.body.append(document.createElement("div"), document.createElementNS("http://www.w3.org/2000/svg", "span"));</script>
|
||||
<script type="application/json">{"note": "addEventListener"}</script>`;
|
||||
expect(await swapMarks(rootProject(root))).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it("runs a scene's local script file in source order with its inline scripts, as a render does", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><p>A</p>
|
||||
<script>window.__assetBase = "compositions/assets/";</script>
|
||||
<script src="reader.js"></script></div></template>`,
|
||||
"compositions/reader.js": `window.__readBase = window.__assetBase;`,
|
||||
});
|
||||
const order = (html: string) => [html.indexOf("__assetBase = "), html.indexOf("__readBase =")];
|
||||
const [setPreview, readPreview] = order(await bundleToSingleHtml(dir, { sceneParts: true }));
|
||||
const [setRender, readRender] = order(await bundleToSingleHtml(dir));
|
||||
expect(setRender).toBeGreaterThan(-1);
|
||||
expect(setRender).toBeLessThan(readRender);
|
||||
expect(setPreview).toBeGreaterThan(-1);
|
||||
expect(setPreview).toBeLessThan(readPreview);
|
||||
});
|
||||
|
||||
it("refuses to swap a scene that runs a module script or an import map", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div data-composition-id="m" data-composition-src="compositions/m.html" data-start="0" data-duration="1"></div>
|
||||
<div data-composition-id="i" data-composition-src="compositions/i.html" data-start="1" data-duration="1"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/m.html": `<template id="m-template"><div data-composition-id="m"><p>M</p>
|
||||
<script type="module">window.__mRan = true;</script></div></template>`,
|
||||
"compositions/i.html": `<template id="i-template"><div data-composition-id="i"><p>I</p>
|
||||
<div data-composition-id="n" data-composition-src="compositions/n.html"></div></div></template>`,
|
||||
"compositions/n.html": `<template id="n-template"><div data-composition-id="n">
|
||||
<script type="importmap">{"imports":{"x":"./x.js"}}</script></div></template>`,
|
||||
});
|
||||
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
|
||||
for (const id of ["m", "i"]) {
|
||||
expect(
|
||||
doc.querySelector(`div[data-hf-scene="${id}"]`)?.getAttribute("data-hf-scene-no-swap"),
|
||||
).toBe("it runs a module script or import map");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to swap a scene for every kind of work its script can leave behind", async () => {
|
||||
const leaks = [
|
||||
'window.addEventListener("resize", f)',
|
||||
"requestAnimationFrame(f)",
|
||||
"requestIdleCallback(f)",
|
||||
"setTimeout(f, 1)",
|
||||
"setInterval(f, 1)",
|
||||
"queueMicrotask(f)",
|
||||
'c.getContext("webgl")',
|
||||
"new WebGLRenderer()",
|
||||
"navigator.gpu",
|
||||
"new Worker(u)",
|
||||
"new Audio(u).play()",
|
||||
"new AudioContext()",
|
||||
"new ResizeObserver(f)",
|
||||
"fetch(u)",
|
||||
'import("x")',
|
||||
"eval(s)",
|
||||
"new Function(s)",
|
||||
"Promise.resolve()",
|
||||
"async function f() {}",
|
||||
"await img.decode()",
|
||||
"d3.json(u).then(f)",
|
||||
"el.animate([], 1000)",
|
||||
"gsap.ticker.add(f)",
|
||||
"gsap.delayedCall(1, f)",
|
||||
"ScrollTrigger.create({})",
|
||||
"lottie.loadAnimation({})",
|
||||
"new THREE.Scene()",
|
||||
"window.__hfLottie = []",
|
||||
"window.onresize = f",
|
||||
'el["onclick"] = f',
|
||||
"document.head.appendChild(s)",
|
||||
"document.body.append(s)",
|
||||
"new WebGPURenderer()",
|
||||
'customElements.define("x-a", A)',
|
||||
"CSS.registerProperty(p)",
|
||||
"new WebSocket(u)",
|
||||
"new EventSource(u)",
|
||||
"Draggable.create(el)",
|
||||
"anime({ loop: true })",
|
||||
'document.documentElement.style.setProperty("--x", "1")',
|
||||
'document.getElementsByTagName("head")[0]',
|
||||
'document.querySelector("body").append(s)',
|
||||
"gsap.to(el, { x: 1, repeat: -1 })",
|
||||
"onresize = f",
|
||||
'Object.defineProperty(window, "__ready", { value: true })',
|
||||
"matchMedia(q).addListener(f)",
|
||||
"tl.repeat(-1)",
|
||||
"document.fonts.add(face)",
|
||||
"document.adoptedStyleSheets = [sheet]",
|
||||
'history.pushState({}, "", u)',
|
||||
'new BroadcastChannel("c")',
|
||||
"gsap.to(window.__shared, { value: 200, duration: 10 })",
|
||||
];
|
||||
const files: Record<string, string> = {};
|
||||
const hosts = leaks
|
||||
.map(
|
||||
(_, i) =>
|
||||
`<div data-composition-id="s${i}" data-composition-src="compositions/s${i}.html" data-start="0" data-duration="1"></div>`,
|
||||
)
|
||||
.join("\n");
|
||||
files["index.html"] = `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="1">${hosts}</div></body></html>`;
|
||||
leaks.forEach((code, i) => {
|
||||
files[`compositions/s${i}.html`] =
|
||||
`<template id="s${i}-template"><div data-composition-id="s${i}"><p>${i}</p>
|
||||
<script>${code};</script></div></template>`;
|
||||
});
|
||||
const doc = parseHTML(
|
||||
await bundleToSingleHtml(makeTempProject(files), { sceneParts: true }),
|
||||
).document;
|
||||
const unmarked = leaks.filter(
|
||||
(_, i) =>
|
||||
!doc.querySelector(`div[data-hf-scene="s${i}"]`)?.hasAttribute("data-hf-scene-no-swap"),
|
||||
);
|
||||
expect(unmarked).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the shared style's @import first when scene styles are split out", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head><style>.root { color: red; }
|
||||
@import url("https://fonts.example.com/montserrat.css");</style></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><style>.a { color: blue; }</style></div></template>`,
|
||||
});
|
||||
const shared = (html: string) =>
|
||||
[...parseHTML(html).document.querySelectorAll("head style:not([data-hf-scene])")]
|
||||
.map((el) => el.textContent ?? "")
|
||||
.find((css) => css.includes(".root")) ?? "";
|
||||
expect(shared(await bundleToSingleHtml(dir, { sceneParts: true })).startsWith("@import")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shared(await bundleToSingleHtml(dir)).startsWith("@import")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not refuse a scene for words that only look like side effects", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="1">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1"></div>
|
||||
</div></body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><p id="nt-ticker-track">A</p>
|
||||
<script>
|
||||
// animate the title in, then hold
|
||||
const tl = gsap.timeline({ paused: true, onComplete: () => {} });
|
||||
tl.to("#nt-ticker-track", { x: 10, className: "fade-animate" });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["a"] = tl;
|
||||
const state = {};
|
||||
Object.defineProperty(state, "flap", { get: () => 1 });
|
||||
</script></div></template>`,
|
||||
});
|
||||
const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document;
|
||||
expect(
|
||||
doc.querySelector('div[data-hf-scene="a"]')?.getAttribute("data-hf-scene-no-swap"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampHfIds", () => {
|
||||
const project = () =>
|
||||
makeTempProject({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inlineScriptRuns } from "./scriptRuns";
|
||||
import { SCENE_PART_ATTR } from "../sceneParts";
|
||||
import {
|
||||
ensureExternalScriptTag,
|
||||
readExternalScriptAttributes,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
import { readDeclaredDefaults } from "../runtime/getVariables";
|
||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||
import { inlineSubCompositions, refuseSwapsReachedByRootScripts } from "./inlineSubCompositions";
|
||||
import { queryByAttr } from "../utils/cssSelector";
|
||||
import { isSafePath, resolveWithinProject } from "../safePath.js";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
@@ -656,34 +657,67 @@ function autoHealMissingCompositionIds(document: Document): void {
|
||||
}
|
||||
}
|
||||
|
||||
function coalesceHeadStylesAndBodyScripts(document: Document): void {
|
||||
const headStyleEls = [...document.querySelectorAll("head style")];
|
||||
if (headStyleEls.length > 1) {
|
||||
const imports: string[] = [];
|
||||
const cssParts: string[] = [];
|
||||
const seenImports = new Set<string>();
|
||||
for (const el of headStyleEls) {
|
||||
const raw = (el.textContent || "").trim();
|
||||
if (!raw) continue;
|
||||
const nonImportCss = raw.replace(CSS_IMPORT_RE, (match) => {
|
||||
const cleaned = match.trim();
|
||||
if (!seenImports.has(cleaned)) {
|
||||
seenImports.add(cleaned);
|
||||
imports.push(cleaned);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const trimmed = nonImportCss.trim();
|
||||
if (trimmed) cssParts.push(trimmed);
|
||||
}
|
||||
const merged = [...imports, ...cssParts].join("\n\n").trim();
|
||||
if (merged) {
|
||||
headStyleEls[0]!.textContent = merged;
|
||||
for (let i = 1; i < headStyleEls.length; i++) headStyleEls[i]!.remove();
|
||||
}
|
||||
/** Join stylesheets into one, moving every distinct `@import` to the front, where CSS allows it. */
|
||||
function joinCssHoistingImports(sheets: string[]): string {
|
||||
const imports = new Set<string>();
|
||||
const cssParts: string[] = [];
|
||||
for (const sheet of sheets) {
|
||||
const rest = sheet.trim().replace(CSS_IMPORT_RE, (match) => {
|
||||
imports.add(match.trim());
|
||||
return "";
|
||||
});
|
||||
if (rest.trim()) cssParts.push(rest.trim());
|
||||
}
|
||||
return [...imports, ...cssParts].join("\n\n").trim();
|
||||
}
|
||||
|
||||
const isPinned = (el: Element) => el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR);
|
||||
// A render joins every head style into one sheet at the first one's place, each distinct @import first.
|
||||
function placeSceneStylesLikeRender(document: Document): void {
|
||||
const styles = [...document.querySelectorAll("head style")];
|
||||
const imports = new Set<string>();
|
||||
for (const el of styles) {
|
||||
el.textContent = (el.textContent || "")
|
||||
.replace(CSS_IMPORT_RE, (match) => (imports.add(match.trim()), ""))
|
||||
.trim();
|
||||
}
|
||||
styles.slice(1).reduce((previous, el) => (previous.after(el), el), styles[0]!);
|
||||
if (imports.size === 0) return;
|
||||
const hoisted = [...imports].join("\n\n");
|
||||
const first = styles[0]!;
|
||||
if (!first.hasAttribute(SCENE_PART_ATTR)) {
|
||||
first.textContent = [hoisted, first.textContent].filter(Boolean).join("\n\n");
|
||||
return;
|
||||
}
|
||||
const holder = document.createElement("style");
|
||||
holder.textContent = hoisted;
|
||||
first.before(holder);
|
||||
}
|
||||
|
||||
type PartRun<T> = { scene?: string; chunks: T[] };
|
||||
|
||||
function pushRun<T>(runs: PartRun<T>[], scene: string | undefined, chunk: T): void {
|
||||
const last = runs.at(-1);
|
||||
if (last && last.scene === scene) last.chunks.push(chunk);
|
||||
else runs.push({ scene, chunks: [chunk] });
|
||||
}
|
||||
|
||||
function coalesceHeadStylesAndBodyScripts(document: Document): void {
|
||||
const allHeadStyles = [...document.querySelectorAll("head style")];
|
||||
const untaggedRuns: Element[][] = [[]];
|
||||
for (const el of allHeadStyles) {
|
||||
if (el.hasAttribute(SCENE_PART_ATTR)) untaggedRuns.push([]);
|
||||
else untaggedRuns.at(-1)!.push(el);
|
||||
}
|
||||
for (const run of allHeadStyles.length > 1 ? untaggedRuns : []) {
|
||||
const merged = joinCssHoistingImports(run.map((el) => el.textContent || ""));
|
||||
if (!merged) continue;
|
||||
run[0]!.textContent = merged;
|
||||
for (const el of run.slice(1)) el.remove();
|
||||
}
|
||||
if (untaggedRuns.length > 1) placeSceneStylesLikeRender(document);
|
||||
|
||||
const isPinned = (el: Element) =>
|
||||
el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR) || el.hasAttribute(SCENE_PART_ATTR);
|
||||
for (const { members, anchor } of inlineScriptRuns(
|
||||
[...document.querySelectorAll("body script")],
|
||||
isPinned,
|
||||
@@ -790,6 +824,8 @@ export interface BundleOptions {
|
||||
* `inlineColorGradingLuts` narrows LUTs further; it cannot inline a LUT this option excluded.
|
||||
*/
|
||||
inlineAssets?: boolean;
|
||||
/** Preview only: tag each scene's host, styles and scripts (`data-hf-scene`) so one can be swapped. */
|
||||
sceneParts?: boolean;
|
||||
/** Warn when the compiled HTML breaks the HyperFrames contract (default true). */
|
||||
staticGuard?: boolean;
|
||||
}
|
||||
@@ -969,6 +1005,20 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// Read before sub-compositions add theirs: only the root's own scripts can reach into scenes.
|
||||
const rootScripts = options?.sceneParts
|
||||
? [
|
||||
...document.querySelectorAll(`script:not([${RUNTIME_BOOTSTRAP_ATTR}])`),
|
||||
...[...document.querySelectorAll("template")].flatMap((t) => [
|
||||
...(t as HTMLTemplateElement).content.querySelectorAll("script"),
|
||||
]),
|
||||
].map((el) => {
|
||||
const src = el.getAttribute("src");
|
||||
const path = src && isRelativeUrl(src) ? resolveEntryPath(src) : null;
|
||||
return src ? (path && safeReadFile(path)) || "" : el.textContent || "";
|
||||
})
|
||||
: [];
|
||||
|
||||
// Inline sub-compositions (via shared function)
|
||||
const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
|
||||
const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
|
||||
@@ -985,7 +1035,7 @@ export async function bundleToSingleHtml(
|
||||
parseHtml: parseHTMLContent,
|
||||
hostIdentityMap: hostIdentityByElement,
|
||||
rewriteInlineStyles: true,
|
||||
// A sub-composition's SIBLING assets (`_shared.css` next to it) must be
|
||||
// A sub-composition's SIBLING assets (a stylesheet next to it) must be
|
||||
// re-pointed at its own directory when its content moves to the root
|
||||
// document; project-root refs with no such sibling stay as authored.
|
||||
assetExists: (path: string) => {
|
||||
@@ -993,6 +1043,7 @@ export async function bundleToSingleHtml(
|
||||
return resolved !== null && existsSync(resolved);
|
||||
},
|
||||
flattenInnerRoot: prepareFlattenedInnerRoot,
|
||||
tagScenes: options?.sceneParts === true,
|
||||
readVariableDefaults: readDeclaredDefaults,
|
||||
parseHostVariables: parseHostVariableValues,
|
||||
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
|
||||
@@ -1003,7 +1054,11 @@ export async function bundleToSingleHtml(
|
||||
);
|
||||
},
|
||||
});
|
||||
const compStyleChunks: string[] = [...subCompResult.styles];
|
||||
refuseSwapsReachedByRootScripts(document, rootScripts);
|
||||
const styleRuns: PartRun<string>[] = [];
|
||||
subCompResult.styles.forEach((css, i) => pushRun(styleRuns, subCompResult.styleScenes[i], css));
|
||||
const scriptRuns: PartRun<DeferredScriptChunk>[] = [];
|
||||
const compStyleChunks: string[] = [];
|
||||
const compScriptChunks: DeferredScriptChunk[] = [];
|
||||
const compExternalLinks = [...subCompResult.externalLinks];
|
||||
const compVariablesByComp: Record<string, Record<string, unknown>> = {
|
||||
@@ -1012,7 +1067,7 @@ export async function bundleToSingleHtml(
|
||||
const seenCompScriptSrcs = new Set<string>();
|
||||
for (const scriptItem of subCompResult.scriptItems) {
|
||||
if (scriptItem.kind === "inline") {
|
||||
compScriptChunks.push(scriptItem.content);
|
||||
pushRun(scriptRuns, scriptItem.scene, scriptItem.content);
|
||||
continue;
|
||||
}
|
||||
const extSrc = scriptItem.src;
|
||||
@@ -1027,9 +1082,9 @@ export async function bundleToSingleHtml(
|
||||
const jsPath = resolveEntryPath(extSrc);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js != null) {
|
||||
compScriptChunks.push(() =>
|
||||
preserveLocalScriptIntegrity(document, extSrc, resolveEntryPath) ? "" : js,
|
||||
);
|
||||
const chunk = () =>
|
||||
preserveLocalScriptIntegrity(document, extSrc, resolveEntryPath) ? "" : js;
|
||||
pushRun(scriptRuns, scriptItem.scene, chunk);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1198,21 +1253,26 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
}
|
||||
|
||||
if (compStyleChunks.length) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = compStyleChunks.join("\n\n");
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
for (const css of compStyleChunks) pushRun(styleRuns, undefined, css);
|
||||
for (const chunk of compScriptChunks) pushRun(scriptRuns, undefined, chunk);
|
||||
const variablesByCompScript = buildVariablesByCompScript(compVariablesByComp);
|
||||
if (variablesByCompScript) {
|
||||
compScriptChunks.unshift(variablesByCompScript);
|
||||
if (scriptRuns[0] && !scriptRuns[0].scene) scriptRuns[0].chunks.unshift(variablesByCompScript);
|
||||
else scriptRuns.unshift({ chunks: [variablesByCompScript] });
|
||||
}
|
||||
if (compScriptChunks.length) {
|
||||
const compScript = document.createElement("script");
|
||||
compScript.textContent = joinJsChunks(
|
||||
compScriptChunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk())),
|
||||
for (const { scene, chunks } of styleRuns) {
|
||||
const style = document.createElement("style");
|
||||
if (scene) style.setAttribute(SCENE_PART_ATTR, scene);
|
||||
style.textContent = scene ? joinCssHoistingImports(chunks) : chunks.join("\n\n");
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
for (const { scene, chunks } of scriptRuns) {
|
||||
const script = document.createElement("script");
|
||||
if (scene) script.setAttribute(SCENE_PART_ATTR, scene);
|
||||
script.textContent = joinJsChunks(
|
||||
chunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk())),
|
||||
);
|
||||
document.body.appendChild(compScript);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
emitMountedModuleScripts(document, subCompResult.importMaps, subCompResult.moduleScripts);
|
||||
|
||||
|
||||
@@ -220,3 +220,26 @@ describe("htmlDocument helpers", () => {
|
||||
expect(insertBeforeCloseTag('<div><script>"</head>"</script></div>', "head", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTagsAtHeadStart on long adversarial input", () => {
|
||||
const M = 2_000_000;
|
||||
it.each([
|
||||
["an unclosed double quote", `<html data-x="${"a".repeat(M)}`],
|
||||
["an unclosed single quote", `<html data-x='${"a".repeat(M)}`],
|
||||
["many quoted values", `<html ${'"a" '.repeat(M / 4)}`],
|
||||
["many <", "<".repeat(M)],
|
||||
["many <html>", "<html>".repeat(M / 6)],
|
||||
["many comments", `${"<!--x-->".repeat(M / 8)}<head>`],
|
||||
["an unclosed comment", `<!--${"a".repeat(M)}`],
|
||||
["an unclosed comment of <", `<!--${"<".repeat(M)}`],
|
||||
["an unclosed comment of quotes", `<!--${'"'.repeat(M)}`],
|
||||
["alternating quotes", `<html ${`"'`.repeat(M / 2)}`],
|
||||
["leading spaces", `${" ".repeat(M)}x`],
|
||||
["a tag that never closes", `<html ${"a ".repeat(M / 2)}`],
|
||||
["an unclosed tag name", `<${"a".repeat(M)}`],
|
||||
])("stays fast on %s", (_, html) => {
|
||||
const started = performance.now();
|
||||
injectTagsAtHeadStart(html, "<meta>");
|
||||
expect(performance.now() - started).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,8 @@ export {
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
|
||||
export { addScenePartsManifest } from "./scenePartsManifest";
|
||||
|
||||
// Script ordering shared by the bundler and the producer coalescers
|
||||
export { inlineScriptRuns, type InlineScriptRun } from "./scriptRuns";
|
||||
|
||||
|
||||
@@ -33,6 +33,99 @@ import {
|
||||
planCompositionAssembly,
|
||||
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
|
||||
} from "./compositionAssembly";
|
||||
import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts";
|
||||
|
||||
// Anything a scene script can leave running, pending or registered outside its timeline, or that
|
||||
// throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse.
|
||||
const SIDE_EFFECT_RE =
|
||||
/\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.animate\s*\(|\.ticker\b|repeat\s*:\s*-1|\.repeat\s*\(\s*-1|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\bfonts\s*\.\s*add\b|\.on[a-z]+\s*=(?!=)|\bon(resize|scroll|message|key\w+|click|pointer\w+|mouse\w+|wheel|visibilitychange|hashchange|popstate|error|load)\s*=(?!=)|\[\s*["']on[a-z]+["']\s*\]|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|\bgsap\s*\.\s*(?:to|from|fromTo)\s*\(|\bgsap\s*\.\s*timeline\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:to|from|fromTo|set|add|call)\s*\(/;
|
||||
|
||||
// npm packages that only define globals when loaded; lottie-web is absent because it scans the page on load.
|
||||
const SWAP_SAFE_LIBRARY_URL =
|
||||
/^https:\/\/(cdn\.jsdelivr\.net\/npm|unpkg\.com)\/(gsap|three|d3|d3-[a-z-]+|topojson-client|clipper-lib)(@[^/]+)?\//;
|
||||
const isSwapSafeLibrary = (src: string) =>
|
||||
URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href);
|
||||
|
||||
/** Why an authored scene script cannot be swapped out cleanly, or null when it can. */
|
||||
function sceneScriptSwapRefusal(script: string): string | null {
|
||||
const match = SIDE_EFFECT_RE.exec(script);
|
||||
return match ? `its script uses ${match[0].trim()}` : null;
|
||||
}
|
||||
|
||||
// A quoted string in a script, in any of the three quote styles.
|
||||
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g;
|
||||
// Text ending where createElement takes its tag: that string makes a node, it selects none.
|
||||
const CREATES_ELEMENT_RE = /createElement\s*\(\s*$|createElementNS\s*\([^()]*,\s*$/;
|
||||
// A "*" after a comma is a later argument, like postMessage's target origin; selector calls take it first.
|
||||
const isNotASelector = (script: string, at: number, literal: string) =>
|
||||
CREATES_ELEMENT_RE.test(script.slice(Math.max(0, at - 60), at)) ||
|
||||
(literal.trim() === "*" && /,\s*$/.test(script.slice(Math.max(0, at - 20), at)));
|
||||
|
||||
/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would strand it. */
|
||||
export function refuseSwapsReachedByRootScripts(document: Document, rootScripts: string[]): void {
|
||||
const hosts = [...document.querySelectorAll(`[${SCENE_PART_ATTR}]`)];
|
||||
const byName = new Map<string, Set<Element>>();
|
||||
for (const host of hosts) {
|
||||
for (const el of [host, ...host.querySelectorAll("*")]) {
|
||||
for (const name of [
|
||||
el.localName,
|
||||
el.id && `#${el.id}`,
|
||||
...[...el.classList].map((c) => `.${c}`),
|
||||
]) {
|
||||
if (name) byName.set(name, (byName.get(name) ?? new Set()).add(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A selector naming a tag the document lacks cannot match, so it is not worth a query.
|
||||
const namesKnown = (selector: string) =>
|
||||
selector
|
||||
.split(",")
|
||||
.some((alt) =>
|
||||
alt
|
||||
.split(/[\s>+~]+/)
|
||||
.every(
|
||||
(part) =>
|
||||
!/^[a-z][\w-]*$/i.test(part) ||
|
||||
byName.has(part.toLowerCase()) ||
|
||||
document.querySelector(part) !== null,
|
||||
),
|
||||
);
|
||||
const literals = new Set(
|
||||
rootScripts.flatMap((s) =>
|
||||
[...s.matchAll(STRING_LITERAL_RE)]
|
||||
.filter((m) => !isNotASelector(s, m.index, m[2] ?? ""))
|
||||
.map((m) => m[2] ?? ""),
|
||||
),
|
||||
);
|
||||
for (const literal of literals) {
|
||||
const open = hosts.filter((host) => !host.hasAttribute(SCENE_NO_SWAP_ATTR));
|
||||
if (open.length === 0) return;
|
||||
// Tag names match in any case; ids and classes do not.
|
||||
const reached =
|
||||
literal.trim() === "*"
|
||||
? open
|
||||
: /^[A-Za-z_][\w-]*$/.test(literal)
|
||||
? [literal.toLowerCase(), `#${literal}`, `.${literal}`].flatMap((name) => [
|
||||
...(byName.get(name) ?? []),
|
||||
])
|
||||
: /[#.[:>]|[\w\]*] *[\s,]+ *[\w*]/.test(literal) &&
|
||||
literal.length <= 120 &&
|
||||
namesKnown(literal)
|
||||
? open.filter((host) => reaches(host, literal))
|
||||
: [];
|
||||
for (const host of reached)
|
||||
if (!host.hasAttribute(SCENE_NO_SWAP_ATTR))
|
||||
host.setAttribute(SCENE_NO_SWAP_ATTR, `a script outside the scene selects ${literal}`);
|
||||
}
|
||||
}
|
||||
|
||||
const reaches = (host: Element, selector: string) => {
|
||||
try {
|
||||
return host.matches(selector) || host.querySelector(selector) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interface
|
||||
@@ -132,15 +225,19 @@ export interface InlineSubCompositionsOptions {
|
||||
* Defaults to `console.warn`.
|
||||
*/
|
||||
onMissingComposition?: (srcPath: string, reason?: string) => void;
|
||||
/** Tag each top-level host with `data-hf-scene` and report every part's scene, for preview swaps. */
|
||||
tagScenes?: boolean;
|
||||
}
|
||||
|
||||
export interface InlineSubCompositionsResult {
|
||||
styles: string[];
|
||||
/** With `tagScenes`: the scene each entry of `styles` belongs to. */
|
||||
styleScenes: string[];
|
||||
scripts: string[];
|
||||
externalScriptSrcs: string[];
|
||||
scriptItems: Array<
|
||||
| { kind: "inline"; content: string }
|
||||
| ({ kind: "external"; src: string } & ExternalScriptAttributes)
|
||||
| { kind: "inline"; content: string; scene?: string }
|
||||
| ({ kind: "external"; src: string; scene?: string } & ExternalScriptAttributes)
|
||||
>;
|
||||
externalLinks: { href: string; rel: string; crossorigin?: string }[];
|
||||
variablesByComp: Record<string, Record<string, unknown>>;
|
||||
@@ -197,9 +294,11 @@ export function inlineSubCompositions(
|
||||
scriptErrorLabel = "[HyperFrames] composition script error:",
|
||||
onMissingComposition,
|
||||
assetExists,
|
||||
tagScenes = false,
|
||||
} = options;
|
||||
|
||||
const styles: string[] = [];
|
||||
const styleScenes: string[] = [];
|
||||
const scripts: string[] = [];
|
||||
const externalScriptSrcs: string[] = [];
|
||||
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
|
||||
@@ -209,9 +308,14 @@ export function inlineSubCompositions(
|
||||
const seenLinkHrefs = new Set<string>();
|
||||
const variablesByComp: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const queue = hosts.map((element) => ({ element, ancestry: [] as string[] }));
|
||||
const sceneHosts = new Map<string, Element>();
|
||||
const queue = hosts.map((element) => ({
|
||||
element,
|
||||
ancestry: [] as string[],
|
||||
scene: undefined as string | undefined,
|
||||
}));
|
||||
for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
|
||||
const { element: hostEl, ancestry } = queue[queueIndex]!;
|
||||
const { element: hostEl, ancestry, scene: parentScene } = queue[queueIndex]!;
|
||||
const src = hostEl.getAttribute("data-composition-src");
|
||||
if (!src) continue;
|
||||
|
||||
@@ -276,6 +380,18 @@ export function inlineSubCompositions(
|
||||
const scopeCompId = plan.authoredCompositionId || "";
|
||||
const scriptCompositionId = plan.scriptCompositionId || "";
|
||||
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
|
||||
const scene = tagScenes ? (parentScene ?? (runtimeCompId || src)) : undefined;
|
||||
if (scene && !parentScene) {
|
||||
hostEl.setAttribute(SCENE_PART_ATTR, scene);
|
||||
sceneHosts.set(scene, hostEl);
|
||||
}
|
||||
// Lazy, so renders (no scene hosts) never run the refusal checks.
|
||||
const refuseSwap = (why: () => string | null) => {
|
||||
const host = scene ? sceneHosts.get(scene) : undefined;
|
||||
if (!host || host.hasAttribute(SCENE_NO_SWAP_ATTR)) return;
|
||||
const reason = why();
|
||||
if (reason) host.setAttribute(SCENE_NO_SWAP_ATTR, reason);
|
||||
};
|
||||
|
||||
// Variable merging (bundler feature). Read declared defaults from the
|
||||
// document element (full-document sub-comps) AND the inner composition root
|
||||
@@ -340,15 +456,24 @@ export function inlineSubCompositions(
|
||||
// (GSAP from a CDN) has to run before the content scripts calling into it.
|
||||
for (const styleEl of plan.styleSources) {
|
||||
styles.push(scopeSubStyle(styleEl.textContent || ""));
|
||||
if (scene) styleScenes.push(scene);
|
||||
styleEl.remove();
|
||||
}
|
||||
|
||||
// Head- and content-sourced scripts take the same branch. The head loop
|
||||
// used to handle only `src`, so an inline <head> script was silently
|
||||
// discarded on render while the mount path executed it.
|
||||
// Head- and content-sourced scripts take the same branch.
|
||||
for (const scriptEl of plan.scriptSources) {
|
||||
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
|
||||
const type = (scriptEl.getAttribute("type") || "").trim().toLowerCase();
|
||||
// A swap never re-runs external or module scripts: only a known library that just defines globals is safe.
|
||||
refuseSwap(() =>
|
||||
type === "importmap" || type === "module"
|
||||
? "it runs a module script or import map"
|
||||
: !externalSrc
|
||||
? sceneScriptSwapRefusal(scriptEl.textContent || "")
|
||||
: isSwapSafeLibrary(externalSrc)
|
||||
? null
|
||||
: "it runs a script that is not a known library",
|
||||
);
|
||||
if (type === "importmap") {
|
||||
const map = parseImportMap(scriptEl.textContent || "", (url) => {
|
||||
// The rebase drops a leading "./" and a trailing "/"; an import map address needs both.
|
||||
@@ -375,6 +500,7 @@ export function inlineSubCompositions(
|
||||
kind: "external",
|
||||
src: externalSrc,
|
||||
...readExternalScriptAttributes(scriptEl),
|
||||
...(scene ? { scene } : {}),
|
||||
});
|
||||
} else {
|
||||
const wrappedScript = scriptCompositionId
|
||||
@@ -389,7 +515,7 @@ export function inlineSubCompositions(
|
||||
)
|
||||
: wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
|
||||
scripts.push(wrappedScript);
|
||||
scriptItems.push({ kind: "inline", content: wrappedScript });
|
||||
scriptItems.push({ kind: "inline", content: wrappedScript, ...(scene ? { scene } : {}) });
|
||||
}
|
||||
scriptEl.remove();
|
||||
}
|
||||
@@ -484,12 +610,13 @@ export function inlineSubCompositions(
|
||||
onMissingComposition?.(skipped.src, skipped.reason);
|
||||
}
|
||||
for (const nestedHost of nested.hosts) {
|
||||
queue.push({ element: nestedHost.host, ancestry: nestedAncestry });
|
||||
queue.push({ element: nestedHost.host, ancestry: nestedAncestry, scene });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
styles,
|
||||
styleScenes,
|
||||
scripts,
|
||||
externalScriptSrcs,
|
||||
scriptItems,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { addScenePartsManifest } from "./scenePartsManifest";
|
||||
|
||||
const doc = (sceneA: string, root: string, signature = "1") => `<!doctype html><html><head>
|
||||
<meta name="sig" content="${signature}"><style>.root{}</style><style data-hf-scene="a">${sceneA}</style>
|
||||
</head><body><div data-composition-id="main">${root}
|
||||
<div data-composition-id="a" data-hf-scene="a"><p>${sceneA}</p></div>
|
||||
<div data-composition-id="b" data-hf-scene="b"><p>B</p></div></div>
|
||||
<script data-hf-scene="a">/* ${sceneA} */</script></body></html>`;
|
||||
|
||||
const manifestOf = (html: string) =>
|
||||
JSON.parse(
|
||||
parseHTML(html)
|
||||
.document.querySelector('meta[name="hf-scene-parts"]')
|
||||
?.getAttribute("content") ?? "",
|
||||
);
|
||||
|
||||
describe("addScenePartsManifest", () => {
|
||||
it("changes only the edited scene's hash when only that scene changes", () => {
|
||||
const before = manifestOf(
|
||||
addScenePartsManifest(doc("one", "<h1>t</h1>"), ['meta[name="sig"]']),
|
||||
);
|
||||
const after = manifestOf(
|
||||
addScenePartsManifest(doc("two", "<h1>t</h1>", "2"), ['meta[name="sig"]']),
|
||||
);
|
||||
expect(Object.keys(before.scenes).sort()).toEqual(["a", "b"]);
|
||||
expect(after.shared).toBe(before.shared);
|
||||
expect(after.scenes.b).toBe(before.scenes.b);
|
||||
expect(after.scenes.a).not.toBe(before.scenes.a);
|
||||
});
|
||||
|
||||
it("changes the shared hash for any change outside the scenes, including an unignored one", () => {
|
||||
const base = manifestOf(addScenePartsManifest(doc("one", "<h1>t</h1>"), ['meta[name="sig"]']));
|
||||
const root = manifestOf(addScenePartsManifest(doc("one", "<h1>u</h1>"), ['meta[name="sig"]']));
|
||||
const sig = manifestOf(addScenePartsManifest(doc("one", "<h1>t</h1>", "2")));
|
||||
expect(root.shared).not.toBe(base.shared);
|
||||
expect(root.scenes).toEqual(base.scenes);
|
||||
expect(sig.shared).not.toBe(manifestOf(addScenePartsManifest(doc("one", "<h1>t</h1>"))).shared);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a double-quoted >", '<html><head data-title="a>b">'],
|
||||
["a single-quoted >", "<html><head data-title='a>b'>"],
|
||||
[
|
||||
"an earlier <head> in an html attribute and a comment",
|
||||
'<html data-x="<head>"><!-- <head> --><head>',
|
||||
],
|
||||
])("adds the manifest inside the head when the page holds %s", (_, opening) => {
|
||||
const html = doc("one", "<h1>t</h1>").replace("<html><head>", opening);
|
||||
const { document } = parseHTML(addScenePartsManifest(html));
|
||||
expect(document.head.querySelector('meta[name="hf-scene-parts"]')).not.toBeNull();
|
||||
expect(document.head.querySelector('style[data-hf-scene="a"]')).not.toBeNull();
|
||||
expect(document.body.textContent).not.toMatch(/b["']>|head>/);
|
||||
});
|
||||
|
||||
it("adds the manifest right after a head tag with attributes when a script precedes the doctype", () => {
|
||||
const html = `<script src="gsap.js"></script>${doc("one", "<h1>t</h1>").replace("<head>", '<head lang="en">')}`;
|
||||
expect(addScenePartsManifest(html)).toContain('<head lang="en">\n<meta name="hf-scene-parts"');
|
||||
});
|
||||
|
||||
it("leaves a document without scene parts untouched", () => {
|
||||
const html = "<!doctype html><html><head></head><body><p>x</p></body></html>";
|
||||
expect(addScenePartsManifest(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("puts the manifest right after a head tag with attributes, in linear time", () => {
|
||||
const withLang = addScenePartsManifest(doc("one", "").replace("<head>", '<head lang="en">'));
|
||||
const head = parseHTML(withLang).document.head;
|
||||
expect([head.getAttribute("lang"), head.firstElementChild?.getAttribute("name")]).toEqual([
|
||||
"en",
|
||||
"hf-scene-parts",
|
||||
]);
|
||||
const unclosed = `<body><div data-hf-scene="a"></div><head `;
|
||||
expect(addScenePartsManifest(unclosed)).toMatch(/^<meta name="hf-scene-parts"[^>]*>\n<body>/);
|
||||
const started = performance.now();
|
||||
addScenePartsManifest(`<body><div data-hf-scene="a"></div>${"<head ".repeat(40_000)}`);
|
||||
expect(performance.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("changes the shared hash when scenes are reordered", () => {
|
||||
const page = (first: string, second: string) => `<html><head></head><body><div id="main">
|
||||
<div data-hf-scene="${first}"><p>${first}</p></div><div data-hf-scene="${second}"><p>${second}</p></div>
|
||||
</div></body></html>`;
|
||||
const ab = manifestOf(addScenePartsManifest(page("a", "b")));
|
||||
const ba = manifestOf(addScenePartsManifest(page("b", "a")));
|
||||
expect(ba.scenes).toEqual(ab.scenes);
|
||||
expect(ba.shared).not.toBe(ab.shared);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { SCENE_PART_ATTR, SCENE_PARTS_META, type SceneParts } from "../sceneParts";
|
||||
import { injectTagsAtHeadStart, parseHTMLContent } from "./htmlDocument";
|
||||
|
||||
const hash = (text: string) => createHash("sha1").update(text).digest("hex").slice(0, 16);
|
||||
|
||||
/**
|
||||
* Adds a {@link SceneParts} manifest so the runtime can tell a rebuild differs only inside scenes.
|
||||
* `ignore` lists selectors for parts that change on every build without changing what is shown.
|
||||
*/
|
||||
export function addScenePartsManifest(html: string, ignore: readonly string[] = []): string {
|
||||
const doc = parseHTMLContent(html);
|
||||
const parts = [...doc.querySelectorAll(`[${SCENE_PART_ATTR}]`)];
|
||||
if (parts.length === 0) return html;
|
||||
const byScene = new Map<string, string[]>();
|
||||
for (const el of parts) {
|
||||
const scene = el.getAttribute(SCENE_PART_ATTR) ?? "";
|
||||
byScene.set(scene, [...(byScene.get(scene) ?? []), el.outerHTML]);
|
||||
}
|
||||
// A marker keeps each part's place in `shared`, so moving or reordering a scene changes it.
|
||||
for (const el of parts)
|
||||
el.replaceWith(doc.createComment(`hf-scene:${el.getAttribute(SCENE_PART_ATTR)}`));
|
||||
for (const el of ignore.flatMap((sel) => [...doc.querySelectorAll(sel)])) el.remove();
|
||||
const manifest: SceneParts = {
|
||||
shared: hash(doc.toString()),
|
||||
scenes: Object.fromEntries([...byScene].map(([scene, html]) => [scene, hash(html.join("\n"))])),
|
||||
};
|
||||
const content = JSON.stringify(manifest).replace(/&/g, "&").replace(/"/g, """);
|
||||
return injectTagsAtHeadStart(html, `<meta name="${SCENE_PARTS_META}" content="${content}">`);
|
||||
}
|
||||
@@ -236,6 +236,48 @@ describe("registerPreviewRoutes", () => {
|
||||
expect(html).toContain("compositions/scene.html");
|
||||
});
|
||||
|
||||
it("serves scene parts with a manifest whose shared hash ignores the project signature", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
mkdirSync(join(projectDir, "compositions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html><html><head></head><body><div data-composition-id="main" data-width="1280" data-height="720" data-duration="2">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div></div></body></html>`,
|
||||
);
|
||||
const scene = (text: string) =>
|
||||
writeFileSync(
|
||||
join(projectDir, "compositions/a.html"),
|
||||
`<template><div data-composition-id="a"><p>${text}</p></div></template>`,
|
||||
);
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(
|
||||
app,
|
||||
createAdapter(projectDir, {
|
||||
bundle: (dir, options) =>
|
||||
bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options }),
|
||||
}),
|
||||
);
|
||||
const served = async () => {
|
||||
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
|
||||
const content = /<meta name="hf-scene-parts" content="([^"]+)">/.exec(html)?.[1] ?? "";
|
||||
const signature = /<meta name="hyperframes-project-signature" content="([^"]+)">/.exec(
|
||||
html,
|
||||
)?.[1];
|
||||
return { html, signature, parts: JSON.parse(content.replace(/"/g, '"')) };
|
||||
};
|
||||
scene("one");
|
||||
const before = await served();
|
||||
scene("two, longer");
|
||||
pastSettleWindow();
|
||||
const after = await served();
|
||||
|
||||
expect(before.html).toContain('data-hf-scene="a"');
|
||||
expect(after.signature).not.toBe(before.signature);
|
||||
expect(after.parts.shared).toBe(before.parts.shared);
|
||||
expect(after.parts.scenes.a).not.toBe(before.parts.scenes.a);
|
||||
});
|
||||
|
||||
it("applies adapter preview transforms to bundled root previews", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const app = new Hono();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Readable } from "node:stream";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
addScenePartsManifest,
|
||||
injectScriptsIntoHtml,
|
||||
insertBeforeCloseTag,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
@@ -318,6 +319,7 @@ export const PREVIEW_BUNDLE_OPTIONS = {
|
||||
runtime: "placeholder",
|
||||
inlineAssets: false,
|
||||
staticGuard: false,
|
||||
sceneParts: true,
|
||||
} as const satisfies BundleOptions;
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
|
||||
@@ -395,6 +397,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
mainCompositionPath,
|
||||
mediaCodecProbeCache,
|
||||
);
|
||||
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
|
||||
rememberPreview(builtKey, bundled);
|
||||
adapter.previewDocuments?.write(builtKey, bundled);
|
||||
return bundled;
|
||||
|
||||
Reference in New Issue
Block a user