mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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/";
|
||||
|
||||
Reference in New Issue
Block a user