Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fc57485
perf(core): run a far scene's scripts when the playhead nears it
miguel-heygen Oct 16, 2026
4b4c2dc
perf(core): a scene that mounts during playback leaves the clock running
miguel-heygen Oct 16, 2026
8ef62ad
perf(core): keep the scene lead private to the module that uses it
miguel-heygen Oct 16, 2026
368ae64
perf(core): a scene an eager scene reads stays eager, however far out…
miguel-heygen Oct 17, 2026
8b2de0d
perf(core): a scene a root script reads stays eager, however far out …
miguel-heygen Oct 17, 2026
691fc4e
perf(core): a registry read check can't name keeps every scene eager …
miguel-heygen Oct 17, 2026
2ff895a
perf(core): typeof registry reads and renamed scene copies keep their…
miguel-heygen Oct 17, 2026
f9245bf
perf(core): show the first frame without waiting on far scenes' fonts
miguel-heygen Oct 16, 2026
525d817
perf(core): a scene another scene reads runs at boot, before its readers
miguel-heygen Oct 17, 2026
6bbf929
perf(core): let a scene wait when it only looks inside its own root
miguel-heygen Oct 16, 2026
1f8298c
fix(core): preserve held scene dependencies and root timing
miguel-heygen Oct 17, 2026
4a2e06c
perf(core): integrate held-scene boot and font scheduling
miguel-heygen Oct 18, 2026
2de7320
docs(core): state the after-boot scheduling contract once
miguel-heygen Oct 18, 2026
e2f29bb
fix(core): prove indexed scene marks stay scoped
miguel-heygen Oct 18, 2026
8a8cb29
fix(core): fail closed on unproven scene query-list uses
miguel-heygen Oct 18, 2026
f58a6ec
fix(core): keep cross-scene DOM providers ready at boot
miguel-heygen Oct 18, 2026
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
409 changes: 409 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ import { readDeclaredDefaults } from "../runtime/getVariables";
import {
ensureExternalLinkTag,
inlineSubCompositions,
refuseSwapsReachedByRootScripts,
refuseScenesReachedByRootScripts,
keepScenesReadAtBootEager,
} from "./inlineSubCompositions";
import { isSafePath, resolveWithinProject } from "../safePath.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
Expand Down Expand Up @@ -1157,7 +1158,8 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
);
},
});
refuseSwapsReachedByRootScripts(document, rootScripts);
refuseScenesReachedByRootScripts(document, [...rootScripts, ...subCompResult.outsideScripts]);
keepScenesReadAtBootEager(subCompResult.sceneReads, rootScripts);
const styleRuns: PartRun<CompositionStyle>[] = [];
subCompResult.styles.forEach((style, i) =>
pushRun(styleRuns, subCompResult.styleScenes[i], style),
Expand Down
44 changes: 43 additions & 1 deletion packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { ensureExternalLinkTag, inlineSubCompositions } from "./inlineSubCompositions";
import {
ensureExternalLinkTag,
inlineSubCompositions,
registryReads,
} from "./inlineSubCompositions";
import { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
import { JSDOM } from "jsdom";

Expand Down Expand Up @@ -1091,3 +1095,41 @@ describe("SVG namespacing compatibility on current compiler", () => {
dom.window.close();
});
});

