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
60 changes: 3 additions & 57 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import postcss, { type AtRule, type Node, type Rule } from "postcss";
import { replaceSelectorIdTokens } from "./selectorIdTokens";

const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
const INNER_ROOT_ATTR = "data-hf-inner-root";
Expand All @@ -23,68 +24,13 @@ function getAuthoredRootIdSelectorForms(authoredRootId: string): string[] {
return Array.from(new Set([trimmed, escapeCssIdentifier(trimmed)])).filter(Boolean);
}

function isSelectorNameChar(char: string | undefined): boolean {
return !!char && /[\w-]/.test(char);
}

function replaceAuthoredRootIdSelectors(
selector: string,
authoredRootId: string,
replacement: string,
): string {
const forms = getAuthoredRootIdSelectorForms(authoredRootId).sort((a, b) => b.length - a.length);
if (forms.length === 0) return selector;

let result = "";
let bracketDepth = 0;
let quote: '"' | "'" | null = null;

for (let index = 0; index < selector.length; index += 1) {
const char = selector[index];
const previousChar = index > 0 ? selector[index - 1] : "";

if (quote) {
result += char;
if (char === quote && previousChar !== "\\") {
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) {
const matchedForm = forms.find((form) => selector.startsWith(form, index + 1));
if (matchedForm) {
const nextChar = selector[index + 1 + matchedForm.length];
if (!isSelectorNameChar(nextChar)) {
result += replacement;
index += matchedForm.length;
continue;
}
}
}

result += char;
}

return result;
const forms = getAuthoredRootIdSelectorForms(authoredRootId);
return replaceSelectorIdTokens(selector, forms, () => replacement);
}

function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: string | null): string {
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
import { assignBundledRuntimeCompositionIds } from "./htmlBundler";

// Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked.

Expand Down Expand Up @@ -676,3 +677,135 @@ describe("inlineSubCompositions – sub-composition asset paths", () => {
);
});
});

