Skip to content

fix(native): animate a transition or animation held in a variable - #497

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/transition-variable
Open

YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/transition-variable

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A transition held in a variable never animates as the written one does:

  • transition: var(--t) with --t: width 1s ease-in 200ms renders { transition: ["width", 1000, "ease-in", 200] }, where the written value renders the four transition* longhands.
  • transition-property: var(--p) names each CSS property as written, so background-image and border-inline-start-color match no style key.
  • transition-duration: var(--d) leaves its element a plain View: addDescriptor marks a rule animated by the property only for a static value, and by the style function's name otherwise, and var names neither an animation nor a transition. The same holds for every animation-* longhand but animation-name, and for a written cubic-bezier() or steps().
  • Once animated, a held longhand reaches Reanimated as the variable holds it, and its normalizers throw on EASE-IN or on a list holding a list variable, var(--d), 2s.

The written route names a transition by the property's camelCase rather than the key it is written to, backgroundImage for experimental_backgroundImage, and drops visibility and unknown names from the list, so every later property takes its neighbour's timing, where CSS Transitions 1 §2.1 keeps each at its index.

Solution

  • propertyRename moves to utilities beside toRNProperty, and transitionStyleKey names the key a transition animates on both routes, reading a name in any case and without a vendor prefix; a property with no style key, such as visibility, keeps its own name and its place.
  • addDescriptor marks a rule animated by the property it writes, whatever the value.
  • Every transition and animation property is read at runtime when it holds a var() or a value lightningcss leaves unparsed, into the lists the compiler builds for a written value: keywords in any case, list variables in a list as one list, a time a script sets as "300ms", rounded as a written one is through round, which moves to utilities, and for a timing function only a keyword or the easing cubic-bezier() and steps() resolve to. It writes nothing for a value the property refuses, or for an item of several tokens in a list the declaration writes. A value with no variable is read where the cascade puts it, as animation already is, and one the compiler cannot read at all is dropped. transition-behavior, which lightningcss does not parse, is read the same way.
  • The transition resolver reads each comma group by CSS Transitions 2 §2.6, in any order, a negative time as the delay and allow-discrete as the behavior, and splices a variable's tokens into its group. It writes no transition for a written group naming two properties, or for none beside another property, which Reanimated throws on.

Left for changes of their own:

  • A list of one-token items keeps no commas once compiled, held or written (width, opacity as width opacity), and a variable's list nests where another variable reads it, so a held transition's groups are told apart by property names alone: 1s, 2s reads as one transition, a held width var(--d) over --d: 1s ease-in as two, and a held 1s 2s, 3s as three times.
  • The compiler drops an item it cannot read from a list: auto, ease-in reads as ease-in.
  • A shorthand expanded from a variable resolves after every rule, so a later rule's longhand loses to it, and an invalid one leaves an earlier rule's longhands, as with border: var(--b).
  • A rule's static values merge into one record where the first is written, so width: 10px; transition: display 1s allow-discrete; transition-duration: 3s keeps 1s.
  • Neither shorthand writes transition-behavior: normal, as lightningcss moves a transition-behavior it cannot parse ahead of the shorthand, so a later shorthand does not reset an earlier allow-discrete.
  • A property the compiler writes under other keys, such as scale into transform, names a key no style has.
  • A custom property stores 5px, 5 and 5ms alike, so a held duration takes a length as milliseconds.

Tests

  • src/__tests__/native/transition-variables.test.tsx renders its route cases written, through a variable the compiler inlines, through one it keeps (inlineVariables: false), and through a kept one after JSON.parse(JSON.stringify(sheet)), the form a device reads, and expects the element handed to Reanimated with the case's transition each way; the evidence comment names the cases that run fewer ways. It also covers a time a script sets through VariableContextProvider, a later rule over a transition the runtime reads, a transition-behavior after a transition in its rule, and a value the compiler cannot read. animations.test.tsx renders each animation-* longhand but animation-name the four ways, and its invalid values the two kept ways.
  • compiler.test.tsx holds the animated flag to every animation and transition property, whatever its value, and to nothing else.
  • property-names.test.ts holds each renamed property's transition to the key it is written to, a name in another case to its key, and visibility and a custom property to their own names.

