@rootnative/inertia-gradients 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ This package ships in lockstep with `@rootnative/inertia` — version numbers tr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.0.11] - 2026-09-09
10
+
11
+ ### Changed
12
+
13
+ - **Breaking: the peer ranges are narrowed to the Expo SDK 57 band.** `react` is now `>=19.2.3 <20.0.0`, `react-native` is `>=0.83.0 <0.87.0`, and `react-native-reanimated` is `>=4.5.0 <4.6.0`. This adapter tracks the band that `@rootnative/inertia` declares, so the two cannot disagree and no consumer can install a pair that resolves to different runtimes. The reason the band is this narrow is in the core changelog: each peer range resolves on its own, so a range wide enough for both SDK 54 and SDK 57 also admits `react-native 0.81` with Reanimated `4.6`, which cannot install.
14
+
15
+ Migration: move the app to Expo SDK 57 (React `19.2.3`, React Native `0.86.3`, Reanimated `4.5.1`, Worklets `0.10.1`). A project on SDK 54 stays on `0.0.10`. `expo-linear-gradient` keeps its own floor, because its version relationship is with the Expo SDK and not with this adapter.
16
+
17
+ ## [0.0.10] - 2026-09-06
18
+
19
+ ### Changed
20
+
21
+ - **The `@rootnative/inertia` peer range has an upper bound: `>=0.0.9 <0.1.0`.** Pre-`1.0.0`, a minor bump of core is where a breaking change lands, so this adapter must not accept it. Enforced by `check:versions`. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
22
+
23
+ ### Fixed
24
+
25
+ - **The `animatedProps` worklet no longer reads a ref on the UI thread.** `hasLocationsRef.current` was read inside the worklet; the worklet runtime clones a captured ref object once, so the read was a frozen snapshot that happened to be correct only because `locations` presence is locked at mount. The worklet now captures a plain boolean. No behaviour change; a test pins the presence-change throw. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
26
+
27
+ - **A spring config without a `type` key is now honoured as the top-level transition.** `SpringTransition.type` is optional, so `transition={{ tension: 300, friction: 20 }}` is valid. The adapter's `'type' in transition` check read it as a per-property map, looked up the prop name, found nothing, and ran the library default spring with no warning. The check is now `isTopLevelTransition`, exported from `@rootnative/inertia`, which is the same structural discriminator the core factory uses. Found by the `1.0.0` readiness audit (2026-09-05).
28
+
29
+ - **An inline `transition` literal no longer restarts the animation on every parent re-render.** The value-driving effects keyed on the `transition` object's identity. The documented call shape passes `transition={{ type: 'timing', duration: 600 }}` inline, so each parent render made a new object, re-fired every effect, and re-assigned the animation from its current value with the full duration; a frequently re-rendering parent never let the animation finish. The effects now key on `stableSig(transition)`, exported from `@rootnative/inertia`, so only a change to the config's content re-resolves. Found by the `1.0.0` readiness audit (2026-09-05).
30
+
9
31
  ## [0.0.9] - 2026-08-22
10
32
 
11
33
  **Lockstep version bump** alongside `@rootnative/inertia@0.0.9` (`useInterpolatedStyle` types its return against the map it was given, so a `style` array no longer needs a cast). No runtime changes in this adapter; the `@rootnative/inertia` peer range moves to `>=0.0.9`.
