@rootnative/inertia 0.0.2 → 0.0.3

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/dist/{chunk-NKA6XR77.js → chunk-3XTVY34H.js} +2 -2
  3. package/dist/{chunk-V6BTXHZK.mjs → chunk-46P57VMY.mjs} +1 -1
  4. package/dist/{chunk-IWLU3VDE.js → chunk-BP3Y2SHQ.js} +132 -26
  5. package/dist/{chunk-TUMNV4P7.mjs → chunk-BQQTHG2V.mjs} +1 -1
  6. package/dist/{chunk-LYMLQ6WQ.mjs → chunk-CSODMRJ7.mjs} +132 -26
  7. package/dist/chunk-FNVFV4EY.js +8 -0
  8. package/dist/chunk-FWQOXA43.js +8 -0
  9. package/dist/chunk-KBP4LR75.js +8 -0
  10. package/dist/{chunk-2DHKV2XY.mjs → chunk-O22NXXCZ.mjs} +1 -1
  11. package/dist/{chunk-HFDZAHWP.mjs → chunk-OQV66TBQ.mjs} +1 -1
  12. package/dist/{chunk-ZN3PNGHQ.mjs → chunk-SGUHE5CX.mjs} +1 -1
  13. package/dist/{chunk-TUYHTHVV.js → chunk-W5MC3P4N.js} +2 -2
  14. package/dist/index.d.mts +10 -8
  15. package/dist/index.d.ts +10 -8
  16. package/dist/index.js +41 -28
  17. package/dist/index.mjs +34 -21
  18. package/dist/motion/Image.js +3 -3
  19. package/dist/motion/Image.mjs +2 -2
  20. package/dist/motion/Pressable.js +3 -3
  21. package/dist/motion/Pressable.mjs +2 -2
  22. package/dist/motion/ScrollView.js +3 -3
  23. package/dist/motion/ScrollView.mjs +2 -2
  24. package/dist/motion/Text.js +3 -3
  25. package/dist/motion/Text.mjs +2 -2
  26. package/dist/motion/View.js +3 -3
  27. package/dist/motion/View.mjs +2 -2
  28. package/package.json +1 -1
  29. package/src/layout/index.ts +1 -0
  30. package/src/layout/sharedRegistry.ts +51 -2
  31. package/src/motion/createMotionComponent.tsx +176 -20
  32. package/src/presence/Presence.tsx +73 -10
  33. package/src/values/useAnimator.ts +25 -14
  34. package/src/values/useColorCascade.ts +34 -16
  35. package/dist/chunk-34Q4UM6V.js +0 -8
  36. package/dist/chunk-KYJROYCG.js +0 -8
  37. package/dist/chunk-X5J5M3K3.js +0 -8
@@ -14,7 +14,7 @@ import Animated, {
14
14
  useSharedValue,
15
15
  type SharedValue,
16
16
  } from 'react-native-reanimated'
17
- import { type LayoutChangeEvent } from 'react-native'
17
+ import { StyleSheet, type LayoutChangeEvent } from 'react-native'
18
18
  import {
19
19
  lookupNamedTransition,
20
20
  resolveNamedTransitionProp,
@@ -448,16 +448,57 @@ export function createMotionComponent<C extends ComponentType<any>>(
448
448
  )
449
449
  }
450
450
 
