Skip to content

fix(core): namespace SVG ids during composition inline to prevent cross-scene collisions - #3494

Open
miga-heygen wants to merge 5 commits into
mainfrom
fix/svg-id-collision-nested-compositions
Open

miga-heygen wants to merge 5 commits into
mainfrom
fix/svg-id-collision-nested-compositions

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Prefix SVG element ids with the composition's document-unique runtime id during sub-composition inline, so <clipPath id="clip">, <symbol id="shape">, <filter id="fx">, etc. no longer collide once two nested scenes are merged into one render/preview document.
  • Rewrite every same-document reference to a renamed id to match: DOM attributes (href, xlink:href, any url(#id) value — clip-path, filter, mask, fill, stroke, marker-start/mid/end, including inside style) and the composition's own extracted <style> text (both #id selectors and url(#id) declaration values).
  • Renamed elements keep their original id on data-hf-authored-id (the same attribute fix(core): scoped getElementById fails with duplicate element IDs across sub-compositions #646 added for the composition root), so an inline script's own document.getElementById(originalId) keeps resolving via the existing __hfGetElementById scoping shim — the fix doesn't regress the already-fixed getElementById scoping.
  • Extracted selectorIdTokens.ts from compositionScoping.ts's existing single-id selector scan so the new many-id rewrite reuses the same guarded-region (quote/bracket) logic instead of a second copy.

Why not the media-id / getElementById approach?

getElementById was already scoped per composition in #646, and media pipeline ids got a parallel data-hf-render-id attribute in #3340 — both work by adding a side-channel attribute without touching the real id. That doesn't work here: url(#id) and href="#id" are resolved by the browser's native SVG/CSS engine, which always binds to the first element in document order carrying that literal id attribute. No JS proxy can intercept native resolution, so the id attribute itself has to become document-unique.

Closes #3490

Test plan

  • packages/core/src/compiler/svgIdNamespacing.test.ts (new, 13 tests): unit coverage for id renaming, url()/href rewriting across clip-path/filter/mask/fill/stroke/marker-*/style, xlink:href, and CSS selector/declaration rewriting.
  • packages/core/src/compiler/inlineSubCompositions.test.ts (new suite): end-to-end repro of the issue — two sibling scenes reusing #clip/#shape/#fx get distinct non-colliding ids that still resolve correctly; the same catalog block used twice in one scene is disambiguated; getElementById(originalId) still resolves for an inline script after rename.
  • bun run typecheck — clean.
  • oxlint — clean.
  • Full existing suites re-run clean: compositionScoping.test.ts (50), htmlBundler.test.ts (58, exercises inlineSubCompositions end-to-end via the preview bundler) — no regressions.

Review follow-up (compatibility)

Two review blockers changed the semantics from "rename every natively referenced SVG id" to the following:

  • Collision-driven, not just demand-driven. namespaceCollidingSvgIds runs once per assembled document, after every instance is inlined, and renames an id only when the merged document would otherwise carry it more than once. Ids that never collide — including every single-composition project — are byte-for-byte unchanged, so svg.querySelector("#shape") / document.querySelector("#shape") / gsap.to("#shape") keep working exactly as before. Among colliding elements one keeps the authored id (the first in document order that cannot be renamed — top-level document content or a JS-only id — else the first in document order); native url(#id) / href="#id" resolution binds to that keeper, and every other renamable duplicate gets a document-unique id with its references rewritten. The JS-only gate from the previous revision is kept; its residual (two instances sharing a JS-only id) is documented in collectNativelyReferencedIds.
  • Renamed ids stay reachable from author scripts. The composition script runtime rewrites #authoredId to :is(#authoredId, [data-hf-authored-id="authoredId"]) (a superset, so it still matches an untouched instance) in the scoped document.querySelector/querySelectorAll, the GSAP proxy (gsap.to, timeline.to, gsap.utils.toArray/selector), GSAP array targets (string entries now resolve through the scoped lookup), and Element.prototype.querySelector/querySelectorAll — patched once per document and only when at least one id was actually renamed (discovered from [data-hf-authored-id][id] in the compiled DOM), so documents without collisions run untouched natives.
  • Escaped CSS ids. The shared selector scanner now decodes CSS identifier escapes (\. and \HEX forms, per CSS Syntax Level 3) before matching and emits replacements through a CSS.escape-equivalent, so #fx\.1 { … } for id="fx.1" becomes #scene-b--fx\.1 { … } and url(#fx.1) becomes url(#scene-b--fx.1). The guarded-segment regex also lost its ambiguous [^\]] branch (CodeQL backtracking alert).

Not covered by the selector shim (documented in code): Document.prototype is deliberately not patched — an unscoped document-wide lookup by a third-party library (e.g. anime({ targets: "#id" })) cannot know which instance it means and resolves to the first in document order, exactly as before this PR; Element.prototype.matches/closest and script selectors using hex escapes at runtime are not rewritten; references a script injects at runtime (el.setAttribute("clip-path", "url(#x)") as the only reference) are not pre-scanned.

Test plan (follow-up)

  • svgIdNamespacing.test.ts (24): single composition byte-for-byte unchanged with native refs; two non-colliding compositions untouched; JS-only colliding ids kept; keeper/rename rules incl. parent-document and JS-only keepers; nested-scope exclusion; id minting against taken ids; authored-id idempotency; escaped selector and hex-escape rewriting.
  • selectorIdTokens.test.ts (10, new): CSS identifier decoding (character/hex escapes, \r\n, U+FFFD), CSS.escape algorithm parity and round-trip, whole-token and guarded-region behaviour.
  • inlineSubCompositions.test.ts (35): end-to-end regressions that execute the compiled scoped scripts in a real DOM — (i) single composition, native + script references to the same element; (ii) two colliding compositions, native refs and every script lookup form (document.querySelector, svg.querySelector, getElementById, GSAP array/toArray) resolve to their own element; (iii) id="fx.1" styled via #fx\.1 and referenced via filter="url(#fx.1)" keeps its rule after rename.
  • compositionScoping.test.ts (54): Element.prototype shim installs only when an id was renamed; element/document/GSAP-array lookups; escaped authored id in a script selector.
  • htmlBundler.test.ts (58) unchanged and green; bun run typecheck (core + producer) clean; oxlint, oxfmt, and fallow audit clean.
  • Windows test job: inlineSubCompositions.test.ts no longer imports ./htmlBundler (whose static esbuild import cannot load in a jsdom realm on Windows); it carries a local mirror of the duplicate-host runtime-id assignment instead.

Co-Authored-By: Miga noreply@anthropic.com

🤖 Generated with Claude Code

…ss-scene collisions

Two nested compositions that each declare their own SVG ids (`<clipPath
id="clip">`, `<symbol id="shape">`, `<filter id="fx">`) are legal per
file and pass `hyperframes check`, but collide once both are inlined
into one render/preview document. `url(#id)` funcrefs (`clip-path`,
`filter`, `mask`, `fill`, `stroke`, `marker-start/mid/end`) and
fragment `href`/`xlink:href` refs (`<use href="#id">`) are resolved by
the browser's native SVG/CSS engine, which always binds to the first
matching id in document order — so the later scene either clips to
nothing or paints the earlier scene's content.

`getElementById` was already scoped per composition in #646, and media
pipeline ids got a parallel `data-hf-render-id` attribute in #3340.
Neither covers this: native `url(#id)`/`href="#id"` resolution can't be
intercepted by a JS proxy, so the `id` attribute itself has to become
document-unique.

Add `svgIdNamespacing.ts`: during `inlineSubCompositions`, every id
declared on an `<svg>`-subtree element is prefixed with the
composition's document-unique runtime id, and every same-document
reference to it — DOM attributes (`href`, `xlink:href`, any `url(#id)`
value including inside `style`) and the composition's own extracted
`<style>` text (both `#id` selectors and `url(#id)` declaration
values) — is rewritten to match. Renamed elements keep their original
id on `data-hf-authored-id`, the same attribute #646 already added for
the composition root, so an inline script's own
`document.getElementById(originalId)` keeps resolving via the existing
`__hfGetElementById` scoping shim.

Extract `selectorIdTokens.ts` from `compositionScoping.ts`'s existing
single-id selector scan so the new many-id rewrite reuses the same
guarded-region logic instead of a second copy.

Closes #3490

Co-Authored-By: Miga <noreply@anthropic.com>

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

R1 adversarial — SVG ID collision namespace pass

Summary: DOM walk over svg[id]/svg [id] renames each declared id to ${sanitizedNamespace}--${originalId}, records the authored id on data-hf-authored-id, and rewrites same-element attribute refs via a url(#...) regex + suffix :href detector. <style> text goes through a postcss pass + a shared selectorIdTokens scan (extracted from the existing root-id rewrite so both callers share one quote/bracket state machine).

Blockers (P0/P1):

  • (none)

Concerns (non-blocking):

  • ARIA id-refs not rewrittenaria-labelledby, aria-describedby, aria-controls, aria-owns, aria-flowto all carry bare id lists (no #), and <svg><title id=\"chart-title\"> referenced by aria-labelledby=\"chart-title\" is standard SVG a11y. Post-rename the id is scene-a--chart-title; the aria attr still says chart-title and either resolves to another scene's title in the merged doc or dangles. Not a first-order render bug (matches the ticket's url(#)/href scope) but worth a followup — same class of native-resolver reference that motivated this PR.
  • [id=\"foo\"] attribute selectors in <style> are silently skippedselectorIdTokens.ts:markUnguardedOffsets deliberately masks out bracket regions (correct for # disambiguation), so rewriteSvgIdReferencesInCss never touches [id=\"clip\"]. Rare in author CSS, but any composition that reaches for the attribute form instead of #clip will silently stop matching after rename. Worth a test asserting current behavior + a doc note.
  • Idempotency not guarded — a second namespaceSvgIds(root, ns) call double-prefixes (scene-a--scene-a--clip) AND overwrites data-hf-authored-id with the already-namespaced id, destroying the original. inlineSubCompositions looks single-pass per instance so this is latent, but no if (el.hasAttribute(SVG_AUTHORED_ID_ATTR)) skip guard exists to catch a future re-entry (e.g. nested inline of a fragment that was itself pre-inlined).
  • No golden-frame / render-pixel test — three unit tests confirm the id map is correct, but nothing asserts "two scenes reusing #fx actually paint their own filter after the compiler ran end-to-end." The repro in #3490 is visual; the failure mode is silent misresolution. A single regression-shard scene mirroring the ticket repro would lock the fix in against future refactors of the URL regex.
  • sanitizeNamespaceSegment folds special chars to - without a collision guardfoo! and foo? both become foo-, so any two runtime ids that differ only in special chars produce the same prefix. Runtime ids from assignBundledRuntimeCompositionIds are compiler-generated and almost certainly alphanumeric, but the sanitizer is a public-ish contract; a defensive test asserting the runtime-id shape would prevent surprise later.

Verified clean:

  • URL fragment coverage: url(#id), url(\"#id\"), url('#id'), url( #id ), and all funcref attrs (clip-path, filter, mask, fill, stroke, marker-start/mid/end) reach the same regex via the generic attribute walk — no per-attr whitelist to drift.
  • Fragment-href: isHrefAttrName catches href and every foo:href suffix (covers xlink:href regardless of DOM impl), and rewriteHrefValue short-circuits on non-# values so https://example.com/#clip and asset URLs stay untouched (explicit test).
  • Longer-id-shadowing: selectorIdTokens.ts sorts candidates longest-first and gates on isSelectorNameChar boundary, so #clip in the id map never eats #clip2. Explicit test in both suites.
  • No-SVG fast path: querySelectorAll(\"svg [id], svg[id]\") returns empty → idMap.size === 0 → early return, no attribute walk.
  • Anonymous-host guard: empty namespace → early return with empty map, no mutation. Matches the same guard scopeCssToComposition and wrapScopedCompositionScript apply — consistent with existing scoping-primitive contract.
  • getElementById(originalId) for author scripts: data-hf-authored-id is set on every renamed element; __hfGetElementById shim (from #646) already checks this attr as fallback. Explicit test asserts scene-a root still resolves symbol by authored id shape after rename.
  • SVG_AUTHORED_ID_ATTR shares the exact string constant (\"data-hf-authored-id\") with AUTHORED_ROOT_ID_ATTR in compositionScoping.ts — the shim's fallback path already reads it, so no third attr introduced.
  • Shared scanner extraction: replaceAuthoredRootIdSelectors is now a thin wrapper over replaceSelectorIdTokens; behavior preserved (single-form → one-element candidate list), one state machine to maintain instead of two.
  • Perf: single tree walk + O(N) attr scan + one postcss parse per composition — no visible O(N²).

CI: Preflight/lint/format/typecheck/unit/producer-integration/SDK/perf-drift/parity/fps/load/scrub/preview-parity/Fallow all green. regression-shards shards 1-9, Smoke: global install, Render/Tests on windows-latest, Test, Analyze (javascript-typescript) still pending — the render-parity signal is exactly what would exercise the visual repro so worth waiting on before merge.

Signature: — Via

Comment thread packages/core/src/compiler/selectorIdTokens.ts Fixed
SVG ids referenced exclusively by JavaScript (e.g. GSAP's
`tl.to("#cut-1")`) must not be renamed — global libraries access
`document` directly and bypass the composition-scoped querySelector
Proxy, so renamed ids break animation targeting.

Now namespaceSvgIds pre-scans for url(#id) funcrefs and href="#id"
fragment refs (both attribute values and <style> text content) and
only renames ids that appear in at least one native reference. Ids
with no native reference (only used by JS) keep their original
names.

Fixes style-17-prod regression where GSAP selectors targeted
#cut-1/#tear-path-1 etc. which were unreachable after rename.

Co-Authored-By: miga-heygen <miguel.sierra_miga@heygen.com>

@terencecho terencecho left a comment

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.

COMMENT — R1 SVG id namespacing @ 75b84322586c06a4f8b9c84415a9e9a8465639a6. No P0/P1 blockers; three orthogonal follow-ups. Holding off APPROVE only because regression-shards (esp. shard-5 which contains style-17-prod) and preview-parity are still in-flight and are the load-bearing signals for both this fix AND the regression it patches.

Concurring with @vibook-bot's R1 (unchanged at head):

  • ARIA id-refs (aria-labelledby / -describedby / -controls / -owns / -flowto) still not tracked. Same class as url(#) / href="#" — native browser resolution — worth a follow-up.
  • [id="foo"] attribute-selector inside <style> intentionally skipped by the bracket-guard mask.
  • No idempotency guard on SVG_AUTHORED_ID_ATTR — a re-entry double-prefixes and clobbers the original.
  • No golden-frame test locking the #3490 repro; leaning on regression-shards + preview-parity to catch it.

Independent concerns (differentiated):

  1. Regression-fix trade-off worth documenting in-code. The 75b8432 pivot to "only rename ids that have a native url()/href reference" is the right call for style-17-prod (GSAP → document.querySelector bypasses the scoped shim, so a renamed id is unreachable). But it introduces a residual cross-comp collision path: two composition instances that both animate the SAME JS-only id (e.g. two catalog scenes each doing tl.to("#cut-1")) now BOTH keep id="cut-1" → merged doc has two id="cut-1" → scene B's GSAP targets scene A's element — the exact document-order-resolution bug this PR fixes, now residual for the JS-only subset. Sound trade-off (less common than the always-broken JS case), but the module doc should call it out so a future reader doesn't broaden the pre-scan and silently re-break style-17. Suggest a comment near collectNativelyReferencedIds naming this residual class.

  2. Test gap paired with #1. No test asserts the two-comps-sharing-JS-only-id case — either as the "we accept this collision" contract or a scoped mitigation. Would lock in the current design.

  3. Runtime-injected url(#id) isn't pre-scanned. A <script> that later does el.setAttribute("clip-path", "url(#foo)") as the ONLY reference to #foo misses the pre-scan → foo isn't renamed → same-class collision if duplicated across comps. Unlikely in HF composition authoring, but a known blind spot.

Verified clean at 75b8432:

  • url(#..) (bare/quoted/single-quoted/spaced), all funcref attrs (clip-path/filter/mask/fill/stroke/marker-*), inline style attr, and <style> text content — all routed through the same URL_HASH_REF_RE. No per-attr whitelist to drift.
  • href / foo:href (covers xlink:href regardless of DOM impl); non-fragment hrefs (https://example.com/#clip) untouched, explicit test.
  • Pre-scan scope is per-comp (namespaceSvgIds(innerRoot ?? contentDoc, ns)) — no cross-comp poisoning path.
  • Multi-instance same-src block: assignBundledRuntimeCompositionIds gives each instance a distinct runtime id → distinct namespace. End-to-end test locks it.
  • data-hf-authored-id reuses the exact string constant from compositionScoping.ts__hfGetElementById fallback already checks it, no third attr introduced.
  • Shared selectorIdTokens.ts scanner: replaceAuthoredRootIdSelectors is now a thin wrapper — one state machine to maintain, longest-first sort preserved (#clip never eats #clip2).
  • <style> extraction pipeline: sub-comp <style> textContent (including SVG-inline <style>) is extracted via plan.styleSources and rewritten by rewriteSvgIdReferencesInCss before scopeCssToComposition scopes it. Ordering is correct (rename → asset URL rewrite → composition scope).

CI: many required checks in-progress at snapshot; nothing failing. regression-shards (esp. shard-5 containing style-17-prod) and preview-parity are the load-bearing signals for both the primary fix AND the regression it patches — worth waiting on before merge. Happy to bump to APPROVE once those settle green.

— Review by tai (pr-review)

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Validated against current main c98d6fb with the public #3490 fixture in Chrome: the collision reproduces, and this patch restores the missing/incorrect native SVG paint. However, I cannot recommend merging at 75b8432 because renaming also breaks working author code.

  • Blocker — selector compatibility: packages/core/src/compiler/svgIdNamespacing.ts:203-205 renames any natively referenced ID, even when scripts also use it. With <svg><path id="shape"/><use href="#shape"/></svg>, svg.querySelector("#shape") now returns null, even in a single composition. Preserving the authored attribute/getElementById shim does not preserve native Element selector APIs or GSAP selector arrays. Add a regression covering native and script references to the same element and preserve those lookups before landing. This is the mixed-use case beyond the previously noted JS-only collision.
  • Blocker — escaped CSS IDs: svgIdNamespacing.ts:221-225 searches raw IDs and emits unescaped replacement selectors. For id="fx.1", #fx\.1 stops matching after rename. Match decoded CSS IDs and emit valid escaped selectors; cover an actual styled element whose ID is also referenced natively.

The original issue is worth fixing, but a partial selector shim still introduces regressions. Keeping this PR open for a complete compatibility fix rather than publishing the partial successor explored during this audit.

Verdict: REQUEST CHANGES
Reasoning: The native rendering improvement is demonstrated, but valid existing selector-based author code and escaped-ID styling regress.

— Codex

miga-heygen and others added 2 commits September 10, 2026 18:24
`replaceSelectorIdTokens` compared the raw text after `#` against the
candidate ids, so an escaped selector such as `#fx\.1` never matched the
element whose id attribute is `fx.1`, and callers spliced in unescaped
replacements that were invalid CSS for ids containing `.`, `:` or a
leading digit.

The scanner now reads the token as a CSS identifier per CSS Syntax
Level 3 (`\.`-style and `\HEX `-style escapes, U+FFFD for out-of-range
code points) before comparing, and exports a spec-faithful
`escapeCssIdentifier` (the `CSS.escape` algorithm) for emitting
replacements. Consuming the whole identifier also makes the token
boundary check exact for non-ASCII and escaped name characters.

Also removes the ambiguous `[^\]]` branch from the guarded-segment
regex so a bracket body cannot match both the quoted and bare
alternatives (flagged as potential exponential backtracking).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…resolving

Renaming every natively referenced SVG id broke author code even in a
single composition: with `<path id="shape"/><use href="#shape"/>`,
`svg.querySelector("#shape")` returned null because the id had been
prefixed although nothing collided. The getElementById shim did not
cover Element selector APIs or GSAP selector arrays, and escaped
stylesheet selectors (`#fx\.1` for `id="fx.1"`) stopped matching after
the rename.

Namespacing now runs once per assembled document, after every instance
is inlined, and is collision-driven: an id is renamed only when the
merged document would otherwise carry it more than once. One colliding
element keeps the authored id - the first in document order that cannot
be renamed (top-level document content, or a JS-only id), else the
first in document order - so native `url(#id)`/`href="#id"` resolution
keeps binding to it, and every other renamable duplicate gets a
document-unique id with its references rewritten. Ids that never
collide, including every single-composition project, are byte-for-byte
unchanged. The demand-driven gate is kept: JS-only ids are never
renamed, and the residual cross-instance collision for that class is
documented in `collectNativelyReferencedIds`. Nested hosts are excluded
from their parent's scope, minted ids are checked against every id in
the document, and an existing data-hf-authored-id is never overwritten.

The composition script runtime keeps `#authoredId` selectors resolving
for renamed elements by rewriting the token to
`:is(#authoredId, [data-hf-authored-id="authoredId"])` - a superset, so
the same selector still matches an untouched instance:
- scoped `document.querySelector/querySelectorAll` and the GSAP proxy
  (`gsap.to`, `timeline.to`, `gsap.utils.toArray/selector`);
- GSAP array targets, whose string entries now resolve through the same
  scoped lookup instead of the global document;
- `Element.prototype.querySelector/querySelectorAll`, patched once per
  document and only when at least one id was actually renamed, so a
  document without collisions runs untouched natives. The renamed-id
  set is discovered from `[data-hf-authored-id][id]` in the compiled
  DOM, so no extra plumbing between compiler and runtime is needed.
`Document.prototype` is deliberately left alone: an unscoped
document-wide lookup by a third-party library cannot know which
instance it means and resolves to the first in document order, exactly
as before. `matches`/`closest` and selectors built at runtime with hex
escapes are likewise outside the shim.

Stylesheet rewriting matches ids through CSS identifier escapes and
emits escaped replacements, so `#fx\.1 { ... }` becomes
`#scene-b--fx\.1 { ... }` and `url(#fx.1)` becomes `url(#scene-b--fx.1)`.

Tests: unit coverage for the collision/keeper rules, exclusion, id
minting, escaped selectors and hex escapes; end-to-end regressions that
execute the compiled scoped scripts in a real DOM for (i) a single
composition with native and script references to the same element,
(ii) two colliding compositions where native refs and every script
lookup form resolve to their own element, and (iii) an escaped id that
is styled and natively referenced keeping its rule after rename.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@miga-heygen

miga-heygen commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both blockers at 0f4830bd0cd85830b3eb6de39fa98f45030a223f (two new commits on top of 75b8432, no rewrite): 2b85e607c (selector scanner: CSS identifier decoding + CSS.escape equivalent) and 0f4830bd0 (collision-driven renaming + selector runtime).

Blocker 1 — selector compatibility

  • Renaming is now collision-driven on the assembled document, not per file: packages/core/src/compiler/svgIdNamespacing.ts:375 (namespaceCollidingSvgIds) builds an id census over the whole document and :323 (planRenames) renames an element only when its id appears more than once; one colliding element keeps the authored id (first unrenamable in document order, else first in document order) so native url(#)/href="#" resolution still binds to it. A single composition — the <svg><path id="shape"/><use href="#shape"/></svg> repro — comes out with its ids byte-for-byte unchanged and no runtime shim installed. The pass moved to after the inline loop in inlineSubCompositions.ts:460-490, with nested hosts excluded from their parent's scope. The JS-only gate is kept and its residual is documented at svgIdNamespacing.ts:198.
  • For genuine collisions, the composition script runtime (compositionScoping.ts) rewrites #authoredId:is(#authoredId, [data-hf-authored-id="authoredId"]) (a superset, so it still matches an untouched instance): :269 generalised token rewriter, :340 renamed-id discovery from [data-hf-authored-id][id] (cached per document), :389 applied in the scoped document proxy / GSAP proxy path, :535 GSAP array targets now resolve string entries through the scoped lookup, and :371 installs the same rewrite on Element.prototype.querySelector/querySelectorAll once per document — only when at least one id was actually renamed (:648). I chose the prototype patch over leaving element.querySelector uncovered because it is the only way to reach arbitrary-Element lookups without compiler↔runtime plumbing; it is gated so collision-free documents run untouched natives. Document.prototype is deliberately not patched: an unscoped document-wide lookup by a third-party library cannot know which instance it means and resolves to the first in document order, exactly as before this PR. matches/closest and runtime selectors written with hex escapes are also outside the shim — all of this is stated in the code comments and the PR body.
  • Regressions (executing the compiled scoped scripts in a real DOM): inlineSubCompositions.test.ts:851 (single composition, native + script refs, svg.querySelector/document.querySelector/getElementById all resolve), :877 (two colliding compositions — native refs and every lookup form incl. GSAP array/toArray resolve to their own element), compositionScoping.test.ts:1078-1135 (shim gating, element/document/GSAP-array lookups, escaped authored id), svgIdNamespacing.test.ts:45 (byte-for-byte single composition).

Blocker 2 — escaped CSS ids

  • selectorIdTokens.ts:74 (decodeCssIdentifierAt) reads the #… token as a CSS identifier per CSS Syntax Level 3 (\. and \HEX escapes, one trailing whitespace, U+FFFD for out-of-range) before comparing with the raw attribute id; :129 (escapeCssIdentifier) is the CSS.escape algorithm (Node has no CSS global; the repo only used CSS.escape in browser runtime code) and svgIdNamespacing.ts:410 emits replacements through it, so #fx\.1 { … }#scene-b--fx\.1 { … }. url(#fx.1) fragments keep their original quoting and only swap the id (the prefix is restricted to [A-Za-z0-9_-], so a valid fragment stays valid). The runtime rewriter uses CSS.escape when available and matches both the raw and escaped spelling.
  • Also fixed the CodeQL alert on selectorIdTokens.ts:154 by removing the ambiguous [^\]] branch from the guarded-segment regex.
  • Regressions: inlineSubCompositions.test.ts:934 (id="fx.1" styled via #fx\.1 and referenced via filter="url(#fx.1)" in two colliding compositions — the rewritten rule selector matches the renamed element in a real selector engine, the untouched instance's stylesheet is unchanged, and the author's escaped selector resolves through the scoped proxy), svgIdNamespacing.test.ts:330-341 (escaped and hex-escaped selectors), selectorIdTokens.test.ts (decode/escape parity and round-trip).

CI follow-up — head is now 04aa0f931e9184a5fc03072061757a52d6fb6738 (one more commit, 04aa0f931, tests only): the required Tests on windows-latest job was failing on this branch since 31274556e because inlineSubCompositions.test.ts imported assignBundledRuntimeCompositionIds from ./htmlBundler, which statically imports esbuild, and esbuild refuses to load inside a jsdom test realm on Windows (new TextEncoder().encode('') instanceof Uint8Array is incorrectly false). The test now carries a small local mirror of that id assignment (<id>__hf<n> for duplicate hosts); no behaviour change. regression (all 9 shards, incl. shard-5 style-17-prod) passed on 0f4830bd0.

Local results: svgIdNamespacing 24, selectorIdTokens 10, inlineSubCompositions 35, compositionScoping 54, htmlBundler 58 — all passing; typecheck (core, producer), oxlint, oxfmt, fallow audit clean.

— Miga

…n suite

`inlineSubCompositions.test.ts` imported `assignBundledRuntimeCompositionIds`
from `./htmlBundler` only to give duplicate hosts distinct runtime ids.
`htmlBundler.ts` statically imports esbuild, and esbuild refuses to load
inside a jsdom test realm on Windows ("new TextEncoder().encode('')
instanceof Uint8Array is incorrectly false"), which made the whole suite
fail to load in the Windows test job while every other suite passed.

The test now carries a small local mirror of the bundler's assignment
(`<id>__hf<n>` for hosts whose authored id repeats), so the suite has no
esbuild dependency. Test behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed 04aa0f931e9184a5fc03072061757a52d6fb6738. The 88 focused scoping/SVG/selector tests pass and all eight required CI contexts are green. The original direct-query, selector-array, and ordinary escaped-ID cases are covered, but two executable regressions remain.

  • [P2] Preserve CSS selector grammar in the runtime shimpackages/core/src/compiler/compositionScoping.ts:354-355 registers both raw and escaped spellings of every renamed ID. If an authored ID is foo.bar, the valid compound selector #foo.bar (id foo, class bar) gets rewritten as the literal ID foo.bar. I executed the compiled wrapper with both elements present: svg.querySelector("#foo.bar") returned scene--foo.bar instead of foo. Only the escaped #foo\.bar spelling should target that literal ID. Decode selector tokens rather than matching arbitrary raw ID strings, and cover both selectors side by side.

  • [P2] Preserve inherited SVG references across nested scopespackages/core/src/compiler/svgIdNamespacing.ts:236-240 / inlineSubCompositions.ts:460-463 exclude child subtrees from parent rewriting, but a child with no local definition does not inherit the parent rename map. Actual inlineSubCompositions repro: parent defines/uses red #paint; nested child uses fill="url(#paint)"; a later top-level SVG defines blue #paint. The parent becomes parent--paint, while the child remains url(#paint) and binds to blue instead of its previous red definition. Preserve the original reference target when a nested child does not shadow it, including extracted child CSS.

Verdict: REQUEST CHANGES
Reasoning: The earlier cases improved, but these two cases still silently change which element or SVG resource author code references.

— Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nested compositions that reuse SVG ids bind url(#…) / <use href="#"> to the first scene after inline

6 participants