Repository navigation
fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element - #411
Open
YevheniiKotyrlo wants to merge 6 commits into
Open
YevheniiKotyrlo wants to merge 6 commits into
YevheniiKotyrlo wants to merge 6 commits into
Conversation
This was referenced Aug 15, 2026
YevheniiKotyrlo
marked this pull request as draft
August 15, 2026 14:35
YevheniiKotyrlo
marked this pull request as ready for review
August 15, 2026 19:13
Contributor
Author
…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.
YevheniiKotyrlo
force-pushed
the
fix/pseudo-element-declaration-leak
branch
from
October 11, 2026 12:35
55cc11b to
76a7282
Compare
This was referenced Oct 11, 2026
This branch has not been deployed
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
::selectionor::placeholderrule 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 }setsselectionColor, the band behind the selected text, which CSS styles withbackground-color.colorpublishescurrentcolorand afont-sizetheembase to the element and its subtree,container-namemakes the element a container, and an animation makes it animated.light-dark()bypasses the mapping.Root cause
modifyRuleForSelection/modifyRuleForPlaceholderrewroterule.dand returned the rule, sov,c,aanddvstill applied to the element, and thelight-dark()extra rule replaceddafter the rewrite.Solution
::selection { background-color }→selectionColor,::placeholder { color }→placeholderTextColor, and the variables it declares, under the selector's own conditions. A policy typed overkeyof StyleRuleclassifies every field, so a new field fails to compile until it is classified. The rest is dropped and listed bycompile().warnings(). Thelight-dark()extra rule is scoped the same way.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, soinlineVariablesdoes not change the result.currentcolorinside a pseudo-element is its owncolor(CSS Color 4).Breaking:
::selection { color }no longer setsselectionColor, so Tailwind'sselection:text-*becomesselection:bg-*. nativewind/nativewind#1754 verifiedselection:text-*on the RC; this follows CSS and Tailwind instead, whereselection:text-*is the selected text. TheBREAKING 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. Alsocurrentcolor, andlight-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, ::afterdeclares).src/__tests__/vendor/tailwind/states.test.tsx:selection:bg-*,selection:text-*,selection:[--brand:…] selection:bg-(--brand)andselection: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 throughgetComputedStyle(element, "::selection")and"::placeholder".Verification
On Windows with Node 26:
yarn lintclean ·yarn typecheckclean ·yarn test --maxWorkers=2 --coverage1447 passed, 4 failed — the babel cases that also fail onmainon this machine ·yarn buildclean ·yarn example expo export --platform webexported · 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).::selectiondoes not reach a descendant's highlight. CSS Pseudo-Elements 4 §3.5 inherits it, as Chromium and WebKit do, and Tailwind'sselection:variant targets descendants (& *::selection); on native it styles only the input it is on..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 howmainresolves 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 compilescolor: 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).