451
+ // Which keys a record actually drives *right now*. Everything else in
452
+ // the active set is there only because a gesture sub-state, an `exit`
453
+ // target, or a variant branch that isn't current mentions it. Those must
454
+ // rest at whatever the static `style` says — the animated style merges
455
+ // AFTER `style`, so resting them at `DEFAULT_RESTING` silently stomps it
456
+ // (`borderColor` → 'transparent', `width` → 0, and so on for every key
457
+ // whose default isn't an identity). Transforms and `opacity` hid this
458
+ // bug for a long time: their defaults happen to be no-ops.
459
+ const drivenNow = new Set<AnimatableKey>()
460
+ collectTouchedKeys(drivenNow, animateRecord)
461
+ if (initialRecord) collectTouchedKeys(drivenNow, initialRecord)
462
+ if (isExiting && exitRecord) collectTouchedKeys(drivenNow, exitRecord)
463
+
464
+ // Monotonic, like the active set. Once a record has driven a key we stop
465
+ // syncing it to the style, so transitioning to a variant that doesn't
466
+ // mention the key leaves it where the previous variant put it — the
467
+ // existing semantic — instead of snapping back to the style value.
468
+ const everDrivenRef = useRef<Set<AnimatableKey>>(new Set())
469
+ for (const k of drivenNow) everDrivenRef.current.add(k)
470
+
471
+ // Resting values pulled off the static `style`, for active keys nothing
472
+ // has driven. Gated so the common `animate`-only instance never flattens
473
+ // a style it has no use for.
474
+ const activeKeys = activeKeysRef.current!
475
+ const everDriven = everDrivenRef.current
476
+ const styleResting: Partial<Record<AnimatableKey, number | string>> = {}
477
+ if (activeKeys.some((k) => !everDriven.has(k))) {
478
+ const flat = StyleSheet.flatten(style as never) as
479
+ | Record<string, unknown>
480
+ | undefined
481
+ if (flat) {
482
+ for (const key of activeKeys) {
483
+ if (everDriven.has(key)) continue
484
+ const v = styleValueFor(flat, key)
485
+ if (v !== undefined) styleResting[key] = v
486
+ }
487
+ }
488
+ }
489
+
451
490
  const sharedValues = useAnimatableSharedValues((key) => {
452
491
  // Shadow offset synthetics seed from the corresponding axis on the
453
492
  // `shadowOffset: { width, height }` source — the consumer doesn't write
454
493
  // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
455
- // generic resting default when neither initial nor animate touched it.
494
+ // static style, then the generic resting default, when neither initial
495
+ // nor animate touched it.
456
496
  if (SHADOW_OFFSET_KEY_SET.has(key)) {
457
497
  const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
458
498
  if (initial === false) {
459
499
  return (
460
500
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
501
+ styleResting[key] ??
461
502
  DEFAULT_RESTING[key]
462
503
  )
463
504
  }
@@ -469,20 +510,39 @@ export function createMotionComponent<C extends ComponentType<any>>(
469
510
  axis,
470
511
  ) ??
471
512
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
513
+ styleResting[key] ??
472
514
  DEFAULT_RESTING[key]
473
515
  )
474
516
  }
475
517
  if (initial === false) {
476
518
  const a = animateRecord[key]
477
- return restValue(a) ?? DEFAULT_RESTING[key]
519
+ return restValue(a) ?? styleResting[key] ?? DEFAULT_RESTING[key]
478
520
  }
479
521
  return (
480
522
  initialRecord?.[key] ??
481
523
  restValue(animateRecord[key]) ??
524
+ styleResting[key] ??
482
525
  DEFAULT_RESTING[key]
483
526
  )
484
527
  })
485
528
 
529
+ // Keep never-driven keys tracking the *live* static style. The seed above
530
+ // only runs at mount, but the animated style emits these keys on every
531
+ // frame — so without this a `style` that changes afterwards (a theme
532
+ // swap, a conditional colour) would stay invisible for any key some
533
+ // gesture / exit / variant branch happens to mention. Direct assignment,
534
+ // not an animation: this is a static value, not a target.
535
+ const styleRestingSig = stableSig(styleResting)
536
+ useEffect(() => {
537
+ for (const key of activeKeysRef.current!) {
538
+ if (everDrivenRef.current.has(key)) continue
539
+ sharedValues[key].value = (styleResting[key] ??
540
+ DEFAULT_RESTING[key]) as never
541
+ }
542
+ // `styleResting` is rebuilt each render; its signature is the real dep.
543
+ // eslint-disable-next-line react-hooks/exhaustive-deps
544
+ }, [styleRestingSig])
545
+
486
546
  // One progress SV per gesture layer, allocated unconditionally for hook
487
547
  // stability. Each layer's progress animates 0↔1 with its own transition
488
548
  // when its activation flips; the worklet reads them when compositing.
@@ -568,15 +628,39 @@ export function createMotionComponent<C extends ComponentType<any>>(
568
628
  }
569
629
  }
570
630
 