describe("inlineSubCompositions – #3490 nested SVG id collisions", () => {
// Same shape as the issue repro: a composition-scoped clipPath, a <use>
// target, and a CSS filter, all hardcoded ids a catalog block or a scene
// author has no reason to think are unsafe to repeat.
function svgScene(compId: string, color: string): string {
return `<!DOCTYPE html><html><body><div id="${compId}" data-composition-id="${compId}" data-width="100" data-height="100">
<svg width="0" height="0">
<clipPath id="clip"><rect width="10" height="10"/></clipPath>
<symbol id="shape"><circle r="5" fill="${color}"/></symbol>
<filter id="fx"><feFlood flood-color="${color}"/></filter>
</svg>
<g class="clipped" clip-path="url(#clip)"><use class="shape" href="#shape"></use></g>
<div class="fx-target" style="filter:url(#fx)"></div>
<style>#${compId} .fx-target { filter: url(#fx); }</style>
</div></body></html>`;
}

function inlineTwoScenes(
sources: Record<string, string>,
hostEntries: Array<{ compId: string; src: string }>,
) {
const hostsMarkup = hostEntries
.map(
({ compId, src }, i) =>
`<div data-composition-id="${compId}" data-composition-src="${src}"
data-start="0" data-duration="4" data-track-index="${i}"></div>`,
)
.join("\n");
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">${hostsMarkup}</div>
</body></html>`);
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
const hostIdentityMap = assignBundledRuntimeCompositionIds(hosts);
const result = inlineSubCompositions(document, hosts, {
resolveHtml: (src) => sources[src] ?? null,
parseHtml: (html) => parseHTML(html).document,
hostIdentityMap,
});
return { document, result };
}

it("gives two sibling scenes reusing #clip/#shape/#fx distinct, non-colliding ids", () => {
const { document, result } = inlineTwoScenes(
{ "scene-a.html": svgScene("scene-a", "red"), "scene-b.html": svgScene("scene-b", "blue") },
[
{ compId: "scene-a", src: "scene-a.html" },
{ compId: "scene-b", src: "scene-b.html" },
],
);

// No literal "clip"/"shape"/"fx" id survives verbatim, and no id repeats
// across the two scenes — the exact document-order collision the browser
// resolves incorrectly before this fix.
const allIds = Array.from(document.querySelectorAll("[id]")).map((el) => el.getAttribute("id"));
expect(allIds.filter((id) => id === "clip")).toHaveLength(0);
expect(new Set(allIds).size).toBe(allIds.length);

const [gA, gB] = Array.from(document.querySelectorAll("g.clipped"));
const [useA, useB] = Array.from(document.querySelectorAll("use.shape"));
const [fxA, fxB] = Array.from(document.querySelectorAll(".fx-target"));

const clipA = gA!.getAttribute("clip-path");
const clipB = gB!.getAttribute("clip-path");
expect(clipA).toMatch(/^url\(#.*clip\)$/);
expect(clipA).not.toBe(clipB);

const hrefA = useA!.getAttribute("href");
const hrefB = useB!.getAttribute("href");
expect(hrefA).toMatch(/^#.*shape$/);
expect(hrefA).not.toBe(hrefB);

// <use>/clip-path must resolve to the id actually declared in THIS
// scene's <svg>, not merely to a unique-looking string.
const clipAId = clipA!.slice("url(#".length, -1);
const shapeAId = hrefA!.slice(1);
expect(document.getElementById(clipAId)?.closest("svg")).toBeTruthy();
expect(document.getElementById(shapeAId)?.closest("svg")).toBeTruthy();

const styleFxA = fxA!.getAttribute("style");
const styleFxB = fxB!.getAttribute("style");
expect(styleFxA).not.toBe(styleFxB);

// The CSS text (`<style>#scene-a .fx-target { filter: url(#fx) }`)
// extracted alongside the DOM (never re-injected by this shared function
// — that is the caller's job) must resolve to the SAME renamed id the
// element attribute above did, or the two diverge and the rule stops
// matching once inlined.
const styles = result.styles.join("\n");
const fxAId = styleFxA!.match(/url\(#([^)]+)\)/)?.[1];
expect(fxAId).toBeTruthy();
expect(styles).toContain(`filter: url(#${fxAId})`);
});

it("disambiguates the same catalog block used twice in one scene", () => {
const block = svgScene("block", "green");
const { document } = inlineTwoScenes({ "block.html": block }, [
{ compId: "block", src: "block.html" },
{ compId: "block", src: "block.html" },
]);

const uses = Array.from(document.querySelectorAll("use.shape"));
expect(uses).toHaveLength(2);
const hrefs = uses.map((u) => u.getAttribute("href"));
expect(hrefs[0]).not.toBe(hrefs[1]);
expect(new Set(hrefs).size).toBe(2);

// Each <use> must still resolve within the merged document.
for (const href of hrefs) {
const id = href!.slice(1);
expect(document.getElementById(id)).toBeTruthy();
}
});

it("keeps getElementById(originalId) working for a scoped inline script after rename", () => {
// Mirrors #646: an author's own script calling
// document.getElementById('shape') from inside its composition must
// still find its element after this module renames the real id.
const { document } = inlineTwoScenes(
{ "scene-a.html": svgScene("scene-a", "red"), "scene-b.html": svgScene("scene-b", "blue") },
[
{ compId: "scene-a", src: "scene-a.html" },
{ compId: "scene-b", src: "scene-b.html" },
],
);
const sceneARoot = document.querySelector('[data-composition-id="scene-a"]')!;
const authoredMatch = sceneARoot.querySelector('[data-hf-authored-id="shape"]');
expect(authoredMatch).toBeTruthy();
expect(authoredMatch).toBe(document.querySelector("symbol"));
});
});
15 changes: 14 additions & 1 deletion packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
import { namespaceSvgIds, rewriteSvgIdReferencesInCss } from "./svgIdNamespacing";

// ---------------------------------------------------------------------------
// Public interface
Expand Down Expand Up @@ -260,6 +261,17 @@ export function inlineSubCompositions(
const scriptCompositionId = plan.scriptCompositionId || "";
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";

// Namespace this instance's SVG ids (`<clipPath id="clip">`, `<symbol
// id="shape">`, `<filter id="fx">`, …) before any <style>/<script>
// extraction below, so `scopeSubStyle`'s CSS-text rewrite sees the exact
// id map this DOM pass just applied. Keyed on the document-unique
// runtime id (falling back to the authored id for an anonymous host with
// no duplicate instances) — the same identity CSS scoping and script
// scoping already key on. See svgIdNamespacing.ts for why this is a
// rename, unlike the sibling getElementById/media-id fixes.
const svgIdNamespace = runtimeCompId || scopeCompId;
const svgIdMap = namespaceSvgIds(innerRoot ?? contentDoc, svgIdNamespace);

// Variable merging (bundler feature). Read declared defaults from the
// document element (full-document sub-comps) AND the inner composition root
// (template/fragment sub-comps store their schema on the root div, not a
Expand Down Expand Up @@ -293,7 +305,8 @@ export function inlineSubCompositions(
// sub-comp's html/body/:root rules from clobbering the host document (they
// are remapped to the composition box); see compositionScoping.
const scopeSubStyle = (raw: string): string => {
const css = rewriteCssAssetUrls(raw, src, assetExists);
const withNamespacedSvgIds = rewriteSvgIdReferencesInCss(raw, svgIdMap);
const css = rewriteCssAssetUrls(withNamespacedSvgIds, src, assetExists);
return scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
Expand Down
96 changes: 96 additions & 0 deletions packages/core/src/compiler/selectorIdTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Shared low-level scanner: walk a CSS selector and replace whole-token
* `#id` occurrences that sit outside quotes and attribute-selector brackets.
*
* Extracted from `compositionScoping.ts`'s authored-root-id rewrite (the
* original, single-id version of this scan) so `svgIdNamespacing.ts` can
* reuse the exact same quote/bracket-tracking logic for its many-id rewrite
* instead of a second, drifting copy of the same state machine.
*
* Split into two small passes rather than one branch-heavy loop: first mark
* which offsets are outside a quoted string or an attribute-selector
* bracket (`markUnguardedOffsets`), then walk the selector once more,
* consulting that mask, to actually splice in replacements
* (`replaceSelectorIdTokens`). Each pass stays simple enough to read at a
* glance instead of one function juggling both jobs.
*/

/** A `#id` token boundary character — an id selector never spans one. */
function isSelectorNameChar(char: string | undefined): boolean {
return !!char && /[\w-]/.test(char);
}

/**
* A full attribute-selector bracket (`[data-x="a"]`, quotes optional, `]`
* inside a quoted value tolerated) or a bare quoted string. Either is a
* region where a literal `#` is never an id-selector prefix — the CSS parser
* itself never looks for one there — so `markUnguardedOffsets` below can
* find every such region with one pass of `matchAll` instead of a hand-rolled
* character-by-character state machine.
*/
const GUARDED_SELECTOR_SEGMENT_RE =
/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\[(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\]])*\]/g;

/**
* `mask[i]` is `true` when `selector[i]` sits outside both a quoted string
* and an attribute-selector bracket — the two places a literal `#` is never
* an id selector prefix.
*/
function markUnguardedOffsets(selector: string): boolean[] {
const mask = new Array<boolean>(selector.length).fill(true);
for (const match of selector.matchAll(GUARDED_SELECTOR_SEGMENT_RE)) {
const start = match.index;
mask.fill(false, start, start + match[0].length);
}
return mask;
}

/** The longest candidate id starting at `selector[start]`, provided the
* match ends on a token boundary (so `#clip2` never matches `#clip`). */
function matchIdTokenAt(
selector: string,
start: number,
formsLongestFirst: readonly string[],
): string | null {
const form = formsLongestFirst.find((candidate) => selector.startsWith(candidate, start));
if (!form) return null;
return isSelectorNameChar(selector[start + form.length]) ? null : form;
}

/**
* Replace every whole-token `#id` in `selector` whose id is in
* `candidateIds`, skipping occurrences inside a quoted string or an
* attribute-selector bracket.
*
* `candidateIds` is scanned longest-first so `#clip2` is never mistaken for
* `#clip` followed by a literal `2`. `resolveReplacement` receives the
* matched id and returns the full replacement text (including its own
* leading `#`, if any) to splice in.
*/
export function replaceSelectorIdTokens(
selector: string,
candidateIds: readonly string[],
resolveReplacement: (matchedId: string) => string,
): string {
if (candidateIds.length === 0 || !selector.includes("#")) return selector;
const forms = [...candidateIds].sort((a, b) => b.length - a.length);
const unguarded = markUnguardedOffsets(selector);

let result = "";
let index = 0;
while (index < selector.length) {
const matchedForm =
unguarded[index] && selector[index] === "#"
? matchIdTokenAt(selector, index + 1, forms)
: null;
if (matchedForm) {
result += resolveReplacement(matchedForm);
index += 1 + matchedForm.length;
} else {
result += selector[index];
index += 1;
}
}

return result;
}
Loading
Loading