From 12ab17231a09ffe30bf3c393accddd58cef75460 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 19:45:32 +0300 Subject: [PATCH] fix(native): rank an unset specificity slot the same however it is spelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/native/specificity.test.tsx | 45 +++++++++++++++++++++++ src/utilities/specificity.ts | 37 +++++++++++++------ 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/__tests__/native/specificity.test.tsx b/src/__tests__/native/specificity.test.tsx index b5828935..638a3402 100644 --- a/src/__tests__/native/specificity.test.tsx +++ b/src/__tests__/native/specificity.test.tsx @@ -1,9 +1,12 @@ import { StyleSheet, type ViewProps } from "react-native"; import { fireEvent, render } from "@testing-library/react-native"; +import type { StyleRule } from "react-native-css/compiler"; +import { compile } from "react-native-css/compiler"; import { Text } from "react-native-css/components/Text"; import { registerCSS, testID } from "react-native-css/jest"; import { styled } from "react-native-css/runtime"; +import { specificityCompareFn } from "react-native-css/utilities/specificity"; test("inline styles", () => { registerCSS(`.red { background-color: red; }`); @@ -188,3 +191,45 @@ test("passThrough - inline important existing", () => { color: "#00f", }); }); + +test("a pseudo-element rule still outranks a plain one after the sheet is serialised", () => { + // The compiler leaves HOLES: a rule that sets `PseudoElements` (slot 4) never + // writes slots 2 and 3, so they sit empty *inside* the array's length. Metro + // writes the sheet with `JSON.stringify` (`metro/injection-code.ts`), and JSON + // has no holes — every one becomes `null`. + // + // The comparator branched on the RAW slot while returning a NORMALISED + // difference, so `undefined !== null` entered the branch and returned + // `0 - 0 = 0`, settling the comparison at a slot neither rule uses. A zero + // leaves the runtime sort with nothing to order by, so the `className` + // attribute's token order decided the cascade — `placeholder:` and + // `selection:` are the everyday Tailwind triggers. + // + // Asserted at the comparator rather than through a render on purpose. The + // rendered form depends on a non-`color` declaration leaking out of the + // pseudo-element rule, so it would go inert the moment that leak is fixed; + // this assertion does not. + const rules = compile( + `.inp { color: red; } .inp::placeholder { color: blue; }`, + ).stylesheet().s?.[0]?.[1]; + + if (rules === undefined) { + throw new Error( + "compiled no rules for .inp — the fixture or the compiler moved", + ); + } + + // The shape a device receives, not the shape the compiler holds. + const [plain, placeholder] = JSON.parse(JSON.stringify(rules)) as StyleRule[]; + + if (plain === undefined || placeholder === undefined) { + throw new Error("expected two rules"); + } + + expect(specificityCompareFn(plain, placeholder)).toBeLessThan(0); + expect(specificityCompareFn(placeholder, plain)).toBeGreaterThan(0); + + // An inline record carries no `s` at all, so it falls back to + // `inlineSpecificity` — itself a sparse array. It must still win. + expect(specificityCompareFn({}, placeholder)).toBeGreaterThan(0); +}); diff --git a/src/utilities/specificity.ts b/src/utilities/specificity.ts index 342f0a38..a7de72e0 100644 --- a/src/utilities/specificity.ts +++ b/src/utilities/specificity.ts @@ -22,6 +22,20 @@ const Order = Specificity.Order; export const inlineSpecificity: SpecificityArray = []; inlineSpecificity[Specificity.Inline] = 1; +/** + * What a slot is worth. An unset slot is worth nothing, however it is spelled. + * + * 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. A + * hole reads as `undefined` in memory, and the sheet reaches a native runtime + * through `JSON.stringify` (`metro/injection-code.ts`), which has no holes and + * writes each one as `null`. Both mean "unset", so both must rank the same. + */ +const rank = (spec: SpecificityArray, slot: number): number => spec[slot] || 0; + +/** Most significant first. */ +const slots = [Important, Inline, PseudoElements, ClassName, Order]; + export const specificityCompareFn = ( a: StyleRule | InlineStyleRecord, b: StyleRule | InlineStyleRecord, @@ -29,17 +43,16 @@ export const specificityCompareFn = ( const aSpec = a.s ? a.s : inlineSpecificity; const bSpec = b.s ? b.s : inlineSpecificity; - if (aSpec[Important] !== bSpec[Important]) { - return (aSpec[Important] || 0) - (bSpec[Important] || 0); - } else if (aSpec[Inline] !== bSpec[Inline]) { - return (aSpec[Inline] || 0) - (bSpec[Inline] || 0); - } else if (aSpec[PseudoElements] !== bSpec[PseudoElements]) { - return (aSpec[PseudoElements] || 0) - (bSpec[PseudoElements] || 0); - } else if (aSpec[ClassName] !== bSpec[ClassName]) { - return (aSpec[ClassName] || 0) - (bSpec[ClassName] || 0); - } else if (aSpec[Order] !== bSpec[Order]) { - return (aSpec[Order] || 0) - (bSpec[Order] || 0); - } else { - return 0; + // Compare the RANKED value, never the raw slot. Branching on the raw slot + // while returning a normalised difference is what let `undefined !== null` + // enter a branch and answer `0 - 0`, settling the comparison at a slot + // neither rule uses and leaving the caller to fall back on source order. + for (const slot of slots) { + const difference = rank(aSpec, slot) - rank(bSpec, slot); + if (difference !== 0) { + return difference; + } } + + return 0; };