From 5ff58bddb431b688fbe3f16ab86f0e6309e03eac Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 15:41:21 +0300 Subject: [PATCH 1/7] fix(compiler): expand var()-valued border-inline shorthands to start/end --- .../compiler/logical-borders.test.ts | 33 +++++++++++++++++++ src/compiler/declarations.ts | 24 ++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 87db4f74..6b20c66b 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -116,3 +116,36 @@ describe("logical border styles", () => { }); }); }); + +describe("logical border shorthands via var() (unparsed path)", () => { + // A var() forces a shorthand onto the unparsed path, where propertyRename + // (longhands only) and the parseBorderInline* parsers (parsed path only) do + // not reach. These must still expand to the RTL-aware start/end props. + test("border-inline-color with var()", () => { + expect( + getRule("border-inline-color: hsl(var(--primary));").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "hsl", [{}, "var", "primary", 1]], "borderStartColor", 1], + [[{}, "hsl", [{}, "var", "primary", 1]], "borderEndColor", 1], + ], + dv: 1, + }, + ]); + }); + + test("border-inline-width with var()", () => { + expect(getRule("border-inline-width: var(--w);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "w", 1], "borderStartWidth", 1], + [[{}, "var", "w", 1], "borderEndWidth", 1], + ], + dv: 1, + }, + ]); + }); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 17beba0a..2ea2ad92 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -80,6 +80,15 @@ const unsupportedInlineStyles = new Set([ "border-inline-end-style", ]); +// Logical-border SHORTHANDS have no RN equivalent either; when var()-valued +// they reach the unparsed path (the parsed parseBorderInline* never run) and +// propertyRename only maps the longhands. Expand each to its RTL-aware +// start/end props, sharing the runtime value. +const inlineShorthandExpansion: Record = { + "border-inline-color": ["border-start-color", "border-end-color"], + "border-inline-width": ["border-start-width", "border-end-width"], +}; + const unparsedRuntimeParsing = new Set([ "animation", "border", @@ -955,6 +964,21 @@ export function parseUnparsedDeclaration( property = rename; } + /** + * Logical-border shorthands (border-inline-color / -width) reach here when + * var()-valued. RN has no border-inline-*; expand to start/end sharing the + * value, mirroring parseBorderInline* on the parsed path. + */ + const shorthandExpansion = inlineShorthandExpansion[property]; + if (shorthandExpansion) { + const value = parseUnparsed(declaration.value.value, builder, property); + for (const target of shorthandExpansion) { + builder.descriptorProperty = target; + builder.addDescriptor(target, value); + } + return; + } + /** * Unparsed shorthand properties need to be parsed at runtime */ From a52daa044007f58aee690cab5aedbeda44c2b501 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 01:14:34 +0300 Subject: [PATCH 2/7] fix(compiler): correct the unparsed border-inline-color/-width expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../compiler/logical-borders.test.ts | 122 +++++++ src/__tests__/native/logical-borders.test.tsx | 329 ++++++++++++++++++ .../vendor/tailwind/borders.test.tsx | 19 + src/compiler/declarations.ts | 117 +++++-- src/compiler/stylesheet.ts | 15 +- 5 files changed, 578 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/native/logical-borders.test.tsx diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 6b20c66b..6a386883 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -148,4 +148,126 @@ describe("logical border shorthands via var() (unparsed path)", () => { }, ]); }); + + test("border-inline-color with a bare var()", () => { + expect(getRule("border-inline-color: var(--c);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "c", 1], "borderStartColor", 1], + [[{}, "var", "c", 1], "borderEndColor", 1], + ], + dv: 1, + }, + ]); + }); + + test("border-inline-color with a var() fallback", () => { + expect(getRule("border-inline-color: var(--c, red);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", ["c", "red"], 1], "borderStartColor", 1], + [[{}, "var", ["c", "red"], 1], "borderEndColor", 1], + ], + dv: 1, + }, + ]); + }); + + test("border-inline-width with a var() fallback", () => { + expect(getRule("border-inline-width: var(--w, 3px);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", ["w", 3], 1], "borderStartWidth", 1], + [[{}, "var", ["w", 3], 1], "borderEndWidth", 1], + ], + dv: 1, + }, + ]); + }); + + test("border-inline-width with calc() over a var()", () => { + const calc = [{}, "calc", [[{}, "var", "w", 1], "*", 2]]; + + expect( + getRule("border-inline-width: calc(var(--w) * 2);").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [ + [calc, "borderStartWidth", 1], + [calc, "borderEndWidth", 1], + ], + dv: 1, + }, + ]); + }); +}); + +describe("logical border shorthands with two values (unparsed path)", () => { + // The grammar is `{1,2}` — the second component is the END edge. The + // parsed path splits it that way, so the unparsed path must too. + test("border-inline-width: var() var()", () => { + expect( + getRule("border-inline-width: var(--a) var(--b);").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "a", 1], "borderStartWidth", 1], + [[{}, "var", "b", 1], "borderEndWidth", 1], + ], + dv: 1, + }, + ]); + }); + + test("border-inline-color: var() var()", () => { + expect( + getRule("border-inline-color: var(--a) var(--b);").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "a", 1], "borderStartColor", 1], + [[{}, "var", "b", 1], "borderEndColor", 1], + ], + dv: 1, + }, + ]); + }); + + test("more than two values is not the grammar, so the declaration drops", () => { + const { rule, warnings } = getRule( + "border-inline-width: var(--a) var(--b) var(--c);", + ); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ + values: { "border-inline-width": ["3 values (expected 1 or 2)"] }, + }); + }); +}); + +describe("logical border shorthands React Native cannot express", () => { + // border-inline / -start / -end pack width, style and colour into one + // runtime value, and no style resolver fans one slot out to a per-edge pair. + test.each(["border-inline", "border-inline-start", "border-inline-end"])( + "%s with a var() warns and drops", + (property) => { + const { rule, warnings } = getRule(`${property}: var(--b);`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ properties: [property] }); + }, + ); + + test.each(["border-inline", "border-inline-start", "border-inline-end"])( + "%s without a var() still expands", + (property) => { + expect(getRule(`${property}: 2px solid red;`).warnings).toStrictEqual({}); + }, + ); }); diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx new file mode 100644 index 00000000..36789325 --- /dev/null +++ b/src/__tests__/native/logical-borders.test.tsx @@ -0,0 +1,329 @@ +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +import { dimensions } from "../../native/reactivity"; + +const children = undefined; + +/** + * A variable with a single definition is inlined by the compiler, so the + * declaration never reaches the unparsed path these tests exercise. Every + * variable below is defined twice to keep it unresolved at compile time. + */ +const twiceDefined = ` + :root { --width: 1px; --other-width: 2px; --color: red; --other-color: blue; } + .redefine { --width: 9px; --other-width: 9px; --color: black; --other-color: black; } +`; + +afterEach(() => { + act(() => { + colorScheme.set("light"); + }); +}); + +describe("border-inline-color / -width via var()", () => { + test("a single var() reaches both edges of a rendered View", () => { + registerCSS( + `.my-class { border-inline-color: var(--color); } ${twiceDefined}`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props).toStrictEqual({ + children, + testID, + style: { borderStartColor: "red", borderEndColor: "red" }, + }); + }); + + test("var() with a fallback", () => { + registerCSS(` + .colour { border-inline-color: var(--missing, red); } + .width { border-inline-width: var(--missing, 3px); } + .redefine-a { --missing: 1px; } + .redefine-b { --missing: 2px; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("colour").props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "red", + }); + expect(screen.getByTestId("width").props.style).toStrictEqual({ + borderStartWidth: 3, + borderEndWidth: 3, + }); + }); + + test("calc() over a var()", () => { + registerCSS( + `.my-class { border-inline-width: calc(var(--width) * 2); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 2, + borderEndWidth: 2, + }); + }); + + test("a var() that resolves to nothing paints nothing", () => { + registerCSS(` + .my-class { border-inline-color: var(--undefined-everywhere); } + .redefine-a { --undefined-everywhere: red; } + .redefine-b { --undefined-everywhere: blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); +}); + +describe("border-inline-color / -width two-value expansion", () => { + /** + * The grammar is `{1,2}`: the second component is the END edge, not a + * second value for both edges. The parsed path already does this, so the + * unparsed path is asserted against it as well as against the literal props. + */ + test("width: the unparsed pair matches the parsed pair", () => { + registerCSS(` + .unparsed { border-inline-width: var(--width) var(--other-width); } + .parsed { border-inline-width: 1px 2px; } + ${twiceDefined} + `); + + render( + + + , + ); + + const unparsed = screen.getByTestId("unparsed").props.style; + const parsed = screen.getByTestId("parsed").props.style; + + expect(unparsed).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 2, + }); + expect(unparsed).toStrictEqual(parsed); + }); + + test("colour: the unparsed pair matches the parsed pair", () => { + registerCSS(` + .unparsed { border-inline-color: var(--color) var(--other-color); } + .parsed { border-inline-color: red blue; } + ${twiceDefined} + `); + + render( + + + , + ); + + expect(screen.getByTestId("unparsed").props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "blue", + }); + expect(screen.getByTestId("parsed").props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#00f", + }); + }); + + test("a literal start and a var() end", () => { + registerCSS( + `.my-class { border-inline-width: 1px var(--other-width); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 2, + }); + }); + + test("a literal start colour and a var() end colour", () => { + registerCSS( + `.my-class { border-inline-color: red var(--other-color); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "blue", + }); + }); +}); + +describe("border-inline-color via light-dark()", () => { + test("the dark value reaches both edges and no other property", () => { + registerCSS(` + .my-class { + width: var(--width); + border-inline-color: light-dark(var(--color), var(--other-color)); + } + ${twiceDefined} + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + width: 1, + borderStartColor: "red", + borderEndColor: "red", + }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props.style).toStrictEqual({ + width: 1, + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); + + test("a preceding colour declaration is left alone", () => { + registerCSS(` + .my-class { + color: black; + border-inline-color: light-dark(var(--color), var(--other-color)); + } + ${twiceDefined} + `); + + render(); + const component = screen.getByTestId(testID); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props.style).toStrictEqual({ + color: "#000", + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); +}); + +describe("border-inline-color via var() under a condition", () => { + test("inside @media", () => { + registerCSS(` + .my-class { border-inline-color: red; } + @media (min-width: 500px) { + .my-class { border-inline-color: var(--other-color); } + } + ${twiceDefined} + `); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 100 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#f00", + }); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); + + test("on :hover", () => { + registerCSS(` + .my-class { border-inline-color: red; } + .my-class:hover { border-inline-color: var(--other-color); } + ${twiceDefined} + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#f00", + }); + + act(() => { + fireEvent(component, "hoverIn"); + }); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); + + test("!important beats a later longhand", () => { + registerCSS(` + .my-class { border-inline-color: var(--color) !important; } + .my-class { border-inline-end-color: black; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "red", + }); + }); + + test("a later longhand beats the shorthand", () => { + registerCSS(` + .my-class { border-inline-color: var(--color); border-inline-end-color: black; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "#000", + }); + }); +}); + +describe("border-inline / -start / -end shorthands via var()", () => { + /** + * Each packs width, style and colour into one runtime value, and no style + * resolver can fan one slot out to a per-edge pair. They are dropped rather + * than emitted as a `borderInline*` prop React Native has no attribute for. + */ + test.each(["border-inline", "border-inline-start", "border-inline-end"])( + "%s paints nothing", + (property) => { + registerCSS(` + .my-class { ${property}: var(--shorthand); } + :root { --shorthand: 1px solid red; } + .redefine { --shorthand: 2px solid blue; } + `); + + render(); + expect(screen.getByTestId(testID).props).toStrictEqual({ + children, + testID, + }); + }, + ); +}); diff --git a/src/__tests__/vendor/tailwind/borders.test.tsx b/src/__tests__/vendor/tailwind/borders.test.tsx index 03a37a08..f52b1abe 100644 --- a/src/__tests__/vendor/tailwind/borders.test.tsx +++ b/src/__tests__/vendor/tailwind/borders.test.tsx @@ -238,6 +238,25 @@ describe("Border - Border Color", () => { }, }); }); + + // An arbitrary var() Tailwind cannot fold at build time (unlike --spacing) + // keeps `border-inline-color` on the compiler's unparsed path. Two + // definitions keep it off the single-definition inliner as well. + test("border-x-[color:var(--c)]", async () => { + expect( + await renderSimple({ + className: "border-x-[color:var(--c)]", + extraCss: `:root { --c: red; } .redefine { --c: blue; }`, + }), + ).toStrictEqual({ + props: { + style: { + borderStartColor: "red", + borderEndColor: "red", + }, + }, + }); + }); test("border-y-current", async () => { expect( await renderSimple({ className: "border-y-current text-red-500" }), diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 2ea2ad92..59e327f4 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -80,15 +80,27 @@ const unsupportedInlineStyles = new Set([ "border-inline-end-style", ]); -// Logical-border SHORTHANDS have no RN equivalent either; when var()-valued -// they reach the unparsed path (the parsed parseBorderInline* never run) and -// propertyRename only maps the longhands. Expand each to its RTL-aware -// start/end props, sharing the runtime value. -const inlineShorthandExpansion: Record = { +// A var() keeps a logical-border shorthand on the unparsed path, where the +// parsed parseBorderInline* never run and propertyRename only maps longhands. +// These are the inline-axis shorthands React Native can express, as the +// [start, end] pair each expands to. The grammar is `{1,2}`: one +// component feeds both edges, two feed one edge each. +const inlineAxisExpansion: Record = { "border-inline-color": ["border-start-color", "border-end-color"], "border-inline-width": ["border-start-width", "border-end-width"], }; +// The inline-axis shorthands React Native cannot express from one runtime +// value: each packs width, style and colour into a single list, and no style +// resolver fans one slot out to a per-edge pair. Warn rather than emit a +// borderInline* prop React Native has no style attribute for. The parsed path +// still expands these — lightningcss has already split the value there. +const unsupportedInlineShorthands = new Set([ + "border-inline", + "border-inline-start", + "border-inline-end", +]); + const unparsedRuntimeParsing = new Set([ "animation", "border", @@ -314,7 +326,7 @@ function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; - builder.descriptorProperty = declaration.property; + builder.descriptorProperties = [declaration.property]; builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); @@ -949,7 +961,10 @@ export function parseUnparsedDeclaration( return; } - if (unsupportedInlineStyles.has(property)) { + if ( + unsupportedInlineStyles.has(property) || + unsupportedInlineShorthands.has(property) + ) { builder.addWarning("property", property); return; } @@ -964,25 +979,21 @@ export function parseUnparsedDeclaration( property = rename; } - /** - * Logical-border shorthands (border-inline-color / -width) reach here when - * var()-valued. RN has no border-inline-*; expand to start/end sharing the - * value, mirroring parseBorderInline* on the parsed path. - */ - const shorthandExpansion = inlineShorthandExpansion[property]; - if (shorthandExpansion) { - const value = parseUnparsed(declaration.value.value, builder, property); - for (const target of shorthandExpansion) { - builder.descriptorProperty = target; - builder.addDescriptor(target, value); - } + const inlineAxis = inlineAxisExpansion[property]; + if (inlineAxis) { + parseUnparsedInlineAxis( + declaration.value.value, + inlineAxis, + builder, + property, + ); return; } /** * Unparsed shorthand properties need to be parsed at runtime */ - builder.descriptorProperty = property; + builder.descriptorProperties = [property]; if (unparsedRuntimeParsing.has(property)) { const args = parseUnparsed(declaration.value.value, builder, property); @@ -1013,6 +1024,72 @@ export function parseUnparsedDeclaration( } } +/** + * The top-level component values of an unparsed value. A component value is a + * preserved token, a function, or a block, so every entry here is already one + * — a var(), a calc(), a length, a colour. Whitespace is the only entry that + * is not, and lightningcss keeps it only sometimes: `var(--a) var(--b)` and + * `var(--a)var(--b)` both arrive as two bare var tokens, while `red var(--b)` + * keeps its separator. Dropping whitespace is what makes the two agree. + */ +function unparsedComponentValues( + tokenOrValues: TokenOrValue[], +): TokenOrValue[] { + return tokenOrValues.filter( + (tokenOrValue) => + !( + tokenOrValue.type === "token" && + tokenOrValue.value.type === "white-space" + ), + ); +} + +/** + * Expand an inline-axis shorthand that a var() kept unparsed, the way + * parseBorderInline* expands the parsed form. + */ +function parseUnparsedInlineAxis( + tokenOrValues: TokenOrValue[], + [startProperty, endProperty]: readonly [string, string], + builder: StylesheetBuilder, + property: string, +) { + const components = unparsedComponentValues(tokenOrValues); + + if (components.length === 1) { + /** + * One component feeds both edges. descriptorProperties carries the pair so + * that light-dark(), which writes to the builder from inside parseUnparsed + * rather than through the returned value, reaches both edges of the single + * extra rule it opens. + */ + builder.descriptorProperties = [startProperty, endProperty]; + + const value = parseUnparsed(components[0], builder, property); + + builder.addDescriptor(startProperty, value); + builder.addDescriptor(endProperty, value); + return; + } + + if (components.length === 2) { + builder.descriptorProperties = [startProperty]; + builder.addDescriptor( + startProperty, + parseUnparsed(components[0], builder, property), + ); + + builder.descriptorProperties = [endProperty]; + builder.addDescriptor( + endProperty, + parseUnparsed(components[1], builder, property), + ); + return; + } + + builder.addWarning("value", `${components.length} values (expected 1 or 2)`); +} + export function parseCustomDeclaration( declaration: Extract, builder: StylesheetBuilder, diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..dfd1d28f 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -58,7 +58,12 @@ export class StylesheetBuilder { }, // Any default mapping should be included in the @nativeMapping parsing private mapping: StyleRuleMapping = {}, - public descriptorProperty?: string, + /** + * The properties the declaration being parsed writes to. Usually one, but + * a shorthand that expands across an axis writes to every property in the + * expansion, and an unnamed descriptor has to reach all of them. + */ + public descriptorProperties?: readonly string[], private shared: { ruleSets: Record; rootVariables?: VariableRecord; @@ -102,7 +107,7 @@ export class StylesheetBuilder { mode, this.cloneRule(), { ...this.mapping }, - this.descriptorProperty, + this.descriptorProperties, this.shared, selectors, ); @@ -305,11 +310,13 @@ export class StylesheetBuilder { forceTuple?: boolean, rule = this.rule, ) { - if (this.descriptorProperty === undefined) { + if (this.descriptorProperties === undefined) { return; } - this.addDescriptor(this.descriptorProperty, value, forceTuple, rule); + for (const property of this.descriptorProperties) { + this.addDescriptor(property, value, forceTuple, rule); + } } addDescriptor( From 0b527e9d927df3d1dc1a91ab720778bfcdd2d440 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 10:26:44 +0300 Subject: [PATCH 3/7] fix(compiler): drop a var()-valued border-inline style silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../compiler/logical-borders.test.ts | 40 +++++++++++++++++ src/__tests__/native/logical-borders.test.tsx | 43 +++++++++++++++++++ src/compiler/declarations.ts | 17 +++++--- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 6a386883..73694226 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -117,6 +117,46 @@ describe("logical border styles", () => { }); }); +describe("logical border styles via var() (unparsed path)", () => { + /** + * A var() keeps the declaration unparsed, so its value is unknown at compile + * time. React Native has no per-side border style either way, so the + * declaration drops — and an unknown value is not a known non-solid one, so + * it drops as quietly as `solid` does. Tailwind v4 puts every + * `border-{x,s,e}-*` utility through here via `var(--tw-border-style)`. + */ + test.each([ + "border-inline-style", + "border-inline-start-style", + "border-inline-end-style", + ])("%s with a var() drops without warning", (property) => { + const { rule, warnings } = getRule(`${property}: var(--tw-border-style);`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + test("a var() style leaves the width beside it alone", () => { + const { rule, warnings } = getRule( + "border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px;", + ); + + expect(rule).toStrictEqual([{ s: [1, 1], d: [{ borderStartWidth: 1 }] }]); + expect(warnings).toStrictEqual({}); + }); + + test("a var() style over the whole inline axis leaves both widths alone", () => { + const { rule, warnings } = getRule( + "border-inline-style: var(--tw-border-style); border-inline-width: 1px;", + ); + + expect(rule).toStrictEqual([ + { s: [1, 1], d: [{ borderStartWidth: 1, borderEndWidth: 1 }] }, + ]); + expect(warnings).toStrictEqual({}); + }); +}); + describe("logical border shorthands via var() (unparsed path)", () => { // A var() forces a shorthand onto the unparsed path, where propertyRename // (longhands only) and the parseBorderInline* parsers (parsed path only) do diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx index 36789325..26c4e603 100644 --- a/src/__tests__/native/logical-borders.test.tsx +++ b/src/__tests__/native/logical-borders.test.tsx @@ -304,6 +304,49 @@ describe("border-inline-color via var() under a condition", () => { }); }); +describe("border-inline style longhands via var()", () => { + /** + * React Native has no per-side border style attribute, so an inline border + * style paints nothing whatever its value resolves to. The width beside it + * still reaches its edge — that pair is what every Tailwind v4 + * `border-{x,s,e}-*` utility emits. + */ + test.each([ + "border-inline-style", + "border-inline-start-style", + "border-inline-end-style", + ])("%s paints nothing", (property) => { + registerCSS(` + .my-class { ${property}: var(--style); } + :root { --style: solid; } + .redefine { --style: dashed; } + `); + + render(); + expect(screen.getByTestId(testID).props).toStrictEqual({ + children, + testID, + }); + }); + + test("the width beside a var() style still reaches its edge", () => { + registerCSS(` + .my-class { + border-inline-start-style: var(--style); + border-inline-start-width: var(--width); + } + :root { --style: solid; } + .redefine { --style: dashed; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 1, + }); + }); +}); + describe("border-inline / -start / -end shorthands via var()", () => { /** * Each packs width, style and colour into one runtime value, and no style diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 59e327f4..a88a0c2a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -73,7 +73,9 @@ const propertyRename: Record = { // React Native only supports a uniform borderStyle, so per-side border // styles have no native equivalent and are dropped. "solid" is dropped -// silently as it matches React Native's default rendering. +// silently as it matches React Native's default rendering. A var() keeps the +// value unknown at compile time, and an unknown value is not a known +// non-solid one, so the unparsed path drops these as quietly. const unsupportedInlineStyles = new Set([ "border-inline-style", "border-inline-start-style", @@ -961,14 +963,19 @@ export function parseUnparsedDeclaration( return; } - if ( - unsupportedInlineStyles.has(property) || - unsupportedInlineShorthands.has(property) - ) { + if (unsupportedInlineShorthands.has(property)) { builder.addWarning("property", property); return; } + // Nothing is lost that React Native could have rendered: the whole property + // has no native attribute, at any value. Warning here would fire on every + // Tailwind v4 border-{x,s,e}-* utility, which emits `var(--tw-border-style)` + // defaulting to the `solid` the parsed path drops without a word. + if (unsupportedInlineStyles.has(property)) { + return; + } + builder.setWarningProperty(property); /** From 1189fae015205b8b1230e77663931101a4154839 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 20:46:50 +0300 Subject: [PATCH 4/7] fix: expand var()-valued border-inline shorthands at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../compiler/logical-borders.test.ts | 55 ++++- src/__tests__/native/logical-borders.test.tsx | 215 +++++++++++++++++- src/compiler/declarations.ts | 24 +- src/native/styles/shorthands/border.ts | 91 +++++++- 4 files changed, 342 insertions(+), 43 deletions(-) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 73694226..af5b891e 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -291,21 +291,52 @@ describe("logical border shorthands with two values (unparsed path)", () => { }); }); -describe("logical border shorthands React Native cannot express", () => { - // border-inline / -start / -end pack width, style and colour into one - // runtime value, and no style resolver fans one slot out to a per-edge pair. - test.each(["border-inline", "border-inline-start", "border-inline-end"])( - "%s with a var() warns and drops", - (property) => { - const { rule, warnings } = getRule(`${property}: var(--b);`); +describe("logical border three-part shorthands via var() (unparsed path)", () => { + // border-inline / -start / -end each pack width, style and colour into one + // value that stays opaque until the variable resolves, exactly as `border` + // does. They compile to the same runtime-call shape, and the native resolver + // fans the resolved list out across the inline edges. + test.each([ + ["border-inline", "borderInline"], + ["border-inline-start", "borderInlineStart"], + ["border-inline-end", "borderInlineEnd"], + ])("%s with a var() compiles to a runtime call", (property, resolver) => { + const { rule, warnings } = getRule(`${property}: var(--b);`); - expect(rule).toBeUndefined(); - expect(warnings).toStrictEqual({ properties: [property] }); - }, - ); + expect(rule).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, resolver, [{}, "var", "b", 1], 1], resolver, 1]], + dv: 1, + }, + ]); + expect(warnings).toStrictEqual({}); + }); + + // The same shape `border` compiles to, which is what makes one runtime + // handler serve both. + test("the runtime-call shape matches the one `border` compiles to", () => { + const inline = getRule("border-inline: var(--b);").rule; + const uniform = getRule("border: var(--b);").rule; + + expect(inline).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "borderInline", [{}, "var", "b", 1], 1], "borderInline", 1]], + dv: 1, + }, + ]); + expect(uniform).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "border", [{}, "var", "b", 1], 1], "border", 1]], + dv: 1, + }, + ]); + }); test.each(["border-inline", "border-inline-start", "border-inline-end"])( - "%s without a var() still expands", + "%s without a var() still expands at compile time", (property) => { expect(getRule(`${property}: 2px solid red;`).warnings).toStrictEqual({}); }, diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx index 26c4e603..9b10c5cc 100644 --- a/src/__tests__/native/logical-borders.test.tsx +++ b/src/__tests__/native/logical-borders.test.tsx @@ -7,6 +7,23 @@ import { dimensions } from "../../native/reactivity"; const children = undefined; +/** + * The style keys a rendered component actually received, sorted. + * + * `props` is untyped, and every assertion below that counts keys rather than + * comparing whole objects needs a real `string[]` to work from. + */ +const styleKeys = (id: string): string[] => { + const { style } = screen.getByTestId(id).props as { style?: object }; + return style === undefined ? [] : Object.keys(style).sort(); +}; + +/** + * React Native has no `borderInline*` style attribute of any kind, so a key + * shaped like one is inert whatever value it carries. + */ +const isInlineKey = (key: string): boolean => key.startsWith("borderInline"); + /** * A variable with a single definition is inlined by the compiler, so the * declaration never reaches the unparsed path these tests exercise. Every @@ -347,26 +364,200 @@ describe("border-inline style longhands via var()", () => { }); }); +/** + * React Native has no per-edge border STYLE attribute, at any layer: + * `BaseViewConfig.{android,ios}.js` lists `borderStyle` and nothing per-edge, + * `ViewStyle` declares only `borderStyle`, and Android's `BorderDrawable` + * holds one `borderStyle` for the whole path. So the style component of an + * inline-axis shorthand is dropped rather than widened to `borderStyle`, which + * would paint the block edges the declaration never mentioned. + * + * The width and colour components have RTL-aware per-edge props + * (`borderStartWidth` / `borderEndWidth` / `borderStartColor` / + * `borderEndColor`) and do reach the component. + */ +const shorthandDefinitions = ` + :root { --shorthand: 1px solid red; } + .redefine { --shorthand: 2px dashed blue; } +`; + describe("border-inline / -start / -end shorthands via var()", () => { - /** - * Each packs width, style and colour into one runtime value, and no style - * resolver can fan one slot out to a per-edge pair. They are dropped rather - * than emitted as a `borderInline*` prop React Native has no attribute for. - */ + test.each([ + [ + "border-inline", + { + borderStartWidth: 1, + borderEndWidth: 1, + borderStartColor: "red", + borderEndColor: "red", + }, + ], + ["border-inline-start", { borderStartWidth: 1, borderStartColor: "red" }], + ["border-inline-end", { borderEndWidth: 1, borderEndColor: "red" }], + ])("%s expands onto its edges", (property, expected) => { + registerCSS( + `.my-class { ${property}: var(--shorthand); } ${shorthandDefinitions}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("the unparsed expansion matches the parsed expansion", () => { + registerCSS(` + .unparsed { border-inline: var(--shorthand); } + .parsed { border-inline: 1px solid red; } + ${shorthandDefinitions} + `); + + render( + + + , + ); + + expect(screen.getByTestId("unparsed").props.style).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 1, + borderStartColor: "red", + borderEndColor: "red", + }); + // The parsed path resolves `red` to #f00 at compile time; every other key + // must agree, which is what makes the two paths one behaviour. + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }); + test.each(["border-inline", "border-inline-start", "border-inline-end"])( - "%s paints nothing", + "%s never widens its style component to borderStyle", (property) => { registerCSS(` - .my-class { ${property}: var(--shorthand); } - :root { --shorthand: 1px solid red; } - .redefine { --shorthand: 2px solid blue; } + .my-class { ${property}: var(--dashed); } + :root { --dashed: 1px dashed red; } + .redefine { --dashed: 2px dotted blue; } `); render(); - expect(screen.getByTestId(testID).props).toStrictEqual({ - children, - testID, + expect(screen.getByTestId(testID).props.style).not.toHaveProperty( + "borderStyle", + ); + }, + ); + + /** + * An empty style object, not an absent one: the declaration compiled to a + * runtime call, so the descriptor exists and resolves to nothing. The style + * LONGHANDS above have no descriptor at all, which is why they assert the + * stricter shape. What both share is that no `borderInline*` key survives. + */ + test("a var() that resolves to nothing paints nothing", () => { + registerCSS(` + .my-class { border-inline: var(--undefined-everywhere); } + .redefine-a { --undefined-everywhere: 1px solid red; } + .redefine-b { --undefined-everywhere: 2px solid blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); + + /** + * The runtime route has its own way to leak the dead key the parsed path was + * fixed for: an unmatched or partly-matched value could fall back to writing + * the descriptor under its own `borderInline*` name. Assert against the + * component for every arity the grammar accepts, plus one it does not. + */ + test.each([ + "1px solid red", + "solid red", + "1px solid", + "solid", + "1px solid red blue extra", + ])("no borderInline* key reaches the component for `%s`", (value) => { + registerCSS(` + .my-class { border-inline: var(--shorthand); } + :root { --shorthand: ${value}; } + .redefine { --shorthand: 2px dotted blue; } + `); + + render(); + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); + }); + + /** + * A var()-valued shorthand resolves after the cascade has already been + * flattened, so it overwrites a longhand written after it. That is how EVERY + * runtime shorthand in the library behaves — `border` has done this since it + * joined the runtime-parsed set — and it is not specific to the inline axis: + * the literal `border-inline` and the parsed `border-inline-color` pair both + * let the later longhand win, because those are split at compile time. + * + * Asserted as PARITY WITH `border` rather than as a pinned value, so that + * whoever fixes the shared ordering sees both move together instead of + * finding a test that hard-codes the wrong answer. + */ + test("a var() shorthand overrides a later longhand, exactly as `border` does", () => { + registerCSS(` + .inline { border-inline: var(--shorthand); border-inline-end-color: black; } + .uniform { border: var(--shorthand); border-color: black; } + .literal { border-inline: 1px solid red; border-inline-end-color: black; } + ${shorthandDefinitions} + `); + + render( + + + + , + ); + + const inline = screen.getByTestId("inline").props.style; + const uniform = screen.getByTestId("uniform").props.style; + + // Both runtime shorthands lose the later longhand, in the same direction. + expect(inline.borderEndColor).toBe("red"); + expect(uniform.borderColor).toBe("red"); + + // The compile-time split does respect it, which is what makes the above a + // property of the runtime route rather than of `border-inline` itself. + expect(screen.getByTestId("literal").props.style.borderEndColor).toBe( + "#000", + ); + }); +}); + +describe("the literal border-inline shorthand reaching the component", () => { + /** + * The compiler IR cannot see this defect: a `borderInlineStyle` entry in the + * emitted declarations looks exactly like a real one. Only the rendered + * component shows that React Native has no such style attribute, so the key + * is inert. Assert on the props React Native actually receives. + */ + test.each(["solid", "dashed", "dotted"])( + "border-inline: 6px %s #2266ee reaches the edges and carries no dead key", + (style) => { + registerCSS(`.my-class { border-inline: 6px ${style} #2266ee; }`); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "#26e", + borderEndColor: "#26e", + borderStartWidth: 6, + borderEndWidth: 6, }); + // Named separately from the exact match above, so a regression reports + // the dead key rather than a whole-object diff. + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); + }, + ); + + test.each(["border-inline-start", "border-inline-end"])( + "%s carries no dead key either", + (property) => { + registerCSS(`.my-class { ${property}: 6px dashed #2266ee; }`); + + render(); + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); }, ); }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index a88a0c2a..4f31d77f 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -92,20 +92,17 @@ const inlineAxisExpansion: Record = { "border-inline-width": ["border-start-width", "border-end-width"], }; -// The inline-axis shorthands React Native cannot express from one runtime -// value: each packs width, style and colour into a single list, and no style -// resolver fans one slot out to a per-edge pair. Warn rather than emit a -// borderInline* prop React Native has no style attribute for. The parsed path -// still expands these — lightningcss has already split the value there. -const unsupportedInlineShorthands = new Set([ - "border-inline", - "border-inline-start", - "border-inline-end", -]); - +// Shorthands whose value has to be split after the variable resolves, so the +// compiler emits a runtime call instead of descriptors. The inline-axis three +// are here for the same reason `border` is — each packs width, style and +// colour into one list that a var() keeps opaque — and their runtime handlers +// fan the resolved list onto the RTL-aware per-edge props. const unparsedRuntimeParsing = new Set([ "animation", "border", + "border-inline", + "border-inline-end", + "border-inline-start", "box-shadow", "line-height", "rotate", @@ -963,11 +960,6 @@ export function parseUnparsedDeclaration( return; } - if (unsupportedInlineShorthands.has(property)) { - builder.addWarning("property", property); - return; - } - // Nothing is lost that React Native could have rendered: the whole property // has no native attribute, at any value. Warning here would fire on every // Tailwind v4 border-{x,s,e}-* utility, which emits `var(--tw-border-style)` diff --git a/src/native/styles/shorthands/border.ts b/src/native/styles/shorthands/border.ts index cefdb005..25a8ff39 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -1,10 +1,95 @@ +import { ShortHandSymbol } from "../constants"; +import type { StyleResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; const width = ["borderWidth", "number"] as const; const style = ["borderStyle", "string"] as const; const color = ["borderColor", "color", "color"] as const; -export const border = shorthandHandler( - [[width, style, color], [style, color], [width, style], [style]], - [], +/** + * ` || || `, in the component orders a + * resolved runtime value can arrive in. `border` and the three inline-axis + * shorthands share the same grammar, so it is stated once here rather than + * copied per handler. + */ +const mappings = [ + [width, style, color], + [style, color], + [width, style], + [style], +]; + +export const border = shorthandHandler(mappings, []); + +const matchBorder = shorthandHandler(mappings, [], "object"); + +/** + * Which React Native props each matched slot feeds, per inline-axis shorthand. + * + * `borderStyle` is absent from every entry deliberately. React Native has no + * per-edge border style at any layer: `BaseViewConfig.{android,ios}.js` lists + * `borderStyle` and nothing per-edge, `ViewStyle` declares only `borderStyle`, + * and Android's `BorderDrawable` holds a single style for the whole border + * path. Widening it to `borderStyle` would paint the block edges the + * declaration never mentioned and clobber a `border-style` set elsewhere in + * the cascade, so the component is dropped — exactly as the parsed path drops + * it for `border-inline` and for the `border-inline-*-style` longhands. + */ +const inlineTargets = { + borderInline: { + borderWidth: ["borderStartWidth", "borderEndWidth"], + borderColor: ["borderStartColor", "borderEndColor"], + }, + borderInlineStart: { + borderWidth: ["borderStartWidth"], + borderColor: ["borderStartColor"], + }, + borderInlineEnd: { + borderWidth: ["borderEndWidth"], + borderColor: ["borderEndColor"], + }, +} as const; + +type InlineTargets = (typeof inlineTargets)[keyof typeof inlineTargets]; + +/** + * An inline-axis border shorthand whose value stayed opaque until runtime. + * + * The resolved components are matched against the same grammar `border` uses, + * then fanned onto the RTL-aware per-edge props. `ShortHandSymbol` is what + * lets one descriptor write several props: the style object it marks is spread + * onto the target rather than assigned under the shorthand's own name. + */ +function inlineBorderHandler(targets: InlineTargets): StyleResolver { + return (resolveValue, value, get, options) => { + const parsed = matchBorder(resolveValue, value, get, options); + + if (typeof parsed !== "object" || !parsed) { + return; + } + + const target: Record = { [ShortHandSymbol]: true }; + + if ("borderWidth" in parsed) { + for (const property of targets.borderWidth) { + target[property] = parsed.borderWidth; + } + } + + if ("borderColor" in parsed) { + for (const property of targets.borderColor) { + target[property] = parsed.borderColor; + } + } + + return target; + }; +} + +export const borderInline = inlineBorderHandler(inlineTargets.borderInline); +export const borderInlineStart = inlineBorderHandler( + inlineTargets.borderInlineStart, +); +export const borderInlineEnd = inlineBorderHandler( + inlineTargets.borderInlineEnd, ); From ed48ccd5885125c801846301550be02ad42080b8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 22:49:11 +0300 Subject: [PATCH 5/7] fix: map the block-axis logical borders onto props React Native reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #379 fixed. `src/__tests__/vendor/tailwind/borders.test.tsx` asserted those two dead keys and passed while broken, exactly as #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. --- .../compiler/logical-borders.test.ts | 205 +++++++++++++ src/__tests__/native/logical-borders.test.tsx | 289 ++++++++++++++++++ .../vendor/tailwind/borders.test.tsx | 12 +- src/compiler/declarations.ts | 182 ++++++----- src/native/styles/shorthands/border.ts | 62 ++-- 5 files changed, 650 insertions(+), 100 deletions(-) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index af5b891e..955f9429 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -342,3 +342,208 @@ describe("logical border three-part shorthands via var() (unparsed path)", () => }, ); }); + +/** + * The block axis, which React Native supports differently from the inline one. + * + * The three block COLOURS are real props — `borderBlockColor`, + * `borderBlockStartColor` and `borderBlockEndColor` are in + * `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in `ViewStyle` — + * so they are emitted as-is. The block WIDTHS appear only in + * `BaseViewConfig.ios.js`, so emitting them paints on iOS and nowhere else; + * they map to the physical edges instead. `direction` never flips the block + * axis, so block-start is the top edge on every platform. + */ +describe("block border widths", () => { + test("border-block-start-width", () => { + expect(getRule("border-block-start-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 2 }] }, + ]); + }); + + test("border-block-end-width", () => { + expect(getRule("border-block-end-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderBottomWidth: 2 }] }, + ]); + }); + + test("border-block-width", () => { + expect(getRule("border-block-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 2, borderBottomWidth: 2 }] }, + ]); + }); + + test("border-block-width with two values", () => { + expect(getRule("border-block-width: 1px 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 1, borderBottomWidth: 2 }] }, + ]); + }); +}); + +describe("block border colors", () => { + test.each([ + ["border-block-start-color", "borderBlockStartColor"], + ["border-block-end-color", "borderBlockEndColor"], + ["border-block-color", "borderBlockColor"], + ])("%s keeps React Native's own prop", (property, key) => { + expect(getRule(`${property}: red;`).rule).toStrictEqual([ + { s: [1, 1], d: [{ [key]: "#f00" }] }, + ]); + }); +}); + +describe("block border styles", () => { + // React Native has no per-edge border style on either axis. + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s: solid is dropped without warning", (property) => { + const { rule, warnings } = getRule(`${property}: solid;`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + test("border-block-start-style: dashed is dropped with a warning", () => { + const { rule, warnings } = getRule("border-block-start-style: dashed;"); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ + values: { "border-block-start-style": ["dashed"] }, + }); + }); + + test("border-block-style: two values warn per edge", () => { + expect( + getRule("border-block-style: dashed dotted;").warnings, + ).toStrictEqual({ + values: { + "border-block-start-style": ["dashed"], + "border-block-end-style": ["dotted"], + }, + }); + }); +}); + +describe("block border shorthands", () => { + test("border-block", () => { + expect(getRule("border-block: 2px solid red;").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + { + borderBlockColor: "#f00", + borderTopWidth: 2, + borderBottomWidth: 2, + }, + ], + }, + ]); + }); + + test("border-block-start", () => { + expect(getRule("border-block-start: 2px solid red;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderBlockStartColor: "#f00", borderTopWidth: 2 }] }, + ]); + }); + + test("border-block-end", () => { + expect(getRule("border-block-end: 2px solid red;").rule).toStrictEqual([ + { + s: [1, 1], + d: [{ borderBlockEndColor: "#f00", borderBottomWidth: 2 }], + }, + ]); + }); + + test("a block shorthand with a dashed style warns", () => { + expect(getRule("border-block: 2px dashed red;").warnings).toStrictEqual({ + values: { "border-block-style": ["dashed"] }, + }); + }); +}); + +describe("block borders via var() (unparsed path)", () => { + test.each([ + ["border-block-start-width", "borderTopWidth"], + ["border-block-end-width", "borderBottomWidth"], + ])("%s renames on the unparsed path too", (property, key) => { + expect(getRule(`${property}: var(--w);`).rule).toStrictEqual([ + { s: [1, 1], d: [[[{}, "var", "w", 1], key, 1]], dv: 1 }, + ]); + }); + + test.each([ + ["border-block-width", "borderTopWidth", "borderBottomWidth"], + ["border-block-color", "borderBlockStartColor", "borderBlockEndColor"], + ])("%s expands to both edges", (property, startKey, endKey) => { + expect(getRule(`${property}: var(--v);`).rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "v", 1], startKey, 1], + [[{}, "var", "v", 1], endKey, 1], + ], + dv: 1, + }, + ]); + }); + + test.each([ + ["border-block-width", "borderTopWidth", "borderBottomWidth"], + ["border-block-color", "borderBlockStartColor", "borderBlockEndColor"], + ])("%s: var() var() feeds one edge each", (property, startKey, endKey) => { + expect(getRule(`${property}: var(--a) var(--b);`).rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "a", 1], startKey, 1], + [[{}, "var", "b", 1], endKey, 1], + ], + dv: 1, + }, + ]); + }); + + test("more than two values is not the grammar, so the declaration drops", () => { + const { rule, warnings } = getRule( + "border-block-width: var(--a) var(--b) var(--c);", + ); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ + values: { "border-block-width": ["3 values (expected 1 or 2)"] }, + }); + }); + + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s with a var() drops without warning", (property) => { + const { rule, warnings } = getRule(`${property}: var(--tw-border-style);`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + // The same runtime-call shape the inline axis and `border` compile to, which + // is what lets one handler grammar serve all seven. + test.each([ + ["border-block", "borderBlock"], + ["border-block-start", "borderBlockStart"], + ["border-block-end", "borderBlockEnd"], + ])("%s with a var() compiles to a runtime call", (property, resolver) => { + const { rule, warnings } = getRule(`${property}: var(--b);`); + + expect(rule).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, resolver, [{}, "var", "b", 1], 1], resolver, 1]], + dv: 1, + }, + ]); + expect(warnings).toStrictEqual({}); + }); +}); diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx index 9b10c5cc..ab0af229 100644 --- a/src/__tests__/native/logical-borders.test.tsx +++ b/src/__tests__/native/logical-borders.test.tsx @@ -1,3 +1,5 @@ +import type { ViewStyle } from "react-native"; + import { act, fireEvent, render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; @@ -7,6 +9,50 @@ import { dimensions } from "../../native/reactivity"; const children = undefined; +/** + * Every border style key React Native declares. + * + * `satisfies readonly (keyof ViewStyle)[]` is what makes this a derivation + * rather than a list somebody wrote down: a name React Native does not declare + * cannot be added here at all, so the census cannot be widened to let a dead + * key through, and a name React Native drops in a later release turns the + * type-check red. Note which logical names are absent — there is no + * `borderInline*` of any kind, no `borderBlockWidth`, and no per-edge + * `border*Style`. Those are the keys this file exists to keep out of a + * rendered component. + */ +const REACT_NATIVE_BORDER_KEYS = [ + "borderBlockColor", + "borderBlockEndColor", + "borderBlockStartColor", + "borderBottomColor", + "borderBottomWidth", + "borderColor", + "borderEndColor", + "borderEndWidth", + "borderLeftColor", + "borderLeftWidth", + "borderRightColor", + "borderRightWidth", + "borderStartColor", + "borderStartWidth", + "borderStyle", + "borderTopColor", + "borderTopWidth", + "borderWidth", +] as const satisfies readonly (keyof ViewStyle)[]; + +/** + * Whether React Native understands a style key. + * + * A key it does not declare never reaches a shadow node — React Native's view + * config is a whitelist, so an unknown key is dropped with no error, no + * warning and no paint. That silence is why the assertion has to be made here, + * against the props a component actually received. + */ +const isRealStyleKey = (key: string): boolean => + (REACT_NATIVE_BORDER_KEYS as readonly string[]).includes(key); + /** * The style keys a rendered component actually received, sorted. * @@ -561,3 +607,246 @@ describe("the literal border-inline shorthand reaching the component", () => { }, ); }); + +describe("the block axis reaching the component", () => { + /** + * The block axis is the inline axis's twin and React Native supports it + * differently, which is why it needs its own expectations rather than a + * mirrored copy of the ones above. The three block COLOURS are real props — + * `borderBlockColor`, `borderBlockStartColor` and `borderBlockEndColor` are + * in `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in + * `ViewStyle` — so they are kept. The block WIDTHS are in + * `BaseViewConfig.ios.js` only, so they map to the physical edges every + * platform reads. `direction` never flips the block axis, so block-start is + * the top edge and block-end the bottom one on every platform. + */ + test.each([ + [ + "border-block", + { + borderBlockColor: "#26e", + borderTopWidth: 6, + borderBottomWidth: 6, + }, + ], + [ + "border-block-start", + { borderBlockStartColor: "#26e", borderTopWidth: 6 }, + ], + ["border-block-end", { borderBlockEndColor: "#26e", borderBottomWidth: 6 }], + ])("%s: 6px dashed #2266ee reaches real props", (property, expected) => { + registerCSS(`.my-class { ${property}: 6px dashed #2266ee; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test.each([ + ["border-block-width", { borderTopWidth: 6, borderBottomWidth: 6 }], + ["border-block-start-width", { borderTopWidth: 6 }], + ["border-block-end-width", { borderBottomWidth: 6 }], + ])("%s: 6px reaches a width React Native reads", (property, expected) => { + registerCSS(`.my-class { ${property}: 6px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("border-block-width takes its second value as the bottom edge", () => { + registerCSS(`.my-class { border-block-width: 1px 2px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderTopWidth: 1, + borderBottomWidth: 2, + }); + }); + + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s paints nothing", (property) => { + registerCSS(`.my-class { ${property}: dashed; }`); + + render(); + expect(screen.getByTestId(testID).props).toStrictEqual({ + children, + testID, + }); + }); + + test.each([ + [ + "border-block", + { + borderTopWidth: 1, + borderBottomWidth: 1, + borderBlockColor: "red", + }, + ], + ["border-block-start", { borderTopWidth: 1, borderBlockStartColor: "red" }], + ["border-block-end", { borderBottomWidth: 1, borderBlockEndColor: "red" }], + ])("%s via var() expands onto the same props", (property, expected) => { + registerCSS( + `.my-class { ${property}: var(--shorthand); } ${shorthandDefinitions}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("the unparsed block expansion matches the parsed one", () => { + registerCSS(` + .unparsed { border-block: var(--shorthand); } + .parsed { border-block: 1px solid red; } + ${shorthandDefinitions} + `); + + render( + + + , + ); + + // The parsed path resolves `red` to #f00 at compile time; every key must + // agree, which is what makes the two routes one behaviour. + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }); + + test.each(["border-block", "border-block-start", "border-block-end"])( + "%s never widens its style component to borderStyle", + (property) => { + registerCSS(` + .my-class { ${property}: var(--dashed); } + :root { --dashed: 1px dashed red; } + .redefine { --dashed: 2px dotted blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).not.toHaveProperty( + "borderStyle", + ); + }, + ); + + test("border-block-width via a two-value var() pair splits the edges", () => { + registerCSS(` + .my-class { border-block-width: var(--width) var(--other-width); } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderTopWidth: 1, + borderBottomWidth: 2, + }); + }); +}); + +/** + * The whole logical-border family, against React Native's own census of style + * attributes. + * + * This is the guard for the CLASS rather than for the rows fixed today. The + * defect it catches is invisible in the compiler IR — a `borderBlockWidth` + * entry in the emitted declarations looks exactly like a real one — and it is + * invisible to a hand-written expectation too, because a test author has to + * already know which of React Native's near-identical logical props exist. + * + * `ReactNativeStyleAttributes` is React Native's own answer to that question, + * so the expectation is DERIVED from it rather than restated here: a React + * Native release that adds a prop relaxes this test on its own, and one that + * removes a prop we depend on turns it red without anyone editing a list. + */ +describe("no logical border property reaches a key React Native lacks", () => { + /** Every `border-{inline,block}[-start|-end][-width|-style|-color]`. */ + const FAMILY: string[] = ["inline", "block"].flatMap((axis) => + ["", "-start", "-end"].flatMap((edge) => + ["", "-width", "-style", "-color"].map( + (suffix) => `border-${axis}${edge}${suffix}`, + ), + ), + ); + + const literalFor = (property: string): string => { + if (property.endsWith("-color")) return "#2266ee"; + if (property.endsWith("-width")) return "6px"; + if (property.endsWith("-style")) return "dashed"; + return "6px dashed #2266ee"; + }; + + const varFor = (property: string): string => { + if (property.endsWith("-color")) return "var(--color)"; + if (property.endsWith("-width")) return "var(--width)"; + if (property.endsWith("-style")) return "var(--style)"; + return "var(--shorthand)"; + }; + + /** + * Generating the census trades a drift failure for a vacuity one: a family + * that stopped being generated makes every case below pass over nothing. + * + * 24 is the closed set CSS defines for this family — two axes, three edges, + * four value slots — so a different count means the generator changed rather + * than that CSS did. The two members are spot-checked because a generator + * producing 24 wrong strings would satisfy the count alone. + */ + test("the family census is the whole family", () => { + expect(FAMILY).toHaveLength(24); + expect(FAMILY).toContain("border-inline-start-width"); + expect(FAMILY).toContain("border-block-end-color"); + }); + + /** + * The negative control for the oracle above. Every case in this describe is + * an assertion that a set is EMPTY, and such an assertion passes just as + * happily when the predicate can never say no — so the predicate is pinned + * against the exact names this whole file exists to keep out. + */ + test.each([ + "borderInlineWidth", + "borderInlineStyle", + "borderInlineColor", + "borderInlineStartWidth", + "borderInlineEndColor", + "borderBlockWidth", + "borderBlockStartWidth", + "borderBlockEndWidth", + "borderBlockStyle", + "borderBlockStartStyle", + "borderBlockEndStyle", + ])("%s is not a key React Native understands", (key) => { + expect(isRealStyleKey(key)).toBe(false); + }); + + test("the three block COLOURS are keys React Native does understand", () => { + expect(isRealStyleKey("borderBlockColor")).toBe(true); + expect(isRealStyleKey("borderBlockStartColor")).toBe(true); + expect(isRealStyleKey("borderBlockEndColor")).toBe(true); + }); + + test.each(FAMILY)("%s (literal) emits only real style keys", (property) => { + registerCSS(`.my-class { ${property}: ${literalFor(property)}; }`); + + render(); + expect( + styleKeys(testID).filter((key) => !isRealStyleKey(key)), + ).toStrictEqual([]); + }); + + test.each(FAMILY)("%s (var) emits only real style keys", (property) => { + registerCSS(` + .my-class { ${property}: ${varFor(property)}; } + :root { --style: solid; } + .redefine { --style: dashed; } + ${twiceDefined} + ${shorthandDefinitions} + `); + + render(); + expect( + styleKeys(testID).filter((key) => !isRealStyleKey(key)), + ).toStrictEqual([]); + }); +}); diff --git a/src/__tests__/vendor/tailwind/borders.test.tsx b/src/__tests__/vendor/tailwind/borders.test.tsx index f52b1abe..79856304 100644 --- a/src/__tests__/vendor/tailwind/borders.test.tsx +++ b/src/__tests__/vendor/tailwind/borders.test.tsx @@ -41,12 +41,16 @@ describe("Border - Border Width", () => { }, }); }); + // The block-axis twin of border-x-1 above. React Native reads no + // borderBlockWidth on Android or the old architecture and no per-edge + // border style anywhere, so both keys this used to assert were inert and + // the utility painted nothing. test("border-y-1", async () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - borderBlockWidth: 1, - borderBlockStyle: "solid", + borderTopWidth: 1, + borderBottomWidth: 1, }, }, }); @@ -108,8 +112,8 @@ describe("Border - Border Width", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - borderBlockWidth: 2, - borderBlockStyle: "solid", + borderTopWidth: 2, + borderBottomWidth: 2, }, }, }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 4f31d77f..b6d2a14e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -62,6 +62,16 @@ type Parser = ( const propertyRename: Record = { "background-image": "experimental_backgroundImage", + // React Native ships no border-block-* WIDTH on Android or the old + // architecture: ReactNativeStyleAttributes and BaseViewConfig.android.js + // both list the three block COLOURS and none of the widths, and ViewStyle + // declares only the colours. BaseViewConfig.ios.js is the outlier that + // carries them, which makes an unrenamed block width paint on iOS and + // nowhere else. The block axis is never flipped by `direction`, so + // block-start is the top edge and block-end the bottom one on every + // platform — the colours need no rename because RN already reads them. + "border-block-end-width": "border-bottom-width", + "border-block-start-width": "border-top-width", // React Native has no border-inline-* props, but ships the equivalent // RTL-aware border-start-* / border-end-* props "border-inline-end-color": "border-end-color", @@ -71,35 +81,43 @@ const propertyRename: Record = { "font-variant-caps": "font-variant", }; -// React Native only supports a uniform borderStyle, so per-side border +// React Native only supports a uniform borderStyle, so per-edge border // styles have no native equivalent and are dropped. "solid" is dropped // silently as it matches React Native's default rendering. A var() keeps the // value unknown at compile time, and an unknown value is not a known // non-solid one, so the unparsed path drops these as quietly. -const unsupportedInlineStyles = new Set([ +const unsupportedEdgeStyles = new Set([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", "border-inline-style", "border-inline-start-style", "border-inline-end-style", ]); // A var() keeps a logical-border shorthand on the unparsed path, where the -// parsed parseBorderInline* never run and propertyRename only maps longhands. -// These are the inline-axis shorthands React Native can express, as the -// [start, end] pair each expands to. The grammar is `{1,2}`: one -// component feeds both edges, two feed one edge each. -const inlineAxisExpansion: Record = { +// parsed parseBorderInline* / parseBorderBlock* never run and propertyRename +// only maps longhands. These are the two-edge shorthands React Native can +// express, as the [start, end] pair each expands to. The grammar is +// `{1,2}`: one component feeds both edges, two feed one edge each. +const axisExpansion: Record = { + "border-block-color": ["border-block-start-color", "border-block-end-color"], + "border-block-width": ["border-top-width", "border-bottom-width"], "border-inline-color": ["border-start-color", "border-end-color"], "border-inline-width": ["border-start-width", "border-end-width"], }; // Shorthands whose value has to be split after the variable resolves, so the -// compiler emits a runtime call instead of descriptors. The inline-axis three -// are here for the same reason `border` is — each packs width, style and -// colour into one list that a var() keeps opaque — and their runtime handlers -// fan the resolved list onto the RTL-aware per-edge props. +// compiler emits a runtime call instead of descriptors. The six logical-axis +// shorthands are here for the same reason `border` is — each packs width, +// style and colour into one list that a var() keeps opaque — and their +// runtime handlers fan the resolved list onto the per-edge props. const unparsedRuntimeParsing = new Set([ "animation", "border", + "border-block", + "border-block-end", + "border-block-start", "border-inline", "border-inline-end", "border-inline-start", @@ -137,12 +155,13 @@ const parsers: { "border-block-color": parseBorderColor, "border-block-end": parseBorderBlockEnd, "border-block-end-color": parseColorDeclaration, + "border-block-end-style": parseUnsupportedEdgeStyle, "border-block-end-width": parseBorderSideWidthDeclaration, "border-block-start": parseBorderBlockStart, "border-block-start-color": parseColorDeclaration, - "border-block-start-style": parseBorderStyleDeclaration, + "border-block-start-style": parseUnsupportedEdgeStyle, "border-block-start-width": parseBorderSideWidthDeclaration, - "border-block-style": parseBorderBlockStyle, + "border-block-style": parseUnsupportedEdgeStyle, "border-block-width": parseBorderBlockWidth, "border-bottom": parseBorderSide, "border-bottom-color": parseColorDeclaration, @@ -157,13 +176,13 @@ const parsers: { "border-inline-color": parseBorderColor, "border-inline-end": parseBorderInlineEnd, "border-inline-end-color": parseColorDeclaration, - "border-inline-end-style": parseBorderInlineStyle, + "border-inline-end-style": parseUnsupportedEdgeStyle, "border-inline-end-width": parseBorderSideWidthDeclaration, "border-inline-start": parseBorderInlineStart, "border-inline-start-color": parseColorDeclaration, - "border-inline-start-style": parseBorderInlineStyle, + "border-inline-start-style": parseUnsupportedEdgeStyle, "border-inline-start-width": parseBorderSideWidthDeclaration, - "border-inline-style": parseBorderInlineStyle, + "border-inline-style": parseUnsupportedEdgeStyle, "border-inline-width": parseBorderInlineWidth, "border-left": parseBorderSide, "border-left-color": parseColorDeclaration, @@ -472,14 +491,15 @@ function parseBorderBlock( { value }: DeclarationType<"border-block">, builder: StylesheetBuilder, ) { + const width = parseBorderSideWidth(value.width, builder); + builder.addDescriptor("border-block-color", parseColor(value.color, builder)); - builder.addDescriptor( - "border-block-width", - parseBorderSideWidth(value.width, builder), - ); - builder.addDescriptor( - "border-block-style", + builder.addDescriptor("border-top-width", width); + builder.addDescriptor("border-bottom-width", width); + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), + builder, + "border-block-style", ); } @@ -492,9 +512,14 @@ function parseBorderBlockStart( parseColor(value.color, builder), ); builder.addDescriptor( - "border-block-start-width", + "border-top-width", parseBorderSideWidth(value.width, builder), ); + dropUnsupportedEdgeStyle( + parseBorderStyle(value.style, builder), + builder, + "border-block-start-style", + ); } function parseBorderBlockEnd( @@ -506,9 +531,14 @@ function parseBorderBlockEnd( parseColor(value.color, builder), ); builder.addDescriptor( - "border-block-end-width", + "border-bottom-width", parseBorderSideWidth(value.width, builder), ); + dropUnsupportedEdgeStyle( + parseBorderStyle(value.style, builder), + builder, + "border-block-end-style", + ); } function parseBorderInline( @@ -522,7 +552,7 @@ function parseBorderInline( builder.addDescriptor("border-end-color", color); builder.addDescriptor("border-start-width", width); builder.addDescriptor("border-end-width", width); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-style", @@ -538,7 +568,7 @@ function parseBorderInlineStart( "border-start-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-start-style", @@ -554,7 +584,7 @@ function parseBorderInlineEnd( "border-end-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-end-style", @@ -575,8 +605,21 @@ export function parseBorderInlineWidth( ); } -export function parseBorderInlineStyle( +/** + * Every per-edge border style, on either logical axis. + * + * React Native has no per-edge border style at any layer — the two + * BaseViewConfigs and ReactNativeStyleAttributes carry `borderStyle` and + * nothing else, and Android's BorderDrawable holds one style for the whole + * path — so all six longhands drop rather than reaching a key the platform + * ignores. The two-value forms name the edge they came from in the warning, + * so a reader is told which half of the declaration was discarded. + */ +export function parseUnsupportedEdgeStyle( declaration: DeclarationType< + | "border-block-style" + | "border-block-start-style" + | "border-block-end-style" | "border-inline-style" | "border-inline-start-style" | "border-inline-end-style" @@ -584,26 +627,32 @@ export function parseBorderInlineStyle( builder: StylesheetBuilder, ) { if (typeof declaration.value === "string") { - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(declaration.value, builder), builder, declaration.property, ); - } else { - dropUnsupportedInlineStyle( - parseBorderStyle(declaration.value.start, builder), - builder, - "border-inline-start-style", - ); - dropUnsupportedInlineStyle( - parseBorderStyle(declaration.value.end, builder), - builder, - "border-inline-end-style", - ); + return; } + + const [startProperty, endProperty] = + declaration.property === "border-block-style" + ? (["border-block-start-style", "border-block-end-style"] as const) + : (["border-inline-start-style", "border-inline-end-style"] as const); + + dropUnsupportedEdgeStyle( + parseBorderStyle(declaration.value.start, builder), + builder, + startProperty, + ); + dropUnsupportedEdgeStyle( + parseBorderStyle(declaration.value.end, builder), + builder, + endProperty, + ); } -function dropUnsupportedInlineStyle( +function dropUnsupportedEdgeStyle( style: string | undefined, builder: StylesheetBuilder, property: string, @@ -964,7 +1013,7 @@ export function parseUnparsedDeclaration( // has no native attribute, at any value. Warning here would fire on every // Tailwind v4 border-{x,s,e}-* utility, which emits `var(--tw-border-style)` // defaulting to the `solid` the parsed path drops without a word. - if (unsupportedInlineStyles.has(property)) { + if (unsupportedEdgeStyles.has(property)) { return; } @@ -978,14 +1027,9 @@ export function parseUnparsedDeclaration( property = rename; } - const inlineAxis = inlineAxisExpansion[property]; - if (inlineAxis) { - parseUnparsedInlineAxis( - declaration.value.value, - inlineAxis, - builder, - property, - ); + const axis = axisExpansion[property]; + if (axis) { + parseUnparsedAxis(declaration.value.value, axis, builder, property); return; } @@ -1044,10 +1088,10 @@ function unparsedComponentValues( } /** - * Expand an inline-axis shorthand that a var() kept unparsed, the way - * parseBorderInline* expands the parsed form. + * Expand a two-edge logical-axis shorthand that a var() kept unparsed, the way + * parseBorderInline* / parseBorderBlock* expand the parsed form. */ -function parseUnparsedInlineAxis( +function parseUnparsedAxis( tokenOrValues: TokenOrValue[], [startProperty, endProperty]: readonly [string, string], builder: StylesheetBuilder, @@ -2288,30 +2332,14 @@ export function parseBorderBlockWidth( declaration: DeclarationType<"border-block-width">, builder: StylesheetBuilder, ) { - const start = parseBorderSideWidth(declaration.value.start, builder); - const end = parseBorderSideWidth(declaration.value.end, builder); - - if (start === end) { - builder.addDescriptor("border-block-width", start); - } else { - builder.addDescriptor("border-block-start-width", start); - builder.addDescriptor("border-block-end-width", end); - } -} - -function parseBorderBlockStyle( - declaration: DeclarationType<"border-block-style">, - builder: StylesheetBuilder, -) { - const start = parseBorderStyle(declaration.value.start, builder); - const end = parseBorderStyle(declaration.value.end, builder); - - if (start == end) { - builder.addDescriptor("border-block-style", start); - } else { - builder.addDescriptor("border-block-start-style", start); - builder.addDescriptor("border-block-end-style", end); - } + builder.addDescriptor( + "border-top-width", + parseBorderSideWidth(declaration.value.start, builder), + ); + builder.addDescriptor( + "border-bottom-width", + parseBorderSideWidth(declaration.value.end, builder), + ); } export function parseBorderSideWidthDeclaration( diff --git a/src/native/styles/shorthands/border.ts b/src/native/styles/shorthands/border.ts index 25a8ff39..48d9220e 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -8,7 +8,7 @@ const color = ["borderColor", "color", "color"] as const; /** * ` || || `, in the component orders a - * resolved runtime value can arrive in. `border` and the three inline-axis + * resolved runtime value can arrive in. `border` and the six logical-axis * shorthands share the same grammar, so it is stated once here rather than * copied per handler. */ @@ -24,18 +24,29 @@ export const border = shorthandHandler(mappings, []); const matchBorder = shorthandHandler(mappings, [], "object"); /** - * Which React Native props each matched slot feeds, per inline-axis shorthand. + * Which React Native props each matched slot feeds, per logical-axis shorthand. + * + * The two axes reach different props because React Native supports them + * differently. The inline axis has no native prop of its own, so it maps onto + * the RTL-aware `borderStart*` / `borderEnd*` pair. The block axis has native + * COLOURS — `borderBlockColor`, `borderBlockStartColor` and + * `borderBlockEndColor` are in `ReactNativeStyleAttributes`, both + * `BaseViewConfig`s and `ViewStyle` — but its WIDTHS exist only in + * `BaseViewConfig.ios.js`, so a `borderBlockWidth` paints on iOS and nowhere + * else. Block widths therefore map to the physical edges, which every + * platform reads; block start is the top edge and block end the bottom one, + * on every platform, because `direction` never flips the block axis. * * `borderStyle` is absent from every entry deliberately. React Native has no * per-edge border style at any layer: `BaseViewConfig.{android,ios}.js` lists * `borderStyle` and nothing per-edge, `ViewStyle` declares only `borderStyle`, * and Android's `BorderDrawable` holds a single style for the whole border - * path. Widening it to `borderStyle` would paint the block edges the - * declaration never mentioned and clobber a `border-style` set elsewhere in - * the cascade, so the component is dropped — exactly as the parsed path drops - * it for `border-inline` and for the `border-inline-*-style` longhands. + * path. Widening it to `borderStyle` would paint the edges the declaration + * never mentioned and clobber a `border-style` set elsewhere in the cascade, + * so the component is dropped — exactly as the parsed path drops it for the + * shorthands and for the `border-{inline,block}-*-style` longhands. */ -const inlineTargets = { +const axisTargets = { borderInline: { borderWidth: ["borderStartWidth", "borderEndWidth"], borderColor: ["borderStartColor", "borderEndColor"], @@ -48,19 +59,31 @@ const inlineTargets = { borderWidth: ["borderEndWidth"], borderColor: ["borderEndColor"], }, + borderBlock: { + borderWidth: ["borderTopWidth", "borderBottomWidth"], + borderColor: ["borderBlockColor"], + }, + borderBlockStart: { + borderWidth: ["borderTopWidth"], + borderColor: ["borderBlockStartColor"], + }, + borderBlockEnd: { + borderWidth: ["borderBottomWidth"], + borderColor: ["borderBlockEndColor"], + }, } as const; -type InlineTargets = (typeof inlineTargets)[keyof typeof inlineTargets]; +type AxisTargets = (typeof axisTargets)[keyof typeof axisTargets]; /** - * An inline-axis border shorthand whose value stayed opaque until runtime. + * A logical-axis border shorthand whose value stayed opaque until runtime. * * The resolved components are matched against the same grammar `border` uses, - * then fanned onto the RTL-aware per-edge props. `ShortHandSymbol` is what - * lets one descriptor write several props: the style object it marks is spread - * onto the target rather than assigned under the shorthand's own name. + * then fanned onto that axis's per-edge props. `ShortHandSymbol` is what lets + * one descriptor write several props: the style object it marks is spread onto + * the target rather than assigned under the shorthand's own name. */ -function inlineBorderHandler(targets: InlineTargets): StyleResolver { +function axisBorderHandler(targets: AxisTargets): StyleResolver { return (resolveValue, value, get, options) => { const parsed = matchBorder(resolveValue, value, get, options); @@ -86,10 +109,11 @@ function inlineBorderHandler(targets: InlineTargets): StyleResolver { }; } -export const borderInline = inlineBorderHandler(inlineTargets.borderInline); -export const borderInlineStart = inlineBorderHandler( - inlineTargets.borderInlineStart, -); -export const borderInlineEnd = inlineBorderHandler( - inlineTargets.borderInlineEnd, +export const borderInline = axisBorderHandler(axisTargets.borderInline); +export const borderInlineStart = axisBorderHandler( + axisTargets.borderInlineStart, ); +export const borderInlineEnd = axisBorderHandler(axisTargets.borderInlineEnd); +export const borderBlock = axisBorderHandler(axisTargets.borderBlock); +export const borderBlockStart = axisBorderHandler(axisTargets.borderBlockStart); +export const borderBlockEnd = axisBorderHandler(axisTargets.borderBlockEnd); From 33663bd54fd9f441117ca05c75e2243314813095 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 01:26:42 +0300 Subject: [PATCH 6/7] fix(compiler): give border-block-color one key set per arity, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../compiler/logical-borders.test.ts | 112 +++++++++++++- src/__tests__/native/logical-borders.test.tsx | 140 ++++++++++++++++-- src/compiler/declarations.ts | 68 ++++++--- 3 files changed, 281 insertions(+), 39 deletions(-) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 955f9429..401e1ace 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -289,6 +289,85 @@ describe("logical border shorthands with two values (unparsed path)", () => { values: { "border-inline-width": ["3 values (expected 1 or 2)"] }, }); }); + + /** + * A literal beside a var() is the arity the component-value count is + * actually decided by. `var(--a) var(--b)` arrives from lightningcss as two + * bare var tokens whether or not the separator survives, so it counts to two + * either way and cannot see whether whitespace is being filtered out. + * `1px var(--b)` keeps its separator, and counts to THREE unless it is. + */ + test.each([ + ["border-inline-width: 1px var(--b);", "borderStartWidth", 1], + ["border-inline-color: red var(--b);", "borderStartColor", "red"], + ])("%s splits on the component values, not the tokens", (css, key, value) => { + const endKey = + key === "borderStartWidth" ? "borderEndWidth" : "borderEndColor"; + + expect(getRule(css).rule).toStrictEqual([ + { + s: [1, 1], + d: [{ [key]: value }, [[{}, "var", "b", 1], endKey, 1]], + dv: 1, + }, + ]); + }); +}); + +/** + * `light-dark()` writes its dark branch through the builder from inside + * parseUnparsed rather than through the value the parser returns, so it + * reaches whatever `descriptorProperties` names rather than the properties the + * expansion went on to write. An axis shorthand names more than one, which is + * why that field carries a list. + * + * The native suite covers the rendered result; these assert the emitted rules, + * so the compiler plane can see a regression here on its own. The two axes are + * both here because they name different targets: the inline axis opens its + * dark rule over the edge pair, the block axis over the single axis property. + */ +describe("the axis expansion under light-dark() (unparsed path)", () => { + test("border-inline-color reaches both edges in each scheme", () => { + expect( + getRule("border-inline-color: light-dark(var(--a), var(--b));").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "a", 1], "borderStartColor", 1], + [[{}, "var", "a", 1], "borderEndColor", 1], + ], + dv: 1, + }, + { + s: [1, 1], + d: [ + [[{}, "var", "b", 1], "borderStartColor", 1], + [[{}, "var", "b", 1], "borderEndColor", 1], + ], + dv: 1, + m: [["=", "prefers-color-scheme", "dark"]], + }, + ]); + }); + + test("border-block-color reaches the axis property in each scheme", () => { + expect( + getRule("border-block-color: light-dark(var(--a), var(--b));").rule, + ).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "var", "a", 1], "borderBlockColor", 1]], + dv: 1, + }, + { + s: [1, 1], + d: [[[{}, "var", "b", 1], "borderBlockColor", 1]], + dv: 1, + m: [["=", "prefers-color-scheme", "dark"]], + }, + ]); + }); }); describe("logical border three-part shorthands via var() (unparsed path)", () => { @@ -474,25 +553,42 @@ describe("block borders via var() (unparsed path)", () => { ]); }); - test.each([ - ["border-block-width", "borderTopWidth", "borderBottomWidth"], - ["border-block-color", "borderBlockStartColor", "borderBlockEndColor"], - ])("%s expands to both edges", (property, startKey, endKey) => { - expect(getRule(`${property}: var(--v);`).rule).toStrictEqual([ + test("border-block-width expands to both edges", () => { + expect(getRule("border-block-width: var(--v);").rule).toStrictEqual([ { s: [1, 1], d: [ - [[{}, "var", "v", 1], startKey, 1], - [[{}, "var", "v", 1], endKey, 1], + [[{}, "var", "v", 1], "borderTopWidth", 1], + [[{}, "var", "v", 1], "borderBottomWidth", 1], ], dv: 1, }, ]); }); + /** + * The block colours are where React Native's support stops being uniform, so + * the unparsed path has to make the parsed path's choice rather than a + * consistent-looking one of its own: one value collapses onto the axis + * property `borderBlockColor`, two split across the physical edges. Picking + * `borderBlockStartColor` / `borderBlockEndColor` for either arity would be + * a THIRD key set, disjoint from both — see the cascade test below for what + * that costs. `border-block-width` needs no such split because + * `borderBlockWidth` is in `BaseViewConfig.ios.js` alone. + */ + test("border-block-color takes the axis property for one value", () => { + expect(getRule("border-block-color: var(--v);").rule).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "var", "v", 1], "borderBlockColor", 1]], + dv: 1, + }, + ]); + }); + test.each([ ["border-block-width", "borderTopWidth", "borderBottomWidth"], - ["border-block-color", "borderBlockStartColor", "borderBlockEndColor"], + ["border-block-color", "borderTopColor", "borderBottomColor"], ])("%s: var() var() feeds one edge each", (property, startKey, endKey) => { expect(getRule(`${property}: var(--a) var(--b);`).rule).toStrictEqual([ { diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx index ab0af229..4426026e 100644 --- a/src/__tests__/native/logical-borders.test.tsx +++ b/src/__tests__/native/logical-borders.test.tsx @@ -12,11 +12,11 @@ const children = undefined; /** * Every border style key React Native declares. * - * `satisfies readonly (keyof ViewStyle)[]` is what makes this a derivation - * rather than a list somebody wrote down: a name React Native does not declare - * cannot be added here at all, so the census cannot be widened to let a dead - * key through, and a name React Native drops in a later release turns the - * type-check red. Note which logical names are absent — there is no + * The membership is written by hand; what is not is the CONSTRAINT on it. + * `satisfies readonly (keyof ViewStyle)[]` makes React Native's own type + * decide which names may appear, so a name it does not declare cannot be added + * here to let a dead key through, and a name it drops in a later release turns + * the type-check red. Note which logical names are absent — there is no * `borderInline*` of any kind, no `borderBlockWidth`, and no per-edge * `border*Style`. Those are the keys this file exists to keep out of a * rendered component. @@ -569,6 +569,57 @@ describe("border-inline / -start / -end shorthands via var()", () => { "#000", ); }); + + /** + * A var() is ONE component value however many values it holds, so a variable + * carrying a pair is assigned whole rather than split across the two edges. + * + * This belongs to the unparsed path rather than to the logical axes, and the + * comparison routes prove it: the longhand each axis property renames to + * does the same thing with the same variable, and so does the physical + * `border-width` shorthand that predates the logical axes entirely. Both + * predate this change. So the shape is pinned once, on the longhand route + * that no commit here touches, and the axis routes are asserted as PARITY + * with it — whoever teaches the unparsed path to split a resolved list then + * sees every route move together, instead of finding a value hard-coded + * against three of them. + * + * Which half of the residual bites is worth knowing, and it is not the one + * the colour tests above would suggest. In + * `Libraries/Components/View/ReactNativeStyleAttributes.js` the width keys + * are declared `true` — no processor — so the list is handed to the shadow + * node as it stands, on the longhand route and the axis route alike, while + * the colour keys carry `colorAttributes` and its `processColor` drops a + * list on the way. The keys this file drives a width onto are the same ones + * `border-top-width` and `border-width` have always reached, so the exposure + * is the unparsed path's rather than the logical axes'. + */ + test("a two-value var() is one component on the axis route and on every route beside it", () => { + registerCSS(` + .axis { border-inline-width: var(--pair); } + .longhand { border-inline-start-width: var(--pair); } + .physical { border-width: var(--pair); } + :root { --pair: 1px 2px; } + .redefine { --pair: 9px 9px; } + `); + + render( + + + + , + ); + + const axis = screen.getByTestId("axis").props.style; + const longhand = screen.getByTestId("longhand").props.style; + const physical = screen.getByTestId("physical").props.style; + + expect(longhand.borderStartWidth).toStrictEqual([1, 2]); + + expect(axis.borderStartWidth).toStrictEqual(longhand.borderStartWidth); + expect(axis.borderEndWidth).toStrictEqual(longhand.borderStartWidth); + expect(physical.borderWidth).toStrictEqual(longhand.borderStartWidth); + }); }); describe("the literal border-inline shorthand reaching the component", () => { @@ -742,6 +793,72 @@ describe("the block axis reaching the component", () => { borderBottomWidth: 2, }); }); + + /** + * The longhands, at both arities the grammar accepts. + * + * The shorthand parity test above is not enough on its own: `border-block` + * takes no two-value form, so it cannot see a route that agrees with the + * parsed path at one arity and departs from it at the other. The block + * colours are exactly that shape — React Native gives the axis a property of + * its own only for colour, and the parsed path collapses onto it only when + * both edges agree — so each arity is asserted separately. + */ + test.each([ + ["border-block-color", "var(--color)", "red"], + ["border-block-color", "var(--color) var(--other-color)", "red blue"], + ["border-block-width", "var(--width)", "1px"], + ["border-block-width", "var(--width) var(--other-width)", "1px 2px"], + ])( + "%s: %s reaches the same keys as %s", + (property, unparsedValue, parsedValue) => { + registerCSS(` + .unparsed { ${property}: ${unparsedValue}; } + .parsed { ${property}: ${parsedValue}; } + ${twiceDefined} + `); + + render( + + + , + ); + + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }, + ); + + /** + * Why the parity above is a correctness requirement and not a tidiness one. + * + * Two declarations of the same property have to resolve as one — later wins. + * They only can if they land on the same keys: React Native's style object + * is flat, so two DISJOINT key sets both survive, and its per-edge + * properties outrank the axis one. A route that emitted + * `borderBlockStartColor` / `borderBlockEndColor` here would leave the var() + * painting both edges while the `green` written after it sat unused under + * `borderBlockColor`. + */ + test.each([ + ["var(--color)", "green", { borderBlockColor: "#008000" }], + [ + "var(--color) var(--other-color)", + "green lime", + { borderTopColor: "#008000", borderBottomColor: "#0f0" }, + ], + ])( + "border-block-color: %s is overridden by a later %s", + (unparsedValue, override, expected) => { + registerCSS(` + .base { border-block-color: ${unparsedValue}; } + .override { border-block-color: ${override}; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }, + ); }); /** @@ -754,10 +871,15 @@ describe("the block axis reaching the component", () => { * invisible to a hand-written expectation too, because a test author has to * already know which of React Native's near-identical logical props exist. * - * `ReactNativeStyleAttributes` is React Native's own answer to that question, - * so the expectation is DERIVED from it rather than restated here: a React - * Native release that adds a prop relaxes this test on its own, and one that - * removes a prop we depend on turns it red without anyone editing a list. + * The census below is written out by hand — it has to be, because no type + * enumerates "the border keys". What is derived is the CONSTRAINT on it: + * `satisfies readonly (keyof ViewStyle)[]` makes React Native's own type the + * authority on which names may appear, so a dead key cannot be added here to + * make a failing case pass, and a name React Native drops in a later release + * turns `yarn typecheck` red without anyone editing a list. A name React + * Native ADDS does not appear on its own; widening the census stays a + * deliberate edit, which is why the negative control below pins the eleven + * names this file exists to keep out. */ describe("no logical border property reaches a key React Native lacks", () => { /** Every `border-{inline,block}[-start|-end][-width|-style|-color]`. */ diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index b6d2a14e..3628e73a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -95,16 +95,34 @@ const unsupportedEdgeStyles = new Set([ "border-inline-end-style", ]); -// A var() keeps a logical-border shorthand on the unparsed path, where the -// parsed parseBorderInline* / parseBorderBlock* never run and propertyRename -// only maps longhands. These are the two-edge shorthands React Native can -// express, as the [start, end] pair each expands to. The grammar is -// `{1,2}`: one component feeds both edges, two feed one edge each. -const axisExpansion: Record = { - "border-block-color": ["border-block-start-color", "border-block-end-color"], - "border-block-width": ["border-top-width", "border-bottom-width"], - "border-inline-color": ["border-start-color", "border-end-color"], - "border-inline-width": ["border-start-width", "border-end-width"], +/** + * Where a two-edge logical shorthand lands once a var() has kept it off the + * parsed path, at each arity the grammar `{1,2}` allows. + * + * `edges` is the [start, end] pair two components feed, one each. One + * component normally feeds both, since both edges then carry the same value. + * + * `axis` is the exception, and it exists because React Native's support is + * uneven: `borderBlockColor` is the family's only axis-wide property — there + * is no `borderInlineColor`, and `borderBlockWidth` is in + * `BaseViewConfig.ios.js` alone — and the parsed path collapses onto it + * whenever both block edges agree. The unparsed path has to make the same + * choice, because the two key sets are DISJOINT and so both survive the + * cascade: emit the pair here and a `borderBlockColor` declared later sits + * beside it rather than replacing it, React Native's per-edge properties win, + * and the later declaration silently loses. + */ +const axisExpansion: Record< + string, + { readonly edges: readonly [string, string]; readonly axis?: string } +> = { + "border-block-color": { + edges: ["border-top-color", "border-bottom-color"], + axis: "border-block-color", + }, + "border-block-width": { edges: ["border-top-width", "border-bottom-width"] }, + "border-inline-color": { edges: ["border-start-color", "border-end-color"] }, + "border-inline-width": { edges: ["border-start-width", "border-end-width"] }, }; // Shorthands whose value has to be split after the variable resolves, so the @@ -615,7 +633,7 @@ export function parseBorderInlineWidth( * ignores. The two-value forms name the edge they came from in the warning, * so a reader is told which half of the declaration was discarded. */ -export function parseUnsupportedEdgeStyle( +function parseUnsupportedEdgeStyle( declaration: DeclarationType< | "border-block-style" | "border-block-start-style" @@ -1027,9 +1045,9 @@ export function parseUnparsedDeclaration( property = rename; } - const axis = axisExpansion[property]; - if (axis) { - parseUnparsedAxis(declaration.value.value, axis, builder, property); + const expansion = axisExpansion[property]; + if (expansion) { + parseUnparsedAxis(declaration.value.value, expansion, builder, property); return; } @@ -1093,7 +1111,7 @@ function unparsedComponentValues( */ function parseUnparsedAxis( tokenOrValues: TokenOrValue[], - [startProperty, endProperty]: readonly [string, string], + { edges: [startProperty, endProperty], axis }: (typeof axisExpansion)[string], builder: StylesheetBuilder, property: string, ) { @@ -1101,17 +1119,23 @@ function parseUnparsedAxis( if (components.length === 1) { /** - * One component feeds both edges. descriptorProperties carries the pair so - * that light-dark(), which writes to the builder from inside parseUnparsed - * rather than through the returned value, reaches both edges of the single - * extra rule it opens. + * One component reaches both edges with the same value, so it lands on the + * axis property where React Native has one and on the pair where it does + * not — the choice the parsed path makes for the same declaration. + * descriptorProperties carries the whole target set so that light-dark(), + * which writes to the builder from inside parseUnparsed rather than + * through the returned value, reaches all of the single extra rule it + * opens. */ - builder.descriptorProperties = [startProperty, endProperty]; + const targets = axis === undefined ? [startProperty, endProperty] : [axis]; + + builder.descriptorProperties = targets; const value = parseUnparsed(components[0], builder, property); - builder.addDescriptor(startProperty, value); - builder.addDescriptor(endProperty, value); + for (const target of targets) { + builder.addDescriptor(target, value); + } return; } From 5f94f9fa600f7ebe077e2b47a19ef46aa1d4d8b7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 10:35:00 +0300 Subject: [PATCH 7/7] fix(compiler): give border-block-color one key set, and carry it into light-dark() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../compiler/logical-borders.test.ts | 65 +++-- src/__tests__/native/logical-borders.test.tsx | 222 ++++++++++++++++-- .../vendor/tailwind/borders.test.tsx | 3 +- src/compiler/declarations.ts | 199 +++++++++++----- src/native/styles/shorthands/border.ts | 22 +- 5 files changed, 409 insertions(+), 102 deletions(-) diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 401e1ace..c18cbab8 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -322,9 +322,9 @@ describe("logical border shorthands with two values (unparsed path)", () => { * why that field carries a list. * * The native suite covers the rendered result; these assert the emitted rules, - * so the compiler plane can see a regression here on its own. The two axes are - * both here because they name different targets: the inline axis opens its - * dark rule over the edge pair, the block axis over the single axis property. + * so the compiler plane can see a regression here on its own. Both axes are + * here because they name different edge pairs — the inline axis the RTL-aware + * start/end props, the block axis the physical top/bottom ones. */ describe("the axis expansion under light-dark() (unparsed path)", () => { test("border-inline-color reaches both edges in each scheme", () => { @@ -351,18 +351,24 @@ describe("the axis expansion under light-dark() (unparsed path)", () => { ]); }); - test("border-block-color reaches the axis property in each scheme", () => { + test("border-block-color reaches both edges in each scheme", () => { expect( getRule("border-block-color: light-dark(var(--a), var(--b));").rule, ).toStrictEqual([ { s: [1, 1], - d: [[[{}, "var", "a", 1], "borderBlockColor", 1]], + d: [ + [[{}, "var", "a", 1], "borderTopColor", 1], + [[{}, "var", "a", 1], "borderBottomColor", 1], + ], dv: 1, }, { s: [1, 1], - d: [[[{}, "var", "b", 1], "borderBlockColor", 1]], + d: [ + [[{}, "var", "b", 1], "borderTopColor", 1], + [[{}, "var", "b", 1], "borderBottomColor", 1], + ], dv: 1, m: [["=", "prefers-color-scheme", "dark"]], }, @@ -425,13 +431,15 @@ describe("logical border three-part shorthands via var() (unparsed path)", () => /** * The block axis, which React Native supports differently from the inline one. * - * The three block COLOURS are real props — `borderBlockColor`, - * `borderBlockStartColor` and `borderBlockEndColor` are in - * `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in `ViewStyle` — - * so they are emitted as-is. The block WIDTHS appear only in - * `BaseViewConfig.ios.js`, so emitting them paints on iOS and nowhere else; - * they map to the physical edges instead. `direction` never flips the block - * axis, so block-start is the top edge on every platform. + * The per-EDGE block colours are real props — `borderBlockStartColor` and + * `borderBlockEndColor` are in `ReactNativeStyleAttributes`, in both + * `BaseViewConfig`s and in `ViewStyle` — and each is the highest-precedence + * name for its edge on both platforms, so they are emitted as-is. The block + * WIDTHS appear only in `BaseViewConfig.ios.js`, so emitting them paints on + * iOS and nowhere else, and the axis-wide `borderBlockColor` is real but + * ordered against `borderTopColor` oppositely by the two platforms; both map + * to the physical edges instead. `direction` never flips the block axis, so + * block-start is the top edge on every platform. */ describe("block border widths", () => { test("border-block-start-width", () => { @@ -463,12 +471,17 @@ describe("block border colors", () => { test.each([ ["border-block-start-color", "borderBlockStartColor"], ["border-block-end-color", "borderBlockEndColor"], - ["border-block-color", "borderBlockColor"], ])("%s keeps React Native's own prop", (property, key) => { expect(getRule(`${property}: red;`).rule).toStrictEqual([ { s: [1, 1], d: [{ [key]: "#f00" }] }, ]); }); + + test("border-block-color reaches the edge pair", () => { + expect(getRule("border-block-color: red;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopColor: "#f00", borderBottomColor: "#f00" }] }, + ]); + }); }); describe("block border styles", () => { @@ -512,7 +525,8 @@ describe("block border shorthands", () => { s: [1, 1], d: [ { - borderBlockColor: "#f00", + borderTopColor: "#f00", + borderBottomColor: "#f00", borderTopWidth: 2, borderBottomWidth: 2, }, @@ -567,20 +581,21 @@ describe("block borders via var() (unparsed path)", () => { }); /** - * The block colours are where React Native's support stops being uniform, so - * the unparsed path has to make the parsed path's choice rather than a - * consistent-looking one of its own: one value collapses onto the axis - * property `borderBlockColor`, two split across the physical edges. Picking - * `borderBlockStartColor` / `borderBlockEndColor` for either arity would be - * a THIRD key set, disjoint from both — see the cascade test below for what - * that costs. `border-block-width` needs no such split because - * `borderBlockWidth` is in `BaseViewConfig.ios.js` alone. + * One value reaches both edges, which is the choice the parsed path makes + * for the same declaration. A property has to land on ONE key set whatever + * its arity: the style object is flat, so a second key set would survive the + * cascade beside this one rather than replacing it, and the two platforms + * order `borderBlockColor` against `borderTopColor` oppositely — see the + * native suite's cascade matrix for what that costs. */ - test("border-block-color takes the axis property for one value", () => { + test("border-block-color reaches both edges for one value", () => { expect(getRule("border-block-color: var(--v);").rule).toStrictEqual([ { s: [1, 1], - d: [[[{}, "var", "v", 1], "borderBlockColor", 1]], + d: [ + [[{}, "var", "v", 1], "borderTopColor", 1], + [[{}, "var", "v", 1], "borderBottomColor", 1], + ], dv: 1, }, ]); diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx index 4426026e..ce205f46 100644 --- a/src/__tests__/native/logical-borders.test.tsx +++ b/src/__tests__/native/logical-borders.test.tsx @@ -64,6 +64,22 @@ const styleKeys = (id: string): string[] => { return style === undefined ? [] : Object.keys(style).sort(); }; +/** + * The distinct values a rendered component's style holds, sorted. + * + * A key-set assertion cannot see a scheme whose colour never applied — the + * keys are identical either way — so any test that switches scheme asserts + * over this as well. + */ +const styleValues = (id: string): unknown[] => { + const { style } = screen.getByTestId(id).props as { style?: object }; + return style === undefined + ? [] + : [...new Set(Object.values(style))].sort((first, second) => + String(first).localeCompare(String(second)), + ); +}; + /** * React Native has no `borderInline*` style attribute of any kind, so a key * shaped like one is inert whatever value it carries. @@ -663,19 +679,22 @@ describe("the block axis reaching the component", () => { /** * The block axis is the inline axis's twin and React Native supports it * differently, which is why it needs its own expectations rather than a - * mirrored copy of the ones above. The three block COLOURS are real props — - * `borderBlockColor`, `borderBlockStartColor` and `borderBlockEndColor` are - * in `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in - * `ViewStyle` — so they are kept. The block WIDTHS are in - * `BaseViewConfig.ios.js` only, so they map to the physical edges every - * platform reads. `direction` never flips the block axis, so block-start is - * the top edge and block-end the bottom one on every platform. + * mirrored copy of the ones above. The per-EDGE block colours are real props + * — `borderBlockStartColor` and `borderBlockEndColor` are in + * `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in `ViewStyle`, + * and each outranks every other name for its edge on both platforms — so + * they are kept. The block WIDTHS are in `BaseViewConfig.ios.js` only, and + * the axis-wide `borderBlockColor` is read in the opposite order by the two + * platforms, so both map to the physical edges every platform agrees on. + * `direction` never flips the block axis, so block-start is the top edge and + * block-end the bottom one on every platform. */ test.each([ [ "border-block", { - borderBlockColor: "#26e", + borderTopColor: "#26e", + borderBottomColor: "#26e", borderTopWidth: 6, borderBottomWidth: 6, }, @@ -733,7 +752,8 @@ describe("the block axis reaching the component", () => { { borderTopWidth: 1, borderBottomWidth: 1, - borderBlockColor: "red", + borderTopColor: "red", + borderBottomColor: "red", }, ], ["border-block-start", { borderTopWidth: 1, borderBlockStartColor: "red" }], @@ -833,14 +853,31 @@ describe("the block axis reaching the component", () => { * * Two declarations of the same property have to resolve as one — later wins. * They only can if they land on the same keys: React Native's style object - * is flat, so two DISJOINT key sets both survive, and its per-edge - * properties outrank the axis one. A route that emitted + * is flat, so two DISJOINT key sets both survive, and the platforms then + * disagree about which of them paints. A route that emitted * `borderBlockStartColor` / `borderBlockEndColor` here would leave the var() - * painting both edges while the `green` written after it sat unused under - * `borderBlockColor`. + * painting both edges while the `green` written after it sat unused. + * + * The matrix is the full cross product of the two arities and not its + * diagonal. Two routes that agree at each arity separately still disagree + * across arities, and only an off-diagonal cell can see it. */ test.each([ - ["var(--color)", "green", { borderBlockColor: "#008000" }], + [ + "var(--color)", + "green", + { borderTopColor: "#008000", borderBottomColor: "#008000" }, + ], + [ + "var(--color) var(--other-color)", + "green", + { borderTopColor: "#008000", borderBottomColor: "#008000" }, + ], + [ + "var(--color)", + "green lime", + { borderTopColor: "#008000", borderBottomColor: "#0f0" }, + ], [ "var(--color) var(--other-color)", "green lime", @@ -859,6 +896,42 @@ describe("the block axis reaching the component", () => { expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); }, ); + + /** + * The same requirement stated over the whole property rather than over one + * pair of declarations, because arity is not the only thing that moved the + * target. + * + * `parseBorderColor` chose between the axis property and the edge pair by + * comparing the two parsed components with `===`, which is a REFERENCE + * comparison: `red red` collapses because both components parse to the same + * interned string, while `currentcolor` splits because `parseColor` builds a + * fresh `[{}, "var", "__rn-css-color"]` array per call. Two declarations + * whose CSS says the same thing about both edges therefore landed on + * disjoint keys depending on how their value happened to be represented, + * which no author could predict and no cascade could reconcile. + */ + test.each([ + ["red", "parsed, one component"], + ["red blue", "parsed, two components"], + ["red red", "parsed, two equal components"], + ["currentcolor", "parsed, one non-primitive component"], + ["var(--color)", "unparsed, one component"], + ["var(--color) var(--other-color)", "unparsed, two components"], + ["var(--color) var(--color)", "unparsed, two equal components"], + ])("border-block-color: %s (%s) reaches the block edge pair", (value) => { + registerCSS(` + .my-class { color: black; border-block-color: ${value}; } + ${twiceDefined} + `); + + render(); + expect(styleKeys(testID)).toStrictEqual([ + "borderBottomColor", + "borderTopColor", + "color", + ]); + }); }); /** @@ -905,6 +978,61 @@ describe("no logical border property reaches a key React Native lacks", () => { return "var(--shorthand)"; }; + /** + * The members that can carry a colour, which is every member that is not a + * width or a style — the six shorthands and the six `-color` longhands. + * Derived from the census rather than listed, so a member added to one is + * added to the other. + */ + const COLOUR_BEARING = FAMILY.filter( + (property) => !property.endsWith("-width") && !property.endsWith("-style"), + ); + + /** + * The two halves as written in CSS, and as the compiler emits them. Both + * routes emit the compressed form — a custom property's value is compressed + * where it is defined, so a var() carries the same text a literal does by + * the time it reaches a style object. + */ + const LIGHT_SOURCE = "#2266ee"; + const DARK_SOURCE = "#66aaff"; + const LIGHT_COLOUR = "#26e"; + const DARK_COLOUR = "#6af"; + + const lightDarkFor = (property: string): string => + property.endsWith("-color") + ? `light-dark(${LIGHT_SOURCE}, ${DARK_SOURCE})` + : `6px dashed light-dark(${LIGHT_SOURCE}, ${DARK_SOURCE})`; + + /** + * The same value with the two halves behind variables, which is a different + * ROUTE rather than a different spelling: a var() keeps the declaration off + * the parsed path, so the light-dark() is reduced by the unparsed reducer + * instead of by `parseColor`. Both reducers open the dark rule the same way, + * so both have to address it the same way. + * + * The census is the `-color` members only, and the three-part shorthands are + * left out deliberately rather than overlooked. A var() inside one of those + * compiles to a single runtime call carrying width, style and colour + * together, and the dark rule the reducer opens holds the colour ALONE — so + * whichever of the two rules lands second wins the whole set, and no choice + * of target for the dark rule can fix that. 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 (`border`, + * `border-top`, `box-shadow` and `text-shadow` all miss the same way today) + * and is not this family's to change. + */ + const LIGHT_DARK_VAR_COVERED = COLOUR_BEARING.filter((property) => + property.endsWith("-color"), + ); + + const lightDarkVarFor = (): string => "light-dark(var(--light), var(--dark))"; + + const lightDarkDefinitions = ` + :root { --light: ${LIGHT_SOURCE}; --dark: ${DARK_SOURCE}; } + .redefine { --light: #000; --dark: #000; } + `; + /** * Generating the census trades a drift failure for a vacuity one: a family * that stopped being generated makes every case below pass over nothing. @@ -971,4 +1099,70 @@ describe("no logical border property reaches a key React Native lacks", () => { styleKeys(testID).filter((key) => !isRealStyleKey(key)), ).toStrictEqual([]); }); + + /** + * The same guard over the scheme the two spellings above cannot reach. + * + * `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 carried + * by `descriptorProperties`. Every expectation in this file that renders + * only the light scheme is therefore blind to where that second rule landed, + * and a dark rule addressed to a name React Native drops paints nothing with + * no error, no warning and no fallback. + * + * Both halves are asserted because a dead key is only one of the two ways + * the dark rule can miss. Landing on a REAL key that the light scheme did not + * use is the other: the two schemes then disagree about which key holds the + * colour, so the style object keeps both and the value that paints is + * whichever one the platform ranks higher, not the one the scheme selected. + */ + test.each(COLOUR_BEARING)( + "%s (light-dark) emits only real style keys in both schemes", + (property) => { + registerCSS(`.my-class { ${property}: ${lightDarkFor(property)}; }`); + + render(); + const light = styleKeys(testID); + + expect(light.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(styleValues(testID)).toContain(LIGHT_COLOUR); + + act(() => { + colorScheme.set("dark"); + }); + + const dark = styleKeys(testID); + + expect(dark.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(dark).toStrictEqual(light); + + // The half a key-set assertion cannot see: a dark rule that landed on a + // real key can still be the one that never applied. + expect(styleValues(testID)).toContain(DARK_COLOUR); + expect(styleValues(testID)).not.toContain(LIGHT_COLOUR); + }, + ); + + test.each(LIGHT_DARK_VAR_COVERED)( + "%s (light-dark over var) carries its scheme's colour to every key it sets", + (property) => { + registerCSS(` + .my-class { ${property}: ${lightDarkVarFor()}; } + ${lightDarkDefinitions} + `); + + render(); + const light = styleKeys(testID); + + expect(light.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(styleValues(testID)).toStrictEqual([LIGHT_COLOUR]); + + act(() => { + colorScheme.set("dark"); + }); + + expect(styleKeys(testID)).toStrictEqual(light); + expect(styleValues(testID)).toStrictEqual([DARK_COLOUR]); + }, + ); }); diff --git a/src/__tests__/vendor/tailwind/borders.test.tsx b/src/__tests__/vendor/tailwind/borders.test.tsx index 79856304..dc12ba23 100644 --- a/src/__tests__/vendor/tailwind/borders.test.tsx +++ b/src/__tests__/vendor/tailwind/borders.test.tsx @@ -174,7 +174,8 @@ describe("Border - Border Color", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - borderBlockColor: "#fff", + borderTopColor: "#fff", + borderBottomColor: "#fff", }, }, }); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 3628e73a..29940b97 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -95,33 +95,66 @@ const unsupportedEdgeStyles = new Set([ "border-inline-end-style", ]); +/** + * The two properties the block axis's colour reaches, everywhere it is set. + * + * Not `borderBlockColor`, even for the one-value form React Native has that + * axis-wide property for. A property has to land on ONE key set: the style + * object is flat, so two disjoint sets both survive the cascade and a later + * declaration of the same property sits beside the earlier one instead of + * replacing it. + * + * Which of the two then paints is not even stable across platforms. Android + * resolves the top edge `BLOCK_START ?: TOP ?: BLOCK ?: VERTICAL ?: ALL` + * (`ReactAndroid/.../uimanager/style/BorderColors.kt`), so `borderTopColor` + * outranks `borderBlockColor`; iOS assigns `borderTopColor = _borderBlockColor` + * whenever the axis property is set (`React/Views/RCTView.m`, + * `borderColorsWithTraitCollection`), which is the opposite order. A rule that + * emitted both would paint the earlier declaration on one platform and the + * later one on the other. + * + * Nothing is lost by preferring the pair. It is the set both platforms agree + * on once the axis property is out of play, and `direction` never flips the + * block axis on either — Android keeps `BLOCK_START`/`TOP` for the top edge in + * its RTL branch too, and iOS handles the block colours outside its `isRTL` + * swap — so physical top and bottom stay the block edges under RTL. + * + * The per-EDGE block colours are untouched by this: `borderBlockStartColor` + * and `borderBlockEndColor` are the highest-precedence name for their edge on + * both platforms, so they agree already. + */ +const blockColorEdges = [ + "border-top-color", + "border-bottom-color", +] as const satisfies readonly [string, string]; + +/** + * The inline axis's twin, which needs no such argument — React Native has no + * `borderInlineColor` at all, so the RTL-aware pair is the only target there + * has ever been. + */ +const inlineColorEdges = [ + "border-start-color", + "border-end-color", +] as const satisfies readonly [string, string]; + /** * Where a two-edge logical shorthand lands once a var() has kept it off the * parsed path, at each arity the grammar `{1,2}` allows. * - * `edges` is the [start, end] pair two components feed, one each. One - * component normally feeds both, since both edges then carry the same value. - * - * `axis` is the exception, and it exists because React Native's support is - * uneven: `borderBlockColor` is the family's only axis-wide property — there - * is no `borderInlineColor`, and `borderBlockWidth` is in - * `BaseViewConfig.ios.js` alone — and the parsed path collapses onto it - * whenever both block edges agree. The unparsed path has to make the same - * choice, because the two key sets are DISJOINT and so both survive the - * cascade: emit the pair here and a `borderBlockColor` declared later sits - * beside it rather than replacing it, React Native's per-edge properties win, - * and the later declaration silently loses. + * `edges` is the [start, end] pair two components feed, one each; one + * component feeds both, since both edges then carry the same value. Every + * member has exactly one such pair — the parsed path writes the same two + * properties for the same declaration, which is what makes the two routes one + * behaviour. */ const axisExpansion: Record< string, - { readonly edges: readonly [string, string]; readonly axis?: string } + { readonly edges: readonly [string, string] } > = { - "border-block-color": { - edges: ["border-top-color", "border-bottom-color"], - axis: "border-block-color", - }, + "border-block-color": { edges: blockColorEdges }, "border-block-width": { edges: ["border-top-width", "border-bottom-width"] }, - "border-inline-color": { edges: ["border-start-color", "border-end-color"] }, + "border-inline-color": { edges: inlineColorEdges }, "border-inline-width": { edges: ["border-start-width", "border-end-width"] }, }; @@ -361,17 +394,24 @@ export function parseDeclaration( function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; - - builder.descriptorProperties = [declaration.property]; + const renamed = + propertyRename[declaration.property] ?? declaration.property; + + // The default target set, which holds for every parser that writes to the + // declaration's own property. It is the RENAMED name because that is what + // such a parser writes: `light-dark()` hands its dark half to the builder + // as a second rule addressed to `descriptorProperties` rather than + // returning it, so seeding the raw CSS name would put the dark half on a + // property React Native never renamed and never reads. A parser that + // expands onto other properties instead names them itself — see + // `parseColorFor`. + builder.descriptorProperties = [renamed]; builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); if (value !== undefined) { - builder.addDescriptor( - propertyRename[declaration.property] ?? declaration.property, - value, - ); + builder.addDescriptor(renamed, value); } } else { builder.addWarning("property", declaration.property); @@ -437,6 +477,30 @@ function parseBorderRadius( }); } +/** + * Parse a colour that the caller will write to `targets`. + * + * `light-dark()` is the reason this exists. It 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` seeds that with the declaration's own property, which is + * right only for a parser that writes there too; a parser that EXPANDS onto + * other properties has to name them, or the light half lands on the edges and + * the dark half lands on the shorthand's own name, where React Native's view + * config drops it without a word. + * + * `parseUnparsedAxis` does the same thing for the unparsed path, which is what + * keeps the two routes one behaviour. + */ +function parseColorFor( + targets: readonly string[], + cssColor: CssColor, + builder: StylesheetBuilder, +) { + builder.descriptorProperties = targets; + return parseColor(cssColor, builder); +} + function parseBorderColor( declaration: DeclarationType< "border-color" | "border-block-color" | "border-inline-color" @@ -451,18 +515,32 @@ function parseBorderColor( "border-right-color": parseColor(declaration.value.right, builder), }); } else { - const start = parseColor(declaration.value.start, builder); - const end = parseColor(declaration.value.end, builder); - - if (declaration.property === "border-inline-color") { - builder.addDescriptor("border-start-color", start); - builder.addDescriptor("border-end-color", end); - } else if (start === end) { - builder.addDescriptor(declaration.property, start); - } else { - builder.addDescriptor("border-top-color", start); - builder.addDescriptor("border-bottom-color", end); - } + // Both axes land on their edge pair at every arity — see `axisExpansion` + // for why one key set per property is a correctness requirement rather + // than a tidiness one. Collapsing the block axis onto `borderBlockColor` + // when both edges agreed made the target depend on how the value happened + // to be represented: `red red` collapsed because both components parse to + // the same interned string, `currentcolor` did not because `parseColor` + // builds a fresh `var()` array per call, and the two therefore reached + // disjoint keys while saying the same thing about both edges. + // + // Each component is parsed against its OWN edge rather than both, so that + // a light-dark() start and a light-dark() end each open a dark rule over + // the edge they feed. The one-value form arrives here already expanded to + // two equal components, so it opens one dark rule per edge and both hold. + const [startProperty, endProperty] = + declaration.property === "border-inline-color" + ? inlineColorEdges + : blockColorEdges; + + builder.addDescriptor( + startProperty, + parseColorFor([startProperty], declaration.value.start, builder), + ); + builder.addDescriptor( + endProperty, + parseColorFor([endProperty], declaration.value.end, builder), + ); } } @@ -509,9 +587,14 @@ function parseBorderBlock( { value }: DeclarationType<"border-block">, builder: StylesheetBuilder, ) { + // The physical edges, for the reason `axisExpansion` gives: the shorthand + // and `border-block-color` set the same two edges, so they have to reach the + // same keys or a later one of them will not override an earlier one. + const color = parseColorFor(blockColorEdges, value.color, builder); const width = parseBorderSideWidth(value.width, builder); - builder.addDescriptor("border-block-color", parseColor(value.color, builder)); + builder.addDescriptor(blockColorEdges[0], color); + builder.addDescriptor(blockColorEdges[1], color); builder.addDescriptor("border-top-width", width); builder.addDescriptor("border-bottom-width", width); dropUnsupportedEdgeStyle( @@ -527,7 +610,7 @@ function parseBorderBlockStart( ) { builder.addDescriptor( "border-block-start-color", - parseColor(value.color, builder), + parseColorFor(["border-block-start-color"], value.color, builder), ); builder.addDescriptor( "border-top-width", @@ -546,7 +629,7 @@ function parseBorderBlockEnd( ) { builder.addDescriptor( "border-block-end-color", - parseColor(value.color, builder), + parseColorFor(["border-block-end-color"], value.color, builder), ); builder.addDescriptor( "border-bottom-width", @@ -563,11 +646,11 @@ function parseBorderInline( { value }: DeclarationType<"border-inline">, builder: StylesheetBuilder, ) { - const color = parseColor(value.color, builder); + const color = parseColorFor(inlineColorEdges, value.color, builder); const width = parseBorderSideWidth(value.width, builder); - builder.addDescriptor("border-start-color", color); - builder.addDescriptor("border-end-color", color); + builder.addDescriptor(inlineColorEdges[0], color); + builder.addDescriptor(inlineColorEdges[1], color); builder.addDescriptor("border-start-width", width); builder.addDescriptor("border-end-width", width); dropUnsupportedEdgeStyle( @@ -581,7 +664,10 @@ function parseBorderInlineStart( { value }: DeclarationType<"border-inline-start">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-start-color", parseColor(value.color, builder)); + builder.addDescriptor( + "border-start-color", + parseColorFor(["border-start-color"], value.color, builder), + ); builder.addDescriptor( "border-start-width", parseBorderSideWidth(value.width, builder), @@ -597,7 +683,10 @@ function parseBorderInlineEnd( { value }: DeclarationType<"border-inline-end">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-end-color", parseColor(value.color, builder)); + builder.addDescriptor( + "border-end-color", + parseColorFor(["border-end-color"], value.color, builder), + ); builder.addDescriptor( "border-end-width", parseBorderSideWidth(value.width, builder), @@ -1045,6 +1134,12 @@ export function parseUnparsedDeclaration( property = rename; } + // Keyed on the name as WRITTEN, and read after the rename above, which holds + // only because no `axisExpansion` member is renamed. Give one of them a + // `propertyRename` entry and its expansion stops firing here silently — the + // lookup misses, the declaration falls through to the single-descriptor path + // below, and the axis quietly reaches one property instead of two. A member + // that ever needs both has to be keyed on its renamed name. const expansion = axisExpansion[property]; if (expansion) { parseUnparsedAxis(declaration.value.value, expansion, builder, property); @@ -1111,7 +1206,7 @@ function unparsedComponentValues( */ function parseUnparsedAxis( tokenOrValues: TokenOrValue[], - { edges: [startProperty, endProperty], axis }: (typeof axisExpansion)[string], + { edges: [startProperty, endProperty] }: (typeof axisExpansion)[string], builder: StylesheetBuilder, property: string, ) { @@ -1119,15 +1214,13 @@ function parseUnparsedAxis( if (components.length === 1) { /** - * One component reaches both edges with the same value, so it lands on the - * axis property where React Native has one and on the pair where it does - * not — the choice the parsed path makes for the same declaration. - * descriptorProperties carries the whole target set so that light-dark(), - * which writes to the builder from inside parseUnparsed rather than - * through the returned value, reaches all of the single extra rule it - * opens. + * One component reaches both edges with the same value — the choice the + * parsed path makes for the same declaration. descriptorProperties carries + * the whole target set so that light-dark(), which writes to the builder + * from inside parseUnparsed rather than through the returned value, + * reaches all of the single extra rule it opens. */ - const targets = axis === undefined ? [startProperty, endProperty] : [axis]; + const targets = [startProperty, endProperty]; builder.descriptorProperties = targets; diff --git a/src/native/styles/shorthands/border.ts b/src/native/styles/shorthands/border.ts index 48d9220e..c1601d09 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -28,14 +28,18 @@ const matchBorder = shorthandHandler(mappings, [], "object"); * * The two axes reach different props because React Native supports them * differently. The inline axis has no native prop of its own, so it maps onto - * the RTL-aware `borderStart*` / `borderEnd*` pair. The block axis has native - * COLOURS — `borderBlockColor`, `borderBlockStartColor` and - * `borderBlockEndColor` are in `ReactNativeStyleAttributes`, both - * `BaseViewConfig`s and `ViewStyle` — but its WIDTHS exist only in - * `BaseViewConfig.ios.js`, so a `borderBlockWidth` paints on iOS and nowhere - * else. Block widths therefore map to the physical edges, which every - * platform reads; block start is the top edge and block end the bottom one, - * on every platform, because `direction` never flips the block axis. + * the RTL-aware `borderStart*` / `borderEnd*` pair. The block axis maps onto + * the physical edges: its WIDTHS exist only in `BaseViewConfig.ios.js`, so a + * `borderBlockWidth` paints on iOS and nowhere else, and its axis-wide COLOUR + * is real on both platforms but ordered against `borderTopColor` oppositely by + * each — Android has `borderTopColor` outrank it, iOS the reverse — so a + * `borderBlock` that emitted `borderBlockColor` could not be overridden by a + * `border-block-color` declared after it without the two platforms + * disagreeing about which won. `src/compiler/declarations.ts`'s + * `axisExpansion` carries the platform reads; the compiler makes the same + * choice there, which is what keeps the two routes one behaviour. Block start + * is the top edge and block end the bottom one on every platform, because + * `direction` never flips the block axis. * * `borderStyle` is absent from every entry deliberately. React Native has no * per-edge border style at any layer: `BaseViewConfig.{android,ios}.js` lists @@ -61,7 +65,7 @@ const axisTargets = { }, borderBlock: { borderWidth: ["borderTopWidth", "borderBottomWidth"], - borderColor: ["borderBlockColor"], + borderColor: ["borderTopColor", "borderBottomColor"], }, borderBlockStart: { borderWidth: ["borderTopWidth"],