@rootnative/inertia 0.0.2 → 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 (77) hide show
  1. package/CHANGELOG.md +63 -5
  2. package/README.md +5 -3
  3. package/dist/{chunk-ZN3PNGHQ.mjs → chunk-4ASOELLA.mjs} +1 -1
  4. package/dist/{chunk-LYMLQ6WQ.mjs → chunk-55W7BNO2.mjs} +604 -106
  5. package/dist/chunk-5SRD2WCO.js +8 -0
  6. package/dist/{chunk-PTRF47DA.js → chunk-7A6WTXNK.js} +10 -3
  7. package/dist/{chunk-NKA6XR77.js → chunk-CENTULW6.js} +2 -2
  8. package/dist/{chunk-4PEHWDAZ.js → chunk-D5KAHWMC.js} +7 -7
  9. package/dist/{chunk-2DHKV2XY.mjs → chunk-J5Q46OPW.mjs} +1 -1
  10. package/dist/{chunk-RGNX6UZN.mjs → chunk-K3CLP74S.mjs} +2 -2
  11. package/dist/{chunk-IWLU3VDE.js → chunk-MYE5NMXW.js} +623 -123
  12. package/dist/{chunk-HFDZAHWP.mjs → chunk-NAOXQJ7U.mjs} +1 -1
  13. package/dist/{chunk-R63GIUNU.mjs → chunk-QKC3APP4.mjs} +1 -1
  14. package/dist/{chunk-V6BTXHZK.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-TUYHTHVV.js → chunk-XMEVPEMH.js} +2 -2
  20. package/dist/{chunk-TUMNV4P7.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 +14 -12
  26. package/dist/index.d.ts +14 -12
  27. package/dist/index.js +68 -176
  28. package/dist/index.mjs +40 -147
  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 +11 -0
  64. package/src/layout/measureWindow.ts +135 -0
  65. package/src/layout/sharedRegistry.ts +156 -18
  66. package/src/layout/useSharedLayout.ts +256 -91
  67. package/src/motion/createMotionComponent.tsx +550 -55
  68. package/src/motion/installCheck.ts +1 -1
  69. package/src/presence/Presence.tsx +73 -10
  70. package/src/transitions/index.ts +1 -1
  71. package/src/types.ts +52 -12
  72. package/src/values/useAnimator.ts +25 -14
  73. package/src/values/useColorCascade.ts +34 -16
  74. package/src/values/useColorTransition.ts +1 -1
  75. package/dist/chunk-34Q4UM6V.js +0 -8
  76. package/dist/chunk-KYJROYCG.js +0 -8
  77. package/dist/chunk-X5J5M3K3.js +0 -8
@@ -144,17 +144,24 @@ export function parseBoxShadow(input: string): ResolvedBoxShadowLayer[] {
144
144
  })
145
145
  }
146
146
 
147
- /** Resolve either input form (CSS string or structured layers) to layers. */
147
+ /**
148
+ * Resolve either input form (CSS string or structured layers) to layers.
149
+ *
150
+ * Lengths may arrive as numbers (what `useShadow`'s `BoxShadowLayer` declares)
151
+ * or as px strings (what RN's own `BoxShadowValue` permits, so what the
152
+ * `animate` surface has to accept). `coerceLength` normalizes both and rejects
153
+ * anything else, so downstream only ever sees concrete numbers.
154
+ */
148
155
  export function resolveBoxShadowInput(
149
- input: string | readonly BoxShadowLayer[] | undefined,
156
+ input: BoxShadowInput | undefined,
150
157
  ): ResolvedBoxShadowLayer[] {
151
158
  if (input === undefined) return []
152
159
  if (typeof input === 'string') return parseBoxShadow(input)
153
160
  return input.map((layer) => ({
154
- offsetX: layer.offsetX,
155
- offsetY: layer.offsetY,
156
- blurRadius: layer.blurRadius ?? 0,
157
- spreadDistance: layer.spreadDistance ?? 0,
161
+ offsetX: coerceLength(layer.offsetX, 'offsetX'),
162
+ offsetY: coerceLength(layer.offsetY, 'offsetY'),
163
+ blurRadius: coerceLength(layer.blurRadius, 'blurRadius'),
164
+ spreadDistance: coerceLength(layer.spreadDistance, 'spreadDistance'),
158
165
  color: layer.color ?? 'black',
159
166
  inset: layer.inset ?? false,
160
167
  }))
@@ -202,3 +209,167 @@ function invisibleLayer(inset: boolean): ResolvedBoxShadowLayer {
202
209
  inset,
203
210
  }
204
211
  }
