Skip to content

fix(native): answer :dir() per element, and compile every spelling of it - #459

Open
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/per-element-directionality
Open

YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/per-element-directionality

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

:dir() — what Tailwind's rtl: / 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 the ltr arm answers true unconditionally, so on a right-to-left device both variants apply at once.

// src/native/conditions/media-query.ts
case "dir":
  return (I18nManager.isRTL && value === "rtl") || value === "ltr";

Selectors 4 §7.1 defines :dir() per element — the element's own dir, else the closest ancestor's. The compiler decides which spellings reach that arm, and today they disagree. Measured, the m each rule compiles to:

spelling before after
.x:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) — Tailwind's rtl: [["&", [["=","dir","rtl"]]]], from the first arm alone [["=","dir","rtl"]]
.x:dir(rtl) · .x:where([dir="rtl"]) · :dir(rtl) .x · :root[dir="rtl"] .x the rule is discarded [["=","dir","rtl"]]
[dir="rtl"] .x no condition — it applies in every direction [["=","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:

  • an element's dir prop is its declaration, published to descendants as the inherited variable __rn-css-directionality and read back through the VariableContext every descendant already subscribes to — written after the bag's inline merge, so nothing inherited or inline can shadow the element's own;
  • both reads are render guards, so a changed dir anywhere above re-derives the subtree;
  • an element that names dir provides 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");
  • the feature compares against that, falling back to I18nManager.isRTL — the root a React Native tree has — so an app declaring nothing reads rtl: exactly as today;
  • a declaring element takes the UA rule [dir=…] { direction: … } beneath every author rule, so an author direction class still wins while the directionality stays declared (§7.1: the property does not affect whether it matches);
  • the compiler routes every spelling to that one arm. An ancestor's [dir] is the directionality the element inherits, so it lands on the rule rather than on a container query; :root and html are 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's s flag says otherwise.

Tests

src/__tests__/compiler/directionality.test.ts, 27 cases over compile(): every spelling above, both directions, :dir() beside another pseudo-class, an author's own and carried whole beside the dir condition, 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 real updateRules: 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 author direction class outranking it, dir="auto", inline variables, a changed dir re-deriving the subtree, a dir that 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 :root opaque 2, ignoring the s flag 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 across undefined → 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 — testGuards runs during render against the live props, so a guard dropped while dir is 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 without dir, 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 dir coverage mocks I18nManager.isRTL, which passes for exactly the behaviour this replaces.

Verification

yarn typecheck and yarn lint clean; yarn test 1097 passed. The three failures are two babel suites that fail identically on an untouched main worktree (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 on metro-transformer.ts; on a device this change is inert until that one lands. #397 maps text-align for RTL. Neither touches how dir is answered.

Out of scope: :not(:dir()) — Tailwind's not-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 dir key itself — a conditional spread rather than a changing value — still changes its render shape unless its rules already provide a scope (any color declaration does), and the engine's existing "added or removed a variable after the initial render" log reports it, exactly as it does for a className that gains a variable.

Base

Branched off f70c402. main has 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 on f70c402. Say the word and I will re-apply it onto current main.

`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.
@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — All four bars are blue. The device's locale is left-to-right, so I18nManager.isRTL is false, and that one process-wide answer is given to every element on the screen regardless of what any of them declares.

FIXED — The first two bars are red and the last two blue. :dir(rtl) is answered per element (Selectors 4 §7.1): from the element's own dir, else from the nearest ancestor that declares one.

before — the process-global answer after — with this PR

Read the dir row. All four bars carry the identical class list — bg-[#2266ee] rtl:bg-[#ee2222], Tailwind's own rtl: variant over a blue base — and differ only in what is declared on or above them: dir="rtl" on the bar itself, dir="rtl" on its parent, dir="ltr" on the bar under an rtl parent, and nothing at all.

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 ltr island under an rtl ancestor, which must look like the baseline rather than like its parent. Both are blue in both frames.

The other five rows are byte-identical between the frames. before is this build with the runtime arm returned to its stock, process-global answer, one token and nothing else — and the bars use the rtl: spelling stock 3.0.7 already routes to that arm, so a fully stock build paints this row the same.

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 metro-transformer.ts (#461) lands, and these frames carry it: without it the native bundle receives Expo's web build of the stylesheet, where :dir(rtl) has already become a :lang() list and every rtl: rule is dropped before the runtime sees it.

`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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant