@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- 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
|
+
}
|