@rootnative/inertia-svg 0.0.8 → 0.0.10

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,26 @@ This package ships in lockstep with `@rootnative/inertia` — version numbers tr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.0.10] - 2026-09-06
10
+
11
+ ### Changed
12
+
13
+ - **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.
14
+
15
+ ### Added
16
+
17
+ - A test for the `initial.d` template-mismatch throw. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
18
+
19
+ ### Fixed
20
+
21
+ - **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).
22
+
23
+ - **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).
24
+
25
+ ## [0.0.9] - 2026-08-22
26
+
27
+ **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`.
28
+
9
29
  ## [0.0.8] - 2026-08-16
10
30
 
11
31
  **Lockstep version bump** alongside `@rootnative/inertia@0.0.8` (`<Stagger>`, parent-owned per-child delays for cascading list entrances, and a settle callback on `buildReleaseAnimation`). No runtime changes in this adapter; the `@rootnative/inertia` peer range moves to `>=0.0.8`.
@@ -61,7 +81,9 @@ _No git tag was cut for this release; the published artifact is on npm as [`@roo
61
81
 
62
82
  - `MotionPath` over `react-native-svg`. Animatable: `d` (element-wise scalar interpolation on structurally-compatible paths), `fill`, `stroke`, `strokeWidth`, opacities, `strokeDashoffset`. Source and target paths must share the same command sequence after implicit-repeat expansion; remount with `key` to switch shape.
63
83
 
