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; };