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:
Miguel Ángel
2026-09-26 20:23:13 +00:00
committed by GitHub
parent e0e6442d9c
commit dce0acd6d2
9 changed files with 863 additions and 53 deletions
@@ -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({
+104 -44
View File
@@ -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);
});
});
+2
View File
@@ -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, "&amp;").replace(/"/g, "&quot;");
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(/&quot;/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;