64
- [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.8...HEAD
84
+ [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.10...HEAD
85
+ [0.0.10]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.10
86
+ [0.0.9]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.9
65
87
  [0.0.8]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.8
66
88
  [0.0.7]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.7
67
89
  [0.0.6]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.6
package/dist/index.js CHANGED
@@ -180,7 +180,7 @@ var AnimatedPath = Animated__default.default.createAnimatedComponent(reactNative
180
180
  var NO_ANIMATION = { type: "no-animation" };
181
181
  function pickTransition(per, key) {
182
182
  if (!per) return void 0;
183
- if ("type" in per) return per;
183
+ if (inertia.isTopLevelTransition(per)) return per;
184
184
  return per[key];
185
185
  }
186
186
  function MotionPath(props) {
@@ -254,6 +254,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
254
254
  seedSource?.strokeDashoffset ?? strokeDashoffset ?? 0
255
255
  );
256
256
  const reduce = inertia.useShouldReduceMotion();
257
+ const transitionSig = inertia.stableSig(transition);
257
258
  const animateD = animate?.d;
258
259
  const animateFill = animate?.fill;
259
260
  const animateStroke = animate?.stroke;
@@ -278,22 +279,22 @@ If you need to swap to a structurally different path, remount with key={...}.`
278
279
  for (let i = 0; i < paramSvs.length; i++) {
279
280
  paramSvs[i].value = inertia.resolveTransition(cfg, target[i] ?? 0);
280
281
  }
281
- }, [animateD, reduce, transition]);
282
+ }, [animateD, reduce, transitionSig]);
282
283
  react.useEffect(() => {
283
284
  if (animateFill === void 0) return;
284
285
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "fill");
285
286
  fillSv.value = inertia.resolveTransition(cfg, animateFill);
286
- }, [animateFill, reduce, transition]);
287
+ }, [animateFill, reduce, transitionSig]);
287
288
  react.useEffect(() => {
288
289
  if (animateStroke === void 0) return;
289
290
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "stroke");
290
291
  strokeSv.value = inertia.resolveTransition(cfg, animateStroke);
291
- }, [animateStroke, reduce, transition]);
292
+ }, [animateStroke, reduce, transitionSig]);
292
293
  react.useEffect(() => {
293
294
  if (animateStrokeWidth === void 0) return;
294
295
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeWidth");
295
296
  strokeWidthSv.value = inertia.resolveTransition(cfg, animateStrokeWidth);
296
- }, [animateStrokeWidth, reduce, transition]);
297
+ }, [animateStrokeWidth, reduce, transitionSig]);
297
298
  react.useEffect(() => {
298
299
  if (animateStrokeOpacity === void 0) return;
299
300
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeOpacity");
@@ -301,17 +302,17 @@ If you need to swap to a structurally different path, remount with key={...}.`
301
302
  cfg,
302
303
  animateStrokeOpacity
303
304
  );
304
- }, [animateStrokeOpacity, reduce, transition]);
305
+ }, [animateStrokeOpacity, reduce, transitionSig]);
305
306
  react.useEffect(() => {
306
307
  if (animateFillOpacity === void 0) return;
307
308
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "fillOpacity");
308
309
  fillOpacitySv.value = inertia.resolveTransition(cfg, animateFillOpacity);
309
- }, [animateFillOpacity, reduce, transition]);
310
+ }, [animateFillOpacity, reduce, transitionSig]);
310
311
  react.useEffect(() => {
311
312
  if (animateOpacity === void 0) return;
312
313
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "opacity");
313
314
  opacitySv.value = inertia.resolveTransition(cfg, animateOpacity);
314
- }, [animateOpacity, reduce, transition]);
315
+ }, [animateOpacity, reduce, transitionSig]);
315
316
  react.useEffect(() => {
316
317
  if (animateStrokeDashoffset === void 0) return;
317
318
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeDashoffset");
@@ -319,7 +320,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
319
320
  cfg,
320
321
  animateStrokeDashoffset
321
322
  );
322
- }, [animateStrokeDashoffset, reduce, transition]);
323
+ }, [animateStrokeDashoffset, reduce, transitionSig]);
323
324
  const animatedProps = Animated.useAnimatedProps(() => {
324
325
  "worklet";
325
326
  const params = new Array(paramSvs.length);
@@ -354,7 +355,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
354
355
  var NO_ANIMATION2 = { type: "no-animation" };
355
356
  function pickTransition2(transition, key, registry) {
356
357
  if (!transition) return void 0;
357
- if (typeof transition === "string" || "type" in transition) {
358
+ if (typeof transition === "string" || inertia.isTopLevelTransition(transition)) {
358
359
  return inertia.resolveNamedTransition(transition, registry);
359
360
  }
360
361
  return inertia.resolveNamedTransition(
@@ -447,6 +448,7 @@ function createMotionSvgComponent(Component, config) {
447
448
  }
448
449
  }
449
450
  }
451
+ const transitionSig = inertia.stableSig(transition);
450
452
  for (const k of numericKeys) {
451
453
  const target = anim?.[k];
452
454
  react.useEffect(() => {
@@ -457,7 +459,7 @@ function createMotionSvgComponent(Component, config) {
457
459
  }
458
460
  const cfg = reduce ? NO_ANIMATION2 : pickTransition2(transition, k, registry);
459
461
  numericSvs[k].value = inertia.resolveTransition(cfg, target);
460
- }, [target, reduce, transition]);
462
+ }, [target, reduce, transitionSig]);
461
463
  }
462
464
  for (const k of colorKeys) {
463
465
  const target = anim?.[k];
@@ -469,7 +471,7 @@ function createMotionSvgComponent(Component, config) {
469
471
  }
470
472
  const cfg = reduce ? NO_ANIMATION2 : pickTransition2(transition, k, registry);
471
473
  colorSvs[k].value = inertia.resolveTransition(cfg, target);
472
- }, [target, reduce, transition]);
474
+ }, [target, reduce, transitionSig]);
473
475
  }
474
476
  for (const k of arrayKeys) {
475
477
  const target = anim?.[k];
@@ -494,7 +496,7 @@ function createMotionSvgComponent(Component, config) {
494
496
  for (let i = 0; i < len; i++) {
495
497
  svs[i].value = inertia.resolveTransition(cfg, target[i] ?? 0);
496
498
  }
497
- }, [sig, reduce, transition]);
499
+ }, [sig, reduce, transitionSig]);
498
500
  }