@@ -60,7 +82,9 @@ _No git tag was cut for this release; the published artifact is on npm as [`@roo
60
82
 
61
83
  - `MotionLinearGradient` over `expo-linear-gradient`. Accepts the same `initial` / `animate` / `transition` shape as the core `Motion.*` primitives, with animatable keys for `colors`, `start`, `end`, and `locations`.
62
84
 
63
- [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.9...HEAD
85
+ [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.11...HEAD
86
+ [0.0.11]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.11
87
+ [0.0.10]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.10
64
88
  [0.0.9]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.9
65
89
  [0.0.8]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.8
66
90
  [0.0.7]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.7
package/README.md CHANGED
@@ -16,9 +16,9 @@ Animatable linear gradient primitive for [`@rootnative/inertia`](../core), built
16
16
  pnpm add @rootnative/inertia-gradients expo-linear-gradient
17
17
  ```
18
18
 
19
- `expo-linear-gradient` works in bare React Native projects as well as Expo no `expo-modules-core` runtime is required.
19
+ `expo-linear-gradient` works in bare React Native projects as well as Expo, but it is an Expo Module. Bare projects need the Expo modules runtime installed. See [Installing Expo modules](https://docs.expo.dev/bare/installing-expo-modules/).
20
20
 
21
- **Peer dependencies:** `@rootnative/inertia` (workspace or installed), `react >=19.0.0`, `react-native >=0.81.0`, `react-native-reanimated >=4.0.0`, `expo-linear-gradient >=14.0.0`.
21
+ **Peer dependencies:** `@rootnative/inertia` (workspace or installed), `react >=19.2.3`, `react-native >=0.83.0 <0.87.0`, `react-native-reanimated >=4.5.0 <4.6.0`, `expo-linear-gradient >=14.0.0`.
22
22
 
23
23
  ## Usage
24
24
 
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ var DEFAULT_START = { x: 0, y: 0 };
17
17
  var DEFAULT_END = { x: 1, y: 0 };
18
18
  function pickTransition(per, key) {
19
19
  if (!per) return void 0;
20
- if ("type" in per) return per;
20
+ if (inertia.isTopLevelTransition(per)) return per;
21
21
  return per[key];
22
22
  }
23
23
  function MotionLinearGradient(props) {
@@ -33,6 +33,7 @@ function MotionLinearGradient(props) {
33
33
  } = props;
34
34
  const slotCountRef = react.useRef(colors.length);
35
35
  const hasLocationsRef = react.useRef(locations !== void 0);
36
+ const hasLocations = hasLocationsRef.current;
36
37
  if (__DEV__) {
37
38
  if (slotCountRef.current !== colors.length) {
38
39
  throw new Error(
@@ -73,6 +74,7 @@ function MotionLinearGradient(props) {
73
74
  const startKey = animate?.start ? `${animate.start.x},${animate.start.y}` : "";
74
75
  const endKey = animate?.end ? `${animate.end.x},${animate.end.y}` : "";
75
76
  const locationsKey = animate?.locations ? animate.locations.join("|") : "";
77
+ const transitionSig = inertia.stableSig(transition);
76
78
  const animateColors = animate?.colors;
77
79
  const animateStart = animate?.start;
78
80
  const animateEnd = animate?.end;
@@ -84,19 +86,19 @@ function MotionLinearGradient(props) {
84
86
  const target = animateColors[i] ?? colors[i] ?? "";
85
87
  colorSvs[i].value = inertia.resolveTransition(cfg, target);
86
88
  }
87
- }, [colorsKey, reduce, transition]);
89
+ }, [colorsKey, reduce, transitionSig]);
88
90
  react.useEffect(() => {
89
91
  if (!animateStart) return;
90
92
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "start");
91
93
  startX.value = inertia.resolveTransition(cfg, animateStart.x);
92
94
  startY.value = inertia.resolveTransition(cfg, animateStart.y);
93
- }, [startKey, reduce, transition]);
95
+ }, [startKey, reduce, transitionSig]);
94
96
  react.useEffect(() => {
95
97
  if (!animateEnd) return;
96
98
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "end");
97
99
  endX.value = inertia.resolveTransition(cfg, animateEnd.x);
98
100
  endY.value = inertia.resolveTransition(cfg, animateEnd.y);
99
- }, [endKey, reduce, transition]);
101
+ }, [endKey, reduce, transitionSig]);
100
102
  react.useEffect(() => {
101
103
  if (!animateLocations) return;
102
104
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "locations");
@@ -106,7 +108,7 @@ function MotionLinearGradient(props) {
106
108
  locationSvs[i].value = inertia.resolveTransition(cfg, target);
107
109
  }
108
110
  }
