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

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). With a runtime scope
// override (the renamed instance), the prefix form MUST be rewritten in
// place to that scope — exactly like the exact form. If it were instead
// prepended, it would become `${scope} [data-composition-id^="scene"] …`,
// which needs a NESTED composition-id element; the loader strips the inner
// root's composition-id 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"] { --accent: red; }
[data-composition-id*="scene"] .bar { fill: blue; }`,
"scene",
scope,
);

expect(scoped).toContain('[data-composition-id="scene__hf2"] .gas { stroke: red; }');
expect(scoped).toContain('[data-composition-id="scene__hf2"] { --accent: red; }');
expect(scoped).toContain('[data-composition-id="scene__hf2"] .bar { fill: blue; }');
// The prefix/substring forms must be consumed in place — never left intact
// behind a prepended scope (that is the bug this guards against).
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 @@ -112,8 +112,19 @@ function scopeSelector(
const trimmed = selectorWithoutRootTiming.trim();
if (!trimmed) return selector;
if (/^(html|body|:root|\*)$/i.test(trimmed)) return 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