631
+ // Per-key transition resolution, hoisted out of the loop below so the
632
+ // transform-group count can ask about a key's config before the loop
633
+ // reaches it. Reduced motion overrides every per-key transition (and
634
+ // any nested sequence-step transition) with `no-animation`, which the
635
+ // resolver turns into a direct value assignment. Sequences still
636
+ // iterate but each step settles instantly, matching the "snap to final
637
+ // state" expectation.
638
+ const configFor = (key: AnimatableKey): TransitionConfig | undefined =>
639
+ shouldReduceMotion
640
+ ? ({ type: 'no-animation' } as const)
641
+ : transitionFor(
642
+ SHADOW_OFFSET_KEY_SET.has(key)
643
+ ? ('shadowOffset' as keyof typeof baseRecord)
644
+ : key,
645
+ transition,
646
+ )
647
+
571
648
  // Count transform axes participating in this effect run so the factory
572
649
  // can coalesce their terminal callbacks into a single transform-group
573
650
  // event. `undefined` when no transform axis is animating, which lets
574
651
  // the factory skip the coalescing branch entirely.
652
+ //
653
+ // Endless axes are excluded. A `repeat: 'infinite'` axis never reaches
654
+ // the terminal `'animation'` phase, so counting it would pin
655
+ // `remaining` permanently above zero — a sibling axis with a finite
656
+ // transition would settle, decrement, and have its completion silently
657
+ // swallowed. An axis that never finishes simply isn't part of the group
658
+ // that does.
575
659
  let transformPending = 0
576
660
  for (const k of ALL_KEYS) {
577
- if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
578
- transformPending++
579
- }
661
+ if (!TRANSFORM_KEY_SET.has(k) || baseRecord[k] === undefined) continue
662
+ if (!reachesTerminalPhase(configFor(k))) continue
663
+ transformPending++
580
664
  }
581
665
  const transformGroup: TransformGroup | undefined =
582
666
  transformPending > 0 ? { remaining: transformPending } : undefined
@@ -595,20 +679,15 @@ export function createMotionComponent<C extends ComponentType<any>>(
595
679
  )
596
680
  : baseRecord[key]
597
681
  if (target === undefined) continue
