Skip to content

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

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/specificity-sparse-slots
Open

fix(native): rank an unset specificity slot the same however it is spelled#432
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/specificity-sparse-slots

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Problem

specificityCompareFn can return 0 for two rules that are not equally specific, once the stylesheet has been through Metro.

A specificity array is sparse. A rule that sets PseudoElements never writes Important or Inlineselector-builder.ts merges with if (value !== undefined), and stylesheet.ts skips an absent spec entirely — so those slots are holes inside the array's length:

.inp              ->  [1, 1]
.inp::placeholder ->  [2, 1, <2 empty items>, 1]

A hole reads as undefined in memory. getNativeInjectionCode (src/metro/injection-code.ts) writes the sheet with JSON.stringify, and JSON has no holes, so a device receives:

[2, 1, null, null, 1]

The comparator branches 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 enters that branch and answers 0 - 0 — settling at a slot neither rule uses, and never reaching the one that decides.

Why it is visible

The caller is the runtime sort in src/native/styles/index.ts, over rules gathered across every class name on the element. A 0 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 ::selection / ::placeholder are the only two pseudo-elements this compiler emits.

The compile-time sort in src/compiler/stylesheet.ts runs on the in-memory form, where the holes are still holes, and is correct. That is also why the current suite does not catch this: the compile-time sort masks it whenever two rules share a class name, and only the runtime sort — over a cross-class set — is exposed.

Fix

Compare the ranked value rather than the raw slot:

const rank = (spec: SpecificityArray, slot: number): number => spec[slot] || 0;

The loop is part of the fix, not cosmetic. Returning inside a raw-slot branch is what made a 0 difference terminal instead of falling through to the next slot; that behaviour is reproducible by restoring the early return inside the loop, and it is what the new test pins.

Scope

This touches the comparator only. Every other Specificity. read is compile time (selector-builder.ts, stylesheet.ts), where the array still has its holes and is already correct — there are no .s[ reads anywhere under src/native. Nothing else needs to change.

Test

src/__tests__/native/specificity.test.tsx gains one case. It asserts at the comparator, over a sheet put through the same JSON.stringify round trip a device receives, and covers both directions plus the inline-record fallback (inlineSpecificity is itself a sparse array, so an inline style must still win).

Measured on this branch: red at Received: 0, green after.

A rendered test would be the more obvious choice and is deliberately not what this uses. Making the tie visible in rendered output needs a non-color declaration to leak out of the pseudo-element rule, so a rendered assertion would go inert as soon as that leak is fixed. The comparator assertion does not depend on it.

Verification

  • New test red before / green after, on this branch.
  • Full suite: 3 failed, 21 skipped, 1049 passed, 1073 total. The three are src/__tests__/babel/* path suites, unrelated to this change and pre-existing on this base — they fail identically with src/utilities/specificity.ts reverted.
  • yarn typecheck and yarn lint both clean.
  • Differential check over 5184 compiler-shaped pairs (order × classes × important × pseudo-element): 0 disagreements against the current comparator on the in-memory form, so no existing behaviour changes; 1152 disagreements on the post-JSON.stringify form — half of every pair where exactly one rule carries a pseudo-element.

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