From 622170fc12ef798c0d41519288c0d117b1078476 Mon Sep 17 00:00:00 2001 From: Mark Lawlor Date: Tue, 29 Jul 2025 12:44:15 +1000 Subject: [PATCH] fix: reanimated v4 animations --- example/src/App.tsx | 2 +- src/compiler/compiler.types.ts | 3 +- src/compiler/declarations.ts | 34 +-- src/compiler/keyframes.ts | 2 +- src/compiler/stylesheet.ts | 9 +- src/runtime/native/conditions/guards.ts | 4 - src/runtime/native/react/rules.ts | 26 +- src/runtime/native/styles/animation.ts | 235 +++++++++++-------- src/runtime/native/styles/calculate-props.ts | 165 +++++++++++++ src/runtime/native/styles/index.ts | 140 +---------- src/runtime/native/styles/resolve.ts | 12 +- src/runtime/native/styles/shorthand.ts | 4 +- 12 files changed, 353 insertions(+), 283 deletions(-) create mode 100644 src/runtime/native/styles/calculate-props.ts diff --git a/example/src/App.tsx b/example/src/App.tsx index 23149e7..4cfb29e 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,7 +6,7 @@ export default function App() { return ( <> - Test Component + Test Component ); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 137c45e..cbfd25c 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -1,4 +1,5 @@ /* eslint-disable */ +import type { Debugger } from "debug"; import type { AnimationDirection, AnimationFillMode, @@ -14,7 +15,7 @@ export interface CompilerOptions { selectorPrefix?: string; stylesheetOrder?: number; features?: FeatureFlagRecord; - logger?: (message: string) => void; + logger?: (message: string) => void | Debugger; /** Strip unused variables declarations. Defaults: false */ stripUnusedVariables?: boolean; /** @internal */ diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index b9d7740..79df06e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -974,6 +974,8 @@ export function parseUnparsed( case "hsla": case "linear-gradient": case "radial-gradient": + case "cubic-bezier": + case "steps": return unparsedFunction(tokenOrValue, builder); case "hairlineWidth": return [{}, tokenOrValue.value.name, []]; @@ -999,31 +1001,29 @@ export function parseUnparsed( case "token": switch (tokenOrValue.value.type) { case "string": - case "number": case "ident": { const value = tokenOrValue.value.value; - if (typeof value === "string") { - if (!allowAuto && value === "auto") { - builder.addWarning("value", value); - return; - } + if (!allowAuto && value === "auto") { + builder.addWarning("value", value); + return; + } - if (value === "inherit") { - builder.addWarning("value", value); - return; - } + if (value === "inherit") { + builder.addWarning("value", value); + return; + } - if (value === "true") { - return true; - } else if (value === "false") { - return false; - } else { - return value; - } + if (value === "true") { + return true; + } else if (value === "false") { + return false; } else { return value; } } + case "number": { + return round(tokenOrValue.value.value); + } case "function": builder.addWarning("value", tokenOrValue.value.value); return; diff --git a/src/compiler/keyframes.ts b/src/compiler/keyframes.ts index a999207..1c719e2 100644 --- a/src/compiler/keyframes.ts +++ b/src/compiler/keyframes.ts @@ -53,7 +53,7 @@ export function extractKeyFrames( switch (selector.type) { case "percentage": return frame.selectors.length > 1 - ? `${selector.value}%` + ? `${selector.value * 100}%` : selector.value; case "from": case "to": diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 86a82a5..64280b2 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -196,6 +196,9 @@ export class StylesheetBuilder { const [delayed, usesVariables] = postProcessStyleFunction(value); this.rule.d ??= []; + if (value[1] === "@animation") { + this.rule.a ??= true; + } if (usesVariables) { this.rule.dv = 1; @@ -209,6 +212,10 @@ export class StylesheetBuilder { delayed || usesVariables, ); } else { + if (property.startsWith("animation-")) { + this.rule.a ??= true; + } + this.rule.d ??= []; this.pushDescriptor(property, value, this.rule.d); } @@ -370,7 +377,7 @@ export class StylesheetBuilder { } this.animationDeclarations = []; - this.staticDeclarations = {}; + this.staticDeclarations = undefined; this.animationFrames.push([progress, this.animationDeclarations]); } } diff --git a/src/runtime/native/conditions/guards.ts b/src/runtime/native/conditions/guards.ts index b926ed3..558ec70 100644 --- a/src/runtime/native/conditions/guards.ts +++ b/src/runtime/native/conditions/guards.ts @@ -40,10 +40,6 @@ export function testGuards( break; } - // if (result) { - // console.log(`Guard ${guard[0]}:${guard[1]} failed`); - // } - return result; }); } diff --git a/src/runtime/native/react/rules.ts b/src/runtime/native/react/rules.ts index ef21d3b..63f5507 100644 --- a/src/runtime/native/react/rules.ts +++ b/src/runtime/native/react/rules.ts @@ -221,7 +221,7 @@ export function updateRules( } // Generate a StyleObservable for this unique set of rules / variables - const stylesObs = stylesFamily(generateHash(state, rules), rules); + const stylesObs = stylesFamily(generateStateHash(state, rules), rules); // Get the guards without subscribing to the observable // We will subscribe within the render using the StyleEffect @@ -277,21 +277,12 @@ function pushInlineRule( let hashKeyCount = 0; const hashKeyFamily = weakFamily(() => hashKeyCount++); -/** - * Quickly generate a unique hash for a set of numbers. - * This is not a cryptographic hash, but it is fast and has a low chance of collision. - */ -const MOD = 9007199254740871; // Largest prime within safe integer range 2^53 -const PRIME = 31; // A smaller prime for mixing -export function generateHash( +export function generateStateHash( state: ComponentState, iterableKeys?: Iterable, variables?: WeakKey, inlineVars?: Set, ): string { - let hash = 0; - let product = 1; // Used for mixing to enhance uniqueness - if (!iterableKeys) { return ""; } @@ -306,6 +297,19 @@ export function generateHash( keys.push(...inlineVars); } + return generateHash(keys); +} + +/** + * Quickly generate a unique hash for a set of numbers. + * This is not a cryptographic hash, but it is fast and has a low chance of collision. + */ +const MOD = 9007199254740871; // Largest prime within safe integer range 2^53 +const PRIME = 31; // A smaller prime for mixing +export function generateHash(keys: WeakKey[]): string { + let hash = 0; + let product = 1; // Used for mixing to enhance uniqueness + for (const key of keys) { if (!key) continue; // Skip if key is undefined diff --git a/src/runtime/native/styles/animation.ts b/src/runtime/native/styles/animation.ts index a985237..d49e307 100644 --- a/src/runtime/native/styles/animation.ts +++ b/src/runtime/native/styles/animation.ts @@ -1,26 +1,37 @@ /* eslint-disable */ import type { ComponentType } from "react"; -import type { StyleDescriptor } from "../../../compiler"; import { StyleCollection } from "../injection"; -import { observable, weakFamily, type Getter } from "../reactivity"; -import type { SimpleResolveValue, StyleFunctionResolver } from "./resolve"; +import { weakFamily } from "../reactivity"; +import type { StyleFunctionResolver } from "./resolve"; import { shorthandHandler } from "./shorthand"; -const name = ["n", "string", "none"] as const; -const delay = ["de", "number", 0] as const; -const duration = ["du", "number", 0] as const; -const fill = ["f", ["none", "forwards", "backwards", "both"], "none"] as const; -const iteration = ["i", "number", 1] as const; -const playState = ["p", ["running", "paused"], "running"] as const; +const name = ["animationName", "string", "none"] as const; +const delay = ["animationDelay", "number", 0] as const; +const duration = ["animationDuration", "number", 0] as const; +const iteration = [ + "animationIterationCount", + ["number", "infinite"], + 1, +] as const; +const fill = [ + "animationFillMode", + ["none", "forwards", "backwards", "both"], + "none", +] as const; +const playState = [ + "animationPlayState", + ["running", "paused"], + "running", +] as const; const direction = [ - "di", + "animationDirection", ["normal", "reverse", "alternate", "alternate-reverse"], "normal", ] as const; -const easing = [ - "e", - ["linear", "ease", "ease-in", "ease-out", "ease-in-out"], +const timingFunction = [ + "animationTimingFunction", + ["linear", "ease", "ease-in", "ease-out", "ease-in-out", "object"], "ease", ] as const; @@ -29,100 +40,23 @@ export const animationShorthand = shorthandHandler( [name], [duration, name], [name, duration], + [name, duration, iteration], + [name, duration, timingFunction, iteration], [duration, delay, name], [duration, delay, iteration, name], - [duration, delay, iteration, easing, name], - [name, duration, easing, delay, iteration, fill], + [duration, delay, iteration, timingFunction, name], + [name, duration, timingFunction, delay, iteration, fill], + ], + [ + name, + delay, + direction, + duration, + fill, + iteration, + playState, + timingFunction, ], - [name, delay, direction, duration, fill, iteration, playState, easing], -); - -export const animation: StyleFunctionResolver = ( - resolveValue, - value, - get, - { inheritedVariables }, -) => { - const name = resolveValue(value[2]); - - /** - * Get a stable reference to the StyleProp observer. - * We can use that as a WeakKey - */ - return get( - animationFamily(get, { - name, - resolveValue, - inheritedVariables, - }), - ); -}; - -type AnimationFamilyOptions = { - name: StyleDescriptor; - resolveValue: SimpleResolveValue; - inheritedVariables: any; -}; - -const animationFamily = weakFamily( - (_: Getter, options: AnimationFamilyOptions) => { - const { name: nameDescriptor, resolveValue } = options; - - return observable((get) => { - const names = resolveValue(nameDescriptor); - - if (!Array.isArray(names)) { - return; - } - - return names.map((name: string) => { - const keyframes = get(StyleCollection.keyframes(name)); - - const animation: Record = {}; - - for (const [progress, declarations] of keyframes) { - const result: Record = {}; - - // This code needs to match calculateProps - // TODO: Refactor this to use the same code - for (const declaration of declarations) { - let target = result; - - if (!Array.isArray(declaration)) { - // Static styles - Object.assign(target, declaration); - } else { - // Dynamic styles - let value: any = declaration[0]; - let propPath = declaration[1]; - let prop: string | undefined = ""; - - if (typeof propPath === "string") { - prop = propPath; - } else { - prop = propPath[0]; - - for ( - let i = 0; - i < propPath.length - 2 && typeof prop === "string"; - i++ - ) { - target = target[prop] ??= {}; - prop = propPath[i + 1]; - } - } - - value = resolveValue(value); - } - } - - animation[progress] = result; - } - - return animation; - }); - }); - }, ); export const animatedComponentFamily = weakFamily( @@ -140,3 +74,96 @@ export const animatedComponentFamily = weakFamily( return createAnimatedComponent(component); }, ); + +export const animation: StyleFunctionResolver = ( + resolveValue, + value, + get, + options, +) => { + const animationShortHandTuples: [unknown, string][] | undefined = + animationShorthand(resolveValue, value, get, options); + + if (!animationShortHandTuples) { + return; + } + + const nameTuple = animationShortHandTuples.find( + (tuple) => tuple[1] === "animationName", + ); + + const name = nameTuple?.[0]; + + if (!nameTuple || typeof name !== "string") { + return; + } + + const keyframes = get(StyleCollection.keyframes(name)); + + const animation: Record = {}; + for (const [progress, declarations] of keyframes) { + animation[progress] ??= {}; + + const props = options.calculateProps?.( + get, + // Cast this into a StyleRule[] + [{ s: [0], d: declarations }], + options.renderGuards, + options.inheritedVariables, + options.inlineVariables, + ); + + if (!props) { + continue; + } + + if (props.normal) { + Object.assign(animation[progress], props.normal); + } + if (props.important) { + Object.assign(animation[progress], props.important); + } + + animation[progress] = animation[progress].style; + } + + nameTuple[0] = animation; + + return animationShortHandTuples; +}; + +const advancedTimingFunctions: Record< + string, + () => (...args: any[]) => unknown +> = { + "cubic-bezier": () => { + return ( + require("react-native-reanimated") as typeof import("react-native-reanimated") + ).cubicBezier; + }, + "steps": () => { + return ( + require("react-native-reanimated") as typeof import("react-native-reanimated") + ).steps; + }, +}; + +export const timingFunctionResolver: StyleFunctionResolver = ( + resolveValue, + value, +) => { + const name = value[1]; + const resolver = advancedTimingFunctions[name]; + + if (!resolver) { + return; + } + + const args: unknown[] = resolveValue(value[2]); + + const fn = resolver(); + + const result = fn(...args); + + return result; +}; diff --git a/src/runtime/native/styles/calculate-props.ts b/src/runtime/native/styles/calculate-props.ts new file mode 100644 index 0000000..96b6a2e --- /dev/null +++ b/src/runtime/native/styles/calculate-props.ts @@ -0,0 +1,165 @@ +/* eslint-disable */ +import type { + InlineVariable, + StyleDeclaration, + StyleRule, +} from "../../../compiler"; +import { applyValue, Specificity as S } from "../../utils"; +import type { RenderGuard } from "../conditions/guards"; +import { + VAR_SYMBOL, + type Getter, + type VariableContextValue, +} from "../reactivity"; +import { resolveValue } from "./resolve"; + +export function calculateProps( + get: Getter, + rules: (StyleRule | InlineVariable | VariableContextValue)[], + guards: RenderGuard[] = [], + inheritedVariables: VariableContextValue = { + [VAR_SYMBOL]: true, + }, + inlineVariables: InlineVariable = { + [VAR_SYMBOL]: "inline", + }, +) { + let normal: Record | undefined; + let important: Record | undefined; + + const delayedStyles: (() => void)[] = []; + + for (const rule of rules) { + if (VAR_SYMBOL in rule) { + if (typeof rule[VAR_SYMBOL] === "string") { + Object.assign(inlineVariables, rule); + } else { + Object.assign(inheritedVariables, rule); + } + continue; + } + + if (rule.v) { + for (const variable of rule.v) { + inlineVariables[variable[0]] = variable[1]; + } + } + + if (rule.d) { + let topLevelTarget = rule.s?.[S.Important] + ? (important ??= {}) + : (normal ??= {}); + let target = topLevelTarget; + + const ruleTarget = rule.target || "style"; + + if (typeof ruleTarget === "string") { + target = target[ruleTarget] ??= {}; + } else if (ruleTarget) { + for (const path of ruleTarget) { + target = target[path] ??= {}; + } + } + + applyDeclarations( + get, + rule.d, + inlineVariables, + inheritedVariables, + delayedStyles, + guards, + target, + topLevelTarget, + ); + } + } + + for (const delayedStyle of delayedStyles) { + delayedStyle(); + } + + return { + normal, + guards, + important, + }; +} + +export function applyDeclarations( + get: Getter, + declarations: StyleDeclaration[], + inlineVariables: InlineVariable, + inheritedVariables: VariableContextValue, + delayedStyles: (() => void)[] = [], + guards: RenderGuard[] = [], + target: Record = {}, + topLevelTarget = target, +) { + for (const declaration of declarations) { + if (!Array.isArray(declaration)) { + // Static styles + Object.assign(target, declaration); + } else { + // Dynamic styles + let value: any = declaration[0]; + let propPath = declaration[1]; + let prop = ""; + + if (typeof propPath === "string") { + if (propPath.startsWith("^")) { + propPath = propPath.slice(1); + target = topLevelTarget[propPath] ??= {}; + } + prop = propPath; + } else { + for (prop of propPath) { + if (prop.startsWith("^")) { + prop = prop.slice(1); + target = topLevelTarget[prop] ??= {}; + } else { + target = target[prop] ??= {}; + } + } + } + + if (Array.isArray(value)) { + const shouldDelay = declaration[2]; + + if (shouldDelay) { + /** + * We need to delay the resolution of this value until after all + * styles have been calculated. But another style might override + * this value. So we set a placeholder value and only override + * if the placeholder is preserved + * + * This also ensures the props exist, so setValue will properly + * mutate the props object and not create a new one + */ + const originalValue = value; + value = {}; + delayedStyles.push(() => { + if (target[prop] === value) { + delete target[prop]; + value = resolveValue(originalValue, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }); + applyValue(target, prop, value); + } + }); + } else { + value = resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }); + } + + applyValue(target, prop, value); + } + } + } +} diff --git a/src/runtime/native/styles/index.ts b/src/runtime/native/styles/index.ts index 491f691..e22e2df 100644 --- a/src/runtime/native/styles/index.ts +++ b/src/runtime/native/styles/index.ts @@ -1,11 +1,6 @@ /* eslint-disable */ import type { InlineVariable, StyleRule } from "../../../compiler"; -import { - applyValue, - Specificity as S, - specificityCompareFn, -} from "../../utils"; -import type { RenderGuard } from "../conditions/guards"; +import { specificityCompareFn } from "../../utils"; import { getInteractionHandler } from "../react/interaction"; import type { ComponentState, Config } from "../react/useNativeCss"; import { @@ -17,10 +12,9 @@ import { observable, VAR_SYMBOL, type Effect, - type Getter, type VariableContextValue, } from "../reactivity"; -import { resolveValue } from "./resolve"; +import { calculateProps } from "./calculate-props"; export const stylesFamily = family( ( @@ -45,136 +39,6 @@ export const stylesFamily = family( }, ); -function calculateProps( - get: Getter, - rules: Array, -) { - let normal: Record | undefined; - let important: Record | undefined; - - const delayedStyles: (() => void)[] = []; - - const guards: RenderGuard[] = []; - - const inheritedVariables: VariableContextValue = { - [VAR_SYMBOL]: true, - }; - - const inlineVariables: InlineVariable = { - [VAR_SYMBOL]: "inline", - }; - - for (const rule of rules) { - if (VAR_SYMBOL in rule) { - if (typeof rule[VAR_SYMBOL] === "string") { - Object.assign(inlineVariables, rule); - } else { - Object.assign(inheritedVariables, rule); - } - continue; - } - - if (rule.v) { - for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; - } - } - - if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); - let target = topLevelTarget; - - const ruleTarget = rule.target || "style"; - - if (typeof ruleTarget === "string") { - target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { - target = target[path] ??= {}; - } - } - - for (const declaration of rule.d) { - if (!Array.isArray(declaration)) { - // Static styles - Object.assign(target, declaration); - } else { - // Dynamic styles - let value: any = declaration[0]; - let propPath = declaration[1]; - let prop = ""; - - if (typeof propPath === "string") { - if (propPath.startsWith("^")) { - propPath = propPath.slice(1); - target = topLevelTarget[propPath] ??= {}; - } - prop = propPath; - } else { - for (prop of propPath) { - if (prop.startsWith("^")) { - prop = prop.slice(1); - target = topLevelTarget[prop] ??= {}; - } else { - target = target[prop] ??= {}; - } - } - } - - if (Array.isArray(value)) { - const shouldDelay = declaration[2]; - - if (shouldDelay) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ - const originalValue = value; - value = {}; - delayedStyles.push(() => { - if (target[prop] === value) { - delete target[prop]; - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - }); - applyValue(target, prop, value); - } - }); - } else { - value = resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - }); - } - - applyValue(target, prop, value); - } - } - } - } - } - - for (const delayedStyle of delayedStyles) { - delayedStyle(); - } - - return { - normal, - guards, - important, - }; -} - export function getStyledProps( state: ComponentState, inline: Record | undefined | null, diff --git a/src/runtime/native/styles/resolve.ts b/src/runtime/native/styles/resolve.ts index 04288d1..096187e 100644 --- a/src/runtime/native/styles/resolve.ts +++ b/src/runtime/native/styles/resolve.ts @@ -6,10 +6,11 @@ import type { } from "../../../compiler"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; -import { animation } from "./animation"; +import { animation, timingFunctionResolver } from "./animation"; import { border } from "./border"; import { boxShadow } from "./box-shadow"; import { calc } from "./calc"; +import type { calculateProps } from "./calculate-props"; import { transformKeys } from "./defaults"; import { fontScale, @@ -37,6 +38,7 @@ export type StyleFunctionResolver = ( ) => any; const shorthands: Record<`@${string}`, StyleFunctionResolver> = { + "@animation": animation, "@textShadow": textShadow, "@transform": transform, "@boxShadow": boxShadow, @@ -55,7 +57,9 @@ const functions: Record = { fontScale, pixelSizeForLayoutSize, roundToNearestPixel, - animationName: animation, + "animationName": animation, + "cubic-bezier": timingFunctionResolver, + "steps": timingFunctionResolver, ...shorthands, }; @@ -65,6 +69,8 @@ export type ResolveValueOptions = { inlineVariables?: InlineVariable | undefined; renderGuards?: RenderGuard[]; variableHistory?: Set; + /** Pass down to perform recursive calculations and avoid circular dependencies */ + calculateProps?: typeof calculateProps; }; export function resolveValue( @@ -94,7 +100,7 @@ export function resolveValue( } if (isDescriptorArray(value)) { - value = value.map((d) => { + value = value.flatMap((d) => { const value = resolveValue(d, get, options); return value === undefined ? [] : value; }) as StyleDescriptor[]; diff --git a/src/runtime/native/styles/shorthand.ts b/src/runtime/native/styles/shorthand.ts index 45be3f3..8976976 100644 --- a/src/runtime/native/styles/shorthand.ts +++ b/src/runtime/native/styles/shorthand.ts @@ -8,7 +8,7 @@ type ShorthandType = | "number" | "length" | "color" - | Readonly; + | Readonly<(string | Function)[]>; type ShorthandRequiredValue = | readonly [string | readonly string[], ShorthandType] @@ -46,7 +46,7 @@ export function shorthandHandler( const value = resolved[index]; if (Array.isArray(type)) { - return type.includes(value); + return type.includes(value) || type.includes(typeof value); } switch (type) {