diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..0b34cea4 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -181,23 +181,65 @@ test("animations preserve named keyframes and their timing contract", () => { ]); }); -test("breaks apart comma separated variables", () => { - const compiled = compile( - ` - :root { - --test: blue, green; - } - `, - { - inlineVariables: false, - }, - ); +// css-variables-1 §2.1: a custom property's value is its tokens, its commas among them +test.each([ + ["blue, green", [["blue"], ["green"]]], + ["blue ,green", [["blue"], ["green"]]], + ["blue/**/, green", [["blue"], ["green"]]], + ["blue green", ["blue", "green"]], + [ + "1px 2px #000, 3px 4px #fff", + [ + [1, 2, "#000"], + [3, 4, "#fff"], + ], + ], + ["1px 2px #000 3px 4px #fff", [1, 2, "#000", 3, 4, "#fff"]], +])("--test: %s keeps its separator", (value, expected) => { + const compiled = compile(`:root { --test: ${value}; }`, { + inlineVariables: false, + }); expect(compiled.stylesheet()).toStrictEqual({ - vr: [["test", [[["blue", "green"]]]]], + vr: [["test", [[expected]]]], }); }); +test.each([ + ["var(--a), var(--b)", [[[{}, "var", "a", 1]], [[{}, "var", "b", 1]]]], + [ + "var(--a) var(--b)", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + ], +])("box-shadow: %s keeps its separator", (value, expected) => { + const compiled = compile(`.my-class { box-shadow: ${value}; }`); + + expect(compiled.stylesheet().s?.[0]?.[1][0]?.d).toStrictEqual([ + [[{}, "boxShadow", expected, 1], "boxShadow", 1], + ]); +}); + +// A transform is a space-separated list, so it holds each function as a token of its own +test.each([ + [ + "translate(10px, 20px)", + [ + [{}, "translateX", 10], + [{}, "translateY", 20], + ], + ], + ["translateZ(10px) rotate(45deg)", [[{}, "rotate", "45deg"]]], +])("transform: %s compiles to a flat list of functions", (value, expected) => { + const compiled = compile(`.my-class { transform: ${value}; }`); + + expect(compiled.stylesheet().s?.[0]?.[1][0]?.d).toStrictEqual([ + [[{}, "transform", expected], "transform"], + ]); +}); + test("light-dark()", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts index 119c85d7..c1966b9b 100644 --- a/src/__tests__/compiler/property.test.ts +++ b/src/__tests__/compiler/property.test.ts @@ -224,3 +224,16 @@ test("@property with repeated multi-child preserves array", () => { // Multi-child repeated (+ with two values) keeps the array form expect(vrMap.get("my-offsets")).toStrictEqual([[[10, 20]]]); }); + +test("@property with a comma-separated repeated value keeps each item apart", () => { + const compiled = compile(` +@property --my-offsets { + syntax: "#"; + inherits: false; + initial-value: 10px, 20px; +} +`); + + const vrMap = new Map(compiled.stylesheet().vr); + expect(vrMap.get("my-offsets")).toStrictEqual([[[[10], [20]]]]); +}); diff --git a/src/__tests__/native/variable-lists.test.tsx b/src/__tests__/native/variable-lists.test.tsx new file mode 100644 index 00000000..ac57d846 --- /dev/null +++ b/src/__tests__/native/variable-lists.test.tsx @@ -0,0 +1,445 @@ +import { processColor, StyleSheet } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import type { StyleDescriptor } from "react-native-css/compiler"; +import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { useNativeVariable } from "react-native-css/native"; + +import type { Getter } from "../../native/reactivity"; +import { resolveValue } from "../../native/styles/resolve"; + +// Observe the transition contract before Reanimated consumes it, as transitions.test.tsx does +jest.mock("../../native/reanimated", () => ({ + animatedComponentFamily: (component: unknown) => component, +})); + +const style = () => + StyleSheet.flatten(screen.getByTestId(testID).props.style) as + | Record + | undefined; + +// The compiler folds a variable declared once into the declaration that reads it, unless told to keep it +describe.each([ + ["folded", {}], + ["kept", { inlineVariables: false }], +] as const)( + "a list read through a variable the compiler has %s", + (_, options) => { + // css-variables-1 §3 substitutes a var() by its tokens, commas among them + test.each([ + [ + "box-shadow: var(--o) var(--b)", + "--o: 1px 2px; --b: 3px 4px #123;", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 2, + blurRadius: 3, + spreadDistance: 4, + color: "#123", + }, + ], + }, + ], + [ + "box-shadow: var(--o), var(--b)", + "--o: 1px 2px 0 0 #123; --b: 3px 4px 0 0 #456;", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + color: "#123", + }, + { + offsetX: 3, + offsetY: 4, + blurRadius: 0, + spreadDistance: 0, + color: "#456", + }, + ], + }, + ], + [ + "box-shadow: var(--a)", + "--b: 1px 2px; --a: var(--b) 3px 4px #123;", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 2, + blurRadius: 3, + spreadDistance: 4, + color: "#123", + }, + ], + }, + ], + [ + "filter: var(--f)", + "--f: blur(2px) brightness(1.5);", + { filter: [{ blur: 2 }, { brightness: 1.5 }] }, + ], + [ + "transform: var(--t)", + "--t: rotate(45deg) translateX(10px);", + { transform: [{ rotate: "45deg" }, { translateX: 10 }] }, + ], + [ + "filter: var(--a) var(--b)", + "--a: blur(2px); --b: brightness(1.5);", + { filter: [{ blur: 2 }, { brightness: 1.5 }] }, + ], + [ + "box-shadow: var(--s), var(--s)", + "--s: 1px 1px 0 0 #123, 2px 2px 0 0 #456;", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#123", + }, + { + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + color: "#456", + }, + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#123", + }, + { + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + color: "#456", + }, + ], + }, + ], + [ + "box-shadow: var(--s) #456", + "--s: 1px 1px 0 0 #123, 2px 2px 0 0;", + { + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#123", + }, + { + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + color: "#456", + }, + ], + }, + ], + [ + "transform: translate(var(--x), 20px)", + "--x: 10px;", + { transform: [{ translateX: 10 }, { translateY: 20 }] }, + ], + [ + "transform: scale(var(--s), 2)", + "--s: 3;", + { transform: [{ scaleX: 3 }, { scaleY: 2 }] }, + ], + [ + "transform: translate(var(--t))", + "--t: 10px, 20px;", + { transform: [{ translateX: 10 }, { translateY: 20 }] }, + ], + [ + "transform: scale(var(--s))", + "--s: 2, 3;", + { transform: [{ scaleX: 2 }, { scaleY: 3 }] }, + ], + [ + "transition-property: var(--p), height", + "--p: width;", + { transitionProperty: ["width", "height"] }, + ], + ])("%s over %s", (declaration, variables, expected) => { + registerCSS(`.a { ${variables} ${declaration}; }`, options); + render(); + expect(style()).toStrictEqual(expected); + }); + + // Reanimated's easing builders return instances of their own classes, so the held value is compared with the written one + test.each(["steps(4, end)", "cubic-bezier(0.25, 0.5, 0.75, 1)"])( + "transition-timing-function: var(--e) over --e: %s", + (easing) => { + registerCSS(`.a { transition-timing-function: ${easing}; }`, options); + render(); + const written = style(); + screen.unmount(); + registerCSS( + `.a { --e: ${easing}; transition-timing-function: var(--e); }`, + options, + ); + render(); + expect(style()).toEqual(written); + }, + ); + + // react-native-css maps translateZ() to nothing, so the list keeps the functions it maps + test("transform: var(--t) over --t: translateZ(10px) rotate(45deg)", () => { + registerCSS( + `.a { --t: translateZ(10px) rotate(45deg); transform: var(--t); }`, + options, + ); + render(); + expect(style()).toStrictEqual({ transform: [{ rotate: "45deg" }] }); + }); + + test("background-image: linear-gradient(to right, var(--c)) over --c: #123, #456", () => { + registerCSS( + `.a { --c: #123, #456; background-image: linear-gradient(to right, var(--c)); }`, + options, + ); + render(); + expect(style()).toStrictEqual({ + experimental_backgroundImage: "linear-gradient(to right, #123, #456)", + }); + }); + + test("background-color: rgb(var(--c) / 0.5) over --c: 255 0 0", () => { + registerCSS( + `.a { --c: 255 0 0; background-color: rgb(var(--c) / 0.5); }`, + options, + ); + render(); + expect(processColor(style()?.backgroundColor as string)).toBe( + processColor("rgba(255, 0, 0, 0.5)"), + ); + }); + + test("text-shadow: var(--o) var(--c) over --o: 1px 2px, --c: #123", () => { + registerCSS( + `.a { --o: 1px 2px; --c: #123; text-shadow: var(--o) var(--c); }`, + options, + ); + render(); + expect(style()).toStrictEqual({ + textShadowOffset: { width: 1, height: 2 }, + textShadowRadius: 0, + textShadowColor: "#123", + }); + }); + }, +); + +// A list the property's grammar refuses makes the declaration invalid at computed-value time (css-variables-1 §3.1), so it unsets what an earlier rule set +test.each([ + [ + "box-shadow: var(--o) var(--b)", + "--o: 1px 2px 0 0 #123; --b: 3px 4px 0 0 #456;", + "box-shadow: 5px 5px green;", + { boxShadow: [] }, + ], + [ + "filter: var(--f)", + "--f: blur(2px), brightness(1.5);", + "filter: grayscale(1);", + { filter: undefined }, + ], + [ + "transform: var(--t)", + "--t: rotate(45deg), translateX(10px);", + "transform: rotate(10deg);", + { transform: undefined }, + ], + [ + "filter: var(--a), var(--missing)", + "--a: blur(2px);", + "filter: grayscale(1);", + { filter: undefined }, + ], +])( + "%s over %s replaces an earlier rule's value with none", + (declaration, variables, earlier, expected) => { + registerCSS(`.earlier { ${earlier} } .a { ${variables} ${declaration}; }`, { + inlineVariables: false, + }); + render(); + expect(style()).toStrictEqual(expected); + }, +); + +test("linear-gradient(var(--c)) over --c: #123 #456 prints the space it was written with", () => { + registerCSS( + `.a { --c: #123 #456; background-image: linear-gradient(var(--c)); }`, + { inlineVariables: false }, + ); + render(); + expect(style()).toStrictEqual({ + experimental_backgroundImage: "linear-gradient(#123 #456)", + }); +}); + +test.each([ + [ + "box-shadow: var(--missing, 1px 2px) 3px 4px #123", + [ + { + offsetX: 1, + offsetY: 2, + blurRadius: 3, + spreadDistance: 4, + color: "#123", + }, + ], + ], + [ + "box-shadow: var(--missing, 1px 1px 0 0 #123, 2px 2px 0 0 #456)", + [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#123", + }, + { + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + color: "#456", + }, + ], + ], +])( + "%s substitutes its fallback's list as a variable's", + (declaration, boxShadow) => { + registerCSS(`.a { ${declaration}; }`); + render(); + expect(style()).toStrictEqual({ boxShadow }); + }, +); + +// The compiler folds a variable read in an unresolved colour's alpha away and drops the alpha, so this case keeps it +test("background-color: rgb(255 0 0 / var(--a)) over --a: 0.5 with the variable kept", () => { + registerCSS(`.a { --a: 0.5; background-color: rgb(255 0 0 / var(--a)); }`, { + inlineVariables: false, + }); + render(); + expect(processColor(style()?.backgroundColor as string)).toBe( + processColor("rgba(255, 0, 0, 0.5)"), + ); +}); + +// The compiler writes a folded translate() with its second offset, which defaults to 0 +test("transform: translate(var(--x)) over --x: 10px with the variable kept", () => { + registerCSS(`.a { --x: 10px; transform: translate(var(--x)); }`, { + inlineVariables: false, + }); + render(); + expect(style()).toStrictEqual({ transform: [{ translateX: 10 }] }); +}); + +// React Native's list props and the variable hook's callers take a list of one-token items as a flat array +test("transition-property: var(--p) over --p: width, opacity hands React Native a flat list", () => { + registerCSS(`.a { --p: width, opacity; transition-property: var(--p); }`, { + inlineVariables: false, + }); + render(); + expect(style()).toStrictEqual({ transitionProperty: ["width", "opacity"] }); +}); + +test("useNativeVariable reads --p: width, opacity as a flat list", () => { + registerCSS(`:root { --p: width, opacity; }`, { inlineVariables: false }); + function Sample() { + return ( + {JSON.stringify(useNativeVariable("--p"))} + ); + } + render(); + expect(screen.getByTestId(testID).props.children).toBe( + JSON.stringify(["width", "opacity"]), + ); +}); + +// A variable read twice in one element resolves the second time from the value the first read keeps +test.each<[string, readonly unknown[]]>([ + ["a space-separated list of tokens", [1, 2, "#123"]], + [ + "a space-separated list of objects", + [{ rotate: "45deg" }, { translateX: 10 }], + ], + [ + "a comma-separated list", + [ + [1, 1, 0, 0, "#123"], + [2, 2, 0, 0, "#456"], + ], + ], + [ + "a comma-separated list whose items start with an object and a string", + [ + [{ rotate: "45deg" }, "x"], + [{ rotate: "90deg" }, "y"], + ], + ], +])("%s resolves to itself once resolved", (_, list) => { + const get = (() => undefined) as unknown as Getter; + expect(resolveValue(list as StyleDescriptor, get, {})).toStrictEqual(list); +}); + +test.each<[string, readonly unknown[], readonly unknown[]]>([ + [ + "a list whose tokens all resolve to nothing", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + [], + ], + [ + "a token list nested in a space-separated list", + [1, [2, 3], 4], + [1, 2, 3, 4], + ], + [ + "a comma-separated list with a lone token in an item's place, as React Native's list props take it", + [[1, 2, "#123"], "#456"], + [[1, 2, "#123"], ["#456"]], + ], +])("%s resolves as the list it stands for", (_, list, expected) => { + const get = (() => undefined) as unknown as Getter; + expect(resolveValue(list as StyleDescriptor, get, {})).toStrictEqual( + expected, + ); +}); + +test.each<[string, StyleDescriptor, unknown]>([ + [ + "rotate() over three comma-separated arguments", + [{}, "rotate", [["10deg"], ["20deg"], ["30deg"]]], + [{ rotateX: "10deg" }, { rotateY: "20deg" }, { rotateZ: "30deg" }], + ], + ["join() over one value", [{}, "join", "#123"], "#123"], +])("%s reads each argument", (_, descriptor, expected) => { + const get = (() => undefined) as unknown as Getter; + expect(resolveValue(descriptor, get, {})).toStrictEqual(expected); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..9f032870 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -469,7 +469,13 @@ function parsePropertyInitialValue( // Unwrap single-child repeated values so downstream consumers get a // scalar instead of a 1-element array. For example, `+` with // initial-value `10px` should produce the same shape as ``. - return results.length === 1 ? results[0] : results; + if (results.length === 1) { + return results[0]; + } + + return component.value.multiplier.type === "comma" + ? results.map((result) => [result]) + : results; } default: return undefined; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..478b9bf4 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -755,11 +755,9 @@ function parseTransform( parseLengthOrCoercePercentageToRuntime(t.value[0], builder), ], [ - [ - {}, - "translateY", - parseLengthOrCoercePercentageToRuntime(t.value[1], builder), - ], + {}, + "translateY", + parseLengthOrCoercePercentageToRuntime(t.value[1], builder), ], ]; case "translateX": @@ -811,7 +809,7 @@ function parseTransform( case "rotate3d": case "matrix": case "matrix3d": - return [[]]; + return []; } }), ]); @@ -1094,8 +1092,8 @@ export function reduceParseUnparsed( return undefined; } - let currentGroup: StyleDescriptor = []; - let groups: StyleDescriptor[] = [currentGroup]; + let currentGroup: StyleDescriptor[] = []; + const groups: StyleDescriptor[][] = [currentGroup]; for (const value of result) { if ((value as unknown) === CommaSeparator) { @@ -1106,40 +1104,24 @@ export function reduceParseUnparsed( } } - // Groups are the tokens grouped together by comma location - // If a group only has 1 item, it shouldn't be an array - groups = groups.flatMap((group): StyleDescriptor[] => { - if (!Array.isArray(group)) { - return []; - } + const items = groups.filter((group) => group.length > 0).map(reduceRatio); - if (group.length === 0) { - return []; - } else if (group.length === 1) { - const first = group[0]; + if (items.length > 1) { + return items; + } - if (first === undefined) { - return []; - } else { - return [first]; - } - } else if ( - // This is a special case for values - group.includes("/") && - group.every((item) => - typeof item === "string" && item === "/" - ? item - : typeof item === "number", - ) - ) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - return [group.join(" ")]; - } else { - return [group]; - } - }); + const item = items[0] ?? []; + return item.length === 1 ? item[0] : item; +} - return groups.length === 1 ? groups[0] : groups; +// React Native takes a such as `16 / 9` as one string +function reduceRatio(group: StyleDescriptor[]): StyleDescriptor[] { + return group.includes("/") && + group.every((item): item is number | "/" => { + return item === "/" || typeof item === "number"; + }) + ? [group.join(" ")] + : group; } export function unparsedFunction( @@ -2936,10 +2918,10 @@ export function parseUnresolvedColor( {}, "rgba", [ - round(color.r * 255), - round(color.g * 255), - round(color.b * 255), - parseUnparsed(color.alpha, builder, property), + [round(color.r * 255)], + [round(color.g * 255)], + [round(color.b * 255)], + [parseUnparsed(color.alpha, builder, property)], ], ]; case "hsl": @@ -2947,10 +2929,10 @@ export function parseUnresolvedColor( {}, color.type, [ - color.h, - color.s, - color.l, - parseUnparsed(color.alpha, builder, property), + [color.h], + [color.s], + [color.l], + [parseUnparsed(color.alpha, builder, property)], ], ]; case "light-dark": { @@ -3220,8 +3202,8 @@ function parseGradient( {}, "linear-gradient", [ - parseLineDirection(gradient.direction, builder), - ...gradient.items.map((item) => parseGradientItem(item, builder)), + [parseLineDirection(gradient.direction, builder)], + ...gradient.items.map((item) => [parseGradientItem(item, builder)]), ], ]; } diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..148d2e5c 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -417,7 +417,7 @@ export class StylesheetBuilder { } else { declarations.push([value, propPath]); } - } else if (Array.isArray(value) && value.some(isStyleFunction)) { + } else if (Array.isArray(value) && value.some(holdsStyleFunction)) { declarations.push([value, propPath]); } else if (typeof value === "string" && keywords.has(value)) { declarations.push([value, propPath]); @@ -617,6 +617,14 @@ function isStyleFunction( ); } +/** A comma-separated list holds each item as the array of its tokens, so a function may be one of an item's tokens. */ +function holdsStyleFunction(entry: StyleDescriptor): boolean { + return ( + isStyleFunction(entry) || + (Array.isArray(entry) && entry.some(isStyleFunction)) + ); +} + function postProcessStyleFunction(value: StyleDescriptor): [ // Should it be delayed boolean, diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..d8d45f42 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -4,6 +4,7 @@ import { Appearance, type ViewStyle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; +import { commaListItems } from "react-native-css/utilities"; import type { ColorScheme, @@ -114,7 +115,9 @@ export function useNativeVariable(name: string) { return () => cleanupEffect(effect); }, [effect]); cleanupEffect(effect); - return resolveValue([{}, "var", [name]], effect.get, { inheritedVariables }); + return commaListItems( + resolveValue([{}, "var", [name]], effect.get, { inheritedVariables }), + ); } /** diff --git a/src/native/objects.ts b/src/native/objects.ts index 12e69ccd..4e49ccde 100644 --- a/src/native/objects.ts +++ b/src/native/objects.ts @@ -1,4 +1,6 @@ /* eslint-disable */ +import { commaListItems } from "react-native-css/utilities"; + import { ShortHandSymbol } from "../native/styles/constants"; import { transformKeys } from "../native/styles/defaults"; @@ -84,7 +86,11 @@ export function applyValue( return; } - target[prop] = value; + // commaListItems changes only a list of lists, and Hermes pays for a call on every declaration + target[prop] = + Array.isArray(value) && Array.isArray(value[0]) + ? commaListItems(value) + : value; } export function setDeepPath( diff --git a/src/native/styles/functions/animation-timing.ts b/src/native/styles/functions/animation-timing.ts index 2bd58078..1e942ca6 100644 --- a/src/native/styles/functions/animation-timing.ts +++ b/src/native/styles/functions/animation-timing.ts @@ -1,3 +1,5 @@ +import { commaListItems } from "react-native-css/utilities"; + import type { StyleFunctionResolver } from "../resolve"; /* eslint-disable @typescript-eslint/no-require-imports */ @@ -26,7 +28,7 @@ const timingFunctionResolver: StyleFunctionResolver = (resolveValue, value) => { return; } - const args = resolveValue(value[2]); + const args = commaListItems(resolveValue(value[2])); if (!Array.isArray(args)) { return; diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 4a88a98a..dd5f6671 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,4 +1,7 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isCommaList, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; @@ -98,11 +101,12 @@ export const dropShadow: StyleFunctionResolver = ( : undefined; }; -/** CSS variables may expand to multiple filters; native filter arrays must be flat. */ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { const value: unknown = resolveValue(descriptor[2]); + // filter-effects-1 §5: a filter list is space-separated, so a comma makes the declaration invalid at computed-value time + if (isCommaList(value)) return null; if (Array.isArray(value)) { - const filters: unknown[] = value.flat(Infinity); + const filters: readonly unknown[] = value; return filters.filter( (entry) => entry !== undefined && entry !== "initial", ); diff --git a/src/native/styles/functions/platform-functions.ts b/src/native/styles/functions/platform-functions.ts index 57f095f5..2fe21849 100644 --- a/src/native/styles/functions/platform-functions.ts +++ b/src/native/styles/functions/platform-functions.ts @@ -1,9 +1,11 @@ import { PixelRatio, PlatformColor, StyleSheet } from "react-native"; +import { commaListItems } from "react-native-css/utilities"; + import type { StyleFunctionResolver } from "../resolve"; export const platformColor: StyleFunctionResolver = (resolveValue, value) => { - const color: unknown = resolveValue(value[2]); + const color: unknown = commaListItems(resolveValue(value[2])); if (Array.isArray(color)) { return PlatformColor(...(color as string[])); } else if (typeof color === "string") { diff --git a/src/native/styles/functions/string-functions.ts b/src/native/styles/functions/string-functions.ts index 8ab29a98..700a6dce 100644 --- a/src/native/styles/functions/string-functions.ts +++ b/src/native/styles/functions/string-functions.ts @@ -1,3 +1,5 @@ +import { isStyleDescriptorArray } from "react-native-css/utilities"; + import type { StyleFunctionResolver } from "../resolve"; export const gradientPosition: StyleFunctionResolver = ( @@ -19,14 +21,14 @@ export const colorStop: StyleFunctionResolver = (resolveValue, value) => { }; export const join: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); + const args = value[2]; - if (!Array.isArray(args)) { - return args; + if (!isStyleDescriptorArray(args)) { + return resolveValue(args); } - const array: unknown = args[0]; - const separator: unknown = args[1] ?? ","; + const array: unknown = resolveValue(args[0]); + const separator: unknown = resolveValue(args[1]) ?? ","; if (!Array.isArray(array)) { return array; diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts index 9a38d15e..c6db1c48 100644 --- a/src/native/styles/functions/transform-functions.ts +++ b/src/native/styles/functions/transform-functions.ts @@ -1,17 +1,22 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; - -import type { StyleFunctionResolver } from "../resolve"; +import type { StyleDescriptor } from "react-native-css/compiler"; +import { + commaListItems, + isCommaList, + isStyleDescriptorArray, +} from "react-native-css/utilities"; + +import type { SimpleResolveValue, StyleFunctionResolver } from "../resolve"; import { scaleFactor } from "../scale-factor"; export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; + const values = transformArguments(resolveValue, descriptor[2]); - if (!isStyleDescriptorArray(args)) { - return { scale: scaleFactor(resolveValue(args)) }; + if (values.length === 1) { + return { scale: scaleFactor(values[0]) }; } - const x = scaleFactor(resolveValue(args[0])); - const y = scaleFactor(resolveValue(args[1])); + const x = scaleFactor(values[0]); + const y = scaleFactor(values[1]); const isXValid = typeof x === "string" || typeof x === "number"; const isYValid = typeof y === "string" || typeof y === "number"; @@ -28,15 +33,13 @@ export const scale: StyleFunctionResolver = (resolveValue, descriptor) => { }; export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; + const values = transformArguments(resolveValue, descriptor[2]); - if (!isStyleDescriptorArray(args)) { - return { rotate: resolveValue(args) }; + if (values.length === 1) { + return { rotate: values[0] }; } - const x = resolveValue(args[0]); - const y = resolveValue(args[1]); - const z = resolveValue(args[2]); + const [x, y, z] = values; const isXValid = typeof x === "string" || typeof x === "number"; const isYValid = typeof y === "string" || typeof y === "number"; @@ -62,14 +65,7 @@ export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => { }; export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { - const args = descriptor[2]; - - if (!isStyleDescriptorArray(args)) { - return; - } - - const x = resolveValue(args[0]); - const y = resolveValue(args[1]); + const [x, y] = transformArguments(resolveValue, descriptor[2]); const isXValid = typeof x === "string" || typeof x === "number"; const isYValid = typeof y === "string" || typeof y === "number"; @@ -84,3 +80,16 @@ export const translate: StyleFunctionResolver = (resolveValue, descriptor) => { return; }; + +/** The values in their written places, so one that resolves to nothing moves no other, or the items of a comma-separated list a lone value resolves to. */ +function transformArguments( + resolveValue: SimpleResolveValue, + args: StyleDescriptor, +): unknown[] { + if (isStyleDescriptorArray(args)) { + return commaListItems(args).map((value) => resolveValue(value)); + } + + const resolved: unknown = resolveValue(args); + return isCommaList(resolved) ? commaListItems(resolved) : [resolved]; +} diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..92b19d76 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,7 +5,11 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isCommaList, + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; @@ -91,15 +95,15 @@ export function resolveValue( } if (isStyleDescriptorArray(value)) { - value = value - .map((d) => resolveValue(d, get, options)) - .filter((d) => d !== undefined); - - if (castToArray && !Array.isArray(value)) { - return [value]; - } else { - return value; - } + const first = value[0]; + + // An item is an array that does not start as a function does, with its marker object then its name, tested inline as Hermes pays for a call on every list + return Array.isArray(first) && + (typeof first[0] !== "object" || + Array.isArray(first[0]) || + typeof first[1] !== "string") + ? resolveCommaList(value, get, options) + : resolveTokens(value, get, options); } const name = value[1]; @@ -141,15 +145,9 @@ export function resolveValue( if (args === undefined) { return; } else if (Array.isArray(args)) { - let joinedArgs = args - .map((arg: unknown) => { - if (Array.isArray(arg)) { - return arg.flat().join(" "); - } - return arg; - }) - .filter((value) => value !== "/") - .join(", "); + let joinedArgs = isCommaList(args) + ? args.map((item: readonly unknown[]) => item.join(" ")).join(", ") + : args.join(" "); if (name === "radial-gradient") { // Nativewind / Tailwind CSS hack which can force the 'in oklab' color space @@ -166,3 +164,86 @@ export function resolveValue( } } } + +/** Resolves the items of a comma-separated list, each the array of its tokens. */ +function resolveCommaList( + list: StyleDescriptor[], + get: Getter, + options: ResolveValueOptions, +): unknown[] { + const items: unknown[] = []; + + for (const tokens of list) { + // A function is one token though it is an array; a list taken for one is still resolved whole, as resolveValue tells them apart + const isTokens = + Array.isArray(tokens) && + (typeof tokens[0] !== "object" || + Array.isArray(tokens[0]) || + typeof tokens[1] !== "string"); + const item = resolveTokens( + isTokens ? (tokens as StyleDescriptor[]) : [tokens], + get, + options, + ); + + if (Array.isArray(item[0])) { + for (const split of item) items.push(split); + } else if (item.length > 0) { + items.push(item); + } + } + + return items; +} + +/** Resolves a space-separated list's tokens as css-variables-1 §3 substitutes a var(): a list joins it in its place, and a comma-separated one splits it. */ +function resolveTokens( + tokens: StyleDescriptor[], + get: Getter, + options: ResolveValueOptions, +): unknown[] { + if (tokens.length === 1) { + // A lone token's value is the list + const resolved: unknown = resolveValue(tokens[0], get, options); + return Array.isArray(resolved) + ? resolved + : resolved === undefined + ? [] + : [resolved]; + } + + let item: unknown[] = []; + const items: unknown[][] = [item]; + let isFirst = true; + + for (const token of tokens) { + const resolved: unknown = resolveValue(token, get, options); + + // Only a function or a nested list can resolve to a list + if (typeof token !== "object" || !Array.isArray(resolved)) { + if (resolved !== undefined) item.push(resolved); + } else if (isFirst && !isStyleFunction(token)) { + // A list led by an item, which here starts with an object as only a resolved list can, is comma-separated + return resolveCommaList(tokens, get, options); + } else if (!Array.isArray(resolved[0])) { + for (const value of resolved) item.push(value); + } else { + // A resolved list holds no functions, so one led by an array is comma-separated: its first item continues this one, each later one starts its own + let startsItem = false; + + for (const group of resolved) { + if (startsItem) { + item = []; + items.push(item); + } + + startsItem = true; + for (const value of group) item.push(value); + } + } + + isFirst = false; + } + + return items.length === 1 ? item : items; +} diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index 20936e49..60f62d03 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -1,5 +1,7 @@ -import type { StyleDescriptor } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isCommaList, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; @@ -45,42 +47,17 @@ export const boxShadow: StyleFunctionResolver = ( return args; } - // 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 = isCommaList(args) ? args : [args]; - return args - .flatMap(flattenShadowDescriptor) - .map((shadows) => { - if (shadows === undefined) { - return; - } else { - return normalizeInsetValue( - omitTransparentShadows(handler(resolveValue, shadows, get, options)), - ); - } - }) + return shadows + .map((shadow) => + normalizeInsetValue( + omitTransparentShadows(handler(resolveValue, shadow, get, options)), + ), + ) .filter((v) => v !== undefined); }; -function flattenShadowDescriptor(arg: StyleDescriptor): StyleDescriptor[] { - if (isStyleDescriptorArray(arg) && isStyleDescriptorArray(arg[0])) { - return arg.map((arg) => { - return flattenShadowDescriptor(arg); - }); - } - - return [arg]; -} - function omitTransparentShadows(style: unknown) { if (typeof style === "object" && style && "color" in style) { if (style.color === "#0000" || style.color === "transparent") { diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index ef603dd8..efa26afe 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -1,3 +1,5 @@ +import { isCommaList } from "react-native-css/utilities"; + import type { StyleFunctionResolver } from "../resolve"; /** @@ -10,7 +12,10 @@ export const transform: StyleFunctionResolver = ( ) => { const transforms = resolveValue(transformDescriptor[2]); - if (Array.isArray(transforms)) { + // css-transforms-1 §4: a transform list is space-separated, so a comma makes the declaration invalid at computed-value time + if (isCommaList(transforms)) { + return null; + } else if (Array.isArray(transforms)) { return transforms.filter( (transform) => transform !== undefined && transform !== "initial", ) as unknown; diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 7a166fa1..01d2e3c9 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -17,3 +17,22 @@ export function isStyleFunction(value: unknown): value is StyleFunction { typeof value[1] === "string" ); } + +/** A comma-separated list holds each item as the array of its tokens, and a token is never such an array, so its first item tells. */ +export function isCommaList(value: unknown): value is StyleDescriptor[][] { + return ( + Array.isArray(value) && + Array.isArray(value[0]) && + !isStyleFunction(value[0]) + ); +} + +/** A comma-separated list with a lone token in place of its array, the form function arguments and React Native's list props take. */ +export function commaListItems(value: StyleDescriptor[]): StyleDescriptor[]; +export function commaListItems(value: StyleDescriptor): StyleDescriptor; +export function commaListItems(value: unknown): unknown; +export function commaListItems(value: unknown): unknown { + return isCommaList(value) + ? value.map((item) => (item.length === 1 ? item[0] : item)) + : value; +}