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/";