109
- }, [locationsKey, reduce, transition]);
111
+ }, [locationsKey, reduce, transitionSig]);
110
112
  const animatedProps = Animated.useAnimatedProps(() => {
111
113
  "worklet";
112
114
  const colorsOut = new Array(colorSvs.length);
@@ -116,7 +118,7 @@ function MotionLinearGradient(props) {
116
118
  start: { x: startX.value, y: startY.value },
117
119
  end: { x: endX.value, y: endY.value }
118
120
  };
119
- if (hasLocationsRef.current) {
121
+ if (hasLocations) {
120
122
  const locsOut = new Array(locationSvs.length);
121
123
  for (let i = 0; i < locationSvs.length; i++)
122
124
  locsOut[i] = locationSvs[i].value;
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useRef, useEffect } from 'react';
2
2
  import { LinearGradient } from 'expo-linear-gradient';
3
3
  import Animated, { useSharedValue, useAnimatedProps } from 'react-native-reanimated';
4
- import { useShouldReduceMotion, resolveTransition } from '@rootnative/inertia';
4
+ import { useShouldReduceMotion, stableSig, resolveTransition, isTopLevelTransition } from '@rootnative/inertia';
5
5
  import { jsx } from 'react/jsx-runtime';
6
6
 
7
7
  // src/MotionLinearGradient.tsx
@@ -11,7 +11,7 @@ var DEFAULT_START = { x: 0, y: 0 };
11
11
  var DEFAULT_END = { x: 1, y: 0 };
12
12
  function pickTransition(per, key) {
13
13
  if (!per) return void 0;
14
- if ("type" in per) return per;
14
+ if (isTopLevelTransition(per)) return per;
15
15
  return per[key];
16
16
  }
17
17
  function MotionLinearGradient(props) {
@@ -27,6 +27,7 @@ function MotionLinearGradient(props) {
27
27
  } = props;
28
28
  const slotCountRef = useRef(colors.length);
29
29
  const hasLocationsRef = useRef(locations !== void 0);
30
+ const hasLocations = hasLocationsRef.current;
30
31
  if (__DEV__) {
31
32
  if (slotCountRef.current !== colors.length) {
32
33
  throw new Error(
@@ -67,6 +68,7 @@ function MotionLinearGradient(props) {
67
68
  const startKey = animate?.start ? `${animate.start.x},${animate.start.y}` : "";
68
69
  const endKey = animate?.end ? `${animate.end.x},${animate.end.y}` : "";
69
70
  const locationsKey = animate?.locations ? animate.locations.join("|") : "";
71
+ const transitionSig = stableSig(transition);
70
72
  const animateColors = animate?.colors;
71
73
  const animateStart = animate?.start;
72
74
  const animateEnd = animate?.end;
@@ -78,19 +80,19 @@ function MotionLinearGradient(props) {
78
80
  const target = animateColors[i] ?? colors[i] ?? "";
79
81
  colorSvs[i].value = resolveTransition(cfg, target);
80
82
  }
81
- }, [colorsKey, reduce, transition]);
83
+ }, [colorsKey, reduce, transitionSig]);
82
84
  useEffect(() => {
83
85
  if (!animateStart) return;
84
86
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "start");
85
87
  startX.value = resolveTransition(cfg, animateStart.x);
86
88
  startY.value = resolveTransition(cfg, animateStart.y);
87
- }, [startKey, reduce, transition]);
89
+ }, [startKey, reduce, transitionSig]);
88
90
  useEffect(() => {
89
91
  if (!animateEnd) return;
90
92
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "end");
91
93
  endX.value = resolveTransition(cfg, animateEnd.x);
92
94
  endY.value = resolveTransition(cfg, animateEnd.y);
93
- }, [endKey, reduce, transition]);
95
+ }, [endKey, reduce, transitionSig]);
94
96
  useEffect(() => {
95
97
  if (!animateLocations) return;
96
98
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "locations");
@@ -100,7 +102,7 @@ function MotionLinearGradient(props) {
100
102
  locationSvs[i].value = resolveTransition(cfg, target);
101
103
  }
102
104
  }
