@rootnative/inertia 0.0.3 → 0.0.4

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 (74) hide show
  1. package/CHANGELOG.md +41 -5
  2. package/README.md +5 -3
  3. package/dist/{chunk-SGUHE5CX.mjs → chunk-4ASOELLA.mjs} +1 -1
  4. package/dist/{chunk-CSODMRJ7.mjs → chunk-55W7BNO2.mjs} +475 -83
  5. package/dist/chunk-5SRD2WCO.js +8 -0
  6. package/dist/{chunk-PTRF47DA.js → chunk-7A6WTXNK.js} +10 -3
  7. package/dist/{chunk-3XTVY34H.js → chunk-CENTULW6.js} +2 -2
  8. package/dist/{chunk-4PEHWDAZ.js → chunk-D5KAHWMC.js} +7 -7
  9. package/dist/{chunk-O22NXXCZ.mjs → chunk-J5Q46OPW.mjs} +1 -1
  10. package/dist/{chunk-RGNX6UZN.mjs → chunk-K3CLP74S.mjs} +2 -2
  11. package/dist/{chunk-BP3Y2SHQ.js → chunk-MYE5NMXW.js} +495 -101
  12. package/dist/{chunk-OQV66TBQ.mjs → chunk-NAOXQJ7U.mjs} +1 -1
  13. package/dist/{chunk-R63GIUNU.mjs → chunk-QKC3APP4.mjs} +1 -1
  14. package/dist/{chunk-46P57VMY.mjs → chunk-RF7J5FRM.mjs} +1 -1
  15. package/dist/{chunk-CY7Y64C3.mjs → chunk-TU2XFPAK.mjs} +10 -4
  16. package/dist/chunk-TVFNJ25Z.js +8 -0
  17. package/dist/chunk-VLDIFL3F.js +8 -0
  18. package/dist/{chunk-7AOERN53.js → chunk-WVRCKOPO.js} +3 -3
  19. package/dist/{chunk-W5MC3P4N.js → chunk-XMEVPEMH.js} +2 -2
  20. package/dist/{chunk-BQQTHG2V.mjs → chunk-YKGY22SB.mjs} +1 -1
  21. package/dist/gestureLayer/index.d.mts +2 -2
  22. package/dist/gestureLayer/index.d.ts +2 -2
  23. package/dist/gestureLayer/index.js +10 -10
  24. package/dist/gestureLayer/index.mjs +3 -3
  25. package/dist/index.d.mts +4 -4
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +48 -169
  28. package/dist/index.mjs +17 -137
  29. package/dist/motion/Image.d.mts +1 -1
  30. package/dist/motion/Image.d.ts +1 -1
  31. package/dist/motion/Image.js +5 -5
  32. package/dist/motion/Image.mjs +4 -4
  33. package/dist/motion/Pressable.d.mts +1 -1
  34. package/dist/motion/Pressable.d.ts +1 -1
  35. package/dist/motion/Pressable.js +5 -5
  36. package/dist/motion/Pressable.mjs +4 -4
  37. package/dist/motion/ScrollView.d.mts +1 -1
  38. package/dist/motion/ScrollView.d.ts +1 -1
  39. package/dist/motion/ScrollView.js +5 -5
  40. package/dist/motion/ScrollView.mjs +4 -4
  41. package/dist/motion/Text.d.mts +1 -1
  42. package/dist/motion/Text.d.ts +1 -1
  43. package/dist/motion/Text.js +5 -5
  44. package/dist/motion/Text.mjs +4 -4
  45. package/dist/motion/View.d.mts +1 -1
  46. package/dist/motion/View.d.ts +1 -1
  47. package/dist/motion/View.js +5 -5
  48. package/dist/motion/View.mjs +4 -4
  49. package/dist/touch/index.d.mts +1 -1
  50. package/dist/touch/index.d.ts +1 -1
  51. package/dist/touch/index.js +3 -3
  52. package/dist/touch/index.mjs +1 -1
  53. package/dist/{types-G1dBU2IQ.d.mts → types-37pN82yh.d.mts} +49 -14
  54. package/dist/{types-G1dBU2IQ.d.ts → types-37pN82yh.d.ts} +49 -14
  55. package/dist/{useGesture-D1e4ixJo.d.ts → useGesture-Cl4uzZIr.d.ts} +1 -1
  56. package/dist/{useGesture-RFY6kTL3.d.mts → useGesture-_IKkS8Dv.d.mts} +1 -1
  57. package/llms.txt +3 -1
  58. package/package.json +1 -1
  59. package/src/index.ts +2 -1
  60. package/src/internal/boxShadow.ts +177 -6
  61. package/src/internal/nonWorkletWarning.ts +4 -6
  62. package/src/internal/warnOnce.ts +28 -0
  63. package/src/layout/index.ts +10 -0
  64. package/src/layout/measureWindow.ts +135 -0
  65. package/src/layout/sharedRegistry.ts +107 -18
  66. package/src/layout/useSharedLayout.ts +256 -91
  67. package/src/motion/createMotionComponent.tsx +378 -39
  68. package/src/motion/installCheck.ts +1 -1
  69. package/src/transitions/index.ts +1 -1
  70. package/src/types.ts +52 -12
  71. package/src/values/useColorTransition.ts +1 -1
  72. package/dist/chunk-FNVFV4EY.js +0 -8
  73. package/dist/chunk-FWQOXA43.js +0 -8
  74. package/dist/chunk-KBP4LR75.js +0 -8
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Module-level registry of last-known on-screen rects for shared-layout
3
- * elements, indexed by `layoutId`. Backs `<Motion.* layoutId="..." />` —
2
+ * Module-level registry of last-known on-screen rects and the style values
3
+ * the rect can't express — for shared-layout elements, indexed by `layoutId`.
4
+ * Backs `<Motion.* layoutId="..." />` —
4
5
  * Reanimated 4 dropped the `sharedTransitionTag` API the previous design
