Skip to content

fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element - #411

Open
YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/pseudo-element-declaration-leak
Open

YevheniiKotyrlo wants to merge 6 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/pseudo-element-declaration-leak

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A ::selection or ::placeholder rule maps one declaration onto a React Native prop and keeps every other one, so the rest style the element itself:

  • .a::selection { background-color: red } paints the whole control, and ::selection { color } sets selectionColor, the band behind the selected text, which CSS styles with background-color.
  • A color publishes currentcolor and a font-size the em base to the element and its subtree, container-name makes the element a container, and an animation makes it animated.
  • A custom property the pseudo-element declares lands on the element and its children, and the dark value of light-dark() bypasses the mapping.

Root cause

modifyRuleForSelection / modifyRuleForPlaceholder rewrote rule.d and returned the rule, so v, c, a and dv still applied to the element, and the light-dark() extra rule replaced d after the rewrite.

Solution

  • The compiler rebuilds a pseudo-element rule from what the pseudo-element can express: ::selection { background-color } → selectionColor, ::placeholder { color } → placeholderTextColor, and the variables it declares, under the selector's own conditions. A policy typed over keyof StyleRule classifies every field, so a new field fails to compile until it is classified. The rest is dropped and listed by compile().warnings(). The light-dark() extra rule is scoped the same way.
  • CSS Pseudo-Elements 4 §3.5: a pseudo-element's own custom properties are its own, and the rest come from its originating element. The scoped rule keeps its variables and records its pseudo-element (pe). The runtime gives each pseudo-element a scope that shadows the element's variables and falls back to them, and never publishes it to children. The inliner leaves alone a property only pseudo-elements declare, so inlineVariables does not change the result. currentcolor inside a pseudo-element is its own color (CSS Color 4).

Breaking: ::selection { color } no longer sets selectionColor, so Tailwind's selection:text-* becomes selection:bg-*. nativewind/nativewind#1754 verified selection:text-* on the RC; this follows CSS and Tailwind instead, where selection:text-* is the selected text. The BREAKING CHANGE: footer is on the first commit.

