From 24cfd5a0ed5090f6e60971e4c2b676dca6d262fc Mon Sep 17 00:00:00 2001 From: Matthew Beck Date: Fri, 7 Nov 2025 23:20:37 +0000 Subject: [PATCH] fix(compiler): support complex selectors in :nth-child() :nth-child() (and its siblings) support complex expressions, e.g. `:nth-child(2n of :is(.foo, .bar))`. Previously we'd choke because of the `:is()`. Now, we reuse the `_parenSuffix` subexpression to match nested parentheses the same way we do for :host() and :host-context(). Note that we only support 3 levels of nesting, so a selector like `:nth-child(n of :is(:has(:not(.foo))))` will still break. I'll say yet again that we really should add a proper parser so we stop getting bug reports like this :) Fixes #64913 --- packages/compiler/src/shadow_css.ts | 5 +++-- .../test/shadow_css/host_and_host_context_spec.ts | 3 +++ .../compiler/test/shadow_css/shadow_css_spec.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index a1d570ab292..c9d36d1e2c4 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -1029,9 +1029,9 @@ class SafeSelector { // Replaces the expression in `:nth-child(2n + 1)` with a placeholder. // WS and "+" would otherwise be interpreted as selector separators. - this._content = selector.replace(/(:nth-[-\w]+)(\([^)]+\))/g, (_, pseudo, exp) => { + this._content = selector.replace(nthRegex, (_, pseudo, exp) => { const replaceBy = `__ph-${this.index}__`; - this.placeholders.push(exp); + this.placeholders.push(`(${exp})`); this.index++; return pseudo + replaceBy; }); @@ -1076,6 +1076,7 @@ const _level1Parens = String.raw`(?:\(${_noParens}\)|${_noParens})+?`; // Matches content with at most TWO levels of nesting, e.g., "a(b(c)d)e" const _level2Parens = String.raw`(?:\(${_level1Parens}\)|${_noParens})+?`; const _parenSuffix = String.raw`(?:\((${_level2Parens})\))`; +const nthRegex = new RegExp(String.raw`(:nth-[-\w]+)` + _parenSuffix, 'g'); const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix + '?([^,{]*)', 'gim'); // note: :host-context patterns are terminated with `{`, as opposed to :host which // is both `{` and `,` because :host-context handles top-level commas differently. diff --git a/packages/compiler/test/shadow_css/host_and_host_context_spec.ts b/packages/compiler/test/shadow_css/host_and_host_context_spec.ts index 825f68b9f4a..db7c473d771 100644 --- a/packages/compiler/test/shadow_css/host_and_host_context_spec.ts +++ b/packages/compiler/test/shadow_css/host_and_host_context_spec.ts @@ -71,6 +71,9 @@ describe('ShadowCss, :host and :host-context', () => { expect(shim(':host:nth-child(8n+1) {}', 'contenta', 'a-host')).toEqualCss( '[a-host]:nth-child(8n+1) {}', ); + expect(shim(':host(:nth-child(3n of :not(p, a))) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:nth-child(3n of :not(p, a)) {}', + ); expect(shim(':host:nth-of-type(8n+1) {}', 'contenta', 'a-host')).toEqualCss( '[a-host]:nth-of-type(8n+1) {}', ); diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 8705f65e5e6..2777b83272d 100644 --- a/packages/compiler/test/shadow_css/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -214,6 +214,20 @@ describe('ShadowCss', () => { 'table[contenta] [contenta]:where(td, th):hover { color:lime;}', ); + // :nth + expect(shim(':nth-child(3n of :not(p, a), :is(.foo)) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:nth-child(3n of :not(p, a), :is(.foo)) {}', + ); + expect(shim('li:nth-last-child(-n + 3) {}', 'contenta', 'a-host')).toEqualCss( + 'li[contenta]:nth-last-child(-n + 3) {}', + ); + expect(shim('dd:nth-last-of-type(3n) {}', 'contenta', 'a-host')).toEqualCss( + 'dd[contenta]:nth-last-of-type(3n) {}', + ); + expect(shim('dd:nth-of-type(even) {}', 'contenta', 'a-host')).toEqualCss( + 'dd[contenta]:nth-of-type(even) {}', + ); + // complex selectors expect(shim(':host:is([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss( '[a-host]:is([foo],[foo-2]) > div.example-2[contenta] {}',