Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/__tests__/native/specificity.test.tsx
Original file line number Diff line number Diff line change
@@ -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; }`);
Expand Down Expand Up @@ -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);
});
37 changes: 25 additions & 12 deletions src/utilities/specificity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,37 @@ 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,
) => {
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;
};