`,
+ });
+ 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 = {}) =>
+ makeTempProject({
+ "index.html": `
+
+
+
+
${root}`,
+ "compositions/a.html": `
0!
`,
+ "compositions/b.html": `
B
`,
+ ...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",
+ ``,
+ {},
+ ".go",
+ ],
+ [
+ "binds a listener from a local file",
+ ``,
+ { "root.js": `document.querySelector(".go").onclick = () => {};` },
+ ".go",
+ ],
+ [
+ "tweens a scene node from the root timeline",
+ ``,
+ {},
+ "#count",
+ ],
+ [
+ "binds listeners by tag name",
+ ``,
+ {},
+ "em",
+ ],
+ [
+ "reaches a node by a tag selector",
+ ``,
+ {},
+ "div button",
+ ],
+ [
+ "updates a scene node from a timer",
+ ``,
+ {},
+ "count",
+ ],
+ [
+ "binds a listener from an inline template's script",
+ ``,
+ {},
+ ".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 = ``;
+ expect(await swapMarks(rootProject(root))).toEqual(marks);
+ },
+ );
+
+ it("keeps scenes swappable when a script outside them never names their nodes", async () => {
+ const root = `
+ `;
+ 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": `
+
+
+
`,
+ "compositions/a.html": `
A
+
+
`,
+ "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": `
+
+
+
+
`,
+ "compositions/m.html": `
M
+
`,
+ "compositions/i.html": `
I
+
`,
+ "compositions/n.html": `
+
`,
+ });
+ 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 = {};
+ const hosts = leaks
+ .map(
+ (_, i) =>
+ ``,
+ )
+ .join("\n");
+ files["index.html"] = `
+
`,
+ "compositions/a.html": ``,
+ });
+ 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": `
+
+
+
`,
+ "compositions/a.html": `
A
+
`,
+ });
+ 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({
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index d401181147..6eab1480ee 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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();
- 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();
+ 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();
+ 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 = { scene?: string; chunks: T[] };
+
+function pushRun(runs: PartRun[], 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[] = [];
+ subCompResult.styles.forEach((css, i) => pushRun(styleRuns, subCompResult.styleScenes[i], css));
+ const scriptRuns: PartRun[] = [];
+ const compStyleChunks: string[] = [];
const compScriptChunks: DeferredScriptChunk[] = [];
const compExternalLinks = [...subCompResult.externalLinks];
const compVariablesByComp: Record> = {
@@ -1012,7 +1067,7 @@ export async function bundleToSingleHtml(
const seenCompScriptSrcs = new Set();
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);
diff --git a/packages/core/src/compiler/htmlDocument.test.ts b/packages/core/src/compiler/htmlDocument.test.ts
index 2f9961832d..c98b00b727 100644
--- a/packages/core/src/compiler/htmlDocument.test.ts
+++ b/packages/core/src/compiler/htmlDocument.test.ts
@@ -220,3 +220,26 @@ describe("htmlDocument helpers", () => {
expect(insertBeforeCloseTag('', "head", "x")).toBeNull();
});
});
+
+describe("injectTagsAtHeadStart on long adversarial input", () => {
+ const M = 2_000_000;
+ it.each([
+ ["an unclosed double quote", `", "".repeat(M / 6)],
+ ["many comments", `${"".repeat(M / 8)}`],
+ ["an unclosed comment", `',
+ ],
+ ])("adds the manifest inside the head when the page holds %s", (_, opening) => {
+ const html = doc("one", "
t
").replace("", 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 = `${doc("one", "
t
").replace("", '')}`;
+ expect(addScenePartsManifest(html)).toContain('\n {
+ const html = "
x
";
+ 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("", ''));
+ const head = parseHTML(withLang).document.head;
+ expect([head.getAttribute("lang"), head.firstElementChild?.getAttribute("name")]).toEqual([
+ "en",
+ "hf-scene-parts",
+ ]);
+ const unclosed = `]*>\n/);
+ const started = performance.now();
+ addScenePartsManifest(`${" {
+ const page = (first: string, second: string) => `
+
${first}
${second}
+
`;
+ 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);
+ });
+});
diff --git a/packages/core/src/compiler/scenePartsManifest.ts b/packages/core/src/compiler/scenePartsManifest.ts
new file mode 100644
index 0000000000..52a9b818ad
--- /dev/null
+++ b/packages/core/src/compiler/scenePartsManifest.ts
@@ -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();
+ 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, ``);
+}
diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts
index 26f8138091..bb3ff1b7b6 100644
--- a/packages/studio-server/src/routes/preview.test.ts
+++ b/packages/studio-server/src/routes/preview.test.ts
@@ -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"),
+ `