5
6
  * relied on, so the cross-screen shared-element transition lives in
6
7
  * userland now.
@@ -14,25 +15,86 @@
14
15
  * it becomes the FLIP source rect; the entry is removed so a third
15
16
  * mount with the same id doesn't re-animate from a stale snapshot.
16
17
  *
17
- * Rects are stored in **parent-relative coordinates** (what `onLayout`'s
18
- * `nativeEvent.layout` reports). This composes for the common case where the
19
- * source and target share an outer content container e.g. a typical stack
20
- * navigator. Nested-parent setups, where the two parents sit at different
21
- * window offsets, need a window-coordinate path (`measureInWindow`); that is
22
- * punted to v2 per the roadmap.
18
+ * Rects are stored in **window coordinates** when the host node can be
19
+ * measured synchronously, and in the parent-relative coordinates `onLayout`
20
+ * reports when it can't (see `measureWindow.ts`). Which one an entry holds is
21
+ * recorded on the rect itself, because the two are not comparable: a FLIP
22
+ * computed from a parent-relative source against a window-space target is off
23
+ * by the parent's window offset. Consumers check the space and skip the
24
+ * animation rather than play a wrong one.
25
+ *
26
+ * Window coordinates are what make a shared element work when the source and
27
+ * target sit under containers at different screen offsets — the case the
28
+ * original parent-relative implementation got wrong. They cost something in
29
+ * return: `onLayout` does not fire when an *ancestor* scrolls, so a stored
30
+ * window rect goes stale as the user scrolls, where a parent-relative rect
31
+ * would not have. That is why an entry also carries `remeasure` while its
32
+ * owner is mounted; see `SharedLayoutSource`.
23
33
  */
24
34
 
