fix: map every logical border property onto a prop React Native reads - #393
Open
YevheniiKotyrlo wants to merge 7 commits into
Open
Conversation
Expanding a var()-valued border-inline shorthand across the inline axis
had three defects, and the one that mattered most was invisible from the
compiler tests.
light-dark() wrote into the PREVIOUS declaration. parseUnparsed() ran
before the expansion target was set, and light-dark() adds its dark-mode
descriptor straight to the builder from inside that call, so it read
whichever property the declaration before it had left behind. Measured in
dark mode, `.c { width: var(--w); border-inline-color: light-dark(a, b) }`
rendered `width: "blue"` and left both edges on the light colour; with a
`color` declaration in front it overwrote the element's text colour.
Every other branch of parseUnparsedDeclaration sets the target first, and
this one now does too. It also carries the pair rather than one property,
so the dark value reaches both edges of the single extra rule light-dark()
opens — which is why descriptorProperty becomes descriptorProperties.
Two-value declarations put the whole pair on both edges.
`border-inline-width: var(--a) var(--b)` is start=a, end=b, which
parseBorderInlineWidth already does on the parsed path. The unparsed path
assigned the list twice, so an array reached borderStartWidth and
borderEndWidth, which React Native consumes as numbers. The expansion now
splits the value into its top-level component values first. Whitespace is
not a reliable separator: lightningcss keeps the space in `red var(--b)`
and drops it in `var(--a) var(--b)`, so the split filters whitespace and
treats each remaining entry as one component value, which is what the CSS
syntax definition makes it. More than two is not the grammar, so the
declaration drops with a warning.
border-inline, border-inline-start and border-inline-end no longer
pretend to be expanded. Each packs width, style and colour into one
runtime value and no style resolver fans one slot out to a per-edge pair,
so a var()-valued one now warns and drops rather than emitting a
borderInline* prop React Native has no style attribute for. This is a
behaviour change: those props were emitted before and silently ignored.
The parsed path still expands all three — lightningcss has split the
value by then.
The light-dark defect is only observable once a component renders, so the
guards live in a new src/__tests__/native/logical-borders suite beside the
compiler ones: light and dark render, the two-value pair asserted against
the parsed path's own output, @media, :hover, !important, a longhand
cascade, an unresolvable var(), and Tailwind's border-x-[color:var(--c)].
Every variable in them is defined twice — a variable with one definition
is inlined by the compiler and never reaches this path at all.
React Native has no per-side border style attribute, so an inline border
style is dropped whatever its value resolves to. The parsed path splits the
two cases: `solid` matches React Native's default rendering and drops
without a word, anything else drops with a value warning.
A var() keeps the declaration on the unparsed path, where the value is
unknown at compile time — and an unknown value is not a known non-solid
one, so it drops as quietly as `solid` does. Tailwind v4 sends every
border-{x,s,e}-* utility through here as `var(--tw-border-style)`, whose
default is `solid`, so a property warning fires on correct input and
reports the whole property unsupported when only the per-side value is.
The inline shorthands keep their warning: border-inline / -start / -end
pack width, style and colour into one runtime value, so dropping one loses
width and colour React Native could otherwise have rendered.
This was referenced Aug 15, 2026
The three-part border-inline / -start / -end shorthands were dropped with a warning when a var() kept them unparsed, on the reasoning that no style resolver can fan one slot out to a per-edge pair. That reasoning does not hold: ShortHandSymbol already does exactly that. A resolver returning a marked object has its keys spread onto the style object, which is how `border: var(--b)` reaches borderWidth, borderStyle and borderColor from one opaque value. So route the three through unparsedRuntimeParsing beside `border`, and add runtime handlers that match the same grammar and fan the resolved list onto the RTL-aware per-edge props. The grammar table is shared with `border` rather than copied, so the two cannot drift. The style component is dropped rather than widened to borderStyle. React Native has no per-edge border style at any layer: BaseViewConfig.android.js and BaseViewConfig.ios.js list borderStyle and nothing per-edge, ViewStyle declares only borderStyle, and Android's BorderDrawable holds a single style for the whole border path. Widening would paint the block edges the declaration never mentioned and clobber a border-style set elsewhere in the cascade, so this matches what the parsed path already does. Tested at both planes, because the compiler IR cannot see the defect that matters most here: a borderInlineStyle entry in the emitted declarations looks like a real declaration, and only the rendered component shows that React Native has no such attribute and ignores it. Reverting the runtime handler alone leaves every compiler test green and turns the native ones red, which is why both exist. A var()-valued shorthand still overrides a longhand written after it. That is how every runtime shorthand in the library behaves — `border` included, long before this change — so it is asserted as parity with `border` rather than pinned to a value, and it is unreachable from the compile-time split.
`border-block-*` reaches React Native under keys it has no attribute for, so
the declaration compiles, renders and paints nothing. Measured through this
branch's own compiler and asserted against the props a rendered `View`
receives:
border-block: 2px solid red -> borderBlockColor, borderBlockWidth,
borderBlockStyle
border-block-width: 2px -> borderBlockWidth
border-block-start-width: 2px -> borderBlockStartWidth
border-block-style: dashed -> borderBlockStyle
border-block: var(--b) -> borderBlock: [2, "dashed", "red"]
React Native's support here is not uniform, which is what makes the defect
hard to see. The three block COLOURS are real props — `borderBlockColor`,
`borderBlockStartColor` and `borderBlockEndColor` are in
`ReactNativeStyleAttributes`, in `BaseViewConfig.android.js`, in
`BaseViewConfig.ios.js` and in `ViewStyle`. The block WIDTHS are in
`BaseViewConfig.ios.js` and nowhere else, so an emitted `borderBlockWidth`
paints on iOS Fabric and is dropped on Android and on the old architecture.
No per-edge border STYLE exists at any layer on either platform.
So the colours are kept as they are, the widths map to the physical edges
every platform reads, and the styles drop the way the inline axis already
drops them. `direction` never flips the block axis, so block-start is the top
edge and block-end the bottom one on every platform, which makes the mapping
exact rather than an approximation.
The live trigger is Tailwind: `border-y-1` compiled to
`{ borderBlockWidth: 1, borderBlockStyle: "solid" }`, two keys React Native
ignores, so the utility drew nothing on Android — the block-axis twin of the
`border-x-*` bug nativewind#379 fixed. `src/__tests__/vendor/tailwind/borders.test.tsx`
asserted those two dead keys and passed while broken, exactly as nativewind#378
describes for the inline axis; it now asserts `borderTopWidth` /
`borderBottomWidth`.
Also on the unparsed path: `border-block-color: var(--a) var(--b)` put the
whole two-value list into one key, and `border-block-end-style` was missing
from the parser table so it warned as an unsupported property while
`border-block-start-style` silently emitted a dead key. Both now behave like
their inline-axis twins.
`parseBorderInlineStyle` becomes `parseUnsupportedEdgeStyle` and serves all
six per-edge style longhands: the decision it encodes — which per-edge styles
React Native can express, and how a dropped one is reported — is the same on
both axes, and two copies of it could answer differently.
The two planes are independently load-bearing, by measurement. Removing the
`borderBlock` runtime handler leaves every compiler test green and turns three
native ones red; pointing that handler at `borderBlockWidth` does the same.
A dead key is invisible in the IR, where it looks exactly like a real
declaration, so the assertion has to be made against the props the component
received.
The new sweep is derived rather than restated: it generates all 24
`border-{inline,block}[-start|-end][-width|-style|-color]` properties, drives
each through both the literal and the var() route, and asserts every rendered
key is one React Native declares. The census of real keys carries
`satisfies readonly (keyof ViewStyle)[]`, so a name React Native does not
declare cannot be added to it to make a dead key pass, and a name React Native
drops later turns the type-check red.
YevheniiKotyrlo
marked this pull request as ready for review
August 15, 2026 19:54
`border-block-color` reached React Native under three different key sets
depending on how it was written, and the third was this branch's doing.
The parsed path has always had two, matching what React Native gives the
block axis: one value collapses onto `borderBlockColor`, the axis-wide
property, and two split across `borderTopColor` / `borderBottomColor`.
Routing the unparsed path through `axisExpansion` added
`borderBlockStartColor` / `borderBlockEndColor` for both arities.
That is a wrong render rather than a representational difference. The
key sets are disjoint, React Native's style object is flat, so both
survive the cascade — and its per-edge properties outrank the axis one:
.base { border-block-color: var(--color); } /* red */
.override { border-block-color: green; }
{borderBlockColor: "#008000",
borderBlockStartColor: "red", borderBlockEndColor: "red"}
Both edges paint red; the later declaration is inert. It is reachable
from Tailwind, where `border-y-*` compiles to `border-block-color`.
The expansion table now states a target per arity instead of one pair,
so the unparsed route makes the parsed route's choice at each: the entry
carries the edge pair plus, where React Native has one, the axis-wide
property a single component collapses onto. `borderBlockColor` is the
family's only such property — there is no `borderInlineColor`, and
`borderBlockWidth` is in `BaseViewConfig.ios.js` alone — so it is the
only entry that declares it, and the inline axis is untouched.
The parity test that should have caught this covered `border-block`,
which has no two-value form and so cannot see a route that agrees at one
arity and departs at the other. It now covers both longhands at both
arities, beside a cascade test that pins why parity is the requirement.
Four smaller things ride along, each measured the same way:
- Two compiler tests close a plane gap. Removing the whitespace filter
in `unparsedComponentValues` reddened no compiler test and two native
ones, because `var(--a) var(--b)` counts to two whether or not the
separator survives; `1px var(--b)` is the shape that counts to three
without the filter, and it is now asserted. The `light-dark()` axis
case had the same gap and gets the same treatment, on both axes.
- The multi-value residual gets the test it never had. A var() holding
a pair is one component value, so it is assigned whole rather than
split. That belongs to the unparsed path, not to the logical axes:
`border-inline-start-width: var(--pair)` and `border-width:
var(--pair)` produce the same list on the same keys, and predate
this branch. It is asserted as parity with them so the routes move
together whenever the shared behaviour is fixed.
- The census comments named the wrong mechanism. The expectation is
constrained by `keyof ViewStyle`, not derived from
`ReactNativeStyleAttributes`, and a name React Native adds does not
widen a hand-written list on its own — only the removal half of that
claim held.
- `parseUnsupportedEdgeStyle` has no consumer outside this module, so
it is no longer exported.
… light-dark()
`border-block-color` reached three different key sets depending on how its
value happened to be written. `parseBorderColor` chose between the axis
property and the physical edge pair with `start === end`, which is a REFERENCE
comparison, so `red red` collapsed onto `borderBlockColor` while `currentcolor`
split across `borderTopColor`/`borderBottomColor` — `parseColor` interns the
first and builds a fresh `var()` array per call for the second. The unparsed
path made the same split by arity.
Two of those sets are disjoint, and a flat style object keeps both, so a later
declaration of the same property sat beside the earlier one instead of
replacing it:
.a { border-block-color: var(--x) var(--y); } /* red blue */
.b { border-block-color: var(--z); } /* green */
emitted `borderTopColor`, `borderBottomColor` AND `borderBlockColor`. Which one
paints is not even the same on both platforms — Android resolves the top edge
`BLOCK_START ?: TOP ?: BLOCK ?: ...` so `borderTopColor` wins, iOS assigns
`borderTopColor = _borderBlockColor` whenever the axis property is set, which
is the opposite order. So that rule painted red/blue on Android and green/green
on iOS.
Everything in the family now reaches the physical edge pair: the axis key is
gone from `axisExpansion`, from `parseBorderColor`, from `parseBorderBlock` and
from the runtime `axisTargets`, and `parseUnparsedAxis`'s axis branch goes with
it. Nothing is lost — the pair is the set both platforms agree on once the axis
property is out of play, and `direction` never flips the block axis on either,
so top and bottom stay the block edges under RTL. `border-block-color` now
behaves exactly like `border-block-width`.
The second half finishes the mechanism this branch already built for the
unparsed path. `light-dark()` does not return its dark half — it writes it to
the builder as a second rule addressed to `descriptorProperties`, and
`parseWithParser` seeded that with the declaration's raw CSS name, before both
the rename and any target expansion. So the dark rule landed on a name React
Native drops without a word: `border-inline: 1px solid light-dark(...)` painted
four correct per-edge props in light and collapsed to `{borderInline: ...}` in
dark, and nine of the family's twenty-four members missed the same way.
`parseUnparsedAxis` already carried its target set correctly; the parsed path
now does too, via `parseColorFor`, and the seed defaults to the RENAMED name so
`border-inline-start-color` and its twin reach the props they are renamed to.
Making the block colour physical creates a tenth case for the same mechanism:
the dark rule kept writing `borderBlockColor` while the light rule wrote the
edge pair. Both are real keys, so a dead-key check cannot see it — the guard
below asserts that the two schemes reach the same keys as well.
Guarding it: the class test generated `literal` and `var` spellings only, and
rendered only the light scheme, which is why all of this shipped green. It now
generates a `light-dark()` spelling too, renders both schemes, and asserts the
values rather than just the key names, so a dark rule that landed on a real key
and still never applied is caught. The cascade matrix over `border-block-color`
covers the full cross product of the two arities instead of its diagonal — only
an off-diagonal cell can see two routes that agree at each arity separately —
and a parity test pins every spelling of the property to one key set.
The three-part shorthands are excluded from the var()-bearing half of that
guard, and the exclusion is named where it is made. A var() inside one compiles
to a single runtime call carrying width, style and colour together while the
dark rule holds the colour alone, so whichever rule lands second wins the whole
set and no choice of target fixes it. That needs the reducer to take a scheme
and run twice, which is machinery every runtime-parsed shorthand shares
(`border`, `border-top`, `box-shadow` and `text-shadow` all miss the same way
today) and is not this family's to change.
Contributor
Author
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.