212
+
213
+ /* ------------------------------------------------------------------ *
214
+ * Declarative `animate={{ boxShadow }}` support
215
+ *
216
+ * The `useShadow` path above interpolates two endpoints itself, by
217
+ * progress. The `animate` path instead hands the target to Reanimated's
218
+ * own animation drivers, which have their own rules — the helpers below
219
+ * exist to satisfy them on the JS thread so the worklet never does string
220
+ * or structural work at frame time (Principle 8).
221
+ * ------------------------------------------------------------------ */
222
+
223
+ /**
224
+ * The interpolable half of a box-shadow layer: every field Reanimated can
225
+ * drive, and nothing it can't.
226
+ *
227
+ * `inset` is deliberately absent. `withTiming` / `withSpring` recurse into
228
+ * arrays and objects and animate each leaf, and a boolean leaf falls through
229
+ * to the plain numeric path — `false + (false - false) * p` evaluates to `0`,
230
+ * so an in-flight frame hands the native shadow a number where it expects a
231
+ * boolean. Inset is instead carried alongside as a static per-layer flag (see
232
+ * `SplitBoxShadow.insets`) and reattached when the style is emitted.
233
+ */
234
+ export interface AnimatedBoxShadowLayer {
235
+ offsetX: number
236
+ offsetY: number
237
+ blurRadius: number
238
+ spreadDistance: number
239
+ color: string
240
+ }
241
+
242
+ /**
243
+ * Resolved layers split into the part Reanimated animates and the part it
244
+ * must not touch.
245
+ *
246
+ * `insets` is `null` — not an array of `false` — when no layer is inset, which
247
+ * is the overwhelmingly common case. That lets the emitting worklet skip
248
+ * reassembly entirely and pass the animated array straight through with zero
249
+ * per-frame allocation.
250
+ */
251
+ export interface SplitBoxShadow {
252
+ layers: AnimatedBoxShadowLayer[]
253
+ insets: boolean[] | null
254
+ }
255
+
256
+ /**
257
+ * Accepted target shape on the `animate` surface: a CSS string or RN's own
258
+ * `BoxShadowValue[]`, whose lengths may be numbers or px strings.
259
+ *
260
+ * Structurally this is RN's `boxShadow` style value. It is restated here
261
+ * rather than imported so this module stays dependency-free and testable as a
262
+ * pure function; `types.ts` is where the public-facing alias lives.
263
+ */
264
+ export type BoxShadowInput =
265
+ | string
266
+ | ReadonlyArray<{
267
+ offsetX: number | string
268
+ offsetY: number | string
269
+ // `unknown` because RN types this as `ColorValue | number` — a quirk of
270
+ // its own declarations rather than a real capability. Coerced (and
271
+ // rejected) by `coerceLength` like every other length.
272
+ blurRadius?: unknown
273
+ spreadDistance?: number | string | undefined
274
+ color?: string | undefined
275
+ inset?: boolean | undefined
276
+ }>
277
+
278
+ /**
279
+ * Coerce one RN length field to a number. RN's `BoxShadowValue` types its
280
+ * lengths as `number | string` so `{ offsetX: '4px' }` is legal input; the
281
+ * animated slot needs a plain number to interpolate. Unit handling matches
282
+ * `parseBoxShadow` — px and unitless only, and anything else throws rather
283
+ * than silently animating from a `NaN`.
284
+ */
285
+ function coerceLength(value: unknown, field: string): number {
286
+ if (value === undefined) return 0
287
+ if (typeof value === 'number') return value
288
+ const trimmed = typeof value === 'string' ? value.trim() : ''
289
+ if (!LENGTH.test(trimmed)) {
290
+ throw new Error(
291
+ `[inertia] boxShadow: ${field} must be a number or a px length, got ` +
292
+ `${JSON.stringify(value)}.`,
293
+ )
294
+ }
295
+ return parseFloat(trimmed)
296
+ }
297
+
298
+ /** Drop `inset`, leaving only the fields Reanimated may drive. */
299
+ function strip(layer: ResolvedBoxShadowLayer): AnimatedBoxShadowLayer {
300
+ const { inset: _inset, ...rest } = layer
301
+ return rest
302
+ }
303
+
304
+ /**
305
+ * Record layer `i` as inset, materializing the flag list on first use so the
306
+ * common all-outset case keeps `null` and costs the worklet nothing.
307
+ */
308
+ function markInset(
309
+ insets: boolean[] | null,
310
+ i: number,
311
+ count: number,
312
+ ): boolean[] {
313
+ const list = insets ?? new Array<boolean>(count).fill(false)
314
+ list[i] = true
315
+ return list
316
+ }
317
+
318
+ /**
319
+ * Normalize a target into the shared-value seed shape. Used when the slot is
320
+ * first populated (mount seed, static-style resting value).
321
+ */
322
+ export function normalizeBoxShadow(input: BoxShadowInput): SplitBoxShadow {
323
+ const resolved = resolveBoxShadowInput(input)
324
+ const layers: AnimatedBoxShadowLayer[] = []
325
+ let insets: boolean[] | null = null
326
+ for (let i = 0; i < resolved.length; i++) {
327
+ const layer = resolved[i]!
328
+ layers.push(strip(layer))
329
+ if (layer.inset) insets = markInset(insets, i, resolved.length)
330
+ }
331
+ return { layers, insets }
332
+ }
333
+
334
+ /**
335
+ * Prepare a from/to pair for a Reanimated-driven `boxShadow` animation.
336
+ *
337
+ * Both sides come back padded to the same layer count, because Reanimated's
338
+ * `arrayOnStart` walks the **current** value's indices and reads `toValue[i]`
339
+ * for each — a target with fewer layers leaves the surplus leaves with
340
+ * `toValue: undefined`, and a target with more never animates the extras at
341
+ * all. `pairBoxShadowLayers` supplies the padding (a transparent zero layer,
342
+ * matching CSS transition semantics) and rejects a genuine inset mismatch.
343
+ *
344
+ * The returned `from` is only meaningful when it differs from what the slot
345
+ * already holds; callers snap the slot to it before starting the animation so
346
+ * the interpolation has a same-shaped base to run from.
347
+ */
348
+ export function prepareBoxShadowAnimation(
349
+ current: SplitBoxShadow,
350
+ target: BoxShadowInput,
351
+ ): {
352
+ from: AnimatedBoxShadowLayer[]
353
+ to: AnimatedBoxShadowLayer[]
354
+ insets: boolean[] | null
355
+ } {
356
+ const pairs = pairBoxShadowLayers(
357
+ current.layers.map((layer, i) => ({
358
+ ...layer,
359
+ inset: current.insets?.[i] ?? false,
360
+ })),
361
+ resolveBoxShadowInput(target),
362
+ )
363
+ const from: AnimatedBoxShadowLayer[] = []
364
+ const to: AnimatedBoxShadowLayer[] = []
365
+ let insets: boolean[] | null = null
366
+ for (let i = 0; i < pairs.length; i++) {
367
+ const pair = pairs[i]!
368
+ from.push(strip(pair.from))
369
+ to.push(strip(pair.to))
370
+ // `pairBoxShadowLayers` has already established that the two sides agree
371
+ // on inset, so reading either describes the pair.
372
+ if (pair.to.inset) insets = markInset(insets, i, pairs.length)
373
+ }
374
+ return { from, to, insets }
375
+ }
@@ -1,3 +1,5 @@
1
+ import { __resetWarnOnceForTests, warnOnce } from './warnOnce'
2
+
1
3
  declare const __DEV__: boolean