103
- }, [locationsKey, reduce, transition]);
105
+ }, [locationsKey, reduce, transitionSig]);
104
106
  const animatedProps = useAnimatedProps(() => {
105
107
  "worklet";
106
108
  const colorsOut = new Array(colorSvs.length);
@@ -110,7 +112,7 @@ function MotionLinearGradient(props) {
110
112
  start: { x: startX.value, y: startY.value },
111
113
  end: { x: endX.value, y: endY.value }
112
114
  };
113
- if (hasLocationsRef.current) {
115
+ if (hasLocations) {
114
116
  const locsOut = new Array(locationSvs.length);
115
117
  for (let i = 0; i < locationSvs.length; i++)
116
118
  locsOut[i] = locationSvs[i].value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia-gradients",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Animated linear gradient primitive for @rootnative/inertia, built on expo-linear-gradient.",
5
5
  "license": "MIT",
6
6
  "author": "RootNative",
@@ -50,28 +50,30 @@
50
50
  "!**/*.test.*"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@rootnative/inertia": ">=0.0.9",
53
+ "@rootnative/inertia": ">=0.0.11 <0.1.0",
54
54
  "expo-linear-gradient": ">=14.0.0",
55
- "react": ">=19.0.0",
56
- "react-native": ">=0.81.0",
57
- "react-native-reanimated": ">=4.0.0"
55
+ "react": ">=19.2.3 <20.0.0",
56
+ "react-native": ">=0.83.0 <0.87.0",
57
+ "react-native-reanimated": ">=4.5.0 <4.6.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@react-native/babel-preset": "^0.81.5",
60
+ "@react-native/babel-preset": "^0.86.3",
61
+ "@react-native/jest-preset": "^0.86.3",
61
62
  "@size-limit/preset-small-lib": "^11.1.0",
62
63
  "@testing-library/react-native": "^13.3.3",
63
64
  "@types/jest": "^29.5.14",
64
- "@types/react": "^19.1.0",
65
- "expo-linear-gradient": "~15.0.8",
65
+ "@types/react": "^19.2.0",
66
+ "expo-linear-gradient": "~57.0.1",
66
67
  "jest": "^29.7.0",
67
- "react": "19.1.0",
68
- "react-native": "0.81.5",
69
- "react-native-reanimated": "~4.1.1",
70
- "react-test-renderer": "19.1.0",
68
+ "react": "19.2.3",
69
+ "react-native": "0.86.3",
70
+ "react-native-reanimated": "4.5.1",
71
+ "react-native-worklets": "0.10.1",
72
+ "react-test-renderer": "19.2.3",
71
73
  "size-limit": "^11.1.0",
72
74
  "tsup": "^8.3.5",
73
75
  "typescript": "^5.7.3",
74
- "@rootnative/inertia": "0.0.9"
76
+ "@rootnative/inertia": "0.0.11"
75
77
  },
76
78
  "publishConfig": {
77
79
  "access": "public"
@@ -6,7 +6,9 @@ import Animated, {
6
6
  type SharedValue,
7
7
  } from 'react-native-reanimated'
8
8
  import {
9
+ isTopLevelTransition,
9
10
  resolveTransition,
11
+ stableSig,
10
12
  useShouldReduceMotion,
11
13
  type TransitionConfig,
12
14
  } from '@rootnative/inertia'
@@ -34,7 +36,11 @@ function pickTransition(
34
36
  key: keyof LinearGradientPerPropertyTransition,
35
37
  ): TransitionConfig | undefined {
36
38
  if (!per) return undefined
37
- if ('type' in per) return per as TransitionConfig
39
+ // Structural check, not `'type' in per`: `SpringTransition.type` is
40
+ // optional, so `{ tension: 300 }` is a valid top-level config with no
41
+ // `type` key. The old check treated it as a per-property map, looked up
42
+ // `map[key]`, got `undefined`, and ran the default spring instead.
43
+ if (isTopLevelTransition(per)) return per
38
44
  return (per as LinearGradientPerPropertyTransition)[key]
39
45
  }
40
46
 
@@ -114,6 +120,11 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
114
120
  // allocate its shared-value table on the same path.
115
121
  const slotCountRef = useRef(colors.length)
116
122
  const hasLocationsRef = useRef(locations !== undefined)
123
+ // Plain boolean for the worklet below. A ref's `.current` must not be read
124
+ // on the UI thread — the worklet runtime clones the ref object once, so a
125
+ // later write would be invisible there. Presence is locked at mount, so the
126
+ // value cannot change after the first render.
127
+ const hasLocations = hasLocationsRef.current
117
128
 
118
129
  if (__DEV__) {
119
130
  if (slotCountRef.current !== colors.length) {
@@ -174,6 +185,13 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
174
185
  const endKey = animate?.end ? `${animate.end.x},${animate.end.y}` : ''
175
186
  const locationsKey = animate?.locations ? animate.locations.join('|') : ''
176
187
 
188
+ // Effects below key on this signature, not on the `transition` object. A
189
+ // parent that re-renders with an inline `transition={{ ... }}` literal makes
190
+ // a new object each time; keying on identity re-fired every effect and
191
+ // restarted the in-flight animation from its current value with the full
192
+ // duration. The signature only changes when the config itself changes.
193
+ const transitionSig = stableSig(transition)
194
+
177
195
  const animateColors = animate?.colors
178
196
  const animateStart = animate?.start
179
197
  const animateEnd = animate?.end
@@ -188,7 +206,7 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
188
206
  }
189
207
  // colorSvs / colors are stable across renders by the length-lock above.
190
208
  // eslint-disable-next-line react-hooks/exhaustive-deps
191
- }, [colorsKey, reduce, transition])
209
+ }, [colorsKey, reduce, transitionSig])
192
210
 
193
211
  useEffect(() => {
194
212
  if (!animateStart) return
@@ -196,7 +214,7 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
196
214
  startX.value = resolveTransition(cfg, animateStart.x) as number
197
215
  startY.value = resolveTransition(cfg, animateStart.y) as number
198
216
  // eslint-disable-next-line react-hooks/exhaustive-deps
199
- }, [startKey, reduce, transition])
217
+ }, [startKey, reduce, transitionSig])
200
218
 
