diff --git a/src/__tests__/native/specificity.test.tsx b/src/__tests__/native/specificity.test.tsx index b5828935..4c928668 100644 --- a/src/__tests__/native/specificity.test.tsx +++ b/src/__tests__/native/specificity.test.tsx @@ -1,9 +1,15 @@ 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 { + Specificity, + specificityCompareFn, +} from "react-native-css/utilities/specificity"; test("inline styles", () => { registerCSS(`.red { background-color: red; }`); @@ -188,3 +194,129 @@ test("passThrough - inline important existing", () => { color: "#00f", }); }); + +test("the runtime sort reproduces the compile-time order after the sheet's JSON transport", () => { + const compiled = compile( + `.inp { color: red; } .inp::placeholder { color: blue; }`, + ).stylesheet().s?.[0]?.[1]; + + if (compiled === undefined) { + throw new Error( + "compiled no rules for .inp — the fixture or the compiler moved", + ); + } + + const pseudoElementCounts = (rules: StyleRule[]) => + rules.map((rule) => rule.s[Specificity.PseudoElements] ?? 0); + + expect(pseudoElementCounts(compiled)).toEqual([0, 1]); + + // Metro injects the sheet as JSON, which writes each specificity hole as `null`. + const transported = JSON.parse(JSON.stringify(compiled)) as StyleRule[]; + + expect(transported.flatMap((rule) => rule.s)).toContain(null); + + for (const order of [transported, [...transported].reverse()]) { + expect(pseudoElementCounts([...order].sort(specificityCompareFn))).toEqual([ + 0, 1, + ]); + } +}); + +describe("specificityCompareFn", () => { + const slotsByPrecedence = [ + ["Important", Specificity.Important], + ["Inline", Specificity.Inline], + ["PseudoElements", Specificity.PseudoElements], + ["ClassName", Specificity.ClassName], + ["Order", Specificity.Order], + ] as const; + + const ruleWith = (entries: [slot: number, value: number | null][]) => { + const s: StyleRule["s"] = []; + for (const [slot, value] of entries) { + s[slot] = value; + } + return { s }; + }; + + test.each(slotsByPrecedence)("%s decides when it alone is set", (_, slot) => { + expect( + specificityCompareFn(ruleWith([[slot, 1]]), ruleWith([])), + ).toBeGreaterThan(0); + expect( + specificityCompareFn(ruleWith([]), ruleWith([[slot, 1]])), + ).toBeLessThan(0); + }); + + test.each(slotsByPrecedence.slice(0, -1))( + "%s outranks every slot beneath it", + (name, slot) => { + const beneath = slotsByPrecedence + .slice(slotsByPrecedence.findIndex(([entry]) => entry === name) + 1) + .map(([, lower]): [number, number] => [lower, 9]); + + expect( + specificityCompareFn(ruleWith([[slot, 1]]), ruleWith(beneath)), + ).toBeGreaterThan(0); + }, + ); + + const withUnsetSlot = ( + slot: number, + spelling: "hole" | "null" | "zero", + order: number, + ) => + ruleWith([ + ...(spelling === "hole" + ? [] + : [[slot, spelling === "null" ? null : 0] as [number, number | null]]), + [Specificity.Order, order], + ]); + + test.each(slotsByPrecedence.slice(0, -1))( + "an unset %s defers to the slots beneath it however each side spells it", + (_, slot) => { + for (const [left, right] of [ + ["hole", "null"], + ["null", "zero"], + ["zero", "hole"], + ] as const) { + expect( + specificityCompareFn( + withUnsetSlot(slot, left, 2), + withUnsetSlot(slot, right, 1), + ), + ).toBeGreaterThan(0); + expect( + specificityCompareFn( + withUnsetSlot(slot, right, 2), + withUnsetSlot(slot, left, 1), + ), + ).toBeGreaterThan(0); + } + }, + ); + + test.each(slotsByPrecedence)( + "%s ranks a hole, a null and a zero alike", + (_, slot) => { + const hole = ruleWith([]); + const transported = ruleWith([[slot, null]]); + const zero = ruleWith([[slot, 0]]); + + expect(specificityCompareFn(hole, transported)).toBe(0); + expect(specificityCompareFn(transported, zero)).toBe(0); + expect(specificityCompareFn(zero, hole)).toBe(0); + }, + ); + + test("an inline record ranks beneath an important rule and above any selector", () => { + expect( + specificityCompareFn({}, ruleWith([[Specificity.Important, 1]])), + ).toBeLessThan(0); + expect( + specificityCompareFn({}, ruleWith([[Specificity.ClassName, 9]])), + ).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index c4abd1e0..be3b5072 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -1,5 +1,10 @@ import { fireEvent, screen } from "@testing-library/react-native"; +import type { StyleRule } from "react-native-css/compiler"; import { Switch, TextInput, View } from "react-native-css/components"; +import { + Specificity, + specificityCompareFn, +} from "react-native-css/utilities/specificity"; import { render } from "./_tailwind"; @@ -100,6 +105,35 @@ test("placeholder", async () => { }); }); +test.each(["placeholder", "selection"])( + "a %s: utility still sorts after its element utility once the sheet is serialised", + async (variant) => { + const { stylesheet } = await render( + , + ); + + const rules = (stylesheet().s ?? []).flatMap(([, ruleSet]) => ruleSet); + const pseudoElementCounts = (sorted: StyleRule[]) => + sorted.map((rule) => rule.s[Specificity.PseudoElements] ?? 0); + + expect(pseudoElementCounts([...rules].sort(specificityCompareFn))).toEqual([ + 0, 1, + ]); + + // Metro injects the sheet as JSON, which writes each specificity hole as `null`. + const transported = JSON.parse(JSON.stringify(rules)) as StyleRule[]; + + for (const order of [transported, [...transported].reverse()]) { + expect( + pseudoElementCounts([...order].sort(specificityCompareFn)), + ).toEqual([0, 1]); + } + }, +); + test("disabled", async () => { const { rerender } = await render( , diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..44f606b1 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -239,7 +239,8 @@ export interface ContainerQuery { * This array is sorted by most common values when parsing a StyleSheet */ export type SpecificityArray = SpecificityValue[]; -export type SpecificityValue = number | undefined; +/** `null` is what a native runtime reads for an unset slot, because the sheet reaches it as JSON. */ +export type SpecificityValue = number | null | undefined; /****************************** Compiler ********************************/ diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..d909fc47 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -88,7 +88,7 @@ function parseComponents( // Merge the specificity with the root specificity for (let i = 0; i < specificity.length; i++) { const value = specificity[i]; - if (value !== undefined) { + if (typeof value === "number") { root.specificity[i] = (root.specificity[i] ?? 0) + value; } } @@ -215,7 +215,7 @@ function parseComponents( if (component.kind === "is") { for (let i = 0; i < specificity.length; i++) { const value = specificity[i]; - if (value !== undefined) { + if (typeof value === "number") { parent.specificity[i] = (parent.specificity[i] ?? 0) + value; } diff --git a/src/utilities/specificity.ts b/src/utilities/specificity.ts index 342f0a38..bc741c16 100644 --- a/src/utilities/specificity.ts +++ b/src/utilities/specificity.ts @@ -22,6 +22,12 @@ const Order = Specificity.Order; export const inlineSpecificity: SpecificityArray = []; inlineSpecificity[Specificity.Inline] = 1; +/** An unset slot ranks as zero, whether it is a hole in memory or the `null` the sheet's JSON transport writes for one. */ +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 +35,12 @@ 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; + for (const slot of slots) { + const difference = rank(aSpec, slot) - rank(bSpec, slot); + if (difference !== 0) { + return difference; + } } + + return 0; };