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

fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element#411
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

Breaking. ::selection { color } stops painting anything on native. The BREAKING CHANGE: footer is on the last commit, so @release-it/conventional-changelog reads this branch as a major bump rather than a patch — checked against the preset in .config/release-it.config.ts, which reports { reason: 'There is 1 BREAKING CHANGE and 0 features', releaseType: 'major' }.

Problem

A pseudo-element's declarations are scoped to the pseudo-element. react-native-css compiles a ::selection / ::placeholder rule by mapping ONE declaration onto a React Native prop and returning every other declaration unchanged — so an unmapped one is applied to the real element.

Two consequences, both silent:

  1. ::selection { background-color } paints the element. It compiles to exactly what a plain background-color on the same class compiles to, so a rule intended to tint a selection tints the whole control.
  2. ::selection { color } maps to selectionColor, which is its opposite. In CSS, color inside ::selection is the colour of the selected text; in React Native selectionColor is the band painted behind it. A stylesheet asking for white selected text gets a white band, and the text it meant to lighten is unchanged — now sitting on a light band.

::placeholder has the same leak on its unmapped side; its colorplaceholderTextColor mapping is correct and is unchanged here.

Reproduction

No framework, no device, no bundler — the compiler alone.

import { compile } from 'react-native-css/compiler';

const CSS = `
.a::selection   { background-color: rgb(1, 2, 3); }
.b::selection   { color: rgb(4, 5, 6); }
.c::placeholder { background-color: rgb(7, 8, 9); }
.d::placeholder { color: rgb(10, 11, 12); }
.e              { background-color: rgb(1, 2, 3); }
`;

for (const [className, rules] of compile(CSS).stylesheet().s ?? []) {
  console.log(className, JSON.stringify(rules.flatMap((rule) => rule.d ?? [])));
}

Actual, on 3.0.7:

.a    -> [{"backgroundColor":"#010203"}]
.b    -> [{},["#040506",["selectionColor"]]]
.c    -> [{"backgroundColor":"#070809"}]
.d    -> [{},["#0a0b0c",["placeholderTextColor"]]]
.e    -> [{"backgroundColor":"#010203"}]

With this PR:

.a    -> [["#010203",["selectionColor"]]]
.d    -> [["#0a0b0c",["placeholderTextColor"]]]
.e    -> [{"backgroundColor":"#010203"}]

.b and .c do not appear at all — a rule with no surviving declaration is not registered, so the class key is absent from stylesheet().s rather than present-and-empty.

Read .a against .e on 3.0.7. They are byte-identical output for two selectors that mean different things — one asks to tint a selection, the other to paint a box, and the compiler cannot tell them apart afterwards. .b is the inversion: a request to recolour selected TEXT emerges as the selection BAND.

What a real app sees

Written together, which is how every web stylesheet styles a selection:

.field::selection {
  background-color: #09090b;
  color: #ffffff;
}

3.0.7 renders a near-black background across the whole field plus a white selection band. Neither declaration does what it says, and nothing warns.

Root cause

modifyStyleDeclaration returns unmatched declarations rather than dropping them, on every path — including the mapped one, where rest leaks alongside the mapped prop. The second defect is the caller's argument rather than this function's logic: modifyRuleForSelection passes "color" as the source property, when the property meaning "the selection band" on both platforms is background-color.

The fix — a field policy, not an allowlist

Dropping unmapped declarations closes d and leaves every other declaration-derived field open. modifyRuleForSelection mutated rule.d and returned the same rule, so everything else rode along onto the host:

  • v — a color mirrors into --__rn-css-color, which every descendant reads as currentColor, so .a::selection { color: red } painted the whole subtree. A font-size mirrors into --__rn-css-em and became the element's em base.
  • ccontainer-name inside a pseudo-element registered the HOST as a named container.
  • a — a transition or animation left a: true on a host with nothing to animate.
  • dv — an unmapped var() left dv: 1 behind.

modifyRuleForSelection and modifyStyleDeclaration are gone. scopeRuleToPseudoElement rebuilds the rule from an accumulator instead of mutating it, and every field of StyleRule is classified:

export const pseudoElementFieldPolicy = {
  s: "selector",
  m: "selector",
  p: "selector",
  cq: "selector",
  aq: "selector",
  d: "rebuilt",
  dv: "rebuilt",
  v: "dropped",
  c: "dropped",
  a: "dropped",
  target: "dropped",
} as const satisfies Record<keyof StyleRule, "selector" | "rebuilt" | "dropped">;