2
4
 
3
5
  // The core package intentionally has no Node types — declare the minimal
@@ -5,8 +7,6 @@ declare const __DEV__: boolean
5
7
  // environments without `process` are fine).
6
8
  declare const process: { env?: Record<string, string | undefined> } | undefined
7
9
 
8
- const warned = new Set<string>()
9
-
10
10
  /**
11
11
  * Dev-only, once-per-key warning for plain (non-worklet) functions handed to
12
12
  * surfaces that need real worklets — `useTransform` transformers and custom
@@ -30,12 +30,10 @@ const warned = new Set<string>()
30
30
  export function warnNonWorkletOnce(key: string, message: string): void {
31
31
  if (!__DEV__) return
32
32
  if (typeof process !== 'undefined' && process.env?.JEST_WORKER_ID) return
33
- if (warned.has(key)) return
34
- warned.add(key)
35
- console.warn(message)
33
+ warnOnce(key, message)
36
34
  }
37
35
 
38
36
  /** @internal — test-only hook to reset the once-per-key state. */
39
37
  export function __resetNonWorkletWarningsForTests(): void {
40
- warned.clear()
38
+ __resetWarnOnceForTests()
41
39
  }
@@ -0,0 +1,28 @@
1
+ declare const __DEV__: boolean
2
+
3
+ const warned = new Set<string>()
4
+
5
+ /**
6
+ * Dev-only warning that fires at most once per `key` for the lifetime of the
7
+ * module.
8
+ *
9
+ * Inertia's warnings mostly fire from render or from a per-frame-adjacent
10
+ * resolver, so an unguarded `console.warn` would repeat on every render and
11
+ * bury the signal. Keying the guard (rather than using a single boolean)
12
+ * keeps two genuinely different misconfigurations from silencing each other.
13
+ *
14
+ * Unlike `warnNonWorkletOnce`, this is **not** suppressed under Jest — these
15
+ * warnings describe consumer-authored prop combinations, which a test suite
16
+ * should be able to assert on.
17
+ */
18
+ export function warnOnce(key: string, message: string): void {
19
+ if (!__DEV__) return
20
+ if (warned.has(key)) return
21
+ warned.add(key)
22
+ console.warn(message)
23
+ }
24
+
25
+ /** @internal — test-only hook to reset the once-per-key state. */
26
+ export function __resetWarnOnceForTests(): void {
27
+ warned.clear()
28
+ }
@@ -7,10 +7,21 @@ export {
7
7
  releaseLayout,
8
8
  SHARED_LAYOUT_TTL_MS,
9
9
  __setSharedLayoutClock,
10
+ __sharedRegistrySize,
11
+ type CoordinateSpace,
12
+ type SharedLayoutSource,
10
13
  type SharedRect,
14
+ type SharedStyleSnapshot,
11
15
  } from './sharedRegistry'
