Skip to content

fix: map every logical border property onto a prop React Native reads - #393

Open
YevheniiKotyrlo wants to merge 7 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/border-inline-var-shorthand
Open

YevheniiKotyrlo wants to merge 7 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/border-inline-var-shorthand

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On main:

  1. border-block-color lands on two key sets. border-block-color: red writes borderBlockColor, and red blue writes borderTopColor / borderBottomColor. A later border-top-color then sits beside the axis key instead of replacing it, and Android and iOS rank borderBlockColor against borderTopColor oppositely, so the two platforms paint different colours. border-block writes borderBlockColor too.
  2. A var() keeps the axis colours and the six logical shorthands off the parsed path. border-inline-color: var(--c) writes borderInlineColor, which React Native has no prop for. border-block-color: var(--c) with --c: red blue writes borderBlockColor: ["red", "blue"]. border-inline: var(--b) reaches React Native as borderInline: [2, "solid", "red"], and border-block-start: var(--b) as borderBlockStart the same way.
  3. A var() edge style warns. border-inline-style: var(--tw-border-style), which every Tailwind v4 border-{x,s,e}-* utility emits, warns where the parsed path drops solid quietly, and border-block-style: dotted dashed names the inline edges in its warning.

Solution

  • Each axis colour lands on its edge pair at every arity: the inline axis on borderStartColor / borderEndColor, the block axis on borderTopColor / borderBottomColor. Each edge parses its own colour, so a light-dark() component opens its dark rule over the edge it feeds.
  • border-block-color, border-inline-color and the six logical shorthands resolve a var() at runtime onto the same keys, reading as many values as the variable substitutes, as border-inline-width already does.
  • A var() edge style drops as quietly as solid, and a two-value edge style names its own edges.
  • A runtime-parsed value that compiles to nothing declares nothing; border-inline-color: inherit left an empty style.

Tests

  • src/__tests__/compiler/logical-borders.test.ts pins every logical property on the parsed path and the runtime call each var() compiles to.
  • src/__tests__/native/logical-borders.test.tsx renders each: one key set per property under every cascade order and condition, each var() shorthand, a pair through one var(), a substitution the grammar does not take, and light-dark() on the parsed path.
  • On main, 80 of the 276 cases in these files and borders.test.tsx fail. Each of 10 mutations fails its own cases.
  • Every expectation is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS.

Verification

On Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes.

Device evidence, before and after: #393 (comment)

Known limits

Merge order

This builds on #474, which resolves every declaration in cascade order, so a runtime shorthand leaves a later longhand its keys. It lands first, and this carries its commits until it does.

It also shares lines with #389 (native/styles/calculate-props.ts), #391 (compiler/declarations.ts, native/styles/calculate-props.ts), #411 (native/styles/calculate-props.ts), #420 (compiler/declarations.ts, compiler/stylesheet.ts); whichever lands second rebases.

It also shares lines with #488 (native/styles/calculate-props.ts); whichever lands second rebases.

It also shares lines with #495 (compiler/declarations.ts); whichever lands second rebases.

Base

Re-written on main (a5002c5), which carries #379's inline longhand renames through #451, so this targets main instead of fix-logical-border-props. #451 also renames the block widths, drops the per-edge styles and resolves border-block-width / border-inline-width at runtime; this builds on that in place of its own compile-time expansion.

@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(compiler): expand var()-valued border-inline shorthands to start/end fix(compiler): expand var()-valued border-inline shorthands, drop the dead borderInlineStyle key Aug 15, 2026
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(compiler): expand var()-valued border-inline shorthands, drop the dead borderInlineStyle key fix: map every logical border property onto a prop React Native reads Aug 15, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 19:54
@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The three-part shorthand row paints no border — expanded from a var(), it sets neither edge. The width-only row above it paints on both builds.

FIXED — Both subject rows have a border on BOTH inline edges, matching the control.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

applyDeclarations reassigned its target parameter per declaration, and a
deferred declaration's closure captured that shared binding. The closure
runs after every declaration in the rule has been walked, so it read
whatever nested target the last declaration ended on: a deferred color
beside a box-shadow checked its placeholder against the shadow entry,
missed it, and left the internal { color: true } in the style.
-webkit-line-clamp: var(--n) left { numberOfLines: true } on the prop the
same way.

Each declaration now walks into a binding of its own.
A deferred declaration resolves after every other declaration of the
element, so a shorthand that resolved at runtime spread its keys over
whatever a later declaration had set: border: var(--b) followed by
border-color: blue painted the variable's colour, within a rule and across
rules, and so did border-inline-width: var(--w) over a later
border-inline-start-width. Each key belongs to the declaration that set it
last (css-cascade-5 §6.4).

Each element now records, per target and key, the cascade position of its
last writer. A deferred shorthand learns its keys only when it resolves, so
it claims them one by one and keeps the ones a later declaration holds.
A shadow that omits its colour takes currentcolor, which resolves as a
deferred declaration inside the shadow. On main its placeholder reaches
the style: textShadowColor: { textShadowColor: true } for a colourless
text-shadow beside a color, and color: { color: true } on a colourless
box-shadow entry under an inherited colour.
A later `text-shadow` writes its offset through `textShadowOffset.width`
and `.height`, and only the leaf was claimed, so an earlier
`text-shadow: var()` that resolved late still replaced `textShadowOffset`
whole: `.earlier { --s: 2px 2px blue; text-shadow: var(--s) }` before
`.my-class { text-shadow: 1px 1px red }` drew the red shadow at the blue
offset. A declaration written through a path now claims each key it
passes through.
The two text-shadow rows are about which declaration wins, so the
colour a variable carries is written in the form a runtime reader keeps
whether it serialises a named colour or not.
…ent's variables are assigned

A declaration that reads a var() was resolved after the walk, through a
placeholder at its key, so the walk had to remember which later declaration
set each key to keep a deferred shorthand off it. A placeholder is also what
an individual transform property replaced with an empty list, so
`.a { transform: var(--t) } .b { rotate: 45deg }` lost the transform.

Every rule's variables are now assigned before any declaration resolves, and
every declaration resolves in cascade order, so a later declaration overwrites
an earlier one as the browsers apply them and the key claims are gone. A
placeholder is left only for an individual transform property, which composes
into the transform list once every rule is walked. A runtime value that
resolves to nothing still resets the property.
border-block-color wrote borderBlockColor when its edges matched and
borderTopColor / borderBottomColor when they did not, so a later
border-top-color sat beside the axis key instead of replacing it, and
Android and iOS rank the two oppositely. Each axis colour now lands on its
edge pair at every arity, and a light-dark() component opens its dark rule
over the edge it feeds.

A var() kept the axis colours and the six logical shorthands off the parsed
path: border-inline-color: var(--c) wrote borderInlineColor, which React
Native lacks, and border-inline: var(--b) reached it as a list. Each now
resolves at runtime onto the same keys the compiler writes, reading as many
values as the variable substitutes, as border-inline-width already does. A
var() edge style drops as quietly as solid, since an unknown value is not a
known non-solid one, and a runtime-parsed value that compiles to nothing
declares nothing.

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