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:fix-logical-border-propsfrom
YevheniiKotyrlo:fix/border-inline-var-shorthand
Open

fix: map every logical border property onto a prop React Native reads#393
YevheniiKotyrlo wants to merge 7 commits into
nativewind:fix-logical-border-propsfrom
YevheniiKotyrlo:fix/border-inline-var-shorthand

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Completes #379

#379 maps the logical-border longhands to their RN start/end props via propertyRename (border-inline-start-colorborderStartColor, …). Every other border-inline* form takes a different path once a var() is involved: the value stays unparsed, the parsed parseBorderInline* never runs, and propertyRename only 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 a light-dark() written either way. Stacked on your fix-logical-border-props branch, so the diff here is only the delta.

CSS before after
border-inline-color: var(--c) borderInlineColor — dead borderStartColor + borderEndColor
border-inline-width: var(--w) borderInlineWidth — dead borderStartWidth + borderEndWidth
border-inline-width: var(--a) var(--b) both values in one dead key start = --a, end = --b
border-inline-color: light-dark(var(--a), var(--b)) wrote into the previous declaration both edges, correct scheme
border-inline-style: var(--s) warned on every Tailwind border-x-* dropped silently
border-inline: var(--v) borderInline: [1, "solid", "red"] — dead borderStart/EndWidth + borderStart/EndColor
border-inline-start: var(--v) borderInlineStart: [...] borderStartWidth + borderStartColor
border-inline-end: var(--v) borderInlineEnd: [...] borderEndWidth + borderEndColor
border-inline: 6px solid #26e also emitted a dead borderInlineStyle four real edge props, nothing else
border-inline: 1px solid light-dark(a, b) four correct props in light, borderInline in dark four correct props in both schemes

The block axis is the same defect, and border-y-* is its live trigger

Enumerating 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 rendered View received:

CSS before after
border-block-width: 2px borderBlockWidth borderTopWidth + borderBottomWidth
border-block-start-width: 2px borderBlockStartWidth borderTopWidth
border-block-end-width: 2px borderBlockEndWidth borderBottomWidth
border-block-style: dashed borderBlockStyle dropped, with the same warning the inline axis gives
border-block-start-style: dashed borderBlockStartStyle dropped, same
border-block-end-style: dashed warned as an unsupported property dropped, same
border-block: 2px solid red borderBlockColor + borderBlockWidth + borderBlockStyle borderTopColor + borderBottomColor + borderTopWidth + borderBottomWidth
border-block: var(--b) borderBlock: [2, "dashed", "red"] — a raw list under a dead key expands like border-inline does
border-block-start: var(--b) borderBlockStart: [...] borderTopWidth + borderBlockStartColor
border-block-end: var(--b) borderBlockEnd: [...] borderBottomWidth + borderBlockEndColor
border-block-color: red borderBlockColor borderTopColor + borderBottomColor
border-block-color: var(--c) borderBlockColor borderTopColor + borderBottomColor
border-block-color: var(--a) var(--b) both values in one key borderTopColor = --a, borderBottomColor = --b

React Native's support here is not uniform, which is what makes this hard to see. The two per-edge block colours are real props — borderBlockStartColor and borderBlockEndColor appear in ReactNativeStyleAttributes, in BaseViewConfig.android.js, in BaseViewConfig.ios.js and in ViewStyle, 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 in BaseViewConfig.ios.js and nowhere else, so an emitted borderBlockWidth paints 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-wide borderBlockColor is 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.js entry is a top-level prop whitelist entry, while a style object is filtered by validAttributes.style — which is ReactNativeStyleAttributes, and that lacks the block widths. And Fabric's ViewProps has no borderBlock* at all. So a block width inside style is dead on iOS too, not merely on Android.

The mapping is exact rather than an approximation: direction never 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-1 compiled to { borderBlockWidth: 1, borderBlockStyle: "solid" } — two keys React Native ignores — so the utility drew nothing on Android. src/__tests__/vendor/tailwind/borders.test.tsx asserted both dead keys and passed while broken, exactly as #378 describes for the inline axis. It now asserts borderTopWidth / borderBottomWidth, mirroring the border-x-1 case two tests above it.

parseBorderInlineStyle becomes parseUnsupportedEdgeStyle and 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-color needs one key set, and the discriminator was a reference comparison

border-block-color is 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.

parseBorderColor chose between them with start === end. That is a reference comparison over two parsed values, not a comparison of what the CSS says: parseColor returns an interned string for a literal and builds a fresh var() array per call for anything unresolved. So red red collapsed onto borderBlockColor while currentcolor — which says exactly the same thing about both edges — split across borderTopColor / 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:

.a { border-block-color: var(--x) var(--y); }  /* red blue */
.b { border-block-color: var(--z); }           /* green    */
{ borderBlockColor: "#008000", borderBottomColor: "blue", borderTopColor: "red" }

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:

  • Android resolves the top edge BLOCK_START ?: TOP ?: BLOCK ?: VERTICAL ?: ALL (ReactAndroid/.../uimanager/style/BorderColors.kt, BorderColors.resolve), so borderTopColor outranks borderBlockColor and the rule paints red / blue.
  • iOS assigns borderTopColor = _borderBlockColor whenever 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, from parseBorderColor, from parseBorderBlock and from the runtime axisTargets, and parseUnparsedAxis'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 borderBlockColor is 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-color now behaves exactly like border-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 its isRTL swap, which only ever touches left and right. direction does not flip the block axis on either platform.

The per-edge block colours are untouched: borderBlockStartColor and borderBlockEndColor are 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 wrongly

light-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 whatever descriptorProperties names. parseWithParser seeded that field with the declaration's raw CSS name — before the propertyRename and 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:

missed in dark landed on
border-inline borderInline
border-inline-color borderInlineColor
border-inline-start borderInlineStart
border-inline-start-color borderInlineStartColor
border-inline-end borderInlineEnd
border-inline-end-color borderInlineEndColor
border-block borderBlock
border-block-start borderBlockStart
border-block-end borderBlockEnd

Every 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 — parseUnparsedAxis carried 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, through parseColorFor.

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 and borderBlockColor in 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 the descriptorProperties fix 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:

descriptorProperties seed border-block-color key set guard failures
unfixed axis property, by arity 9 — the nine dead keys above
unfixed the edge pair 10 — the nine, plus border-block-color
fixed the edge pair 0

The 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 / -end and their block twins pack width, style and colour into one list that a var() keeps opaque, so the split has to happen after resolution. That is not a new capability — it is exactly what border does. ShortHandSymbol marks 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 how border: var(--b) has always reached borderWidth, borderStyle and borderColor from a single opaque value.

So the six now sit in unparsedRuntimeParsing beside border, and src/native/styles/shorthands/border.ts gains 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.js and .ios.js list borderStyle: true and nothing per-edge, so on the old architecture a per-edge style is not a valid attribute and never crosses the bridge.
  • ViewStyle in Libraries/StyleSheet/StyleSheetTypes.d.ts declares only borderStyle — no borderStartStyle, borderEndStyle, borderInlineStyle or borderBlockStyle.
  • Android's BackgroundStyleApplicator.setBorderStyle(view, style) writes a single borderStyle field on BorderDrawable, applied to the whole border path.