Each expected value matches what Chromium, Firefox and WebKit compute with getComputedStyle for the declaration written and through a variable.

Verification

The workflow's jobs pass on Ubuntu, and the iOS dev build passes with Xcode 27.1; the workflow pins 26.6, which I did not run. Not run: a device recording of a transition held in a variable.

255 of this PR's 384 cases fail on main, and none fail here: #497 (comment)

Merge order

Stacked on #491, which gives toRNProperty the utilities home this extends, so #491 lands first. #413's variable-fold-parity.test.tsx has a case pinning the unanimated transition-duration this fixes, so whichever of the two lands second updates it. It also shares files with #389, #391, #393, #411, #417, #420, #447, #448, #449, #459, #494, #495, #501 and #503; whichever lands later rebases.

…roperty 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.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

This PR's cases — main against this PR

The test files this PR changes, run on main (2421ea77) and on this branch (35dc97b7): on main 186 of the 252 cases in transition-variables.test.tsx fail, 50 of the 96 in animations.test.tsx and 19 of the 36 in compiler.test.tsx; none fail on this branch. property-names.test.ts tests transitionStyleKey and propertyRename, which this PR adds to utilities, so it has no run on main.

Most cases render one declaration four ways: written; through a variable the compiler inlines; through one it keeps for the runtime (inlineVariables: false); and through a kept one after JSON.parse(JSON.stringify(sheet)), the form the sheet reaches a device in. They expect the same result each way: whether the element is handed to Reanimated, and the style it is given. A list that holds a list variable, such as var(--d), 2s, runs only the three variable ways, as no written value can hold one; an invalid value, and a transition whose first time is negative, which lightningcss takes as the duration when written, run the two kept ways; an item of several tokens and a group naming two properties run written and inlined. The rest render one way: a time a script sets through VariableContextProvider on a kept sheet, a value the compiler cannot read as written, and two rules on one element.

What fails on main:

  • a transition, a transition-property, and each transition-* and animation-* longhand held in a kept variable: the element is not handed to Reanimated, transition is not expanded into its longhands, and a longhand reaches Reanimated with a keyword in upper case (EASE-IN), with a list nested where a variable held a list (var(--d), 2s), or with a value its property refuses, such as rotate(45deg) for a timing function
  • transition-behavior, which lightningcss does not parse, written or held: after a transition in the same rule, and an invalid one after an earlier allow-discrete
  • a time a script sets through VariableContextProvider, which main writes into the style as the string, "fast" included, and a script's "width 300ms", which main writes as one property name
  • an item of several tokens in a longhand's comma list, width foo in width foo, height, which main writes as a nested list, and a transition group naming two properties, width height 1s, which main writes as a raw transition key
  • a timing function written as cubic-bezier() or steps(), which leaves its element unanimated all four ways
  • a written transition-property naming background-image or border-inline-start-color, each of which names a key no style has, or visibility or an unknown name, which drops out of the list and shifts every later property onto its neighbour's duration
  • the compiled rule's animated flag (a), which decides whether the element is handed to Reanimated, and which main leaves unset for an animation or transition property held in a variable and for a written cubic-bezier() or steps()

What passes on main is each written and inlined case whose value main's compiler already writes as this PR does, the animation suite's existing cases, and the cases this PR adds to keep main's behaviour: a value the compiler cannot read is dropped, and a later written transition wins.

Each expected value matches what Chromium 156, Firefox 157 and WebKit 27.2 compute with getComputedStyle for the same declaration written and through a variable.

To rerun, copy this PR's shared render helper src/__tests__/native/_reanimated-routes.tsx, transition-variables.test.tsx, animations.test.tsx and src/__tests__/compiler/compiler.test.tsx onto main and run them with yarn jest. Here they ran on Windows with Node 26.12.1, and merged into main in the CI workflow's test job on Ubuntu.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 28, 2026 20:37
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/transition-variable branch from aa2c34a to 8c2f777 Compare October 28, 2026 23:02
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): expand a transition held in a variable fix(native): animate a transition or animation held in a variable Oct 28, 2026
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.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/transition-variable branch from 8c2f777 to 35dc97b Compare October 29, 2026 01:55
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 29, 2026 05:44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant