@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,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
|
+
})
|