From 44c460987cd02b9ddff8dd3ff8052d866222d87a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 18 Oct 2026 10:36:39 -0400 Subject: [PATCH] fix(core): a variable-bound title split into words shows once data-var-text was applied at init, after composition scripts ran, so a span the script had split into word spans got the full text again beside them. Text bindings now apply as the page parses, before those scripts, so the script splits the variable's value, and a later pass skips an element already bound to the same value. --- .../src/runtime/applyVariableBindings.test.ts | 15 ++++++ .../core/src/runtime/applyVariableBindings.ts | 42 +++++++++++++--- packages/core/src/runtime/entry.ts | 2 + .../src/services/coreRuntimeBrowser.test.ts | 49 ++++++++++++++++++- 4 files changed, 100 insertions(+), 8 deletions(-) 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/";