@unrulysystems/native-motion 0.1.0-alpha.0

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 (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,1605 @@
1
+ // AnimatePresence, the spike (specs/M2.3-BUILD-PACKET.md): the thin React layer over
2
+ // presenceBinding — keyed-children diffing, exit re-rendering, the exits-in-flight stepper, and the
3
+ // usePresence context. All SEMANTICS live in the binding/controller (deterministically checked on
4
+ // PresenceChecksScreen); this file owns only React plumbing, mirroring the MotionView split.
5
+ //
6
+ // Mechanics: each commit reads the keyed children's motion props into PresenceChild records and
7
+ // syncs them; the render set is the controller's mountedKeys — a dropped child keeps rendering (its
8
+ // last-seen element cloned with `animate = resolvedExitTarget`, so the visuals retarget through the
9
+ // sealed MotionView→worklet-driver path) until its exit settles, at which point the stepper notices
10
+ // the mounted set shrank and bumps a version to unmount it. The stepper drives the binding's graph
11
+ // (core ManualClock/ManualScheduler advanced by real frame deltas) ONLY while exits are in flight —
12
+ // idle presence costs zero frames (the M2.0 CPU lesson).
13
+
14
+ import {
15
+ Children,
16
+ cloneElement,
17
+ createContext,
18
+ Fragment,
19
+ isValidElement,
20
+ useCallback,
21
+ useContext,
22
+ useEffect,
23
+ useId,
24
+ useLayoutEffect,
25
+ useRef,
26
+ useState,
27
+ type ReactElement,
28
+ type ReactNode,
29
+ type Ref,
30
+ type RefCallback,
31
+ } from 'react'
32
+ import {
33
+ captureTransition,
34
+ createMotionGraph,
35
+ flattenVariantApplications,
36
+ isColor,
37
+ type LengthLayoutContext,
38
+ ManualClock,
39
+ ManualScheduler,
40
+ UNIVERSAL_SUBSET,
41
+ type PresenceMode,
42
+ type PresenceChild,
43
+ type Target,
44
+ type Transition,
45
+ type VariantLabels,
46
+ type VariantResolver,
47
+ } from '@unrulysystems/native-motion-core'
48
+ import {
49
+ createPresenceBinding,
50
+ exitLaneTransitionRefusal,
51
+ validateExitLaneTransition,
52
+ type PresenceBinding,
53
+ type PresenceLiveValues,
54
+ type PresenceResolvedExits,
55
+ } from './presenceBinding'
56
+ import { useLengthLayoutParentContext } from './lengthLayoutContext'
57
+ import { isNativeMotionHost } from './nativeHostMarker'
58
+ import {
59
+ composePopLayoutOnLayout,
60
+ composePopLayoutStyle,
61
+ readPopLayoutRect,
62
+ refreshPopLayoutRect,
63
+ resolvePopLayoutDirection,
64
+ type PopLayoutHostHandle,
65
+ } from './popLayout'
66
+ import { gateTargetWithSeverity } from './motionViewController'
67
+ import { ambientNativeSeverity, consoleReporter } from './severity'
68
+ import {
69
+ dictionaryTargetKeys,
70
+ gateExitTransitionEnd,
71
+ gateLabelDefinition,
72
+ gateVariantApplicationFallbacks,
73
+ gateVariantsDictionary,
74
+ isVariantLabelForm,
75
+ isRuntimeVariantDefinitionForm,
76
+ resolveVariantApplicationsGated,
77
+ } from './variantProps'
78
+ import type { MotionComponentId } from './motionViewController'
79
+
80
+ // The per-child presence contract (REQ-PRESENCE-014): [isPresent, safeToRemove], plus the consumer
81
+ // registration that makes a `usePresence` caller a MANUAL (deferred) exit — a child whose subtree
82
+ // holds a registered consumer is retained past its drop until safeToRemove, exactly the spec's
83
+ // isPresent=false interval (review cycle 1 major 2: deferral must come from the hook's existence,
84
+ // not a static prop). Children outside an AnimatePresence are always present; safeToRemove/register
85
+ // are then no-ops by definition (nothing retains them).
86
+ interface PresenceRegistration {
87
+ readonly safeToRemove: (consumerId: string) => void
88
+ readonly register: (consumerId: string) => () => void
89
+ }
90
+
91
+ interface PresenceRemovalSnapshot {
92
+ readonly reader: PresenceLiveValuesReader
93
+ readonly values: PresenceLiveValues
94
+ }
95
+
96
+ interface PresenceContextValue {
97
+ readonly isPresent: boolean
98
+ readonly registration: PresenceRegistration
99
+ /** The AnimatePresence custom payload captured when this child entered its exit episode. */
100
+ readonly custom?: unknown
101
+ readonly removalSnapshot?: PresenceRemovalSnapshot
102
+ /**
103
+ * Pin PresenceChild: when the tracked host is a Fragment (or other non-exit host),
104
+ * nested motion hosts with object-form exit must register consumers and self-drive
105
+ * exit (r10 major 1c84dbe8f2a7). Direct MotionView keys keep the binding beginExit path.
106
+ */
107
+ readonly nestedExitAuthority: boolean
108
+ /**
109
+ * Pin PresenceChild initial={false}: suppress enter on descendants under this host
110
+ * (r10 major 7a3f6ce1b904). Direct hosts still receive decoratePresenceChild's clone.
111
+ */
112
+ readonly enterSuppressed: boolean
113
+ }
114
+
115
+ const PresenceContext = createContext<PresenceContextValue | null>(null)
116
+
117
+ // The removal-boundary authority a MotionView registers: `read` supplies committed scalar values
118
+ // (the null-first origin, REQ-PRESENCE-020); the optional `readResolvedExits` supplies px-RESOLVED
119
+ // endpoints for measure-length exit arrays (reviews 2f3a8e6c1d90/7ae34b29f081) — the owning
120
+ // MotionView resolves against its own layout context, the SAME resolution the property lane
121
+ // performs at command time. `currentContext` carries the container's RENDER-CURRENT provider
122
+ // context (review 493cd7e58a10): the drop capture runs in the parent's render, before the
123
+ // retained child re-renders under a same-commit provider change.
124
+ type PresenceLiveValuesReader = ((keys: readonly string[]) => PresenceLiveValues) & {
125
+ readResolvedExits?: (
126
+ keys: readonly string[],
127
+ currentContext?: LengthLayoutContext | null,
128
+ previousContainerContext?: LengthLayoutContext | null,
129
+ ) => Readonly<Record<string, readonly (number | null)[]>>
130
+ }
131
+
132
+ interface PresenceLiveValuesRegistration {
133
+ register(read: PresenceLiveValuesReader): () => void
134
+ isRegistered(read: PresenceLiveValuesReader): boolean
135
+ }
136
+
137
+ // Separate from usePresence's subtree-wide lifecycle context: only the first native MotionView host
138
+ // under a tracked child supplies that child's committed values. MotionView places a null provider around
139
+ // its descendants so nested hosts cannot replace the removal-boundary authority (REQ-PRESENCE-020).
140
+ const PresenceLiveValuesContext = createContext<PresenceLiveValuesRegistration | null>(null)
141
+
142
+ export function usePresenceLiveValues(
143
+ read: (keys: readonly string[]) => PresenceLiveValues,
144
+ readResolvedExits?: (
145
+ keys: readonly string[],
146
+ currentContext?: LengthLayoutContext | null,
147
+ previousContainerContext?: LengthLayoutContext | null,
148
+ ) => Readonly<Record<string, readonly (number | null)[]>>,
149
+ ): () => PresenceLiveValues | undefined {
150
+ // The same authority that supplies the snapshot receives it back during exit. Sibling top-level
151
+ // hosts remain registered as fallbacks but cannot consume the first host's committed state.
152
+ const presence = useContext(PresenceContext)
153
+ const registration = useContext(PresenceLiveValuesContext)
154
+ const readRef = useRef(read)
155
+ readRef.current = read
156
+ const readResolvedExitsRef = useRef(readResolvedExits)
157
+ readResolvedExitsRef.current = readResolvedExits
158
+ const stableReadRef = useRef<PresenceLiveValuesReader | null>(null)
159
+ if (stableReadRef.current === null) {
160
+ const stable: PresenceLiveValuesReader = (keys) => readRef.current(keys)
161
+ stable.readResolvedExits = (keys, currentContext, previousContainerContext) =>
162
+ readResolvedExitsRef.current?.(keys, currentContext, previousContainerContext) ?? {}
163
+ stableReadRef.current = stable
164
+ }
165
+ const stableRead = stableReadRef.current
166
+ useEffect(() => {
167
+ if (registration === null) return
168
+ return registration.register(stableRead)
169
+ }, [registration, stableRead])
170
+ const removalSnapshot = presence?.removalSnapshot
171
+ const resolveRemovalValues = useCallback(() => {
172
+ if (registration === null || removalSnapshot === undefined) return undefined
173
+ // React runs deleted-child passive cleanups before surviving siblings' update effects. Re-check
174
+ // the captured reader HERE (not during render): transferring one host's T1 snapshot to another
175
+ // host is ambiguous and would desynchronize property timing from presence retention.
176
+ if (!registration.isRegistered(removalSnapshot.reader)) {
177
+ throw new Error(
178
+ 'AnimatePresence removal authority unmounted during the removal commit while another ' +
179
+ 'top-level native host survived; the origin handoff is ambiguous (REQ-PRESENCE-020).',
180
+ )
181
+ }
182
+ return removalSnapshot.reader === stableRead ? removalSnapshot.values : undefined
183
+ }, [registration, removalSnapshot, stableRead])
184
+ // A top-level host introduced during the retained render has no claim on the old host's T1
185
+ // snapshot. Refuse during render, before MotionView can create its controller or mutate the driver.
186
+ // Already-mounted siblings are registered and remain legal; the passive resolver above handles
187
+ // an authority that disappears later in this same commit.
188
+ if (
189
+ registration !== null &&
190
+ removalSnapshot !== undefined &&
191
+ !registration.isRegistered(stableRead)
192
+ ) {
193
+ throw new Error(
194
+ 'AnimatePresence cannot mount a new top-level native host during an active removal ' +
195
+ 'snapshot; replacing the removal authority is ambiguous (REQ-PRESENCE-020).',
196
+ )
197
+ }
198
+ return resolveRemovalValues
199
+ }
200
+
201
+ export function PresenceLiveValuesBoundary({ children }: { readonly children: ReactNode }) {
202
+ return (
203
+ <PresenceLiveValuesContext.Provider value={null}>{children}</PresenceLiveValuesContext.Provider>
204
+ )
205
+ }
206
+
207
+ interface PresenceConsumerState {
208
+ readonly registered: Set<string>
209
+ readonly released: Set<string>
210
+ exiting: boolean
211
+ delivered: boolean
212
+ }
213
+
214
+ export interface PresenceConsumerLatch {
215
+ hasConsumers(key: string): boolean
216
+ register(key: string, consumerId: string): () => void
217
+ setPresent(key: string, isPresent: boolean): void
218
+ safeToRemove(key: string, consumerId: string): void
219
+ pruneUnmounted(mountedKeys: readonly string[]): void
220
+ // True while the key's exit episode has delivered its release and no later registration has
221
+ // reopened it — the container's commit-boundary check before finalizing a deferred release.
222
+ isDelivered(key: string): boolean
223
+ }
224
+
225
+ // One Presence controller entry is keyed by the tracked React child, but any number of manual
226
+ // consumers may live in that child's subtree. Keep their exactly-once releases separate and open
227
+ // the controller's key-wide latch only after every still-registered consumer has released. A
228
+ // consumer disappearing during exit satisfies its own obligation; re-entry cancels the episode.
229
+ // `adoptKey` is the retention-authority coupling (r5 protocol law): whenever an exit episode holds
230
+ // unreleased consumers whose registration the drop commit's sync may not have carried into the
231
+ // controller record, the latch adopts the key so an ANIMATION-driven record cannot resolve on
232
+ // animation completion alone (REQ-PRESENCE-001/013/014). The controller-side seam is idempotent.
233
+ export function createPresenceConsumerLatch(
234
+ releaseKey: (key: string) => void,
235
+ adoptKey: (key: string) => void,
236
+ ): PresenceConsumerLatch {
237
+ const states = new Map<string, PresenceConsumerState>()
238
+
239
+ const stateFor = (key: string): PresenceConsumerState => {
240
+ const existing = states.get(key)
241
+ if (existing !== undefined) return existing
242
+ const created: PresenceConsumerState = {
243
+ registered: new Set(),
244
+ released: new Set(),
245
+ exiting: false,
246
+ delivered: false,
247
+ }
248
+ states.set(key, created)
249
+ return created
250
+ }
251
+
252
+ const deliverIfReady = (key: string, state: PresenceConsumerState): void => {
253
+ if (!state.exiting || state.delivered) return
254
+ for (const consumerId of state.registered) {
255
+ if (!state.released.has(consumerId)) return
256
+ }
257
+ state.delivered = true
258
+ releaseKey(key)
259
+ }
260
+
261
+ return {
262
+ hasConsumers(key) {
263
+ return (states.get(key)?.registered.size ?? 0) > 0
264
+ },
265
+
266
+ isDelivered(key) {
267
+ return states.get(key)?.delivered === true
268
+ },
269
+
270
+ register(key, consumerId) {
271
+ const state = stateFor(key)
272
+ state.registered.add(consumerId)
273
+ if (state.exiting) {
274
+ state.released.delete(consumerId)
275
+ // A consumer joining a LIVE episode (a layoutId introduced mid-exit registers from the
276
+ // passive-effect flush after the drop) must acquire retention authority in the controller
277
+ // record too — the drop-time sync could not have seen it. Delivery is NOT terminal while
278
+ // the key still exits (r6 finding 5ed46aa1322f): an early first delivery hands retention
279
+ // back to the exit animation, and a consumer registering in that window REOPENS the
280
+ // episode — prior releases keep their marks, and the joiner's release (or unregister)
281
+ // delivers again exactly once. No hold is stranded: a registration after the key has
282
+ // resolved is bounded by the controller seam's exiting-only guard and pruneUnmounted,
283
+ // never by a permanently closed episode.
284
+ state.delivered = false
285
+ adoptKey(key)
286
+ }
287
+ let registered = true
288
+ return () => {
289
+ if (!registered) return
290
+ registered = false
291
+ state.registered.delete(consumerId)
292
+ state.released.delete(consumerId)
293
+ if (state.exiting) deliverIfReady(key, state)
294
+ // A LIVE-exit state survives its last consumer unregistering — even delivered (r7
295
+ // finding cd76f4209a31): a REPLACEMENT consumer registering afterwards must land in the
296
+ // same still-exiting episode and take the reopen path, never a fresh non-exiting state
297
+ // that skips adoption. pruneUnmounted discards the state once the key actually resolves.
298
+ if (state.registered.size === 0 && !state.exiting) states.delete(key)
299
+ }
300
+ },
301
+
302
+ setPresent(key, isPresent) {
303
+ // An identity can become a Presence consumer only after its parent has entered retention.
304
+ // Preserve that absent edge until its post-commit registration joins the same exit episode.
305
+ // A present key with no consumers still needs no bookkeeping at all.
306
+ const state = states.get(key) ?? (isPresent ? undefined : stateFor(key))
307
+ if (state === undefined) return
308
+ if (isPresent) {
309
+ // A consumer-less state kept alive through an exit (the r7 replacement-consumer law
310
+ // preserves live-exit states across unregister) has no further obligation once the key
311
+ // re-enters — drop it, or it would linger for the container's lifetime (pruneUnmounted
312
+ // only covers unmounted keys).
313
+ if (state.registered.size === 0) {
314
+ states.delete(key)
315
+ return
316
+ }
317
+ state.exiting = false
318
+ state.delivered = false
319
+ state.released.clear()
320
+ return
321
+ }
322
+ if (state.exiting) return
323
+ state.exiting = true
324
+ state.delivered = false
325
+ state.released.clear()
326
+ // No consumer may have registered yet: React installs a newly introduced layoutId's
327
+ // subscription in the following passive-effect flush. Do not deliver the empty set before
328
+ // that consumer gets its one chance to join this exit; register/safeToRemove then drive the
329
+ // normal conjunction, and unregister keeps the existing cleanup path intact.
330
+ // Consumers already registered on the exit edge also adopt: unreachable through the
331
+ // container today (a pre-drop registration lands `deferred` at the drop sync), but the
332
+ // latch cannot know the caller's sync ordering — adopting on both edges keeps authority
333
+ // acquisition unconditional, and the controller-side seam is idempotent.
334
+ if (state.registered.size > 0) {
335
+ adoptKey(key)
336
+ deliverIfReady(key, state)
337
+ }
338
+ },
339
+
340
+ safeToRemove(key, consumerId) {
341
+ const state = states.get(key)
342
+ if (
343
+ state === undefined ||
344
+ !state.exiting ||
345
+ !state.registered.has(consumerId) ||
346
+ state.released.has(consumerId)
347
+ ) {
348
+ return
349
+ }
350
+ state.released.add(consumerId)
351
+ deliverIfReady(key, state)
352
+ },
353
+
354
+ // A key with no consumer can still need a short-lived exiting marker for a layoutId that
355
+ // registers in this commit's passive effects. Once Presence has removed that key entirely,
356
+ // there can be no later registration for the episode, so discard its marker too.
357
+ pruneUnmounted(mountedKeys) {
358
+ const mounted = new Set(mountedKeys)
359
+ for (const key of states.keys()) {
360
+ if (!mounted.has(key)) states.delete(key)
361
+ }
362
+ },
363
+ }
364
+ }
365
+
366
+ // The pinned motion/react return shape (type-identity gate, review round 2 major 6): outside
367
+ // any AnimatePresence → [true, null]; present inside one → [true]; exiting → [false,
368
+ // safeToRemove]. `subscribe: false` opts out of the manual-exit registration exactly like
369
+ // motion's flag — the caller reads presence without deferring its own removal.
370
+ type SafeToRemove = () => void
371
+ type AlwaysPresent = [true, null]
372
+ type Present = [true]
373
+ type NotPresent = [false, SafeToRemove]
374
+ const ALWAYS_PRESENT: AlwaysPresent = [true, null]
375
+ const PRESENT: Present = [true]
376
+
377
+ export function usePresence(subscribe: boolean = true): AlwaysPresent | Present | NotPresent {
378
+ const context = useContext(PresenceContext)
379
+ const consumerId = useId()
380
+ const registration = context?.registration
381
+ const safeToRemove = useCallback(() => {
382
+ registration?.safeToRemove(consumerId)
383
+ }, [consumerId, registration])
384
+ // Register on mount so the container knows this child needs a manual exit; unregister on
385
+ // unmount. The effect re-runs only if the context identity changes (a different enclosing
386
+ // child slot) or the subscribe flag flips.
387
+ useEffect(() => {
388
+ if (registration === undefined || !subscribe) return
389
+ return registration.register(consumerId)
390
+ }, [consumerId, registration, subscribe])
391
+ if (context === null) return ALWAYS_PRESENT
392
+ return context.isPresent ? PRESENT : [false, safeToRemove]
393
+ }
394
+
395
+ // Read-only presence signal (SPEC-PRESENCE §usePresence family): unlike usePresence it does NOT
396
+ // register a manual exit — reading presence never defers removal.
397
+ export function useIsPresent(): boolean {
398
+ const context = useContext(PresenceContext)
399
+ return context === null ? true : context.isPresent
400
+ }
401
+
402
+ /** The pin's raw PresenceContext custom reader; reading never registers a removal deferral. */
403
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
404
+ export function usePresenceData(): any {
405
+ const context = useContext(PresenceContext)
406
+ return context?.custom
407
+ }
408
+
409
+ /**
410
+ * Read the presence-level custom payload for variant resolution. The context value is cached at
411
+ * the present → exiting edge, so a later AnimatePresence custom change cannot re-resolve an active
412
+ * exit (pin animation-state.ts:210-224). Present children deliberately read `undefined` and fall
413
+ * back to their own component custom prop.
414
+ */
415
+ export function usePresenceVariantCustom(): unknown | undefined {
416
+ const context = useContext(PresenceContext)
417
+ return context?.isPresent === false ? context.custom : undefined
418
+ }
419
+
420
+ /** Pin PresenceChild policy for nested hosts (Fragment / non-exit tracked children). */
421
+ export function usePresenceHostPolicy(): {
422
+ readonly nestedExitAuthority: boolean
423
+ readonly enterSuppressed: boolean
424
+ readonly isPresent: boolean
425
+ } {
426
+ const context = useContext(PresenceContext)
427
+ if (context === null) {
428
+ return { nestedExitAuthority: false, enterSuppressed: false, isPresent: true }
429
+ }
430
+ return {
431
+ nestedExitAuthority: context.nestedExitAuthority,
432
+ enterSuppressed: context.enterSuppressed,
433
+ isPresent: context.isPresent,
434
+ }
435
+ }
436
+
437
+ // Non-readonly by exception (type-identity gate): the pinned motion/react AnimatePresenceProps
438
+ // declares plain optional members, and the cross-entry Equal compares readonly modifiers too.
439
+ export interface AnimatePresenceProps {
440
+ mode?: PresenceMode
441
+ initial?: boolean
442
+ onExitComplete?: () => void
443
+ /**
444
+ * Pin catalog alias (R13 V4): motion dev sandboxes still write `onRest` while the
445
+ * pin library surface names `onExitComplete`. Same callback — accepted as an alias,
446
+ * never a second lifecycle. Prefer `onExitComplete` in new product code.
447
+ */
448
+ onRest?: () => void
449
+ // SPEC-PRESENCE's end-state surface includes `custom` (dynamic exit variants). The spike does not
450
+ // implement variants, and per the fail-loud rule an accepted-but-ignored prop is a defect — so it
451
+ // exists on the type and THROWS if provided (never silently dropped). Typed `any`, not
452
+ // `unknown`: the pinned motion/react surface declares `custom?: any` and the identity gate
453
+ // compares the shapes verbatim.
454
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
455
+ custom?: any
456
+ /**
457
+ * Family-6 (REQ-PRESENCE-010): when true, this nested AnimatePresence joins its parent
458
+ * presence exit — children exit when the parent is not present, and parent safeToRemove
459
+ * fires after every nested exit settles (pinned motion/react `propagate`).
460
+ */
461
+ propagate?: boolean
462
+ anchorX?: 'left' | 'right'
463
+ anchorY?: 'top' | 'bottom'
464
+ presenceAffectsLayout?: boolean
465
+ // Optional like motion/react's own surface (round 16 major 4b8c6d0e2a95: requiring it here
466
+ // let a childless container typecheck only through the web entry; the identity gate pins
467
+ // `children` across entries now). A childless container tracks nothing and renders nothing.
468
+ children?: ReactNode
469
+ }
470
+
471
+ // Motion props AnimatePresence reads off each keyed child element (the child stays a MotionView; the
472
+ // container only inspects, never re-implements). A manual (deferred) exit comes ONLY from a
473
+ // registered usePresence consumer in the child's subtree (REQ-PRESENCE-014) — the old `deferred`
474
+ // JSX escape hatch is gone (review rounds 3/6: it was never public surface). R7 (REQ-API-032):
475
+ // animate/exit additionally carry the label form, resolved against the child's own dictionary.
476
+ interface MotionChildProps {
477
+ readonly animate?: Target | VariantLabels | VariantResolver | boolean
478
+ readonly exit?: Target | VariantLabels | VariantResolver
479
+ readonly transition?: Transition
480
+ readonly variants?: unknown
481
+ readonly style?: unknown
482
+ readonly onLayout?: (event: unknown) => void
483
+ readonly ref?: Ref<unknown>
484
+ }
485
+
486
+ function setPresenceChildRef(ref: Ref<unknown> | undefined, node: unknown): void | (() => void) {
487
+ if (typeof ref === 'function') return ref(node)
488
+ if (ref !== undefined && ref !== null) {
489
+ ;(ref as { current: unknown }).current = node
490
+ }
491
+ }
492
+
493
+ // The container's per-child target lanes (review rounds 7/8): BOTH targets ride the severity
494
+ // boundary before any binding mutation — value shapes via gateTargetWithSeverity, and the
495
+ // exit-keys ⊆ animate-keys rule under the same law (dev throws; production reports and refuses
496
+ // the offending KEY). The binding's interior unconditional throws become backstops that gated
497
+ // input never reaches.
498
+ // Capture a caller-owned child transition ONCE into a frozen snapshot (review major 3e8bca): the R8-F1
499
+ // filtering, the exit-lane validation, and the transition forwarded to the PresenceChild must all read the
500
+ // SAME value — a stateful `type`/`duration` getter must not filter animate vs exit inconsistently or
501
+ // validate one value then forward another. A malformed SHAPE stays raw so the exit-lane validator throws
502
+ // its typed refusal (a capture would launder an array/Date into an empty `{}`).
503
+ function captureChildTransition(transition: Transition | undefined): Transition | undefined {
504
+ if (transition === undefined) return undefined
505
+ if (typeof transition !== 'object' || transition === null || Array.isArray(transition)) {
506
+ return transition
507
+ }
508
+ const proto = Object.getPrototypeOf(transition) as unknown
509
+ if (proto !== Object.prototype && proto !== null) return transition
510
+ return captureTransition(transition)
511
+ }
512
+
513
+ export function gatePresenceChildTargets(
514
+ targets: {
515
+ readonly animate: Target | VariantLabels | VariantResolver | boolean | undefined
516
+ readonly exit: Target | VariantLabels | VariantResolver | undefined
517
+ // R7: the child's dictionary — label forms resolve against it, and the exit-subset law
518
+ // widens to the law-g mounted union (animate keys ∪ dictionary keys).
519
+ readonly variants?: unknown
520
+ // The child's transition (review major 44): drives the R8-F1 spring/keyframe-array compat refusal
521
+ // for the OBJECT-form animate/exit HERE — so a spring + >2-array exit is refused before presence
522
+ // bookkeeping (never a nonempty exit that retains the child while the property is dropped later).
523
+ readonly transition?: Transition | undefined
524
+ },
525
+ severity: 'development' | 'production',
526
+ report: (error: Error) => void,
527
+ componentId: MotionComponentId = '<Motion.View>',
528
+ ): {
529
+ animate: Target | undefined
530
+ exit: Target | undefined
531
+ /** Law g: animate keys ∪ dictionary keys — the binding's exit-key backstop admits these. */
532
+ mountedKeys: readonly string[]
533
+ /** Law f (M2 r3 major 5a64e2f0c19b): a production-REFUSED label prop must be STRIPPED
534
+ * from the rendered clone — one report here, never a second at the child's gate. */
535
+ animateLabelRefused: boolean
536
+ exitLabelRefused: boolean
537
+ } {
538
+ // Gated SILENTLY for resolution (dev still throws): the child MotionView owns the
539
+ // dictionary's one-report-per-offense duty at its own mount gate — a second container
540
+ // report for the same entry would double-count the offense.
541
+ const dictionary = gateVariantsDictionary(
542
+ targets.variants,
543
+ { severity, report: () => {}, componentId },
544
+ componentId,
545
+ )
546
+ // A refused label prop (production; development throws) is refused AS A UNIT: the caller
547
+ // strips it from the rendered clone so the child's gate never re-reports the offense.
548
+ let animateLabelRefused = false
549
+ let exitLabelRefused = false
550
+ // The EFFECTIVE transition for the R8-F1 compat check (review major 47): an exit-lane-INVALID
551
+ // transition (e.g. a spring carrying `delay`) is refused and FALLS BACK to the default (non-spring)
552
+ // at the child, so its array target stays valid — the array must NOT be refused against a transition
553
+ // that itself gets refused. Only a VALID exit-lane spring triggers R8-F1. (The invalid transition is
554
+ // reported once by toPresenceChild's own transition gate, not double-reported here.)
555
+ // Capture the transition ONCE (review major 3e8bca) so the R8-F1 filtering below reads a stable value —
556
+ // a stateful `type` getter must not filter animate against 'spring' and exit against 'tween'.
557
+ const captured = captureChildTransition(targets.transition)
558
+ const directTargetKeys = (
559
+ target: Target | VariantLabels | VariantResolver | undefined,
560
+ ): readonly string[] =>
561
+ typeof target === 'object' && target !== null && !Array.isArray(target)
562
+ ? Object.keys(target)
563
+ : []
564
+ // Only properties actually consumed by the exit lane participate in its narrower transition
565
+ // vocabulary. An animate-only sibling must not invalidate the exit branch's transition map:
566
+ // the supplying gate still needs to see the exit property's own spring so it can refuse an
567
+ // incompatible keyframe array before presence bookkeeping (review major n4wse8).
568
+ const directPropertyKeys = [...new Set(directTargetKeys(targets.exit))]
569
+ const effectiveTransition: Transition | undefined =
570
+ captured !== undefined &&
571
+ exitLaneTransitionRefusal(captured, componentId, directPropertyKeys) === null
572
+ ? captured
573
+ : undefined
574
+ const resolveLabelTarget = (
575
+ prop: 'animate' | 'exit',
576
+ value: VariantLabels,
577
+ ): { target: Target | undefined; refused: boolean } => {
578
+ const labels = gateLabelDefinition(prop, value, { severity, report, componentId }, componentId)
579
+ if (labels === undefined) return { target: undefined, refused: true }
580
+ if (
581
+ labels.some(
582
+ (label) =>
583
+ dictionary !== undefined &&
584
+ Object.hasOwn(dictionary, label) &&
585
+ typeof dictionary[label] === 'function',
586
+ )
587
+ ) {
588
+ // The presence container has no visual element. Preserve the authored label for the direct
589
+ // MotionView, which resolves it with live state at enter/removal activation.
590
+ return { target: undefined, refused: false }
591
+ }
592
+ return {
593
+ target: flattenVariantApplications(
594
+ gateVariantApplicationFallbacks(
595
+ resolveVariantApplicationsGated(
596
+ labels,
597
+ dictionary,
598
+ { severity, report, componentId },
599
+ componentId,
600
+ ),
601
+ effectiveTransition,
602
+ { severity, report, componentId },
603
+ componentId,
604
+ ),
605
+ ).target,
606
+ refused: false,
607
+ }
608
+ }
609
+ let animate: Target | undefined
610
+ if (typeof targets.animate === 'function') {
611
+ // The container has no visual element and therefore cannot pre-apply the pin's live-state
612
+ // function. The direct MotionView resolves it at its own activation boundary.
613
+ animate = undefined
614
+ } else if (typeof targets.animate === 'boolean') {
615
+ // U7h (REQ-API-057): pin skips boolean — no overlay, not EMPTY_TARGET (an empty object
616
+ // would tighten the exit-subset law). Presence enterSuppressed sees the same absence.
617
+ animate = undefined
618
+ } else if (isVariantLabelForm(targets.animate)) {
619
+ const resolved = resolveLabelTarget('animate', targets.animate)
620
+ animate = resolved.target
621
+ animateLabelRefused = resolved.refused
622
+ } else {
623
+ animate =
624
+ targets.animate === undefined
625
+ ? undefined
626
+ : gateTargetWithSeverity(
627
+ targets.animate,
628
+ severity,
629
+ report,
630
+ true,
631
+ effectiveTransition,
632
+ componentId,
633
+ )
634
+ }
635
+ const exitIsLabel = isVariantLabelForm(targets.exit)
636
+ let exit: Target | undefined
637
+ if (typeof targets.exit === 'function') {
638
+ exit = undefined
639
+ } else if (exitIsLabel) {
640
+ const resolved = resolveLabelTarget('exit', targets.exit as VariantLabels)
641
+ exit = resolved.target
642
+ exitLabelRefused = resolved.refused
643
+ } else {
644
+ exit =
645
+ targets.exit === undefined
646
+ ? undefined
647
+ : gateTargetWithSeverity(
648
+ targets.exit as Target,
649
+ severity,
650
+ report,
651
+ true,
652
+ effectiveTransition,
653
+ componentId,
654
+ )
655
+ }
656
+ // T23 B3b: a carrier on the RESOLVED exit (object form or a label's entry) is the deferred
657
+ // exit-jump lane — one severity refusal here; the member drops, siblings survive.
658
+ if (exit !== undefined) {
659
+ exit = gateExitTransitionEnd(exit, { severity, report, componentId })
660
+ }
661
+ // Law-g union (M2 r1 major 8): a dictionary-named key is mounted on the child even when
662
+ // no animate target names it, so it is a legal exit key. The union also rides the
663
+ // PresenceChild to the binding's backstop (M2 r2 major 18). T23 B3b: the animate CARRIER's
664
+ // SUB-keys are mount-registered on the child (legal exit keys); the carrier itself is
665
+ // structural, never a key.
666
+ const animateCarrier = (animate as Record<string, unknown> | undefined)?.['transitionEnd']
667
+ const mountedKeys = new Set([
668
+ ...Object.keys(animate ?? {}).filter((key) => key !== 'transitionEnd'),
669
+ ...(typeof animateCarrier === 'object' && animateCarrier !== null
670
+ ? Object.keys(animateCarrier)
671
+ : []),
672
+ ...dictionaryTargetKeys(dictionary),
673
+ ])
674
+ // A label-form exit resolves FROM the dictionary, so its keys are dictionary keys by
675
+ // construction — the subset law below can never fail for it and is skipped.
676
+ if (exit !== undefined && !exitIsLabel) {
677
+ const kept: Record<string, unknown> = {}
678
+ for (const [key, value] of Object.entries(exit)) {
679
+ if (!mountedKeys.has(key)) {
680
+ const error = new Error(
681
+ `${componentId}: presence exit key '${key}' is not in the child's animate target or variants ` +
682
+ 'dictionary — exit keys must be a subset of the mounted keys (REQ-API-032 law g; ' +
683
+ 'an un-mounted key has no shared value).',
684
+ )
685
+ if (severity === 'development') throw error
686
+ report(error)
687
+ continue
688
+ }
689
+ kept[key] = value
690
+ }
691
+ exit = kept as Target
692
+ }
693
+ return { animate, exit, mountedKeys: [...mountedKeys], animateLabelRefused, exitLabelRefused }
694
+ }
695
+
696
+ export function componentIdForPresenceChild(type: unknown): MotionComponentId {
697
+ const visited = new Set<object>()
698
+ let current = type
699
+ for (let depth = 0; depth < 16; depth += 1) {
700
+ if ((typeof current !== 'object' && typeof current !== 'function') || current === null) break
701
+ if (visited.has(current)) break
702
+ visited.add(current)
703
+ const displayName = (current as { displayName?: unknown }).displayName
704
+ if (displayName === 'Motion.Text') return '<Motion.Text>'
705
+ if (displayName === 'Motion.Image') return '<Motion.Image>'
706
+ // React.memo stores its wrapped type on `.type`; forwardRef is deliberately opaque because
707
+ // its rendered host cannot be known without invoking user code.
708
+ const wrapped = (current as { type?: unknown }).type
709
+ if (wrapped === undefined) break
710
+ current = wrapped
711
+ }
712
+ return '<Motion.View>'
713
+ }
714
+
715
+ function toPresenceChild(
716
+ element: ReactElement,
717
+ key: string,
718
+ hasConsumer: boolean,
719
+ reenteringActiveExit: boolean,
720
+ ): PresenceChild & {
721
+ transitionConsumed: boolean
722
+ animateLabelRefused: boolean
723
+ exitLabelRefused: boolean
724
+ } {
725
+ const props = element.props as MotionChildProps
726
+ const componentId = componentIdForPresenceChild(element.type)
727
+ const severity = ambientNativeSeverity()
728
+ // Capture the caller-owned transition ONCE at this supplying boundary (review major 3e8bca): the gate's
729
+ // R8-F1 filtering, the exit-lane validation, and the transition forwarded to the PresenceChild all read
730
+ // THIS one snapshot — a stateful accessor cannot pass the gate then mutate into an invalid/divergent
731
+ // config downstream.
732
+ const capturedTransition = captureChildTransition(props.transition)
733
+ const gated = gatePresenceChildTargets(
734
+ {
735
+ animate: props.animate,
736
+ exit: props.exit,
737
+ variants: props.variants,
738
+ transition: capturedTransition,
739
+ },
740
+ severity,
741
+ consoleReporter,
742
+ componentId,
743
+ )
744
+ const exitPresent = !(gated.exit === undefined || Object.keys(gated.exit).length === 0)
745
+ // The transition is CONSUMED (exit progress factory) when an exit target exists or the key
746
+ // re-enters an active exit — then it rides the severity law HERE (r11 major 1c3f4f0b4b77:
747
+ // production reports once and refuses; the binding's loud guard is the direct-consumer
748
+ // backstop, never the render path's crash).
749
+ const transitionConsumed =
750
+ capturedTransition !== undefined && (exitPresent || reenteringActiveExit)
751
+ let childTransition = capturedTransition
752
+ if (transitionConsumed) {
753
+ try {
754
+ const consumedKeys = reenteringActiveExit
755
+ ? [...new Set([...Object.keys(gated.animate ?? {}), ...Object.keys(gated.exit ?? {})])]
756
+ : Object.keys(gated.exit ?? {})
757
+ validateExitLaneTransition(childTransition, componentId, [...consumedKeys])
758
+ } catch (error) {
759
+ if (severity === 'development') throw error
760
+ consoleReporter(error as Error)
761
+ childTransition = undefined
762
+ }
763
+ }
764
+ return {
765
+ key,
766
+ transitionConsumed,
767
+ animateLabelRefused: gated.animateLabelRefused,
768
+ exitLabelRefused: gated.exitLabelRefused,
769
+ ...(gated.animate === undefined ? {} : { animate: gated.animate }),
770
+ // A fully-refused exit is OMITTED: the child then follows the no-exit removal path
771
+ // (REQ-PRESENCE-011) instead of retaining on an empty target.
772
+ ...(gated.exit === undefined || Object.keys(gated.exit).length === 0
773
+ ? {}
774
+ : { exit: gated.exit }),
775
+ ...(childTransition === undefined ? {} : { transition: childTransition }),
776
+ ...(gated.mountedKeys.length === 0 ? {} : { mountedKeys: gated.mountedKeys }),
777
+ ...(hasConsumer ? { deferred: true } : {}),
778
+ }
779
+ }
780
+
781
+ // Decorate one mounted child for render — exported so the checks drive the EXACT render-path logic
782
+ // without React (the M2.2 pattern). Ordering of concerns: a retained exiting child retargets to the
783
+ // once-resolved exit target; a present child whose enter was suppressed (container initial={false},
784
+ // first commit) mounts settled via MotionView's initial={false} sentinel (review cycle 1 major 1:
785
+ // suppression must reach the rendered element, not just the controller's ledger).
786
+ export function decoratePresenceChild(
787
+ binding: PresenceBinding,
788
+ key: string,
789
+ element: ReactElement,
790
+ ): ReactElement {
791
+ const isPresent = binding.isPresent(key)
792
+ const exitTarget = binding.resolvedExitTarget(key)
793
+ // Pin PresenceChild: a Fragment is a pure key boundary + context provider — never retarget
794
+ // animate/exit onto it (React.Fragment accepts only key/children). Descendants self-drive
795
+ // exit via the shared presence context (r9 0f7c3e14a8d2).
796
+ if (element.type === Fragment) {
797
+ return element
798
+ }
799
+ if (!isPresent) {
800
+ // R7 law (d), ONE exit law for both forms: a label-form exit rides the SAME retained-
801
+ // clone path — the clone's animate carries the AUTHORED labels so the child executes
802
+ // exit PER LABEL through its own animate lane (law b), and it does so EVEN WHEN the
803
+ // local resolution is empty (M2 r2 major 17: the propagation-source shape — descendants
804
+ // resolve the labels; the consumer path owns removal timing). The binding's removal
805
+ // bookkeeping rides the resolved target where one exists.
806
+ const authoredExit = (element.props as MotionChildProps).exit
807
+ if (isRuntimeVariantDefinitionForm(authoredExit)) {
808
+ return cloneElement(
809
+ element as ReactElement<{ animate?: Target | VariantLabels | VariantResolver }>,
810
+ {
811
+ animate: authoredExit,
812
+ },
813
+ )
814
+ }
815
+ // Object-form authored exit (pin ExitAnimationFeature): when the binding has not yet
816
+ // resolved an exit target (deferred / multi-consumer hold), still retarget animate so
817
+ // the direct host flies the authored exit while descendants hold via usePresence.
818
+ if (exitTarget !== undefined) {
819
+ return cloneElement(element as ReactElement<{ animate?: Target }>, {
820
+ animate: exitTarget as Target,
821
+ })
822
+ }
823
+ if (
824
+ authoredExit !== undefined &&
825
+ typeof authoredExit === 'object' &&
826
+ !Array.isArray(authoredExit)
827
+ ) {
828
+ return cloneElement(element as ReactElement<{ animate?: Target }>, {
829
+ animate: authoredExit as Target,
830
+ })
831
+ }
832
+ }
833
+ // Enter suppress for direct hosts is applied at the container render site with the
834
+ // first-render ref (REQ-PRESENCE-016) — not here via sticky binding.enterSuppressed.
835
+ return element
836
+ }
837
+
838
+ // The deferred-capability gate under the ratified severity law (review round 5 — production
839
+ // parity with the web shim wrapper): development throws; production reports and refuses invalid
840
+ // values. T24 P consumes popLayout; custom is consumed by the presence context below.
841
+ export function gateDeferredPresenceProps(
842
+ props: { readonly mode: PresenceMode | undefined; readonly custom: unknown },
843
+ severity: 'development' | 'production',
844
+ report: (error: Error) => void,
845
+ ): PresenceMode | undefined {
846
+ let effectiveMode = props.mode
847
+ // The mode VALUE domain is gated first (round 17 major d11c0c6e9a42): an unsupported value
848
+ // (`mode: 'bogus'`) is not a deferred capability, it is an invalid input — REQ-PRESENCE-017
849
+ // demands it fail loud, never ride into the binding as-is.
850
+ if (
851
+ props.mode !== undefined &&
852
+ props.mode !== 'sync' &&
853
+ props.mode !== 'wait' &&
854
+ props.mode !== 'popLayout'
855
+ ) {
856
+ const error = new Error(
857
+ `presence mode '${String(props.mode)}' is not a valid mode — REQ-PRESENCE-010 admits ` +
858
+ "'sync' | 'wait' | 'popLayout' (development throws; production reports and falls " +
859
+ 'back to the default).',
860
+ )
861
+ if (severity === 'development') throw error
862
+ report(error)
863
+ effectiveMode = undefined
864
+ }
865
+ return effectiveMode
866
+ }
867
+
868
+ export function AnimatePresence({
869
+ mode,
870
+ initial,
871
+ onExitComplete,
872
+ onRest,
873
+ custom,
874
+ propagate = false,
875
+ anchorX = 'left',
876
+ anchorY = 'top',
877
+ presenceAffectsLayout = true,
878
+ children,
879
+ ...rest
880
+ }: AnimatePresenceProps) {
881
+ // Unknown JS-supplied props are refused under the severity law (round 15 major
882
+ // 5f21d834ca90): TypeScript closes the surface at compile time; this closes it at runtime.
883
+ // An accepted-but-inert prop is a defect (REQ-PRESENCE-017). `root` is intrinsic-web: the
884
+ // native entry has no stylesheet-injection root, so runtime injection receives a precise
885
+ // platform refusal rather than the generic unknown-prop diagnostic.
886
+ for (const key of Object.keys(rest)) {
887
+ if ((rest as Record<string, unknown>)[key] === undefined) continue
888
+ const error =
889
+ key === 'root'
890
+ ? new Error(
891
+ "AnimatePresence prop 'root' is intrinsic-web and platform-unavailable on React " +
892
+ 'Native; use it only on the web entry (REQ-PRESENCE-023).',
893
+ )
894
+ : new Error(
895
+ `AnimatePresence prop '${key}' is outside the contracted native presence surface ` +
896
+ '(REQ-PRESENCE-010/017).',
897
+ )
898
+ if (ambientNativeSeverity() === 'development') throw error
899
+ consoleReporter(error)
900
+ }
901
+ // Deferred surface gate (severity law): dev throws here, production reports + refuses —
902
+ // matching the web wrapper render-for-render.
903
+ const effectiveMode = gateDeferredPresenceProps(
904
+ { mode, custom },
905
+ ambientNativeSeverity(),
906
+ consoleReporter,
907
+ )
908
+ // Family-6: nested AP joins parent exit when propagate is true (pin usePresence(propagate)).
909
+ // Closed boolean domain (major 29845259d444): only true/false/undefined accepted.
910
+ let effectivePropagate = false
911
+ if (propagate !== undefined && propagate !== true && propagate !== false) {
912
+ const error = new Error(
913
+ `AnimatePresence prop 'propagate' must be boolean (got ${typeof propagate}); ` +
914
+ 'invalid values are refused under the severity law (REQ-PRESENCE-010/017).',
915
+ )
916
+ if (ambientNativeSeverity() === 'development') throw error
917
+ consoleReporter(error)
918
+ effectivePropagate = false
919
+ } else {
920
+ effectivePropagate = propagate === true
921
+ }
922
+ // subscribe=false when propagate is off — register nothing.
923
+ const parentPresence = usePresence(effectivePropagate)
924
+ const isParentPresent = parentPresence[0]
925
+ const parentSafeToRemove =
926
+ parentPresence[0] === false && typeof parentPresence[1] === 'function'
927
+ ? parentPresence[1]
928
+ : null
929
+ // Family-6: a propagated nested container exits every mounted child while its parent is not
930
+ // present, even when those children remain authored in this render.
931
+ const forceExitAll = effectivePropagate && !isParentPresent
932
+ const parentSafeToRemoveRef = useRef(parentSafeToRemove)
933
+ parentSafeToRemoveRef.current = parentSafeToRemove
934
+ // One release per parent-exit episode (empty/no-exit nested case may not fire onExitComplete).
935
+ const releasedParentEpisodeRef = useRef(false)
936
+ if (isParentPresent) releasedParentEpisodeRef.current = false
937
+ const releaseParentOnce = (): void => {
938
+ if (releasedParentEpisodeRef.current) return
939
+ if (parentSafeToRemoveRef.current === null) return
940
+ releasedParentEpisodeRef.current = true
941
+ parentSafeToRemoveRef.current()
942
+ }
943
+ // Re-render trigger for async presence transitions (exit settle → unmount; wait-mode flushes).
944
+ const [, setVersion] = useState(0)
945
+ // Pin PresenceChild initial={false} only on the container's first render (r11/r12).
946
+ // Layout phase (not passive): a child layout effect must not re-render the container
947
+ // while the latch is still true (r12 major cc2b0e71f3a6).
948
+ const isInitialPresenceRenderRef = useRef(true)
949
+ useLayoutEffect(() => {
950
+ isInitialPresenceRenderRef.current = false
951
+ }, [])
952
+
953
+ // Behavioral props are read PER RENDER like motion/react's container (review round 6 major
954
+ // c4b918e2): the exit-complete callback rides a latest-ref so the binding never holds a stale
955
+ // closure, and a mode change recreates the rig below.
956
+ // R13 V4: onRest is a pin-catalog alias of onExitComplete (same lifecycle, one fire).
957
+ const onExitCompleteRef = useRef(onExitComplete ?? onRest)
958
+ onExitCompleteRef.current = onExitComplete ?? onRest
959
+
960
+ // The binding + its manually-stepped graph. The scheduler is core's ManualScheduler advanced
961
+ // by REAL frame deltas from the stepper below — deterministic core pieces, live time.
962
+ const rigRef = useRef<{
963
+ binding: PresenceBinding
964
+ scheduler: ManualScheduler
965
+ mode: PresenceMode | undefined
966
+ } | null>(null)
967
+ const recreated = useRef(false)
968
+ // The prior render's records let an idle mode swap prime the replacement binding before the
969
+ // current child diff. Without that handoff, `sync → popLayout` plus a removal clears the old
970
+ // binding first and the replacement never learns that the dropped key existed.
971
+ const previousPresenceChildrenRef = useRef<readonly PresenceChild[]>([])
972
+ const createRig = (
973
+ mode: PresenceMode | undefined,
974
+ firstRender: boolean,
975
+ ): { binding: PresenceBinding; scheduler: ManualScheduler; mode: PresenceMode | undefined } => {
976
+ const clock = new ManualClock()
977
+ const scheduler = new ManualScheduler(clock)
978
+ const graph = createMotionGraph({ clock, scheduler })
979
+ const binding = createPresenceBinding({
980
+ graph,
981
+ ...(mode === undefined ? {} : { mode }),
982
+ initial: firstRender ? (initial ?? true) : false,
983
+ onExitComplete: () => {
984
+ // Family-6: when nested under a parent exit with propagate, release the parent
985
+ // presence consumer after every nested exit settles (pin motion/react).
986
+ releaseParentOnce()
987
+ const callback = onExitCompleteRef.current
988
+ if (callback !== undefined) callback()
989
+ setVersion((v) => v + 1) // wait-mode enters flush after this — re-render to commit them
990
+ },
991
+ })
992
+ return { binding, scheduler, mode }
993
+ }
994
+ if (rigRef.current === null) {
995
+ rigRef.current = createRig(effectiveMode, !recreated.current)
996
+ recreated.current = true
997
+ }
998
+ let binding = rigRef.current.binding
999
+ let scheduler = rigRef.current.scheduler
1000
+
1001
+ // Collect keyed elements the way the PINNED oracle does (round 19 major 6ca42f0e9bd3:
1002
+ // round-18's loud gate on text/keyless children was an UNRATIFIED divergence —
1003
+ // SPEC-PRESENCE requires Motion compatibility, and motion/react silently filters
1004
+ // non-element children and accepts keyless elements). Children.toArray assigns positional
1005
+ // keys ('.0') to keyless children — the same positional identity motion itself falls back
1006
+ // to, including its known multiple-keyless limitation; non-elements (text, numbers) are
1007
+ // filtered without error, exactly like motion.
1008
+ //
1009
+ // Pin PresenceChild law (r9 major 0f7c3e14a8d2): a Fragment is ONE tracked child. Nested
1010
+ // motion descendants register on the shared presence context and hold the whole child until
1011
+ // every registered exit finishes. Never flatten — independent peer keys diverge from the pin
1012
+ // (no-exit peer would leave immediately while A/C retain).
1013
+ const elements = new Map<string, ReactElement>()
1014
+ for (const child of Children.toArray(children)) {
1015
+ if (!isValidElement(child)) continue
1016
+ elements.set(String(child.key), child)
1017
+ }
1018
+
1019
+ // A mode change swaps the rig (core fixes mode at construction) — but NEVER mid-exit
1020
+ // (review round 7 major 6ab357d3: an eager swap discarded retained exiting children;
1021
+ // SPEC-PRESENCE's retention authority holds across the flip). While exits are in flight the
1022
+ // OLD rig keeps running and its retained children resolve under the old mode; the swap lands
1023
+ // at the first idle render — the stepper re-renders at every settle edge, so idleness is
1024
+ // always observed promptly. When the current render removes a key, or propagation forces every
1025
+ // authored key out, prime the replacement from the prior render before syncing the new set so
1026
+ // the removal is visible under the new mode. The authored set can remain unchanged on a
1027
+ // parent-exit render.
1028
+ if (rigRef.current.mode !== effectiveMode && binding.idle()) {
1029
+ const previousMounted = binding.mounted()
1030
+ const nextRig = createRig(effectiveMode, false)
1031
+ if (forceExitAll || previousMounted.some((key) => !elements.has(key))) {
1032
+ nextRig.binding.sync(previousPresenceChildrenRef.current)
1033
+ }
1034
+ rigRef.current = nextRig
1035
+ binding = nextRig.binding
1036
+ scheduler = nextRig.scheduler
1037
+ }
1038
+ const popLayoutActive = rigRef.current.mode === 'popLayout'
1039
+
1040
+ // usePresence consumer registry: a child whose subtree registered a consumer is a MANUAL exit
1041
+ // (REQ-PRESENCE-014). Consumers register in effects, i.e. AFTER their first render — which is
1042
+ // exactly the spec's shape: deferral matters at DROP time, by which the consumer has mounted.
1043
+ const bindingRef = useRef(binding)
1044
+ bindingRef.current = binding
1045
+ const consumerLatchRef = useRef<PresenceConsumerLatch | null>(null)
1046
+ // A latch delivery lands inside a passive-effect flush, but a consumer REGISTERING later in
1047
+ // that SAME flush must still join the episode (r8 finding 9b42f2c7e1a6): a drop-time deferred
1048
+ // record has no scalar animation to bridge the gap, so a synchronous key-wide release would
1049
+ // resolve the controller before the late registration effect runs. Finalize releases at the
1050
+ // NEXT commit instead — React flushes every passive effect of the commit before the version
1051
+ // bump re-renders, so by flush time a same-flush registration has already reopened the episode
1052
+ // and its pending release is simply dropped (the joiner's own release re-delivers).
1053
+ const pendingReleasesRef = useRef(new Set<string>())
1054
+ if (consumerLatchRef.current === null) {
1055
+ consumerLatchRef.current = createPresenceConsumerLatch(
1056
+ (key) => {
1057
+ pendingReleasesRef.current.add(key)
1058
+ setVersion((v) => v + 1)
1059
+ },
1060
+ // The r5 retention-authority coupling: a consumer joining a live exit upgrades the RETAINED
1061
+ // controller record (deferExit), so the scalar exit completing cannot unmount the key ahead
1062
+ // of the consumer's release. A mode-swap recreates the binding mid-life; route through the
1063
+ // latest-ref like the release path so the adoption always lands in the live controller.
1064
+ (key) => {
1065
+ bindingRef.current.deferExit(key)
1066
+ },
1067
+ )
1068
+ }
1069
+ const consumerLatch = consumerLatchRef.current
1070
+
1071
+ // The direct native host registers a stable reader after mount. On a drop render, read it BEFORE
1072
+ // presence mutates lifecycle state and pass the resulting immutable data through the binding/core seam.
1073
+ const liveValueReadersRef = useRef(new Map<string, Set<PresenceLiveValuesReader>>())
1074
+ const liveValueRegistrationCacheRef = useRef(new Map<string, PresenceLiveValuesRegistration>())
1075
+ const liveOriginKeysRef = useRef(new Map<string, readonly string[]>())
1076
+ // Measure-length exit keys per child (review 2f3a8e6c1d90): their retention endpoints must
1077
+ // arrive px-resolved from the child's own layout context at the removal boundary.
1078
+ const lengthExitKeysRef = useRef(new Map<string, readonly string[]>())
1079
+ const removalSnapshotsRef = useRef(new Map<string, PresenceRemovalSnapshot>())
1080
+ // PopChild's native equivalent keeps the direct host and last element available through the
1081
+ // removal snapshot. Ref entries are keyed and stable unless the authored ref itself changes.
1082
+ const popLayoutRectsRef = useRef(new Map<string, ReturnType<typeof readPopLayoutRect>>())
1083
+ const popLayoutHostRefsRef = useRef(new Map<string, PopLayoutHostHandle>())
1084
+ const popLayoutRefCallbacksRef = useRef(
1085
+ new Map<
1086
+ string,
1087
+ {
1088
+ readonly authoredRef: Ref<unknown> | undefined
1089
+ readonly callback: RefCallback<unknown>
1090
+ }
1091
+ >(),
1092
+ )
1093
+ const lastSeenRef = useRef(new Map<string, ReactElement>())
1094
+ // The container's RENDER-CURRENT provider context (review 493cd7e58a10): the drop capture below
1095
+ // runs in THIS render, before the retained child re-renders under a same-commit provider
1096
+ // change — the child reader merges it over its own (stale) box so both lanes resolve the exit
1097
+ // against the same context. The PREVIOUS render's context object rides along (review
1098
+ // 9d78af2e065c): identity with the child's own consumed context proves the child shares this
1099
+ // provider chain (an update's current values are freshest); a different object means a CLOSER
1100
+ // provider sits between container and child and must not be overwritten by the outer one.
1101
+ const renderCurrentLengthContext = useLengthLayoutParentContext()
1102
+ const previousLengthContextRef = useRef<LengthLayoutContext | null>(null)
1103
+ const previousLengthContext = previousLengthContextRef.current
1104
+ previousLengthContextRef.current = renderCurrentLengthContext
1105
+
1106
+ // Commit-boundary release finalization (see the pending-release law above), run from the
1107
+ // container's own passive effect — never from render (r10 finding 4d8c6b9fe2a1): resolving
1108
+ // the last deferred exit reaches the PUBLIC onExitComplete, and a consumer's normal
1109
+ // setState-in-callback pattern must fire from effect context exactly like motion's (a render
1110
+ // invocation emits React's "Cannot update a component while rendering" error and would let an
1111
+ // abandoned render mutate the controller). Ordering still closes the r8 race: this effect is
1112
+ // declared on the container, so React runs every descendant's registration effect first —
1113
+ // a consumer rendered OR staged before finalization has already reopened the episode by the
1114
+ // time this runs, and its pending release is dropped (the joiner's own release re-delivers).
1115
+ // The version bump after a real resolution re-renders so the mounted-set read catches up;
1116
+ // the set is cleared each flush, so a bump-triggered re-run flushes nothing (no loop), and
1117
+ // StrictMode's double effect invocation is safe for the same reason.
1118
+ useEffect(() => {
1119
+ if (pendingReleasesRef.current.size === 0) return
1120
+ let resolvedAny = false
1121
+ for (const key of pendingReleasesRef.current) {
1122
+ if (consumerLatch.isDelivered(key)) {
1123
+ bindingRef.current.safeToRemove(key)
1124
+ resolvedAny = true
1125
+ }
1126
+ }
1127
+ pendingReleasesRef.current.clear()
1128
+ if (resolvedAny) setVersion((v) => v + 1)
1129
+ })
1130
+
1131
+ // Family-6 major a34778f940c7: empty/no-exit nested force-exit never fires binding
1132
+ // onExitComplete — release the parent consumer once the forced drop is idle (effect context).
1133
+ const forceExitAllRef = useRef(false)
1134
+ useEffect(() => {
1135
+ if (!forceExitAllRef.current) return
1136
+ if (!bindingRef.current.idle()) return
1137
+ releaseParentOnce()
1138
+ })
1139
+
1140
+ // Render-time commit sync (the same class of bookkeeping Motion's own container does): the drop
1141
+ // diff must land BEFORE this render's mounted-set read. The binding's sync is diff-based and
1142
+ // guard-idempotent, so a StrictMode double render with the same set is a no-op.
1143
+ // ONE gating pass per commit feeds BOTH the binding and the rendered elements (round 13
1144
+ // major a4f209bd77e1): rendering the ORIGINAL props made MotionView re-validate and re-report
1145
+ // what the container had already reported+refused — the web container clones its children on
1146
+ // gated targets, so production report counts diverged across engines (REQ-PRESENCE-019).
1147
+ const gatedTargets = new Map<
1148
+ string,
1149
+ {
1150
+ animate?: Target
1151
+ exit?: Target
1152
+ transition?: MotionChildProps['transition']
1153
+ animateLabelRefused?: boolean
1154
+ exitLabelRefused?: boolean
1155
+ }
1156
+ >()
1157
+ const presenceChildren = [...elements.entries()].map(([key, element]) => {
1158
+ // PRE-sync state read: a currently-exiting key consumes the incoming transition on
1159
+ // re-entry (r11 major 6f0d2b6721ad).
1160
+ const reenteringActiveExit =
1161
+ binding.mounted().includes(key) && binding.stateOf(key) === 'exiting'
1162
+ const child = toPresenceChild(
1163
+ element,
1164
+ key,
1165
+ consumerLatch.hasConsumers(key),
1166
+ reenteringActiveExit,
1167
+ )
1168
+ gatedTargets.set(key, {
1169
+ ...(child.animate === undefined ? {} : { animate: child.animate }),
1170
+ ...(child.exit === undefined ? {} : { exit: child.exit }),
1171
+ // A consumed transition renders GATED (round-13 parity: one gating pass feeds both
1172
+ // the binding and the render — accepted → same value, refused → stripped).
1173
+ ...(child.transitionConsumed ? { transition: child.transition } : {}),
1174
+ ...(child.animateLabelRefused ? { animateLabelRefused: true } : {}),
1175
+ ...(child.exitLabelRefused ? { exitLabelRefused: true } : {}),
1176
+ })
1177
+ const {
1178
+ transitionConsumed: _consumed,
1179
+ animateLabelRefused: _animateRefused,
1180
+ exitLabelRefused: _exitRefused,
1181
+ ...record
1182
+ } = child
1183
+ liveOriginKeysRef.current.set(
1184
+ key,
1185
+ Object.freeze(
1186
+ Object.entries(record.exit ?? {})
1187
+ .filter(
1188
+ ([property, value]) =>
1189
+ (Array.isArray(value) && value[0] === null) ||
1190
+ // A scalar ANGLE exit under an explicit spring measures its committed degrees
1191
+ // origin (review 4f95f4f8076a) — angles resolve in their own unit, but the
1192
+ // from-current anchor still comes from the committed snapshot.
1193
+ (typeof value === 'string' && UNIVERSAL_SUBSET.get(property)?.valueType === 'angle'),
1194
+ )
1195
+ .map(([property]) => property),
1196
+ ),
1197
+ )
1198
+ lengthExitKeysRef.current.set(
1199
+ key,
1200
+ Object.freeze(
1201
+ Object.entries(record.exit ?? {})
1202
+ .filter(
1203
+ ([property, value]) =>
1204
+ // Only registry-LENGTH keys carry measure-resolved endpoints (review 4f95f4f8076a):
1205
+ // angle strings resolve in degrees without a host and must NOT reach this channel.
1206
+ UNIVERSAL_SUBSET.get(property)?.valueType === 'length' &&
1207
+ ((typeof value === 'string' && !isColor(value)) ||
1208
+ (Array.isArray(value) &&
1209
+ value.some((element) => typeof element === 'string' && !isColor(element)))),
1210
+ )
1211
+ .map(([property]) => property),
1212
+ ),
1213
+ )
1214
+ return record
1215
+ })
1216
+ // Family-6: when nested under a parent exit with propagate, present keys are empty —
1217
+ // every mounted child exits even while still authored under this container (pin presentKeys=[]).
1218
+ const liveValuesByKey = new Map<string, PresenceLiveValues>()
1219
+ const resolvedExitsByKey = new Map<string, PresenceResolvedExits>()
1220
+ const removalSnapshots = new Map<string, PresenceRemovalSnapshot>()
1221
+ for (const key of binding.mounted()) {
1222
+ if (binding.stateOf(key) !== 'present') continue
1223
+ // Forced parent exit: capture removal origins even though the child is still in `elements`.
1224
+ if (!forceExitAll && elements.has(key)) continue
1225
+ const originKeys = liveOriginKeysRef.current.get(key) ?? []
1226
+ const lengthKeys = lengthExitKeysRef.current.get(key) ?? []
1227
+ if (originKeys.length === 0 && lengthKeys.length === 0) continue
1228
+ // React effect order defines the direct-host authority deterministically: the first mounted
1229
+ // host wins, and removing it promotes the next still-mounted sibling without losing the key.
1230
+ const read = liveValueReadersRef.current.get(key)?.values().next().value
1231
+ if (read !== undefined) {
1232
+ if (originKeys.length > 0) {
1233
+ const values = read(originKeys)
1234
+ liveValuesByKey.set(key, values)
1235
+ removalSnapshots.set(key, { reader: read, values })
1236
+ }
1237
+ // The removal-boundary endpoint resolution (review 2f3a8e6c1d90): the child's OWN host
1238
+ // resolves each measure-length exit array against its live layout context — the same
1239
+ // resolveLengthToPx the property lane performs — so presence retention measures real px.
1240
+ // The container's render-current context rides along (review 493cd7e58a10).
1241
+ if (lengthKeys.length > 0 && read.readResolvedExits !== undefined) {
1242
+ resolvedExitsByKey.set(
1243
+ key,
1244
+ read.readResolvedExits(lengthKeys, renderCurrentLengthContext, previousLengthContext),
1245
+ )
1246
+ }
1247
+ }
1248
+ }
1249
+ const capturePopLayoutRect = (key: string, event: unknown, style: unknown): void => {
1250
+ const rect = readPopLayoutRect(
1251
+ event,
1252
+ popLayoutHostRefsRef.current.get(key),
1253
+ resolvePopLayoutDirection(style),
1254
+ )
1255
+ if (rect !== undefined) popLayoutRectsRef.current.set(key, rect)
1256
+ }
1257
+ const popLayoutRefFor = (
1258
+ key: string,
1259
+ authoredRef: Ref<unknown> | undefined,
1260
+ ): RefCallback<unknown> => {
1261
+ const cached = popLayoutRefCallbacksRef.current.get(key)
1262
+ if (cached !== undefined && cached.authoredRef === authoredRef) return cached.callback
1263
+ const callback: RefCallback<unknown> = (node) => {
1264
+ if (node === null) {
1265
+ popLayoutHostRefsRef.current.delete(key)
1266
+ setPresenceChildRef(authoredRef, null)
1267
+ return
1268
+ }
1269
+ popLayoutHostRefsRef.current.set(key, node as PopLayoutHostHandle)
1270
+ const authoredCleanup = setPresenceChildRef(authoredRef, node)
1271
+ return () => {
1272
+ if (popLayoutHostRefsRef.current.get(key) === node) {
1273
+ popLayoutHostRefsRef.current.delete(key)
1274
+ }
1275
+ if (typeof authoredCleanup === 'function') authoredCleanup()
1276
+ else setPresenceChildRef(authoredRef, null)
1277
+ }
1278
+ }
1279
+ popLayoutRefCallbacksRef.current.set(key, { authoredRef, callback })
1280
+ return callback
1281
+ }
1282
+ // The child `onLayout` can precede an independent parent resize. Refresh parent geometry at the
1283
+ // actual removal boundary, matching PopChildMeasure.getSnapshotBeforeUpdate rather than using a
1284
+ // stale parent box.
1285
+ for (const key of binding.mounted()) {
1286
+ if (binding.stateOf(key) !== 'present') continue
1287
+ if (!forceExitAll && elements.has(key)) continue
1288
+ const rect = popLayoutRectsRef.current.get(key)
1289
+ if (rect === undefined) continue
1290
+ const source = elements.get(key) ?? lastSeenRef.current.get(key)
1291
+ const style = (source?.props as MotionChildProps | undefined)?.style
1292
+ popLayoutRectsRef.current.set(
1293
+ key,
1294
+ refreshPopLayoutRect(
1295
+ rect,
1296
+ popLayoutHostRefsRef.current.get(key),
1297
+ resolvePopLayoutDirection(style),
1298
+ ),
1299
+ )
1300
+ }
1301
+ binding.sync(forceExitAll ? [] : presenceChildren, liveValuesByKey, resolvedExitsByKey)
1302
+ previousPresenceChildrenRef.current = presenceChildren
1303
+ forceExitAllRef.current = forceExitAll
1304
+ // Publish only after the controller accepted the whole removal commit. The same immutable object
1305
+ // now feeds retention and the retained host's later passive-effect command; no second live read.
1306
+ for (const [key, snapshot] of removalSnapshots) removalSnapshotsRef.current.set(key, snapshot)
1307
+ for (const key of elements.keys()) {
1308
+ if (binding.stateOf(key) === 'present') removalSnapshotsRef.current.delete(key)
1309
+ }
1310
+ // `setPresent(false)` may create an empty state so a layoutId introduced in this commit can
1311
+ // register after render. Drop that marker once the graph has actually removed its key; otherwise
1312
+ // consumer-free exits would retain unreachable latch entries for the container's lifetime.
1313
+ consumerLatch.pruneUnmounted(binding.mounted())
1314
+ const mountedAfterSync = new Set(binding.mounted())
1315
+ for (const key of liveValueReadersRef.current.keys()) {
1316
+ if (!mountedAfterSync.has(key)) {
1317
+ liveValueReadersRef.current.delete(key)
1318
+ liveOriginKeysRef.current.delete(key)
1319
+ lengthExitKeysRef.current.delete(key)
1320
+ }
1321
+ }
1322
+ for (const key of removalSnapshotsRef.current.keys()) {
1323
+ if (!mountedAfterSync.has(key)) removalSnapshotsRef.current.delete(key)
1324
+ }
1325
+ for (const key of popLayoutRectsRef.current.keys()) {
1326
+ if (!mountedAfterSync.has(key)) popLayoutRectsRef.current.delete(key)
1327
+ }
1328
+ for (const key of popLayoutRefCallbacksRef.current.keys()) {
1329
+ if (!mountedAfterSync.has(key)) popLayoutRefCallbacksRef.current.delete(key)
1330
+ }
1331
+
1332
+ // Last-seen elements for retained (exiting) keys — a dropped key is absent from `elements`, so its
1333
+ // exit clone renders from what it last looked like. Stored as GATED clones: the rendered child
1334
+ // (present or exiting) carries exactly what the container's boundary kept — an explicitly-
1335
+ // undefined animate/exit strips a fully-refused original (the no-exit removal path).
1336
+ for (const [key, element] of elements) {
1337
+ const gated = gatedTargets.get(key)
1338
+ const authored = element.props as MotionChildProps
1339
+ // Only forward motion props when the child authored them (or the gate produced a value).
1340
+ // A Fragment PresenceChild has no animate/exit — cloneElement must not inject those props
1341
+ // (React.Fragment accepts only key/children; pin wraps Fragment without retargeting it).
1342
+ const animatePatch = isRuntimeVariantDefinitionForm(authored.animate)
1343
+ ? gated?.animateLabelRefused === true
1344
+ ? { animate: undefined }
1345
+ : {}
1346
+ : typeof authored.animate === 'boolean'
1347
+ ? {}
1348
+ : authored.animate !== undefined || gated?.animate !== undefined
1349
+ ? { animate: gated?.animate }
1350
+ : {}
1351
+ const exitPatch = isRuntimeVariantDefinitionForm(authored.exit)
1352
+ ? gated?.exitLabelRefused === true
1353
+ ? { exit: undefined }
1354
+ : {}
1355
+ : authored.exit !== undefined || gated?.exit !== undefined
1356
+ ? { exit: gated?.exit }
1357
+ : {}
1358
+ // Capture parent-relative geometry even before a mode switch into popLayout. The pinned
1359
+ // PopChild remains mounted when pop is false so it can snapshot the same transition; the
1360
+ // native direct host must therefore keep this observer installed across all presence modes.
1361
+ const directNativeHost = element.type !== Fragment && isNativeMotionHost(element.type)
1362
+ const layoutCaptureOnLayout = directNativeHost
1363
+ ? composePopLayoutOnLayout(authored.onLayout, (event) =>
1364
+ capturePopLayoutRect(key, event, authored.style),
1365
+ )
1366
+ : undefined
1367
+ const layoutCaptureRef = directNativeHost ? popLayoutRefFor(key, authored.ref) : undefined
1368
+ lastSeenRef.current.set(
1369
+ key,
1370
+ cloneElement(element as ReactElement<Record<string, unknown>>, {
1371
+ // R7 (M2 r2 major 16): an ACCEPTED authored label form survives the clone — the
1372
+ // rendered child must execute labels PER LABEL and keep its controlling/joined
1373
+ // tree role; the gated flattened targets feed only the binding's bookkeeping. A
1374
+ // production-REFUSED label prop is STRIPPED (M2 r3 major 5a64e2f0c19b: one report
1375
+ // at this boundary, never a second at the child's gate). Object forms render
1376
+ // gated exactly as before (one gating pass feeds binding and render).
1377
+ ...animatePatch,
1378
+ ...exitPatch,
1379
+ ...(layoutCaptureOnLayout === undefined ? {} : { onLayout: layoutCaptureOnLayout }),
1380
+ ...(layoutCaptureRef === undefined ? {} : { ref: layoutCaptureRef }),
1381
+ ...(gated !== undefined && 'transition' in gated ? { transition: gated.transition } : {}),
1382
+ }),
1383
+ )
1384
+ }
1385
+
1386
+ // The stepper: drive the graph with real frame deltas ONLY while exits are in flight; when the
1387
+ // mounted set changes (an exit settled → key removed), bump the version so React unmounts it.
1388
+ // Stops itself the moment the binding reports idle — zero per-frame cost at rest.
1389
+ const stepperRef = useRef<{ running: boolean; raf: number; last: number }>({
1390
+ running: false,
1391
+ raf: 0,
1392
+ last: 0,
1393
+ })
1394
+ useEffect(() => {
1395
+ const stepper = stepperRef.current
1396
+ if (stepper.running || binding.idle()) return
1397
+ stepper.running = true
1398
+ stepper.last = 0
1399
+ const loop = (now: number): void => {
1400
+ const dt = stepper.last === 0 ? 0 : now - stepper.last
1401
+ stepper.last = now
1402
+ const before = binding.mounted().join('|')
1403
+ if (dt > 0) scheduler.frame(dt)
1404
+ const after = binding.mounted().join('|')
1405
+ if (before !== after) setVersion((v) => v + 1)
1406
+ if (binding.idle()) {
1407
+ stepper.running = false
1408
+ return
1409
+ }
1410
+ stepper.raf = requestAnimationFrame(loop)
1411
+ }
1412
+ stepper.raf = requestAnimationFrame(loop)
1413
+ return () => {
1414
+ // Unmounting the container cancels the loop; the binding/graph go with it.
1415
+ cancelAnimationFrame(stepper.raf)
1416
+ stepper.running = false
1417
+ }
1418
+ })
1419
+
1420
+ // Per-key context values are CACHED and rebuilt only when isPresent flips: a fresh object every
1421
+ // render would re-run every consumer's registration effect (deps: [context]) and loop. A consumer
1422
+ // registering BUMPS the version so the next commit re-syncs with deferred=true — without the bump
1423
+ // the controller's last-seen child predates the registration and a drop exits immediately (the
1424
+ // live check caught exactly this).
1425
+ const contextCacheRef = useRef(
1426
+ new Map<
1427
+ string,
1428
+ {
1429
+ isPresent: boolean
1430
+ removalSnapshot: PresenceRemovalSnapshot | undefined
1431
+ nestedExitAuthority: boolean
1432
+ enterSuppressed: boolean
1433
+ value: PresenceContextValue
1434
+ }
1435
+ >(),
1436
+ )
1437
+ // Registration identity is independent of the present/exiting value object. A presence flip
1438
+ // must not make usePresence clean up the sole consumer before installing its exiting tuple.
1439
+ const registrationCacheRef = useRef(new Map<string, PresenceRegistration>())
1440
+ // The previous render's child order — the splice base for exiting children (pin parity).
1441
+ const renderedOrderRef = useRef<readonly string[]>([])
1442
+
1443
+ // Render the controller's mounted set in the PIN's order (motion AnimatePresence index.tsx
1444
+ // 125-151, T22 packet F6): PRESENT children take the CURRENT React child order; each
1445
+ // EXITING child is spliced back at the index it held in the PREVIOUS render. The ledger's
1446
+ // insertion order APPENDED a prepended enter (Android device evidence — data b=3-7-8-9
1447
+ // rendered 7,8,9,3), so the ledger stays the render SET authority while order derives here.
1448
+ const mountedNow = binding.mounted()
1449
+ const mountedSet = new Set(mountedNow)
1450
+ const orderedKeys: string[] = []
1451
+ for (const key of elements.keys()) {
1452
+ if (mountedSet.has(key) && binding.isPresent(key)) orderedKeys.push(key)
1453
+ }
1454
+ const previousOrder = renderedOrderRef.current
1455
+ for (let i = 0; i < previousOrder.length; i++) {
1456
+ const key = previousOrder[i]!
1457
+ if (mountedSet.has(key) && !binding.isPresent(key)) {
1458
+ orderedKeys.splice(Math.min(i, orderedKeys.length), 0, key)
1459
+ }
1460
+ }
1461
+ // Fail-safe totality: every mounted key renders exactly once — a retained key absent from
1462
+ // the previous render (unreachable by construction) appends rather than vanishes.
1463
+ if (orderedKeys.length !== mountedNow.length) {
1464
+ const seen = new Set(orderedKeys)
1465
+ for (const key of mountedNow) {
1466
+ if (!seen.has(key)) orderedKeys.push(key)
1467
+ }
1468
+ }
1469
+ renderedOrderRef.current = orderedKeys
1470
+
1471
+ // Render through the exported decorator (exit retarget + enter suppression — the
1472
+ // render-path logic the checks drive directly).
1473
+ const rendered: ReactElement[] = []
1474
+ for (const key of orderedKeys) {
1475
+ // lastSeenRef holds the GATED clone for every present key (updated just above) and the
1476
+ // last gated shape for retained (exiting) keys — originals never reach the render.
1477
+ const element = lastSeenRef.current.get(key)
1478
+ if (element === undefined) continue // unreachable: a mounted key was seen on a prior commit
1479
+ const isPresent = binding.isPresent(key)
1480
+ consumerLatch.setPresent(key, isPresent)
1481
+ // REQ-PRESENCE-016 / pin: initial={false} only on the container's first render
1482
+ // (r11/r12). Sticky binding.enterSuppressed would suppress later nested mounts.
1483
+ const enterSuppressed = isPresent && isInitialPresenceRenderRef.current && initial === false
1484
+ let finalElement = decoratePresenceChild(binding, key, element)
1485
+ if (popLayoutActive && !isPresent && finalElement.type !== Fragment) {
1486
+ const authored = finalElement.props as MotionChildProps
1487
+ const style = composePopLayoutStyle(
1488
+ authored.style,
1489
+ popLayoutRectsRef.current.get(key),
1490
+ anchorX,
1491
+ anchorY,
1492
+ )
1493
+ if (style !== authored.style) {
1494
+ finalElement = cloneElement(finalElement as ReactElement<Record<string, unknown>>, {
1495
+ style,
1496
+ })
1497
+ }
1498
+ }
1499
+ // Direct hosts get first-render suppress via clone; Fragment hosts via context only.
1500
+ if (enterSuppressed && finalElement.type !== Fragment) {
1501
+ finalElement = cloneElement(finalElement as ReactElement<{ initial?: Target | false }>, {
1502
+ initial: false,
1503
+ })
1504
+ }
1505
+ const removalSnapshot = isPresent ? undefined : removalSnapshotsRef.current.get(key)
1506
+ // Nested exit authority: Fragment (and any host without an authored object/label exit)
1507
+ // relies on descendants to register and complete exits — pin PresenceChild law.
1508
+ const hostExit = (element.props as MotionChildProps).exit
1509
+ const nestedExitAuthority =
1510
+ element.type === Fragment ||
1511
+ hostExit === undefined ||
1512
+ (typeof hostExit === 'object' &&
1513
+ hostExit !== null &&
1514
+ !Array.isArray(hostExit) &&
1515
+ Object.keys(hostExit as object).length === 0)
1516
+ const cached = contextCacheRef.current.get(key)
1517
+ let contextValue: PresenceContextValue
1518
+ if (
1519
+ cached !== undefined &&
1520
+ cached.isPresent === isPresent &&
1521
+ cached.removalSnapshot === removalSnapshot &&
1522
+ cached.nestedExitAuthority === nestedExitAuthority &&
1523
+ cached.enterSuppressed === enterSuppressed
1524
+ ) {
1525
+ contextValue = presenceAffectsLayout ? { ...cached.value } : cached.value
1526
+ } else {
1527
+ let registration = registrationCacheRef.current.get(key)
1528
+ if (registration === undefined) {
1529
+ registration = {
1530
+ // The bump is load-bearing (review cycle 3 major 4): a manual removal happens BETWEEN
1531
+ // stepper frames, so its before/after diff never sees it — without this render trigger a
1532
+ // deferred child that called safeToRemove stays rendered until an unrelated commit, and
1533
+ // concurrent deferred children cannot unmount independently.
1534
+ safeToRemove: (consumerId) => {
1535
+ consumerLatch.safeToRemove(key, consumerId)
1536
+ },
1537
+ register: (consumerId) => {
1538
+ const unregister = consumerLatch.register(key, consumerId)
1539
+ setVersion((v) => v + 1) // re-sync so the controller learns this child is deferred
1540
+ return () => {
1541
+ unregister()
1542
+ // Symmetric re-sync (review cycle 2 major 3): without it a consumer removed while its
1543
+ // child stays present leaves stale deferred:true in the controller's last-seen child —
1544
+ // a later drop then opens a manual exit NO live hook can release (retained forever,
1545
+ // graph unsettled). React 18+ treats a post-unmount setState as a no-op.
1546
+ setVersion((v) => v + 1)
1547
+ }
1548
+ },
1549
+ }
1550
+ registrationCacheRef.current.set(key, registration)
1551
+ }
1552
+ contextValue = {
1553
+ isPresent,
1554
+ registration,
1555
+ ...(custom === undefined ? {} : { custom }),
1556
+ nestedExitAuthority,
1557
+ enterSuppressed,
1558
+ ...(removalSnapshot === undefined ? {} : { removalSnapshot }),
1559
+ }
1560
+ contextCacheRef.current.set(key, {
1561
+ isPresent,
1562
+ removalSnapshot,
1563
+ nestedExitAuthority,
1564
+ enterSuppressed,
1565
+ value: contextValue,
1566
+ })
1567
+ }
1568
+ rendered.push(
1569
+ <PresenceContext.Provider key={key} value={contextValue}>
1570
+ <PresenceLiveValuesContext.Provider
1571
+ value={
1572
+ liveValueRegistrationCacheRef.current.get(key) ??
1573
+ (() => {
1574
+ const registration: PresenceLiveValuesRegistration = {
1575
+ isRegistered(read) {
1576
+ return liveValueReadersRef.current.get(key)?.has(read) === true
1577
+ },
1578
+ register(read) {
1579
+ let readers = liveValueReadersRef.current.get(key)
1580
+ if (readers === undefined) {
1581
+ readers = new Set()
1582
+ liveValueReadersRef.current.set(key, readers)
1583
+ }
1584
+ readers.add(read)
1585
+ return () => {
1586
+ readers.delete(read)
1587
+ if (readers.size === 0 && liveValueReadersRef.current.get(key) === readers) {
1588
+ liveValueReadersRef.current.delete(key)
1589
+ }
1590
+ }
1591
+ },
1592
+ }
1593
+ liveValueRegistrationCacheRef.current.set(key, registration)
1594
+ return registration
1595
+ })()
1596
+ }
1597
+ >
1598
+ {finalElement}
1599
+ </PresenceLiveValuesContext.Provider>
1600
+ </PresenceContext.Provider>,
1601
+ )
1602
+ }
1603
+
1604
+ return <>{rendered}</>
1605
+ }