diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 191b3094f4..840d92a0f6 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -2036,6 +2036,439 @@ describe("bundleToSingleHtml script order", () => { }); }); +describe("bundleToSingleHtml sceneParts", () => { + const film = () => + makeTempProject({ + "index.html": ` + +
+
+
+
+ +`, + "compositions/a.html": ``, + "compositions/n.html": ``, + "compositions/b.html": ``, + }); + 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": ` + +
+
+
+
+`, + "compositions/a.html": ` +`, + "compositions/n.html": ``, + "compositions/b.html": ``, + }); + 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": ` +
+
+
`, + "compositions/a.html": ``, + "compositions/n.html": ``, + }); + 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": ` + + +
+
+
+
+
+
+
+
`, + "compositions/e.html": ``, + "compositions/f.html": ``, + "compositions/d.html": ``, + "compositions/c.html": ``, + "compositions/c.js": `document.querySelector("p").animate([], 1000);`, + "compositions/a.html": ``, + "compositions/n.html": ``, + "compositions/b.html": ``, + }); + 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": ``, + "compositions/b.html": ``, + ...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": ``, + "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": ``, + "compositions/i.html": ``, + "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"] = ` +
${hosts}
`; + leaks.forEach((code, i) => { + files[`compositions/s${i}.html`] = + ``; + }); + 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": ` +
+
+
`, + "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": ``, + }); + 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"), + `
+
`, + ); + const scene = (text: string) => + writeFileSync( + join(projectDir, "compositions/a.html"), + ``, + ); + 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 = //.exec(html)?.[1] ?? ""; + const signature = //.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(); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 24ba56c81c..eb378bd755 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -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;