25
- /** Parent-relative rect of a measured element (from `onLayout`). */
35
+ /** Which coordinate system a rect's `x` / `y` are expressed in. */
36
+ export type CoordinateSpace = 'window' | 'parent'
37
+
38
+ /**
39
+ * The source element's values for the style keys a shared-element transition
40
+ * carries across (colors, `opacity`, `borderRadius`) — everything the rect
41
+ * FLIP can't express. Keys are `AnimatableKey`s, but typed loosely here so the
42
+ * registry stays independent of the factory's key vocabulary.
43
+ *
44
+ * Only keys the source actually had a value for appear. Absent means "the
45
+ * source had nothing to say about this" — the target then keeps its own value
46
+ * rather than crossfading from an invented one.
47
+ */
48
+ export type SharedStyleSnapshot = Record<string, number | string>
49
+
50
+ /** Measured rect of an element, tagged with its coordinate space. */
26
51
  export interface SharedRect {
27
52
  x: number
28
53
  y: number
29
54
  width: number
30
55
  height: number
56
+ space: CoordinateSpace
57
+ }
58
+
59
+ /**
60
+ * What `consumeLayout` hands back: the last recorded rect, plus — only while
61
+ * the source element is *still mounted* — a way to ask it for a fresh one.
62
+ *
63
+ * The re-measure hook is what keeps window coordinates honest in the dominant
64
+ * case. In a stack navigator the source screen stays mounted underneath the
65
+ * pushed one, so at the moment the target first lays out the source is still
66
+ * there and can be measured *now* rather than trusted from whenever its last
67
+ * layout happened. Without it, scrolling a list before tapping a row would
68
+ * offset every shared-element transition by the scroll distance.
69
+ *
70
+ * Returns `undefined` when the source can no longer be measured, in which case
71
+ * the caller keeps the stored rect. Absent entirely once `releaseLayout` has
72
+ * run, since by then there is no node left to measure.
73
+ */
74
+ export interface SharedLayoutSource {
75
+ rect: SharedRect
76
+ remeasure?: () => SharedRect | undefined
77
+ /**
78
+ * The source's carried style values, as of its last layout. Present only
79
+ * once `releaseLayout` has run — while the owner is still mounted,
80
+ * `readStyles` supersedes this for the same reason `remeasure` supersedes
81
+ * the stored rect.
82
+ */
83
+ styles?: SharedStyleSnapshot
84
+ /**
85
+ * Read the source's carried style values *now*. Offered only while the
86
+ * source is mounted. Its values move without a layout pass (an animation
87
+ * settling, a theme swap), so a stored snapshot is a floor, not the truth.
88
+ */
89
+ readStyles?: () => SharedStyleSnapshot | undefined
31
90
  }
32
91
 
33
92
  interface Entry {
34
93
  rect: SharedRect
35
94
  expiresAt: number
95
+ remeasure?: () => SharedRect | undefined
96
+ styles?: SharedStyleSnapshot
97
+ readStyles?: () => SharedStyleSnapshot | undefined
36
98
  }
37
99
 
38
100
  const REGISTRY = new Map<string, Entry>()
@@ -87,37 +149,64 @@ function sweepExpired(at: number): void {
87
149
  * Motion primitive with `layoutId` set so the registry always holds a
88
150
  * current measurement if that primitive becomes the source of a future
89
151
  * transition. Resets the TTL each call.
152
+ *
153
+ * `remeasure` and `readStyles` are the still-mounted owner's offer to be read
154
+ * again on demand — pass them while the element is live so a consumer can
155
+ * prefer fresh values over whatever this call recorded.
90
156
  */
91
- export function registerLayout(id: string, rect: SharedRect): void {
157
+ export function registerLayout(
158
+ id: string,
159
+ rect: SharedRect,
160
+ remeasure?: () => SharedRect | undefined,
161
+ readStyles?: () => SharedStyleSnapshot | undefined,
162
+ ): void {
92
163
  const at = now()
93
164
  sweepExpired(at)
94
- REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS })
165
+ REGISTRY.set(id, {
166
+ rect,
167
+ expiresAt: at + SHARED_LAYOUT_TTL_MS,
168
+ remeasure,
169
+ readStyles,
170
+ })
95
171
  }
96
172
 
97
173
  /**
98
174
  * Record the rect for `id` on unmount so the next mount can consume it as
99
- * the FLIP source. Functionally identical to `registerLayout` — the split
100
- * is purely intent-documenting at the call site.
175
+ * the FLIP source.
176
+ *
177
+ * Deliberately drops any `remeasure` / `readStyles` hook the mounted entry
178
+ * carried: the node is on its way out, and measuring a detached view yields
179
+ * zeros. From here the values recorded by this call are all a consumer gets —
180
+ * which is why `styles` is passed by value here and by callback above.
101
181
  */
102
- export function releaseLayout(id: string, rect: SharedRect): void {
182
+ export function releaseLayout(
183
+ id: string,
184
+ rect: SharedRect,
185
+ styles?: SharedStyleSnapshot,
186
+ ): void {
103
187
  const at = now()
104
188
  sweepExpired(at)
105
- REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS })
189
+ REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS, styles })
106
190
  }
107
191
 
108
192
  /**
109
- * Take the recorded rect for `id` if it exists and hasn't expired. The
193
+ * Take the recorded source for `id` if it exists and hasn't expired. The
110
194
  * entry is removed in either case — at most one incoming mount consumes
111
195
  * a given release, and an expired entry is dropped so it can't poison a
112
196
  * later transition. Returns `undefined` when no fresh source is available,
113
197
  * in which case the caller should mount without a layout animation.
114
198
  */
