diff --git a/packages/core/src/runtime/applyVariableBindings.test.ts b/packages/core/src/runtime/applyVariableBindings.test.ts index 4dabbf0c9b0..11f7a1a57d0 100644 --- a/packages/core/src/runtime/applyVariableBindings.test.ts +++ b/packages/core/src/runtime/applyVariableBindings.test.ts @@ -55,6 +55,35 @@ describe("applyVariableBindings", () => { expect(document.querySelector("img")?.getAttribute("src")).toBe("keep.jpg"); }); + it("does not write a bound text again after a script split it into word spans", () => { + setDeclared([{ id: "title", type: "string", label: "Title", default: "Launch day is here" }]); + document.body.innerHTML = `

Launch day is here

`; + applyVariableBindings(document); + const h1 = document.querySelector("h1")!; + const words = h1.textContent!.split(" "); + h1.replaceChildren( + ...words.map((word) => + Object.assign(document.createElement("span"), { textContent: `${word} ` }), + ), + ); + + applyVariableBindings(document); + + expect(h1.textContent!.trim()).toBe("Launch day is here"); + }); + + it("writes a bound text again when the parser extended its node after a script removed itself", () => { + setDeclared([{ id: "title", type: "string", label: "Title", default: "Bound" }]); + document.body.innerHTML = `

Bound

`; + applyVariableBindings(document); + const h1 = document.querySelector("h1")!; + (h1.firstChild as Text).appendData(" suffix"); + + applyVariableBindings(document); + + expect(h1.textContent).toBe("Bound"); + }); + it("sets text content from a scalar via data-var-text", () => { setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]); win.__hfVariables = { title: "Overridden" }; diff --git a/packages/core/src/runtime/applyVariableBindings.ts b/packages/core/src/runtime/applyVariableBindings.ts index a84ba1cc544..bb99ab58547 100644 --- a/packages/core/src/runtime/applyVariableBindings.ts +++ b/packages/core/src/runtime/applyVariableBindings.ts @@ -32,7 +32,7 @@ import { isScalarVariableValue as isScalar, isSafeMediaUrl, } from "@hyperframes/parsers/composition"; -import { isHtmlElement } from "./domRealm"; +import { isElementNode, isHtmlElement } from "./domRealm"; import { unproxiedSrc } from "./proxySrc"; // data-var-src only rebinds media `src` on media elements. A user-controlled @@ -101,20 +101,19 @@ function valuesForElement(el: Element, cache: ScopeValuesCache): Record(); + +/** The runtime emptied `el` (a composition host remount), so its bound text must be written again. */ +export function forgetBoundText(el: Element): void { + boundText.delete(el); +} + +function bindText(el: Element, cache: ScopeValuesCache): void { + const id = el.getAttribute("data-var-text")?.trim(); + if (!id) return; + const value = valuesForElement(el, cache)[id]; + if (!isScalar(value)) return; + const text = String(value); + const prior = boundText.get(el); + // The parser extends the node it wrote into; only a node that no longer starts with the text was taken over. + const scriptOwnsText = + prior?.text === text && + prior.node !== null && + !(prior.node.parentNode === el && prior.node.nodeValue?.startsWith(text)); + // A composition script split the text it was given into word spans; writing it again would show it twice. + if (scriptOwnsText) return; + boundText.set(el, { text, node: setOwnTextPreservingChildren(el, text) }); +} + +/** Binds text as the page parses, so composition scripts that split it see the variable's value. */ +export function installEarlyTextBindings(): void { + if (typeof MutationObserver === "undefined" || typeof document === "undefined") return; + const root = document.documentElement; + if (!root) return; + const scan = (node: Node): void => { + if (!isElementNode(node)) return; + const cache: ScopeValuesCache = new Map(); + if (node.matches("[data-var-text]")) bindText(node, cache); + for (const el of node.querySelectorAll("[data-var-text]")) bindText(el, cache); + }; + scan(root); + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) for (const node of mutation.addedNodes) scan(node); + }); + observer.observe(root, { childList: true, subtree: true }); + document.addEventListener("DOMContentLoaded", () => observer.disconnect(), { once: true }); } diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index 2a3f64bea0a..6b51c3ff0fe 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { applyVariableBindings } from "./applyVariableBindings"; +import { getVariables } from "./getVariables"; import { loadCompositions } from "./compositionLoader"; type LoaderParams = Parameters[0]; @@ -1900,3 +1902,34 @@ describe("namespaceMountedSvgIds", () => { expect(host("outer").contains(outerClip) && !inner.contains(outerClip)).toBe(true); }); }); + +describe("variable-bound composition hosts", () => { + afterEach(() => { + document.documentElement.removeAttribute("data-composition-variables"); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__hyperframes"); + }); + + it("restores a host's bound text after mounting its composition clears the host", async () => { + Reflect.set(window, "__hyperframes", { getVariables }); + document.documentElement.setAttribute( + "data-composition-variables", + JSON.stringify([{ id: "title", type: "string", label: "Title", default: "Bound" }]), + ); + document.body.innerHTML = ` +
+
+ +
`; + applyVariableBindings(document); + + await loadFixture(); + applyVariableBindings(document); + + const host = document.getElementById("host")!; + expect(host.textContent).toContain("Bound"); + expect(host.textContent).toContain("child"); + }); +}); diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index 319ae435fb6..f3a5e660269 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -19,6 +19,7 @@ import { namespaceCollidingSvgIds, rewriteSvgIdReferencesInCss, } from "../compiler/svgIdNamespacing"; +import { forgetBoundText } from "./applyVariableBindings"; import { waitForFonts } from "./afterFonts"; import { parseLayoutDimension } from "./compositionDimension"; import { markFlattenedInnerRoot } from "./flattenedRoot"; @@ -211,6 +212,7 @@ const waitForExternalScriptLoad = ( }); function resetCompositionHost(host: Element) { + forgetBoundText(host); while (host.firstChild) { host.removeChild(host.firstChild); } diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 4ccb40216ce..3923574eb84 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -12,6 +12,7 @@ import { fitTextFontSize } from "../text/fitTextFontSize"; import { pretext } from "../text/pretext"; import { assetUrl } from "./assetUrl"; import { getVariables } from "./getVariables"; +import { installEarlyTextBindings } from "./applyVariableBindings"; import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData"; import { runScriptsAfterFonts } from "./afterFonts"; import { AFTER_FONTS_SCRIPTS } from "../compiler/scriptRuns"; @@ -43,6 +44,7 @@ type HyperframeWindow = Window & { // composition's animation scripts (and the grading hide) mutate it — must run // at script evaluation time, while the document is still parsing. installAuthoredOpacityCapture(); +installEarlyTextBindings(); installAuthoredMediaCapture(); installFlatGsapTransforms(); trackAcceleratedCanvases(); diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 6de55082b6a..c4c8cc15efe 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from "vitest" import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { addScenePartsManifest, bundleToSingleHtml } from "@hyperframes/core/compiler"; -import { resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import puppeteer, { type Browser, type Page } from "puppeteer"; import type {} from "../../../core/src/runtime/window"; import { openComposition } from "../../../sdk/src/session"; @@ -742,6 +743,82 @@ describe("core runtime browser contract", () => { 30_000, ); + it.each([ + [undefined, "Launch day is here"], + [{ title: "Doors open at nine" }, "Doors open at nine"], + ])( + "shows a bound title once when the composition splits it into words (overrides %j)", + async (overrides, expected) => { + // A navigation, not setContent: document.write runs scripts with no microtask checkpoint before them. + const file = join(mkdtempSync(join(tmpdir(), "hf-split-title-")), "index.html"); + writeFileSync( + file, + ` + + + + + + +
+

