fix(native): answer :dir() per element, and compile every spelling of it - #459
YevheniiKotyrlo wants to merge 4 commits into
Conversation
`testComparison`'s `dir` arm read `I18nManager.isRTL`, so every `rtl:` /
`ltr:` utility in an app was answered once, for the whole process, from
what the OS locale said at launch — and it answered `true` for `ltr`
unconditionally, so on a right-to-left device both variants applied.
Selectors 4 §7.1 defines `:dir()` per element: the element's own `dir`,
else the closest ancestor's.
An element's `dir` prop is now its declaration, published to descendants
as the inherited variable `__rn-css-directionality` after the bag's
inline merge, read back through the same context every descendant
already subscribes to, and guarded on both reads so a changed `dir`
re-derives the subtree. The `dir` feature compares against that, falling
back to the platform's own layout direction — the root a React Native
tree has — so an app that declares nothing keeps what it had. A
declaring element lands the UA rule `[dir=…] { direction: … }` beneath
every author rule.
The compiler hands every spelling to that one arm: a bare `:dir()` or a
`[dir=…]` on the subject, either on an ancestor, or either inside
`:is()` / `:where()`. The ancestor forms previously compiled with NO
condition and applied in every direction; `:root` and `html` are
transparent as ancestors, since every element descends from the document
element and none is it; identical arms are deduplicated so Tailwind's
three-arm variant is one rule; and the attribute's value is read
ASCII-case-insensitively unless the selector's own `s` flag says
otherwise.
… attribute operators
Device evidence — before / afterUNFIXED — All four bars are blue. The device's locale is left-to-right, so FIXED — The first two bars are red and the last two blue.
Read the The second bar is the one only a per-element channel can paint. It declares nothing itself, so a process-global answer paints it exactly like the baseline. The third and fourth bars are what make the blue reading a real answer rather than an absent rule, and they do not move. The fourth is the baseline; the third is the override half — an The other five rows are byte-identical between the frames. Both frames: pooled Android 36 emulator, 1140×2400 @ 480dpi, dark scheme, LTR locale, same run. On a device this is inert until my sibling PR on |
`updateRules` resolved `props.dir` twice — once as `declaredDirectionality`, and again inside `resolveDirectionality`, which re-ran the same check before falling back to the inherited variable. The caller already holds the declared half, so the waterfall composes at the call site and the inherited half becomes a function that answers only its own question. Measured at 500 elements x 200 renders, median of 7 rounds: 3.1% of the directionality work this change adds to the per-element path. Behaviour is unchanged. The two directionality suites are 45/45, and the full run is 1093 passed with the same two Windows babel suites failing as on main.
A coverage sweep over this branch's own suite found the `direction === undefined` return in the `:is()` / `:where()` attribute arm never executed: the subject compound's fail-closed cases cover `[dir="auto"]`, a bare `[dir]` and every non-equality operator, and none of them was written one nesting in. Measured: removing that guard left all 24 cases green, so a rule the engine cannot answer would have applied in every direction with nothing to report it. Three cases. Two drive the arm — the three unanswerable spellings inside `:is()` and `:where()`, and a mixed arm list where only the unanswerable arm is dropped while its `:dir(rtl)` sibling survives. The third pins that an author's own `and` reaches the rule whole beside the dir condition. The same removal now reddens two of them.


Problem
:dir()— what Tailwind'srtl:/ltr:variants compile to — is answered from a process global, so every direction utility in an app resolves once for the whole process from what the OS locale said at launch, and theltrarm answerstrueunconditionally, so on a right-to-left device both variants apply at once.Selectors 4 §7.1 defines
:dir()per element — the element's owndir, else the closest ancestor's. The compiler decides which spellings reach that arm, and today they disagree. Measured, themeach rule compiles to:.x:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)— Tailwind'srtl:[["&", [["=","dir","rtl"]]]], from the first arm alone[["=","dir","rtl"]].x:dir(rtl)·.x:where([dir="rtl"])·:dir(rtl) .x·:root[dir="rtl"] .x[["=","dir","rtl"]][dir="rtl"] .x[["=","dir","rtl"]]The last row is the worse half: a rule the author scoped to RTL paints on every screen.
Solution
React Native has no
dir, so the library carries no directionality — but it does carry an inherited variable scope, render guards for a prop read and a variable read, and a rule set per element. That is the whole chain HTML supplies for free, unnamed. This names it:dirprop is its declaration, published to descendants as the inherited variable__rn-css-directionalityand read back through theVariableContextevery descendant already subscribes to — written after the bag's inline merge, so nothing inherited or inline can shadow the element's own;diranywhere above re-derives the subtree;I18nManager.isRTL— the root a React Native tree has — so an app declaring nothing readsrtl:exactly as today;[dir=…] { direction: … }beneath every author rule, so an authordirectionclass still wins while the directionality stays declared (§7.1: the property does not affect whether it matches);[dir]is the directionality the element inherits, so it lands on the rule rather than on a container query;:rootandhtmlare transparent as ancestors; identical:is()/:where()arms are deduplicated, which makes Tailwind's three arms one rule; and the value is read ASCII-case-insensitively unless the selector'ssflag says otherwise.Tests
src/__tests__/compiler/directionality.test.ts, 24 cases overcompile(): every spelling above, both directions,:dir()beside another pseudo-class, composition with a media and a container query, the case-sensitivity flags, and what the engine cannot answer compiling to nothing —auto, bare presence, a negated:dir(), and every attribute operator but equality.src/__tests__/native/directionality.test.tsx, 21 cases through the realupdateRules: a declaring element, an undeclared one on each platform direction, inheritance, a nested override, a removed declaration, a sibling that must not see it, the UA rule landing on the declaring element alone, an authordirectionclass outranking it,dir="auto", inline variables, a changeddirre-deriving the subtree, and an idempotent re-render.Mutation-proved, each reverted: dropping the arm unwrap turns 5 compiler cases red, making
:rootopaque 2, ignoring thesflag 1; sharing the inherited variable object turns 3 runtime cases red.The per-element cost is two guards and one resolve. Measured at 500 elements x 200 renders, median of 7 rounds: 17.8 ns per element, of which the two guards are about two thirds. They are not gateable on current information —
testGuardsruns during render against the live props, so a guard dropped whilediris absent would miss the element ever gaining one. A stylesheet-level "any rule asks about direction" flag would make the cost proportional to use rather than to element count, and is a wire-format decision rather than something to slip in here.No test declares a direction on an element today, because there was no prop to declare it with — the only
dircoverage mocksI18nManager.isRTL, which passes for exactly the behaviour this replaces.Verification
yarn typecheckandyarn lintclean;yarn test1096 passed. The three failures are two babel suites that fail identically on an untouchedmainworktree (Windows-only module-specifier rewrites) — this touches no babel file.Known limits
#453 reports
rtl:/ltr:dropped before the runtime sees them — a different hop, and the subject of my sibling PR onmetro-transformer.ts; on a device this change is inert until that one lands. #397 mapstext-alignfor RTL. Neither touches howdiris answered.Out of scope:
:not(:dir())— Tailwind'snot-rtl:— still compiles to nothing, because the builder negates prop questions only. Fail-closed, and a separate change.Base
Branched off
f70c402.mainhas since taken #451 (a5002c5). 4 of the 10 files this changes also moved there, and 2 genuinely conflict —src/native/conditions/media-query.ts,types.d.ts. Every measurement above was taken onf70c402. Say the word and I will re-apply it onto currentmain.