@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.
- package/CHANGELOG.md +24 -2
- package/dist/{chunk-NKA6XR77.js → chunk-3XTVY34H.js} +2 -2
- package/dist/{chunk-V6BTXHZK.mjs → chunk-46P57VMY.mjs} +1 -1
- package/dist/{chunk-IWLU3VDE.js → chunk-BP3Y2SHQ.js} +132 -26
- package/dist/{chunk-TUMNV4P7.mjs → chunk-BQQTHG2V.mjs} +1 -1
- package/dist/{chunk-LYMLQ6WQ.mjs → chunk-CSODMRJ7.mjs} +132 -26
- package/dist/chunk-FNVFV4EY.js +8 -0
- package/dist/chunk-FWQOXA43.js +8 -0
- package/dist/chunk-KBP4LR75.js +8 -0
- package/dist/{chunk-2DHKV2XY.mjs → chunk-O22NXXCZ.mjs} +1 -1
- package/dist/{chunk-HFDZAHWP.mjs → chunk-OQV66TBQ.mjs} +1 -1
- package/dist/{chunk-ZN3PNGHQ.mjs → chunk-SGUHE5CX.mjs} +1 -1
- package/dist/{chunk-TUYHTHVV.js → chunk-W5MC3P4N.js} +2 -2
- package/dist/index.d.mts +10 -8
- package/dist/index.d.ts +10 -8
- package/dist/index.js +41 -28
- package/dist/index.mjs +34 -21
- package/dist/motion/Image.js +3 -3
- package/dist/motion/Image.mjs +2 -2
- package/dist/motion/Pressable.js +3 -3
- package/dist/motion/Pressable.mjs +2 -2
- package/dist/motion/ScrollView.js +3 -3
- package/dist/motion/ScrollView.mjs +2 -2
- package/dist/motion/Text.js +3 -3
- package/dist/motion/Text.mjs +2 -2
- package/dist/motion/View.js +3 -3
- package/dist/motion/View.mjs +2 -2
- package/package.json +1 -1
- package/src/layout/index.ts +1 -0
- package/src/layout/sharedRegistry.ts +51 -2
- package/src/motion/createMotionComponent.tsx +176 -20
- package/src/presence/Presence.tsx +73 -10
- package/src/values/useAnimator.ts +25 -14
- package/src/values/useColorCascade.ts +34 -16
- package/dist/chunk-34Q4UM6V.js +0 -8
- package/dist/chunk-KYJROYCG.js +0 -8
- 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
|
|
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)
|
|
578
|
-
|
|
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
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
140
|
+
const key = el.key as Key
|
|
141
|
+
byKey.set(key, el)
|
|
142
|
+
presentKeys.add(key)
|
|
143
|
+
order.push(key)
|
|
117
144
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
28
|
-
* the reduced-motion flag
|
|
29
|
-
*
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
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
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
90
|
-
|
|
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'
|
package/dist/chunk-34Q4UM6V.js
DELETED
package/dist/chunk-KYJROYCG.js
DELETED
package/dist/chunk-X5J5M3K3.js
DELETED