16
+ export {
17
+ __setSharedLayoutMeasurer,
18
+ measureWindowRect,
19
+ type MeasuredRect,
20
+ type WindowMeasurer,
21
+ } from './measureWindow'
12
22
  export {
13
23
  useSharedLayout,
14
24
  type SharedLayoutBindings,
25
+ type SharedLayoutStyleValues,
15
26
  type SharedLayoutValues,
16
27
  } from './useSharedLayout'
@@ -0,0 +1,135 @@
1
+ import { type CoordinateSpace, type SharedRect } from './sharedRegistry'
2
+
3
+ /**
4
+ * Window-coordinate measurement for shared-element rects.
5
+ *
6
+ * ## Synchronous or not at all
7
+ *
8
+ * `measureInWindow` is callback-shaped but not reliably asynchronous, and the
9
+ * difference decides this module's design:
10
+ *
11
+ * - **Fabric** (the default from RN 0.76, and our 0.81+ baseline) resolves it
12
+ * through a synchronous JSI call — the callback runs before
13
+ * `measureInWindow` returns.
14
+ * - **Paper** goes over the bridge, so the callback lands a tick later.
15
+ * - **A detached node calls back never at all.** Not an edge case and not a
16
+ * platform quirk: `ReactFabricHostComponent.measureInWindow` looks the node
17
+ * up and simply returns when it is missing. The Jest host mock behaves the
18
+ * same way.
19
+ *
20
+ * So this module reads the result **synchronously or treats it as
21
+ * unavailable**, and ignores a callback that arrives late. That is not a
22
+ * limitation to work around — it is what keeps the coordinate space coherent.
23
+ * An implementation that awaited the callback would put Fabric in window space
24
+ * and Paper in parent space *for the same element at different moments*, and
25
+ * since the two spaces aren't comparable, a source stored in one and a target
26
+ * measured in the other silently skips the transition. Better to be
27
+ * consistently parent-relative on a platform that can't measure synchronously —
28
+ * which is exactly how this worked before window coordinates existed.
29
+ *
30
+ * Net effect: nested-parent shared elements are fixed on Fabric, and on Paper
31
+ * the behavior is unchanged rather than intermittently broken.
32
+ *
33
+ * ## Why the result is validated rather than trusted
34
+ *
35
+ * A node that is off-screen or laid out to nothing reports zeros (or `NaN` on
36
+ * some platforms) instead of failing. Feeding that into a FLIP would fling the
37
+ * element in from the top-left corner — far worse than not animating. Anything
38
+ * unusable is reported as `undefined` so the caller falls back to the
39
+ * parent-relative rect it already has.
40
+ */
41
+
42
+ /** Raw measurement, before it is tagged with a coordinate space. */
43
+ export interface MeasuredRect {
44
+ x: number
45
+ y: number
46
+ width: number
47
+ height: number
48
+ }
49
+
50
+ /**
51
+ * Measures `node` in window coordinates, or returns `undefined` when it can't
52
+ * be measured synchronously. Swappable for tests via
53
+ * `__setSharedLayoutMeasurer`.
54
+ */
55
+ export type WindowMeasurer = (node: unknown) => MeasuredRect | undefined
56
+
57
+ type MeasureInWindowNode = {
58
+ measureInWindow?: (
59
+ callback: (x: number, y: number, width: number, height: number) => void,
60
+ ) => void
61
+ }
62
+
63
+ const defaultMeasurer: WindowMeasurer = (node) => {
64
+ const measure = (node as MeasureInWindowNode | null)?.measureInWindow
65
+ if (typeof measure !== 'function') return undefined
66
+
67
+ let result: MeasuredRect | undefined
68
+ let returned = false
69
+ try {
70
+ measure.call(node, (x, y, width, height) => {
71
+ // A callback that arrives after we've returned is Paper's asynchronous
72
+ // bridge answering too late to use. Dropping it is deliberate: accepting
73
+ // it would upgrade this element to window space while its counterpart
74
+ // stays parent-relative, and mixed spaces cancel the transition.
75
+ if (returned) return
76
+ result = { x, y, width, height }
77
+ })
78
+ } catch {
79
+ // Some hosts throw rather than no-op on a detached node.
80
+ returned = true
81
+ return undefined
82
+ }
83
+ returned = true
84
+ return result
85
+ }
86
+
87
+ let measurer: WindowMeasurer = defaultMeasurer
88
+
89
+ function usable(rect: MeasuredRect): boolean {
90
+ const { x, y, width, height } = rect
91
+ for (const v of [x, y, width, height]) {
92
+ if (typeof v !== 'number' || !Number.isFinite(v)) return false
93
+ }
94
+ // Zero-sized means "not really measurable" in practice — detached, or laid
95
+ // out to nothing. Either way there is no FLIP to compute from it.
96
+ return width !== 0 || height !== 0
97
+ }
98
+
99
+ /**
100
+ * Measure `node` in window coordinates and tag the result, or return
101
+ * `undefined` when no usable synchronous measurement is available.
102
+ *
103
+ * Validation lives here rather than in the default measurer so a swapped-in
104
+ * measurer is held to the same contract — an unusable rect can never reach a
105
+ * FLIP, however it was produced.
106
+ */
107
+ export function measureWindowRect(node: unknown): SharedRect | undefined {
108
+ const measured = measurer(node)
109
+ if (!measured || !usable(measured)) return undefined
110
+ return toSharedRect(measured, 'window')
111
+ }
112
+
113
+ /** Tag a raw measurement with its coordinate space. */
114
+ export function toSharedRect(
115
+ rect: MeasuredRect,
116
+ space: CoordinateSpace,
117
+ ): SharedRect {
118
+ return { ...rect, space }
119
+ }
120
+
121
+ /**
122
+ * Test hook: swap the measurement implementation. Pass `undefined` to restore
123
+ * the real `measureInWindow` path. Not exported from the package root —
124
+ * reachable only from inside the workspace.
125
+ *
126
+ * Needed because the Jest host mock's `measureInWindow` never invokes its
127
+ * callback, so without an override every test would exercise only the
128
+ * parent-relative fallback — which is also, deliberately, what a consumer's
129
+ * test suite sees.
130
+ */
131
+ export function __setSharedLayoutMeasurer(
132
+ fn: WindowMeasurer | undefined,
133
+ ): void {
134
+ measurer = fn ?? defaultMeasurer
135
+ }
@@ -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`.
33
+ */
34
+
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.
23
47
  */
48
+ export type SharedStyleSnapshot = Record<string, number | string>
24
49
 
25
- /** Parent-relative rect of a measured element (from `onLayout`). */
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>()
@@ -51,43 +113,106 @@ export const SHARED_LAYOUT_TTL_MS = 1000
51
113
  */
52
114
  let now = (): number => Date.now()
53
115
 
116
+ /**
117
+ * When the last expiry sweep ran, so the scan is amortized to at most once per
118
+ * TTL window rather than running on every write.
119
+ */
120
+ let lastSweep = 0
121
+
122
+ /**
123
+ * Drop every entry whose TTL has passed.
124
+ *
125
+ * Without this the map only ever shrank via `consumeLayout` / `peekSharedLayout`,
126
+ * so a `layoutId` that unmounted and was never remounted left its rect behind
127
+ * for the lifetime of the process. Bounded by distinct-id count, which is small
128
+ * for a handful of hero images but not for per-item ids in a long-lived list
129
+ * (`layoutId={`photo-${item.id}`}`).
130
+ *
131
+ * Sweeping a *live* element's entry is harmless: while mounted it re-registers
132
+ * on every `onLayout`, and on unmount `releaseLayout` re-adds it with a fresh
133
+ * TTL. So the only thing an over-eager sweep can cost is a FLIP source that was
134
+ * already too old to be used.
135
+ *
136
+ * Time-gated so a burst of layout events doesn't turn into a burst of full
137
+ * scans — worst case an expired entry survives one extra TTL window.
138
+ */
139
+ function sweepExpired(at: number): void {
140
+ if (at - lastSweep < SHARED_LAYOUT_TTL_MS) return
141
+ lastSweep = at
142
+ for (const [id, entry] of REGISTRY) {
143
+ if (entry.expiresAt < at) REGISTRY.delete(id)
144
+ }
145
+ }
146
+
54
147
  /**
55
148
  * Update the latest known rect for `id`. Called on every `onLayout` of a
56
149
  * Motion primitive with `layoutId` set so the registry always holds a
57
150
  * current measurement if that primitive becomes the source of a future
58
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.
59
156
  */
60
- export function registerLayout(id: string, rect: SharedRect): void {
61
- REGISTRY.set(id, { rect, expiresAt: now() + SHARED_LAYOUT_TTL_MS })
157
+ export function registerLayout(
158
+ id: string,
159
+ rect: SharedRect,
160
+ remeasure?: () => SharedRect | undefined,
161
+ readStyles?: () => SharedStyleSnapshot | undefined,
162
+ ): void {
163
+ const at = now()
164
+ sweepExpired(at)
165
+ REGISTRY.set(id, {
166
+ rect,
167
+ expiresAt: at + SHARED_LAYOUT_TTL_MS,
168
+ remeasure,
169
+ readStyles,
170
+ })
62
171
  }
63
172
 
64
173
  /**
65
174
  * Record the rect for `id` on unmount so the next mount can consume it as
66
- * the FLIP source. Functionally identical to `registerLayout` — the split
67
- * 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.
68
181
  */
69
- export function releaseLayout(id: string, rect: SharedRect): void {
70
- REGISTRY.set(id, { rect, expiresAt: now() + SHARED_LAYOUT_TTL_MS })
182
+ export function releaseLayout(
183
+ id: string,
184
+ rect: SharedRect,
185
+ styles?: SharedStyleSnapshot,
186
+ ): void {
187
+ const at = now()
188
+ sweepExpired(at)
189
+ REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS, styles })
71
190
  }
72
191
 
73
192
  /**
74
- * 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
75
194
  * entry is removed in either case — at most one incoming mount consumes
76
195
  * a given release, and an expired entry is dropped so it can't poison a
77
196
  * later transition. Returns `undefined` when no fresh source is available,
78
197
  * in which case the caller should mount without a layout animation.
79
198
  */
80
- export function consumeLayout(id: string): SharedRect | undefined {
199
+ export function consumeLayout(id: string): SharedLayoutSource | undefined {
81
200
  const entry = REGISTRY.get(id)
82
201
  if (!entry) return undefined
83
202
  REGISTRY.delete(id)
84
203
  if (entry.expiresAt < now()) return undefined
85
- return entry.rect
204
+ return {
205
+ rect: entry.rect,
206
+ remeasure: entry.remeasure,
207
+ styles: entry.styles,
208
+ readStyles: entry.readStyles,
209
+ }
86
210
  }
87
211
 
88
212
  /** Drop all entries. Tests use this to isolate between cases. */
89
213
  export function clearSharedRegistry(): void {
90
214
  REGISTRY.clear()
215
+ lastSweep = 0
91
216
  }
92
217
 
93
218
  /**
@@ -108,4 +233,17 @@ export function peekSharedLayout(id: string): SharedRect | undefined {
108
233
  */
109
234
  export function __setSharedLayoutClock(fn: (() => number) | undefined): void {
110
235
  now = fn ?? Date.now
236
+ // Swapping the clock makes the previous sweep timestamp meaningless — a
237
+ // stubbed clock usually starts near 0, which would otherwise suppress every
238
+ // sweep until it caught up past the real-time value recorded earlier.
239
+ lastSweep = 0
240
+ }
241
+
242
+ /**
243
+ * Test hook: how many entries the registry is holding, expired or not. Used to
244
+ * assert that released-but-never-consumed rects are actually evicted. Not
245
+ * exported from the package root — reachable only from inside the workspace.
246
+ */
247
+ export function __sharedRegistrySize(): number {
248
+ return REGISTRY.size
111
249
  }