Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
// Same arrangement for the audio FX graph builders, bundled by
// build-audio-fx-runtime.ts for the offline render page.
"packages/core/stubs/audio-fx-runtime-entry.ts",
// And for the SVG selector alias installer compiled scene scripts carry.
"packages/core/stubs/svg-selector-aliases-entry.ts",
// In-page audit scripts read as raw strings and injected via
// page.addScriptTag (see layout.ts / validate.ts) — referenced by file
// path, never imported, so they have no import-graph referrer.
Expand Down Expand Up @@ -938,7 +940,7 @@
"packages/studio/src/player/components/TimelineClipDiamonds.tsx",
// Sub-composition html/body scoping fix: these files carry pre-existing
// CRITICAL/HIGH functions (inlineSubCompositions, bundleToSingleHtml,
// mountCompositionContent/loadExternalCompositions, scopeSelector/
// mountCompositionContent/loadCompositions, scopeSelector/
// replaceAuthoredRootIdSelectors) that pre-date this PR. The change only
// threads a scopeRootSelectors option through the existing scoping calls;
// the line-shift fingerprint re-flags the inherited complexity.
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ tmp/
packages/core/src/generated/*
!packages/core/src/generated/position-edits-render-inline.ts
!packages/core/src/generated/position-edits-render-inline.test.ts
!packages/core/src/generated/svg-selector-aliases-inline.ts
!packages/core/src/generated/svg-selector-aliases-inline.test.ts
packages/core/src/fonts/fontData.generated.ts

# Plan documents (working artifacts, not committed)
Expand Down
6 changes: 4 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -848,9 +848,9 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "bun run build:fonts && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
"build:fonts": "node scripts/build-fonts.mjs",
"test": "bun run check:position-edits-render && bun run build:audio-fx-runtime && vitest run",
"test": "bun run check:position-edits-render && bun run check:svg-selector-aliases && bun run build:audio-fx-runtime && vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:runtime-coverage": "vitest run --coverage src/runtime",
Expand All @@ -861,6 +861,8 @@
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:position-edits-render": "tsx scripts/build-position-edits-render.ts",
"check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts",
"build:svg-selector-aliases": "tsx scripts/build-svg-selector-aliases.ts",
"check:svg-selector-aliases": "bun run build:svg-selector-aliases && git diff --exit-code -- src/generated/svg-selector-aliases-inline.ts",
"build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts",
Expand Down
13 changes: 13 additions & 0 deletions packages/core/scripts/build-svg-selector-aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Build the SVG selector alias installer that compiled scene scripts carry. */

import { buildInjectedArtifact } from "./buildInjectedArtifact.js";

buildInjectedArtifact({
scriptUrl: import.meta.url,
entry: "stubs/svg-selector-aliases-entry.ts",
out: "svg-selector-aliases-inline.ts",
constName: "SVG_SELECTOR_ALIASES_IIFE",
fnName: "getSvgSelectorAliasesScript",
what: "SVG selector alias installer IIFE",
event: "svg_selector_aliases_generated",
});
2 changes: 1 addition & 1 deletion packages/core/scripts/buildInjectedArtifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function buildInjectedArtifact(spec: InjectedArtifact): void {
if (!iife) throw new Error(`esbuild produced no output for ${spec.entry.split("/").pop()}`);

const source = [
`// AUTO-GENERATED by scripts/${scriptName} - do not edit`,
`// Generated by scripts/${scriptName}; do not edit.`,
`const ${spec.constName}: string = ${JSON.stringify(iife)};`,
"",
`/** Returns the pre-built ${spec.what} as a string constant. */`,
Expand Down
27 changes: 21 additions & 6 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});

it("keeps a classic authored script in sloppy mode", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = { document, __timelines: {} };
const wrapped = wrapScopedCompositionScript(
`window.__captured = (function() { return this !== undefined; })();`,
"scene",
);

new Function("window", wrapped)(fakeWindow);

expect(fakeWindow.__captured).toBe(true);
});

