@onlynative/inertia 0.0.1-alpha.7 → 0.0.1-alpha.9

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/gestureLayer/index.d.mts +119 -0
  3. package/dist/gestureLayer/index.d.ts +119 -0
  4. package/dist/gestureLayer/index.js +346 -0
  5. package/dist/gestureLayer/index.js.map +1 -0
  6. package/dist/gestureLayer/index.mjs +344 -0
  7. package/dist/gestureLayer/index.mjs.map +1 -0
  8. package/dist/index.d.mts +114 -74
  9. package/dist/index.d.ts +114 -74
  10. package/dist/index.js +388 -1542
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +388 -1545
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/motion/Image.d.mts +1 -1
  15. package/dist/motion/Image.d.ts +1 -1
  16. package/dist/motion/Image.js +244 -1462
  17. package/dist/motion/Image.js.map +1 -1
  18. package/dist/motion/Image.mjs +247 -1465
  19. package/dist/motion/Image.mjs.map +1 -1
  20. package/dist/motion/Pressable.d.mts +1 -1
  21. package/dist/motion/Pressable.d.ts +1 -1
  22. package/dist/motion/Pressable.js +244 -1462
  23. package/dist/motion/Pressable.js.map +1 -1
  24. package/dist/motion/Pressable.mjs +247 -1465
  25. package/dist/motion/Pressable.mjs.map +1 -1
  26. package/dist/motion/ScrollView.d.mts +1 -1
  27. package/dist/motion/ScrollView.d.ts +1 -1
  28. package/dist/motion/ScrollView.js +244 -1462
  29. package/dist/motion/ScrollView.js.map +1 -1
  30. package/dist/motion/ScrollView.mjs +247 -1465
  31. package/dist/motion/ScrollView.mjs.map +1 -1
  32. package/dist/motion/Text.d.mts +1 -1
  33. package/dist/motion/Text.d.ts +1 -1
  34. package/dist/motion/Text.js +244 -1462
  35. package/dist/motion/Text.js.map +1 -1
  36. package/dist/motion/Text.mjs +247 -1465
  37. package/dist/motion/Text.mjs.map +1 -1
  38. package/dist/motion/View.d.mts +1 -1
  39. package/dist/motion/View.d.ts +1 -1
  40. package/dist/motion/View.js +244 -1462
  41. package/dist/motion/View.js.map +1 -1
  42. package/dist/motion/View.mjs +247 -1465
  43. package/dist/motion/View.mjs.map +1 -1
  44. package/dist/touch/index.d.mts +146 -0
  45. package/dist/touch/index.d.ts +146 -0
  46. package/dist/touch/index.js +166 -0
  47. package/dist/touch/index.js.map +1 -0
  48. package/dist/touch/index.mjs +164 -0
  49. package/dist/touch/index.mjs.map +1 -0
  50. package/dist/{types-NmNeJjo1.d.mts → types-cU43dEmH.d.mts} +64 -17
  51. package/dist/{types-NmNeJjo1.d.ts → types-cU43dEmH.d.ts} +64 -17
  52. package/dist/useGesture-B7A_1DVg.d.ts +84 -0
  53. package/dist/useGesture-cimMrzC1.d.mts +84 -0
  54. package/jest-setup.js +4 -0
  55. package/llms.txt +12 -3
  56. package/package.json +22 -2
  57. package/src/__type-tests__/variants.test-d.tsx +67 -0
  58. package/src/gestureLayer/index.ts +21 -0
  59. package/src/gestureLayer/useGestureLayer.ts +285 -0
  60. package/src/index.ts +7 -0
  61. package/src/layout/index.ts +15 -0
  62. package/src/layout/sharedRegistry.ts +111 -0
  63. package/src/layout/useSharedLayout.ts +289 -0
  64. package/src/motion/createMotionComponent.tsx +123 -37
  65. package/src/motion/installCheck.ts +7 -11
  66. package/src/touch/index.ts +18 -0
  67. package/src/touch/useTouchDrag.ts +289 -0
  68. package/src/types.ts +79 -20
  69. package/src/values/index.ts +11 -0
  70. package/src/values/useBooleanSpring.ts +33 -0
  71. package/src/values/useColorTransition.ts +72 -0
  72. package/src/values/useShadow.ts +116 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Compile-time gate for variant-key narrowing on `animate`.
3
+ *
4
+ * CLAUDE.md Principle 5 / line 43: "Variant string keys must autocomplete on
5
+ * `animate`." That requires the `variants` map to be inferred at the JSX call
6
+ * site and `animate`'s string form to be narrowed to its keys — so a key typo
7
+ * is a type error, not a silent runtime no-op. These assertions run under
8
+ * `tsc --noEmit` (the typecheck CI step); if a `@ts-expect-error` here stops
9
+ * being an error, tsc fails with "Unused '@ts-expect-error' directive" and the
10
+ * differentiator has regressed.
11
+ *
12
+ * Unlike `animate.test-d.tsx`, these MUST mount JSX: the narrowing depends on
13
+ * `V` being inferred from the `variants` prop at the call site, which only
14
+ * happens through the generic component call signature.
15
+ */
16
+
17
+ import { Motion } from '../motion'
18
+
19
+ const variants = {
20
+ open: { opacity: 1, translateY: 0 },
21
+ closed: { opacity: 0, translateY: 100 },
22
+ } as const
23
+
24
+ // ─── Variant key narrowing ──────────────────────────────────────────────────
25
+
26
+ // A declared key is accepted (and `open` / `closed` autocomplete here).
27
+ const _acceptsKnownKey = <Motion.View variants={variants} animate="open" />
28
+ const _acceptsOtherKey = <Motion.View variants={variants} animate="closed" />
29
+
30
+ // A typo'd key is a compile error rather than a silent no-op.
31
+ // @ts-expect-error 'opne' is not a key of `variants`
32
+ const _rejectsTypoKey = <Motion.View variants={variants} animate="opne" />
33
+
34
+ // The style-object form still works alongside `variants` (escape hatch for a
35
+ // one-off target that isn't a named state).
36
+ const _acceptsStyleObject = (
37
+ <Motion.View variants={variants} animate={{ opacity: 0.5 }} />
38
+ )
39
+
40
+ // `as const` is NOT required — an inline object literal narrows just as well.
41
+ const inlineVariants = { a: { opacity: 1 }, b: { opacity: 0 } }
42
+ const _inlineVariantsNarrow = (
43
+ <Motion.View variants={inlineVariants} animate="a" />
44
+ )
45
+ const _inlineVariantsReject = (
46
+ // @ts-expect-error 'c' is not a key of the inline variants map
47
+ <Motion.View variants={inlineVariants} animate="c" />
48
+ )
49
+
50
+ // ─── No variants → string stays open (back-compat) ──────────────────────────
51
+
52
+ // Without `variants`, the string form is unconstrained, so this must NOT error
53
+ // (the variant-less call site is unchanged by the narrowing machinery).
54
+ const _noVariantsAnyString = <Motion.View animate="whatever" />
55
+ const _noVariantsStyleObject = <Motion.View animate={{ translateX: 10 }} />
56
+
57
+ // Silence "declared but never read" — these exist purely as type assertions.
58
+ export type _VariantTypeAssertions = [
59
+ typeof _acceptsKnownKey,
60
+ typeof _acceptsOtherKey,
61
+ typeof _rejectsTypoKey,
62
+ typeof _acceptsStyleObject,
63
+ typeof _inlineVariantsNarrow,
64
+ typeof _inlineVariantsReject,
65
+ typeof _noVariantsAnyString,
66
+ typeof _noVariantsStyleObject,
67
+ ]
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Opt-in subpath: `@onlynative/inertia/gesture-layer`.
3
+ *
4
+ * `useGestureLayer` is a generic interactive-feedback primitive that sits one
5
+ * step above `useGesture()` — consumer supplies per-state target values; the
6
+ * hook handles "strongest active layer wins" composition (clamped-max for
7
+ * numerics, priority cascade for colors), the worklet, and the transition.
8
+ *
9
+ * Reach for it when MD3 state-layer haloes, iOS-translucent overlays, or
10
+ * any "highest engaged state controls the overlay" pattern shows up. For
11
+ * composition models this hook doesn't express (additive, multiply, custom
12
+ * per-key blends), drop to `useGesture()` and write a `useAnimatedStyle`
13
+ * block by hand.
14
+ */
15
+ export { useGestureLayer } from './useGestureLayer'
16
+ export type {
17
+ GestureLayerStates,
18
+ GestureLayerStyle,
19
+ UseGestureLayerOptions,
20
+ UseGestureLayerResult,
21
+ } from './useGestureLayer'
@@ -0,0 +1,285 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import {
3
+ interpolateColor,
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ type AnimatedStyle,
7
+ } from 'react-native-reanimated'
8
+ import { useShouldReduceMotion } from '../config'
9
+ import { isTopLevelTransition, resolveTransition } from '../transitions'
10
+ import { useGesture, type UseGestureHandlers } from '../values/useGesture'
11
+ import { type GestureLayerTransitions, type TransitionConfig } from '../types'
12
+
13
+ /**
14
+ * A single gesture-layer style — a flat map of style keys to a value. Numeric
15
+ * values participate in clamped-max composition (the "strongest active layer
16
+ * wins" model used by MD3 state-layer haloes); string values are treated as
17
+ * colors and composed via priority cascade with `interpolateColor`.
18
+ *
19
+ * The hook does not validate that string values are valid colors — passing
20
+ * something like `borderStyle: 'solid'` will crash inside the worklet. Keep
21
+ * string values to color strings.
22
+ */
23
+ export type GestureLayerStyle = {
24
+ [key: string]: number | string | undefined
25
+ }
26
+
27
+ /**
28
+ * Per-state style maps. Every key is optional; missing layers default to
29
+ * `rest` (or `0` / `'transparent'` if `rest` is also absent for that key).
30
+ *
31
+ * - `rest` — base values, applied when no other layer is active.
32
+ * - `hovered` / `focused` / `focusVisible` / `pressed` — gesture-driven
33
+ * states tracked via the underlying `useGesture` hook. Each owns an
34
+ * independent 0↔1 progress that fades the layer in/out per the configured
35
+ * transition.
36
+ * - `disabled` — gated by the JS-side `options.disabled` flag rather than a
37
+ * gesture. Sits at the top of the priority cascade and overrides every
38
+ * gesture layer when active.
39
+ */
40
+ export interface GestureLayerStates {
41
+ rest?: GestureLayerStyle
42
+ hovered?: GestureLayerStyle
43
+ focused?: GestureLayerStyle
44
+ focusVisible?: GestureLayerStyle
45
+ pressed?: GestureLayerStyle
46
+ disabled?: GestureLayerStyle
47
+ }
48
+
49
+ export interface UseGestureLayerOptions {
50
+ /**
51
+ * When `true`, the `disabled` layer becomes active (or `rest` if `disabled`
52
+ * is undefined). Animates via the top-level transition or the library
53
+ * default spring; per-layer transitions (`GestureLayerTransitions`) do not
54
+ * apply to `disabled`.
55
+ */
56
+ disabled?: boolean
57
+ /**
58
+ * Transition forwarded to the underlying `useGesture` hook. Either a single
59
+ * `TransitionConfig` for every gesture layer, or a `GestureLayerTransitions`
60
+ * map for per-layer fades. Reduced motion collapses every transition to
61
+ * `no-animation`.
62
+ */
63
+ transition?: TransitionConfig | GestureLayerTransitions
64
+ }
65
+
66
+ export interface UseGestureLayerResult {
67
+ /**
68
+ * Animated style produced by `useAnimatedStyle` — spread on an
69
+ * `Animated.View` or pass through `<Motion.View style={...} />`.
70
+ */
71
+ style: AnimatedStyle<Record<string, unknown>>
72
+ /** Handlers to spread on the receiving `Pressable`. */
73
+ handlers: UseGestureHandlers
74
+ }
75
+
76
+ /**
77
+ * A "strongest active layer wins" interactive-feedback primitive. Sits one
78
+ * step above `useGesture()` — the consumer supplies the per-state target
79
+ * values, the hook handles the four gesture progress shared values, the
80
+ * disabled override, the worklet, and the transition.
81
+ *
82
+ * Composition model:
83
+ *
84
+ * - **Numeric keys** (opacity, scale, borderWidth, etc.) compose via
85
+ * clamped-max with `rest` as the floor:
86
+ * `out = max(rest, ...for each active gesture layer: lerp(rest, layer, progress))`.
87
+ * This matches the MD3 state-layer halo pattern — multiple states active
88
+ * simultaneously raise the value to the strongest, not the sum.
89
+ * - **Color keys** (any string value) compose via priority cascade with
90
+ * `interpolateColor`, lowest priority first: `hovered → focused →
91
+ * focusVisible → pressed`. Clamped-max doesn't apply to colors; this
92
+ * matches the cascade used by the declarative `gesture` prop.
93
+ * - **Disabled** sits at the top of the cascade for both numeric and color
94
+ * keys — when active, it lerps the composed value toward the `disabled`
95
+ * target.
96
+ *
97
+ * Reach for this when you want MD3 / iOS-translucent state-layer overlays
98
+ * without rewriting the worklet by hand for every consumer; reach for plain
99
+ * `useGesture()` when you need a composition model this hook doesn't
100
+ * express (additive, multiply, per-key custom blends).
101
+ *
102
+ * @example MD3 state-layer halo
103
+ * ```tsx
104
+ * import { useGestureLayer } from '@onlynative/inertia/gesture-layer'
105
+ * import Animated from 'react-native-reanimated'
106
+ * import { Pressable } from 'react-native'
107
+ *
108
+ * function SwitchHalo({ disabled }: { disabled?: boolean }) {
109
+ * const { style, handlers } = useGestureLayer(
110
+ * {
111
+ * rest: { opacity: 0, backgroundColor: 'transparent' },
112
+ * hovered: { opacity: 0.08, backgroundColor: '#000' },
113
+ * focused: { opacity: 0.10, backgroundColor: '#000' },
114
+ * pressed: { opacity: 0.12, backgroundColor: '#000' },
115
+ * },
116
+ * { disabled, transition: { type: 'timing', duration: 150 } },
117
+ * )
118
+ *
119
+ * return (
120
+ * <Pressable {...handlers}>
121
+ * <Animated.View style={style} />
122
+ * </Pressable>
123
+ * )
124
+ * }
125
+ * ```
126
+ */
127
+ export function useGestureLayer(
128
+ states: GestureLayerStates,
129
+ options: UseGestureLayerOptions = {},
130
+ ): UseGestureLayerResult {
131
+ const { disabled: isDisabled = false, transition } = options
132
+ const shouldReduceMotion = useShouldReduceMotion()
133
+ const gesture = useGesture(transition)
134
+ const disabledProgress = useSharedValue(0)
135
+
136
+ useEffect(() => {
137
+ const target = isDisabled ? 1 : 0
138
+ const cfg = shouldReduceMotion
139
+ ? ({ type: 'no-animation' } as const)
140
+ : (disabledTransition(transition) ?? ({ type: 'spring' } as const))
141
+ disabledProgress.value = resolveTransition(cfg, target) as never
142
+ }, [isDisabled, shouldReduceMotion, transition, disabledProgress])
143
+
144
+ // JS-thread precompute: union of keys across all layers, per-key type
145
+ // (number vs color), and a rest-fallback table. The worklet body reads
146
+ // from `meta` instead of probing each layer per frame — the type check
147
+ // only runs when layer identities change.
148
+ const meta = useMemo(() => {
149
+ const layers = {
150
+ rest: states.rest,
151
+ hovered: states.hovered,
152
+ focused: states.focused,
153
+ focusVisible: states.focusVisible,
154
+ pressed: states.pressed,
155
+ disabled: states.disabled,
156
+ }
157
+ const sources = [
158
+ layers.rest,
159
+ layers.hovered,
160
+ layers.focused,
161
+ layers.focusVisible,
162
+ layers.pressed,
163
+ layers.disabled,
164
+ ]
165
+ const keySet = new Set<string>()
166
+ for (const src of sources) {
167
+ if (!src) continue
168
+ for (const k in src) if (src[k] !== undefined) keySet.add(k)
169
+ }
170
+ const keys = Array.from(keySet)
171
+ const types: Record<string, 'number' | 'color'> = {}
172
+ const restValues: Record<string, number | string> = {}
173
+ for (const k of keys) {
174
+ let firstDefined: number | string | undefined
175
+ for (const src of sources) {
176
+ if (src && src[k] !== undefined) {
177
+ firstDefined = src[k]
178
+ break
179
+ }
180
+ }
181
+ const isColor = typeof firstDefined === 'string'
182
+ types[k] = isColor ? 'color' : 'number'
183
+ const restRaw = layers.rest ? layers.rest[k] : undefined
184
+ restValues[k] =
185
+ restRaw !== undefined ? restRaw : isColor ? 'transparent' : 0
186
+ }
187
+ return { layers, keys, types, restValues }
188
+ }, [
189
+ states.rest,
190
+ states.hovered,
191
+ states.focused,
192
+ states.focusVisible,
193
+ states.pressed,
194
+ states.disabled,
195
+ ])
196
+
197
+ const style = useAnimatedStyle(() => {
198
+ const { layers, keys, types, restValues } = meta
199
+ const ph = gesture.hovered.value
200
+ const pf = gesture.focused.value
201
+ const pfv = gesture.focusVisible.value
202
+ const pp = gesture.pressed.value
203
+ const pd = disabledProgress.value
204
+
205
+ const hoveredLayer = layers.hovered
206
+ const focusedLayer = layers.focused
207
+ const focusVisibleLayer = layers.focusVisible
208
+ const pressedLayer = layers.pressed
209
+ const disabledLayer = layers.disabled
210
+
211
+ const out: Record<string, unknown> = {}
212
+
213
+ for (let i = 0; i < keys.length; i++) {
214
+ const k = keys[i]!
215
+ const isColor = types[k] === 'color'
216
+ const rest = restValues[k]!
217
+
218
+ if (isColor) {
219
+ let v = rest as string
220
+ if (hoveredLayer && ph > 0 && hoveredLayer[k] !== undefined) {
221
+ v = interpolateColor(ph, [0, 1], [v, hoveredLayer[k] as string])
222
+ }
223
+ if (focusedLayer && pf > 0 && focusedLayer[k] !== undefined) {
224
+ v = interpolateColor(pf, [0, 1], [v, focusedLayer[k] as string])
225
+ }
226
+ if (
227
+ focusVisibleLayer &&
228
+ pfv > 0 &&
229
+ focusVisibleLayer[k] !== undefined
230
+ ) {
231
+ v = interpolateColor(pfv, [0, 1], [v, focusVisibleLayer[k] as string])
232
+ }
233
+ if (pressedLayer && pp > 0 && pressedLayer[k] !== undefined) {
234
+ v = interpolateColor(pp, [0, 1], [v, pressedLayer[k] as string])
235
+ }
236
+ if (disabledLayer && pd > 0 && disabledLayer[k] !== undefined) {
237
+ v = interpolateColor(pd, [0, 1], [v, disabledLayer[k] as string])
238
+ }
239
+ out[k] = v
240
+ } else {
241
+ const base = rest as number
242
+ let m = base
243
+ if (hoveredLayer && ph > 0 && hoveredLayer[k] !== undefined) {
244
+ const c = base + ((hoveredLayer[k] as number) - base) * ph
245
+ if (c > m) m = c
246
+ }
247
+ if (focusedLayer && pf > 0 && focusedLayer[k] !== undefined) {
248
+ const c = base + ((focusedLayer[k] as number) - base) * pf
249
+ if (c > m) m = c
250
+ }
251
+ if (
252
+ focusVisibleLayer &&
253
+ pfv > 0 &&
254
+ focusVisibleLayer[k] !== undefined
255
+ ) {
256
+ const c = base + ((focusVisibleLayer[k] as number) - base) * pfv
257
+ if (c > m) m = c
258
+ }
259
+ if (pressedLayer && pp > 0 && pressedLayer[k] !== undefined) {
260
+ const c = base + ((pressedLayer[k] as number) - base) * pp
261
+ if (c > m) m = c
262
+ }
263
+ if (disabledLayer && pd > 0 && disabledLayer[k] !== undefined) {
264
+ m = m + ((disabledLayer[k] as number) - m) * pd
265
+ }
266
+ out[k] = m
267
+ }
268
+ }
269
+
270
+ return out
271
+ })
272
+
273
+ return {
274
+ style: style as AnimatedStyle<Record<string, unknown>>,
275
+ handlers: gesture.handlers,
276
+ }
277
+ }
278
+
279
+ function disabledTransition(
280
+ transition: TransitionConfig | GestureLayerTransitions | undefined,
281
+ ): TransitionConfig | undefined {
282
+ if (!transition) return undefined
283
+ if (isTopLevelTransition(transition)) return transition
284
+ return undefined
285
+ }
package/src/index.ts CHANGED
@@ -30,18 +30,25 @@ export {
30
30
  } from './transitions'
