Repository navigation
fix(native): rank an unset specificity slot the same however it is spelled - #432
Open
YevheniiKotyrlo wants to merge 4 commits into
Open
YevheniiKotyrlo wants to merge 4 commits into
YevheniiKotyrlo wants to merge 4 commits into
Conversation
…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.
Contributor
Author
No device frameThe description's assertion is the evidence: after the JSON round trip a device's sheet goes through, |
The sheet reaches a native runtime as JSON, which writes each hole in a specificity array as `null`, so `SpecificityValue` admits it. The two compile-time merges read a slot by its type rather than `!== undefined`, and the test asserts the transported rule carries a `null` before it ranks.
The runtime sort must reproduce the order the compile-time sort gave the same rules, from either input order, after the JSON transport. Each slot is pinned on its own, against every slot beneath it, and with a hole, a `null` and a zero on either side.
…utility Tailwind v4's own output for `text-red-500 placeholder:text-blue-500` and `text-red-500 selection:text-blue-500`, put through the JSON transport, sorts the pseudo-element rule last from either input order.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
specificityCompareFnanswers0for two rules that are not equally specific once the sheet has reached a device.A specificity array is sparse, and
getNativeInjectionCode(src/metro/injection-code.ts) injects the sheet withJSON.stringify, which writes each hole asnull:The comparator branched on the raw slot and returned a normalised difference:
undefined !== null, so it answered0 - 0at a slot neither rule sets. The runtime sort insrc/native/styles/index.tsthen leaves the two rules inclassNameorder, soclassName="inp inp-ph"and"inp-ph inp"cascade differently. Any rule carrying a pseudo-element or an!importantdeclaration can trigger it.Solution
spec[slot] || 0before it is compared, so a hole, anulland a0are one unset component — Selectors 4 §16 counts an absent component as zero.SpecificityValueadmitsnull, because that is what a native runtime reads. The two compile-time merges inselector-builder.tsread a slot by its type instead of!== undefined.Why it isn't caught today
registerCSSinjects the compiler's own object, where both sides are holes, so every existing test comparesundefinedwithundefined. The compile-time sort insrc/compiler/stylesheet.tsruns the same comparator over that in-memory form, which is why only the runtime sort is exposed.Tests
src/__tests__/native/specificity.test.tsxcompiles.inp/.inp::placeholder, asserts the compile-time order, puts the rules through the sameJSON.stringifyround trip, asserts anullarrived, and sorts them with the runtime comparator from both input orders. It also pins each slot alone, against every slot beneath it, and an unset slot spelled as a hole,nullor0on either side.src/__tests__/vendor/tailwind/states.test.tsxdoes the same with Tailwind's own output fortext-red-500 placeholder:text-blue-500andtext-red-500 selection:text-blue-500.On
main, the 7 new cases that carry anullor a0into a deciding comparison fail withReceived: 0and pass with the fix; the other 15 pass on both.Verification
On Windows with Node 26:
yarn lintclean ·yarn typecheckclean ·yarn test --maxWorkers=2 --coverage1070 passed, 21 skipped, 3 failed — the two babel suites that fail identically onf70c402·yarn buildclean ·yarn example expo export --platform webclean · nothing unstaged after either build. Coverage ofsrc/utilities/specificity.tsis 100% of lines and branches. Not run: the iOS dev build, which no file in this change reaches.No existing issue tracks this — searched the tracker for
specificity,specificityCompareFn,placeholder order,className orderandnull specificity.Merge order
It shares lines with #459 (
compiler/selector-builder.ts,utilities/specificity.ts); whichever lands second rebases.Base
Cut from
f70c402. It merges cleanly intomain(a5002c5), where the new cases fail before the fix and pass after it.