From ce6b7310c81333e1f3ae70d025922d47d83d4b59 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:28:05 +0300 Subject: [PATCH 1/7] fix(native): scope a declaration's style target to its own iteration applyDeclarations reassigned its target parameter per declaration, and a deferred declaration's closure captured that shared binding. The closure runs after every declaration in the rule has been walked, so it read whatever nested target the last declaration ended on: a deferred color beside a box-shadow checked its placeholder against the shadow entry, missed it, and left the internal { color: true } in the style. -webkit-line-clamp: var(--n) left { numberOfLines: true } on the prop the same way. Each declaration now walks into a binding of its own. --- .../native/deferred-declarations.test.tsx | 67 +++++++++++++++++++ src/native/styles/calculate-props.ts | 29 ++++---- 2 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/native/deferred-declarations.test.tsx diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..01a084d1 --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,67 @@ +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; }`; + +function renderText(css: string) { + 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); + }); +}); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..44abaf53 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -105,14 +105,13 @@ export function applyDeclarations( target: Record = {}, topLevelTarget = target, ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + // Each declaration's own binding, since its deferred closure runs after the walk has moved on + let declarationTarget = target; if (!Array.isArray(declaration)) { // Static styles - Object.assign(target, declaration); + Object.assign(declarationTarget, declaration); } else { // Dynamic styles let value: any = declaration[0]; @@ -131,7 +130,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,19 +142,19 @@ export function applyDeclarations( if (!Array.isArray(previousTarget[previousProp])) { previousTarget[previousProp] = []; - target = previousTarget[previousProp]; + declarationTarget = previousTarget[previousProp]; } } - previousTarget = target; + previousTarget = declarationTarget; previousProp = prop; - target[prop] ??= {}; - target = target[prop]; + declarationTarget[prop] ??= {}; + declarationTarget = declarationTarget[prop]; } prop = final; } else { - target = topLevelTarget; + declarationTarget = topLevelTarget; prop = first; } } else { @@ -186,19 +185,19 @@ export function applyDeclarations( renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); }); } else { delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; + if (getDeepPath(declarationTarget, prop) === value) { + delete declarationTarget[prop]; value = resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } }); } @@ -211,7 +210,7 @@ export function applyDeclarations( }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } } From 9819d3d8c79229b0c009bfa095755eac05bb2b9e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:31:09 +0300 Subject: [PATCH 2/7] fix(native): keep a later declaration's keys from a deferred shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deferred declaration resolves after every other declaration of the element, so a shorthand that resolved at runtime spread its keys over whatever a later declaration had set: border: var(--b) followed by border-color: blue painted the variable's colour, within a rule and across rules, and so did border-inline-width: var(--w) over a later border-inline-start-width. Each key belongs to the declaration that set it last (css-cascade-5 §6.4). Each element now records, per target and key, the cascade position of its last writer. A deferred shorthand learns its keys only when it resolves, so it claims them one by one and keeps the ones a later declaration holds. --- .../native/deferred-declarations.test.tsx | 50 +++++- src/native/styles/calculate-props.ts | 160 +++++++++++++----- 2 files changed, 167 insertions(+), 43 deletions(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 01a084d1..cf3a4599 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,12 +3,12 @@ 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; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; }`; -function renderText(css: string) { +function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); - return render().getByTestId( + return render().getByTestId( testID, ).props; } @@ -65,3 +65,47 @@ describe("a deferred declaration beside a nested one", () => { 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/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 44abaf53..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>; + readonly next: () => number; +} + +function createCascade(): Cascade { + let position = 0; + + return { writers: new WeakMap(), next: () => position++ }; +} + +function claim( + cascade: Cascade, + target: object, + key: string | number, + position: number, +): boolean { + let writers = cascade.writers.get(target); + + if (writers === undefined) { + writers = new Map(); + cascade.writers.set(target, writers); + } + + const holder = writers.get(key); + + if (holder !== undefined && holder > position) { + return false; + } + + writers.set(key, position); + return true; +} + +function isShorthandObject(value: unknown): value is Record { + return ( + typeof value === "object" && value !== null && ShortHandSymbol in value + ); +} + +// A shorthand learns its keys only once it resolves, so each key is claimed on its own +function applyInCascade( + cascade: Cascade, + target: Record, + prop: string | number, + value: unknown, + position: number, +) { + if (!isShorthandObject(value)) { + if (claim(cascade, target, prop, position)) { + applyValue(target, prop as string, value); + } + return; + } + + const kept: Record = { [ShortHandSymbol]: true }; + + for (const [key, keyValue] of Object.entries(value)) { + if (claim(cascade, target, key, position)) { + kept[key] = keyValue; + } + } + + applyValue(target, prop as string, kept); +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], @@ -104,13 +174,19 @@ export function applyDeclarations( guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, + cascade: Cascade = createCascade(), ) { for (const declaration of declarations) { // Each declaration's own binding, since its deferred closure runs after the walk has moved on let declarationTarget = target; + const position = cascade.next(); if (!Array.isArray(declaration)) { // Static styles + for (const key of Object.keys(declaration)) { + claim(cascade, declarationTarget, key, position); + } + Object.assign(declarationTarget, declaration); } else { // Dynamic styles @@ -161,56 +237,60 @@ export function applyDeclarations( prop = propPath; } - const shouldDelay = declaration[2]; - - if (shouldDelay || transformKeys.has(prop)) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ + if (transformKeys.has(prop)) { const originalValue = value; - // This needs to be a object with the [prop] so we can discover in transform arrays + // An object keyed by the prop lets the transform array find this entry value = { [prop]: true }; - if (transformKeys.has(prop)) { - transformStyles.push(() => { - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }); - applyValue(declarationTarget, prop, value); + transformStyles.push(() => { + value = resolveValue(originalValue, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - } else { - delayedStyles.push(() => { - if (getDeepPath(declarationTarget, prop) === value) { - delete declarationTarget[prop]; - value = resolveValue(originalValue, get, { + applyValue(declarationTarget, prop, value); + }); + + applyValue(declarationTarget, prop, value); + } else if (declaration[2]) { + const originalValue = value; + // Resolved once every declaration is walked; a later declaration of the same key replaces the placeholder + const placeholder = { [prop]: true }; + + delayedStyles.push(() => { + if (getDeepPath(declarationTarget, prop) === placeholder) { + delete declarationTarget[prop]; + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, - }); - applyValue(declarationTarget, prop, value); - } - }); - } - } else { - value = resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, + }), + position, + ); + } }); - } - applyValue(declarationTarget, prop, value); + applyInCascade(cascade, declarationTarget, prop, placeholder, position); + } else { + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }), + position, + ); + } } } } From a406b34d79a24ef1aa02da9e292450e56c4c23ac Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 16:41:59 +0300 Subject: [PATCH 3/7] test(native): pin a shadow with no colour beside a colour A shadow that omits its colour takes currentcolor, which resolves as a deferred declaration inside the shadow. On main its placeholder reaches the style: textShadowColor: { textShadowColor: true } for a colourless text-shadow beside a color, and color: { color: true } on a colourless box-shadow entry under an inherited colour. --- .../native/deferred-declarations.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index cf3a4599..a4a8402b 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -64,6 +64,49 @@ describe("a deferred declaration beside a nested one", () => { expect(props.style).toStrictEqual({ color: "red" }); expect(props.numberOfLines).toBe(2); }); + + test("a text-shadow with no colour takes the element's colour", () => { + expect( + renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style, + ).toStrictEqual({ + color: "#00f", + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }); + }); + + test("a box-shadow's first shadow with no colour takes the inherited colour", () => { + registerCSS( + `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`, + ); + + const child = render( + + + , + ).getByTestId(testID); + + expect(child.props.style).toStrictEqual({ + boxShadow: [ + { + color: "#123456", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + inset: true, + }, + { + color: "#654321", + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); }); // 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 From 1027ae75926c2beca3badb36cc0d470dbd8585fc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:09:33 +0300 Subject: [PATCH 4/7] fix(native): hold a key a later path passes through A later `text-shadow` writes its offset through `textShadowOffset.width` and `.height`, and only the leaf was claimed, so an earlier `text-shadow: var()` that resolved late still replaced `textShadowOffset` whole: `.earlier { --s: 2px 2px blue; text-shadow: var(--s) }` before `.my-class { text-shadow: 1px 1px red }` drew the red shadow at the blue offset. A declaration written through a path now claims each key it passes through. --- .../native/deferred-declarations.test.tsx | 22 ++++++++++++++++++- src/native/styles/calculate-props.ts | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index a4a8402b..02c151a4 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,7 +3,7 @@ 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; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; }`; function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); @@ -148,6 +148,26 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { "my-class", { borderStartWidth: 2, borderEndWidth: 3 }, ], + [ + "text-shadow: var() then text-shadow, across rules", + ".earlier { --s: 2px 2px blue; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + "earlier my-class", + { + textShadowColor: "#f00", + textShadowRadius: 0, + textShadowOffset: { width: 1, height: 1 }, + }, + ], + [ + "text-shadow then text-shadow: var(), across rules", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px blue; text-shadow: var(--s); }", + "earlier my-class", + { + textShadowColor: "blue", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }, + ], ] as const)("%s", (_name, css, className, style) => { expect(renderText(css, className).style).toStrictEqual(style); }); diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index af92b8db..71d61c64 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -224,6 +224,8 @@ export function applyDeclarations( previousTarget = declarationTarget; previousProp = prop; + // A shorthand that resolves late writes this key whole, so a path through it holds it too + claim(cascade, declarationTarget, prop, position); declarationTarget[prop] ??= {}; declarationTarget = declarationTarget[prop]; } From 1167e3adb90dc501a5d05e06d8293bae2a550602 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:25:20 +0300 Subject: [PATCH 5/7] test(native): give the deferred text shadows a hex colour The two text-shadow rows are about which declaration wins, so the colour a variable carries is written in the form a runtime reader keeps whether it serialises a named colour or not. --- src/__tests__/native/deferred-declarations.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 02c151a4..5a2b8319 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -150,7 +150,7 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { ], [ "text-shadow: var() then text-shadow, across rules", - ".earlier { --s: 2px 2px blue; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", "earlier my-class", { textShadowColor: "#f00", @@ -160,10 +160,10 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { ], [ "text-shadow then text-shadow: var(), across rules", - ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px blue; text-shadow: var(--s); }", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }", "earlier my-class", { - textShadowColor: "blue", + textShadowColor: "#00f", textShadowRadius: 0, textShadowOffset: { width: 2, height: 2 }, }, From 2507a93dc4ddcc1a0a68a1bc3f5463b280ccb9d5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 04:19:32 +0300 Subject: [PATCH 6/7] fix(native): resolve every declaration in cascade order once the element's variables are assigned A declaration that reads a var() was resolved after the walk, through a placeholder at its key, so the walk had to remember which later declaration set each key to keep a deferred shorthand off it. A placeholder is also what an individual transform property replaced with an empty list, so `.a { transform: var(--t) } .b { rotate: 45deg }` lost the transform. Every rule's variables are now assigned before any declaration resolves, and every declaration resolves in cascade order, so a later declaration overwrites an earlier one as the browsers apply them and the key claims are gone. A placeholder is left only for an individual transform property, which composes into the transform list once every rule is walked. A runtime value that resolves to nothing still resets the property. --- src/__tests__/native/animations.test.tsx | 16 ++ .../native/deferred-declarations.test.tsx | 88 +++++++- src/compiler/compiler.types.ts | 2 +- src/native/styles/calculate-props.ts | 191 ++++-------------- 4 files changed, 146 insertions(+), 151 deletions(-) diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index c9565d16..b5875d9d 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -133,6 +133,22 @@ test("an invalid numeric animation name is omitted without throwing", () => { expect(style()?.animationName).toBeUndefined(); }); +// Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it +test.each([undefined, false] as const)( + "a written animation-duration after a variable animation applies with inlineVariables=%s", + (inlineVariables) => { + registerCSS( + `${keyframes} .other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); } .later { animation-duration: 2s; }`, + { inlineVariables }, + ); + render(); + expect(style()).toMatchObject({ + animationName: horizontal, + animationDuration: [2000], + }); + }, +); + test("multiple animation names preserve keyframe order and timing metadata", () => { registerCSS(`${keyframes} .motion { animation: slide-in 1s linear 250ms 2 alternate both, diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx index 5a2b8319..8c51cc84 100644 --- a/src/__tests__/native/deferred-declarations.test.tsx +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -3,7 +3,7 @@ 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; --s: 9px 9px green; }`; +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`; function renderText(css: string, className = "my-class") { registerCSS(`${decoy} ${css}`); @@ -13,7 +13,7 @@ function renderText(css: string, className = "my-class") { ).props; } -// A deferred declaration resolves after every other one in its rule, against the target it was declared on +// A declaration that reads a variable resolves against the target it was declared on describe("a deferred declaration beside a nested one", () => { test("color: var() beside a box-shadow", () => { expect( @@ -172,3 +172,87 @@ describe("a deferred shorthand keeps no key a later declaration set", () => { expect(renderText(css, className).style).toStrictEqual(style); }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written +test("a deferred transform composes with an individual transform property as a written one does", () => { + registerCSS( + `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`, + ); + + const screen = render( + <> + + + , + ); + + const written = screen.getByTestId("written").props.style as { + transform: unknown[]; + }; + expect(written.transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + expect(screen.getByTestId("held").props.style).toStrictEqual(written); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone +test("a var() with no value resets an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: var(--c-missing); }", + "earlier my-class", + ).style, + ).toStrictEqual({}); +}); + +// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands +test("a written value the runtime cannot read leaves an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: 10 }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it +test.each([ + [ + "before a transform", + ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }", + ], + [ + "after a transform", + ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }", + ], +])( + "an individual transform property declared %s composes with it", + (_order, css) => { + const { transform } = renderText(css, "earlier my-class").style as { + transform: unknown[]; + }; + + expect(transform).toHaveLength(2); + expect(transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property +test("an individual transform property beside a box-shadow", () => { + expect( + renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style, + ).toStrictEqual({ + transform: [{ rotateZ: "45deg" }], + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..38c3c046 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -101,7 +101,7 @@ export type StyleDeclaration = | Record /** A style that needs to be set */ | [StyleDescriptor, string | string[]] - /** A value that can only be computed at runtime, and only after styles have been calculated */ + /** A value that can only be computed at runtime */ | [StyleDescriptor, string | string[], 1]; export type StyleDescriptor = diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 71d61c64..f6dbeade 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -7,13 +7,12 @@ import type { import { Specificity as S } from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; -import { applyValue, getDeepPath } from "../objects"; +import { applyValue } from "../objects"; import { VAR_SYMBOL, type Getter, type VariableContextValue, } from "../reactivity"; -import { ShortHandSymbol } from "./constants"; import { transformKeys } from "./defaults"; import { resolveValue } from "./resolve"; @@ -31,10 +30,9 @@ export function calculateProps( let normal: Record | undefined; let important: Record | undefined; - const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; - const cascade = createCascade(); + // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves for (const rule of rules) { if (VAR_SYMBOL in rule) { if (typeof rule[VAR_SYMBOL] === "string") { @@ -42,48 +40,43 @@ export function calculateProps( } else { Object.assign(inheritedVariables, rule); } - continue; - } - - if (rule.v) { + } else if (rule.v) { for (const variable of rule.v) { inlineVariables[variable[0]] = variable[1]; } } + } - if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); - let target = topLevelTarget; + for (const rule of rules) { + if (VAR_SYMBOL in rule || !rule.d) { + continue; + } - const ruleTarget = rule.target || "style"; + let topLevelTarget = rule.s?.[S.Important] + ? (important ??= {}) + : (normal ??= {}); + let target = topLevelTarget; - if (typeof ruleTarget === "string") { - target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { - target = target[path] ??= {}; - } - } + const ruleTarget = rule.target || "style"; - applyDeclarations( - get, - rule.d, - inlineVariables, - inheritedVariables, - delayedStyles, - transformStyles, - guards, - target, - topLevelTarget, - cascade, - ); + if (typeof ruleTarget === "string") { + target = target[ruleTarget] ??= {}; + } else if (ruleTarget) { + for (const path of ruleTarget) { + target = target[path] ??= {}; + } } - } - for (const delayedStyle of delayedStyles) { - delayedStyle(); + applyDeclarations( + get, + rule.d, + inlineVariables, + inheritedVariables, + transformStyles, + guards, + target, + topLevelTarget, + ); } for (const transformStyle of transformStyles) { @@ -97,96 +90,22 @@ 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>; - readonly next: () => number; -} - -function createCascade(): Cascade { - let position = 0; - - return { writers: new WeakMap(), next: () => position++ }; -} - -function claim( - cascade: Cascade, - target: object, - key: string | number, - position: number, -): boolean { - let writers = cascade.writers.get(target); - - if (writers === undefined) { - writers = new Map(); - cascade.writers.set(target, writers); - } - - const holder = writers.get(key); - - if (holder !== undefined && holder > position) { - return false; - } - - writers.set(key, position); - return true; -} - -function isShorthandObject(value: unknown): value is Record { - return ( - typeof value === "object" && value !== null && ShortHandSymbol in value - ); -} - -// A shorthand learns its keys only once it resolves, so each key is claimed on its own -function applyInCascade( - cascade: Cascade, - target: Record, - prop: string | number, - value: unknown, - position: number, -) { - if (!isShorthandObject(value)) { - if (claim(cascade, target, prop, position)) { - applyValue(target, prop as string, value); - } - return; - } - - const kept: Record = { [ShortHandSymbol]: true }; - - for (const [key, keyValue] of Object.entries(value)) { - if (claim(cascade, target, key, position)) { - kept[key] = keyValue; - } - } - - applyValue(target, prop as string, kept); -} - export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], inlineVariables: InlineVariable, inheritedVariables: VariableContextValue, - delayedStyles: (() => void)[] = [], transformStyles: (() => void)[] = [], guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, - cascade: Cascade = createCascade(), ) { for (const declaration of declarations) { - // Each declaration's own binding, since its deferred closure runs after the walk has moved on + // Each declaration's own binding, since its transform closure runs after the walk has moved on let declarationTarget = target; - const position = cascade.next(); if (!Array.isArray(declaration)) { // Static styles - for (const key of Object.keys(declaration)) { - claim(cascade, declarationTarget, key, position); - } - Object.assign(declarationTarget, declaration); } else { // Dynamic styles @@ -224,8 +143,6 @@ export function applyDeclarations( previousTarget = declarationTarget; previousProp = prop; - // A shorthand that resolves late writes this key whole, so a path through it holds it too - claim(cascade, declarationTarget, prop, position); declarationTarget[prop] ??= {}; declarationTarget = declarationTarget[prop]; } @@ -239,6 +156,7 @@ export function applyDeclarations( prop = propPath; } + // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then if (transformKeys.has(prop)) { const originalValue = value; // An object keyed by the prop lets the transform array find this entry @@ -255,43 +173,20 @@ export function applyDeclarations( }); applyValue(declarationTarget, prop, value); - } else if (declaration[2]) { - const originalValue = value; - // Resolved once every declaration is walked; a later declaration of the same key replaces the placeholder - const placeholder = { [prop]: true }; - - delayedStyles.push(() => { - if (getDeepPath(declarationTarget, prop) === placeholder) { - delete declarationTarget[prop]; - applyInCascade( - cascade, - declarationTarget, - prop, - resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }), - position, - ); - } + } else { + value = resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - applyInCascade(cascade, declarationTarget, prop, placeholder, position); - } else { - applyInCascade( - cascade, - declarationTarget, - prop, - resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }), - position, - ); + // A runtime value that resolves to nothing removes the property, as css-variables-1 §3.1 resets one a var() leaves without a value + if (value === undefined && declaration[2]) { + delete declarationTarget[prop]; + } else { + applyValue(declarationTarget, prop, value); + } } } } From 85c63873b969163f3e3fee15c0ce392561988d65 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 00:36:59 +0300 Subject: [PATCH 7/7] fix: land each logical border property on the keys React Native reads border-block-color wrote borderBlockColor when its edges matched and borderTopColor / borderBottomColor when they did not, so a later border-top-color sat beside the axis key instead of replacing it, and Android and iOS rank the two oppositely. Each axis colour now lands on its edge pair at every arity, and a light-dark() component opens its dark rule over the edge it feeds. A var() kept the axis colours and the six logical shorthands off the parsed path: border-inline-color: var(--c) wrote borderInlineColor, which React Native lacks, and border-inline: var(--b) reached it as a list. Each now resolves at runtime onto the same keys the compiler writes, reading as many values as the variable substitutes, as border-inline-width already does. A var() edge style drops as quietly as solid, since an unknown value is not a known non-solid one, and a runtime-parsed value that compiles to nothing declares nothing. --- .../compiler/logical-borders.test.ts | 422 ++++++- src/__tests__/native/logical-borders.test.tsx | 1095 +++++++++++++++++ .../vendor/tailwind/borders.test.tsx | 26 +- src/compiler/declarations.ts | 202 +-- src/compiler/stylesheet.ts | 11 +- src/native/styles/shorthands/border.ts | 80 +- src/native/styles/shorthands/index.ts | 1 + .../styles/shorthands/logical-border-color.ts | 34 + 8 files changed, 1738 insertions(+), 133 deletions(-) create mode 100644 src/__tests__/native/logical-borders.test.tsx create mode 100644 src/native/styles/shorthands/logical-border-color.ts 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/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/shorthands/border.ts b/src/native/styles/shorthands/border.ts index cefdb005..1a60e90c 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -1,10 +1,84 @@ +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]], - [], +// ` || || `, the grammar `border` and the six logical-axis shorthands share +const mappings = [ + [width, style, color], + [style, color], + [width, style], + [style], +]; + +export const border = shorthandHandler(mappings, []); + +const matchBorder = shorthandHandler(mappings, [], "object"); + +// React Native has no inline-axis prop and no per-edge style, so the inline axis lands on borderStart/borderEnd, the block axis on its physical edges, and a style component is dropped +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]; + +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); diff --git a/src/native/styles/shorthands/index.ts b/src/native/styles/shorthands/index.ts index f9ed4196..8fc81501 100644 --- a/src/native/styles/shorthands/index.ts +++ b/src/native/styles/shorthands/index.ts @@ -1,5 +1,6 @@ export * from "./animation"; export * from "./border"; +export * from "./logical-border-color"; export * from "./logical-border-width"; export * from "./box-shadow"; export * from "./text-shadow"; diff --git a/src/native/styles/shorthands/logical-border-color.ts b/src/native/styles/shorthands/logical-border-color.ts new file mode 100644 index 00000000..244ecd84 --- /dev/null +++ b/src/native/styles/shorthands/logical-border-color.ts @@ -0,0 +1,34 @@ +import { ShortHandSymbol } from "../constants"; +import type { StyleFunctionResolver } from "../resolve"; + +function isColor(value: unknown): boolean { + return ( + typeof value === "string" || + (typeof value === "object" && value !== null && !Array.isArray(value)) + ); +} + +function logicalColor(start: string, end: string): StyleFunctionResolver { + return (resolve, descriptor) => { + const resolved = resolve(descriptor[2]); + const values: unknown[] = Array.isArray(resolved) ? resolved : [resolved]; + if (values.length < 1 || values.length > 2 || !values.every(isColor)) { + return; + } + return { + [ShortHandSymbol]: true, + [start]: values[0], + [end]: values[1] ?? values[0], + }; + }; +} + +// One key set per property, as the compiler writes for the same declaration +export const borderBlockColor = logicalColor( + "borderTopColor", + "borderBottomColor", +); +export const borderInlineColor = logicalColor( + "borderStartColor", + "borderEndColor", +);