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
30 changes: 30 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,36 @@ body { margin: 0; }
expect(scoped).toContain("overflow: hidden");
});

it("rewrites prefix/substring composition-id selectors (^= *= $=) in place, not by prepending", () => {
// Sub-comps commonly ship `[data-composition-id^="scene"]` (an authoring
// pipeline rewrites `="scene"` to `^="scene"` so the rule survives the
// duplicate-instance rename scene -> scene__hf2). The prefix/substring forms
// must be consumed by the composition-id scoper exactly like the exact form:
// a DESCENDANT selector is rewritten in place to the instance scope, and a
// BARE root selector flows into the composition-box remap. If a prefix form
// fell through to the prepend branch it would become
// `${scope} [data-composition-id^="scene"] …`, which needs a NESTED
// composition-id element the loader strips at mount, so nothing matches and
// the rule drops.
const scope = '[data-composition-id="scene__hf2"]';
const scoped = scopeCssToComposition(
`[data-composition-id^="scene"] .gas { stroke: red; }
[data-composition-id*="scene"] .bar { fill: blue; }
[data-composition-id^="scene"] { --accent: red; }`,
"scene",
scope,
);

// Descendant prefix/substring selectors → in-place rewrite to the instance scope.
expect(scoped).toContain('[data-composition-id="scene__hf2"] .gas { stroke: red; }');
expect(scoped).toContain('[data-composition-id="scene__hf2"] .bar { fill: blue; }');
// A bare prefix root selector → the composition-box remap (host or flattened inner root).
expect(scoped).toContain('[data-composition-id="scene__hf2"]:not(:has([data-hf-inner-root]))');
// The prefix/substring forms are consumed — never left intact behind a prepended scope.
expect(scoped).not.toContain('[data-composition-id^="scene"]');
expect(scoped).not.toContain('[data-composition-id*="scene"]');
});

it("wraps classic scripts without render-loop requestAnimationFrame waits", () => {
const wrapped = wrapScopedCompositionScript("window.__ran = true;", "scene");

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,19 @@ function scopeSelector(
// caused the parent-body clobber, which is what this remap targets.
return scopeRootSelectors ? compositionBoxSelector(scope) : selector;
}
// Match the composition-root attribute whether the author used an exact
// (`=`) or a substring operator (`^=`, `*=`, `$=`) with the authored id.
// Sub-comps often ship prefix-match selectors — e.g. authoring pipelines
// rewrite `[data-composition-id="x"]` to `[data-composition-id^="x"]` so
// rules survive the duplicate-instance rename (x -> x__hf2). Those must be
// rewritten IN PLACE to the instance scope, exactly like the exact form.
// Otherwise they fall through to the prepend branch below and become a
// two-level `${scope} [data-composition-id^="x"] …` selector that needs a
// NESTED composition-id element — but the loader strips the inner root's
// composition-id at mount, so nothing matches and the rules silently drop
// (class-styled elements render unstyled).
const compositionIdPattern = new RegExp(
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
`\\[\\s*data-composition-id\\s*[\\^\\*\\$]?=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
);
if (compositionIdPattern.test(trimmed)) {
Expand Down