598
- // Reduced-motion overrides every per-key transition (and any nested
599
- // sequence-step transition) with `no-animation`, which the resolver
600
- // turns into a direct value assignment. Sequences still iterate but
601
- // each step settles instantly, which matches the "snap to final
602
- // state" expectation.
603
- const cfg = shouldReduceMotion
604
- ? ({ type: 'no-animation' } as const)
605
- : transitionFor(
606
- SHADOW_OFFSET_KEY_SET.has(key)
607
- ? ('shadowOffset' as keyof typeof baseRecord)
608
- : key,
609
- transition,
610
- )
611
- if (isExiting) pending++
682
+ const cfg = configFor(key)
683
+ // <Presence> waits on this counter before unmounting, so only count
684
+ // keys that can actually settle. An endless exit animation (a
685
+ // `repeat: 'infinite'` transition inherited by `exit`, e.g. a pulsing
686
+ // element inside <Presence>) would otherwise never call
687
+ // `safeToRemove` and the child would stay mounted forever. Finite
688
+ // keys still gate the unmount; if every exit key is endless, the
689
+ // post-loop `pending === 0` release fires immediately.
690
+ if (isExiting && reachesTerminalPhase(cfg)) pending++
612
691
  const factory = makeKeyCallbackFactory(
613
692
  key,
614
693
  sharedValues[key],
@@ -1146,6 +1225,67 @@ function shadowOffsetAxisValue(
1146
1225
  return source?.[axis]
1147
1226
  }
1148
1227
 
1228
+ /**
1229
+ * Read an animatable key's resting value out of a **flattened style object**.
1230
+ *
1231
+ * Used for keys that are in the active set but that no record drives — they
1232
+ * have to rest wherever the static `style` put them, because the animated
1233
+ * style merges after `style` and would otherwise override it.
1234
+ *
1235
+ * Three shapes need unwrapping: transform keys live inside the `transform`
1236
+ * array rather than at the top level, `shadowOffset*` synthetics decompose
1237
+ * from the nested object, and rotations are unit-suffixed strings on a style
1238
+ * but plain degrees in our shared values.
1239
+ *
1240
+ * Type-narrow deliberately: only numbers are accepted for numeric slots, so a
1241
+ * `width: '100%'` is left to `DEFAULT_RESTING` rather than seeded into a slot
1242
+ * that a later `withTiming` would try to interpolate as a number.
1243
+ */
1244
+ function styleValueFor(
1245
+ flat: Record<string, unknown>,
1246
+ key: AnimatableKey,
1247
+ ): number | string | undefined {
1248
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
1249
+ return shadowOffsetAxisValue(
1250
+ flat.shadowOffset as { width?: number; height?: number } | undefined,
1251
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
1252
+ )
1253
+ }
1254
+ if (TRANSFORM_KEY_SET.has(key)) {
1255
+ const list = flat.transform
1256
+ if (!Array.isArray(list)) return undefined
1257
+ for (const entry of list) {
1258
+ if (!entry || typeof entry !== 'object') continue
1259
+ const v = (entry as Record<string, unknown>)[key]
1260
+ if (v === undefined) continue
1261
+ if (ROTATION_KEYS.has(key)) return parseAngleDegrees(v)
1262
+ return typeof v === 'number' ? v : undefined
1263
+ }
1264
+ return undefined
1265
+ }
1266
+ const v = flat[key]
1267
+ if (COLOR_KEY_SET.has(key)) {
1268
+ return typeof v === 'string' || typeof v === 'number' ? v : undefined
1269
+ }
1270
+ return typeof v === 'number' ? v : undefined
1271
+ }
1272
+
1273
+ /**
1274
+ * Style rotations are unit-suffixed strings (`'45deg'`, `'0.5rad'`); the shared
1275
+ * value behind a rotation key holds plain degrees. Anything we can't convert
1276
+ * confidently returns `undefined` so the caller falls back to the resting
1277
+ * default instead of guessing at a unit.
1278
+ */
1279
+ function parseAngleDegrees(v: unknown): number | undefined {
1280
+ if (typeof v === 'number') return v
1281
+ if (typeof v !== 'string') return undefined
1282
+ const deg = /^(-?\d*\.?\d+)deg$/.exec(v)
1283
+ if (deg) return Number(deg[1])
1284
+ const rad = /^(-?\d*\.?\d+)rad$/.exec(v)
1285
+ if (rad) return (Number(rad[1]) * 180) / Math.PI
1286
+ return undefined
1287
+ }
1288
+
1149
1289
  /**
1150
1290
  * Populate `touched` with the `AnimatableKey`s mentioned in `record`. Direct
1151
1291
  * matches (e.g. `opacity`, `width`) come from the key iteration; the nested
@@ -1190,6 +1330,22 @@ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1190
1330
  return r.count
1191
1331
  }
1192
1332
 
1333
+ /**
1334
+ * Whether an animation built from `cfg` will ever reach its terminal
1335
+ * `'animation'` phase. Everything finite does; `repeat: 'infinite'` never
1336
+ * does, because `dispatch` only promotes a callback to the terminal phase once
1337
+ * `iteration >= totalIterations - 1`, and that comparison is unreachable
1338
+ * against `Infinity`.
1339
+ *
1340
+ * Anything counting pending completions must exclude endless animations, or
1341
+ * the counter never drains: one endless transform axis swallowed every sibling
1342
+ * axis's `onAnimationEnd`, and one endless `exit` key left a `<Presence>` child
1343
+ * mounted forever because `safeToRemove` was gated behind it.
1344
+ */
1345
+ function reachesTerminalPhase(cfg: TransitionConfig | undefined): boolean {
1346
+ return Number.isFinite(totalIterationsOf(cfg))
1347
+ }
1348
+
1193
1349
  /**
1194
1350
  * Pull a single end-value out of an `AnimatableValue` for the
1195
1351
  * `AnimationCallbackInfo.target` field. Plain numbers/strings come through;
@@ -63,6 +63,20 @@ export function Presence({ children }: { children: ReactNode }) {
63
63
  // synchronously alongside the setState call.
64
64
  const prevIncomingRef = useRef<ReactElement[]>(incoming)
65
65
 
66
+ // Render order from the previous pass, *including* entries that were already
67
+ // exiting. An exiting child is by definition absent from `incoming`, so this
68
+ // is the only record of where it sat among its siblings.
69
+ const orderRef = useRef<Key[]>([])
70
+
71
+ // The exiting map this render should actually render with. On the render
72
+ // that detects a departure, `exiting` state is still the pre-departure map —
73
+ // `setExiting` below schedules the update but doesn't apply it here. Ordering
74
+ // has to see the departure immediately: if it doesn't, the key is missing
75
+ // from `orderRef` on the *next* render too, and the walk below (which only
76
+ // visits keys it remembers) would drop the child entirely instead of just
77
+ // misplacing it.
78
+ let pendingExiting: Map<Key, ReactElement> | null = null
79
+
66
80
  if (prevIncomingRef.current !== incoming) {
67
81
  const prev = prevIncomingRef.current
68
82
  prevIncomingRef.current = incoming
@@ -91,9 +105,14 @@ export function Presence({ children }: { children: ReactNode }) {
91
105
  }
92
106
  }
93
107
 
94
- if (next) setExiting(next)
108
+ if (next) {
109
+ pendingExiting = next
110
+ setExiting(next)
111
+ }
95
112
  }
96
113
 
114
+ const activeExiting = pendingExiting ?? exiting
115
+
97
116
  const handleRemove = useCallback((key: Key) => {
98
117
  setExiting((prev) => {
99
118
  if (!prev.has(key)) return prev
@@ -107,20 +126,64 @@ export function Presence({ children }: { children: ReactNode }) {
107
126
  // one array (rather than two `.map` calls inside a fragment) ensures React
108
127
  // reconciles by `key` across positions — when an entry moves from
109
128
  // present-list to exiting-list, the component instance persists.
110
- const renderList: RenderEntry[] = []
129
+ //
130
+ // Exiting entries are spliced back in at the position they held, not
131
+ // appended. React reconciles this array by key, so appending *moves* the
132
+ // node: removing the middle of `a, b, c` rendered `a, c, b` and the
133
+ // departing row visibly jumped to the end before it had finished animating
134
+ // out. Absolutely-positioned overlays (popovers, sheets) never showed it;
135
+ // any list or column did.
136
+ const byKey = new Map<Key, ReactElement>()
137
+ const presentKeys = new Set<Key>()
138
+ const order: Key[] = []
111
139
  for (const el of incoming) {
112
- renderList.push({
113
- key: el.key as Key,
114
- element: el,
115
- isPresent: true,
116
- })
140
+ const key = el.key as Key
141
+ byKey.set(key, el)
142
+ presentKeys.add(key)
143
+ order.push(key)
117
144
  }
118
- for (const [key, el] of exiting) {
119
- if (!renderList.some((entry) => entry.key === key)) {
120
- renderList.push({ key, element: el, isPresent: false })
145
+
146
+ // Walk the remembered order so that several adjacent departures keep their
147
+ // relative order, and anchor each one immediately after the nearest
148
+ // preceding sibling that is still rendered. No surviving predecessor means
149
+ // it was at the front, so it goes back to the front.
150
+ const prevOrder = orderRef.current
151
+ for (let i = 0; i < prevOrder.length; i++) {
152
+ const key = prevOrder[i]!
153
+ const departing = activeExiting.get(key)
154
+ if (!departing || byKey.has(key)) continue
155
+ let insertAt = 0
156
+ for (let j = i - 1; j >= 0; j--) {
157
+ const anchor = order.indexOf(prevOrder[j]!)
158
+ if (anchor !== -1) {
159
+ insertAt = anchor + 1
160
+ break
161
+ }
121
162
  }
163
+ byKey.set(key, departing)
164
+ order.splice(insertAt, 0, key)
165
+ }
166
+
167
+ // Safety net: an exiting child the remembered order never saw still has to
168
+ // render, or it would unmount with no exit animation at all. Appending is
169
+ // the old (wrong-position) behaviour, which is strictly better than dropping
170
+ // it — in practice `activeExiting` keeps this loop empty.
171
+ for (const [key, el] of activeExiting) {
172
+ if (byKey.has(key)) continue
173
+ byKey.set(key, el)
174
+ order.push(key)
122
175
  }
123
176
 
177
+ orderRef.current = order
178
+
179
+ // A live `incoming` entry always wins: a key that returns mid-exit is
180
+ // present again, and the same instance interrupts back toward `animate`.
181
+ const renderList: RenderEntry[] = order.map((key) => ({
182
+ key,
183
+ element: byKey.get(key)!,
184
+ isPresent: presentKeys.has(key),
185
+ }))
186
+
124
187
  return (
125
188
  <>
126
189
  {renderList.map(({ key, element, isPresent }) => (
@@ -1,4 +1,4 @@
1
- import { useCallback } from 'react'
1
+ import { useCallback, useRef } from 'react'
2
2
  import { type SharedValue } from 'react-native-reanimated'
3
3
  import {
4
4
  resolveNamedTransition,
@@ -24,9 +24,11 @@ import { type TransitionInput } from '../types'
24
24
  * `<MotionConfig reducedMotion>`. Hand-rolled `resolveTransition` writes
25
25
  * silently bypass that setting — a correctness bug this hook fixes.
26
26
  *
27
- * The returned callback is stable across renders (it reads the registry and
28
- * the reduced-motion flag live inside the body), so it can be dropped straight
29
- * into memoized handlers.
27
+ * The returned callback is identity-stable for the lifetime of the component
28
+ * it reads the registry and the reduced-motion flag out of refs at call time,
29
+ * so neither a new `<MotionConfig transitions>` map nor a reduced-motion change
30
+ * gives it a new identity. Drop it straight into memoized handlers or a
31
+ * `useCallback` dependency list without churning them.
30
32
  *
31
33
  * This is not a new animation API — it starts animations in Inertia's existing
32
34
  * transition vocabulary, so it does not conflict with the "no imperative-only
@@ -59,14 +61,23 @@ export function useAnimator(): Animator {
59
61
  const registry = useNamedTransitions()
60
62
  const shouldReduceMotion = useShouldReduceMotion()
61
63
 
62
- return useCallback(
63
- (value, to, transition) => {
64
- const resolved = resolveNamedTransition(transition, registry)
65
- const cfg = shouldReduceMotion
66
- ? ({ type: 'no-animation' } as const)
67
- : (resolved ?? ({ type: 'spring' } as const))
68
- value.value = resolveTransition(cfg, to) as never
69
- },
70
- [registry, shouldReduceMotion],
71
- )
64
+ // Latest context values behind refs, so the callback below can close over
65
+ // nothing that changes. Depending on them directly would hand back a new
66
+ // identity whenever a provider re-published its registry or the OS
67
+ // reduced-motion flag flipped — which breaks the documented contract that
68
+ // this is safe to drop into a memoized handler. Reading at call time is also
69
+ // strictly more correct: the write always resolves against the registry that
70
+ // is current *when the event fires*, not the one captured at render.
71
+ const registryRef = useRef(registry)
72
+ registryRef.current = registry
73
+ const reduceMotionRef = useRef(shouldReduceMotion)
74
+ reduceMotionRef.current = shouldReduceMotion
75
+
76
+ return useCallback((value, to, transition) => {
77
+ const resolved = resolveNamedTransition(transition, registryRef.current)
78
+ const cfg = reduceMotionRef.current
79
+ ? ({ type: 'no-animation' } as const)
80
+ : (resolved ?? ({ type: 'spring' } as const))
81
+ value.value = resolveTransition(cfg, to) as never
82
+ }, [])
72
83
  }
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useRef } from 'react'
2
2
  import {
3
3
  interpolateColor,
4
4
  useAnimatedStyle,
@@ -60,11 +60,11 @@ export interface UseColorCascadeOptions {
60
60
  * hook is not a replacement for it. For a mixed numeric + color cascade, or
61
61
  * function-valued layers, drop to a hand-rolled `useAnimatedStyle`.
62
62
  *
63
- * The layer chain is resolved once on the JS thread and memoized on a
64
- * structural signature (colors + key), so a fresh-but-equal `layers` array
65
- * each render produces no new UI-thread closure (CLAUDE.md principle 8). The
66
- * `progress` shared values are read live in the worklet, so their identity is
67
- * intentionally excluded from the signature.
63
+ * The layer chain is resolved once on the JS thread and kept identity-stable,
64
+ * so a fresh-but-equal `layers` array each render produces no new UI-thread
65
+ * closure (CLAUDE.md principle 8). Changing a colour, the `key`, the base
66
+ * `rest`, the layer count, or **which shared value drives a layer** all rewire
67
+ * the worklet as you'd expect.
68
68
  */