Completes #379
#379 maps the logical-border longhands to their RN start/end props via
propertyRename(border-inline-start-color→borderStartColor, …). Every otherborder-inline*form takes a different path once avar()is involved: the value stays unparsed, the parsedparseBorderInline*never runs, andpropertyRenameonly covers longhands. The declaration then reaches React Native under a name it has no attribute for, which is a key that renders nothing.This PR closes that for the whole family — both logical axes, and every spelling a value can take: a literal, a
var(), and alight-dark()written either way. Stacked on yourfix-logical-border-propsbranch, so the diff here is only the delta.border-inline-color: var(--c)borderInlineColor— deadborderStartColor+borderEndColorborder-inline-width: var(--w)borderInlineWidth— deadborderStartWidth+borderEndWidthborder-inline-width: var(--a) var(--b)--a, end =--bborder-inline-color: light-dark(var(--a), var(--b))border-inline-style: var(--s)border-x-*border-inline: var(--v)borderInline: [1, "solid", "red"]— deadborderStart/EndWidth+borderStart/EndColorborder-inline-start: var(--v)borderInlineStart: [...]borderStartWidth+borderStartColorborder-inline-end: var(--v)borderInlineEnd: [...]borderEndWidth+borderEndColorborder-inline: 6px solid #26eborderInlineStyleborder-inline: 1px solid light-dark(a, b)borderInlinein darkThe block axis is the same defect, and
border-y-*is its live triggerEnumerating the whole family rather than the properties in the title turned up the other half.
border-block-*reaches React Native under keys it has no attribute for, so the declaration compiles, renders, and paints nothing. Measured through the compiler on this branch and read off the props a renderedViewreceived:border-block-width: 2pxborderBlockWidthborderTopWidth+borderBottomWidthborder-block-start-width: 2pxborderBlockStartWidthborderTopWidthborder-block-end-width: 2pxborderBlockEndWidthborderBottomWidthborder-block-style: dashedborderBlockStyleborder-block-start-style: dashedborderBlockStartStyleborder-block-end-style: dashedborder-block: 2px solid redborderBlockColor+borderBlockWidth+borderBlockStyleborderTopColor+borderBottomColor+borderTopWidth+borderBottomWidthborder-block: var(--b)borderBlock: [2, "dashed", "red"]— a raw list under a dead keyborder-inlinedoesborder-block-start: var(--b)borderBlockStart: [...]borderTopWidth+borderBlockStartColorborder-block-end: var(--b)borderBlockEnd: [...]borderBottomWidth+borderBlockEndColorborder-block-color: redborderBlockColorborderTopColor+borderBottomColorborder-block-color: var(--c)borderBlockColorborderTopColor+borderBottomColorborder-block-color: var(--a) var(--b)borderTopColor=--a,borderBottomColor=--bReact Native's support here is not uniform, which is what makes this hard to see. The two per-edge block colours are real props —
borderBlockStartColorandborderBlockEndColorappear inReactNativeStyleAttributes, inBaseViewConfig.android.js, inBaseViewConfig.ios.jsand inViewStyle, and each is the highest-precedence name for its edge on both platforms — so they are kept exactly as they are. The block widths appear inBaseViewConfig.ios.jsand nowhere else, so an emittedborderBlockWidthpaints on iOS Paper and is dropped everywhere else; they map to the physical edges every platform reads. No per-edge border style exists at any layer on either platform, so those drop the way the inline axis already drops them. The axis-wideborderBlockColoris real on both platforms and is still not the right target — the next section is why.Two details make the width case stronger than "Android is missing it". That
BaseViewConfig.ios.jsentry is a top-level prop whitelist entry, while astyleobject is filtered byvalidAttributes.style— which isReactNativeStyleAttributes, and that lacks the block widths. And Fabric'sViewPropshas noborderBlock*at all. So a block width insidestyleis dead on iOS too, not merely on Android.The mapping is exact rather than an approximation:
directionnever flips the block axis, so block-start is the top edge and block-end the bottom one on every platform.The live trigger is Tailwind, and it is the block-axis twin of the
border-x-*bug #379 fixes.border-y-1compiled to{ borderBlockWidth: 1, borderBlockStyle: "solid" }— two keys React Native ignores — so the utility drew nothing on Android.src/__tests__/vendor/tailwind/borders.test.tsxasserted both dead keys and passed while broken, exactly as #378 describes for the inline axis. It now assertsborderTopWidth/borderBottomWidth, mirroring theborder-x-1case two tests above it.parseBorderInlineStylebecomesparseUnsupportedEdgeStyleand serves all six per-edge style longhands. What it encodes — which per-edge styles React Native can express, and how a dropped one is reported — is one decision, and two copies of it could answer differently.border-block-colorneeds one key set, and the discriminator was a reference comparisonborder-block-coloris the one property in the family where React Native offers an axis-wide key and per-edge ones, and it reached different ones depending on how its value happened to be written.parseBorderColorchose between them withstart === end. That is a reference comparison over two parsed values, not a comparison of what the CSS says:parseColorreturns an interned string for a literal and builds a freshvar()array per call for anything unresolved. Sored redcollapsed ontoborderBlockColorwhilecurrentcolor— which says exactly the same thing about both edges — split acrossborderTopColor/borderBottomColor. The unparsed path made the same split by arity.React Native's style object is flat, so two disjoint key sets do not override one another — they accumulate:
Two declarations of the same property, and the later one does not replace the earlier one. Which of them paints is not the same on the two platforms, so this is not even a stable wrong answer:
BLOCK_START ?: TOP ?: BLOCK ?: VERTICAL ?: ALL(ReactAndroid/.../uimanager/style/BorderColors.kt,BorderColors.resolve), soborderTopColoroutranksborderBlockColorand the rule paints red / blue.borderTopColor = _borderBlockColorwhenever the axis property is set (React/Views/RCTView.m,borderColorsWithTraitCollection), which is the opposite order, so the same rule paints green / green.So every member of the family now reaches the physical edge pair. The axis key is gone from
axisExpansion, fromparseBorderColor, fromparseBorderBlockand from the runtimeaxisTargets, andparseUnparsedAxis's axis branch goes with it.Nothing is lost, and the reason is not that one name outranks the other — that ranking is precisely the thing the two platforms disagree about. It is that
borderBlockColoris no longer emitted anywhere, so the ranking never applies: every route writes the pair, both platforms read the pair the same way, and a later declaration replaces an earlier one because they land on the same keys.border-block-colornow behaves exactly likeborder-block-width.Under RTL the pair is still the block edges. Android keeps
BLOCK_START ?: TOP ?: …for the top edge in both of its RTL branches, and iOS handles the block colours outside itsisRTLswap, which only ever touches left and right.directiondoes not flip the block axis on either platform.The per-edge block colours are untouched:
borderBlockStartColorandborderBlockEndColorare the highest-precedence name for their edge on both platforms, so they already agree.The dark half of a
light-dark()has to be addressed, and nine members addressed it wronglylight-dark()does not return its dark half through the value the parser hands back. It writes it straight to the builder as a second rule, addressed to whateverdescriptorPropertiesnames.parseWithParserseeded that field with the declaration's raw CSS name — before thepropertyRenameand before any target expansion — so a parser that renames or expands left the dark rule pointing at a property React Native drops without a word.border-inline: 1px solid light-dark(#2266ee, #66aaff)therefore painted four correct per-edge props in light and collapsed to{ borderInline: "#6af" }in dark. Nine of the family's twenty-four members missed the same way:border-inlineborderInlineborder-inline-colorborderInlineColorborder-inline-startborderInlineStartborder-inline-start-colorborderInlineStartColorborder-inline-endborderInlineEndborder-inline-end-colorborderInlineEndColorborder-blockborderBlockborder-block-startborderBlockStartborder-block-endborderBlockEndEvery one of those is a dead key, so the dark branch paints nothing and the light value stays on screen in dark mode.
The fix finishes the mechanism this branch already built for the unparsed path —
parseUnparsedAxiscarried its target set correctly and the parsed path now does too.parseWithParser's seed defaults to the renamed name, which is what a parser that writes to its own property actually writes; a parser that expands onto other properties names them itself, throughparseColorFor.The two halves of this PR are one change, and this is the non-obvious part. Making the block colour physical creates a tenth case for the same mechanism:
border-block-color: light-dark(…)emits the edge pair in light andborderBlockColorin dark. Both of those are real React Native keys, so a dead-key check cannot see it — the style object simply keeps all three, and which colour paints is the platform ranking again. Landing the key-set fix without thedescriptorPropertiesfix ships a new light/dark mismatch that no key census would catch.Measured, running the class guard against each half of the change in turn:
descriptorPropertiesseedborder-block-colorkey setborder-block-colorThe tenth fails on
expect(dark).toStrictEqual(light)rather than on the dead-key filter, with["borderBlockColor", "borderBottomColor", "borderTopColor"]where the light scheme produced two keys.The three-part shorthands
border-inline/-start/-endand their block twins pack width, style and colour into one list that avar()keeps opaque, so the split has to happen after resolution. That is not a new capability — it is exactly whatborderdoes.ShortHandSymbolmarks a resolver's return value so its keys are spread onto the style object rather than assigned under the shorthand's own name, which is howborder: var(--b)has always reachedborderWidth,borderStyleandborderColorfrom a single opaque value.So the six now sit in
unparsedRuntimeParsingbesideborder, andsrc/native/styles/shorthands/border.tsgains handlers that match the same grammar table (shared, not copied, so they cannot drift) and fan the resolved list onto that axis's per-edge props.Why the style component is dropped, not mapped
A logical-axis declaration names two edges, and React Native has no per-edge border style to give them — at any layer:
Libraries/NativeComponent/BaseViewConfig.android.jsand.ios.jslistborderStyle: trueand nothing per-edge, so on the old architecture a per-edge style is not a valid attribute and never crosses the bridge.ViewStyleinLibraries/StyleSheet/StyleSheetTypes.d.tsdeclares onlyborderStyle— noborderStartStyle,borderEndStyle,borderInlineStyleorborderBlockStyle.BackgroundStyleApplicator.setBorderStyle(view, style)writes a singleborderStylefield onBorderDrawable, applied to the whole border path.(
borderStartStyle/borderEndStyledo appear in Fabric'sSET_CASCADED_RECTANGLE_EDGESraw-prop parser and inAnimatedPropSerializer, so they parse — but nothing renders them per-edge, and neither name is in the public style surface. Checked against 0.81.4 and 0.86.0.)The two honest options were to widen the style to
borderStyleor to drop it. Widening loses:border-inline: 2px dashed redwould dash the block edges the declaration never mentioned, and would clobber aborder-styleset elsewhere in the cascade. Dropping degrades to a solid border, which is React Native's default, and the parsed path already warns for a non-solidvalue. Dropping also keeps the shorthands consistent with theborder-{inline,block}-*-stylelonghands, which have always dropped.That same reasoning is why
border-inline: 6px solid #26eno longer emitsborderInlineStyle. React Native silently ignores that key, so an author got width and colour but never the style, with nothing to indicate why.Tested at both planes
The compiler IR cannot see the defect that matters most here: a
borderInlineStyleorborderBlockWidthentry in the emitted declarations looks exactly like a real one, and only the rendered component shows that React Native ignores it. Sosrc/__tests__/native/logical-borders.test.tsxasserts on the props aViewactually receives, alongside the IR assertions insrc/__tests__/compiler/logical-borders.test.ts.The class guard is a sweep over all 24
border-{inline,block}[-start|-end][-width|-style|-color]properties, driving each through the literal and thevar()spelling and asserting every rendered key is one React Native declares. A third and fourth sweep take the 12 colour-bearing members — the six shorthands and the six-colorlonghands, derived from the same census rather than listed — through alight-dark()written literally and again over avar(), and render both colour schemes. The census of real keys is written by hand — no type enumerates "the border keys" — but the constraint on it is not:satisfies readonly (keyof ViewStyle)[]makes React Native's own type decide which names may appear, so a dead key cannot be added to the census to make a failing case pass, and a name React Native drops in a later release turnsyarn typecheckred. A count assertion guards against the generated family silently becoming empty, and a negative control pins that the predicate really does reject the eleven dead names.The guard asserts VALUES, not just key names, because a key-only version of it cannot fail. Point the runtime-call branch at a colour-target census and every key goes green while the dark scheme is still painting the light colour — the shorthand's single runtime call rewrites all four props after the dark rule has landed, so the key set is identical either way. Asserting that the dark scheme's rendered values contain the dark colour and not the light one is what makes the assertion able to go red.
The six three-part shorthands are excluded from the
var()-bearing half of that guard, and the exclusion is named where it is made — see the known limits below.Both planes are independently load-bearing, by measurement rather than by argument. Unregistering the
borderBlockruntime handler leaves the compiler suite at 67 / 67 green and turns exactly three native cases red. Each of the four sites that routeborder-block-colorwas then mutated in turn, against the 277 cases in the three border suites:parseBorderColorcollapses onstart === endagainparseBorderBlockemitsborder-block-coloragainaxisTargets.borderBlock.borderColorpoints atborderBlockColoragainRestored: 277 / 277.
Where a mutation reddened nothing on one plane, that plane got the test it was missing: removing the whitespace filter in
unparsedComponentValuesused to redden zero compiler tests, becausevar(--a) var(--b)counts to two components whether or not the separator survives.1px var(--b)is the shape that counts to three without the filter, and it is now asserted; thelight-dark()axis case had the same hole and is now asserted on both axes.Also covered: single and two-value expansion,
var()fallbacks,calc()over avar(),@media,:hover,!important, unresolvable variables, the full cross product of the two arities in theborder-block-colorcascade matrix (only an off-diagonal cell can see two routes that agree at each arity separately), every spelling ofborder-block-colorpinned to one key set, each arity the grammar accepts plus one it does not, and the parsed/unparsed routes asserted against each other rather than against two hand-written expectations.Known limits
All of these are deliberate, and each is asserted as parity with the route it shares the behaviour with rather than as a pinned value, so whoever fixes the shared cause sees every route move together instead of finding a test that hard-codes the old answer.
1. A
var()-valued shorthand resolves after the cascade has been flattened, so it overrides a longhand written after it:border: var(--v); border-color: blackbehaves identically, and has sinceborderjoined the runtime-parsed set. The compile-time split does respect the longhand. Fixing it means changing shorthand ordering forborder,boxShadow,textShadow,animationandtransformtogether.This PR does not create the case on the block axis either — it makes it visible there, by removing the accident that hid it on one platform.
border-block: var(--v); border-bottom-color: blackused to emitborderBlockColorbesideborderBottomColor, and Android ranks the bottom edgeBLOCK_END ?: BOTTOM ?: BLOCK ?: …, so the later longhand won there while iOS'sif (_borderBlockColor) { … borderBottomColor = _borderBlockColor; }gave the shorthand the edge. Both routes land onborderBottomColornow, so the two platforms agree — on the same answerborderand the inline axis already give.A later longhand that has a higher-ranked key of its own still wins, on both platforms and in the same order:
border-block: var(--v); border-block-end-color: blackrenders{ borderBlockEndColor: "#000", borderBottomColor: "red", … }, andborderBlockEndColoroutranksborderBottomColoron Android and on iOS alike.2. A
var()is one component value however many values it holds, so--pair: 1px 2pxwithborder-inline-width: var(--pair)assigns the whole list to both edges rather than splitting it. Written inline,border-inline-width: var(--a) var(--b)splits correctly, because that is two components.Worth stating precisely, because the width half is not benign the way the colour half is.
borderStartWidth,borderTopWidthand friends are declaredtrueinReactNativeStyleAttributes— no processor — so the list is handed to the shadow node as it stands, whereborderColorand its siblings carrycolorAttributesandprocessColordrops a list on the way.That exposure is not new here, on any key. Measured on this PR's base, every key this change routes a width onto already receives the same list through its own longhand:
So this is a property of the unparsed multi-value path rather than of the logical axes, and guarding it on these four properties alone would leave the physical twins producing the identical list while making two members of the family behave differently from the rest for no reason a reader could derive. The test asserts the axis route against
border-inline-start-widthandborder-widthinstead, so the whole class moves together when the unparsed path learns to split a resolved list.3. On the unparsed path a per-edge
-styledrops with no diagnostic at all. This is a decision, not a side effect: the whole property has no native attribute at any value, and warning here fires on every Tailwind v4border-{x,s,e}-*utility, which emitsborder-inline-*-style: var(--tw-border-style)defaulting to thesolidthe parsed path already drops without a word.The price is that a hand-written
--s: dashed; border-inline-style: var(--s)loses its warning, while the same declaration written as a literaldashedstill warns on the parsed path. If you would rather keep the warning and accept the Tailwind noise, that is a one-line change to the early return inparseUnparsedDeclarationand I will make it.4. A
light-dark()inside a three-part shorthand'svar()still misses, and no choice of target fixes it. Avar()inside one of the six compiles to a single runtime call carrying width, style and colour together, while the dark rule the reducer opens holds the colour alone — so whichever of the two rules lands second wins the whole set. Making it correct means giving the reducer a scheme so the shorthand is reduced twice, once per branch, which is machinery every runtime-parsed shorthand shares. That is the next section, and it is not this family's to change. The six are excluded from thevar()-bearing half of the class guard, with that reason written at the exclusion.5. The four logical radii have no tests, anywhere in the repo.
border-{start,end}-{start,end}-radiusare outside this PR's scope — they areparseSize2DDimensionPercentageDeclaration, not the border parsers — and React Native does declare all four (borderStartStartRadiusand its siblings are inReactNativeStyleAttributesand inViewStyle). Measured on this branch, all four reach their real key on both the literal and thevar()route, so they are untested rather than broken. Worth a case in whichever PR next touches that file.A wider class of the same
light-dark()defect, deliberately not touched hereThe
descriptorPropertiesseed this PR fixes for the border family is wrong for other properties too. None of the below is touched here, and that is provable rather than asserted:border,border-top,box-shadowandtext-shadowhave nopropertyRenameentry, so the new seed resolves to the same name the old one did, and none of them goes throughparseColorFor. A logical-borders PR is also the wrong place to changebox-shadow. Measured on this branch, off the props a renderedViewreceived:border,borderTopandtextShadoware all dead keys, so in every one of those the dark half is discarded and the light colour stays on screen in dark mode.The two shadows are a worse class than a wrong colour, because the dark rule violates the value's shape rather than its name. Over a
var():boxShadowis a real React Native key, and in dark mode it holds a bare string where an array of shadow objects is expected — the dark rule replaces the whole resolved shadow with just its colour.Happy to open these as a separate PR against whichever branch owns the extra-rule mechanism, if that is useful. I did not fold them in here because the fix is the same shared reducer as known limit 4, and it would put a change to
box-shadowinside a border PR.Checks
yarn typecheckandyarn lintexit 0.The three failures are
src/__tests__/babel/react-native.test.tscase 7 andsrc/__tests__/babel/react-native-web.test.tscases 6 and 17 — the samebabel-plugin-testermismatches over an unrewritten relativerequire("../View")that the base has, unrelated to this diff (src/babel/*imports nothing fromsrc/compiler/*orsrc/native/*); #390 fixes them.Base of this PR (
fix-logical-border-propsat12ebb97), measured in a clean checkout of the same tree:Two things about reproducing either figure. Those three failures are the stable ones on a warm cache; a cold or loaded run also produces a tail of first-in-file 5000 ms timeouts that vanish on re-run, so 3 is not an invariant constant. And read the suite line rather than the total: a suite that fails to load on Windows reports zero failed tests and silently subtracts its whole count, which is what a cold first run does here.