(borderStartStyle / borderEndStyle do appear in Fabric's SET_CASCADED_RECTANGLE_EDGES raw-prop parser and in AnimatedPropSerializer, 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 borderStyle or to drop it. Widening loses: border-inline: 2px dashed red would dash the block edges the declaration never mentioned, and would clobber a border-style set 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-solid value. Dropping also keeps the shorthands consistent with the border-{inline,block}-*-style longhands, which have always dropped.

That same reasoning is why border-inline: 6px solid #26e no longer emits borderInlineStyle. 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 borderInlineStyle or borderBlockWidth entry in the emitted declarations looks exactly like a real one, and only the rendered component shows that React Native ignores it. So src/__tests__/native/logical-borders.test.tsx asserts on the props a View actually receives, alongside the IR assertions in src/__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 the var() 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 -color longhands, derived from the same census rather than listed — through a light-dark() written literally and again over a var(), 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 turns yarn typecheck red. 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 borderBlock runtime handler leaves the compiler suite at 67 / 67 green and turns exactly three native cases red. Each of the four sites that route border-block-color was then mutated in turn, against the 277 cases in the three border suites:

mutation red
the unparsed one-value arm emits the axis property again 6
parseBorderColor collapses on start === end again 8
parseBorderBlock emits border-block-color again 3
the runtime axisTargets.borderBlock.borderColor points at borderBlockColor again 2

Restored: 277 / 277.

Where a mutation reddened nothing on one plane, that plane got the test it was missing: removing the whitespace filter in unparsedComponentValues used to redden zero compiler tests, because var(--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; the light-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 a var(), @media, :hover, !important, unresolvable variables, the full cross product of the two arities in the border-block-color cascade matrix (only an off-diagonal cell can see two routes that agree at each arity separately), every spelling of border-block-color pinned 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:

.x { border-inline: var(--v); border-inline-end-color: black; }  /* end colour stays --v's */

border: var(--v); border-color: black behaves identically, and has since border joined the runtime-parsed set. The compile-time split does respect the longhand. Fixing it means changing shorthand ordering for border, boxShadow, textShadow, animation and transform together.

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: black used to emit borderBlockColor beside borderBottomColor, and Android ranks the bottom edge BLOCK_END ?: BOTTOM ?: BLOCK ?: …, so the later longhand won there while iOS's if (_borderBlockColor) { … borderBottomColor = _borderBlockColor; } gave the shorthand the edge. Both routes land on borderBottomColor now, so the two platforms agree — on the same answer border and 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: black renders { borderBlockEndColor: "#000", borderBottomColor: "red", … }, and borderBlockEndColor outranks borderBottomColor on Android and on iOS alike.

2. A var() is one component value however many values it holds, so --pair: 1px 2px with border-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, borderTopWidth and friends are declared true in ReactNativeStyleAttributes — no processor — so the list is handed to the shadow node as it stands, where borderColor and its siblings carry colorAttributes and processColor drops 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:

border-inline-start-width: var(--pair)  ->  {borderStartWidth: [1, 2]}
border-inline-end-width:   var(--pair)  ->  {borderEndWidth:   [1, 2]}
border-top-width:          var(--pair)  ->  {borderTopWidth:   [1, 2]}
border-bottom-width:       var(--pair)  ->  {borderBottomWidth:[1, 2]}
border-width:              var(--pair)  ->  {borderWidth:      [1, 2]}

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-width and border-width instead, so the whole class moves together when the unparsed path learns to split a resolved list.

3. On the unparsed path a per-edge -style drops 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 v4 border-{x,s,e}-* utility, which emits border-inline-*-style: var(--tw-border-style) defaulting to the solid the 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 literal dashed still 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 in parseUnparsedDeclaration and I will make it.

4. A light-dark() inside a three-part shorthand's var() still misses, and no choice of target fixes it. A var() 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 the var()-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}-radius are outside this PR's scope — they are parseSize2DDimensionPercentageDeclaration, not the border parsers — and React Native does declare all four (borderStartStartRadius and its siblings are in ReactNativeStyleAttributes and in ViewStyle). Measured on this branch, all four reach their real key on both the literal and the var() 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 here

The descriptorProperties seed 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-shadow and text-shadow have no propertyRename entry, so the new seed resolves to the same name the old one did, and none of them goes through parseColorFor. A logical-borders PR is also the wrong place to change box-shadow. Measured on this branch, off the props a rendered View received:

border: 1px solid light-dark(purple, orange)
  light  {borderWidth: 1, borderStyle: "solid", borderColor: "#800080"}
  dark   {borderWidth: 1, borderStyle: "solid", borderColor: "#800080", border: "#ffa500"}

border-top: 1px solid light-dark(purple, orange)          (and -bottom / -left / -right)
  light  {borderTopColor: "#800080", borderTopWidth: 1}
  dark   {borderTopColor: "#800080", borderTopWidth: 1, borderTop: "#ffa500"}

text-shadow: 1px 1px 1px light-dark(purple, orange)
  light  {textShadowColor: "#800080", textShadowRadius: 1, textShadowOffset: {width: 1, height: 1}}
  dark   {textShadowColor: "#800080", textShadowRadius: 1, textShadowOffset: {width: 1, height: 1},
          textShadow: "#ffa500"}

border, borderTop and textShadow are 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():

box-shadow:  1px 1px 1px light-dark(var(--l), var(--d))
  light  {boxShadow: [{offsetX: 1, offsetY: 1, blurRadius: 1, color: "purple"}]}
  dark   {boxShadow: "orange"}

text-shadow: 1px 1px 1px light-dark(var(--l), var(--d))
  light  {textShadowOffset: {width: 1, height: 1}, textShadowRadius: 1, textShadowColor: "purple"}
  dark   {textShadow: "orange"}

boxShadow is 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-shadow inside a border PR.

Checks

yarn typecheck and yarn lint exit 0.

Test Suites: 2 failed, 4 skipped, 55 passed, 57 of 61 total
Tests:       3 failed, 21 skipped, 1266 passed, 1290 total

The three failures are src/__tests__/babel/react-native.test.ts case 7 and src/__tests__/babel/react-native-web.test.ts cases 6 and 17 — the same babel-plugin-tester mismatches over an unrewritten relative require("../View") that the base has, unrelated to this diff (src/babel/* imports nothing from src/compiler/* or src/native/*); #390 fixes them.

Base of this PR (fix-logical-border-props at 12ebb97), measured in a clean checkout of the same tree:

Test Suites: 2 failed, 4 skipped, 54 passed, 56 of 60 total
Tests:       3 failed, 21 skipped, 1061 passed, 1085 total

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.

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

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — No border paints — the var()-valued shorthand expands to nothing, so neither edge is set.

FIXED — The box has a border on BOTH inline edges, matching the control beside it.

before — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), differing only in whether this PR is applied.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

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