Launch day is here

+
+ + `, + ); + const splitPage = await browser.newPage(); + try { + await splitPage.goto(pathToFileURL(file).href); + await splitPage.waitForFunction(() => window.__playerReady); + + const shown = await splitPage.evaluate(() => ({ + text: document.getElementById("title")!.textContent!.trim(), + words: document.querySelectorAll("#title span").length, + })); + expect(shown).toEqual({ text: expected, words: expected.split(" ").length }); + } finally { + await splitPage.close(); + rmSync(dirname(file), { recursive: true, force: true }); + } + }, + 30_000, + ); + + it("shows only the bound title when the parser adds text after an inert script inside it", async () => { + const file = join(mkdtempSync(join(tmpdir(), "hf-inert-title-")), "index.html"); + writeFileSync( + file, + ` + + + +
+

Fallback suffix

+
+ `, + ); + const inertPage = await browser.newPage(); + try { + await inertPage.goto(pathToFileURL(file).href); + await inertPage.waitForFunction(() => window.__playerReady); + const shown = await inertPage.evaluate(() => + [...document.getElementById("title")!.childNodes] + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.nodeValue) + .join(""), + ); + expect(shown).toBe("Bound"); + } finally { + await inertPage.close(); + rmSync(dirname(file), { recursive: true, force: true }); + } + }, 30_000); + it("renders a later clip's authored lazy image as it is: laid out at setup, fetched before any seek", async () => { // A chunked render starts a worker straight at a later clip, so its image must already be loaded. const assets = "https://assets.test/";