499
501
  const animatedProps = Animated.useAnimatedProps(() => {
500
502
  "worklet";
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useRef, useEffect, useMemo } from 'react';
2
2
  import { Path, Circle, Rect, Line } from 'react-native-svg';
3
3
  import Animated, { useSharedValue, useAnimatedProps } from 'react-native-reanimated';
4
- import { useShouldReduceMotion, useNamedTransitions, TRANSPARENT, resolveTransition, resolveNamedTransition } from '@rootnative/inertia';
4
+ import { useShouldReduceMotion, useNamedTransitions, TRANSPARENT, stableSig, resolveTransition, isTopLevelTransition, resolveNamedTransition } from '@rootnative/inertia';
5
5
  import { jsx } from 'react/jsx-runtime';
6
6
 
7
7
  // src/MotionPath.tsx
@@ -174,7 +174,7 @@ var AnimatedPath = Animated.createAnimatedComponent(Path);
174
174
  var NO_ANIMATION = { type: "no-animation" };
175
175
  function pickTransition(per, key) {
176
176
  if (!per) return void 0;
177
- if ("type" in per) return per;
177
+ if (isTopLevelTransition(per)) return per;
178
178
  return per[key];
179
179
  }
180
180
  function MotionPath(props) {
@@ -248,6 +248,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
248
248
  seedSource?.strokeDashoffset ?? strokeDashoffset ?? 0
249
249
  );
250
250
  const reduce = useShouldReduceMotion();
251
+ const transitionSig = stableSig(transition);
251
252
  const animateD = animate?.d;
252
253
  const animateFill = animate?.fill;
253
254
  const animateStroke = animate?.stroke;
@@ -272,22 +273,22 @@ If you need to swap to a structurally different path, remount with key={...}.`
272
273
  for (let i = 0; i < paramSvs.length; i++) {
273
274
  paramSvs[i].value = resolveTransition(cfg, target[i] ?? 0);
274
275
  }
275
- }, [animateD, reduce, transition]);
276
+ }, [animateD, reduce, transitionSig]);
276
277
  useEffect(() => {
277
278
  if (animateFill === void 0) return;
278
279
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "fill");
279
280
  fillSv.value = resolveTransition(cfg, animateFill);
280
- }, [animateFill, reduce, transition]);
281
+ }, [animateFill, reduce, transitionSig]);
281
282
  useEffect(() => {
282
283
  if (animateStroke === void 0) return;
283
284
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "stroke");
284
285
  strokeSv.value = resolveTransition(cfg, animateStroke);
285
- }, [animateStroke, reduce, transition]);
286
+ }, [animateStroke, reduce, transitionSig]);
286
287
  useEffect(() => {
287
288
  if (animateStrokeWidth === void 0) return;
288
289
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeWidth");
289
290
  strokeWidthSv.value = resolveTransition(cfg, animateStrokeWidth);
290
- }, [animateStrokeWidth, reduce, transition]);
291
+ }, [animateStrokeWidth, reduce, transitionSig]);
291
292
  useEffect(() => {
292
293
  if (animateStrokeOpacity === void 0) return;
293
294
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeOpacity");
@@ -295,17 +296,17 @@ If you need to swap to a structurally different path, remount with key={...}.`
295
296
  cfg,
296
297
  animateStrokeOpacity
297
298
  );
298
- }, [animateStrokeOpacity, reduce, transition]);
299
+ }, [animateStrokeOpacity, reduce, transitionSig]);
299
300
  useEffect(() => {
300
301
  if (animateFillOpacity === void 0) return;
301
302
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "fillOpacity");
302
303
  fillOpacitySv.value = resolveTransition(cfg, animateFillOpacity);
303
- }, [animateFillOpacity, reduce, transition]);
304
+ }, [animateFillOpacity, reduce, transitionSig]);
304
305
  useEffect(() => {
305
306
  if (animateOpacity === void 0) return;
306
307
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "opacity");
307
308
  opacitySv.value = resolveTransition(cfg, animateOpacity);
308
- }, [animateOpacity, reduce, transition]);
309
+ }, [animateOpacity, reduce, transitionSig]);
309
310
  useEffect(() => {
310
311
  if (animateStrokeDashoffset === void 0) return;
311
312
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, "strokeDashoffset");
@@ -313,7 +314,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
313
314
  cfg,
314
315
  animateStrokeDashoffset
315
316
  );
