From 656a97312b4e76a7952a72ec5f44ef21fde713ef Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 14:10:03 +0300 Subject: [PATCH 1/4] fix(native): read a runtime shadow the way the compiler does A box-shadow, text-shadow or drop-shadow() whose value is only known at runtime was matched against a fixed list of token orders. A spelling the list missed was dropped, `inset` without a colour was taken for the colour, and an omitted blur, spread or colour was left out where the compiler writes 0 and currentcolor. A colourless drop-shadow() became an empty filter entry, which React Native's processFilter throws on, and a lone length reached React Native as a number, which processBoxShadow throws on. All three now read their tokens through one reader: the lengths adjacent, a colour and `inset` on either side, in the counts lightningcss accepts for each property, with every shadow of a list read and an invalid one rejecting the whole value. --- src/__tests__/native/box-shadow.test.tsx | 14 +- src/__tests__/native/shadow-grammar.test.tsx | 419 ++++++++++++++++++ .../vendor/tailwind/effects.test.tsx | 47 ++ src/native/styles/functions/filters.ts | 46 +- src/native/styles/shorthands/_handler.ts | 7 +- src/native/styles/shorthands/_shadow.ts | 123 +++++ src/native/styles/shorthands/box-shadow.ts | 110 +---- src/native/styles/shorthands/text-shadow.ts | 35 +- 8 files changed, 650 insertions(+), 151 deletions(-) create mode 100644 src/__tests__/native/shadow-grammar.test.tsx create mode 100644 src/native/styles/shorthands/_shadow.ts diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 85a809a5..556f3252 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -498,6 +498,7 @@ describe("shadow values from runtime variables", () => { offsetX: 0, offsetY: 4, blurRadius: 6, + spreadDistance: 0, color: "#000", }, ]); @@ -515,13 +516,13 @@ describe("shadow values from runtime variables", () => { render(); const component = screen.getByTestId(testID); - // Runtime path has no default color injection (compile-time adds currentcolor) expect(component.props.style.boxShadow).toStrictEqual([ { offsetX: 0, offsetY: 4, blurRadius: 6, spreadDistance: -1, + color: { semantic: ["label", "labelColor"] }, }, ]); }); @@ -595,7 +596,7 @@ describe("shadow values from runtime variables", () => { ]); }); - test("inset shadow - without color has broken color parsing (known limitation)", () => { + test("inset shadow - without color", () => { registerCSS( `.test { --my-shadow: inset 0 0 10px 5px; @@ -607,18 +608,14 @@ describe("shadow values from runtime variables", () => { render(); const component = screen.getByTestId(testID); - // TODO: Known bug — runtime shorthand handler pattern-matches the flat - // array and places "inset" string in the color slot. The compile-time - // path (via lightningcss CSS parser) correctly identifies the inset - // keyword and inherits platform default color. This only affects inset - // shadows without an explicit color, which is uncommon in practice. expect(component.props.style.boxShadow).toStrictEqual([ { - color: "inset", + inset: true, offsetX: 0, offsetY: 0, blurRadius: 10, spreadDistance: 5, + color: { semantic: ["label", "labelColor"] }, }, ]); }); @@ -641,6 +638,7 @@ describe("shadow values from runtime variables", () => { offsetX: 0, offsetY: 2, blurRadius: 4, + spreadDistance: 0, color: "#000", }, ]); diff --git a/src/__tests__/native/shadow-grammar.test.tsx b/src/__tests__/native/shadow-grammar.test.tsx new file mode 100644 index 00000000..51d4a832 --- /dev/null +++ b/src/__tests__/native/shadow-grammar.test.tsx @@ -0,0 +1,419 @@ +import { StyleSheet, View } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { VariableContextProvider } from "react-native-css"; +import { compile } from "react-native-css/compiler"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +// Every expected value is what Chromium, Firefox and WebKit compute for the spelling held in a variable. +const currentColor = { semantic: ["label", "labelColor"] }; +const navy = "#123456"; + +interface ShadowCase { + readonly spelling: string; + readonly variables?: string; + readonly expected: unknown; +} + +const offsetVariable = "--offset: 2px 2px;"; +const layersVariable = "--layers: 1px 1px #123456, 2px 2px #654321;"; + +const expectedBoxShadow = ( + [offsetX, offsetY, blurRadius, spreadDistance]: readonly [ + number, + number, + number, + number, + ], + color: unknown, + inset = false, +) => ({ + offsetX, + offsetY, + blurRadius, + spreadDistance, + color, + ...(inset ? { inset } : {}), +}); + +const expectedTextShadow = ( + [width, height, textShadowRadius]: readonly [number, number, number], + textShadowColor: unknown, +) => ({ + textShadowOffset: { width, height }, + textShadowRadius, + textShadowColor, +}); + +const expectedDropShadow = ( + [offsetX, offsetY, standardDeviation]: readonly [number, number, number], + color: unknown, +) => [{ dropShadow: { offsetX, offsetY, standardDeviation, color } }]; + +const boxShadowCases: readonly ShadowCase[] = [ + { + spelling: "2px 2px", + expected: [expectedBoxShadow([2, 2, 0, 0], currentColor)], + }, + { + spelling: "2px 2px 4px", + expected: [expectedBoxShadow([2, 2, 4, 0], currentColor)], + }, + { + spelling: "2px 2px 4px 1px", + expected: [expectedBoxShadow([2, 2, 4, 1], currentColor)], + }, + { + spelling: "2px 2px #123456", + expected: [expectedBoxShadow([2, 2, 0, 0], navy)], + }, + { + spelling: "#123456 2px 2px", + expected: [expectedBoxShadow([2, 2, 0, 0], navy)], + }, + { + spelling: "#123456 2px 2px 4px", + expected: [expectedBoxShadow([2, 2, 4, 0], navy)], + }, + { + spelling: "2px 2px 4px #123456", + expected: [expectedBoxShadow([2, 2, 4, 0], navy)], + }, + { + spelling: "#123456 2px 2px 4px 1px", + expected: [expectedBoxShadow([2, 2, 4, 1], navy)], + }, + { + spelling: "2px 2px 4px 1px #123456", + expected: [expectedBoxShadow([2, 2, 4, 1], navy)], + }, + { + spelling: "inset 2px 2px", + expected: [expectedBoxShadow([2, 2, 0, 0], currentColor, true)], + }, + { + spelling: "2px 2px inset", + expected: [expectedBoxShadow([2, 2, 0, 0], currentColor, true)], + }, + { + spelling: "inset 2px 2px #123456", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "2px 2px #123456 inset", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "#123456 inset 2px 2px", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "inset #123456 2px 2px", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "2px 2px inset #123456", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "#123456 2px 2px inset", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "2px 2px 4px 1px inset", + expected: [expectedBoxShadow([2, 2, 4, 1], currentColor, true)], + }, + { + spelling: "INSET 2px 2px #123456", + expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)], + }, + { + spelling: "-2px -2px 4px -1px #123456", + expected: [expectedBoxShadow([-2, -2, 4, -1], navy)], + }, + { + spelling: "0 0", + expected: [expectedBoxShadow([0, 0, 0, 0], currentColor)], + }, + { + spelling: "2px 2px currentcolor", + expected: [expectedBoxShadow([2, 2, 0, 0], currentColor)], + }, + { + spelling: "2px 2px #123456, 4px 4px", + expected: [ + expectedBoxShadow([2, 2, 0, 0], navy), + expectedBoxShadow([4, 4, 0, 0], currentColor), + ], + }, + { + spelling: "2px 2px #123456, inset 1px 1px", + expected: [ + expectedBoxShadow([2, 2, 0, 0], navy), + expectedBoxShadow([1, 1, 0, 0], currentColor, true), + ], + }, + { + spelling: "var(--offset) #123456", + variables: offsetVariable, + expected: [expectedBoxShadow([2, 2, 0, 0], navy)], + }, + { + spelling: "var(--layers), 4px 4px", + variables: layersVariable, + expected: [ + expectedBoxShadow([1, 1, 0, 0], navy), + expectedBoxShadow([2, 2, 0, 0], "#654321"), + expectedBoxShadow([4, 4, 0, 0], currentColor), + ], + }, + { spelling: "2px", expected: [] }, + { spelling: "2px 2px 4px 1px 3px", expected: [] }, + { spelling: "2px #123456 2px", expected: [] }, + { spelling: "2px 2px #123456 #654321", expected: [] }, + { spelling: "inset inset 2px 2px", expected: [] }, + { spelling: "2px inset 2px", expected: [] }, + { spelling: "2px 2px #123456, 3px", expected: [] }, +]; + +// A declaration that is not a text shadow sets nothing, so the earlier rule's shadow stays. +const earlierTextShadow = expectedTextShadow([9, 9, 0], "#010203"); + +const textShadowCases: readonly ShadowCase[] = [ + { + spelling: "2px 2px", + expected: expectedTextShadow([2, 2, 0], currentColor), + }, + { + spelling: "2px 2px 4px", + expected: expectedTextShadow([2, 2, 4], currentColor), + }, + { + spelling: "2px 2px #123456", + expected: expectedTextShadow([2, 2, 0], navy), + }, + { + spelling: "#123456 2px 2px", + expected: expectedTextShadow([2, 2, 0], navy), + }, + { + spelling: "#123456 2px 2px 4px", + expected: expectedTextShadow([2, 2, 4], navy), + }, + { + spelling: "2px 2px 4px #123456", + expected: expectedTextShadow([2, 2, 4], navy), + }, + { + spelling: "-2px -2px 4px #123456", + expected: expectedTextShadow([-2, -2, 4], navy), + }, + { + spelling: "2px 2px currentcolor", + expected: expectedTextShadow([2, 2, 0], currentColor), + }, + // React Native draws one text shadow, so the compiler keeps the first. + { + spelling: "2px 2px #123456, 4px 4px", + expected: expectedTextShadow([2, 2, 0], navy), + }, + { + spelling: "var(--layers), 4px 4px", + variables: layersVariable, + expected: expectedTextShadow([1, 1, 0], navy), + }, + { + spelling: "var(--offset) #123456", + variables: offsetVariable, + expected: expectedTextShadow([2, 2, 0], navy), + }, + ...[ + "2px", + "#123456", + "2px #123456 2px", + "inset 2px 2px", + "2px 2px #123456 #654321", + "2px 2px #123456, 3px", + ].map((spelling) => ({ spelling, expected: earlierTextShadow })), +]; + +const dropShadowCases: readonly ShadowCase[] = [ + { + spelling: "2px 2px", + expected: expectedDropShadow([2, 2, 0], currentColor), + }, + { + spelling: "2px 2px 4px", + expected: expectedDropShadow([2, 2, 4], currentColor), + }, + { + spelling: "2px 2px #123456", + expected: expectedDropShadow([2, 2, 0], navy), + }, + { + spelling: "#123456 2px 2px", + expected: expectedDropShadow([2, 2, 0], navy), + }, + { + spelling: "#123456 2px 2px 4px", + expected: expectedDropShadow([2, 2, 4], navy), + }, + { + spelling: "2px 2px 4px #123456", + expected: expectedDropShadow([2, 2, 4], navy), + }, + { + spelling: "-2px -2px 4px #123456", + expected: expectedDropShadow([-2, -2, 4], navy), + }, + { + spelling: "2px 2px currentcolor", + expected: expectedDropShadow([2, 2, 0], currentColor), + }, + { + spelling: "var(--offset) #123456", + variables: offsetVariable, + expected: expectedDropShadow([2, 2, 0], navy), + }, + { spelling: "2px", expected: [] }, + { spelling: "2px 2px 4px 1px", expected: [] }, + { spelling: "2px #123456 2px", expected: [] }, + { spelling: "inset 2px 2px", expected: [] }, + { spelling: "2px 2px #123456, 4px 4px", expected: [] }, + { + spelling: "var(--layers), 4px 4px", + variables: layersVariable, + expected: [], + }, +]; + +const properties = [ + { + property: "box-shadow", + earlier: "box-shadow: 9px 9px 0 0 #010203", + declare: "box-shadow: var(--shadow)", + read: (style: Record): unknown => style.boxShadow, + cases: boxShadowCases, + }, + { + property: "text-shadow", + earlier: "text-shadow: 9px 9px 0 #010203", + declare: "text-shadow: var(--shadow)", + read: ({ + textShadowOffset, + textShadowRadius, + textShadowColor, + }: Record): unknown => ({ + textShadowOffset, + textShadowRadius, + textShadowColor, + }), + cases: textShadowCases, + }, + { + property: "drop-shadow()", + earlier: "filter: drop-shadow(9px 9px 0 #010203)", + declare: "filter: drop-shadow(var(--shadow))", + read: (style: Record): unknown => style.filter, + cases: dropShadowCases, + }, +] as const; + +const routes = [ + { variables: "inlined", options: {}, sheet: "as compiled" }, + { + variables: "resolved at runtime", + options: { inlineVariables: false } as const, + sheet: "as compiled", + }, + { + variables: "inlined", + options: {}, + sheet: "as the JSON a device receives", + }, + { + variables: "resolved at runtime", + options: { inlineVariables: false } as const, + sheet: "as the JSON a device receives", + }, +] as const; + +function renderSubject( + css: string, + { options, sheet }: (typeof routes)[number], +): Record { + const stylesheet = compile(css, options).stylesheet(); + StyleCollection.inject( + sheet === "as compiled" + ? stylesheet + : (JSON.parse(JSON.stringify(stylesheet)) as typeof stylesheet), + ); + + render( + + + + + , + ); + + return StyleSheet.flatten(screen.getByTestId(testID).props.style) as Record< + string, + unknown + >; +} + +describe.each(routes)( + "variables $variables, the sheet injected $sheet", + (route) => { + describe.each(properties)( + "$property", + ({ earlier, declare, read, cases }) => { + test.each(cases)("$spelling", ({ spelling, variables, expected }) => { + const style = renderSubject( + `.first { ${earlier}; } .second { ${variables ?? ""} --shadow: ${spelling}; ${declare}; }`, + route, + ); + + expect(read(style)).toStrictEqual(expected); + }); + }, + ); + + test("an omitted box shadow colour is the inherited colour", () => { + const style = renderSubject( + `.parent { color: #0000ff; } .second { --shadow: 2px 2px 4px; box-shadow: var(--shadow); }`, + route, + ); + + expect(style.boxShadow).toStrictEqual([ + expectedBoxShadow([2, 2, 4, 0], "#00f"), + ]); + }); + + test("an omitted text shadow colour is the inherited colour", () => { + const style = renderSubject( + `.parent { color: #0000ff; } .second { --shadow: 2px 2px 4px; text-shadow: var(--shadow); }`, + route, + ); + + expect(style).toMatchObject(expectedTextShadow([2, 2, 4], "#00f")); + }); + }, +); + +test("a box shadow string set from JavaScript reaches React Native, which reads it", () => { + registerCSS(`.subject { box-shadow: var(--shadow); }`); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style.boxShadow).toBe( + "2px 2px #123456", + ); +}); diff --git a/src/__tests__/vendor/tailwind/effects.test.tsx b/src/__tests__/vendor/tailwind/effects.test.tsx index 2ebb54d8..6724f28d 100644 --- a/src/__tests__/vendor/tailwind/effects.test.tsx +++ b/src/__tests__/vendor/tailwind/effects.test.tsx @@ -63,6 +63,53 @@ describe("Effects - Box Shadow", () => { }, }); }); + test("inset-shadow-sm", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { + style: { + boxShadow: [ + { + blurRadius: 4, + color: "#0000000d", + inset: true, + offsetX: 0, + offsetY: 2, + spreadDistance: 0, + }, + ], + }, + }, + }); + }); +}); + +describe("Effects - Text Shadow", () => { + test("text-shadow-md", async () => { + expect(await renderCurrentTest()).toStrictEqual({ + props: { + style: { + textShadowColor: "#0000001a", + textShadowOffset: { height: 1, width: 0 }, + textShadowRadius: 1, + }, + }, + }); + }); + test("text-shadow-md with a text-shadow color", async () => { + expect( + await renderCurrentTest({ + sourceInline: ["text-shadow-md", "text-shadow-red-500"], + }), + ).toStrictEqual({ + props: { + style: { + textShadowColor: "#fb2c36", + textShadowOffset: { height: 1, width: 0 }, + textShadowRadius: 1, + }, + }, + }); + }); }); describe.skip("Effects - Box Shadow Color", () => { diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 4a88a98a..53147c5f 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,7 +1,5 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; - import type { StyleFunctionResolver } from "../resolve"; -import { shorthandHandler } from "../shorthands/_handler"; +import { readShadows, type ShadowGrammar } from "../shorthands/_shadow"; export const blur: StyleFunctionResolver = (resolveValue, value) => { const args = resolveValue(value[2]); @@ -66,36 +64,24 @@ export const opacity: StyleFunctionResolver = (resolveValue, value) => { }; }; -const color = ["color", "color"] as const; -const offsetX = ["offsetX", "number"] as const; -const offsetY = ["offsetY", "number"] as const; -const standardDeviation = ["standardDeviation", "number"] as const; +const dropShadowGrammar: ShadowGrammar = { maxLengths: 3, acceptsInset: false }; -const handler = shorthandHandler( - [ - [offsetX, offsetY, standardDeviation, color], - [color, offsetX, offsetY], - [color, offsetX, offsetY, standardDeviation], - [offsetX, offsetY, color], - [offsetX, offsetY, standardDeviation, color], - ], - [], - "object", -); +export const dropShadow: StyleFunctionResolver = (resolveValue, func) => { + const [shadow, ...others] = + readShadows(resolveValue(func[2]), dropShadowGrammar, resolveValue) ?? []; -export const dropShadow: StyleFunctionResolver = ( - resolveValue, - func, - get, - options, -) => { - const args = resolveValue(func[2]); + if (shadow === undefined || others.length > 0) { + return; + } - return isStyleDescriptorArray(args) - ? { - dropShadow: handler(resolveValue, args, get, options), - } - : undefined; + return { + dropShadow: { + offsetX: shadow.offsetX, + offsetY: shadow.offsetY, + standardDeviation: shadow.blurRadius, + color: shadow.color, + }, + }; }; /** CSS variables may expand to multiple filters; native filter arrays must be flat. */ diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index b8147d1a..019fa6bc 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -29,7 +29,7 @@ type ShorthandDefaultValue = readonly [ export function shorthandHandler( mappings: ShorthandRequiredValue[][], defaults: ShorthandDefaultValue[], - returnType: "shorthandObject" | "tuples" | "object" = "shorthandObject", + returnType: "shorthandObject" | "tuples" = "shorthandObject", ): StyleResolver { return (resolve, value, __, { castToArray }) => { let args = isStyleDescriptorArray(value) @@ -115,9 +115,8 @@ export function shorthandHandler( ), ]; - if (returnType === "shorthandObject" || returnType === "object") { - const target: Record = - returnType === "shorthandObject" ? { [ShortHandSymbol]: true } : {}; + if (returnType === "shorthandObject") { + const target: Record = { [ShortHandSymbol]: true }; for (const [value, prop] of tuples) { if (typeof prop === "string") { diff --git a/src/native/styles/shorthands/_shadow.ts b/src/native/styles/shorthands/_shadow.ts new file mode 100644 index 00000000..6cc0bc4e --- /dev/null +++ b/src/native/styles/shorthands/_shadow.ts @@ -0,0 +1,123 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; +import { isStyleFunction } from "react-native-css/utilities"; + +import type { SimpleResolveValue } from "../resolve"; + +const currentColor: StyleDescriptor = [{}, "var", "__rn-css-color"]; + +/** What one shadow property accepts, as lightningcss parses it for the compiler. */ +export interface ShadowGrammar { + readonly maxLengths: 3 | 4; + readonly acceptsInset: boolean; +} + +export interface Shadow { + readonly offsetX: number; + readonly offsetY: number; + readonly blurRadius: number; + readonly spreadDistance: number; + readonly color: unknown; + readonly inset: boolean; +} + +/** Every shadow in a resolved list, or `undefined` when one is not a shadow, with the compiler's defaults for what it omits. */ +export function readShadows( + value: unknown, + grammar: ShadowGrammar, + resolveValue: SimpleResolveValue, +): readonly Shadow[] | undefined { + const shadows: Shadow[] = []; + + for (const tokens of readCommaGroups(value)) { + const shadow = readShadow(tokens, grammar, resolveValue); + + if (shadow === undefined) { + return undefined; + } + + shadows.push(shadow); + } + + return shadows; +} + +/** A `var()` that resolved to a list stands for its groups, and one that resolved to part of a shadow is spliced into it. */ +function readCommaGroups(value: unknown): readonly (readonly unknown[])[] { + if (!Array.isArray(value) || isStyleFunction(value)) { + return [[value]]; + } + + return isGroupList(value) + ? value.flatMap(readCommaGroups) + : [spliceTokens(value)]; +} + +function isGroupList(value: readonly unknown[]): boolean { + return ( + value.length > 0 && + value.every((entry) => Array.isArray(entry) && !isStyleFunction(entry)) + ); +} + +function spliceTokens(tokens: readonly unknown[]): readonly unknown[] { + return tokens.flatMap((token) => + Array.isArray(token) && !isStyleFunction(token) + ? spliceTokens(token) + : [token], + ); +} + +function readShadow( + tokens: readonly unknown[], + grammar: ShadowGrammar, + resolveValue: SimpleResolveValue, +): Shadow | undefined { + const lengths: number[] = []; + let lengthsEnded = false; + let color: unknown; + let inset = false; + + for (const token of tokens) { + if (typeof token === "number") { + if (lengthsEnded || lengths.length === grammar.maxLengths) { + return undefined; + } + + lengths.push(token); + continue; + } + + lengthsEnded = lengths.length > 0; + + if (isInsetKeyword(token)) { + if (!grammar.acceptsInset || inset) { + return undefined; + } + + inset = true; + } else if (token === undefined || token === null || color !== undefined) { + return undefined; + } else { + color = token; + } + } + + const [offsetX, offsetY, blurRadius = 0, spreadDistance = 0] = lengths; + + if (offsetX === undefined || offsetY === undefined) { + return undefined; + } + + return { + offsetX, + offsetY, + blurRadius, + spreadDistance, + color: color ?? resolveValue(currentColor), + inset, + }; +} + +function isInsetKeyword(token: unknown): boolean { + return typeof token === "string" && token.toLowerCase() === "inset"; +} diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index 20936e49..704ef994 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -1,106 +1,28 @@ -import type { StyleDescriptor } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; - import type { StyleFunctionResolver } from "../resolve"; -import { shorthandHandler } from "./_handler"; - -// "color" type accepts both strings ("#fff") and objects (PlatformColor from currentcolor). -// Using "string" here would reject platform color objects and silently drop the shadow. -const color = ["color", "color"] as const; -const offsetX = ["offsetX", "number"] as const; -const offsetY = ["offsetY", "number"] as const; -const blurRadius = ["blurRadius", "number"] as const; -const spreadDistance = ["spreadDistance", "number"] as const; -// Match the literal string "inset" - the array type checks if value is in array -const inset = ["inset", ["inset"]] as const; +import { readShadows, type Shadow, type ShadowGrammar } from "./_shadow"; -const handler = shorthandHandler( - [ - // Standard patterns (without inset) - [offsetX, offsetY, blurRadius, spreadDistance], - [offsetX, offsetY, blurRadius, spreadDistance, color], - [color, offsetX, offsetY], - [color, offsetX, offsetY, blurRadius, spreadDistance], - [offsetX, offsetY, color], - [offsetX, offsetY, blurRadius, color], - // Inset patterns - "inset" keyword at the beginning - [inset, offsetX, offsetY, blurRadius, spreadDistance], - [inset, offsetX, offsetY, blurRadius, spreadDistance, color], - [inset, offsetX, offsetY, blurRadius, color], - [inset, color, offsetX, offsetY, blurRadius, spreadDistance], - ], - [], - "object", -); +const boxShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: true }; -export const boxShadow: StyleFunctionResolver = ( - resolveValue, - func, - get, - options, -) => { - const args = resolveValue(func[2]); +export const boxShadow: StyleFunctionResolver = (resolveValue, func) => { + const value: unknown = resolveValue(func[2]); - if (!isStyleDescriptorArray(args)) { - return args; + // React Native reads a string itself, `none` included. + if (value === undefined || value === null || typeof value === "string") { + return value; } - // A flat array of primitives (e.g. [0, 4, 6, -1, "#000"]) is a single shadow - // resolved from a runtime variable. Pass it directly to the pattern handler. - // A nested array (e.g. [[0, 4, 6, -1, "#000"], [...]]) is multiple shadows. - if (args.length > 0 && !Array.isArray(args[0])) { - const result = handler(resolveValue, args, get, options); - if (result === undefined) { - return []; - } - const filtered = omitTransparentShadows(result); - return filtered !== undefined ? [normalizeInsetValue(filtered)] : []; - } + const shadows = readShadows(value, boxShadowGrammar, resolveValue); - return args - .flatMap(flattenShadowDescriptor) - .map((shadows) => { - if (shadows === undefined) { - return; - } else { - return normalizeInsetValue( - omitTransparentShadows(handler(resolveValue, shadows, get, options)), - ); - } - }) - .filter((v) => v !== undefined); + return shadows === undefined + ? [] + : shadows.filter((shadow) => !isTransparent(shadow)).map(toBoxShadow); }; -function flattenShadowDescriptor(arg: StyleDescriptor): StyleDescriptor[] { - if (isStyleDescriptorArray(arg) && isStyleDescriptorArray(arg[0])) { - return arg.map((arg) => { - return flattenShadowDescriptor(arg); - }); - } - - return [arg]; +/** Tailwind's unset shadow layers hold `0 0 #0000`, which React Native would still draw as a native shadow. */ +function isTransparent({ color }: Shadow): boolean { + return color === "#0000" || color === "transparent"; } -function omitTransparentShadows(style: unknown) { - if (typeof style === "object" && style && "color" in style) { - if (style.color === "#0000" || style.color === "transparent") { - return; - } - } - - return style; -} - -/** - * Convert inset: "inset" to inset: true for React Native boxShadow. - * - * The shorthand handler matches the literal "inset" string and assigns it as the value. - * React Native's boxShadow expects inset to be a boolean. - */ -function normalizeInsetValue(style: unknown) { - if (typeof style === "object" && style && "inset" in style) { - return { ...style, inset: true }; - } - - return style; +function toBoxShadow({ inset, ...shadow }: Shadow) { + return inset ? { ...shadow, inset } : shadow; } diff --git a/src/native/styles/shorthands/text-shadow.ts b/src/native/styles/shorthands/text-shadow.ts index a3eb61cd..80f62c45 100644 --- a/src/native/styles/shorthands/text-shadow.ts +++ b/src/native/styles/shorthands/text-shadow.ts @@ -1,17 +1,22 @@ -import { shorthandHandler } from "./_handler"; +import { ShortHandSymbol } from "../constants"; +import type { StyleFunctionResolver } from "../resolve"; +import { readShadows, type ShadowGrammar } from "./_shadow"; -const width = [["textShadowOffset", "width"], "number"] as const; -const height = [["textShadowOffset", "height"], "number"] as const; -const blur = ["textShadowRadius", "number", "textShadowRadius"] as const; -const color = ["textShadowColor", "color", "color"] as const; +const textShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: false }; -export const textShadow = shorthandHandler( - [ - [width, height, blur, color], - [color, width, height, blur], - [width, height, color], - [color, width, height], - [width, height], - ], - [blur, color], -); +export const textShadow: StyleFunctionResolver = (resolveValue, func) => { + // React Native draws one text shadow, so the first is the one the compiler keeps. + const [shadow] = + readShadows(resolveValue(func[2]), textShadowGrammar, resolveValue) ?? []; + + if (shadow === undefined) { + return; + } + + return { + [ShortHandSymbol]: true, + textShadowOffset: { width: shadow.offsetX, height: shadow.offsetY }, + textShadowRadius: shadow.blurRadius, + textShadowColor: shadow.color, + }; +}; From 7ff49fa51c2b66820f9ccfd9d8156809e02f04b4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 01:42:00 +0300 Subject: [PATCH 2/4] fix(native): an invalid box shadow sets nothing, as an invalid text or drop shadow does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A written value that is not a shadow is invalid, and browsers ignore the declaration. A variable holding one leaves the declaration invalid at computed-value time, which browsers make unset (css-variables-1 §3.1). The reader can tell neither from the other, so every shadow it rejects sets nothing, and each test row asserts what browsers compute. --- src/__tests__/native/shadow-grammar.test.tsx | 74 ++++++++++++-------- src/native/styles/shorthands/box-shadow.ts | 8 ++- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/__tests__/native/shadow-grammar.test.tsx b/src/__tests__/native/shadow-grammar.test.tsx index 51d4a832..bb75ff04 100644 --- a/src/__tests__/native/shadow-grammar.test.tsx +++ b/src/__tests__/native/shadow-grammar.test.tsx @@ -7,7 +7,7 @@ import { Text } from "react-native-css/components/Text"; import { registerCSS, testID } from "react-native-css/jest"; import { StyleCollection } from "react-native-css/native"; -// Every expected value is what Chromium, Firefox and WebKit compute for the spelling held in a variable. +// Every expected value is what Chromium, Firefox and WebKit compute for the same CSS. const currentColor = { semantic: ["label", "labelColor"] }; const navy = "#123456"; @@ -169,18 +169,18 @@ const boxShadowCases: readonly ShadowCase[] = [ expectedBoxShadow([4, 4, 0, 0], currentColor), ], }, - { spelling: "2px", expected: [] }, - { spelling: "2px 2px 4px 1px 3px", expected: [] }, - { spelling: "2px #123456 2px", expected: [] }, - { spelling: "2px 2px #123456 #654321", expected: [] }, - { spelling: "inset inset 2px 2px", expected: [] }, - { spelling: "2px inset 2px", expected: [] }, - { spelling: "2px 2px #123456, 3px", expected: [] }, + // A variable holding what is not a box shadow leaves none, as browsers make the declaration unset (css-variables-1 §3.1) + ...[ + "2px", + "2px 2px 4px 1px 3px", + "2px #123456 2px", + "2px 2px #123456 #654321", + "inset inset 2px 2px", + "2px inset 2px", + "2px 2px #123456, 3px", + ].map((spelling) => ({ spelling, expected: undefined })), ]; -// A declaration that is not a text shadow sets nothing, so the earlier rule's shadow stays. -const earlierTextShadow = expectedTextShadow([9, 9, 0], "#010203"); - const textShadowCases: readonly ShadowCase[] = [ { spelling: "2px 2px", @@ -229,14 +229,6 @@ const textShadowCases: readonly ShadowCase[] = [ variables: offsetVariable, expected: expectedTextShadow([2, 2, 0], navy), }, - ...[ - "2px", - "#123456", - "2px #123456 2px", - "inset 2px 2px", - "2px 2px #123456 #654321", - "2px 2px #123456, 3px", - ].map((spelling) => ({ spelling, expected: earlierTextShadow })), ]; const dropShadowCases: readonly ShadowCase[] = [ @@ -289,6 +281,16 @@ const dropShadowCases: readonly ShadowCase[] = [ }, ]; +const readTextShadow = ({ + textShadowOffset, + textShadowRadius, + textShadowColor, +}: Record): unknown => ({ + textShadowOffset, + textShadowRadius, + textShadowColor, +}); + const properties = [ { property: "box-shadow", @@ -301,15 +303,7 @@ const properties = [ property: "text-shadow", earlier: "text-shadow: 9px 9px 0 #010203", declare: "text-shadow: var(--shadow)", - read: ({ - textShadowOffset, - textShadowRadius, - textShadowColor, - }: Record): unknown => ({ - textShadowOffset, - textShadowRadius, - textShadowColor, - }), + read: readTextShadow, cases: textShadowCases, }, { @@ -321,6 +315,16 @@ const properties = [ }, ] as const; +// A written value that is not a text shadow is invalid, so browsers ignore the declaration and the earlier rule's shadow stays +const writtenInvalidTextShadows = [ + "2px", + "#123456", + "2px #123456 2px", + "inset 2px 2px", + "2px 2px #123456 #654321", + "2px 2px #123456, 3px", +] as const; + const routes = [ { variables: "inlined", options: {}, sheet: "as compiled" }, { @@ -382,6 +386,20 @@ describe.each(routes)( }, ); + test.each(writtenInvalidTextShadows)( + "text-shadow written as %s is ignored", + (spelling) => { + const style = renderSubject( + `.first { text-shadow: 9px 9px 0 #010203; } .second { text-shadow: ${spelling}; }`, + route, + ); + + expect(readTextShadow(style)).toStrictEqual( + expectedTextShadow([9, 9, 0], "#010203"), + ); + }, + ); + test("an omitted box shadow colour is the inherited colour", () => { const style = renderSubject( `.parent { color: #0000ff; } .second { --shadow: 2px 2px 4px; box-shadow: var(--shadow); }`, diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index 704ef994..733b19b8 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -13,9 +13,11 @@ export const boxShadow: StyleFunctionResolver = (resolveValue, func) => { const shadows = readShadows(value, boxShadowGrammar, resolveValue); - return shadows === undefined - ? [] - : shadows.filter((shadow) => !isTransparent(shadow)).map(toBoxShadow); + if (shadows === undefined) { + return; + } + + return shadows.filter((shadow) => !isTransparent(shadow)).map(toBoxShadow); }; /** Tailwind's unset shadow layers hold `0 0 #0000`, which React Native would still draw as a native shadow. */ From 808d456e0dec454ec5953e56079a7620dd15b9e8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 16:47:04 +0300 Subject: [PATCH 3/4] fix: draw no text shadow for text-shadow: none lightningcss hands `text-shadow: none` over unparsed, so it resolves at runtime, where the reader answered `none` with nothing: an earlier rule's text shadow survived where CSS removes it, and a nested Text kept its parent's. React Native has no `none`, so the reader writes a transparent shadow with no offset or blur, which draws nothing. The compiler also marked every runtime-parsed shorthand to resolve after the element's other declarations, which a value that reads no variable does not need; resolved there, an earlier rule's `none` would beat a later rule's shadow. Such a value now resolves in its cascade position. --- .../native/text-shadow-none.test.tsx | 67 +++++++++++++++++++ src/compiler/declarations.ts | 12 ++-- src/native/styles/shorthands/text-shadow.ts | 18 ++++- 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/native/text-shadow-none.test.tsx diff --git a/src/__tests__/native/text-shadow-none.test.tsx b/src/__tests__/native/text-shadow-none.test.tsx new file mode 100644 index 00000000..7f87463d --- /dev/null +++ b/src/__tests__/native/text-shadow-none.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; + +// Chromium 153, Firefox 155 and WebKit 26.6 compute none wherever this is expected +const NO_SHADOW = { + textShadowColor: "transparent", + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 0, +}; + +// A second definition keeps --shadow from being inlined, so it resolves at runtime +const decoy = `.decoy { --shadow: 9px 9px green; }`; + +test("a later rule's none removes an earlier rule's shadow", () => { + registerCSS( + `.shadowed { text-shadow: 1px 1px red; } .plain { text-shadow: none; }`, + ); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW); +}); + +test("a later rule's shadow outranks an earlier rule's none", () => { + registerCSS( + `.plain { text-shadow: none; } .shadowed { text-shadow: 1px 1px red; }`, + ); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + textShadowColor: "#f00", + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 0, + }); +}); + +test("a shadow written over none leaves the next none drawing nothing", () => { + registerCSS( + `.plain { text-shadow: none; } .shadowed { text-shadow: 1px 1px red; }`, + ); + render(); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW); +}); + +test("none on a child outranks its parent's shadow", () => { + registerCSS( + `.parent { text-shadow: 1px 1px red; } .child { text-shadow: none; }`, + ); + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW); +}); + +test("a variable holding none removes the shadow at runtime", () => { + registerCSS( + `${decoy} .shadowed { text-shadow: 1px 1px red; } .plain { --shadow: none; text-shadow: var(--shadow); }`, + ); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..8cd9834a 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -984,13 +984,11 @@ export function parseUnparsedDeclaration( builder.descriptorProperty = property; if (unparsedRuntimeParsing.has(property)) { - const args = parseUnparsed(declaration.value.value, builder, property); - - if (property === "animation") { - builder.addDescriptor("animation", [{}, property, args]); - } else { - builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); - } + builder.addDescriptor(property, [ + {}, + toRNProperty(property), + parseUnparsed(declaration.value.value, builder, property), + ]); } else { const value = parseUnparsed(declaration.value.value, builder, property); diff --git a/src/native/styles/shorthands/text-shadow.ts b/src/native/styles/shorthands/text-shadow.ts index 80f62c45..12ea5856 100644 --- a/src/native/styles/shorthands/text-shadow.ts +++ b/src/native/styles/shorthands/text-shadow.ts @@ -1,13 +1,27 @@ import { ShortHandSymbol } from "../constants"; import type { StyleFunctionResolver } from "../resolve"; -import { readShadows, type ShadowGrammar } from "./_shadow"; +import { readShadows, type Shadow, type ShadowGrammar } from "./_shadow"; const textShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: false }; +/** React Native has no `none`: a transparent shadow with no offset or blur draws nothing, and outranks one a parent Text passes down. */ +const noTextShadow: Shadow = { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 0, + color: "transparent", + inset: false, +}; + export const textShadow: StyleFunctionResolver = (resolveValue, func) => { + const value = resolveValue(func[2]); + // React Native draws one text shadow, so the first is the one the compiler keeps. const [shadow] = - readShadows(resolveValue(func[2]), textShadowGrammar, resolveValue) ?? []; + value === "none" + ? [noTextShadow] + : (readShadows(value, textShadowGrammar, resolveValue) ?? []); if (shadow === undefined) { return; From 18f40b2c4d3ad0b4a4ae9c321f3af9fc8d49b4e0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 17:19:44 +0300 Subject: [PATCH 4/4] fix(compiler): keep the runtime delay, and compile text-shadow: none Resolving a runtime-parsed shorthand in place lost what its delay gives a CSS-wide keyword: the placeholder that replaces an earlier rule's value, so `box-shadow: initial` after `box-shadow: 1px 1px red` kept the shadow. The delay stays. `text-shadow: none`, which lightningcss leaves unparsed, compiles instead to the no-shadow the runtime reader writes for a variable holding `none`, so it keeps its cascade position. --- src/compiler/declarations.ts | 23 +++++++++++++++------ src/native/styles/shorthands/text-shadow.ts | 20 ++++++------------ src/utilities/index.ts | 1 + src/utilities/text-shadow.ts | 7 +++++++ 4 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 src/utilities/text-shadow.ts diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 8cd9834a..2e36517c 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, NO_TEXT_SHADOW } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -984,11 +984,22 @@ export function parseUnparsedDeclaration( builder.descriptorProperty = property; if (unparsedRuntimeParsing.has(property)) { - builder.addDescriptor(property, [ - {}, - toRNProperty(property), - parseUnparsed(declaration.value.value, builder, property), - ]); + const args = parseUnparsed(declaration.value.value, builder, property); + + if (property === "text-shadow" && args === "none") { + // lightningcss leaves `none` unparsed, and resolved at runtime it would land after a later rule's text shadow + builder.addDescriptor("textShadowColor", NO_TEXT_SHADOW.color); + builder.addDescriptor("&.textShadowOffset.width", NO_TEXT_SHADOW.offsetX); + builder.addDescriptor( + "&.textShadowOffset.height", + NO_TEXT_SHADOW.offsetY, + ); + builder.addDescriptor("textShadowRadius", NO_TEXT_SHADOW.blurRadius); + } else if (property === "animation") { + builder.addDescriptor("animation", [{}, property, args]); + } else { + builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); + } } else { const value = parseUnparsed(declaration.value.value, builder, property); diff --git a/src/native/styles/shorthands/text-shadow.ts b/src/native/styles/shorthands/text-shadow.ts index 12ea5856..c3cc9e45 100644 --- a/src/native/styles/shorthands/text-shadow.ts +++ b/src/native/styles/shorthands/text-shadow.ts @@ -1,27 +1,19 @@ +import { NO_TEXT_SHADOW } from "react-native-css/utilities"; + import { ShortHandSymbol } from "../constants"; import type { StyleFunctionResolver } from "../resolve"; -import { readShadows, type Shadow, type ShadowGrammar } from "./_shadow"; +import { readShadows, type ShadowGrammar } from "./_shadow"; const textShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: false }; -/** React Native has no `none`: a transparent shadow with no offset or blur draws nothing, and outranks one a parent Text passes down. */ -const noTextShadow: Shadow = { - offsetX: 0, - offsetY: 0, - blurRadius: 0, - spreadDistance: 0, - color: "transparent", - inset: false, -}; - export const textShadow: StyleFunctionResolver = (resolveValue, func) => { const value = resolveValue(func[2]); // React Native draws one text shadow, so the first is the one the compiler keeps. - const [shadow] = + const shadow = value === "none" - ? [noTextShadow] - : (readShadows(value, textShadowGrammar, resolveValue) ?? []); + ? NO_TEXT_SHADOW + : readShadows(value, textShadowGrammar, resolveValue)?.[0]; if (shadow === undefined) { return; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..24a7645b 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./text-shadow"; diff --git a/src/utilities/text-shadow.ts b/src/utilities/text-shadow.ts new file mode 100644 index 00000000..c384eb38 --- /dev/null +++ b/src/utilities/text-shadow.ts @@ -0,0 +1,7 @@ +/** React Native has no `text-shadow: none`; a transparent shadow with no offset or blur draws nothing, and outranks one a parent Text passes down. */ +export const NO_TEXT_SHADOW = { + color: "transparent", + offsetX: 0, + offsetY: 0, + blurRadius: 0, +} as const;