fix(native): answer :dir() per element, and compile every spelling of it - #459
YevheniiKotyrlo wants to merge 5 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.
… goes `useNativeCss` wraps an element in `VariableContext.Provider` exactly when its state carries variables, and `updateRules` set them for a directionality only while the element declared one. A `dir` moving between a declaration and none therefore swapped the element type at its position, and React remounted every child: a child counting its mounts read 3 across undefined -> rtl -> undefined, and the dev build logged "added or removed a variable after the initial render" on each change. An element that names `dir` now provides the inherited scope on every render, the invariant the engine already keeps for a rule that declares a variable (`variables ??= inheritedVariables`). It is keyed on the key rather than the value, so an element with no `dir` pays for no provider; the inherited object is handed down unchanged, and the existing copy-on-write keeps it unmutated. Measured at 500 elements x 200 renders, median of 7 rounds, the own-key check is below run-to-run noise on an element without `dir` and about 2.6 ns on one that names it. The new case goes red without the change (Expected 1, Received 3). The two directionality suites are 49/49, and the full run is 1097 passed with the same two Windows babel suites failing as on main. The one line on the branch that prettier reflows is folded in.


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;dirprovides the variable scope on every render, even while it declares nothing, so a declaration that comes or goes keeps the render tree — and every descendant — mounted. It is the invariant the engine already keeps for a rule that declares a variable (variables ??= inheritedVariables, "so we can maintain a consistent render tree");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, 27 cases overcompile(): every spelling above, both directions,:dir()beside another pseudo-class, an author's ownandcarried whole beside thedircondition, 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 — including inside:is(), where an unanswerable arm drops only its own rule.src/__tests__/native/directionality.test.tsx, 22 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, adirthat comes or goes keeping the element's children mounted, 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; providing the scope only while a declaration is present turns the mount case red, with the child mounted 3 times acrossundefined→rtl→undefined.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. The own-key check that keeps the scope is below run-to-run noise on an element withoutdir, and about 2.6 ns on one that names it.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 test1097 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.An element whose props gain or lose the
dirkey itself — a conditional spread rather than a changing value — still changes its render shape unless its rules already provide a scope (anycolordeclaration does), and the engine's existing "added or removed a variable after the initial render" log reports it, exactly as it does for aclassNamethat gains a variable.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.