Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/core/src/runtime/applyVariableBindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ 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")!;
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("sets text content from a scalar via data-var-text", () => {
setDeclared([{ id: "title", type: "string", label: "Title", default: "Hello" }]);
win.__hfVariables = { title: "Overridden" };
Expand Down
64 changes: 50 additions & 14 deletions packages/core/src/runtime/applyVariableBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,20 +101,19 @@ function valuesForElement(el: Element, cache: ScopeValuesCache): Record<string,
* clips, animation-target spans). Mirrors the SDK's setOwnText: write the
* first direct text node, clear the others; append when none exists.
*/
function setOwnTextPreservingChildren(el: Element, text: string): void {
/** Returns the text node now holding `text`, if any. */
function setOwnTextPreservingChildren(el: Element, text: string): Node | null {
if (el.childElementCount === 0) {
el.textContent = text;
return;
return el.firstChild;
}
let written = false;
let written: Node | null = null;
for (const node of Array.from(el.childNodes)) {
if (node.nodeType !== Node.TEXT_NODE) continue;
node.nodeValue = written ? "" : text;
written = true;
}
if (!written) {
el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
written ??= node;
}
return written ?? el.insertBefore(el.ownerDocument.createTextNode(text), el.firstChild);
}

/**
Expand Down Expand Up @@ -186,10 +185,47 @@ 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, { text: string; node: Node | null }>();

/** 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);
const scriptOwnsText =
prior?.text === text &&
prior.node !== null &&
!(prior.node.parentNode === el && prior.node.nodeValue === 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 });
}
33 changes: 33 additions & 0 deletions packages/core/src/runtime/compositionLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof loadCompositions>[0];
Expand Down Expand Up @@ -1877,3 +1879,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 = `
<div data-hf-root data-composition-id="root">
<div id="host" data-composition-id="scene" data-var-text="title"></div>
<template id="scene-template">
<div data-composition-id="scene"><span>child</span></div>
</template>
</div>`;
applyVariableBindings(document);

await loadFixture();
applyVariableBindings(document);

const host = document.getElementById("host")!;
expect(host.textContent).toContain("Bound");
expect(host.textContent).toContain("child");
});
});
2 changes: 2 additions & 0 deletions packages/core/src/runtime/compositionLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -211,6 +212,7 @@ const waitForExternalScriptLoad = (
});

function resetCompositionHost(host: Element) {
forgetBoundText(host);
while (host.firstChild) {
host.removeChild(host.firstChild);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
79 changes: 78 additions & 1 deletion packages/producer/src/services/coreRuntimeBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -403,6 +404,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,
`<!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("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,
`<!doctype html>
<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"Bound"}]'>
<head><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 id="title" data-var-text="title">Fallback<script type="application/json">{}</script> suffix</h1>
</div>
</body></html>`,
);
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/";
Expand Down
Loading