From 3f182f8091405ab8e80b30a990f5c14182a96bf1 Mon Sep 17 00:00:00 2001 From: Thomaswebstich Date: Sat, 11 Jul 2026 21:46:14 +0200 Subject: [PATCH] fix(scoping): rewrite prefix/substring composition-id selectors in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scopeSelector() only rewrote the EXACT `[data-composition-id="x"]` root selector in place; prefix/substring forms (`^=`, `*=`, `$=`) of the authored composition id fell through to the prepend branch, becoming 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 the rule matches nothing and the sub-comp's class-scoped styles silently drop (class-styled elements render unstyled). This surfaces when a sub-comp ships prefix-match root selectors — e.g. an authoring pipeline rewrites `="x"` to `^="x"` so rules survive the duplicate-instance rename (x -> x__hf2). In a multi-scene document each such sub-comp then loses its styling on mount, while single-mount renders (which keep the inner structure) are unaffected. Match the substring operators alongside `=` so the authored-root selector is rewritten in place to the instance scope in every form. Adds a regression test. Co-Authored-By: Claude Fable 5 --- .../src/compiler/compositionScoping.test.ts | 27 +++++++++++++++++++ .../core/src/compiler/compositionScoping.ts | 13 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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)) {