diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index ddd9a1ab60..c4472ca5c7 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -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"); diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 876ab59bc2..e0a3eada01 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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)) {