201
219
  useEffect(() => {
202
220
  if (!animateEnd) return
@@ -204,7 +222,7 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
204
222
  endX.value = resolveTransition(cfg, animateEnd.x) as number
205
223
  endY.value = resolveTransition(cfg, animateEnd.y) as number
206
224
  // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [endKey, reduce, transition])
225
+ }, [endKey, reduce, transitionSig])
208
226
 
209
227
  useEffect(() => {
210
228
  if (!animateLocations) return
@@ -216,7 +234,7 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
216
234
  }
217
235
  }
218
236
  // eslint-disable-next-line react-hooks/exhaustive-deps
219
- }, [locationsKey, reduce, transition])
237
+ }, [locationsKey, reduce, transitionSig])
220
238
 
221
239
  const animatedProps = useAnimatedProps(() => {
222
240
  'worklet'
@@ -232,7 +250,7 @@ export function MotionLinearGradient(props: MotionLinearGradientProps) {
232
250
  start: { x: startX.value, y: startY.value },
233
251
  end: { x: endX.value, y: endY.value },
234
252
  }
235
- if (hasLocationsRef.current) {
253
+ if (hasLocations) {
236
254
  const locsOut = new Array<number>(locationSvs.length)
237
255
  for (let i = 0; i < locationSvs.length; i++)
238
256
  locsOut[i] = locationSvs[i]!.value