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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-10-18 11:21:07 -04:00
parent ab2e939558
commit 44c460987c
4 changed files with 100 additions and 8 deletions
@@ -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 = `<div data-hf-root><h1 data-var-text="title">Launch day is here</h1></div>`;
applyVariableBindings(document);
const h1 = document.querySelector("h1")!;
h1.innerHTML = h1
.textContent!.split(" ")
.map((w) => `<span>${w}</span>`)
.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" };
@@ -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<Element, string>();
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 });
}
+2
View File
@@ -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;
@@ -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,
`<!doctype html>
<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"Launch day is here"}]'>
<head>
<script>window.__hfVariables = ${JSON.stringify(overrides ?? {})};</script>
<script>${readFileSync(RUNTIME_PATH, "utf8")}</script>
</head>
<body>
<div data-composition-id="root" data-start="0" data-duration="1" data-width="320" data-height="180">
<h1><span id="title" data-var-text="title">Launch day is here</span></h1>
</div>
<script>
const title = document.getElementById("title");
const words = title.textContent.trim().split(" ");
title.textContent = "";
for (const word of words) title.append(Object.assign(document.createElement("span"), { textContent: word + " " }));
</script>
</body></html>`,
);
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/";