fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element - #411
Open
YevheniiKotyrlo wants to merge 6 commits into
Open
Conversation
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.
This was referenced Aug 15, 2026
`&` 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
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
marked this pull request as ready for review
August 15, 2026 19:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A pseudo-element's declarations are scoped to the pseudo-element.
react-native-csscompiles a::selection/::placeholderrule 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:
::selection { background-color }paints the element. It compiles to exactly what a plainbackground-coloron the same class compiles to, so a rule intended to tint a selection tints the whole control.::selection { color }maps toselectionColor, which is its opposite. In CSS,colorinside::selectionis the colour of the selected text; in React NativeselectionColoris 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.::placeholderhas the same leak on its unmapped side; itscolor→placeholderTextColormapping is correct and is unchanged here.Reproduction
No framework, no device, no bundler — the compiler alone.
Actual, on 3.0.7:
With this PR:
.band.cdo not appear at all — a rule with no surviving declaration is not registered, so the class key is absent fromstylesheet().srather than present-and-empty.Read
.aagainst.eon 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..bis 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:
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
modifyStyleDeclarationreturns unmatched declarations rather than dropping them, on every path — including the mapped one, whererestleaks alongside the mapped prop. The second defect is the caller's argument rather than this function's logic:modifyRuleForSelectionpasses"color"as the source property, when the property meaning "the selection band" on both platforms isbackground-color.The fix — a field policy, not an allowlist
Dropping unmapped declarations closes
dand leaves every other declaration-derived field open.modifyRuleForSelectionmutatedrule.dand returned the same rule, so everything else rode along onto the host:v— acolormirrors into--__rn-css-color, which every descendant reads ascurrentColor, so.a::selection { color: red }painted the whole subtree. Afont-sizemirrors into--__rn-css-emand became the element's em base.c—container-nameinside a pseudo-element registered the HOST as a named container.a— a transition or animation lefta: trueon a host with nothing to animate.dv— an unmappedvar()leftdv: 1behind.modifyRuleForSelectionandmodifyStyleDeclarationare gone.scopeRuleToPseudoElementrebuilds the rule from an accumulator instead of mutating it, and every field ofStyleRuleis classified:The
satisfiesoverRecord<keyof StyleRule, …>is the enforcement: adding a field toStyleRulefails to compile until it is classified, which is what stops the next declaration-derived field escaping the wayv,c,dvandadid while onlydwas rewritten.Dropped declarations are also reported —
compile(css).warnings()gains entries under the synthetic keys"::selection"/"::placeholder", deduped per authored rule per pseudo-element. That coversv: an authored custom property is the one declaration that lands there rather than ind, and it is reported under the name it was written with.valso carries the compiler's own mirrors —--__rn-css-colorand--__rn-css-embesidecolorandfont-size,--__rn-css-directionbesidedirection— and each of those sits next to addeclaration 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
inlineVariablesleft 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: falsekeeps it, and that is the configuration the README'sVariableContextsection 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.tsreturns early forplatform === "web", so a browser gets the authored CSS unchanged and::selectionbehaves as CSS specifies. The question is what native does with the same source.Measured over 18
::selectionproperties, 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):mainOn
main,width/height/font-size/transform/opacity/border-width/margin/padding/display/positionall land on the HOST,animationandtransitionseta: trueon the host,background-colorpaints the host, andcolorpublishes--__rn-css-colorto the whole subtree. A browser does none of that. Measured in isolation,container-nameand an authored custom property score as agreeing onmain, but neither is scoped —modifyRuleForSelectionbails onif (!rule.d) return, so a rule whose declarations are ALL unmappable is discarded wholesale. Paircontainer-namewith 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-decorationandtext-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 aBREAKING CHANGE:footer. It is not a parity regression — that stylesheet was painting a band where the author asked for text.What is breaking
::selection { color }is dropped rather than mapped toselectionColor. Anyone relying on 3.0.7's behaviour is relying on an inverted mapping, but they are relying on it. Tailwind'sselection:text-*stops painting;selection:bg-*is the equivalent.::selection { background-color }becomesselectionColorinstead of painting the host. This is the one pre-existing expectation the PR rewrites, invendor/tailwind/states.test.tsx.v,c,aandtargetinside a pseudo-element no longer reach the host. The real fix, and a live behaviour change for--__rn-css-color/--__rn-css-emconsumers. (targetis inert — declared onStyleRule, set nowhere insrc/compiler.)stylesheet().s— a shape change, not just a value change.compile(css).warnings()gains entries. Any downstreamtoStrictEqualoverwarnings()for CSS containing a pseudo-element now fails.If 1 is contentious, 2–5 stand alone: with
background-colormapped,colorcould 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 toselectionColorand the dark half into a SECOND rule that the extra-rule path composes after the scoping, so underprefers-color-scheme: darkit lands as{ backgroundColor: "#00f" }on the host. Same defect, different path —createRuleFromPartialreplacesdwholesale 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,::placeholderincluded.metro-transformer.tscalls only.stylesheet(), soexpo startprints nothing and the only thing an author observes is a declaration with no effect. That is pre-existing — 47addWarning("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
.econtrol 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 newStyleRulefield 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 emptiescrather than leaving it absent), a rule whosedholds more than one entry, a second pseudo-element inside one authored rule, and a delayed declaration that reads no variable and so must not setdv. 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-shadowreported&.textShadowOffset.width, where&is the compiler's own "write at the top level" routing marker rather than part of the name, and a droppedcontainer-typereportedcontainer-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: falseand 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 thevnames off a compiled rule instead of restating the prefix. Native: an authored--brandon a::selectionrule that keeps a mapped declaration — the shape where a carried-overvactually reaches the host, which the existingcolor/font-sizecases 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:
--brand--__rn-css-color/--__rn-css-emon rules that merely set a colour__rn-css-color,__rn-css-direction,__rn-css-emvover to the host.apaints#0f0from the leaked variable while the control paints nothingREADME.mdgains a## Pseudo-elementssection: the mapping table, why it isbackground-colorrather thancolor, what an author actually observes when a declaration is dropped, that a custom property is dropped the same way and whatinlineVariablesdoes to it first, and that all of this is native-only.