The satisfies over Record<keyof StyleRule, …> is the enforcement: adding a field to StyleRule fails to compile until it is classified, which is what stops the next declaration-derived field escaping the way v, c, dv and a did while only d was rewritten.

Dropped declarations are also reportedcompile(css).warnings() gains entries under the synthetic keys "::selection" / "::placeholder", deduped per authored rule per pseudo-element. That covers v: an authored custom property is the one declaration that lands there rather than in d, and it is reported under the name it was written with.

compile(`.a::selection { background-color: red; --brand: blue }`, {
  inlineVariables: false,
}).warnings();
// { values: { "::selection": ["--brand"] } }

v also carries the compiler's own mirrors — --__rn-css-color and --__rn-css-em beside color and font-size, --__rn-css-direction beside direction — and each of those sits next to a d declaration the report already names. Reporting the field wholesale would add a variable the author never wrote to every rule that sets a colour or a font size, so the mirrors are filtered by the namespace they are minted in. The prefix is named once at the filter site and tied to the mint sites by a test that reads the names off a compiled rule rather than restating them, so renaming the namespace at either end goes red.

With inlineVariables left on, a custom property declared once is substituted into its uses and its declaration removed before any rule is built — nothing reaches the pseudo-element, so nothing is dropped and nothing is reported. inlineVariables: false keeps it, and that is the configuration the README's VariableContext section asks for, so it is where the silent drop costs the most.

This makes the drop representable, not visible: it lands on warnings(), which no real build reads. See the third known limit below.

Cross-platform: this narrows the gap, it does not widen it

metro-transformer.ts returns early for platform === "web", so a browser gets the authored CSS unchanged and ::selection behaves as CSS specifies. The question is what native does with the same source.

Measured over 18 ::selection properties, scoring each against what a browser does with a highlight pseudo (CSS Pseudo-Elements L4 §3.2 — only colour and decoration properties are honoured; everything else is ignored, which leaves the host untouched):

agrees with the browser
main 1 of 18
this PR 15 of 18
properties where this PR agrees less 0

On main, width / height / font-size / transform / opacity / border-width / margin / padding / display / position all land on the HOST, animation and transition set a: true on the host, background-color paints the host, and color publishes --__rn-css-color to the whole subtree. A browser does none of that. Measured in isolation, container-name and an authored custom property score as agreeing on main, but neither is scoped — modifyRuleForSelection bails on if (!rule.d) return, so a rule whose declarations are ALL unmappable is discarded wholesale. Pair container-name with a declaration that does map and it registers the host as a container, which is the 1-of-18 row.

The three still unmatched under this PR are color, text-decoration and text-shadow: a browser paints the selection with them, React Native has no prop for any of them, so nothing happens. That is a change in the KIND of divergence — from mis-styling the host to doing nothing — not an increase in it.

Separately, and still true: a selection:text-* user loses their highlight. That is a user-facing break, and it is why the last commit carries a BREAKING CHANGE: footer. It is not a parity regression — that stylesheet was painting a band where the author asked for text.

What is breaking

  1. ::selection { color } is dropped rather than mapped to selectionColor. Anyone relying on 3.0.7's behaviour is relying on an inverted mapping, but they are relying on it. Tailwind's selection:text-* stops painting; selection:bg-* is the equivalent.
  2. ::selection { background-color } becomes selectionColor instead of painting the host. This is the one pre-existing expectation the PR rewrites, in vendor/tailwind/states.test.tsx.
  3. v, c, a and target inside a pseudo-element no longer reach the host. The real fix, and a live behaviour change for --__rn-css-color / --__rn-css-em consumers. (target is inert — declared on StyleRule, set nowhere in src/compiler.)
  4. A rule with no surviving declaration is no longer registered. The class key disappears from stylesheet().s — a shape change, not just a value change.
  5. compile(css).warnings() gains entries. Any downstream toStrictEqual over warnings() for CSS containing a pseudo-element now fails.

If 1 is contentious, 2–5 stand alone: with background-color mapped, color could keep its current target and merely stop being the only route to the band. Happy to split.

Known limits

  • light-dark() reaches the host on this branch alone; fix(compiler): make a light-dark() extra rule a rule in its own right #420 closes it. .a::selection { background-color: light-dark(red, blue) } compiles the light half to selectionColor and the dark half into a SECOND rule that the extra-rule path composes after the scoping, so under prefers-color-scheme: dark it lands as { backgroundColor: "#00f" } on the host. Same defect, different path — createRuleFromPartial replaces d wholesale with a partial that never went through the scoping. The fix is to the shared extra-rule path rather than to the pseudo-element scoping, which is why it is a separate PR: fix(compiler): make a light-dark() extra rule a rule in its own right #420 opens the extra rule empty and merges it as a rule in its own right, so it goes through the same scoping this one installs. Its §5 is this exact case, ::placeholder included.
  • The warnings this PR adds are unreachable from a real build. metro-transformer.ts calls only .stylesheet(), so expo start prints nothing and the only thing an author observes is a declaration with no effect. That is pre-existing — 47 addWarning("value") call sites already went nowhere — so I opened it separately as compile().warnings() is unreachable from a real build: 50 addWarning call sites, no Metro consumer #424 rather than widening this PR.