316
- }, [animateStrokeDashoffset, reduce, transition]);
317
+ }, [animateStrokeDashoffset, reduce, transitionSig]);
317
318
  const animatedProps = useAnimatedProps(() => {
318
319
  "worklet";
319
320
  const params = new Array(paramSvs.length);
@@ -348,7 +349,7 @@ If you need to swap to a structurally different path, remount with key={...}.`
348
349
  var NO_ANIMATION2 = { type: "no-animation" };
349
350
  function pickTransition2(transition, key, registry) {
350
351
  if (!transition) return void 0;
351
- if (typeof transition === "string" || "type" in transition) {
352
+ if (typeof transition === "string" || isTopLevelTransition(transition)) {
352
353
  return resolveNamedTransition(transition, registry);
353
354
  }
354
355
  return resolveNamedTransition(
@@ -441,6 +442,7 @@ function createMotionSvgComponent(Component, config) {
441
442
  }
442
443
  }
443
444
  }
445
+ const transitionSig = stableSig(transition);
444
446
  for (const k of numericKeys) {
445
447
  const target = anim?.[k];
446
448
  useEffect(() => {
@@ -451,7 +453,7 @@ function createMotionSvgComponent(Component, config) {
451
453
  }
452
454
  const cfg = reduce ? NO_ANIMATION2 : pickTransition2(transition, k, registry);
453
455
  numericSvs[k].value = resolveTransition(cfg, target);
454
- }, [target, reduce, transition]);
456
+ }, [target, reduce, transitionSig]);
455
457
  }
456
458
  for (const k of colorKeys) {
457
459
  const target = anim?.[k];
@@ -463,7 +465,7 @@ function createMotionSvgComponent(Component, config) {
463
465
  }
464
466
  const cfg = reduce ? NO_ANIMATION2 : pickTransition2(transition, k, registry);
465
467
  colorSvs[k].value = resolveTransition(cfg, target);
466
- }, [target, reduce, transition]);
468
+ }, [target, reduce, transitionSig]);
467
469
  }
468
470
  for (const k of arrayKeys) {
469
471
  const target = anim?.[k];
@@ -488,7 +490,7 @@ function createMotionSvgComponent(Component, config) {
488
490
  for (let i = 0; i < len; i++) {
489
491
  svs[i].value = resolveTransition(cfg, target[i] ?? 0);
490
492
  }
491
- }, [sig, reduce, transition]);
493
+ }, [sig, reduce, transitionSig]);
492
494
  }
493
495
  const animatedProps = useAnimatedProps(() => {
494
496
  "worklet";
package/llms.txt CHANGED
@@ -99,7 +99,7 @@ Path normalization that resamples between arbitrary shapes (the flubber-style ap
99
99
  | Key | Shape | Notes |
100
100
  | ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------- |
101
101
  | `d` | `string` | Path data. Source and target must share the same template — see above. Each scalar param springs / times independently. |
102
- | `fill` | `string` | Color, interpolated via Reanimated's color setter. Defaults to `'transparent'` if neither static nor `initial` is supplied. |
102
+ | `fill` | `string` | Color, interpolated via Reanimated's color setter. Defaults to `TRANSPARENT` (`rgba(0, 0, 0, 0)`) if neither static nor `initial` is supplied. |
103
103
  | `stroke` | `string` | Same as `fill`. |
104
104
  | `strokeWidth` | `number` | Numeric. Default seed `1`. |
105
105
  | `strokeOpacity` | `number` | Numeric, 0–1. |
@@ -187,7 +187,7 @@ Shape-specific rules (they mirror `MotionPath`'s command-sequence lock):
187
187
 
188
188
  - **Array props lock their length at first render.** `strokeDasharray` interpolates element-wise, so a target with a different length throws in dev and is ignored in production. Remount with a new `key` to change the length.
189
189
  - **A key only animates when it's present at mount** — in the static props, `initial`, or `animate`. Keys introduced into `animate` after mount warn in dev and are ignored. Keys never mentioned pass through as ordinary static props, so un-animated attributes keep their element defaults.
190
- - Numeric keys engaged only via `animate` seed from `0`; colors seed from `'transparent'`. Give the key a static prop or an `initial` value when the mount animation should start elsewhere.
190
+ - Numeric keys engaged only via `animate` seed from `0`; colors seed from `TRANSPARENT` (`rgba(0, 0, 0, 0)`, exported from `@rootnative/inertia`). Give the key a static prop or an `initial` value when the mount animation should start elsewhere.
191
191
 
192
192
  ## createMotionSvgComponent {#factory}
193
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia-svg",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Animatable SVG primitives (path morphing, fill/stroke) for @rootnative/inertia, built on react-native-svg.",
5
5
  "license": "MIT",
6
6
  "author": "RootNative",
@@ -50,7 +50,7 @@
50
50
  "!**/*.test.*"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@rootnative/inertia": ">=0.0.8",
53
+ "@rootnative/inertia": ">=0.0.10 <0.1.0",
54
54
  "react": ">=19.0.0",
55
55
  "react-native": ">=0.81.0",
56
56
  "react-native-reanimated": ">=4.0.0",
@@ -69,7 +69,7 @@
69
69
  "react-test-renderer": "19.1.0",
70
70
  "tsup": "^8.3.5",
71
71
  "typescript": "^5.7.3",
72
- "@rootnative/inertia": "0.0.8"
72
+ "@rootnative/inertia": "0.0.10"
73
73
  },
74
74
  "publishConfig": {
75
75
  "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
  TRANSPARENT,
11
13
  useShouldReduceMotion,
12
14
  type TransitionConfig,
@@ -34,7 +36,10 @@ function pickTransition(
34
36
  key: keyof PathPerPropertyTransition,
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 and must not be read as a per-property map.
42
+ if (isTopLevelTransition(per)) return per
38
43
  return (per as PathPerPropertyTransition)[key]
39
44
  }
40
45
 
@@ -209,6 +214,13 @@ export function MotionPath(props: MotionPathProps) {
209
214
  // Serialize scalar targets into stable keys so effects re-run on value
210
215
  // change, not on every parent re-render (a fresh `animate` literal each
211
216
  // render is the common case).
217
+ // Effects below key on this signature, not on the `transition` object. A
218
+ // parent that re-renders with an inline `transition={{ ... }}` literal makes
219
+ // a new object each time; keying on identity re-fired every effect and
220
+ // restarted the in-flight animation from its current value with the full
221
+ // duration. The signature only changes when the config itself changes.
222
+ const transitionSig = stableSig(transition)
223
+
212
224
  const animateD = animate?.d
213
225
  const animateFill = animate?.fill
214
226
  const animateStroke = animate?.stroke
@@ -236,21 +248,21 @@ export function MotionPath(props: MotionPathProps) {
236
248
  }
237
249
  // paramSvs / template are stable across renders by the locks above.
238
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
239
- }, [animateD, reduce, transition])
251
+ }, [animateD, reduce, transitionSig])
240
252
 
241
253
  useEffect(() => {
242
254
  if (animateFill === undefined) return
243
255
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'fill')
244
256
  fillSv.value = resolveTransition(cfg, animateFill) as string
245
257
  // eslint-disable-next-line react-hooks/exhaustive-deps
246
- }, [animateFill, reduce, transition])
258
+ }, [animateFill, reduce, transitionSig])
247
259
 
248
260
  useEffect(() => {
249
261
  if (animateStroke === undefined) return
250
262
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'stroke')
251
263
  strokeSv.value = resolveTransition(cfg, animateStroke) as string
252
264
  // eslint-disable-next-line react-hooks/exhaustive-deps
253
- }, [animateStroke, reduce, transition])
265
+ }, [animateStroke, reduce, transitionSig])
254
266
 
255
267
  useEffect(() => {
256
268
  if (animateStrokeWidth === undefined) return
@@ -259,7 +271,7 @@ export function MotionPath(props: MotionPathProps) {
259
271
  : pickTransition(transition, 'strokeWidth')
260
272
  strokeWidthSv.value = resolveTransition(cfg, animateStrokeWidth) as number
261
273
  // eslint-disable-next-line react-hooks/exhaustive-deps
262
- }, [animateStrokeWidth, reduce, transition])
274
+ }, [animateStrokeWidth, reduce, transitionSig])
263
275
 
264
276
  useEffect(() => {
265
277
  if (animateStrokeOpacity === undefined) return
@@ -271,7 +283,7 @@ export function MotionPath(props: MotionPathProps) {
271
283
  animateStrokeOpacity,
272
284
  ) as number
273
285
  // eslint-disable-next-line react-hooks/exhaustive-deps
274
- }, [animateStrokeOpacity, reduce, transition])
286
+ }, [animateStrokeOpacity, reduce, transitionSig])
275
287
 
276
288
  useEffect(() => {
277
289
  if (animateFillOpacity === undefined) return
@@ -280,14 +292,14 @@ export function MotionPath(props: MotionPathProps) {
280
292
  : pickTransition(transition, 'fillOpacity')
281
293
  fillOpacitySv.value = resolveTransition(cfg, animateFillOpacity) as number
282
294
  // eslint-disable-next-line react-hooks/exhaustive-deps
283
- }, [animateFillOpacity, reduce, transition])
295
+ }, [animateFillOpacity, reduce, transitionSig])
284
296
 
285
297
  useEffect(() => {
286
298
  if (animateOpacity === undefined) return
287
299
  const cfg = reduce ? NO_ANIMATION : pickTransition(transition, 'opacity')
288
300
  opacitySv.value = resolveTransition(cfg, animateOpacity) as number
289
301
  // eslint-disable-next-line react-hooks/exhaustive-deps
290
- }, [animateOpacity, reduce, transition])
302
+ }, [animateOpacity, reduce, transitionSig])
291
303
 
292
304
  useEffect(() => {
293
305
  if (animateStrokeDashoffset === undefined) return
@@ -299,7 +311,7 @@ export function MotionPath(props: MotionPathProps) {
299
311
  animateStrokeDashoffset,
300
312
  ) as number
301
313
  // eslint-disable-next-line react-hooks/exhaustive-deps
302
- }, [animateStrokeDashoffset, reduce, transition])
314
+ }, [animateStrokeDashoffset, reduce, transitionSig])
303
315
 
304
316
  const animatedProps = useAnimatedProps(() => {
305
317
  'worklet'
@@ -5,8 +5,10 @@ import Animated, {
5
5
  type SharedValue,
6
6
  } from 'react-native-reanimated'
7
7
  import {
8
+ isTopLevelTransition,
8
9
  resolveNamedTransition,
9
10
  resolveTransition,
11
+ stableSig,
10
12
  TRANSPARENT,
11
13
  useNamedTransitions,
12
14
  useShouldReduceMotion,
@@ -112,7 +114,10 @@ function pickTransition(
112
114
  registry: NamedTransitions,
113
115
  ): TransitionConfig | undefined {
114
116
  if (!transition) return undefined
115
- if (typeof transition === 'string' || 'type' in transition) {
117
+ // Structural check, not `'type' in transition`: `SpringTransition.type` is
118
+ // optional, so `{ tension: 300 }` is a valid top-level config with no
119
+ // `type` key and must not be read as a per-property map.
120
+ if (typeof transition === 'string' || isTopLevelTransition(transition)) {
116
121
  return resolveNamedTransition(transition as TransitionInput, registry)
117
122
  }
118
123
  return resolveNamedTransition(
@@ -299,6 +304,12 @@ export function createMotionSvgComponent<
299
304
  }
300
305
  }
301
306
 
307
+ // Effects below key on this signature, not on the `transition` object. A
308
+ // parent that re-renders with an inline `transition={{ ... }}` literal makes
309
+ // a new object each time; keying on identity re-fired every effect and
310
+ // restarted the in-flight animation from its current value with the full
311
+ // duration. The signature only changes when the config itself changes.
312
+ const transitionSig = stableSig(transition)
302
313
  // One effect per configured key (fixed count). Targets are read into
303
314
  // locals so the dep arrays key on the values, not on a fresh `animate`
304
315
  // literal each render.
@@ -318,7 +329,7 @@ export function createMotionSvgComponent<
318
329
  // SVs / engaged set are mount-stable; registry changes re-resolve via
319
330
  // the transition dep on the next animate change.
320
331
  // eslint-disable-next-line react-hooks/exhaustive-deps
321
- }, [target, reduce, transition])
332
+ }, [target, reduce, transitionSig])
322
333
  }
323
334
 
324
335
  for (const k of colorKeys) {
@@ -335,7 +346,7 @@ export function createMotionSvgComponent<
335
346
  : pickTransition(transition, k, registry)
336
347
  colorSvs[k]!.value = resolveTransition(cfg, target) as string
337
348
  // eslint-disable-next-line react-hooks/exhaustive-deps
338
- }, [target, reduce, transition])
349
+ }, [target, reduce, transitionSig])
339
350
  }
340
351
 
341
352
  for (const k of arrayKeys) {
@@ -367,7 +378,7 @@ export function createMotionSvgComponent<
367
378
  svs[i]!.value = resolveTransition(cfg, target[i] ?? 0) as number
368
379
  }
369
380
  // eslint-disable-next-line react-hooks/exhaustive-deps
370
- }, [sig, reduce, transition])
381
+ }, [sig, reduce, transitionSig])
371
382
  }
372
383
 
373
384
  const animatedProps = useAnimatedProps(() => {