31
31
  export {
32
32
  useAnimation,
33
+ useBooleanSpring,
34
+ useColorTransition,
33
35
  useGesture,
34
36
  useMotionValue,
35
37
  useScroll,
38
+ useShadow,
36
39
  useSpring,
37
40
  useTransform,
38
41
  useVariants,
39
42
  } from './values'
40
43
  export type {
44
+ ColorStyleKey,
41
45
  ExtrapolationMode,
46
+ ShadowConfig,
47
+ UseColorTransitionOptions,
42
48
  UseGestureHandlers,
43
49
  UseGestureResult,
44
50
  UseScrollResult,
51
+ UseShadowOptions,
45
52
  UseTransformOptions,
46
53
  } from './values'
47
54
  export type {
@@ -1 +1,16 @@
1
1
  export { resolveLayoutTransition, type LayoutProp } from './resolveLayout'
2
+ export {
3
+ clearSharedRegistry,
4
+ consumeLayout,
5
+ peekSharedLayout,
6
+ registerLayout,
7
+ releaseLayout,
8
+ SHARED_LAYOUT_TTL_MS,
9
+ __setSharedLayoutClock,
10
+ type SharedRect,
11
+ } from './sharedRegistry'
12
+ export {
13
+ useSharedLayout,
14
+ type SharedLayoutBindings,
15
+ type SharedLayoutValues,
16
+ } from './useSharedLayout'
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Module-level registry of last-known on-screen rects for shared-layout
3
+ * elements, indexed by `layoutId`. Backs `<Motion.* layoutId="..." />` —
4
+ * Reanimated 4 dropped the `sharedTransitionTag` API the previous design
5
+ * relied on, so the cross-screen shared-element transition lives in
6
+ * userland now.
7
+ *
8
+ * Lifecycle, per id:
9
+ * 1. While a Motion primitive with `layoutId={id}` is mounted, every
10
+ * `onLayout` updates the rect via `registerLayout`.
11
+ * 2. When that primitive unmounts, the same rect is left behind under a
12
+ * TTL via `releaseLayout` so a subsequent mount can consume it.
13
+ * 3. The next mount calls `consumeLayout(id)` — if a fresh entry exists
14
+ * it becomes the FLIP source rect; the entry is removed so a third
15
+ * mount with the same id doesn't re-animate from a stale snapshot.
16
+ *
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.
23
+ */
24
+
25
+ /** Parent-relative rect of a measured element (from `onLayout`). */
26
+ export interface SharedRect {
27
+ x: number
28
+ y: number
29
+ width: number
30
+ height: number
31
+ }
32
+
33
+ interface Entry {
34
+ rect: SharedRect
35
+ expiresAt: number
36
+ }
37
+
38
+ const REGISTRY = new Map<string, Entry>()
39
+
40
+ /**
41
+ * How long (ms) a released rect remains consumable. Sized to comfortably
42
+ * cover a typical screen transition (slide animation, gesture-driven
43
+ * dismiss) without leaving stale entries lying around if no incoming
44
+ * mount picks them up.
45
+ */
46
+ export const SHARED_LAYOUT_TTL_MS = 1000
47
+
48
+ /**
49
+ * Provide the current monotonic-ish timestamp. Indirected so tests can
50
+ * stub it via `__setSharedLayoutClock` without touching `Date.now` globally.
51
+ */
52
+ let now = (): number => Date.now()
53
+
54
+ /**
55
+ * Update the latest known rect for `id`. Called on every `onLayout` of a
56
+ * Motion primitive with `layoutId` set so the registry always holds a
57
+ * current measurement if that primitive becomes the source of a future
58
+ * transition. Resets the TTL each call.
59
+ */
60
+ export function registerLayout(id: string, rect: SharedRect): void {
61
+ REGISTRY.set(id, { rect, expiresAt: now() + SHARED_LAYOUT_TTL_MS })
62
+ }
63
+
64
+ /**
65
+ * 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.
68
+ */
69
+ export function releaseLayout(id: string, rect: SharedRect): void {
70
+ REGISTRY.set(id, { rect, expiresAt: now() + SHARED_LAYOUT_TTL_MS })
71
+ }
72
+
73
+ /**
74
+ * Take the recorded rect for `id` if it exists and hasn't expired. The
75
+ * entry is removed in either case — at most one incoming mount consumes
76
+ * a given release, and an expired entry is dropped so it can't poison a
77
+ * later transition. Returns `undefined` when no fresh source is available,
78
+ * in which case the caller should mount without a layout animation.
79
+ */
80
+ export function consumeLayout(id: string): SharedRect | undefined {
81
+ const entry = REGISTRY.get(id)
82
+ if (!entry) return undefined
83
+ REGISTRY.delete(id)
84
+ if (entry.expiresAt < now()) return undefined
85
+ return entry.rect
86
+ }
87
+
88
+ /** Drop all entries. Tests use this to isolate between cases. */
89
+ export function clearSharedRegistry(): void {
90
+ REGISTRY.clear()
91
+ }
92
+
93
+ /**
94
+ * Inspect a registry entry without consuming it. Intended for tests and
95
+ * dev tooling; production code should go through `consumeLayout`.
96
+ */
97
+ export function peekSharedLayout(id: string): SharedRect | undefined {
98
+ const entry = REGISTRY.get(id)
99
+ if (!entry) return undefined
100
+ if (entry.expiresAt < now()) return undefined
101
+ return entry.rect
102
+ }
103
+
104
+ /**
105
+ * Test hook: swap the clock used for TTL calculations. Pass `undefined` to
106
+ * restore `Date.now`. Not exported from the package root — reachable only
107
+ * from inside the workspace.
108
+ */
109
+ export function __setSharedLayoutClock(fn: (() => number) | undefined): void {
110
+ now = fn ?? Date.now
111
+ }