diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts
index 00a2c709..16739b10 100644
--- a/src/__tests__/compiler/logical-borders.test.ts
+++ b/src/__tests__/compiler/logical-borders.test.ts
@@ -63,29 +63,6 @@ describe("logical border widths", () => {
});
describe("logical 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("border-inline-start", () => {
expect(getRule("border-inline-start: 2px solid red;").rule).toStrictEqual([
{ s: [1, 1], d: [{ borderStartColor: "#f00", borderStartWidth: 2 }] },
@@ -116,29 +93,6 @@ describe("logical border shorthands", () => {
});
describe("logical border styles", () => {
- test.each([
- "border-block-style",
- "border-block-start-style",
- "border-block-end-style",
- ])(
- "%s solid uses the native default without emitting an unsupported prop",
- (property) => {
- const { rule, warnings } = getRule(`${property}: solid;`);
- expect(rule).toBeUndefined();
- expect(warnings).toEqual({});
- },
- );
-
- test("a dashed block style reports the unsupported side styles", () => {
- const { rule, warnings } = getRule("border-block-style: dashed;");
- expect(rule).toBeUndefined();
- expect(warnings).toEqual({
- values: {
- "border-block-start-style": ["dashed"],
- "border-block-end-style": ["dashed"],
- },
- });
- });
// React Native only has a uniform borderStyle. solid matches the native
// default and is dropped silently, anything else drops with a warning
test("border-inline-start-style: solid is dropped without warning", () => {
@@ -162,3 +116,379 @@ 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("a two-edge logical property via var() compiles to a runtime call", () => {
+ // A var() can substitute any number of values, so the runtime handler reads the arity once it resolves, as it does for border
+ test.each([
+ [
+ "border-inline-color: hsl(var(--primary));",
+ "borderInlineColor",
+ [{}, "hsl", [{}, "var", "primary", 1]],
+ ],
+ [
+ "border-inline-color: var(--c);",
+ "borderInlineColor",
+ [{}, "var", "c", 1],
+ ],
+ [
+ "border-inline-color: var(--c, red);",
+ "borderInlineColor",
+ [{}, "var", ["c", "red"], 1],
+ ],
+ [
+ "border-inline-color: var(--a) var(--b);",
+ "borderInlineColor",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ ],
+ ],
+ [
+ "border-inline-color: red var(--b);",
+ "borderInlineColor",
+ ["red", [{}, "var", "b", 1]],
+ ],
+ [
+ "border-inline-width: var(--w);",
+ "borderInlineWidth",
+ [{}, "var", "w", 1],
+ ],
+ [
+ "border-inline-width: var(--w, 3px);",
+ "borderInlineWidth",
+ [{}, "var", ["w", 3], 1],
+ ],
+ [
+ "border-inline-width: calc(var(--w) * 2);",
+ "borderInlineWidth",
+ [{}, "calc", [[{}, "var", "w", 1], "*", 2]],
+ ],
+ [
+ "border-inline-width: var(--a) var(--b);",
+ "borderInlineWidth",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ ],
+ ],
+ [
+ "border-inline-width: var(--a) var(--b) var(--c);",
+ "borderInlineWidth",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ [{}, "var", "c", 1],
+ ],
+ ],
+ [
+ "border-inline-width: 1px var(--b);",
+ "borderInlineWidth",
+ [1, [{}, "var", "b", 1]],
+ ],
+ ["border-block-color: var(--c);", "borderBlockColor", [{}, "var", "c", 1]],
+ [
+ "border-block-color: var(--a) var(--b);",
+ "borderBlockColor",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ ],
+ ],
+ ["border-block-width: var(--w);", "borderBlockWidth", [{}, "var", "w", 1]],
+ [
+ "border-block-width: var(--a) var(--b);",
+ "borderBlockWidth",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ ],
+ ],
+ [
+ "border-block-width: var(--a) var(--b) var(--c);",
+ "borderBlockWidth",
+ [
+ [{}, "var", "a", 1],
+ [{}, "var", "b", 1],
+ [{}, "var", "c", 1],
+ ],
+ ],
+ ] as const)("%s", (css, resolver, args) => {
+ const { rule, warnings } = getRule(css);
+
+ expect(rule).toStrictEqual([
+ { s: [1, 1], d: [[[{}, resolver, args, 1], resolver, 1]], dv: 1 },
+ ]);
+ expect(warnings).toStrictEqual({});
+ });
+
+ test("a value that compiles to nothing declares nothing", () => {
+ const { rule, warnings } = getRule("border-inline-color: inherit;");
+
+ expect(rule).toBeUndefined();
+ expect(warnings).toStrictEqual({
+ values: { "border-inline-color": ["inherit"] },
+ });
+ });
+});
+
+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.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/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx
new file mode 100644
index 00000000..cf3a4599
--- /dev/null
+++ b/src/__tests__/native/deferred-declarations.test.tsx
@@ -0,0 +1,111 @@
+import { render } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { registerCSS, testID } from "react-native-css/jest";
+
+// A second definition keeps each variable from being inlined, so it resolves at runtime
+const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; }`;
+
+function renderText(css: string, className = "my-class") {
+ registerCSS(`${decoy} ${css}`);
+
+ return render().getByTestId(
+ testID,
+ ).props;
+}
+
+// A deferred declaration resolves after every other one in its rule, against the target it was declared on
+describe("a deferred declaration beside a nested one", () => {
+ test("color: var() beside a box-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ boxShadow: [
+ {
+ color: "#00f",
+ offsetX: 1,
+ offsetY: 1,
+ blurRadius: 0,
+ spreadDistance: 0,
+ },
+ ],
+ });
+ });
+
+ test("color: var() beside a text-shadow", () => {
+ expect(
+ renderText(
+ `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`,
+ ).style,
+ ).toStrictEqual({
+ color: "red",
+ textShadowColor: "#00f",
+ textShadowRadius: 2,
+ textShadowOffset: { width: 1, height: 1 },
+ });
+ });
+
+ test("color: var() beside a prop", () => {
+ const props = renderText(
+ `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+
+ test("a deferred prop beside a style", () => {
+ const props = renderText(
+ `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`,
+ );
+
+ expect(props.style).toStrictEqual({ color: "red" });
+ expect(props.numberOfLines).toBe(2);
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves
+describe("a deferred shorthand keeps no key a later declaration set", () => {
+ test.each([
+ [
+ "border: var() then border-color, in one rule",
+ ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), in one rule",
+ ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }",
+ "my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border: var() then border-color, across rules",
+ ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" },
+ ],
+ [
+ "border-color then border: var(), across rules",
+ ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }",
+ "earlier my-class",
+ { borderWidth: 2, borderStyle: "solid", borderColor: "red" },
+ ],
+ [
+ "border-inline-width: var() then border-inline-start-width",
+ ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }",
+ "my-class",
+ { borderStartWidth: 5, borderEndWidth: 3 },
+ ],
+ [
+ "border-inline-start-width then border-inline-width: var()",
+ ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }",
+ "my-class",
+ { borderStartWidth: 2, borderEndWidth: 3 },
+ ],
+ ] as const)("%s", (_name, css, className, style) => {
+ expect(renderText(css, className).style).toStrictEqual(style);
+ });
+});
diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx
new file mode 100644
index 00000000..1d511859
--- /dev/null
+++ b/src/__tests__/native/logical-borders.test.tsx
@@ -0,0 +1,1095 @@
+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()", () => {
+ // Chromium 153, Firefox 155 and WebKit 26.6: a substituted value the grammar does not take is invalid at computed-value time, so neither edge is set (css-variables-1 §3.1)
+ test.each([
+ [".my-class { border-inline-color: var(--number); }", "a number"],
+ [".my-class { border-inline-color: var(--three); }", "three colours"],
+ [".my-class { border-block-color: var(--number); }", "a number"],
+ [".my-class { border-block-color: var(--three); }", "three colours"],
+ ])("%s holding %s sets no edge", (css) => {
+ registerCSS(`${css}
+ :root { --number: 5; --three: red green blue; }
+ .redefine { --number: 9; --three: black black black; }
+ `);
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({});
+ });
+
+ 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 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([]);
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last
+ test("a later longhand beats a var() shorthand, as it beats `border`", () => {
+ 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(
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("inline").props.style.borderEndColor).toBe(
+ "#000",
+ );
+ expect(screen.getByTestId("uniform").props.style.borderColor).toBe("#000");
+ expect(screen.getByTestId("literal").props.style.borderEndColor).toBe(
+ "#000",
+ );
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 read the values a var() substitutes, so a pair feeds one edge each
+ test("a two-value var() feeds one edge each", () => {
+ registerCSS(`
+ .inline { border-inline-width: var(--pair); border-inline-color: var(--colours); }
+ .block { border-block-width: var(--pair); border-block-color: var(--colours); }
+ :root { --pair: 1px 2px; --colours: red blue; }
+ .redefine { --pair: 9px 9px; --colours: black black; }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("inline").props.style).toStrictEqual({
+ borderStartWidth: 1,
+ borderEndWidth: 2,
+ borderStartColor: "red",
+ borderEndColor: "blue",
+ });
+ expect(screen.getByTestId("block").props.style).toStrictEqual({
+ borderTopWidth: 1,
+ borderBottomWidth: 2,
+ borderTopColor: "red",
+ borderBottomColor: "blue",
+ });
+ });
+});
+
+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.
+ */
+ // An axis colour resolves at runtime, where a light-dark() holding a var() has no resolver yet
+ const LIGHT_DARK_VAR_COVERED = COLOUR_BEARING.filter(
+ (property) =>
+ property.endsWith("-start-color") || property.endsWith("-end-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 6de1c0be..dc12ba23 100644
--- a/src/__tests__/vendor/tailwind/borders.test.tsx
+++ b/src/__tests__/vendor/tailwind/borders.test.tsx
@@ -41,6 +41,10 @@ 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: {
@@ -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 9ca2aab8..a25ee564 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -74,23 +74,41 @@ const propertyRename: Record = {
"font-variant-caps": "font-variant",
};
-// 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.
-const unsupportedLogicalStyles = new Set([
- "border-inline-style",
- "border-inline-start-style",
- "border-inline-end-style",
+// React Native has one borderStyle for the whole border, so a per-edge style has no native equivalent; "solid" is its default
+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",
]);
+// One key set per property, so a later declaration replaces an earlier one; Android and iOS rank borderBlockColor against borderTopColor oppositely
+const blockColorEdges = [
+ "border-top-color",
+ "border-bottom-color",
+] as const satisfies readonly [string, string];
+
+// React Native has no borderInlineColor, so the RTL-aware pair is the inline axis's only target
+const inlineColorEdges = [
+ "border-start-color",
+ "border-end-color",
+] as const satisfies readonly [string, string];
+
const unparsedRuntimeParsing = new Set([
"animation",
"animation-name",
"border",
+ "border-block",
+ "border-block-color",
+ "border-block-end",
+ "border-block-start",
"border-block-width",
+ "border-inline",
+ "border-inline-color",
+ "border-inline-end",
+ "border-inline-start",
"border-inline-width",
"box-shadow",
"filter",
@@ -127,13 +145,13 @@ const parsers: {
"border-block-color": parseBorderColor,
"border-block-end": parseBorderBlockEnd,
"border-block-end-color": parseColorDeclaration,
- "border-block-end-style": parseBorderInlineStyle,
+ "border-block-end-style": parseUnsupportedEdgeStyle,
"border-block-end-width": parseBorderSideWidthDeclaration,
"border-block-start": parseBorderBlockStart,
"border-block-start-color": parseColorDeclaration,
- "border-block-start-style": parseBorderInlineStyle,
+ "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,
@@ -148,13 +166,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,
@@ -315,17 +333,17 @@ export function parseDeclaration(
function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) {
if (declaration.property in parsers) {
const parser = parsers[declaration.property] as Parser;
+ const renamed =
+ propertyRename[declaration.property] ?? declaration.property;
- builder.descriptorProperty = declaration.property;
+ // The renamed name, since light-dark() addresses its dark half to whatever this names
+ 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);
@@ -391,6 +409,16 @@ function parseBorderRadius(
});
}
+// light-dark() addresses its dark half to descriptorProperties, so a parser that expands onto other properties names them
+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"
@@ -405,18 +433,20 @@ 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);
- }
+ // Each edge parses its own colour, so a light-dark() component opens its dark rule over the edge it feeds
+ 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),
+ );
}
}
@@ -463,11 +493,14 @@ function parseBorderBlock(
{ value }: DeclarationType<"border-block">,
builder: StylesheetBuilder,
) {
- builder.addDescriptor("border-block-color", parseColor(value.color, builder));
+ 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);
- dropUnsupportedInlineStyle(
+ dropUnsupportedEdgeStyle(
parseBorderStyle(value.style, builder),
builder,
"border-block-style",
@@ -480,13 +513,13 @@ 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",
parseBorderSideWidth(value.width, builder),
);
- dropUnsupportedInlineStyle(
+ dropUnsupportedEdgeStyle(
parseBorderStyle(value.style, builder),
builder,
"border-block-start-style",
@@ -499,13 +532,13 @@ 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",
parseBorderSideWidth(value.width, builder),
);
- dropUnsupportedInlineStyle(
+ dropUnsupportedEdgeStyle(
parseBorderStyle(value.style, builder),
builder,
"border-block-end-style",
@@ -516,14 +549,14 @@ 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",
@@ -534,12 +567,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",
@@ -550,12 +586,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",
@@ -576,37 +615,45 @@ export function parseBorderInlineWidth(
);
}
-export function parseBorderInlineStyle(
+// React Native has no per-edge border style, so each of the six longhands drops and its warning names the edge
+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"
- | "border-block-start-style"
- | "border-block-end-style"
>,
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,
@@ -963,8 +1010,8 @@ export function parseUnparsedDeclaration(
return;
}
- if (unsupportedLogicalStyles.has(property)) {
- builder.addWarning("property", property);
+ // A var() keeps the value unknown, and an unknown value is not a known non-solid one, so it drops as quietly as solid
+ if (unsupportedEdgeStyles.has(property)) {
return;
}
@@ -981,11 +1028,16 @@ export function parseUnparsedDeclaration(
/**
* 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);
+ // A value that compiled to nothing declares nothing, and its warning names it
+ if (args === undefined) {
+ return;
+ }
+
if (property === "animation") {
builder.addDescriptor("animation", [{}, property, args]);
} else {
@@ -2214,22 +2266,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);
-
- builder.addDescriptor("border-top-width", start);
- builder.addDescriptor("border-bottom-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);
-
- dropUnsupportedInlineStyle(start, builder, "border-block-start-style");
- dropUnsupportedInlineStyle(end, builder, "border-block-end-style");
+ 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..a5a7c07c 100644
--- a/src/compiler/stylesheet.ts
+++ b/src/compiler/stylesheet.ts
@@ -58,7 +58,8 @@ export class StylesheetBuilder {
},
// Any default mapping should be included in the @nativeMapping parsing
private mapping: StyleRuleMapping = {},
- public descriptorProperty?: string,
+ // Every property the declaration being parsed writes, so an unnamed descriptor such as a light-dark() dark half reaches each
+ public descriptorProperties?: readonly string[],
private shared: {
ruleSets: Record;
rootVariables?: VariableRecord;
@@ -102,7 +103,7 @@ export class StylesheetBuilder {
mode,
this.cloneRule(),
{ ...this.mapping },
- this.descriptorProperty,
+ this.descriptorProperties,
this.shared,
selectors,
);
@@ -305,11 +306,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/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..af92b8db 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -13,6 +13,7 @@ import {
type Getter,
type VariableContextValue,
} from "../reactivity";
+import { ShortHandSymbol } from "./constants";
import { transformKeys } from "./defaults";
import { resolveValue } from "./resolve";
@@ -32,6 +33,7 @@ export function calculateProps(
const delayedStyles: (() => void)[] = [];
const transformStyles: (() => void)[] = [];
+ const cascade = createCascade();
for (const rule of rules) {
if (VAR_SYMBOL in rule) {
@@ -75,6 +77,7 @@ export function calculateProps(
guards,
target,
topLevelTarget,
+ cascade,
);
}
}
@@ -94,6 +97,73 @@ export function calculateProps(
};
}
+// The cascade position of the declaration that last set each key of each target, since a deferred declaration resolves after every later one
+interface Cascade {
+ readonly writers: WeakMap