Tests

The compiler suite covers every row of the table above, including the .e control an over-broad fix would break, and the field-policy test is driven off the policy object rather than a hand-written list, so classifying a new StyleRule field is what puts it under test. A native suite renders each case beside a control carrying only what the platform can express and asserts the two are indistinguishable, which keeps the expectations derived rather than copied out of a passing run.

Six tests exist because mutating the source survived the whole suite without them: a nested property path, each of the three container spellings, container-name: none (which empties c rather than leaving it absent), a rule whose d holds more than one entry, a second pseudo-element inside one authored rule, and a delayed declaration that reads no variable and so must not set dv. Each was confirmed by re-applying its mutation and watching that test — and only that test — go red.

Two warning strings were wrong and are fixed with the tests above: a dropped text-shadow reported &.textShadowOffset.width, where & is the compiler's own "write at the top level" routing marker rather than part of the name, and a dropped container-type reported container-name, a property the author never wrote.

The custom-property report is pinned at both ends and on both planes. Compiler: the authored name is reported under inlineVariables: false and with the optimization left on (declared twice, so inlining keeps it); a property the optimization folds away is not reported; a mirror stays silent beside an authored name in the same rule; and one test derives the namespace by reading the v names off a compiled rule instead of restating the prefix. Native: an authored --brand on a ::selection rule that keeps a mapped declaration — the shape where a carried-over v actually reaches the host, which the existing color / font-size cases cannot reach, since those rules survive with no declaration at all and are dropped whole.

Four mutations, each applied and then reverted, each watched go red for its own reason:

mutation red
remove the report loop 5 tests, every one naming the absent --brand
remove the mirror filter 6 tests, the report naming --__rn-css-color / --__rn-css-em on rules that merely set a colour
rename the namespace constant the derivation test, listing the three real mints __rn-css-color, __rn-css-direction, __rn-css-em
carry v over to the host the native test — the child under .a paints #0f0 from the leaked variable while the control paints nothing

README.md gains a ## Pseudo-elements section: the mapping table, why it is background-color rather than color, what an author actually observes when a declaration is dropped, that a custom property is dropped the same way and what inlineVariables does to it first, and that all of this is native-only.

A `::selection` / `::placeholder` rule maps ONE declaration onto a React
Native prop and returned every other declaration unchanged — so an unmapped
one was applied to the real element. `::selection { background-color: blue }`
tinted the whole control rather than the selection, silently.

The leak was on the mapping path too: `::selection { color: red;
background-color: blue }` emitted the blue background AND the mapped prop.

Three changes:

- Unmapped declarations are DROPPED. `[]` is the correct answer for a
  declaration the platform cannot express — applying it to the element
  instead is strictly worse than not applying it.
- `::selection` maps `background-color`, not `color`. `selectionColor` is the
  band painted BEHIND the selected text, which is `background-color` in CSS;
  `color` there is the selected TEXT's colour, which React Native has no prop
  for. The old mapping inverted the meaning — a stylesheet asking for white
  selected text got a white band and unchanged text sitting on it.
- `::placeholder` keeps `color` -> `placeholderTextColor`, which is correct,
  and drops the rest.

The second and third are BREAKING for anyone relying on 3.0.7's inverted
`color` mapping. `vendor/tailwind/states.test.tsx`'s `selection` case pinned
it and is updated to `selection:bg-black`, with a second case asserting that
`selection:text-black` no longer reaches the element.

7 new compiler tests, including the control an over-broad fix would break: a
plain `.a { background-color }` on the same class is untouched.
pseudo-elements.ts carries no comments upstream, so the multi-paragraph blocks
stood out. What is left is the two facts the code cannot state: that ::selection
maps background-color rather than color, and that an unmapped declaration is
dropped instead of returned.
modifyRuleForSelection and modifyRuleForPlaceholder rewrote rule.d and left the
rest of the rule alone, so a ::selection declaration still reached the host
through the fields declarations.ts writes beside it:

- color mirrors into --__rn-css-color, which every descendant reads as
  currentColor, so `.a::selection { color: red }` painted the whole subtree