it("gives a mounted module script its composition's own __hyperframes", () => {
const { document } = parseHTML(`<div></div>`);
Object.defineProperty(document, "baseURI", { value: "https://p.test/preview/" });
Expand Down Expand Up @@ -1302,8 +1315,9 @@ describe("composition scoping – renamed-id selector runtime", () => {
const window = dom.window as unknown as Window & typeof globalThis & Record<string, unknown>;
window.__captured = {};
if (gsap) window.gsap = gsap;
const nativeQuery = window.Element.prototype.querySelector;
window.eval(wrapScopedCompositionScript(script, compId));
return { window, captured: window.__captured as Record<string, unknown> };
return { window, nativeQuery, captured: window.__captured as Record<string, unknown> };
}

const TWO_INSTANCES = `
Expand All @@ -1313,17 +1327,17 @@ describe("composition scoping – renamed-id selector runtime", () => {
</div>`;

it("leaves Element.prototype untouched when no id was renamed", () => {
const { window, captured } = bootWindow(
const { window, captured, nativeQuery } = bootWindow(
`<div data-composition-id="scene"><svg><path id="shape"/></svg></div>`,
`window.__captured.hit = document.querySelector("svg").querySelector("#shape");`,
"scene",
);
expect(window.__hfRenamedIdSelectorShim).toBeUndefined();
expect(window.Element.prototype.querySelector).toBe(nativeQuery);
expect(captured.hit).toBe(window.document.querySelector("path"));
});

it("rewrites #authoredId to match a renamed element from element and document lookups", () => {
const { window, captured } = bootWindow(
const { window, captured, nativeQuery } = bootWindow(
TWO_INSTANCES,
`var svg = document.querySelector("svg.art");
window.__captured.viaElement = svg.querySelector("#shape");
Expand All @@ -1336,7 +1350,7 @@ describe("composition scoping – renamed-id selector runtime", () => {
);
const renamed = window.document.querySelector('[data-hf-authored-id="shape"]');
const other = window.document.querySelector('[data-composition-id="other"] path');
expect(window.__hfRenamedIdSelectorShim).toBe(true);
expect(window.Element.prototype.querySelector).not.toBe(nativeQuery);
expect(captured.viaElement).toBe(renamed);
expect(captured.viaElementAll).toBe(1);
expect(captured.viaDocument).toBe(renamed);
Expand Down Expand Up @@ -1399,10 +1413,11 @@ describe("composition scoping – renamed-id selector runtime", () => {
expect(captured.suffix).toBeNull();
});

it("refreshes renamed ids after a scene is replaced and its script runs again", () => {
it("uses the lifecycle alias refresh before a replaced scene script runs", () => {
const { window } = bootWindow(TWO_INSTANCES, "", "scene");
window.document.querySelector('[data-composition-id="scene"]')!.innerHTML =
'<svg><path id="scene--new" data-hf-authored-id="new"/></svg>';
window.__hfSvgSelectorAliases!.refresh();
window.eval(
wrapScopedCompositionScript(
'window.__captured.hit = document.querySelector("svg").querySelector("#new").id;',
Expand Down
123 changes: 4 additions & 119 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getSvgSelectorAliasesScript } from "../generated/svg-selector-aliases-inline";
import postcss, { type AtRule, type Node, type Rule } from "postcss";
import { escapeCssIdentifier, replaceSelectorIdTokens } from "./selectorIdTokens";
import { SCENE_PARTS_META } from "../sceneParts";
Expand Down Expand Up @@ -312,7 +313,9 @@ export function wrapScopedCompositionScript(
const timingSelectorPatternLiteral = jsonScriptLiteral(
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
);
const aliasInstallerOutsideAuthoredStrictness = `(function(){ ${getSvgSelectorAliasesScript()} })();`;
return `(function(){
${aliasInstallerOutsideAuthoredStrictness}
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
Expand All @@ -331,131 +334,15 @@ export function wrapScopedCompositionScript(
var __hfAuthoredRootSelector = __hfAuthoredRootId
? "[" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(__hfAuthoredRootId) + '"]'
: "";
var __hfCssEscape = function(value) {
var text = value + "";
if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") {
try { return CSS.escape(text); } catch {}
}
return text.replace(/[^a-zA-Z0-9_-]/g, function(char) { return "\\\\" + char; });
};
// Decode complete CSS identifiers before comparing authored ids.
var __hfRewriteIdSelectors = function(selector, entries) {
if (!entries || !entries.length || typeof selector !== "string" || selector.indexOf("#") === -1) {
return selector;
}
var result = "";
var bracketDepth = 0;
var quote = null;
for (var index = 0; index < selector.length; index += 1) {
var char = selector[index];
if (char === "\\\\") {
var escape = selector.slice(index).match(/^\\\\(?:[0-9a-fA-F]{1,6}(?:\\r\\n|[ \\t\\r\\n\\f])?|[\\s\\S])/);
if (escape) {
result += escape[0];
index += escape[0].length - 1;
continue;
}
}
if (quote) {
result += char;
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
var token = selector.slice(index + 1).match(/^(?:[\\w\\u0080-\\uffff-]|\\\\(?:[0-9a-fA-F]{1,6}(?:\\r\\n|[ \\t\\r\\n\\f])?|[^\\r\\n\\f]))+/);
if (token) {
var decoded = token[0].replace(/\\\\([0-9a-fA-F]{1,6})(?:\\r\\n|[ \\t\\r\\n\\f])?|\\\\([^\\r\\n\\f])/g, function(_, hex, escaped) {
if (!hex) return escaped;
var code = parseInt(hex, 16);
return String.fromCodePoint(!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) ? 0xfffd : code);
});
var matched = entries.find(function(entry) { return entry.id === decoded; });
result += matched ? matched.replacement : "#" + token[0];
index += token[0].length;
continue;
}
}
result += char;
}
return result;
};
var __hfAuthoredRootEntries = __hfAuthoredRootSelector
? [{ id: __hfAuthoredRootId, replacement: __hfAuthoredRootSelector }]
: [];
// Include both renamed and untouched instances. Each wrapper refreshes this
// document-wide cache because preview scene swaps can introduce new ids.
var __hfRenamedIdEntries = function() {
if (window.__hfRenamedIdSelectorEntries) return window.__hfRenamedIdSelectorEntries;
var entries = [];
var seen = Object.create(null);
var nodes;
try {
nodes = window.document.querySelectorAll("[" + __hfAuthoredRootAttr + "][id]");
} catch {
nodes = [];
}
for (var i = 0; i < nodes.length; i += 1) {
var authored = nodes[i].getAttribute(__hfAuthoredRootAttr);
if (!authored || seen[authored] || authored === nodes[i].id) continue;
seen[authored] = true;
var escaped = __hfCssEscape(authored);
entries.push({
id: authored,
replacement: ":is(#" + escaped + ", [" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(authored) + '"])',
});
}
window.__hfRenamedIdSelectorEntries = entries;
return window.__hfRenamedIdSelectorEntries;
};
// element.querySelector("#authored") on an arbitrary Element never passes
// through the scoped document proxy, so the same rewrite is installed once
// per document on Element.prototype — only when at least one id was
// actually renamed, so a document without collisions runs untouched
// natives. Document.prototype is deliberately NOT patched: an unscoped
// document-wide lookup cannot know which instance it means, and rewriting
// it would only widen the ambiguity.
var __hfInstallRenamedIdSelectorShim = function() {
if (window.__hfRenamedIdSelectorShim) return;
var entries = __hfRenamedIdEntries();
if (!entries.length) return;
var proto = window.Element && window.Element.prototype;
if (!proto || typeof proto.querySelector !== "function" || typeof proto.querySelectorAll !== "function") {
return;
}
var nativeQuerySelector = proto.querySelector;
var nativeQuerySelectorAll = proto.querySelectorAll;
proto.querySelector = function(selector) {
return nativeQuerySelector.call(this, __hfRewriteIdSelectors(selector, __hfRenamedIdEntries()));
};
proto.querySelectorAll = function(selector) {
return nativeQuerySelectorAll.call(this, __hfRewriteIdSelectors(selector, __hfRenamedIdEntries()));
};
window.__hfRenamedIdSelectorShim = true;
};
var __hfNormalizeSelector = function(selector) {
if (!__hfCompId || typeof selector !== "string") return selector;
var normalized = selector
.replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector)
.replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector);
normalized = __hfRewriteIdSelectors(normalized, __hfAuthoredRootEntries);
return __hfRewriteIdSelectors(normalized, __hfRenamedIdEntries());
return window.__hfSvgSelectorAliases.rewrite(normalized, __hfAuthoredRootEntries);
};
var __hfFindRoot = function() {
if (!__hfRoot && __hfRootSelector) {
Expand Down Expand Up @@ -718,8 +605,6 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
});
};
__hfFindRoot();
window.__hfRenamedIdSelectorEntries = null;
__hfInstallRenamedIdSelectorShim();
__hfRecordAnimations(__hfRun);
})();`;
}
Expand Down
17 changes: 8 additions & 9 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,12 +906,13 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
const window = dom.window as unknown as Window & typeof globalThis & Record<string, unknown>;
window.__captured = {};
if (fakeGsap) (window as Record<string, unknown>).gsap = fakeGsap(window);
const nativeQuery = window.Element.prototype.querySelector;
for (const script of result.scripts) window.eval(script);
return { window, result, captured: window.__captured as Record<string, unknown> };
return { window, result, nativeQuery, captured: window.__captured as Record<string, unknown> };
}

it("single composition: native and script references to the same element both keep resolving", () => {
const { window, captured } = inlineAndBoot(
const { window, captured, nativeQuery } = inlineAndBoot(
{
"scene.html": scriptedScene(
"scene",
Expand All @@ -929,7 +930,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(path.getAttribute("id")).toBe("shape");
expect(path.hasAttribute("data-hf-authored-id")).toBe(false);
expect(window.document.querySelector("use")!.getAttribute("href")).toBe("#shape");
expect((window as Record<string, unknown>).__hfRenamedIdSelectorShim).toBeUndefined();
expect(window.Element.prototype.querySelector).toBe(nativeQuery);
expect(captured.viaDocument).toBe(path);
expect(captured.viaElement).toBe(path);
expect(captured.byId).toBe(path);
Expand All @@ -946,7 +947,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
gsapTargets: gsap.to(["#shape", ".ref"], {}),
toArray: gsap.utils.toArray("#shape"),
};`;
const { window, captured } = inlineAndBoot(
const { window, captured, nativeQuery } = inlineAndBoot(
{
"scene-a.html": scriptedScene("scene-a", `var __hfProbeName = "a"; ${probe}`),
"scene-b.html": scriptedScene("scene-b", `var __hfProbeName = "b"; ${probe}`),
Expand Down Expand Up @@ -990,7 +991,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(b.gsapTargets).toEqual([pathB, rootB!.querySelector("use.ref")]);
expect(a.toArray).toEqual([pathA]);
expect(b.toArray).toEqual([pathB]);
expect((window as Record<string, unknown>).__hfRenamedIdSelectorShim).toBe(true);
expect(window.Element.prototype.querySelector).not.toBe(nativeQuery);
});

it("escaped CSS id: a styled element that is also natively referenced keeps its rule after rename", () => {
Expand Down Expand Up @@ -1027,13 +1028,11 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(cssA).toContain("url(#fx.1)");
expect(cssB).toContain(String.raw`#scene-b--fx\.1`);
expect(cssB).toContain("url(#scene-b--fx.1)");
expect(cssB).not.toContain(String.raw`#fx\.1`);
expect(cssB).toContain(String.raw`:is(#fx\.1, #scene-b--fx\.1)`);

// The rewritten selector actually matches the renamed element in a real
// selector engine.
const ruleSelector = /(\[data-composition-id="scene-b"\][^{]*#scene-b--fx\\\.1)\s*\{/.exec(
cssB!,
)?.[1];
const ruleSelector = cssB!.slice(0, cssB!.indexOf("{")).trim();
expect(ruleSelector).toBeTruthy();
expect(window.document.querySelector(ruleSelector!)).toBe(filterB);

Expand Down
Loading
Loading