69
69
  export function useColorCascade(
70
70
  rest: string,
@@ -73,21 +73,39 @@ export function useColorCascade(
73
73
  ): ReturnType<typeof useAnimatedStyle> {
74
74
  const key = options?.key ?? 'backgroundColor'
75
75
 
76
- // Resolve the layer chain into two flat, memoized arrays the worklet closes
77
- // over — the static colors and the live progress shared values. Both are
78
- // memoized on the same structural signature so a fresh-but-equal `layers`
79
- // literal each render yields the same array references; Reanimated then sees
80
- // an unchanged closure dependency and does not rebuild the UI-thread worklet
81
- // (CLAUDE.md principle 8). The `progress` shared values are identity-stable
82
- // per hook instance, so signing the colors + count is enough to detect a
83
- // real change to the chain.
76
+ // Resolve the layer chain into two flat arrays the worklet closes over — the
77
+ // static colors and the live progress shared values. Both must keep a stable
78
+ // identity across renders where nothing really changed, or Reanimated sees a
79
+ // fresh closure dependency and rebuilds the UI-thread worklet every render
80
+ // (CLAUDE.md principle 8).
81
+ //
82
+ // Colors key off a structural signature.
84
83
  const sig = `${key}|${rest}|${layers.length}|${layers
85
84
  .map((l) => l.color)
86
85
  .join(',')}`
87
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
87
  const colors = useMemo(() => layers.map((l) => l.color), [sig])
89
- // eslint-disable-next-line react-hooks/exhaustive-deps
90
- const progressValues = useMemo(() => layers.map((l) => l.progress), [sig])
88
+
89
+ // Progress values can't go in that signature — they're objects, not
90
+ // stringifiable. But they can't be excluded from change detection either:
91
+ // swapping *which* shared value drives a layer while its colour stays the
92
+ // same has to rewire the worklet. (It previously didn't: the cascade kept
93
+ // reading the old SV forever, silently.) So compare references directly and
94
+ // rebuild the array only when one actually differs — an equal-but-fresh
95
+ // `layers` literal still yields the same reference and no new worklet.
96
+ const progressRef = useRef<readonly SharedValue<number>[]>([])
97
+ const prevProgress = progressRef.current
98
+ let progressChanged = prevProgress.length !== layers.length
99
+ if (!progressChanged) {
100
+ for (let i = 0; i < layers.length; i++) {
101
+ if (prevProgress[i] !== layers[i]!.progress) {
102
+ progressChanged = true
103
+ break
104
+ }
105
+ }
106
+ }
107
+ if (progressChanged) progressRef.current = layers.map((l) => l.progress)
108
+ const progressValues = progressRef.current
91
109
 
92
110
  return useAnimatedStyle(() => {
93
111
  'worklet'
@@ -1,8 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkIWLU3VDE_js = require('./chunk-IWLU3VDE.js');
4
- var reactNative = require('react-native');
5
-
6
- var MotionImage = chunkIWLU3VDE_js.createMotionComponent(reactNative.Image);
7
-
8
- exports.MotionImage = MotionImage;
@@ -1,8 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkIWLU3VDE_js = require('./chunk-IWLU3VDE.js');
4
- var reactNative = require('react-native');
5
-
6
- var MotionView = chunkIWLU3VDE_js.createMotionComponent(reactNative.View);
7
-
8
- exports.MotionView = MotionView;
@@ -1,8 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkIWLU3VDE_js = require('./chunk-IWLU3VDE.js');
4
- var reactNative = require('react-native');
5
-
6
- var MotionText = chunkIWLU3VDE_js.createMotionComponent(reactNative.Text);
7
-
8
- exports.MotionText = MotionText;