Skip to content

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

Open
YevheniiKotyrlo wants to merge 2 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 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/pseudo-element-declaration-leak

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

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.padEnd(4)} ->`, 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"}]

Expected:

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

Read .a against .e. 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.

.d shows a third, minor artifact present on both mapping paths: the empty {} alongside the mapped declaration is the ...rest remainder being returned even when nothing is left in it.

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.

Fix

::selection maps background-color and drops everything else; ::placeholder keeps colorplaceholderTextColor and drops everything else.

Dropping — rather than passing through — is the whole change. [] is the correct answer for a declaration the target platform cannot express: the pseudo-element asked for something React Native has no prop for, and applying it to the element instead is strictly worse than not applying it.

Submission shape — three changes, and you may want only the first

  1. The leak — unmapped declarations are dropped rather than passed through. A pure bug fix; non-breaking.
  2. background-colorselectionColor — a new capability, making ::selection { background-color } mean on React Native what it means on the web.
  3. color dropped from ::selectionbreaking, and the one to flag. Anyone relying on 3.0.7's behaviour is relying on an inverted mapping, but they are relying on it. If that is contentious, 1 and 2 stand alone: with background-color mapped, color could keep its current target and merely stop being the only route to the band.

This branch carries all three. Happy to split it.

Tests

7 new compiler tests covering every row of the table above, including the .e control an over-broad fix would break, plus the one existing expectation this legitimately changes (vendor/tailwind/states.test.tsx's selection, updated with the reason at the site).

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.
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