Tests

  • src/__tests__/native/pseudo-elements.test.tsx: each leak renders beside a control carrying only what the platform expresses. For both pseudo-elements, with inline variables on and off, a custom property applies within its rule and across rules, wins over the originating element's and an ancestor's, cascades by importance, follows its media query, falls back to the element's, an ancestor's and :root's, and never reaches the element, its children, the other pseudo-element or a child's pseudo-element. Also currentcolor, and light-dark() in both schemes.
  • src/__tests__/compiler/pseudo-elements.test.ts: the compiled rule for each case, and which custom properties the inliner leaves alone (declared, !important, nested) or still inlines (an element's, one *, ::before, ::after declares).
  • src/__tests__/vendor/tailwind/states.test.tsx: selection:bg-*, selection:text-*, selection:[--brand:…] selection:bg-(--brand) and selection:bg-current.
  • src/__tests__/compiler/pseudo-element-field-policy.test.ts: the policy over every field.

On main, 87 of the 122 cases in the first three files fail; all pass here. Each mechanism was reverted on its own to confirm its cases fail without it. Every expectation matches what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same stylesheet, read through getComputedStyle(element, "::selection") and "::placeholder".

Verification

On Windows with Node 26: yarn lint clean · yarn typecheck clean · yarn test --maxWorkers=2 --coverage 1447 passed, 4 failed — the babel cases that also fail on main on this machine · yarn build clean · yarn example expo export --platform web exported · nothing unstaged after either build. Not run: the iOS build.

No open issue here covers it. Device evidence, before and after: #411 (comment)

Known limits

  • compile().warnings() is the only place a dropped declaration appears; a Metro build does not print it (compile().warnings() is unreachable from a real build: 50 addWarning call sites, no Metro consumer #424).
  • A parent's ::selection does not reach a descendant's highlight. CSS Pseudo-Elements 4 §3.5 inherits it, as Chromium and WebKit do, and Tailwind's selection: variant targets descendants (& *::selection); on native it styles only the input it is on.
  • A custom property the pseudo-element inherits is resolved with the pseudo-element's own variables, so .a { --y: X; --x: var(--y) } .a::selection { --y: Z; background-color: var(--x) } paints Z where browsers copy the originating element's computed X. That is how main resolves every inherited value, not only a pseudo-element's.
  • ::placeholder { color: inherit } depends on fix: resolve CSS-wide keywords and the inherited colour as CSS defines them #391, which compiles color: inherit.

Related

#420 resolves light-dark() at runtime and removes the extra rule this scopes, so whichever lands second drops that scoping.

Merge order

This overlaps #389, #391, #413 and #420; I'll re-cut whichever lands after the others.

It also shares lines with #393 (native/styles/calculate-props.ts), #412 (compiler/compiler.ts, native/react/rules.ts), #430 (README.md); whichever lands second rebases.

It also shares lines with #428 (README.md); whichever lands second rebases.

It also shares lines with #474 (native/styles/calculate-props.ts); whichever lands second rebases.

Base

Re-applied onto main (a5002c5).

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — All three boxes are filled — a ::selection fill compiled to the same thing a plain background does.

FIXED — Only the control is filled. Both ::selection boxes are empty outlines.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

…pseudo-element

A pseudo-element rule mapped one declaration onto a React Native prop and
returned the rest unchanged, so every other declaration styled the host:
`::selection { background-color }` painted the whole control, a `color`
published `currentColor` to the subtree, a `font-size` became the host's em
base, `container-name` made the host a container and an animation made it
animated.

The rule is now rebuilt from what the pseudo-element can express: the
mapped declaration under the selector's own conditions. Every StyleRule
field is classified by a policy typed over `keyof StyleRule`, so a new
field fails to compile until it is. Dropped declarations are reported
through `compile().warnings()` under `::selection` / `::placeholder`.

`::selection` maps `background-color` to `selectionColor`: in CSS `color`
there is the selected text, while `selectionColor` is the band behind it.

BREAKING CHANGE: `::selection { color }` no longer sets `selectionColor`;
use `background-color` (Tailwind `selection:bg-*`). Declarations a
pseudo-element cannot express are dropped instead of styling the host, a
rule left with none is not registered, and `compile().warnings()` reports
them.
CSS Pseudo-Elements 4 §3.5: the custom properties a pseudo-element
declares are its own, and every other one comes from its originating
element. Dropping them left `::selection { --x: red; background-color:
var(--x) }` with no selection color, and with inlining on a declared-once
property was substituted into every element that reads it.

A scoped rule now keeps its variables and carries the pseudo-element it
styles (`pe`). The runtime gives each pseudo-element a scope that
shadows the element's variables and falls back to them, and never
publishes it to the element's children. The inliner leaves a property
declared under a pseudo-element alone, so the result no longer depends
on `inlineVariables`. `currentcolor` inside a pseudo-element reads its
own `color`, as CSS Color 4 specifies.

A light-dark() extra rule replaces the rule's declarations after the
scoping, so its dark value reached the element; it is scoped too.
…ements

Only a property that pseudo-elements alone declare is theirs. One declared
for `*, ::before, ::after` also belongs to every element, so it keeps the
inlining it had: excluding it left Tailwind's `--tw-border-style`
unresolved in `border-x-*`.
A prototype chain made the element's variables visible to the
pseudo-element, which holds only while the resolver reads a scope by
property lookup. The scope is now a plain object: the element's variables,
then the pseudo-element's own, assigned once every rule has been walked.
Every declaration that reads a variable is delayed, so each resolves
against the full scope.
A mapped declaration now compiles to exactly what the same declaration
gives an element, re-targeted at the pseudo-element's prop, with the same
variable subscription and the same variables declared. The comparison is
derived per value, so it holds however a value is represented.
A browser resolves light-dark() against the element's used colour
scheme, which needs `color-scheme: light dark` to follow the preferred
one. Declaring it keeps the case true wherever color-scheme is honoured.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant