@unrulysystems/native-motion 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,614 @@
1
+ // M2.3 presence-binding checks (specs/M2.3-BUILD-PACKET.md §pinned semantics). Rows 1–8 drive the
2
+ // binding + a ManualClock/ManualScheduler graph — fully deterministic, no real time. Row 9 is LIVE
3
+ // (review cycle 1 major 2): a real usePresence consumer inside a real AnimatePresence must get the
4
+ // isPresent=false interval and control its own removal. A screenshot is the assertion; try/catch is
5
+ // evidence capture, never suppression.
6
+
7
+ import { useEffect, useRef, useState } from 'react'
8
+ import { ScrollView, StyleSheet, Text, View } from 'react-native'
9
+ import {
10
+ InvalidTargetError,
11
+ ManualClock,
12
+ ManualScheduler,
13
+ createMotionGraph,
14
+ type MotionGraph,
15
+ type PresenceChild,
16
+ type PresenceMode,
17
+ type Target,
18
+ } from '@unrulysystems/native-motion-core'
19
+ import {
20
+ AnimatePresence,
21
+ decoratePresenceChild,
22
+ useIsPresent,
23
+ usePresence,
24
+ } from '../../native/motion/AnimatePresence'
25
+ import { createPresenceBinding } from '../../native/motion/presenceBinding'
26
+
27
+ const DT = 16.67
28
+ const TWEEN = { type: 'tween', duration: 0.3 } as const
29
+
30
+ interface Row {
31
+ readonly id: string
32
+ readonly pass: boolean
33
+ readonly detail: string
34
+ }
35
+
36
+ interface Rig {
37
+ readonly graph: MotionGraph
38
+ readonly scheduler: ManualScheduler
39
+ }
40
+
41
+ function rig(): Rig {
42
+ const clock = new ManualClock()
43
+ const scheduler = new ManualScheduler(clock)
44
+ return { graph: createMotionGraph({ clock, scheduler }), scheduler }
45
+ }
46
+
47
+ const A: PresenceChild = { key: 'a', animate: { opacity: 1 } }
48
+ const B: PresenceChild = {
49
+ key: 'b',
50
+ animate: { opacity: 1, x: 0 },
51
+ exit: { opacity: 0, x: 40 },
52
+ transition: TWEEN,
53
+ }
54
+
55
+ function checkDropRetainRemove(): Row {
56
+ const id = 'drop → retained exiting → removed at settle'
57
+ try {
58
+ const { graph, scheduler } = rig()
59
+ let exits = 0
60
+ const binding = createPresenceBinding({ graph, onExitComplete: () => (exits += 1) })
61
+ binding.sync([A, B])
62
+ if (!binding.mounted().includes('b'))
63
+ return { id, pass: false, detail: 'b not mounted after commit' }
64
+ binding.sync([A])
65
+ if (!binding.mounted().includes('b') || binding.stateOf('b') !== 'exiting') {
66
+ return {
67
+ id,
68
+ pass: false,
69
+ detail: `dropped b: mounted=${binding.mounted().join(',')} state=${binding.stateOf('b')}`,
70
+ }
71
+ }
72
+ const held = binding.resolvedExitTarget('b')
73
+ scheduler.frames(6, DT) // ~100ms of a 300ms tween
74
+ if (!binding.mounted().includes('b'))
75
+ return { id, pass: false, detail: 'removed before the exit settled' }
76
+ scheduler.frames(30, DT) // well past 300ms
77
+ if (binding.mounted().includes('b'))
78
+ return { id, pass: false, detail: 'still mounted after the exit settled' }
79
+ if (exits !== 1)
80
+ return { id, pass: false, detail: `onExitComplete fired ${exits}×, want exactly 1` }
81
+ if (held === undefined || held['opacity'] !== 0 || held['x'] !== 40) {
82
+ return { id, pass: false, detail: `exit target not held: ${JSON.stringify(held)}` }
83
+ }
84
+ return {
85
+ id,
86
+ pass: true,
87
+ detail: 'retained through the exit, removed at settle, onExitComplete once, target held',
88
+ }
89
+ } catch (e) {
90
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
91
+ }
92
+ }
93
+
94
+ function checkCancelContinuity(): Row {
95
+ const id = 're-entry mid-exit cancels with continuity'
96
+ try {
97
+ const { graph, scheduler } = rig()
98
+ const binding = createPresenceBinding({ graph })
99
+ binding.sync([A, B])
100
+ binding.sync([A])
101
+ scheduler.frames(6, DT)
102
+ const midProgress = binding.exitProgress('b')
103
+ if (midProgress === undefined || midProgress <= 0 || midProgress >= 1) {
104
+ return { id, pass: false, detail: `exit progress not in flight: ${String(midProgress)}` }
105
+ }
106
+ binding.sync([A, B]) // re-enter mid-exit
107
+ if (binding.stateOf('b') !== 'present' || !binding.isPresent('b')) {
108
+ return {
109
+ id,
110
+ pass: false,
111
+ detail: `re-entry state=${binding.stateOf('b')} isPresent=${String(binding.isPresent('b'))}`,
112
+ }
113
+ }
114
+ const atCancel = binding.exitProgress('b')
115
+ if (atCancel === undefined || Math.abs(atCancel - midProgress) > 1e-9) {
116
+ return {
117
+ id,
118
+ pass: false,
119
+ detail: `progress snapped on cancel: ${String(midProgress)} → ${String(atCancel)}`,
120
+ }
121
+ }
122
+ scheduler.frames(60, DT)
123
+ const settledBack = binding.exitProgress('b')
124
+ if (settledBack !== undefined && settledBack > 1e-6) {
125
+ return { id, pass: false, detail: `progress did not return to 0: ${String(settledBack)}` }
126
+ }
127
+ if (!binding.mounted().includes('b'))
128
+ return { id, pass: false, detail: 'cancelled child was removed' }
129
+ return {
130
+ id,
131
+ pass: true,
132
+ detail: `cancelled at progress ${midProgress.toFixed(3)} with no snap; returned present at 0`,
133
+ }
134
+ } catch (e) {
135
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
136
+ }
137
+ }
138
+
139
+ function checkEnterSuppression(): Row {
140
+ const id = 'initial={false} suppresses first-commit enters only'
141
+ try {
142
+ const { graph } = rig()
143
+ const binding = createPresenceBinding({ graph, initial: false })
144
+ binding.sync([A, B])
145
+ if (!binding.enterSuppressed('a')) {
146
+ return { id, pass: false, detail: 'first-commit child a not enter-suppressed' }
147
+ }
148
+ const c: PresenceChild = { key: 'c', animate: { opacity: 1 } }
149
+ binding.sync([A, B, c])
150
+ if (binding.enterSuppressed('c')) {
151
+ return { id, pass: false, detail: 'second-commit child c wrongly suppressed' }
152
+ }
153
+ return { id, pass: true, detail: 'first commit suppressed; later enters animate' }
154
+ } catch (e) {
155
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
156
+ }
157
+ }
158
+
159
+ function checkWaitModeOrdering(): Row {
160
+ const id = "mode='wait': enters land strictly after onExitComplete"
161
+ try {
162
+ const { graph, scheduler } = rig()
163
+ const events: string[] = []
164
+ const binding = createPresenceBinding({
165
+ graph,
166
+ mode: 'wait',
167
+ onExitComplete: () => events.push('exit-complete'),
168
+ })
169
+ binding.sync([A, B])
170
+ binding.sync([A]) // b exits
171
+ const c: PresenceChild = { key: 'c', animate: { opacity: 1 } }
172
+ binding.sync([A, c]) // c enters WHILE b exits — wait mode must defer it
173
+ if (binding.mounted().includes('c')) {
174
+ return {
175
+ id,
176
+ pass: false,
177
+ detail: 'wait mode committed the enter while an exit was in flight',
178
+ }
179
+ }
180
+ for (let i = 0; i < 40; i++) {
181
+ scheduler.frame(DT)
182
+ if (binding.mounted().includes('c') && !events.includes('enter-c')) events.push('enter-c')
183
+ }
184
+ const order = events.join(' → ')
185
+ if (order !== 'exit-complete → enter-c') {
186
+ return { id, pass: false, detail: `order was [${order}], want [exit-complete → enter-c]` }
187
+ }
188
+ if (binding.mounted().includes('b'))
189
+ return { id, pass: false, detail: 'exited b still mounted' }
190
+ return { id, pass: true, detail: order }
191
+ } catch (e) {
192
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
193
+ }
194
+ }
195
+
196
+ function checkDeferredExit(): Row {
197
+ const id = 'usePresence deferred exit holds until safeToRemove'
198
+ try {
199
+ const { graph, scheduler } = rig()
200
+ const deferred: PresenceChild = { key: 'd', animate: { opacity: 1 }, deferred: true }
201
+ const binding = createPresenceBinding({ graph })
202
+ binding.sync([A, deferred])
203
+ binding.sync([A]) // drop d — deferred: no automatic exit animation completes it
204
+ if (!binding.mounted().includes('d') || binding.isPresent('d')) {
205
+ return {
206
+ id,
207
+ pass: false,
208
+ detail: `after drop: mounted=${binding.mounted().join(',')} isPresent=${String(binding.isPresent('d'))}`,
209
+ }
210
+ }
211
+ scheduler.frames(120, DT) // 2s of stepping must NOT remove a deferred exit
212
+ if (!binding.mounted().includes('d'))
213
+ return { id, pass: false, detail: 'deferred exit removed without safeToRemove' }
214
+ if (binding.idle())
215
+ return {
216
+ id,
217
+ pass: false,
218
+ detail: 'graph reports idle while a deferred exit is pending (must count as unsettled)',
219
+ }
220
+ binding.safeToRemove('d')
221
+ if (binding.mounted().includes('d'))
222
+ return { id, pass: false, detail: 'safeToRemove did not release the child' }
223
+ binding.safeToRemove('d') // idempotent — must not throw
224
+ return {
225
+ id,
226
+ pass: true,
227
+ detail:
228
+ 'held through 2s of frames, unsettled while pending, released by safeToRemove (idempotent)',
229
+ }
230
+ } catch (e) {
231
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
232
+ }
233
+ }
234
+
235
+ function checkExitKeysGuard(): Row {
236
+ const id = 'exit keys guarded: validate first, subset second'
237
+ try {
238
+ const { graph } = rig()
239
+ const binding = createPresenceBinding({ graph })
240
+ let unknownError: unknown = null
241
+ try {
242
+ binding.sync([
243
+ { key: 'z', animate: { opacity: 1 }, exit: { warpField: 1 } as unknown as Target },
244
+ ])
245
+ } catch (e) {
246
+ unknownError = e
247
+ }
248
+ if (!(unknownError instanceof InvalidTargetError)) {
249
+ return {
250
+ id,
251
+ pass: false,
252
+ detail: `unknown exit key threw ${unknownError instanceof Error ? unknownError.name : typeof unknownError}, want InvalidTargetError (validation first)`,
253
+ }
254
+ }
255
+ let subsetError: unknown = null
256
+ try {
257
+ binding.sync([{ key: 'z', animate: { opacity: 1 }, exit: { x: 40 } }])
258
+ } catch (e) {
259
+ subsetError = e
260
+ }
261
+ const subsetOk =
262
+ subsetError instanceof Error &&
263
+ !(subsetError instanceof InvalidTargetError) &&
264
+ subsetError.message.includes("'x'") &&
265
+ subsetError.message.includes('z')
266
+ if (!subsetOk) {
267
+ return {
268
+ id,
269
+ pass: false,
270
+ detail: `valid-but-unmounted exit key: ${subsetError instanceof Error ? subsetError.message.slice(0, 90) : String(subsetError)}`,
271
+ }
272
+ }
273
+ return {
274
+ id,
275
+ pass: true,
276
+ detail:
277
+ 'unknown → InvalidTargetError; valid-but-unmounted x → loud subset error naming key+child',
278
+ }
279
+ } catch (e) {
280
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
281
+ }
282
+ }
283
+
284
+ function checkPopLayoutLifecycle(): Row {
285
+ const id = "mode='popLayout' retains an exit through settlement"
286
+ try {
287
+ const { graph, scheduler } = rig()
288
+ const binding = createPresenceBinding({ graph, mode: 'popLayout' as PresenceMode })
289
+ binding.sync([{ key: 'pop', animate: { opacity: 1 }, exit: { opacity: 0 }, transition: TWEEN }])
290
+ binding.sync([])
291
+ if (!binding.mounted().includes('pop')) {
292
+ return { id, pass: false, detail: 'popLayout removed the child before its exit settled' }
293
+ }
294
+ scheduler.frames(30, DT)
295
+ if (binding.mounted().includes('pop')) {
296
+ return { id, pass: false, detail: 'popLayout retained the child after its exit settled' }
297
+ }
298
+ return {
299
+ id,
300
+ pass: true,
301
+ detail: 'popLayout retained the child through its exit and removed it at settle',
302
+ }
303
+ } catch (e) {
304
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
305
+ }
306
+ }
307
+
308
+ function checkDecorateChild(): Row {
309
+ // Exit retarget still lands in decoratePresenceChild. First-render initial={false} is owned
310
+ // by the container render loop (REQ-PRESENCE-016 / r12 74c5d2fa91e8) — not this decorator.
311
+ const id = 'decorator: exit target reaches the element; present is untouched'
312
+ try {
313
+ const probe = <View collapsable={false} />
314
+ const rig2 = rig()
315
+ const exitBinding = createPresenceBinding({ graph: rig2.graph })
316
+ exitBinding.sync([A, B])
317
+ exitBinding.sync([A])
318
+ const exiting = decoratePresenceChild(exitBinding, 'b', probe)
319
+ const animate = (exiting.props as { animate?: Record<string, unknown> }).animate
320
+ if (animate?.['opacity'] !== 0 || animate?.['x'] !== 40) {
321
+ return {
322
+ id,
323
+ pass: false,
324
+ detail: `exiting child animate=${JSON.stringify(animate)}, want the held exit target`,
325
+ }
326
+ }
327
+ const rig3 = rig()
328
+ const plainBinding = createPresenceBinding({ graph: rig3.graph })
329
+ plainBinding.sync([A])
330
+ if (decoratePresenceChild(plainBinding, 'a', probe) !== probe) {
331
+ return { id, pass: false, detail: 'plain present child was needlessly cloned' }
332
+ }
333
+ // initial={false} is NOT applied by decorate (first-render container owns it).
334
+ const rig1 = rig()
335
+ const suppressedBinding = createPresenceBinding({ graph: rig1.graph, initial: false })
336
+ suppressedBinding.sync([{ key: 's', animate: { opacity: 1 } }])
337
+ const suppressed = decoratePresenceChild(suppressedBinding, 's', probe)
338
+ if ((suppressed.props as { initial?: Target | false }).initial === false) {
339
+ return {
340
+ id,
341
+ pass: false,
342
+ detail: 'decorate must not sticky-inject initial={false} (container first-render owns it)',
343
+ }
344
+ }
345
+ return {
346
+ id,
347
+ pass: true,
348
+ detail: 'exiting → held exit target; present → untouched; no sticky enter suppress',
349
+ }
350
+ } catch (e) {
351
+ return { id, pass: false, detail: e instanceof Error ? e.message : String(e) }
352
+ }
353
+ }
354
+
355
+ // Row 9, LIVE: a descendant calling usePresence IS the deferral (no static prop). The consumer must
356
+ // see isPresent flip false at its drop, stay mounted, and control removal via safeToRemove. `id`
357
+ // suffixes events so multiple probes share one journal; `trackIsPresent` also records the READ-ONLY
358
+ // useIsPresent flip (which must never register a deferral of its own).
359
+ function ConsumerProbe({
360
+ events,
361
+ id = '',
362
+ safeDelayMs = 500,
363
+ trackIsPresent = false,
364
+ }: {
365
+ readonly events: string[]
366
+ readonly id?: string
367
+ readonly safeDelayMs?: number
368
+ readonly trackIsPresent?: boolean
369
+ }) {
370
+ const [isPresent, safeToRemove] = usePresence()
371
+ const observed = useIsPresent()
372
+ const firedRef = useRef(false)
373
+ const flaggedRef = useRef(false)
374
+ useEffect(() => {
375
+ if (trackIsPresent && !observed && !flaggedRef.current) {
376
+ flaggedRef.current = true
377
+ events.push(`isPresent-false${id}`)
378
+ }
379
+ }, [observed, trackIsPresent, events, id])
380
+ useEffect(() => {
381
+ if (!isPresent && !firedRef.current) {
382
+ firedRef.current = true
383
+ events.push(`saw-absent${id}`)
384
+ setTimeout(() => {
385
+ events.push(`safe${id}`)
386
+ safeToRemove()
387
+ }, safeDelayMs)
388
+ }
389
+ }, [isPresent, safeToRemove, events, id, safeDelayMs])
390
+ useEffect(() => {
391
+ return () => {
392
+ events.push(`unmounted${id}`)
393
+ }
394
+ }, [events, id])
395
+ return <View style={{ width: 1, height: 1 }} />
396
+ }
397
+
398
+ function useConsumerDeferCheck(): { row: Row | null; probe: React.ReactElement } {
399
+ const [row, setRow] = useState<Row | null>(null)
400
+ const [keys, setKeys] = useState<readonly string[]>(['p'])
401
+ const eventsRef = useRef<string[]>([])
402
+ useEffect(() => {
403
+ const drop = setTimeout(() => setKeys([]), 400)
404
+ const verdict = setTimeout(() => {
405
+ const order = eventsRef.current.join(' → ')
406
+ setRow({
407
+ id: 'usePresence consumer defers removal (live)',
408
+ pass: order === 'saw-absent → safe → unmounted',
409
+ detail: order.length > 0 ? order : 'no events recorded',
410
+ })
411
+ }, 2400)
412
+ return () => {
413
+ clearTimeout(drop)
414
+ clearTimeout(verdict)
415
+ }
416
+ }, [])
417
+ const probe = (
418
+ <View style={styles.hiddenProbe}>
419
+ <AnimatePresence>
420
+ {keys.map((key) => (
421
+ <View key={key}>
422
+ <ConsumerProbe events={eventsRef.current} />
423
+ </View>
424
+ ))}
425
+ </AnimatePresence>
426
+ </View>
427
+ )
428
+ return { row, probe }
429
+ }
430
+
431
+ // Row 10, LIVE (review cycle 2 major 3): a consumer that unmounts while its child stays present
432
+ // must NOT leak deferral — when the child is later dropped it exits normally instead of being
433
+ // retained forever behind a manual exit nobody can release.
434
+ function StaleConsumerChild({ events }: { readonly events: string[] }) {
435
+ useEffect(() => {
436
+ return () => {
437
+ events.push('child-unmounted')
438
+ }
439
+ }, [events])
440
+ return <View style={{ width: 1, height: 1 }} />
441
+ }
442
+
443
+ function useStaleConsumerCheck(): { row: Row | null; probe: React.ReactElement } {
444
+ const [row, setRow] = useState<Row | null>(null)
445
+ const [showConsumer, setShowConsumer] = useState(true)
446
+ const [keys, setKeys] = useState<readonly string[]>(['q'])
447
+ const eventsRef = useRef<string[]>([])
448
+ useEffect(() => {
449
+ const hide = setTimeout(() => setShowConsumer(false), 300) // consumer leaves, child stays
450
+ const drop = setTimeout(() => setKeys([]), 800) // then the child is dropped
451
+ const verdict = setTimeout(() => {
452
+ const sequence = eventsRef.current.join(' → ')
453
+ setRow({
454
+ id: 'stale consumer does not leak deferral (live)',
455
+ pass: eventsRef.current.includes('child-unmounted'),
456
+ detail: eventsRef.current.includes('child-unmounted')
457
+ ? `child exited normally after its consumer left (${sequence})`
458
+ : `child still retained 1.4s after the drop — stale deferral leak (${sequence})`,
459
+ })
460
+ }, 2200)
461
+ return () => {
462
+ clearTimeout(hide)
463
+ clearTimeout(drop)
464
+ clearTimeout(verdict)
465
+ }
466
+ }, [])
467
+ const probe = (
468
+ <View style={styles.hiddenProbe}>
469
+ <AnimatePresence>
470
+ {keys.map((key) => (
471
+ <View key={key}>
472
+ <StaleConsumerChild events={eventsRef.current} />
473
+ {showConsumer ? <ConsumerProbe events={eventsRef.current} /> : null}
474
+ </View>
475
+ ))}
476
+ </AnimatePresence>
477
+ </View>
478
+ )
479
+ return { row, probe }
480
+ }
481
+
482
+ // Row 11, LIVE (review cycle 3 majors 4+5): two deferred children dropped together must unmount
483
+ // INDEPENDENTLY — the first safeToRemove unmounts its child while the other is still pending (no
484
+ // batch-until-the-set-drains behavior) — and the read-only useIsPresent must observe the flip.
485
+ function useIndependentDeferCheck(): { row: Row | null; probe: React.ReactElement } {
486
+ const [row, setRow] = useState<Row | null>(null)
487
+ const [keys, setKeys] = useState<readonly string[]>(['p1', 'p2'])
488
+ const eventsRef = useRef<string[]>([])
489
+ useEffect(() => {
490
+ const drop = setTimeout(() => setKeys([]), 300)
491
+ const verdict = setTimeout(() => {
492
+ const events = eventsRef.current
493
+ const sequence = events.join(' → ')
494
+ const sawFlip = events.includes('isPresent-false-p1')
495
+ const p1Unmounted = events.indexOf('unmounted-p1')
496
+ const p2Safe = events.indexOf('safe-p2')
497
+ const p2Unmounted = events.indexOf('unmounted-p2')
498
+ const independent = p1Unmounted !== -1 && p2Safe !== -1 && p1Unmounted < p2Safe
499
+ setRow({
500
+ id: 'independent deferred unmounts + useIsPresent (live)',
501
+ pass: sawFlip && independent && p2Unmounted !== -1,
502
+ detail: sequence.length > 0 ? sequence : 'no events recorded',
503
+ })
504
+ }, 3000)
505
+ return () => {
506
+ clearTimeout(drop)
507
+ clearTimeout(verdict)
508
+ }
509
+ }, [])
510
+ const probe = (
511
+ <View style={styles.hiddenProbe}>
512
+ <AnimatePresence>
513
+ {keys.map((key) => (
514
+ <View key={key}>
515
+ <ConsumerProbe
516
+ events={eventsRef.current}
517
+ id={`-${key}`}
518
+ safeDelayMs={key === 'p1' ? 400 : 1300}
519
+ trackIsPresent={key === 'p1'}
520
+ />
521
+ </View>
522
+ ))}
523
+ </AnimatePresence>
524
+ </View>
525
+ )
526
+ return { row, probe }
527
+ }
528
+
529
+ export function PresenceChecksScreen() {
530
+ const consumerCheck = useConsumerDeferCheck()
531
+ const staleCheck = useStaleConsumerCheck()
532
+ const independentCheck = useIndependentDeferCheck()
533
+ const rows: Row[] = [
534
+ checkDropRetainRemove(),
535
+ checkCancelContinuity(),
536
+ checkEnterSuppression(),
537
+ checkWaitModeOrdering(),
538
+ checkDeferredExit(),
539
+ checkExitKeysGuard(),
540
+ checkPopLayoutLifecycle(),
541
+ checkDecorateChild(),
542
+ ...(consumerCheck.row === null ? [] : [consumerCheck.row]),
543
+ ...(staleCheck.row === null ? [] : [staleCheck.row]),
544
+ ...(independentCheck.row === null ? [] : [independentCheck.row]),
545
+ ]
546
+ const allDone =
547
+ consumerCheck.row !== null && staleCheck.row !== null && independentCheck.row !== null
548
+ const allPass = allDone && rows.every((row) => row.pass)
549
+
550
+ return (
551
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
552
+ <Text style={styles.title}>M2.3 presence checks</Text>
553
+ <Text style={styles.subtitle}>
554
+ binding semantics (deterministic) + live usePresence consumer
555
+ </Text>
556
+ <View style={[styles.banner, allPass ? styles.pass : allDone ? styles.fail : styles.pending]}>
557
+ <Text style={styles.bannerText}>{allDone ? (allPass ? 'PASS' : 'FAIL') : 'RUNNING'}</Text>
558
+ </View>
559
+ {consumerCheck.probe}
560
+ {staleCheck.probe}
561
+ {independentCheck.probe}
562
+ {allDone ? null : (
563
+ <View style={styles.row}>
564
+ <Text style={[styles.verdict, styles.dim]}>…</Text>
565
+ <View style={styles.rowBody}>
566
+ <Text style={styles.rowId}>live rows running…</Text>
567
+ <Text style={styles.rowDetail}>consumer-defer + stale-consumer probes in flight</Text>
568
+ </View>
569
+ </View>
570
+ )}
571
+ {rows.map((row) => (
572
+ <View key={row.id} style={styles.row}>
573
+ <Text style={[styles.verdict, row.pass ? styles.ok : styles.bad]}>
574
+ {row.pass ? 'PASS' : 'FAIL'}
575
+ </Text>
576
+ <View style={styles.rowBody}>
577
+ <Text style={styles.rowId}>{row.id}</Text>
578
+ <Text style={styles.rowDetail} numberOfLines={3}>
579
+ {row.detail}
580
+ </Text>
581
+ </View>
582
+ </View>
583
+ ))}
584
+ </ScrollView>
585
+ )
586
+ }
587
+
588
+ const styles = StyleSheet.create({
589
+ scroll: { flex: 1, backgroundColor: '#0b0b12' },
590
+ container: { paddingVertical: 72, paddingHorizontal: 20 },
591
+ title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
592
+ subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
593
+ banner: {
594
+ alignSelf: 'center',
595
+ paddingHorizontal: 36,
596
+ paddingVertical: 10,
597
+ borderRadius: 10,
598
+ marginTop: 18,
599
+ marginBottom: 10,
600
+ },
601
+ pass: { backgroundColor: '#1d7a3e' },
602
+ fail: { backgroundColor: '#a32633' },
603
+ pending: { backgroundColor: '#6b6b28' },
604
+ bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
605
+ row: { flexDirection: 'row', marginTop: 10 },
606
+ verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
607
+ ok: { color: '#4ade80' },
608
+ bad: { color: '#f87171' },
609
+ dim: { color: '#8a8aa0' },
610
+ rowBody: { flex: 1 },
611
+ rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
612
+ rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
613
+ hiddenProbe: { position: 'absolute', opacity: 0 },
614
+ })