- font-size mirrors into --__rn-css-em, so `.a::selection { font-size: 40px }`
  became the element's em base
- container-name registered the host as a named container
- an animation or transition left `a: true` on a host with nothing to animate,
  and an unmapped var() left `dv: 1` on a host with no variable declaration

Rebuild the rule rather than mutate it: carry over the fields the selector owns,
recompute d and dv from the declarations that survive, drop the rest. A field
policy over `keyof StyleRule` makes that classification exhaustive, so adding a
field to StyleRule fails to compile until it is classified.

A rule with nothing left is no longer registered at all, and every dropped
property is reported through addWarning rather than vanishing silently.

Delete two dead branches: index 2 of a StyleDeclaration is the delay flag and
never a property name, and StyleDeclaration is a union of object shapes, so the
trailing return sat past a total if/else.

Move postProcessStyleFunction beside the other StyleDescriptor predicates so the
scoping can recompute dv without importing back into the builder.
`&` and `[n]` are the compiler's own path routing rather than part of a property
name, so a dropped `text-shadow` was reported as `&.textShadowOffset.width` — a
name that appears nowhere the author can act on. Render the path the way the
runtime reads it: `textShadowOffset.width`, `boxShadow[0].color`.

`container-name`, `container-type` and the `container` shorthand all reach `c`
without passing through `d`, and `c` records only the name, so a dropped
`container-type` was reported as `container-name` — a property the author never
wrote. Report the family instead, which is true of all three.

Six discriminators had no coverage; mutating the source survived the whole suite
for each. Tests now pin: a nested property path, each of the three container
spellings, `container-name: none` (which empties `c` rather than leaving it
absent, so the report is guarded on the entries), a rule whose `d` holds more
than one entry, a second pseudo-element inside one authored rule, and a delayed
declaration that reads no variable and so must not set `dv`.

The comment above the container report claimed `container-name` was the only
authored declaration that never reaches `d`, and that every `v` entry mirrors a
`d` declaration already reported. Both are false: `container-type`, the
`container` shorthand and any authored custom property also bypass `d`, and `v`
carries authored custom properties as well as the compiler's `--__rn-css-*`
mirrors — so a `--x` written inside a pseudo-element is dropped with no report.
The comment now says so.

The README presented `compile(css).warnings()` as the way a drop surfaces. It is
not: `metro-transformer` calls only `.stylesheet()`, so a `expo start` build
prints nothing and the only thing an author observes is a declaration that has
no effect. The README says that, and names the two callers that do read it.

BREAKING CHANGE: `::selection { color }` no longer reaches the element. It
compiled to `selectionColor`, which is the band painted behind the selected text
rather than the text itself; `background-color` maps to `selectionColor`
instead. A stylesheet using Tailwind's `selection:text-*` loses its highlight on
upgrade, and `selection:bg-*` is the equivalent. Every other declaration inside
`::selection` / `::placeholder` is dropped rather than applied to the host, so a
rule that painted the whole control stops painting it. A rule with no surviving
declaration is no longer registered: its class key disappears from
`stylesheet().s` rather than appearing with a stripped rule. And
`compile(css).warnings()` gains entries under the synthetic keys `"::selection"`
and `"::placeholder"`, which fails any downstream `toStrictEqual` over
`warnings()` for CSS containing a pseudo-element.
The note sat between the container report and the empty-declaration guard, so
it read as documentation of the guard rather than of the field it names.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
A custom property is the one authored declaration that lands in `v` rather
than `d`, so the field policy scopes it out and the declaration loop never
sees it. `.a::selection { --brand: blue }` therefore lost `--brand` and
`warnings()` stayed empty.

`v` also holds the compiler's own mirrors — `--__rn-css-color` and
`--__rn-css-em` beside `color` and `font-size`, `--__rn-css-direction`
beside `direction` — each sitting next to a `d` declaration the report
already names. Reporting the field wholesale would add a variable the user
never wrote to every rule that sets a colour or a font size, so the mirrors
are filtered by the namespace they are minted in, named at the filter site
and tied to the mint sites by a test that reads the names off a compiled
rule.

The drop itself is unchanged: `v` was already classified `dropped`, and a
native test now covers an authored name on a rule that keeps a mapped
declaration, which is the shape where a carried-over `v` reaches the host.

With `inlineVariables` left on, a custom property declared once is
substituted into its uses and removed before any rule is built, so nothing
reaches the pseudo-element and nothing is reported. `inlineVariables: false`
keeps it, which is the configuration the README's `VariableContext` section
asks for and where the silent drop costs the most.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 19:13
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