115
- export function consumeLayout(id: string): SharedRect | undefined {
199
+ export function consumeLayout(id: string): SharedLayoutSource | undefined {
116
200
  const entry = REGISTRY.get(id)
117
201
  if (!entry) return undefined
118
202
  REGISTRY.delete(id)
119
203
  if (entry.expiresAt < now()) return undefined
120
- return entry.rect
204
+ return {
205
+ rect: entry.rect,
206
+ remeasure: entry.remeasure,
207
+ styles: entry.styles,
208
+ readStyles: entry.readStyles,
209
+ }
121
210
  }
122
211
 
123
212
  /** Drop all entries. Tests use this to isolate between cases. */
@@ -8,6 +8,7 @@ import {
8
8
  } from 'react'
9
9
  import { type LayoutChangeEvent } from 'react-native'
10
10
  import {
11
+ cancelAnimation,
11
12
  type SharedValue,
12
13
  useSharedValue,
13
14
  withSequence,
@@ -16,11 +17,14 @@ import {
16
17
  } from 'react-native-reanimated'
17
18
  import { DEFAULT_SPRING, springToReanimated } from '../transitions/spring'
18
19
  import { type SpringTransition, type TransitionConfig } from '../types'
20
+ import { measureWindowRect } from './measureWindow'
19
21
  import {
20
22
  consumeLayout,
21
23
  registerLayout,
22
24
  releaseLayout,
25
+ type SharedLayoutSource,
23
26
  type SharedRect,
27
+ type SharedStyleSnapshot,
24
28
  } from './sharedRegistry'
25
29
 
26
30
  /**
@@ -43,9 +47,29 @@ export interface SharedLayoutValues {
43
47
  sy: SharedValue<number>
44
48
  }
45
49
 
50
+ /**
51
+ * Shared values carrying the *style* half of a shared-element transition — the
52
+ * part the rect FLIP can't express.
53
+ *
54
+ * `snapshot` parks the source element's values for the carried keys; `progress`
55
+ * runs 1 → 0 on the same transition as the FLIP. The host's worklet reads both
56
+ * and pulls each carried key from the snapshot toward its own resolved value,
57
+ * so at the first frame the arriving element wears the source's colors and by
58
+ * the last it wears its own.
59
+ *
60
+ * At rest `snapshot` is `null` and `progress` is 0, so the worklet's whole
61
+ * contribution is one comparison.
62
+ */
63
+ export interface SharedLayoutStyleValues {
64
+ snapshot: SharedValue<SharedStyleSnapshot | null>
65
+ progress: SharedValue<number>
66
+ }
67
+
46
68
  /** What the host component needs to wire into its rendered tree. */
47
69
  export interface SharedLayoutBindings {
48
70
  flip: SharedLayoutValues
71
+ /** Source-style carry for the same transition. See `SharedLayoutStyleValues`. */
72
+ carry: SharedLayoutStyleValues
49
73
  /**
50
74
  * Composite ref the consumer attaches to the rendered animated
51
75
  * component. Forwards the underlying ref to the user-supplied `ref`
@@ -72,14 +96,32 @@ export interface SharedLayoutBindings {
72
96
  * visually places the new element at the source position, then
73
97
  * animate back to identity. `withSequence(snap, animate)` keeps the
74
98
  * animation starting from the snapped delta rather than from zero.
99
+ * 5. Carry the source's style values for the keys the rect can't express
100
+ * (colors, `opacity`, `borderRadius`) by parking them in a snapshot and
101
+ * running a crossfade progress 1 → 0 on the same transition. The blend
102
+ * happens in the host's worklet; see `SharedLayoutStyleValues`.
103
+ *
104
+ * Coordinate space: rects are recorded in **window** coordinates when the host
105
+ * can be measured synchronously (Fabric), and in the parent-relative
106
+ * coordinates `onLayout` reports when it can't (Paper, or a detached node).
107
+ * Window coordinates are what let a source and target sitting under containers
108
+ * at different screen offsets FLIP correctly — the case the original
109
+ * parent-relative implementation got wrong. A source and target that ended up
110
+ * in *different* spaces are not comparable, so the transition is skipped rather
111
+ * than played wrongly. See `measureWindow.ts` for why there is no third,
112
+ * asynchronous option.
113
+ *
114
+ * The source is re-measured at *consume* time when it is still mounted (the
115
+ * usual case — a stack navigator keeps the previous screen alive underneath).
116
+ * That is what stops a scrolled list from offsetting every transition by its
117
+ * scroll distance: `onLayout` never fires for an ancestor's scroll, so the
118
+ * stored rect drifts while the element itself has not moved relative to its
119
+ * parent. See `SharedLayoutSource`.
75
120
  *
76
- * Coordinate space note: rects are in parent-relative coordinates (what
77
- * `onLayout` reports). For the common cross-screen navigator pattern
78
- * both screens share an outer content container parent-relative deltas
79
- * match what the user perceives. Nested-parent setups where the source
80
- * and target screens sit under containers at different screen offsets
81
- * will be off by that offset; v1 documents this and leaves a precise
82
- * window-coords path for v2.
121
+ * Known limitation: if the navigator has already begun translating the outgoing
122
+ * screen by the time the target first lays out, re-measuring catches the source
123
+ * mid-transition. The error is bounded by however far the transition has
124
+ * progressed, which is far smaller than an unbounded scroll offset.
83
125
  *
84
126
  * When `layoutId` is `undefined`, every callback is a no-op and the FLIP
85
127
  * shared values stay at identity — the host's worklet then skips the
@@ -91,15 +133,33 @@ export function useSharedLayout(options: {
91
133
  transition: TransitionConfig | undefined
92
134
  shouldReduceMotion: boolean
93
135
  userOnLayout: ((event: LayoutChangeEvent) => void) | undefined
136
+ /**
137
+ * Read this element's current values for the carried style keys. Supplied by
138
+ * the host (which owns the per-key shared values); `undefined` when the host
139
+ * has nothing to carry. Identity may change every render — it is read through
140
+ * a ref, so it never churns `onLayout`.
141
+ */
142
+ readStyles?: () => SharedStyleSnapshot | undefined
94
143
  }): SharedLayoutBindings {
95
- const { layoutId, userRef, transition, shouldReduceMotion, userOnLayout } =
96
- options
144
+ const {
145
+ layoutId,
146
+ userRef,
147
+ transition,
148
+ shouldReduceMotion,
149
+ userOnLayout,
150
+ readStyles,
151
+ } = options
97
152
 
98
153
  const dx = useSharedValue(0)
99
154
  const dy = useSharedValue(0)
100
155
  const sx = useSharedValue(1)
101
156
  const sy = useSharedValue(1)
102
157
 
158
+ // Style-carry pair. `null` / 0 at rest means the host worklet skips the
159
+ // whole branch on any element that isn't mid-transition.
160
+ const snapshot = useSharedValue<SharedStyleSnapshot | null>(null)
161
+ const carryProgress = useSharedValue(0)
162
+
103
163
  // Most-recent rect for this primitive. Updated on every layout commit;
104
164
  // read on unmount to populate the registry as the FLIP source for the
105
165
  // next mount with the same id.
@@ -110,55 +170,100 @@ export function useSharedLayout(options: {
110
170
  // refresh the registry but never re-trigger a FLIP from an old source.
111
171
  const consumedRef = useRef(false)
112
172
 
173
+ // The host node, captured off the composite ref so this element can be
174
+ // measured in window coordinates — both for its own registry entry and when
175
+ // a later mount asks it to re-measure as a FLIP source.
176
+ const nodeRef = useRef<unknown>(null)
177
+
113
178
  const transitionRef = useRef(transition)
114
179
  transitionRef.current = transition
115
180
  const reducedMotionRef = useRef(shouldReduceMotion)
116
181
  reducedMotionRef.current = shouldReduceMotion
182
+ const readStylesRef = useRef(readStyles)
183
+ readStylesRef.current = readStyles
117
184
 
118
185
  const setRef = useCallback(
119
186
  (node: unknown) => {
187
+ nodeRef.current = node
120
188
  if (typeof userRef === 'function') userRef(node)
121
189
  else if (userRef) (userRef as MutableRefObject<unknown>).current = node
122
190
  },
123
191
  [userRef],
124
192
  )
125
193
 
194
+ // Offered to the registry while mounted so a later mount can ask for a
195
+ // current measurement instead of trusting the one from our last layout.
196
+ const remeasure = useCallback(() => measureWindowRect(nodeRef.current), [])
197
+
198
+ // Same idea for the carried style keys: the host's values move without a
199
+ // layout pass, so a consumer should read them now rather than trust the
200
+ // snapshot from whenever we last laid out. Stable identity — the host's
201
+ // reader is reached through a ref.
202
+ const snapshotStyles = useCallback(() => readStylesRef.current?.(), [])
203
+
204
+ const startTransition = useCallback(
205
+ (
206
+ source: SharedLayoutSource,
207
+ sourceRect: SharedRect,
208
+ target: SharedRect,
209
+ ) => {
210
+ // Mixed coordinate spaces are not comparable — the delta would be off by
211
+ // the parent's window offset. Skipping is the honest outcome; playing
212
+ // the animation anyway would fling the element in from the wrong place.
213
+ // The style carry goes with it: half a shared-element transition reads
214
+ // as a glitch, not as a graceful degradation.
215
+ if (sourceRect.space !== target.space) return
216
+ const shared = {
217
+ transition: transitionRef.current,
218
+ shouldReduceMotion: reducedMotionRef.current,
219
+ }
220
+ applyFlip({ source: sourceRect, target, dx, dy, sx, sy, ...shared })
221
+ applyStyleCarry({
222
+ snapshot,
223
+ progress: carryProgress,
224
+ // Prefer a live read for the same reason the rect is re-measured: a
225
+ // still-mounted source may have moved on since its last layout.
226
+ styles: source.readStyles?.() ?? source.styles,
227
+ ...shared,
228
+ })
229
+ },
230
+ [dx, dy, sx, sy, snapshot, carryProgress],
231
+ )
232
+
126
233
  const onLayout = useCallback(
127
234
  (event: LayoutChangeEvent) => {
128
235
  userOnLayout?.(event)
129
236
  if (!layoutId) return
130
237
 
131
238
  const { x, y, width, height } = event.nativeEvent.layout
132
- const rect: SharedRect = { x, y, width, height }
133
- lastRectRef.current = rect
134
239
 
135
240
  // First-layout-only: read the registry BEFORE writing our own rect
136
241
  // so a previously-released source rect can be consumed cleanly
137
242
  // without being overwritten by the current rect first.
138
- let source: SharedRect | undefined
243
+ let source: SharedLayoutSource | undefined
139
244
  if (!consumedRef.current) {
140
245
  consumedRef.current = true
141
246
  source = consumeLayout(layoutId)
142
247
  }
143
- registerLayout(layoutId, rect)
144
-
145
- if (source) {
146
- applyFlip({
147
- source,
148
- target: rect,
149
- dx,
150
- dy,
151
- sx,
152
- sy,
153
- transition: transitionRef.current,
154
- shouldReduceMotion: reducedMotionRef.current,
155
- })
156
- }
248
+
249
+ // Window coordinates when the host can supply them synchronously,
250
+ // otherwise the parent-relative rect this event carries. See
251
+ // `measureWindow.ts` for why there is no third, asynchronous option.
252
+ const rect: SharedRect =
253
+ measureWindowRect(nodeRef.current) ??
254
+ ({ x, y, width, height, space: 'parent' } as SharedRect)
255
+
256
+ lastRectRef.current = rect
257
+ registerLayout(layoutId, rect, remeasure, snapshotStyles)
258
+
259
+ if (!source) return
260
+ // Prefer a fresh measurement of the source over its stored rect: it may
261
+ // have scrolled since its last layout, and `onLayout` never reports an
262
+ // ancestor's scroll. Falls back to the stored rect once the source has
263
+ // unmounted or when it can't be measured.
264
+ startTransition(source, source.remeasure?.() ?? source.rect, rect)
157
265
  },
158
- // dx/dy/sx/sy are stable refs from useSharedValue, but eslint's
159
- // exhaustive-deps would flag them — including them is harmless and
160
- // silences the warning.
161
- [layoutId, userOnLayout, dx, dy, sx, sy],
266
+ [layoutId, userOnLayout, remeasure, snapshotStyles, startTransition],
162
267
  )
163
268
 
164
269
  // Reset the first-layout latch when the id changes — a new id is logically
@@ -167,33 +272,104 @@ export function useSharedLayout(options: {
167
272
  consumedRef.current = false
168
273
  }, [layoutId])
169
274
 
170
- // On unmount, hand the latest rect to the registry under this id so the
171
- // next mount can consume it as a FLIP source.
275
+ // On unmount, hand the latest rect and a final snapshot of the carried
276
+ // style keys to the registry under this id so the next mount can consume
277
+ // them. The styles are read by value here rather than left behind as a
278
+ // callback: after this the node is gone, and a later read would be measuring
279
+ // a component nobody owns.
172
280
  useEffect(() => {
173
281
  return () => {
174
282
  if (!layoutId) return
175
283
  const rect = lastRectRef.current
176
284
  if (!rect) return
177
- releaseLayout(layoutId, rect)
285
+ releaseLayout(layoutId, rect, snapshotStyles())
178
286
  }
179
- }, [layoutId])
287
+ }, [layoutId, snapshotStyles])
288
+
289
+ // Mark unmounted and stop any in-flight FLIP. The mounted flag is what keeps
290
+ // an asynchronous measurement callback from writing to shared values the
291
+ // component no longer owns; cancelling matches the guard the factory's own
292
+ // per-key values and the value-layer hooks got in `0.0.2` — these four were
293
+ // missed at the time.
294
+ // Stop any in-flight FLIP on unmount. Matches the guard the factory's own
295
+ // per-key values and the value-layer hooks got in `0.0.2` — these four were
296
+ // missed at the time.
297
+ useEffect(
298
+ () => () => {
299
+ cancelAnimation(dx)
300
+ cancelAnimation(dy)
301
+ cancelAnimation(sx)
302
+ cancelAnimation(sy)
303
+ // `snapshot` is assigned, never animated, so there is nothing to cancel
304
+ // on it — only the progress value drives.
305
+ cancelAnimation(carryProgress)
306
+ },
307
+ // The FLIP shared values are identity-stable per hook instance.
308
+ // eslint-disable-next-line react-hooks/exhaustive-deps
309
+ [],
310
+ )
180
311
 
181
312
  return useMemo<SharedLayoutBindings>(
182
313
  () => ({
183
314
  flip: { dx, dy, sx, sy },
315
+ carry: { snapshot, progress: carryProgress },
184
316
  setRef,
185
317
  onLayout,
186
318
  }),
187
- [dx, dy, sx, sy, setRef, onLayout],
319
+ [dx, dy, sx, sy, snapshot, carryProgress, setRef, onLayout],
188
320
  )
189
321
  }
190
322
 
323
+ /**
324
+ * Build the `withSequence(snap, animate)` pair every leg of a shared-element
325
+ * transition uses: jump to `from` instantly, then animate to `to`. The snap leg
326
+ * is what makes the animation start from the source — a plain `withSpring(to)`
327
+ * off the resting base would animate from wherever the value already sat.
328
+ *
329
+ * Returned as a closure so the spring params are converted once and reused
330
+ * across all five legs (four transforms plus the style crossfade), which also
331
+ * guarantees the rect and the style land together.
332
+ */
333
+ function legBuilder(
334
+ transition: TransitionConfig | undefined,
335
+ ): (from: number, to: number) => number {
336
+ if (transition?.type === 'timing') {
337
+ const duration = transition.duration ?? 300
338
+ return (from, to) =>
339
+ withSequence(
340
+ withTiming(from, { duration: 0 }),
341
+ withTiming(to, { duration }),
342
+ )
343
+ }
344
+ // Spring path (default, and the fallback for `'decay'` which doesn't have a
345
+ // meaningful target value for a FLIP transition).
346
+ const springCfg: SpringTransition =
347
+ transition?.type === 'spring'
348
+ ? { ...DEFAULT_SPRING, ...transition }
349
+ : { type: 'spring', ...DEFAULT_SPRING }
350
+ const springParams = springToReanimated(springCfg)
351
+ return (from, to) =>
352
+ withSequence(
353
+ withTiming(from, { duration: 0 }),
354
+ withSpring(to, springParams),
355
+ )
356
+ }
357
+
358
+ /**
359
+ * Whether a shared-element transition should play at all, or snap. Reduced
360
+ * motion and an explicit `'no-animation'` transition are the same answer for
361
+ * both the rect and the style halves.
362
+ */
363
+ function shouldSnap(
364
+ transition: TransitionConfig | undefined,
365
+ shouldReduceMotion: boolean,
366
+ ): boolean {
367
+ return shouldReduceMotion || transition?.type === 'no-animation'
368
+ }
369
+
191
370
  /**
192
371
  * Snap the FLIP shared values so the new element visually overlays its
193
- * source rect, then animate back to identity. The `withSequence(snap,
194
- * animate)` shape is what makes the spring start from the snapped delta —
195
- * a plain `withSpring(0)` from a zero base would animate from-zero, not
196
- * from-source.
372
+ * source rect, then animate back to identity.
197
373
  */
198
374
  function applyFlip(args: {
199
375
  source: SharedRect
@@ -223,9 +399,9 @@ function applyFlip(args: {
223
399
  const scaleX = target.width > 0 ? source.width / target.width : 1
224
400
  const scaleY = target.height > 0 ? source.height / target.height : 1
225
401
 
226
- if (shouldReduceMotion) {
227
- // Reduced-motion: skip the visual transition entirely. The element
228
- // appears at its natural position; the source rect is discarded.
402
+ if (shouldSnap(transition, shouldReduceMotion)) {
403
+ // Skip the visual transition entirely. The element appears at its natural
404
+ // position; the source rect is discarded.
229
405
  dx.value = 0
230
406
  dy.value = 0
231
407
  sx.value = 1
@@ -233,57 +409,46 @@ function applyFlip(args: {
233
409
  return
234
410
  }
235
411
 
236
- if (transition?.type === 'no-animation') {
237
- dx.value = 0
238
- dy.value = 0
239
- sx.value = 1
240
- sy.value = 1
241
- return
242
- }
412
+ const leg = legBuilder(transition)
413
+ dx.value = leg(deltaX, 0)
414
+ dy.value = leg(deltaY, 0)
415
+ sx.value = leg(scaleX, 1)
416
+ sy.value = leg(scaleY, 1)
417
+ }
243
418
 
244
- if (transition?.type === 'timing') {
245
- const duration = transition.duration ?? 300
246
- dx.value = withSequence(
247
- withTiming(deltaX, { duration: 0 }),
248
- withTiming(0, { duration }),
249
- )
250
- dy.value = withSequence(
251
- withTiming(deltaY, { duration: 0 }),
252
- withTiming(0, { duration }),
253
- )
254
- sx.value = withSequence(
255
- withTiming(scaleX, { duration: 0 }),
256
- withTiming(1, { duration }),
257
- )
258
- sy.value = withSequence(
259
- withTiming(scaleY, { duration: 0 }),
260
- withTiming(1, { duration }),
261
- )
419
+ /**
420
+ * Park the source element's carried style values and run the crossfade
421
+ * progress 1 → 0 on the same transition as the rect FLIP.
422
+ *
423
+ * The blend itself happens in the host's worklet, which pulls each carried key
424
+ * from `snapshot[key]` toward its own resolved value by `progress`. Driving
425
+ * only a scalar here is what keeps this cheap: one shared value moves, however
426
+ * many keys are being carried, and colors go through the same
427
+ * `interpolateColor` branch the gesture cascade already uses.
428
+ *
429
+ * A source with nothing to carry leaves both values alone — an element whose
430
+ * counterpart declared no carried keys behaves exactly as it did before style
431
+ * carry existed.
432
+ */
433
+ function applyStyleCarry(args: {
434
+ snapshot: SharedValue<SharedStyleSnapshot | null>
435
+ progress: SharedValue<number>
436
+ styles: SharedStyleSnapshot | undefined
437
+ transition: TransitionConfig | undefined
438
+ shouldReduceMotion: boolean
439
+ }): void {
440
+ const { snapshot, progress, styles, transition, shouldReduceMotion } = args
441
+ if (!styles) return
442
+
443
+ if (shouldSnap(transition, shouldReduceMotion)) {
444
+ // Matching the rect path: the element arrives wearing its own style rather
445
+ // than crossfading into it. Clearing the snapshot too means the worklet's
446
+ // branch stays untaken instead of resting on a dead payload.
447
+ snapshot.value = null
448
+ progress.value = 0
262
449
  return
263
450
  }
264
451
 
265
- // Spring path (default, and the fallback for `'decay'` which doesn't
266
- // have a meaningful target value for a FLIP transition).
267
- const springCfg: SpringTransition =
268
- transition?.type === 'spring'
269
- ? { ...DEFAULT_SPRING, ...transition }
270
- : { type: 'spring', ...DEFAULT_SPRING }
271
- const springParams = springToReanimated(springCfg)
272
-
273
- dx.value = withSequence(
274
- withTiming(deltaX, { duration: 0 }),
275
- withSpring(0, springParams),
276
- )
277
- dy.value = withSequence(
278
- withTiming(deltaY, { duration: 0 }),
279
- withSpring(0, springParams),
280
- )
281
- sx.value = withSequence(
282
- withTiming(scaleX, { duration: 0 }),
283
- withSpring(1, springParams),
284
- )
285
- sy.value = withSequence(
286
- withTiming(scaleY, { duration: 0 }),
287
- withSpring(1, springParams),
288
- )
452
+ snapshot.value = styles
453
+ progress.value = legBuilder(transition)(1, 0)
289
454
  }