diff --git a/packages/core/src/runtime/applyVariableBindings.test.ts b/packages/core/src/runtime/applyVariableBindings.test.ts
index 4dabbf0c9b..21a27a5d5b 100644
--- a/packages/core/src/runtime/applyVariableBindings.test.ts
+++ b/packages/core/src/runtime/applyVariableBindings.test.ts
@@ -55,6 +55,21 @@ 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")!;
+ h1.innerHTML = h1
+ .textContent!.split(" ")
+ .map((w) => `${w}`)
+ .join(" ");
+
+ applyVariableBindings(document);
+
+ expect(h1.textContent).toBe("Launch day is here");
+ });
+
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 a84ba1cc54..55f1e8ffa3 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
@@ -186,10 +186,38 @@ export function applyVariableBindings(doc: Document, within?: Element): void {
if (url !== null && unproxiedSrc(el) !== url) el.setAttribute("src", url);
}
- for (const el of all("[data-var-text]")) {
- const id = el.getAttribute("data-var-text")?.trim();
- if (!id) continue;
- const value = valuesForElement(el, cache)[id];
- if (isScalar(value)) setOwnTextPreservingChildren(el, String(value));
- }
+ for (const el of all("[data-var-text]")) bindText(el, cache);
+}
+
+const boundText = new WeakMap();
+
+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);
+ // A composition script may have split this text into word spans since it was bound.
+ if (boundText.get(el) === text) return;
+ boundText.set(el, text);
+ 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/entry.ts b/packages/core/src/runtime/entry.ts
index 6f34c13baf..5db5987f9b 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";
@@ -41,6 +42,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();
window.__hfHasFrameSources = hasFrameSources;
diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts
index 664407c7c8..d341032fe8 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 { 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";
@@ -403,6 +404,52 @@ 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("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/";