Skip to content

fix(native): rank an unset specificity slot the same however it is spelled - #432

Open
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/specificity-sparse-slots
Open

YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/specificity-sparse-slots

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

specificityCompareFn answers 0 for two rules that are not equally specific once the sheet has reached a device.

A specificity array is sparse, and getNativeInjectionCode (src/metro/injection-code.ts) injects the sheet with JSON.stringify, which writes each hole as null:

.inp              ->  [1, 1]
.inp::placeholder ->  [2, 1, null, null, 1]

The comparator branched on the raw slot and returned a normalised difference:

if (aSpec[Important] !== bSpec[Important]) {
  return (aSpec[Important] || 0) - (bSpec[Important] || 0);

undefined !== null, so it answered 0 - 0 at a slot neither rule sets. The runtime sort in src/native/styles/index.ts then leaves the two rules in className order, so className="inp inp-ph" and "inp-ph inp" cascade differently. Any rule carrying a pseudo-element or an !important declaration can trigger it.

Solution

  • Every slot is ranked with spec[slot] || 0 before it is compared, so a hole, a null and a 0 are one unset component — Selectors 4 §16 counts an absent component as zero.
  • SpecificityValue admits null, because that is what a native runtime reads. The two compile-time merges in selector-builder.ts read a slot by its type instead of !== undefined.

Why it isn't caught today

registerCSS injects the compiler's own object, where both sides are holes, so every existing test compares undefined with undefined. The compile-time sort in src/compiler/stylesheet.ts runs the same comparator over that in-memory form, which is why only the runtime sort is exposed.

Tests

  • src/__tests__/native/specificity.test.tsx compiles .inp / .inp::placeholder, asserts the compile-time order, puts the rules through the same JSON.stringify round trip, asserts a null arrived, and sorts them with the runtime comparator from both input orders. It also pins each slot alone, against every slot beneath it, and an unset slot spelled as a hole, null or 0 on either side.
  • src/__tests__/vendor/tailwind/states.test.tsx does the same with Tailwind's own output for text-red-500 placeholder:text-blue-500 and text-red-500 selection:text-blue-500.

On main, the 7 new cases that carry a null or a 0 into a deciding comparison fail with Received: 0 and pass with the fix; the other 15 pass on both.

Verification

On Windows with Node 26: yarn lint clean · yarn typecheck clean · yarn test --maxWorkers=2 --coverage 1070 passed, 21 skipped, 3 failed — the two babel suites that fail identically on f70c402 · yarn build clean · yarn example expo export --platform web clean · nothing unstaged after either build. Coverage of src/utilities/specificity.ts is 100% of lines and branches. Not run: the iOS dev build, which no file in this change reaches.

No existing issue tracks this — searched the tracker for specificity, specificityCompareFn, placeholder order, className order and null specificity.

Base

Cut from f70c402. It merges cleanly into main (a5002c5), where the new cases fail before the fix and pass after it.

…elled

A specificity array is sparse. A rule that sets `PseudoElements` never writes
`Important` or `Inline`, so those sit as holes inside the array's length —
`selector-builder.ts` merges with `if (value !== undefined)` and
`stylesheet.ts` skips an absent spec entirely, so nothing fills them in.

A hole reads as `undefined` in memory. The sheet reaches a native runtime
through `JSON.stringify` (`metro/injection-code.ts`), and JSON has no holes, so
every one arrives as `null`.

`specificityCompareFn` branched on the RAW slot while returning a NORMALISED
difference:

    if (aSpec[Important] !== bSpec[Important]) {
      return (aSpec[Important] || 0) - (bSpec[Important] || 0);

`undefined !== null` is true, so the comparison entered that branch and answered
`0 - 0`, settling at a slot neither rule uses and never reaching the one that
decides. Two rules that differ only in whether they carry a pseudo-element
compare equal.

The caller is the runtime sort in `native/styles/index.ts`, over rules gathered
across every class name on the element. A zero verdict leaves it nothing to
order by, so the `className` attribute's token order decides the cascade:

    className="inp inp-ph"   ->  one result
    className="inp-ph inp"   ->  the other

`placeholder:` and `selection:` are the everyday Tailwind triggers, and they are
the only two pseudo-elements this compiler emits.

Comparing the ranked value rather than the raw slot fixes it. The loop is part
of that: returning inside a raw-slot branch is what made a `0` difference
terminal instead of falling through to the next slot.

Nothing else reads these slots at runtime — every other `Specificity.` read is
compile time, where the array still has its holes and is already correct. That
is also why the existing suite is blind to this: the compile-time sort runs on
the in-memory form, and it masks the runtime bug whenever two rules share a
class name.

The test asserts at the comparator, over a sheet put through the JSON round trip
a device receives, rather than through a render. A rendered assertion would need
a non-`color` declaration to leak out of the pseudo-element rule, so it would go
inert the moment that leak is fixed; this one does not.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Why this PR carries no device screenshot

Every other fix I've filed here ships a before/after pair from a real device. This one deliberately does not, and the reason is worth stating rather than leaving as an absence.

The defect is device-visible upstream and measured impossible to photograph on my build, because my patch stack already carries #411. Three measurements, each closing one route:

  1. A non-color declaration never survives. compile('.inp { font-size: 10px } .inp-ph::placeholder { font-size: 20px }') emits ONE rule — the pseudo-element rule is dropped whole, so there is nothing left to tie.

  2. A color declaration survives but does not collide. Both rules land under the same className, and fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element #411 remaps the pseudo-element's to a different prop:

    className: inp   rules: 2
      0  spec [1,1]              d [{"color":"#f00"}]
      1  spec [2,1,null,null,1]  d [["#00f",["placeholderTextColor"]]]
    

    The comparator still answers 0 for that pair — the defect is real — but the two rules write color and placeholderTextColor, so the order it fails to decide changes no pixel.

  3. The comparator is not reachable from a story. specificityCompareFn is in no exports entry, and a deep import outside that map does not merely fail to resolve — it 500s the whole Metro bundle.

So the evidence for this PR is its comparator assertion: red at Received: 0 before, green after, with a faithful mutation reproducing the 0.

Worth saying plainly because it inverts the usual reading: the defect being invisible on my build is a fact about my patch stack, not about the defect. Upstream has no #411, so a ::placeholder rule's declarations still reach the element there and the tie decides a real cascade.

The sheet reaches a native runtime as JSON, which writes each hole in a
specificity array as `null`, so `SpecificityValue` admits it. The two
compile-time merges read a slot by its type rather than `!== undefined`,
and the test asserts the transported rule carries a `null` before it ranks.
The runtime sort must reproduce the order the compile-time sort gave the
same rules, from either input order, after the JSON transport. Each slot
is pinned on its own, against every slot beneath it, and with a hole, a
`null` and a zero on either side.
…utility

Tailwind v4's own output for `text-red-500 placeholder:text-blue-500` and
`text-red-500 selection:text-blue-500`, put through the JSON transport,
sorts the pseudo-element rule last from either input order.

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