diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 87db4f74..c18cbab8 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -116,3 +116,545 @@ 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 + // 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, + }, + ]); + }); + + 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)"] }, + }); + }); + + /** + * 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. 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", () => { + 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 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], "borderTopColor", 1], + [[{}, "var", "a", 1], "borderBottomColor", 1], + ], + dv: 1, + }, + { + s: [1, 1], + d: [ + [[{}, "var", "b", 1], "borderTopColor", 1], + [[{}, "var", "b", 1], "borderBottomColor", 1], + ], + dv: 1, + m: [["=", "prefers-color-scheme", "dark"]], + }, + ]); + }); +}); + +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).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 at compile time", + (property) => { + expect(getRule(`${property}: 2px solid red;`).warnings).toStrictEqual({}); + }, + ); +}); + +/** + * The block axis, which React Native supports differently from the inline one. + * + * 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", () => { + 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"], + ])("%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", () => { + // 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: [ + { + borderTopColor: "#f00", + borderBottomColor: "#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("border-block-width expands to both edges", () => { + expect(getRule("border-block-width: var(--v);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "v", 1], "borderTopWidth", 1], + [[{}, "var", "v", 1], "borderBottomWidth", 1], + ], + dv: 1, + }, + ]); + }); + + /** + * 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 reaches both edges for one value", () => { + expect(getRule("border-block-color: var(--v);").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + [[{}, "var", "v", 1], "borderTopColor", 1], + [[{}, "var", "v", 1], "borderBottomColor", 1], + ], + dv: 1, + }, + ]); + }); + + test.each([ + ["border-block-width", "borderTopWidth", "borderBottomWidth"], + ["border-block-color", "borderTopColor", "borderBottomColor"], + ])("%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 new file mode 100644 index 00000000..ce205f46 --- /dev/null +++ b/src/__tests__/native/logical-borders.test.tsx @@ -0,0 +1,1168 @@ +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"; +import { colorScheme } from "react-native-css/runtime"; + +import { dimensions } from "../../native/reactivity"; + +const children = undefined; + +/** + * Every border style key React Native declares. + * + * 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. + */ +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. + * + * `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(); +}; + +/** + * 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. + */ +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 + * 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 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, + }); + }); +}); + +/** + * 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()", () => { + 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 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", + ); + }, + ); + + /** + * 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", + ); + }); + + /** + * 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", () => { + /** + * 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([]); + }, + ); +}); + +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 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", + { + borderTopColor: "#26e", + borderBottomColor: "#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, + borderTopColor: "red", + borderBottomColor: "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 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 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. + * + * 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", + { 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", + { 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); + }, + ); + + /** + * 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", + ]); + }); +}); + +/** + * 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. + * + * 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]`. */ + 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)"; + }; + + /** + * 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. + * + * 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([]); + }); + + /** + * 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 03a37a08..dc12ba23 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, }, }, }); @@ -170,7 +174,8 @@ describe("Border - Border Color", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - borderBlockColor: "#fff", + borderTopColor: "#fff", + borderBottomColor: "#fff", }, }, }); @@ -238,6 +243,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 17beba0a..29940b97 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,18 +81,97 @@ 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. -const unsupportedInlineStyles = new Set([ +// 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 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", ]); +/** + * 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 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] } +> = { + "border-block-color": { edges: blockColorEdges }, + "border-block-width": { edges: ["border-top-width", "border-bottom-width"] }, + "border-inline-color": { edges: inlineColorEdges }, + "border-inline-width": { edges: ["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 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", "box-shadow", "line-height", "rotate", @@ -117,12 +206,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, @@ -137,13 +227,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, @@ -304,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.descriptorProperty = 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); @@ -380,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" @@ -394,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), + ); } } @@ -452,14 +587,20 @@ function parseBorderBlock( { value }: DeclarationType<"border-block">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-block-color", parseColor(value.color, builder)); - builder.addDescriptor( - "border-block-width", - parseBorderSideWidth(value.width, builder), - ); - builder.addDescriptor( - "border-block-style", + // 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(blockColorEdges[0], color); + builder.addDescriptor(blockColorEdges[1], color); + builder.addDescriptor("border-top-width", width); + builder.addDescriptor("border-bottom-width", width); + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), + builder, + "border-block-style", ); } @@ -469,12 +610,17 @@ function parseBorderBlockStart( ) { builder.addDescriptor( "border-block-start-color", - parseColor(value.color, builder), + parseColorFor(["border-block-start-color"], 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( @@ -483,26 +629,31 @@ function parseBorderBlockEnd( ) { builder.addDescriptor( "border-block-end-color", - parseColor(value.color, builder), + parseColorFor(["border-block-end-color"], 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( { 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); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-style", @@ -513,12 +664,15 @@ 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), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-start-style", @@ -529,12 +683,15 @@ 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), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-end-style", @@ -555,8 +712,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. + */ +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" @@ -564,26 +734,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, @@ -940,8 +1116,11 @@ export function parseUnparsedDeclaration( return; } - if (unsupportedInlineStyles.has(property)) { - builder.addWarning("property", property); + // 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 (unsupportedEdgeStyles.has(property)) { return; } @@ -955,10 +1134,22 @@ 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); + 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); @@ -989,6 +1180,76 @@ 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 a two-edge logical-axis shorthand that a var() kept unparsed, the way + * parseBorderInline* / parseBorderBlock* expand the parsed form. + */ +function parseUnparsedAxis( + tokenOrValues: TokenOrValue[], + { edges: [startProperty, endProperty] }: (typeof axisExpansion)[string], + builder: StylesheetBuilder, + property: string, +) { + const components = unparsedComponentValues(tokenOrValues); + + if (components.length === 1) { + /** + * 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 = [startProperty, endProperty]; + + builder.descriptorProperties = targets; + + const value = parseUnparsed(components[0], builder, property); + + for (const target of targets) { + builder.addDescriptor(target, 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, @@ -2188,30 +2449,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/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( diff --git a/src/native/styles/shorthands/border.ts b/src/native/styles/shorthands/border.ts index cefdb005..c1601d09 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -1,10 +1,123 @@ +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 six logical-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 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 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 + * `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 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 axisTargets = { + borderInline: { + borderWidth: ["borderStartWidth", "borderEndWidth"], + borderColor: ["borderStartColor", "borderEndColor"], + }, + borderInlineStart: { + borderWidth: ["borderStartWidth"], + borderColor: ["borderStartColor"], + }, + borderInlineEnd: { + borderWidth: ["borderEndWidth"], + borderColor: ["borderEndColor"], + }, + borderBlock: { + borderWidth: ["borderTopWidth", "borderBottomWidth"], + borderColor: ["borderTopColor", "borderBottomColor"], + }, + borderBlockStart: { + borderWidth: ["borderTopWidth"], + borderColor: ["borderBlockStartColor"], + }, + borderBlockEnd: { + borderWidth: ["borderBottomWidth"], + borderColor: ["borderBlockEndColor"], + }, +} as const; + +type AxisTargets = (typeof axisTargets)[keyof typeof axisTargets]; + +/** + * 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 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 axisBorderHandler(targets: AxisTargets): 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 = 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);