describe("registryReads", () => {
it("names the keys a script reads and leaves the registry's own setup and computed writes alone", () => {
const setup =
"window.__timelines = window.__timelines || {}; window.__timelines ??= {}; if (!window.__timelines) {} typeof window.__timelines;";
expect(registryReads(setup)).toEqual({ names: [], everyScene: false });
expect(registryReads(`${setup} window.__timelines[id] = tl;`)).toEqual({
names: [],
everyScene: false,
});
expect(
registryReads(
'window.__timelines["a"] = tl; window.__timelines.b.progress(1); __timelines?.["c"]; __timelines?.d;',
),
).toEqual({ names: ["b", "c", "d"], everyScene: false });
});
it("keeps member reads through comment trivia without treating a bare typeof guard as a dependency", () => {
for (const trivia of [" /* availability */ ", " // availability\n "]) {
expect(registryReads(`typeof window.__timelines${trivia}.b`)).toEqual({
names: ["b"],
everyScene: false,
});
expect(registryReads(`typeof window.__timelines${trivia};`)).toEqual({
names: [],
everyScene: false,
});
}
});
it("reads every scene when a read is not to one named key", () => {
for (const script of [
"Object.keys(window.__timelines)",
"const all = window.__timelines; all.b;",
"window.__timelines[id].progress(1)",
"for (const k in window.__timelines) {}",
])
expect(registryReads(script).everyScene, script).toBe(true);
});
});
187 changes: 172 additions & 15 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
rewriteInlineStyleAssetUrls,
type AssetExists,
} from "./rewriteSubCompPaths";
import { sharedHfMark } from "./scopedNodeMarks";
import { warnUnknownEnumValues } from "../runtime/getVariables";
import {
scopeCssToComposition,
Expand All @@ -45,7 +46,14 @@ import {
planCompositionAssembly,
extractedCompositionAssets,
} from "./compositionAssembly";
import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts";
import {
REACHED_BY_ROOT_REASON,
READ_BY_SCENE_REASON,
SCENE_AFTER_BOOT_ATTR,
SCENE_EAGER_ATTR,
SCENE_NO_SWAP_ATTR,
SCENE_PART_ATTR,
} from "../sceneParts";

// Anything a scene script can leave running, pending or registered outside its timeline, or that
// throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse.
Expand All @@ -64,17 +72,62 @@ function sceneScriptSwapRefusal(script: string): string | null {
return match ? `its script uses ${match[0].trim()}` : null;
}

// What ties a scene script to boot order: reaching outside its root, or leaving state on window for others. Two
// reads stay inside: an `__hf` mark on a node its scoped `document` found (sharedHfMark), and the compiler's own
// `data-composition-variables` off `<html>`, which no script writes. Any other `<html>` attribute is shared.
const REACHES_OUTSIDE_RE =
/\bgetElementsBy\w+\b|\bdocumentElement\b(?!\s*\.\s*(?:get|has)Attribute\s*\(\s*(["'])data-composition-variables\1\s*\))|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\b(window|globalThis|self)\s*(\.\s*(?!__timelines\b)[A-Za-z_$][\w$]*|\[[^\]]*\])\s*=(?!=)/;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve boot order for compound shared-global writes

This branch requires plain = immediately after the global property, so ordinary mutations such as window.bootCount += 1 (also ??=) are treated as self-contained. The actual scoped window proxy forwards ordinary reads/writes to the real window (compositionScoping.ts:449–494); this state is shared, not scene-local.

For a normal non-capture CLI Studio preview with a 60-second root and system fonts, initialize window.bootCount = 0 in a root head script. Put an external, populated scene first in authored/script order at start 30/duration 5, with window.bootCount += 1. Put a near scene second at start 0/duration 5 whose own unique .readout receives String(window.bootCount) through its scoped query. Use matching ordinary roots, no captions/modules/other eager triggers, and no root selectors or registry reads that independently retain the far writer.

At effective BASE, the unpartitioned after-font script list runs the writer before the reader, displaying 1. Here the compound write matches neither this expression nor sharedHfMark; it also never enters outsideScripts, and the literal/registry closure has no shared-global edge. holdFarSceneScripts removes the far writer from boot, so the near scene displays 0. Later mounting runs only the held scene's scripts (init.ts:3965–3979), not the near reader again, leaving its visible text wrong. Plain window.bootCount = 1 is a recognized-eager control.

Conservatively retain boot order for compound/update writes to shared globals and add the beyond-lead writer/near-reader regression, rather than treating the scoped-node proof as global-state isolation. Source-derived BASE/HEAD counterexample through the actual preview/compiler/proxy/runtime path; no browser/runtime test was executed.


/** Why an authored scene script must run at boot rather than when the playhead nears, or null. */
function sceneScriptEagerReason(script: string): string | null {
const match = REACHES_OUTSIDE_RE.exec(script);
const mark = sharedHfMark(script);
const first = mark && (!match || mark.index < match.index) ? mark.text : match?.[0];
return first ? `its script uses ${first.trim()}` : null;
}

// A quoted string in a script, in any of the three quote styles.
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g;
// The timeline registry's own setup reads no scene: `window.__timelines = window.__timelines || {}`, ??=, ||=,
// `= {}`, and the `if (!window.__timelines)` / `typeof` guards.
const REGISTRY_SETUP_RE =
/(?:window\.)?__timelines\s*(?:\?\?=|\|\|=|=\s*(?:(?:window\.)?__timelines\s*(?:\|\||\?\?)\s*)?\{\s*\})|!\s*(?:window\.)?__timelines\s*\)|typeof\s+(?:window\.)?__timelines\b(?!\s*(?:\?\.|\.|\[))/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve typeof member reads separated by comments

typeof window.__timelines /* availability */ .b is valid JS, but this lookahead skips only whitespace. Seeing /, it accepts/removes the registry prefix as a bare setup guard; registryReads then finds neither a named dependency nor the conservative every-scene fallback. A root DOMContentLoaded callback writing that expression to a .status outside scene b does not otherwise select/name b, so a classic start-30/duration-5 provider remains held in Studio preview. BASE initializes it before callback replay (object); HEAD writes undefined and does not replay the callback when the provider later mounts. Classification precedes later comment normalization. Skip comments as well when deciding the access is bare, or retain uncertain member reads conservatively; the ordinary no-comment case added here does not cover this syntax.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in signed 1f8298c. Leading block/line comment trivia is recognized when distinguishing bare typeof availability guards from member reads. Both trivia controls and the real root-reader bundle are covered; Chrome boot output changes from undefined to object. Bare guards remain dependency-free. Fresh successor CI/review pending.

// A registry key named outright, by string or by dot; a computed key being written reads nothing.
const REGISTRY_KEY_RE =
/^\s*(?:(?:\?\.)?\s*\[\s*(["'`])([\w-]+)\1\s*\]|\??\.\s*([A-Za-z_$][\w$]*))/;
const REGISTRY_WRITE_RE = /^\s*\[[^\]]*\]\s*=(?!=)/;
const leadingTrivia = (text: string) =>
text.replace(/^(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r?\n|$))+/, "");

/** The scene ids a script reads from the timeline registry; `everyScene` when a read is not to one named key. */
export function registryReads(script: string): { names: string[]; everyScene: boolean } {
const rest = script.replace(REGISTRY_SETUP_RE, (setup: string, at: number) =>
setup.startsWith("typeof") &&
/^(?:\?\.|\.|\[)/.test(leadingTrivia(script.slice(at + setup.length)))
? setup
: "",
);
const names: string[] = [];
for (const m of rest.matchAll(/__timelines\b/g)) {
const after = leadingTrivia(rest.slice(m.index + m[0].length, m.index + m[0].length + 200));
const key = REGISTRY_KEY_RE.exec(after);
if (key) {
if (!/^\s*=(?!=)/.test(after.slice(key[0].length))) names.push(key[2] ?? key[3] ?? "");
} else if (!REGISTRY_WRITE_RE.test(after)) return { names, everyScene: true };
}
return { names, everyScene: false };
}

// Text ending where createElement takes its tag: that string makes a node, it selects none.
const CREATES_ELEMENT_RE = /createElement\s*\(\s*$|createElementNS\s*\([^()]*,\s*$/;
// A "*" after a comma is a later argument, like postMessage's target origin; selector calls take it first.
const isNotASelector = (script: string, at: number, literal: string) =>
CREATES_ELEMENT_RE.test(script.slice(Math.max(0, at - 60), at)) ||
(literal.trim() === "*" && /,\s*$/.test(script.slice(Math.max(0, at - 20), at)));

/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would strand it. */
export function refuseSwapsReachedByRootScripts(document: Document, rootScripts: string[]): void {
/** Marks each scene whose nodes a script outside it names by selector, id or class: a swap would
* strand it, and its own scripts must have run before that script does. */
export function refuseScenesReachedByRootScripts(document: Document, rootScripts: string[]): void {
const hosts = [...document.querySelectorAll(`[${SCENE_PART_ATTR}]`)];
const byName = new Map<string, Set<Element>>();
for (const host of hosts) {
Expand Down Expand Up @@ -110,7 +163,9 @@ export function refuseSwapsReachedByRootScripts(document: Document, rootScripts:
),
);
for (const literal of literals) {
const open = hosts.filter((host) => !host.hasAttribute(SCENE_NO_SWAP_ATTR));
const open = hosts.filter(
(host) => !host.hasAttribute(SCENE_NO_SWAP_ATTR) || !host.hasAttribute(SCENE_EAGER_ATTR),
);
if (open.length === 0) return;
// Tag names match in any case; ids and classes do not.
const reached =
Expand All @@ -126,8 +181,9 @@ export function refuseSwapsReachedByRootScripts(document: Document, rootScripts:
? open.filter((host) => reaches(host, literal))
: [];
for (const host of reached)
if (!host.hasAttribute(SCENE_NO_SWAP_ATTR))
host.setAttribute(SCENE_NO_SWAP_ATTR, `a script outside the scene selects ${literal}`);
for (const attr of [SCENE_NO_SWAP_ATTR, SCENE_EAGER_ATTR])
if (!host.hasAttribute(attr))
host.setAttribute(attr, `${REACHED_BY_ROOT_REASON} ${literal}`);
}
}

Expand Down Expand Up @@ -281,6 +337,18 @@ export interface InlineSubCompositionsResult {
importMaps: ImportMap[];
/** Mounted files' inline module scripts, each already carrying its scoped `__hyperframes`. */
moduleScripts: string[];
/** With `tagScenes`: each scene's host and the literals its scripts use; see `keepScenesReadAtBootEager`. */
sceneReads: SceneReads;
/** Authored scene scripts whose selectors may reach other scenes. */
outsideScripts: string[];
}

export interface SceneReads {
hosts: Map<string, Element>;
literals: Map<string, Set<string>>;
registryNames: Map<string, Set<string>>;
/** Scenes whose scripts read the timeline registry by a key no analysis can name. */
readEveryScene: Set<string>;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -346,6 +414,10 @@ export function inlineSubCompositions(
const svgIdScopes: Array<SvgIdScope & { styleStart: number; styleEnd: number }> = [];

const sceneHosts = new Map<string, Element>();
const sceneLiterals = new Map<string, Set<string>>();
const outsideScripts: string[] = [];
const registryNames = new Map<string, Set<string>>();
const readEveryScene = new Set<string>();
const queue = hosts.map((element) => ({
element,
ancestry: [] as string[],
Expand Down Expand Up @@ -423,12 +495,13 @@ export function inlineSubCompositions(
sceneHosts.set(scene, hostEl);
}
// Lazy, so renders (no scene hosts) never run the refusal checks.
const refuseSwap = (why: () => string | null) => {
const markScene = (attr: string, why: () => string | null) => {
const host = scene ? sceneHosts.get(scene) : undefined;
if (!host || host.hasAttribute(SCENE_NO_SWAP_ATTR)) return;
if (!host || host.hasAttribute(attr)) return;
const reason = why();
if (reason) host.setAttribute(SCENE_NO_SWAP_ATTR, reason);
if (reason) host.setAttribute(attr, reason);
};
const refuseSwap = (why: () => string | null) => markScene(SCENE_NO_SWAP_ATTR, why);

// Variable merging (bundler feature). Read declared defaults from the
// document element (full-document sub-comps) AND the inner composition root
Expand Down Expand Up @@ -508,15 +581,26 @@ export function inlineSubCompositions(
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
const type = (scriptEl.getAttribute("type") || "").trim().toLowerCase();
// A swap never re-runs external or module scripts: only a known library that just defines globals is safe.
refuseSwap(() =>
const authored = scriptEl.textContent || "";
const unknownCode =
type === "importmap" || type === "module"
? "it runs a module script or import map"
: !externalSrc
? sceneScriptSwapRefusal(scriptEl.textContent || "")
: isSwapSafeLibrary(externalSrc)
? null
: "it runs a script that is not a known library",
: externalSrc && !isSwapSafeLibrary(externalSrc)
? "it runs a script that is not a known library"
: null;
refuseSwap(() => unknownCode ?? (externalSrc ? null : sceneScriptSwapRefusal(authored)));
markScene(
SCENE_EAGER_ATTR,
() => unknownCode ?? (externalSrc ? null : sceneScriptEagerReason(authored)),
);
if (scene && !externalSrc && type !== "importmap") {
if (REACHES_OUTSIDE_RE.test(authored)) outsideScripts.push(authored);
const { names, everyScene } = registryReads(authored);
const literals = [...authored.matchAll(STRING_LITERAL_RE)].map((m) => m[2] ?? "");
sceneLiterals.set(scene, new Set([...(sceneLiterals.get(scene) ?? []), ...literals]));
registryNames.set(scene, new Set([...(registryNames.get(scene) ?? []), ...names]));
if (everyScene) readEveryScene.add(scene);
}
if (type === "importmap") {
const map = parseImportMap(scriptEl.textContent || "", (url) => {
// The rebase drops a leading "./" and a trailing "/"; an import map address needs both.
Expand Down Expand Up @@ -726,5 +810,78 @@ export function inlineSubCompositions(
variablesByComp,
importMaps,
moduleScripts,
sceneReads: { hosts: sceneHosts, literals: sceneLiterals, registryNames, readEveryScene },
outsideScripts,
};
}

/** A script that names a scene's composition id reads what that scene's script left, so the scene runs at boot whenever
* its reader does. Root scripts always run at boot; run this after every other eager mark. */
export function keepScenesReadAtBootEager(
{ hosts: sceneHosts, literals: sceneLiterals, registryNames, readEveryScene }: SceneReads,
rootScripts: string[],
): void {
// A renamed duplicate (`b__hf1`) still registers under the id its author gave it.
const idsOf = (host: Element) =>
new Set(
[
host,
...host.querySelectorAll("[data-composition-id], [data-hf-original-composition-id]"),
].flatMap((el) => [
el.getAttribute("data-composition-id") ?? "",
el.getAttribute("data-hf-original-composition-id") ?? "",
]),
);
const ids = new Map([...sceneHosts].map(([scene, host]) => [scene, idsOf(host)]));
const namedBy = (literals: Iterable<string>, reader?: string) => {
const named = [...literals].filter(Boolean);
return [...ids]
.filter(([id, other]) => id !== reader && named.some((l) => other.has(l)))
.map(([id]) => id);
};
const ROOT = "a root script";
const rootReads = rootScripts.map(registryReads);
const rootLiterals = [
...rootScripts.flatMap((s) => [...s.matchAll(STRING_LITERAL_RE)].map((m) => m[2] ?? "")),
...rootReads.flatMap(({ names }) => names),
];
const rootReadsEveryScene = rootReads.some(({ everyScene }) => everyScene);
const readsOf = (reader: string) => {
if (reader === ROOT ? rootReadsEveryScene : readEveryScene.has(reader))
return [...ids.keys()].filter((id) => id !== reader);
if (reader === ROOT) return namedBy(rootLiterals);
const own = ids.get(reader);
return namedBy(
[
...[...(sceneLiterals.get(reader) ?? [])].filter((name) => !own?.has(name)),
...(registryNames.get(reader) ?? []),
],
reader,
);
};
// Eager only to read others: nothing it writes is shared, so it may run after boot unless a scene reads it.
const readersOnly = new Set<string>();
for (const [scene, host] of sceneHosts) {
const named = readsOf(scene)[0];
if (!named || host.hasAttribute(SCENE_EAGER_ATTR)) continue;
host.setAttribute(SCENE_EAGER_ATTR, `its script names ${named}`);
readersOnly.add(scene);
}
const eager = [
ROOT,
...[...sceneHosts].filter(([, host]) => host.hasAttribute(SCENE_EAGER_ATTR)).map(([s]) => s),
];
// Every scene some eager script reads, whatever made it eager first.
const read = new Set<string>();
for (const reader of eager) {
for (const provider of readsOf(reader)) {
read.add(provider);
const host = sceneHosts.get(provider);
if (!host || host.hasAttribute(SCENE_EAGER_ATTR)) continue;
host.setAttribute(SCENE_EAGER_ATTR, `${reader} ${READ_BY_SCENE_REASON}`);
eager.push(provider);
}
}
for (const scene of readersOnly)
if (!read.has(scene)) sceneHosts.get(scene)?.setAttribute(SCENE_AFTER_BOOT_ATTR, "");
}
Loading
Loading