From 99dc7628f296640affba2a90f8ebd9ec67b0c02c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 15:50:16 +0300 Subject: [PATCH 1/2] refactor(compiler): delete the unused selectors module and give toRNProperty one home src/compiler/selectors.ts's getSelectors is imported by nothing; the compiler builds selectors in selector-builder.ts. The module stayed alive only through its toRNProperty, which atRules.ts and declarations.ts imported while selector-builder.ts and stylesheet.ts used a second copy. toRNProperty moves to utilities/property-names.ts and every caller imports it from there. --- src/compiler/atRules.ts | 2 +- src/compiler/declarations.ts | 3 +- src/compiler/selector-builder.ts | 13 +- src/compiler/selectors.ts | 505 ------------------------------- src/compiler/stylesheet.ts | 3 +- src/utilities/index.ts | 1 + src/utilities/property-names.ts | 10 + 7 files changed, 16 insertions(+), 521 deletions(-) delete mode 100644 src/compiler/selectors.ts create mode 100644 src/utilities/property-names.ts diff --git a/src/compiler/atRules.ts b/src/compiler/atRules.ts index 6943f672..730aef41 100644 --- a/src/compiler/atRules.ts +++ b/src/compiler/atRules.ts @@ -6,8 +6,8 @@ import type { TokenOrValue, } from "lightningcss"; +import { toRNProperty } from "../utilities"; import type { StyleRuleMapping } from "./compiler.types"; -import { toRNProperty } from "./selectors"; import { splitByDelimiter } from "./split-by-delimiter"; import type { StylesheetBuilder } from "./stylesheet"; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..62777dfb 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, toRNProperty } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -44,7 +44,6 @@ import type { StyleRule, } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; -import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; const CommaSeparator = Symbol("CommaSeparator"); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..f2a2cd75 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -1,6 +1,6 @@ import type { AttrOperation, Selector, SelectorList } from "lightningcss"; -import { Specificity } from "../utilities"; +import { Specificity, toRNProperty } from "../utilities"; import type { AttributeQuery, AttrSelectorOperator, @@ -597,17 +597,6 @@ function isUniversalSelector([first, second]: Selector) { return first && first.type === "universal" && !second; } -export function toRNProperty(str: T) { - return str - .replace(/^-rn-/, "") - .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase; -} - -type CamelCase = - S extends `${infer P1}-${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : Lowercase; - const operatorMap: Record = { "equal": "=", "includes": "~=", diff --git a/src/compiler/selectors.ts b/src/compiler/selectors.ts deleted file mode 100644 index b8623072..00000000 --- a/src/compiler/selectors.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* eslint-disable */ -import type { Selector, SelectorList } from "lightningcss"; - -import { Specificity } from "../utilities"; -import type { - AttributeQuery, - AttrSelectorOperator, - CompilerOptions, - ContainerQuery, - MediaCondition, - PseudoClassesQuery, - SpecificityArray, -} from "./compiler.types"; -import { StylesheetBuilder } from "./stylesheet"; - -export type NormalizeSelector = - | ClassNameSelector - | { - type: "rootVariables" | "universalVariables"; - subtype: "light" | "dark"; - }; - -type ClassNameSelector = { - type: "className"; - specificity: SpecificityArray; - className: string; - mediaQuery?: MediaCondition[]; - containerQuery?: ContainerQuery[]; - pseudoClassesQuery?: PseudoClassesQuery; - attributeQuery?: AttributeQuery[]; -}; - -/** - * Turns a CSS selector into a `react-native-css` selector. - */ -export function getSelectors( - selectorList: SelectorList, - isDarkMode: boolean, - builder: StylesheetBuilder, - selectors: NormalizeSelector[] = [], -) { - for (let cssSelector of selectorList) { - // Ignore `:is()` & `:where()`, and just process its selectors - if (isIsPseudoClass(cssSelector) || isWherePseudoClass(cssSelector)) { - getSelectors(cssSelector[0].selectors, isDarkMode, builder, selectors); - } else if ( - // Matches: :root {} - isRootVariableSelector(cssSelector) - ) { - // Matches: @media(prefers-dark-mode) { :root {} } - if (isDarkMode) { - selectors.push({ - type: "rootVariables", - subtype: "dark", - }); - } else { - selectors.push({ - type: "rootVariables", - subtype: "light", - }); - } - } else if ( - // Matches: .dark:root {} || :root[class~="dark"] - isRootDarkVariableSelector(cssSelector) - ) { - selectors.push({ - type: "rootVariables", - subtype: "dark", - }); - } else if ( - // Matches @media(prefers-dark-mode) { * {} } - isDarkMode && - isDefaultVariableSelector(cssSelector) - ) { - selectors.push({ - type: "universalVariables", - subtype: "dark", - }); - } else if ( - // Matches: * {} - isDefaultVariableSelector(cssSelector) - ) { - selectors.push({ - type: "universalVariables", - subtype: "light", - }); - // } else if ( - // // Matches: .dark * {} - // isDarkUniversalSelector(cssSelector) - // ) { - // selectors.push({ - // type: "universalVariables", - // subtype: "dark", - // }); - // } else if ( - // // Matches: .dark {} - // isDarkClassLegacySelector(cssSelector, collection) - // ) { - // const [_, __, third, ...rest] = cssSelector; - - // getSelectors(extractedStyle, [[third!, ...rest]], collection, selectors, [ - // ["=", "prefers-color-scheme", "dark"], - // ]); - // } else if ( - // // Matches: :is(.dark *) {} - // isDarkClassSelector(cssSelector, collection) - // ) { - // const [first] = cssSelector; - - // getSelectors(extractedStyle, [[first!]], collection, selectors, [ - // ["=", "prefers-color-scheme", "dark"], - // ]); - } else { - const selector = classNameSelector(cssSelector, builder.getOptions()); - - if (selector === null) { - continue; - } - - selectors.push(selector); - } - } - - return selectors; -} - -function classNameSelector( - selector: Selector, - options: CompilerOptions, -): ClassNameSelector | null { - let primaryClassName: string | undefined; - const specificity: SpecificityArray = []; - let mediaQuery: MediaCondition[] | undefined; - let containerQuery: ContainerQuery[] | undefined; - let attributeQuery: AttributeQuery[] | undefined; - let pseudoClassesQuery: PseudoClassesQuery | undefined; - - let currentContainerQuery: ContainerQuery | undefined; - - let isInClassBlock = false; - let newBlock = false; - - function getAttributeQuery() { - if (newBlock) { - currentContainerQuery ??= {}; - } - - if (currentContainerQuery) { - if (!currentContainerQuery.a) { - currentContainerQuery.a = []; - } - return currentContainerQuery.a; - } else { - attributeQuery ??= []; - return attributeQuery; - } - } - - function getPseudoClassesQuery() { - if (newBlock) { - currentContainerQuery ??= {}; - } - - if (currentContainerQuery) { - if (!currentContainerQuery.p) { - currentContainerQuery.p = {}; - } - return currentContainerQuery.p; - } else { - pseudoClassesQuery ??= {}; - return pseudoClassesQuery; - } - } - - /* - * Loop over each token in reverse order. - */ - for (const component of selector.reverse()) { - switch (component.type) { - case "universal": - case "namespace": - case "id": - case "pseudo-element": - // We don't support these selectors at all - return null; - case "nesting": - continue; - case "class": { - if (!primaryClassName) { - primaryClassName = component.name; - } else if (isInClassBlock) { - getAttributeQuery().unshift(["a", "className", "*=", component.name]); - } else if (component.name !== options.selectorPrefix) { - if (currentContainerQuery?.n) { - getAttributeQuery().unshift([ - "a", - "className", - "*=", - component.name, - ]); - } else { - currentContainerQuery ??= {}; - currentContainerQuery.n = component.name; - - containerQuery ??= []; - containerQuery.unshift(currentContainerQuery); - } - } - - isInClassBlock = true; - newBlock = false; - - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - break; - } - case "pseudo-class": { - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - // if (component.kind === "is") { - // if (isDarkUniversalSelector(component.selectors[0]!, collection)) { - // const lastGroup = acc.classNames[acc.classNames.length - 1]!; - // lastGroup[1] ??= {}; - // lastGroup[1].m ??= []; - // lastGroup[1].m.push(["=", "prefers-color-scheme", "dark"]); - // break; - // } - // } - - switch (component.kind) { - case "hover": { - getPseudoClassesQuery().h = 1; - break; - } - case "active": { - getPseudoClassesQuery().a = 1; - break; - } - case "focus": { - getPseudoClassesQuery().f = 1; - break; - } - case "disabled": { - getAttributeQuery().push(["a", "disabled"]); - break; - } - case "empty": { - getAttributeQuery().push(["a", "children", "!"]); - break; - } - default: { - // We don't support other pseudo-classes - return null; - } - } - break; - } - case "attribute": { - // We don't support attribute selectors as standalone selectors - // Except for a top level [dir] selector - // if (classNames.length === 0) { - // if ( - // component.name === "dir" && - // component.operation?.operator === "equal" - // ) { - // mediaQuery ??= []; - // mediaQuery.push(["!!", component.operation.value]); - // break; - // } - - // // Ignore any other attributes that are not part of a class selector - // return null; - // } - - // Turn attribute selectors into AttributeConditions - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - // [data-*] are turned into `dataSet` queries - // Everything else is turned into `attribute` queries - const attributeQuery: AttributeQuery = component.name.startsWith( - "data-", - ) - ? ["d", toRNProperty(component.name.replace("data-", ""))] - : ["a", toRNProperty(component.name)]; - - if (component.operation) { - let operator: AttrSelectorOperator | undefined; - - switch (component.operation.operator) { - case "equal": - operator = "="; - break; - case "includes": - operator = "~="; - break; - case "dash-match": - operator = "|="; - break; - case "prefix": - operator = "^="; - break; - case "substring": - operator = "*="; - break; - case "suffix": - operator = "$="; - break; - default: - component.operation.operator satisfies never; - break; - } - - if (operator) { - attributeQuery.push(operator, component.operation.value); - } - } - - getAttributeQuery().push(attributeQuery); - break; - } - case "type": { - /* - * We only support type selectors as part of the selector prefix - * For example: `html .my-class` - * - * NOTE: We ignore specificity for this - */ - if (component.name === options.selectorPrefix) { - break; - } - return null; - } - case "combinator": { - // We only support the descendant combinator - if (component.value === "descendant") { - isInClassBlock = false; - newBlock = true; - currentContainerQuery = undefined; - break; - } - - return null; - } - } - } - - if (!primaryClassName) { - // No class name found, return null - return null; - } - - return { - type: "className", - specificity: specificity, - className: primaryClassName, - mediaQuery, - containerQuery, - pseudoClassesQuery, - attributeQuery, - }; -} - -function isIsPseudoClass( - selector: Selector, -): selector is [{ type: "pseudo-class"; kind: "is"; selectors: Selector[] }] { - return ( - selector.length === 1 && - selector[0]?.type === "pseudo-class" && - selector[0].kind === "is" - ); -} - -function isWherePseudoClass( - selector: Selector, -): selector is [ - { type: "pseudo-class"; kind: "where"; selectors: Selector[] }, -] { - return ( - selector.length === 1 && - selector[0]?.type === "pseudo-class" && - selector[0].kind === "where" - ); -} - -// function isDarkModeMediaQuery(query?: MediaCondition): boolean { -// if (!query) return false; - -// return ( -// query[0] === "=" && -// query[1] === "prefers-color-scheme" && -// query[2] === "dark" -// ); -// } - -// Matches: :is(.dark *) -// function isDarkClassSelector( -// [first, second, third]: Selector, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// return false; -// } - -// return ( -// first && -// second && -// !third && -// first.type === "class" && -// second.type === "pseudo-class" && -// second.kind === "is" && -// second.selectors.length === 1 && -// second.selectors[0]?.length === 3 && -// second.selectors[0][0]?.type === "class" && -// second.selectors[0][0].name === collection.darkMode && -// second.selectors[0][1]?.type === "combinator" && -// second.selectors[0][1].value === "descendant" && -// second.selectors[0][2]?.type === "universal" -// ); -// } - -// Matches: .dark {} -// function isDarkClassLegacySelector( -// [first, second, third]: Selector, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// return false; -// } - -// return ( -// first && -// second && -// third && -// first.type === "class" && -// first.name === collection.darkMode && -// second.type === "combinator" && -// second.value === "descendant" && -// third.type === "class" -// ); -// } - -// Matches: :root {} -function isRootVariableSelector([first, second]: Selector) { - return ( - first && !second && first.type === "pseudo-class" && first.kind === "root" - ); -} - -// Matches: * {} -function isDefaultVariableSelector([first, second]: Selector) { - return first && !second && first.type === "universal"; -} - -// Matches: .dark:root {} -function isRootDarkVariableSelector([first, second]: Selector) { - return ( - first && - second && - // .dark:root {} - ((first.type === "class" && - second.type === "pseudo-class" && - second.kind === "root") || - // :root[class~=dark] {} - (first.type === "pseudo-class" && - first.kind === "root" && - second.type === "attribute" && - second.name === "class" && - second.operation && - ["includes", "equal"].includes(second.operation.operator))) - ); -} - -// Matches: .dark * {} -// function isDarkUniversalSelector( -// [first, second, third]: Selector, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// return false; -// } -// return ( -// first && -// second && -// third && -// first.type === "class" && -// first.name === collection.darkMode && -// second.type === "combinator" && -// second.value === "descendant" && -// third.type === "universal" -// ); -// } - -export function toRNProperty(str: T) { - return str - .replace(/^-rn-/, "") - .replace(/-./g, (x) => x[1]!.toUpperCase()) as CamelCase; -} - -type CamelCase = - S extends `${infer P1}-${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : Lowercase; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..ffb4c581 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -4,6 +4,7 @@ import { isStyleDescriptorArray, Specificity, specificityCompareFn, + toRNProperty, } from "../utilities"; import type { AnimationKeyframes, @@ -25,7 +26,7 @@ import { modifyRuleForPlaceholder, modifyRuleForSelection, } from "./pseudo-elements"; -import { getClassNameSelectors, toRNProperty } from "./selector-builder"; +import { getClassNameSelectors } from "./selector-builder"; type BuilderMode = "style" | "media" | "container" | "keyframes"; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..4470e985 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 "./property-names"; diff --git a/src/utilities/property-names.ts b/src/utilities/property-names.ts new file mode 100644 index 00000000..ee8fabe2 --- /dev/null +++ b/src/utilities/property-names.ts @@ -0,0 +1,10 @@ +type CamelCase = + S extends `${infer P1}-${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : Lowercase; + +export function toRNProperty(str: T) { + return str + .replace(/^-rn-/, "") + .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase; +} From 35dc97b7e7eb52743259340e2404f4cad61c8a9a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 29 Oct 2026 03:55:50 +0200 Subject: [PATCH 2/2] fix(native): animate a transition or animation held in a variable A transition held in a variable reached React Native unexpanded: `transition: var(--t)` rendered `{ transition: [...] }`, and `transition-property: var(--p)` named the CSS properties as written. Every transition and animation property held in a variable now resolves at runtime into the lists the compiler builds for a written value, its keywords in any case, a list holding list variables read as one list, a time a script sets as "300ms", and only Reanimated's easing objects taken as a timing function, or writes nothing for a value its property refuses. A list the declaration writes is refused for a comma group of several tokens in a longhand or a transition group naming two properties. `transition-behavior`, which lightningcss does not parse, is read the same way when written. A value with no variable is read where the cascade puts it, as `animation` already is, and a value the compiler cannot read at all is dropped. A rule was marked animated by its property only when its value was static, and by the style function's name otherwise, so a longhand read through a variable, or a timing function written as `cubic-bezier()` or `steps()`, left its element unanimated. The mark now follows the property whatever the value. `transitionStyleKey` names the key a transition animates on both routes: the key the property is written to, so `background-image` names `experimental_backgroundImage`, and for a property no style key carries, such as `visibility`, its own name, so it keeps its place in the list and every later property keeps its own timing. --- src/__tests__/compiler/compiler.test.tsx | 32 + src/__tests__/native/_reanimated-routes.tsx | 77 +++ src/__tests__/native/animations.test.tsx | 75 ++- .../native/transition-variables.test.tsx | 624 ++++++++++++++++++ .../utilities/property-names.test.ts | 44 ++ src/compiler/compiler.ts | 2 +- src/compiler/declarations.ts | 131 ++-- src/compiler/keyframes.ts | 6 +- src/compiler/stylesheet.ts | 44 +- src/native/styles/shorthands/_lists.ts | 135 ++++ .../styles/shorthands/animation-longhands.ts | 44 ++ src/native/styles/shorthands/animation.ts | 15 +- src/native/styles/shorthands/index.ts | 2 + src/native/styles/shorthands/transition.ts | 295 +++++++++ src/utilities/index.ts | 1 + src/utilities/numbers.ts | 4 + src/utilities/property-names.ts | 29 + src/utilities/style-descriptor.ts | 8 + 18 files changed, 1482 insertions(+), 86 deletions(-) create mode 100644 src/__tests__/native/_reanimated-routes.tsx create mode 100644 src/__tests__/native/transition-variables.test.tsx create mode 100644 src/__tests__/utilities/property-names.test.ts create mode 100644 src/native/styles/shorthands/_lists.ts create mode 100644 src/native/styles/shorthands/animation-longhands.ts create mode 100644 src/native/styles/shorthands/transition.ts create mode 100644 src/utilities/numbers.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..328d2006 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -181,6 +181,38 @@ test("animations preserve named keyframes and their timing contract", () => { ]); }); +test.each([ + "animation: var(--v)", + "animation-delay: var(--v)", + "animation-direction: var(--v)", + "animation-duration: var(--v)", + "animation-fill-mode: var(--v)", + "animation-iteration-count: var(--v)", + "animation-name: var(--v)", + "animation-play-state: var(--v)", + "animation-timing-function: var(--v)", + "animation-timing-function: cubic-bezier(0.25, 0.5, 0.75, 1)", + "animation-timing-function: steps(4)", + "transition: var(--v)", + "transition-delay: var(--v)", + "transition-duration: var(--v)", + "transition-duration: var(--v) !important", + "transition-property: var(--v)", + "transition-timing-function: var(--v)", + "transition-timing-function: cubic-bezier(0.25, 0.5, 0.75, 1)", + "transition-timing-function: steps(4)", + "transition-behavior: allow-discrete", + "transition-behavior: var(--v)", +])("a rule writing %s carries the animated rule marker", (declaration) => { + const rule = compile(`.test { ${declaration}; }`).stylesheet().s?.[0]?.[1][0]; + expect(rule?.a).toBe(true); +}); + +test("a rule writing no animation or transition carries no animated rule marker", () => { + const { s } = compile(`.test { width: var(--v); }`).stylesheet(); + expect(s?.[0]?.[1][0]?.a).toBeUndefined(); +}); + test("breaks apart comma separated variables", () => { const compiled = compile( ` diff --git a/src/__tests__/native/_reanimated-routes.tsx b/src/__tests__/native/_reanimated-routes.tsx new file mode 100644 index 00000000..04ccd647 --- /dev/null +++ b/src/__tests__/native/_reanimated-routes.tsx @@ -0,0 +1,77 @@ +import { StyleSheet } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { StyleCollection } from "react-native-css/native"; + +import { animatedComponentFamily } from "../../native/reanimated"; + +/** As written, through a variable the compiler folds, through one the runtime keeps, and kept through the JSON a device reads. */ +export const routes = ["written", "folded", "kept", "wire"] as const; + +export type Route = (typeof routes)[number]; + +export const variableRoutes = ["folded", "kept", "wire"] as const; + +/** The routes on which the runtime, not lightningcss, reads the value. */ +export const runtimeRoutes = ["kept", "wire"] as const; + +/** The routes on which the declaration itself holds the value's comma groups. */ +export const declarationRoutes = ["written", "folded"] as const; + +function registerOnRoute( + route: Route, + property: string, + value: string, + reference: string, + declarations: string, +) { + const css = + route === "written" + ? `.a { ${declarations} ${property}: ${value}; }` + : `.a { ${declarations} --v: ${value}; ${property}: ${reference}; }`; + + switch (route) { + case "written": + case "folded": + registerCSS(css); + return; + case "kept": + registerCSS(css, { inlineVariables: false }); + return; + case "wire": { + const sheet = compile(css, { inlineVariables: false }).stylesheet(); + StyleCollection.inject(JSON.parse(JSON.stringify(sheet)) as typeof sheet); + return; + } + default: + route satisfies never; + } +} + +/** + * Whether `.a { property: value }` hands its element to Reanimated on a route, and the style it hands over; a variable route holds `value` in `--v` and writes `reference`, after the rule's other `declarations`. + * The calling test file mocks `../../native/reanimated` with a `jest.fn` that returns the component it is given. + */ +export function renderOnRoute( + route: Route, + property: string, + value: string, + reference = "var(--v)", + declarations = "", +) { + registerOnRoute(route, property, value, reference, declarations); + jest.mocked(animatedComponentFamily).mockClear(); + render(); + + const reached = { + animated: jest.mocked(animatedComponentFamily).mock.calls.length > 0, + style: (StyleSheet.flatten(screen.getByTestId(testID).props.style) ?? + {}) as Readonly>, + }; + + screen.unmount(); + return reached; +} diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index c9565d16..f7c54858 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -5,10 +5,16 @@ import { render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +import { renderOnRoute, routes, runtimeRoutes } from "./_reanimated-routes"; + +const reanimated = jest.requireActual( + "react-native-reanimated", +); + // Check the engine output at the Reanimated boundary. Native interpolation is // covered by the Release renderer suite, not by a fake getAnimatedStyle helper. jest.mock("../../native/reanimated", () => ({ - animatedComponentFamily: (component: unknown) => component, + animatedComponentFamily: jest.fn((component: unknown) => component), })); const style = () => StyleSheet.flatten(screen.getByTestId(testID).props.style) as @@ -96,6 +102,73 @@ test.each([ }, ); +describe.each([ + ["animation-delay", "200ms", { animationDelay: [200] }], + ["animation-delay", "-1s", { animationDelay: [-1000] }], + ["animation-direction", "alternate", { animationDirection: ["alternate"] }], + ["animation-direction", "ALTERNATE", { animationDirection: ["alternate"] }], + ["animation-duration", "3s", { animationDuration: [3000] }], + ["animation-duration", "1s, 2s", { animationDuration: [1000, 2000] }], + ["animation-fill-mode", "both", { animationFillMode: ["both"] }], + ["animation-iteration-count", "2", { animationIterationCount: [2] }], + [ + "animation-iteration-count", + "infinite", + { animationIterationCount: ["infinite"] }, + ], + [ + "animation-iteration-count", + "INFINITE", + { animationIterationCount: ["infinite"] }, + ], + ["animation-play-state", "paused", { animationPlayState: ["paused"] }], + [ + "animation-timing-function", + "ease-in", + { animationTimingFunction: "ease-in" }, + ], + [ + "animation-timing-function", + "EASE-IN", + { animationTimingFunction: "ease-in" }, + ], + [ + "animation-timing-function", + "cubic-bezier(0.25, 0.5, 0.75, 1)", + { animationTimingFunction: reanimated.cubicBezier(0.25, 0.5, 0.75, 1) }, + ], + [ + "animation-timing-function", + "steps(4, end)", + { animationTimingFunction: reanimated.steps(4, "end") }, + ], +])("%s: %s", (property, value, style) => { + test.each(routes)( + "%s reaches Reanimated with the written animation", + (route) => { + expect(renderOnRoute(route, property, value)).toStrictEqual({ + animated: true, + style, + }); + }, + ); +}); + +describe.each([ + ["animation-duration", "-1s"], + ["animation-delay", "paused"], + ["animation-direction", "sideways"], + ["animation-fill-mode", "2"], + ["animation-iteration-count", "-1"], + ["animation-play-state", "running, stopped"], + ["animation-timing-function", "ease-in, sideways"], + ["animation-timing-function", "scale(2)"], +])("%s held in a variable over %s is invalid", (property, value) => { + test.each(runtimeRoutes)("and writes no animation %s", (route) => { + expect(renderOnRoute(route, property, value).style).toStrictEqual({}); + }); +}); + test("an unresolved animation name is omitted and a later variable restores it", () => { registerCSS( `${keyframes} .motion { animation-name: var(--animation-name); animation-duration: 1s; } diff --git a/src/__tests__/native/transition-variables.test.tsx b/src/__tests__/native/transition-variables.test.tsx new file mode 100644 index 00000000..4fc92aa7 --- /dev/null +++ b/src/__tests__/native/transition-variables.test.tsx @@ -0,0 +1,624 @@ +import { StyleSheet } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { VariableContextProvider } from "react-native-css"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +import { + declarationRoutes, + renderOnRoute, + routes, + runtimeRoutes, + variableRoutes, +} from "./_reanimated-routes"; + +// Observe the engine's transition contract before Reanimated consumes it. +jest.mock("../../native/reanimated", () => ({ + animatedComponentFamily: jest.fn((component: unknown) => component), +})); + +const { cubicBezier, steps } = jest.requireActual< + typeof import("react-native-reanimated") +>("react-native-reanimated"); +const easeQuarter = cubicBezier(0.25, 0.5, 0.75, 1); + +describe.each([ + [ + "transition", + "width 1s ease-in 200ms", + { + transitionProperty: ["width"], + transitionDuration: [1000], + transitionDelay: [200], + transitionTimingFunction: "ease-in", + }, + ], + [ + "transition", + "width 1s, opacity 2s linear", + { + transitionProperty: ["width", "opacity"], + transitionDuration: [1000, 2000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "linear"], + }, + ], + [ + "transition", + "width, opacity", + { + transitionProperty: ["width", "opacity"], + transitionDuration: [0, 0], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "width 1s, opacity", + { + transitionProperty: ["width", "opacity"], + transitionDuration: [1000, 0], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "width 1s cubic-bezier(0.25, 0.5, 0.75, 1)", + { + transitionProperty: ["width"], + transitionDuration: [1000], + transitionDelay: [0], + transitionTimingFunction: easeQuarter, + }, + ], + [ + "transition", + "background-image 1s", + { + transitionProperty: ["experimental_backgroundImage"], + transitionDuration: [1000], + transitionDelay: [0], + transitionTimingFunction: "ease", + }, + ], + [ + "transition", + "all 300ms ease-out 50ms", + { + transitionProperty: ["all"], + transitionDuration: [300], + transitionDelay: [50], + transitionTimingFunction: "ease-out", + }, + ], + [ + "transition", + "1s", + { + transitionProperty: ["all"], + transitionDuration: [1000], + transitionDelay: [0], + transitionTimingFunction: "ease", + }, + ], + [ + "transition", + "none", + { + transitionProperty: ["none"], + transitionDuration: [0], + transitionDelay: [0], + transitionTimingFunction: "ease", + }, + ], + [ + "transition", + "WIDTH 1s EASE-IN", + { + transitionProperty: ["width"], + transitionDuration: [1000], + transitionDelay: [0], + transitionTimingFunction: "ease-in", + }, + ], + [ + "transition", + "visibility 2s, width 1s", + { + transitionProperty: ["visibility", "width"], + transitionDuration: [2000, 1000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "foo 2s, width 1s", + { + transitionProperty: ["foo", "width"], + transitionDuration: [2000, 1000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "display 1s allow-discrete", + { + transitionBehavior: ["allow-discrete"], + transitionProperty: ["display"], + transitionDuration: [1000], + transitionDelay: [0], + transitionTimingFunction: "ease", + }, + ], + [ + "transition", + "display 1s allow-discrete, width 2s", + { + transitionBehavior: ["allow-discrete", "normal"], + transitionProperty: ["display", "width"], + transitionDuration: [1000, 2000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "1s, width 2s", + { + transitionProperty: ["all", "width"], + transitionDuration: [1000, 2000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "width 1s, 2s", + { + transitionProperty: ["width", "all"], + transitionDuration: [1000, 2000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition-behavior", + "allow-discrete", + { transitionBehavior: ["allow-discrete"] }, + ], + [ + "transition-behavior", + "normal, ALLOW-DISCRETE", + { transitionBehavior: ["normal", "allow-discrete"] }, + ], + ["transition-property", "width", { transitionProperty: ["width"] }], + [ + "transition-property", + "width, background-image, border-inline-start-color", + { + transitionProperty: [ + "width", + "experimental_backgroundImage", + "borderStartColor", + ], + }, + ], + ["transition-property", "none", { transitionProperty: ["none"] }], + [ + "transition-property", + "Width, Opacity", + { transitionProperty: ["width", "opacity"] }, + ], + ["transition-property", "ALL", { transitionProperty: ["all"] }], + ["transition-property", "NONE", { transitionProperty: ["none"] }], + [ + "transition-property", + "-webkit-transform", + { transitionProperty: ["transform"] }, + ], + [ + "transition-property", + "visibility, width", + { transitionProperty: ["visibility", "width"] }, + ], + [ + "transition-property", + "foo, width", + { transitionProperty: ["foo", "width"] }, + ], + ["transition-duration", "3s", { transitionDuration: [3000] }], + ["transition-duration", "1s, 2s", { transitionDuration: [1000, 2000] }], + ["transition-delay", "200ms", { transitionDelay: [200] }], + ["transition-delay", "-1s", { transitionDelay: [-1000] }], + [ + "transition-timing-function", + "ease-in", + { transitionTimingFunction: "ease-in" }, + ], + [ + "transition-timing-function", + "EASE-IN", + { transitionTimingFunction: "ease-in" }, + ], + [ + "transition-timing-function", + "ease-in, linear", + { transitionTimingFunction: ["ease-in", "linear"] }, + ], + [ + "transition-timing-function", + "cubic-bezier(0.25, 0.5, 0.75, 1)", + { transitionTimingFunction: easeQuarter }, + ], + [ + "transition-timing-function", + "steps(4, end)", + { transitionTimingFunction: steps(4, "end") }, + ], +])("%s: %s", (property, value, style) => { + test.each(routes)( + "%s reaches Reanimated with the written transition", + (route) => { + expect(renderOnRoute(route, property, value)).toStrictEqual({ + animated: true, + style, + }); + }, + ); +}); + +describe.each([ + [ + "transition", + "200ms ease-out", + "transform var(--v)", + { + transitionProperty: ["transform"], + transitionDuration: [200], + transitionDelay: [0], + transitionTimingFunction: "ease-out", + }, + ], + [ + "transition", + "width, height", + "var(--v) 1s", + { + transitionProperty: ["width", "height"], + transitionDuration: [0, 1000], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease"], + }, + ], + [ + "transition", + "200ms ease-out", + "width 1s, var(--v)", + { + transitionProperty: ["width", "all"], + transitionDuration: [1000, 200], + transitionDelay: [0, 0], + transitionTimingFunction: ["ease", "ease-out"], + }, + ], + [ + "transition", + "width 1s, height 2s", + "var(--v), opacity 3s", + { + transitionProperty: ["width", "height", "opacity"], + transitionDuration: [1000, 2000, 3000], + transitionDelay: [0, 0, 0], + transitionTimingFunction: ["ease", "ease", "ease"], + }, + ], + [ + "transition", + "width, height", + "var(--v), opacity 1s", + { + transitionProperty: ["width", "height", "opacity"], + transitionDuration: [0, 0, 1000], + transitionDelay: [0, 0, 0], + transitionTimingFunction: ["ease", "ease", "ease"], + }, + ], + [ + "transition-property", + "width, opacity", + "var(--v), height", + { transitionProperty: ["width", "opacity", "height"] }, + ], + [ + "transition-duration", + "1s, 3s", + "var(--v), 2s", + { transitionDuration: [1000, 3000, 2000] }, + ], + [ + "transition-timing-function", + "EASE-IN, linear", + "var(--v), ease-out", + { transitionTimingFunction: ["ease-in", "linear", "ease-out"] }, + ], +])("%s: %s held in %s", (property, value, reference, style) => { + test.each(variableRoutes)("%s reads as one list", (route) => { + expect(renderOnRoute(route, property, value, reference)).toStrictEqual({ + animated: true, + style, + }); + }); +}); + +test.each(variableRoutes)( + "a list two variables deep reads as one list %s", + (route) => { + expect( + renderOnRoute( + route, + "transition-duration", + "var(--w), 3s", + "var(--v), 4s", + "--w: 1s, 2s;", + ), + ).toStrictEqual({ + animated: true, + style: { transitionDuration: [1000, 2000, 3000, 4000] }, + }); + }, +); + +test.each(variableRoutes)( + "a transition list holding a list variable keeps that list's transitions %s", + (route) => { + expect( + renderOnRoute( + route, + "transition", + "var(--m), opacity 1s", + "var(--v)", + "--m: 150ms ease-in, 300ms ease-out;", + ), + ).toStrictEqual({ + animated: true, + style: { + transitionProperty: ["all", "all", "opacity"], + transitionDuration: [150, 300, 1000], + transitionDelay: [0, 0, 0], + transitionTimingFunction: ["ease-in", "ease-out", "ease"], + }, + }); + }, +); + +test.each(declarationRoutes)( + "transition over width height 1s, opacity 2s, a group naming two properties, writes nothing %s", + (route) => { + expect( + renderOnRoute(route, "transition", "width height 1s, opacity 2s").style, + ).toStrictEqual({}); + }, +); + +// lightningcss reads a written transition's negative first time as its duration, so these rows run where the runtime reads the value +describe.each([ + [ + "width -1s", + { + transitionProperty: ["width"], + transitionDuration: [0], + transitionDelay: [-1000], + transitionTimingFunction: "ease", + }, + ], + [ + "width -1s 1s", + { + transitionProperty: ["width"], + transitionDuration: [1000], + transitionDelay: [-1000], + transitionTimingFunction: "ease", + }, + ], +])( + "transition held in a variable over %s takes a negative time as its delay", + (value, style) => { + test.each(runtimeRoutes)("%s", (route) => { + expect(renderOnRoute(route, "transition", value)).toStrictEqual({ + animated: true, + style, + }); + }); + }, +); + +describe.each([ + ["transition", "width 1s 2s 3s"], + ["transition", "width 1s ease-in linear"], + ["transition", "none 1s, width 2s"], + ["transition", "width -1s -2s"], + ["transition", "display 1s allow-discrete normal"], + ["transition-property", "none, width"], + ["transition-property", "NONE, width"], + ["transition-property", "width, 5"], + ["transition-property", "width, unset"], + ["transition-property", "width, revert"], + ["transition-property", "width, revert-layer"], + ["transition-property", "width, default"], + ["transition-duration", "-1s"], + ["transition-duration", "ease-in"], + ["transition-delay", "ease-in"], + ["transition-timing-function", "1s"], + ["transition-timing-function", "ease-in, sideways"], + ["transition-timing-function", "rotate(45deg)"], + ["transition", "width 1s rotate(45deg)"], + ["transition-behavior", "sideways"], +])("%s held in a variable over %s is invalid", (property, value) => { + test.each(runtimeRoutes)("and writes no transition %s", (route) => { + expect(renderOnRoute(route, property, value).style).toStrictEqual({}); + }); +}); + +describe.each([ + ["transition-property", "width foo, height"], + ["transition-duration", "1s 2s, 3s"], + ["transition-timing-function", "ease ease-in, linear"], + ["transition-behavior", "normal allow-discrete, normal"], +])( + "%s over %s, a comma group of several tokens, is invalid", + (property, value) => { + test.each(declarationRoutes)("and writes no transition %s", (route) => { + expect(renderOnRoute(route, property, value).style).toStrictEqual({}); + }); + }, +); + +const renderedStyle = () => + (StyleSheet.flatten(screen.getByTestId(testID).props.style) ?? + {}) as Readonly>; + +function renderWithScriptVariables( + css: string, + variables: Readonly>, +) { + registerCSS(css, { inlineVariables: false }); + render( + + + , + ); + return renderedStyle(); +} + +test.each([ + ["transition-duration", "300ms", { transitionDuration: [300] }], + ["transition-duration", "0.3s", { transitionDuration: [300] }], + ["transition-delay", "-150MS", { transitionDelay: [-150] }], + ["transition-duration", "2.01s", { transitionDuration: [2010] }], + ["animation-duration", "1s", { animationDuration: [1000] }], + ["transition-duration", "fast", {}], + ["transition-duration", "10.", {}], + ["transition-duration", "1ems", {}], + ["transition-duration", "3 s", {}], + ["transition-duration", "300", {}], +])("%s through a variable a script sets to %s", (property, value, style) => { + expect( + renderWithScriptVariables(`.a { ${property}: var(--v); }`, { + "--v": value, + }), + ).toStrictEqual(style); +}); + +test.each([ + [ + "width var(--v)", + "300ms", + { + transitionProperty: ["width"], + transitionDuration: [300], + transitionDelay: [0], + transitionTimingFunction: "ease", + }, + ], +])( + "transition: %s through a variable a script sets to %s", + (reference, value, style) => { + expect( + renderWithScriptVariables(`.a { transition: ${reference}; }`, { + "--v": value, + }), + ).toStrictEqual(style); + }, +); + +test.each(["transition", "transition-property"])( + "%s through a variable a script sets to one string, width 300ms, names no property", + (property) => { + expect( + renderWithScriptVariables(`.a { ${property}: var(--v); }`, { + "--v": "width 300ms", + }), + ).toStrictEqual({}); + }, +); + +test.each([ + [ + "transition-timing-function", + "ease-out", + "auto", + { transitionTimingFunction: "ease-out" }, + ], + ["animation-delay", "1s", "auto", { animationDelay: [1000] }], + ["transition-duration", "1s", "1s 2s, 3s", { transitionDuration: [1000] }], + [ + "transition-behavior", + "allow-discrete", + "sideways", + { transitionBehavior: ["allow-discrete"] }, + ], + [ + "transition-behavior", + "allow-discrete", + "auto", + { transitionBehavior: ["allow-discrete"] }, + ], +])( + "a later %s that cannot be read leaves the earlier %s", + (property, earlier, later, style) => { + registerCSS( + `.base { ${property}: ${earlier}; } .x { ${property}: ${later}; }`, + ); + render(); + + expect(renderedStyle()).toStrictEqual(style); + }, +); + +test.each([ + ["transition-timing-function", "auto"], + ["animation-delay", "auto"], + ["transition-behavior", "auto"], +])( + "%s: %s, which the compiler cannot read, writes nothing", + (property, value) => { + expect(renderOnRoute("written", property, value)).toStrictEqual({ + animated: false, + style: {}, + }); + }, +); + +test("a later written transition overrides the timing of an earlier one the runtime reads", () => { + registerCSS( + `.a { transition: display 1s allow-discrete; } .b { transition: display 2s; }`, + ); + render(); + + expect(renderedStyle()).toMatchObject({ + transitionProperty: ["display"], + transitionDuration: [2000], + transitionDelay: [0], + transitionTimingFunction: "ease", + }); +}); + +test.each(["allow-discrete", "var(--b)"])( + "transition-behavior: %s after a transition in its rule holds", + (behavior) => { + registerCSS( + `.a { --b: allow-discrete; transition: width 1s; transition-behavior: ${behavior}; }`, + { inlineVariables: false }, + ); + render(); + + expect(renderedStyle().transitionBehavior).toStrictEqual([ + "allow-discrete", + ]); + }, +); diff --git a/src/__tests__/utilities/property-names.test.ts b/src/__tests__/utilities/property-names.test.ts new file mode 100644 index 00000000..8363c72b --- /dev/null +++ b/src/__tests__/utilities/property-names.test.ts @@ -0,0 +1,44 @@ +import { + propertyRename, + toRNProperty, + transitionStyleKey, +} from "react-native-css/utilities"; + +test("the rename census is not empty", () => { + expect(Object.keys(propertyRename).length).toBeGreaterThan(0); +}); + +test.each(Object.entries(propertyRename))( + "a transition on %s names the key it is written to", + (property, renamed) => { + expect(transitionStyleKey(property)).toBe(toRNProperty(renamed)); + }, +); + +test.each([ + ["width", "width"], + ["background-color", "backgroundColor"], + ["all", "all"], + ["none", "none"], +])("a transition on %s names %s", (property, key) => { + expect(transitionStyleKey(property)).toBe(key); +}); + +test.each([ + ["WIDTH", "width"], + ["Background-Image", "experimental_backgroundImage"], + ["-webkit-transform", "transform"], + ["ALL", "all"], +])( + "a transition on %s, a name read in any case and without its vendor prefix, names %s", + (property, key) => { + expect(transitionStyleKey(property)).toBe(key); + }, +); + +test.each(["visibility", "foo", "--foo"])( + "a transition on %s, which no style key carries, keeps its own name", + (property) => { + expect(transitionStyleKey(property)).toBe(property); + }, +); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..3b7c5e48 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -31,8 +31,8 @@ import { parseDeclaration, parseLength, reduceParseUnparsed, - round, } from "./declarations"; +import { round } from "../utilities"; import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 62777dfb..b450b4c9 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,13 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction, toRNProperty } from "../utilities"; +import { + isStyleFunction, + propertyRename, + round, + toRNProperty, + transitionStyleKey, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -60,19 +66,6 @@ type Parser = ( // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ) => StyleDescriptor | void; -const propertyRename: Record = { - "background-image": "experimental_backgroundImage", - // React Native has no border-inline-* props, but ships the equivalent - // RTL-aware border-start-* / border-end-* props - "border-inline-end-color": "border-end-color", - "border-inline-end-width": "border-end-width", - "border-inline-start-color": "border-start-color", - "border-inline-start-width": "border-start-width", - "border-block-start-width": "border-top-width", - "border-block-end-width": "border-bottom-width", - "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. @@ -85,20 +78,35 @@ const unsupportedLogicalStyles = new Set([ "border-block-end-style", ]); -const unparsedRuntimeParsing = new Set([ - "animation", - "animation-name", - "border", - "border-block-width", - "border-inline-width", - "box-shadow", - "filter", - "line-height", - "rotate", - "scale", - "text-shadow", - "transform", - "translate", +/** Where the runtime reads a value the compiler leaves unparsed: where the cascade puts it, or once every rule has applied. */ +type RuntimeReading = "in place" | "after every rule"; + +const unparsedRuntimeParsing = new Map([ + ["animation", "in place"], + ["animation-delay", "in place"], + ["animation-direction", "in place"], + ["animation-duration", "in place"], + ["animation-fill-mode", "in place"], + ["animation-iteration-count", "in place"], + ["animation-name", "after every rule"], + ["animation-play-state", "in place"], + ["animation-timing-function", "in place"], + ["border", "after every rule"], + ["border-block-width", "after every rule"], + ["border-inline-width", "after every rule"], + ["box-shadow", "after every rule"], + ["filter", "after every rule"], + ["line-height", "after every rule"], + ["rotate", "after every rule"], + ["scale", "after every rule"], + ["text-shadow", "after every rule"], + ["transform", "after every rule"], + ["transition", "in place"], + ["transition-delay", "in place"], + ["transition-duration", "in place"], + ["transition-property", "in place"], + ["transition-timing-function", "in place"], + ["translate", "after every rule"], ]); const parsers: { @@ -951,6 +959,34 @@ function parseContainer( return; } +/** A value the runtime reads, as the style function its resolver takes; one the compiler cannot read adds nothing. */ +function addRuntimeReading( + builder: StylesheetBuilder, + property: string, + value: StyleDescriptor, + reading: RuntimeReading, +) { + if (value !== undefined) { + builder.addDescriptor( + property, + runtimeStyleFunction(toRNProperty(property), value, reading), + ); + } +} + +function runtimeStyleFunction( + name: string, + value: StyleDescriptor, + reading: RuntimeReading, +): StyleFunction { + switch (reading) { + case "in place": + return [{}, name, value]; + case "after every rule": + return [{}, name, value, 1]; + } +} + export function parseUnparsedDeclaration( declaration: Extract, builder: StylesheetBuilder, @@ -982,14 +1018,15 @@ export function parseUnparsedDeclaration( */ builder.descriptorProperty = property; - if (unparsedRuntimeParsing.has(property)) { - const args = parseUnparsed(declaration.value.value, builder, property); + const reading = unparsedRuntimeParsing.get(property); - if (property === "animation") { - builder.addDescriptor("animation", [{}, property, args]); - } else { - builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); - } + if (reading !== undefined) { + addRuntimeReading( + builder, + property, + parseUnparsed(declaration.value.value, builder, property), + reading, + ); } else { const value = parseUnparsed(declaration.value.value, builder, property); @@ -1049,6 +1086,14 @@ export function parseCustomDeclaration( property, parseUnparsed(declaration.value.value, builder, property), ); + } else if (property === "transition-behavior") { + // lightningcss parses no transition-behavior (CSS Transitions 2 §2.5), so the runtime reads it + addRuntimeReading( + builder, + property, + parseUnparsed(declaration.value.value, builder, property), + "in place", + ); } else if (property === "corner-shape") { parseCornerShape(declaration.value, builder); } else if (property === "font-variant") { @@ -2650,10 +2695,6 @@ export function parseSVGPaint( builder.addDescriptor(property, parsedValue); } -export function round(number: number) { - return Math.round((number + Number.EPSILON) * 10000) / 10000; -} - export function parseDimensionPercentageFor_LengthValue( value: DimensionPercentageFor_LengthValue, builder: StylesheetBuilder, @@ -3057,15 +3098,9 @@ export function addTransitionValue( case "transition-property": { builder.addDescriptor( declaration.property, - declaration.value - .map((v) => v.property) - .filter( - (v) => - (v in parsers && !["visibility"].includes(v)) || - v === "all" || - v === "none", - ) - .map((v) => toRNProperty(v)), + declaration.value.map((propertyId) => + transitionStyleKey(propertyId.property), + ), ); return; } diff --git a/src/compiler/keyframes.ts b/src/compiler/keyframes.ts index e546d56b..a3a5f591 100644 --- a/src/compiler/keyframes.ts +++ b/src/compiler/keyframes.ts @@ -4,6 +4,7 @@ import type { KeyframesRule, } from "lightningcss"; +import { timingFunctionValue } from "../utilities"; import type { StyleDescriptor } from "./compiler.types"; import { parseDeclaration } from "./declarations"; import type { StylesheetBuilder } from "./stylesheet"; @@ -38,10 +39,7 @@ export function parseEasingFunction( } }) as StyleDescriptor[]; - if (easingFn.length === 1) { - return easingFn[0]; - } - return easingFn; + return timingFunctionValue(easingFn); } export function extractKeyFrames( diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index ffb4c581..507fa7f8 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -334,39 +334,33 @@ export class StylesheetBuilder { } else if (property.startsWith("--")) { rule.v ??= []; rule.v.push([property.slice(2), value]); - } else if (isStyleFunction(value)) { - const [delayed, usesVariables] = postProcessStyleFunction(value); - + } else { rule.d ??= []; + if ( - value[1].startsWith("animation") || - value[1].startsWith("transition") + property.startsWith("animation") || + property.startsWith("transition") ) { rule.a ??= true; } - if (usesVariables) { - rule.dv = 1; - } + if (isStyleFunction(value)) { + const [delayed, usesVariables] = postProcessStyleFunction(value); - this.pushDescriptor( - property, - value, - rule.d, - forceTuple, - delayed || usesVariables, - ); - } else { - if ( - property.startsWith("animation-") || - property.startsWith("transition-") || - property === "transition" - ) { - rule.a ??= true; - } + if (usesVariables) { + rule.dv = 1; + } - rule.d ??= []; - this.pushDescriptor(property, value, rule.d); + this.pushDescriptor( + property, + value, + rule.d, + forceTuple, + delayed || usesVariables, + ); + } else { + this.pushDescriptor(property, value, rule.d); + } } } diff --git a/src/native/styles/shorthands/_lists.ts b/src/native/styles/shorthands/_lists.ts new file mode 100644 index 00000000..9ed7f32e --- /dev/null +++ b/src/native/styles/shorthands/_lists.ts @@ -0,0 +1,135 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; +import { + isStyleDescriptorArray, + round, + timingFunctionValue, +} from "react-native-css/utilities"; + +import type { SimpleResolveValue, StyleFunctionResolver } from "../resolve"; + +const EASING_KEYWORDS: ReadonlySet = new Set([ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out", + "step-start", + "step-end", +]); + +export function isEasingKeyword(token: string): boolean { + return EASING_KEYWORDS.has(token.toLowerCase()); +} + +/** An easing keyword in the case React Native reads, or a `cubic-bezier()` or `steps()` already resolved to Reanimated's easing object, which Reanimated knows by its `normalize`. */ +export function readEasing(token: unknown): unknown { + if (typeof token === "string") { + return isEasingKeyword(token) ? token.toLowerCase() : undefined; + } + + return typeof token === "object" && + token !== null && + typeof (token as { readonly normalize?: unknown }).normalize === "function" + ? token + : undefined; +} + +/** + * A comma list a variable holds, item by item: one item arrives bare, and a list + * that also holds a list variable, `var(--a), 2s`, arrives with that list nested. + */ +export function readItems(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value.flat(Infinity) : [value]; +} + +/** + * Whether a declaration's arguments keep their comma groups: the compiler writes a + * group of several tokens as a list and a group of one bare, so a list holding a + * list is a list of groups. + */ +export function isGroupList( + descriptor: StyleDescriptor, +): descriptor is StyleDescriptor[] { + return ( + isStyleDescriptorArray(descriptor) && + descriptor.some((group) => isStyleDescriptorArray(group)) + ); +} + +/** A `#` list longhand's items, or nothing when one of its comma groups holds several tokens. */ +export function readListItems( + descriptor: StyleDescriptor, + resolveValue: SimpleResolveValue, +): readonly unknown[] | undefined { + return isGroupList(descriptor) + ? undefined + : readItems(resolveValue(descriptor)); +} + +/** Every item read as the list's grammar reads it, or nothing when one is not an item, which makes the declaration invalid. */ +function readList( + descriptor: StyleDescriptor, + resolveValue: SimpleResolveValue, + readItem: (item: unknown) => unknown, +): readonly unknown[] | undefined { + const items = readListItems(descriptor, resolveValue)?.map(readItem); + return items === undefined || items.includes(undefined) ? undefined : items; +} + +/** A time a script sets on a variable, `"300ms"` or `"0.3s"`, in milliseconds at the compiler's precision. */ +function readTimeText(text: string): number | undefined { + const time = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?)(ms|s)$/i.exec( + text.trim(), + ); + const [, amount, unit] = time ?? []; + return amount === undefined + ? undefined + : round(Number(amount) * (unit?.toLowerCase() === "s" ? 1000 : 1)); +} + +/** A time in milliseconds, as the compiler writes one or a script sets one. */ +export function readTime(token: unknown): number | undefined { + return typeof token === "string" + ? readTimeText(token) + : typeof token === "number" + ? token + : undefined; +} + +/** A `