@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,839 @@
|
|
|
1
|
+
// U7b instant-transition device checks (the U7b packet's floor 3): the native card mirroring the
|
|
2
|
+
// pinned catalog useInstantTransition.tsx example — on mount, startInstantTransition(() =>
|
|
3
|
+
// setX(100)) against a MotionView initial={false} animate={{ x }} — plus the window's global
|
|
4
|
+
// scope, delay-zeroing, relock, and token-guard semantics as self-driven rows. Six claims:
|
|
5
|
+
//
|
|
6
|
+
// 1. MOUNT (the pin example verbatim) — the value reaches 100 with NO intermediate flight
|
|
7
|
+
// values (the generator is skipped entirely — NOT a 0-duration tween), and the same-tick
|
|
8
|
+
// read at the call still reads the origin: the commit lands on the driver's next update
|
|
9
|
+
// tick, never synchronously at the call. The commit's JS readback is observed by a rAF
|
|
10
|
+
// poll with a fail-closed deadline, NEVER a fixed frame budget: the pin's law covers the
|
|
11
|
+
// UI-side commit (the driver's next update tick), while this card measures the JS
|
|
12
|
+
// readback, which adds the command crossing, the t=0 frame (null dt — the pin's
|
|
13
|
+
// frame.update deferral), and the marshal's return — a device-dependent latency
|
|
14
|
+
// (cold-boot iOS 2026-08-18 landed past a fixed 5-frame chain, with the boot capture
|
|
15
|
+
// proving the commit by +1400ms; warm Android landed at frame 3). The landing frame/time
|
|
16
|
+
// is EVIDENCE; the gates are never-synchronous and no-flight.
|
|
17
|
+
// 2. PRESS + GLOBAL SCOPE — a button press (timeline-fired through the same handler — the iOS
|
|
18
|
+
// simulator has no tap injection, so the card self-drives) opens the window and retargets
|
|
19
|
+
// TWO boxes on DIFFERENT components: the window is global (any component, any motion
|
|
20
|
+
// value), so both commit instantly.
|
|
21
|
+
// 3. DELAY ZEROED — a third retarget inside the same press carries an AUTHORED
|
|
22
|
+
// { type: 'tween', duration: 0.6, delay: 0.5 }: the window's consult replaces it with the
|
|
23
|
+
// instant commit and zeroes the delay (the pin's global-flag asymmetry — the authored
|
|
24
|
+
// type:false honors a nonzero delay, the global flag does not). A +150ms sample — well
|
|
25
|
+
// before the authored 500ms — must already read the target.
|
|
26
|
+
// 4. THE RELOCK — a retarget started ~100ms post-press (past the two-frame relock) on an
|
|
27
|
+
// ordinary short tween (0.5s) FLIES: post-retarget committed samples must land STRICTLY
|
|
28
|
+
// BETWEEN origin and target — a stuck window commits instantly and shows ZERO
|
|
29
|
+
// intermediates (both 2026-08-18 platforms showed the flight). The terminal value's
|
|
30
|
+
// settle-edge marshal is awaited by a poll with a fail-closed deadline and its landing
|
|
31
|
+
// latency is DISCLOSED as evidence, never gated to a fixed offset: the settle frame's
|
|
32
|
+
// emission is unit-pinned (workletDriver.lanes "the settle frame emits its final
|
|
33
|
+
// change"), but its device delivery lags — both platforms read the last in-flight frame
|
|
34
|
+
// (88.8) at +900ms and the terminal 90.0 later, the 7a settle-notification finding's
|
|
35
|
+
// family. A terminal that never lands inside the deadline fails loud as the
|
|
36
|
+
// genuinely-missing-marshal class.
|
|
37
|
+
// 5. BACK-TO-BACK EXTENSION — two calls ONE FRAME apart; the second retarget still commits
|
|
38
|
+
// instantly: the second call bumped the render-count token, so the first call's stale
|
|
39
|
+
// unlock bails instead of closing the window the second call extended (the pin's own
|
|
40
|
+
// stale-token test, use-instant-transition.test.tsx:150-176). Each call's commit is
|
|
41
|
+
// observed by its own deadline poll — the double forced render lands the readback
|
|
42
|
+
// systematically later than the single-render press leg (the 2026-08-18 runs), so fixed
|
|
43
|
+
// frame budgets are evidence here too. A premature close shows as FLIGHT samples on the
|
|
44
|
+
// second box, and those stay gated.
|
|
45
|
+
// 6. CLEAN RUN — zero refusal reports through the console tap (the U5 pattern: console.error
|
|
46
|
+
// is the severity law's production error channel) across the whole timeline.
|
|
47
|
+
//
|
|
48
|
+
// Readback honesty: every x sample rides the element's own onUpdate (the driver's per-frame
|
|
49
|
+
// committed-values marshal) — the value the host actually received, never a JS-side mirror. A
|
|
50
|
+
// screenshot is the assertion; the console tap is evidence capture, never suppression.
|
|
51
|
+
|
|
52
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
53
|
+
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
|
54
|
+
import { MotionView } from '../../native/motion/MotionView'
|
|
55
|
+
import { ambientNativeSeverity } from '../../native/motion/severity'
|
|
56
|
+
import { useInstantTransition } from '../../native/motion/useInstantTransition'
|
|
57
|
+
|
|
58
|
+
const ROW_MOUNT = 'mount (pin example verbatim): instant commit — never synchronous, no flight'
|
|
59
|
+
const ROW_PRESS = 'press + global scope: two boxes on different components both commit instantly'
|
|
60
|
+
const ROW_DELAY = 'delay zeroed: an authored delay 0.5 inside the window commits without waiting'
|
|
61
|
+
const ROW_RELOCK = 'the relock: a retarget 100ms post-press on a 0.5s tween FLIES (window closed)'
|
|
62
|
+
const ROW_B2B = 'back-to-back: two calls one frame apart — the second retarget still commits'
|
|
63
|
+
const ROW_CLEAN = 'clean run: zero refusal reports through the console tap'
|
|
64
|
+
const ROW_ORDER = [ROW_MOUNT, ROW_PRESS, ROW_DELAY, ROW_RELOCK, ROW_B2B, ROW_CLEAN] as const
|
|
65
|
+
|
|
66
|
+
// Every subject starts at x = 0; the targets are distinct so a crossed wire reads as a wrong
|
|
67
|
+
// value, never a coincidence.
|
|
68
|
+
const MOUNT_TARGET = 100
|
|
69
|
+
const PRESS_A_TARGET = 60
|
|
70
|
+
const PRESS_B_TARGET = -60
|
|
71
|
+
const DELAY_TARGET = 80
|
|
72
|
+
const RELOCK_TARGET = 90
|
|
73
|
+
const B2B_A_TARGET = -70
|
|
74
|
+
const B2B_B_TARGET = 70
|
|
75
|
+
// Committed-x equality / flight margins (device px): the instant commit lands exactly on the
|
|
76
|
+
// target; a "flight" sample must clear 2px off both endpoints to count.
|
|
77
|
+
const EPS = 0.5
|
|
78
|
+
const FLIGHT_MARGIN = 2
|
|
79
|
+
|
|
80
|
+
// Fail-closed observation deadlines (ms after the triggering call/retarget). The instant-commit
|
|
81
|
+
// readback is a few bridge frames warm and ran past a fixed 5-frame chain at cold-boot iOS;
|
|
82
|
+
// the settle-edge terminal of the 0.5s relock tween landed past +900ms on both 2026-08-18
|
|
83
|
+
// platforms. The deadlines sit far past every observed landing; missing one is a loud fail,
|
|
84
|
+
// never a silent wait.
|
|
85
|
+
const COMMIT_DEADLINE_MS = 2000
|
|
86
|
+
const SETTLE_DEADLINE_MS = 3000
|
|
87
|
+
|
|
88
|
+
// Timeline (ms). Dwells make every staged state capturable: the boot dwell (0–2400) holds the
|
|
89
|
+
// pin example's post-mount state (the mount box committed at x=100, every other subject at its
|
|
90
|
+
// origin); the press fires at 2400, the delay leg's early sample at +150ms and the relock
|
|
91
|
+
// retarget at +100ms; the dwell 3400–5400 holds both press boxes and the delay box committed
|
|
92
|
+
// while the relock box's flight settles (its terminal marshal lands inside this window); the
|
|
93
|
+
// back-to-back pair fires at 5400; the final verdict holds from ~8000 on. Rows 1, 4, and 5
|
|
94
|
+
// SELF-FINALIZE when their deadline polls resolve (landing or deadline); rows 2, 3, and 6
|
|
95
|
+
// finalize at fixed offsets (3400 / 7800) — proven green on both 2026-08-18 platforms.
|
|
96
|
+
const T_PRESS = 2400
|
|
97
|
+
const T_RELOCK_RETARGET = 2500
|
|
98
|
+
const T_DELAY_EARLY = 2550
|
|
99
|
+
const T_PRESS_FINALIZE = 3400
|
|
100
|
+
const T_B2B = 5400
|
|
101
|
+
const T_CLEAN = 7800
|
|
102
|
+
const T_DONE = 8000
|
|
103
|
+
|
|
104
|
+
interface RowResult {
|
|
105
|
+
readonly pass: boolean
|
|
106
|
+
readonly detail: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface XSample {
|
|
110
|
+
readonly at: number
|
|
111
|
+
readonly value: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface SubjectTrace {
|
|
115
|
+
latest: number
|
|
116
|
+
readonly samples: XSample[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A deadline poll's resolution: the rAF frame count and the mount-relative time at which the
|
|
120
|
+
// JS readback first read the target — or null when the deadline passed first.
|
|
121
|
+
interface Landing {
|
|
122
|
+
readonly frames: number
|
|
123
|
+
readonly at: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type SubjectKey = 'mount' | 'pressA' | 'pressB' | 'delay' | 'relock' | 'b2bA' | 'b2bB'
|
|
127
|
+
|
|
128
|
+
const freshTrace = (): SubjectTrace => ({ latest: 0, samples: [] })
|
|
129
|
+
|
|
130
|
+
function freshTraces(): Record<SubjectKey, SubjectTrace> {
|
|
131
|
+
return {
|
|
132
|
+
mount: freshTrace(),
|
|
133
|
+
pressA: freshTrace(),
|
|
134
|
+
pressB: freshTrace(),
|
|
135
|
+
delay: freshTrace(),
|
|
136
|
+
relock: freshTrace(),
|
|
137
|
+
b2bA: freshTrace(),
|
|
138
|
+
b2bB: freshTrace(),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readX(latest: Readonly<Record<string, string | number>>): number | null {
|
|
143
|
+
const raw = latest['x']
|
|
144
|
+
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
|
145
|
+
return Number.isFinite(value) ? value : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A post-retarget sample strictly between the endpoints is a FLIGHT — under an instant commit
|
|
149
|
+
// none may exist. lo/hi are the sorted endpoints.
|
|
150
|
+
function flightSample(
|
|
151
|
+
trace: SubjectTrace,
|
|
152
|
+
stamp: number,
|
|
153
|
+
lo: number,
|
|
154
|
+
hi: number,
|
|
155
|
+
): XSample | undefined {
|
|
156
|
+
return trace.samples.find(
|
|
157
|
+
(s) => s.at >= stamp && s.value > lo + FLIGHT_MARGIN && s.value < hi - FLIGHT_MARGIN,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The first rAF-chain frame (1-based) reading the target, or 0 when it never lands.
|
|
162
|
+
function targetFrame(chain: readonly number[], target: number): number {
|
|
163
|
+
return chain.findIndex((value) => Math.abs(value - target) <= EPS) + 1
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The rows 1/4/5 observation primitive: poll per rAF until the trace's latest committed value
|
|
167
|
+
// reads `target` or the fail-closed deadline passes. Replaces the fixed 5-frame chains — the
|
|
168
|
+
// commit/settle JS-readback latency is device-dependent, so the landing is measured EVIDENCE
|
|
169
|
+
// and the deadline is the only time gate. Fires `onResolve` exactly once.
|
|
170
|
+
function pollLanding(
|
|
171
|
+
trace: SubjectTrace,
|
|
172
|
+
target: number,
|
|
173
|
+
deadlineAt: number,
|
|
174
|
+
now: () => number,
|
|
175
|
+
trackRaf: (id: ReturnType<typeof requestAnimationFrame>) => void,
|
|
176
|
+
onResolve: (landing: Landing | null) => void,
|
|
177
|
+
): void {
|
|
178
|
+
let frames = 0
|
|
179
|
+
const step = (): void => {
|
|
180
|
+
frames += 1
|
|
181
|
+
if (Math.abs(trace.latest - target) <= EPS) {
|
|
182
|
+
onResolve({ frames, at: now() })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (now() >= deadlineAt) {
|
|
186
|
+
onResolve(null)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
trackRaf(requestAnimationFrame(step))
|
|
190
|
+
}
|
|
191
|
+
trackRaf(requestAnimationFrame(step))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fmt(value: number | null): string {
|
|
195
|
+
return value === null ? 'unavailable' : value.toFixed(1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Row 2's two subjects are DIFFERENT components by construction — the window is global, so a
|
|
199
|
+
// per-component authority would commit only one of them.
|
|
200
|
+
function PressSubjectA({
|
|
201
|
+
target,
|
|
202
|
+
onSample,
|
|
203
|
+
}: {
|
|
204
|
+
readonly target: number
|
|
205
|
+
readonly onSample: (value: number) => void
|
|
206
|
+
}) {
|
|
207
|
+
return (
|
|
208
|
+
<MotionView
|
|
209
|
+
animate={{ x: target }}
|
|
210
|
+
initial={false}
|
|
211
|
+
onUpdate={(latest) => {
|
|
212
|
+
const value = readX(latest)
|
|
213
|
+
if (value !== null) onSample(value)
|
|
214
|
+
}}
|
|
215
|
+
style={styles.pressBoxA}
|
|
216
|
+
accessibilityLabel="u7b-press-subject-a"
|
|
217
|
+
/>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function PressSubjectB({
|
|
222
|
+
target,
|
|
223
|
+
onSample,
|
|
224
|
+
}: {
|
|
225
|
+
readonly target: number
|
|
226
|
+
readonly onSample: (value: number) => void
|
|
227
|
+
}) {
|
|
228
|
+
return (
|
|
229
|
+
<MotionView
|
|
230
|
+
animate={{ x: target }}
|
|
231
|
+
initial={false}
|
|
232
|
+
onUpdate={(latest) => {
|
|
233
|
+
const value = readX(latest)
|
|
234
|
+
if (value !== null) onSample(value)
|
|
235
|
+
}}
|
|
236
|
+
style={styles.pressBoxB}
|
|
237
|
+
accessibilityLabel="u7b-press-subject-b"
|
|
238
|
+
/>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function InstantTransitionChecksScreen() {
|
|
243
|
+
const severity = ambientNativeSeverity()
|
|
244
|
+
const startInstantTransition = useInstantTransition()
|
|
245
|
+
|
|
246
|
+
// The pin example's exact state: `const [x, setX] = useState(0)`.
|
|
247
|
+
const [x, setX] = useState(0)
|
|
248
|
+
const [pressTargetA, setPressTargetA] = useState(0)
|
|
249
|
+
const [pressTargetB, setPressTargetB] = useState(0)
|
|
250
|
+
const [delayTarget, setDelayTarget] = useState(0)
|
|
251
|
+
const [relockTarget, setRelockTarget] = useState(0)
|
|
252
|
+
const [b2bTargetA, setB2bTargetA] = useState(0)
|
|
253
|
+
const [b2bTargetB, setB2bTargetB] = useState(0)
|
|
254
|
+
const [pressCount, setPressCount] = useState(0)
|
|
255
|
+
const [stage, setStage] = useState('boot: pin example mounted (initial={false}), window firing')
|
|
256
|
+
const [live, setLive] = useState<Partial<Record<SubjectKey, string>>>({})
|
|
257
|
+
const [rows, setRows] = useState<Partial<Record<(typeof ROW_ORDER)[number], RowResult>>>({})
|
|
258
|
+
|
|
259
|
+
const mountStampRef = useRef(Date.now())
|
|
260
|
+
const tracesRef = useRef<Record<SubjectKey, SubjectTrace> | null>(null)
|
|
261
|
+
if (tracesRef.current === null) tracesRef.current = freshTraces()
|
|
262
|
+
|
|
263
|
+
// Row 1 evidence: the same-tick read at the mount call (the poll reports the landing itself).
|
|
264
|
+
const mountSameTickRef = useRef<number | null>(null)
|
|
265
|
+
|
|
266
|
+
// Row 2/3 evidence: the press stamp, the same-tick reads, and the shared post-press chain.
|
|
267
|
+
const pressStampRef = useRef(0)
|
|
268
|
+
const pressSameTickRef = useRef<readonly number[] | null>(null)
|
|
269
|
+
const pressChainRef = useRef<readonly { a: number; b: number; d: number }[]>([])
|
|
270
|
+
const delayEarlyRef = useRef<number | null>(null)
|
|
271
|
+
|
|
272
|
+
// Row 5 evidence: both call stamps, both same-tick reads, both poll resolutions
|
|
273
|
+
// (undefined = the poll is still running; null = the deadline passed first).
|
|
274
|
+
const b2bStampARef = useRef(0)
|
|
275
|
+
const b2bStampBRef = useRef(0)
|
|
276
|
+
const b2bSameTickARef = useRef<number | null>(null)
|
|
277
|
+
const b2bSameTickBRef = useRef<number | null>(null)
|
|
278
|
+
const b2bLandingARef = useRef<Landing | null | undefined>(undefined)
|
|
279
|
+
const b2bLandingBRef = useRef<Landing | null | undefined>(undefined)
|
|
280
|
+
|
|
281
|
+
const rafsRef = useRef<ReturnType<typeof requestAnimationFrame>[]>([])
|
|
282
|
+
const reportsRef = useRef<string[]>([])
|
|
283
|
+
|
|
284
|
+
const trackRaf = (id: ReturnType<typeof requestAnimationFrame>): void => {
|
|
285
|
+
rafsRef.current.push(id)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const record = useCallback((key: SubjectKey, value: number): void => {
|
|
289
|
+
const trace = tracesRef.current![key]
|
|
290
|
+
trace.latest = value
|
|
291
|
+
trace.samples.push({ at: Date.now() - mountStampRef.current, value })
|
|
292
|
+
setLive((previous) => ({ ...previous, [key]: value.toFixed(1) }))
|
|
293
|
+
}, [])
|
|
294
|
+
|
|
295
|
+
const finalize = (id: (typeof ROW_ORDER)[number], pass: boolean, detail: string): void => {
|
|
296
|
+
setRows((previous) => ({ ...previous, [id]: { pass, detail } }))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 1. MOUNT — the pin example verbatim (dev/react/src/examples/useInstantTransition.tsx:14-16):
|
|
300
|
+
// on mount, startInstantTransition(() => setX(100)). The instrumentation around the call
|
|
301
|
+
// does not touch its semantics: the same-tick read proves the commit is NOT synchronous at
|
|
302
|
+
// the call, and the deadline poll observes when the commit's JS readback lands.
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
const traces = tracesRef.current!
|
|
305
|
+
const now = (): number => Date.now() - mountStampRef.current
|
|
306
|
+
const stamp = now()
|
|
307
|
+
startInstantTransition(() => setX(MOUNT_TARGET))
|
|
308
|
+
mountSameTickRef.current = traces.mount.latest
|
|
309
|
+
pollLanding(
|
|
310
|
+
traces.mount,
|
|
311
|
+
MOUNT_TARGET,
|
|
312
|
+
stamp + COMMIT_DEADLINE_MS,
|
|
313
|
+
now,
|
|
314
|
+
trackRaf,
|
|
315
|
+
(landing) => {
|
|
316
|
+
const sameTick = mountSameTickRef.current
|
|
317
|
+
const post = traces.mount.samples.filter((s) => s.at >= stamp)
|
|
318
|
+
const postText = post.map((s) => s.value.toFixed(1)).join(', ')
|
|
319
|
+
const flight = flightSample(traces.mount, stamp, 0, MOUNT_TARGET)
|
|
320
|
+
let bad: string | null = null
|
|
321
|
+
if (sameTick === null || Math.abs(sameTick - 0) > EPS) {
|
|
322
|
+
bad = `the same-tick read at the call reads ${fmt(sameTick)}, want origin 0 — a synchronous commit inside startInstantTransition`
|
|
323
|
+
} else if (landing === null) {
|
|
324
|
+
bad = `the commit's JS readback never landed within the ${COMMIT_DEADLINE_MS}ms deadline (latest ${traces.mount.latest.toFixed(1)}; post-call onUpdates [${postText || 'none'}])`
|
|
325
|
+
} else if (flight !== undefined) {
|
|
326
|
+
bad = `a post-call sample reads ${flight.value.toFixed(1)} at +${flight.at.toFixed(0)}ms — that is a flight, not an instant commit (a generator ran)`
|
|
327
|
+
}
|
|
328
|
+
finalize(
|
|
329
|
+
ROW_MOUNT,
|
|
330
|
+
bad === null,
|
|
331
|
+
bad ??
|
|
332
|
+
`same-tick read ${sameTick!.toFixed(1)} = origin (the commit is NOT synchronous at ` +
|
|
333
|
+
`the call); target ${MOUNT_TARGET} committed — the JS readback landed at ` +
|
|
334
|
+
`+${landing!.at.toFixed(0)}ms (rAF frame ${landing!.frames} of the poll); ` +
|
|
335
|
+
`post-call onUpdates [${postText}] — no flight. The landing latency is the ` +
|
|
336
|
+
'device-dependent readback of the UI-side next-tick commit (command crossing + ' +
|
|
337
|
+
'the null-dt t=0 frame + the marshal return) — EVIDENCE, never the gate; the ' +
|
|
338
|
+
'gates are never-synchronous and no-flight',
|
|
339
|
+
)
|
|
340
|
+
setStage('mount row finalized — press fires next (two components + the delay box)')
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
// The hook's callback is stable by contract; the mount leg runs once per mount.
|
|
344
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
345
|
+
}, [])
|
|
346
|
+
|
|
347
|
+
// The press handler — the Pressable below and the timeline both fire THIS: one handler, so
|
|
348
|
+
// the self-driven run exercises the same path a tap would.
|
|
349
|
+
const firePress = useCallback((): void => {
|
|
350
|
+
const traces = tracesRef.current!
|
|
351
|
+
pressStampRef.current = Date.now() - mountStampRef.current
|
|
352
|
+
pressChainRef.current = []
|
|
353
|
+
startInstantTransition(() => {
|
|
354
|
+
setPressTargetA(PRESS_A_TARGET)
|
|
355
|
+
setPressTargetB(PRESS_B_TARGET)
|
|
356
|
+
setDelayTarget(DELAY_TARGET)
|
|
357
|
+
})
|
|
358
|
+
pressSameTickRef.current = [traces.pressA.latest, traces.pressB.latest, traces.delay.latest]
|
|
359
|
+
setPressCount((count) => count + 1)
|
|
360
|
+
let frame = 0
|
|
361
|
+
const step = (): void => {
|
|
362
|
+
frame += 1
|
|
363
|
+
pressChainRef.current = [
|
|
364
|
+
...pressChainRef.current,
|
|
365
|
+
{ a: traces.pressA.latest, b: traces.pressB.latest, d: traces.delay.latest },
|
|
366
|
+
]
|
|
367
|
+
if (frame < 5) trackRaf(requestAnimationFrame(step))
|
|
368
|
+
}
|
|
369
|
+
trackRaf(requestAnimationFrame(step))
|
|
370
|
+
}, [startInstantTransition])
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const mountStamp = mountStampRef.current
|
|
374
|
+
const now = (): number => Date.now() - mountStamp
|
|
375
|
+
const traces = tracesRef.current!
|
|
376
|
+
|
|
377
|
+
// The production error channel tap (the U5 pattern): refusal reports ride consoleReporter →
|
|
378
|
+
// console.error. Installed for this screen's lifetime only; everything passes through.
|
|
379
|
+
const originalError = console.error
|
|
380
|
+
console.error = (...args: unknown[]) => {
|
|
381
|
+
for (const arg of args) {
|
|
382
|
+
const message = arg instanceof Error ? arg.message : typeof arg === 'string' ? arg : null
|
|
383
|
+
if (message !== null && /motion/i.test(message)) reportsRef.current.push(message)
|
|
384
|
+
}
|
|
385
|
+
originalError(...args)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const timers: ReturnType<typeof setTimeout>[] = []
|
|
389
|
+
const at = (ms: number, run: () => void): void => {
|
|
390
|
+
timers.push(setTimeout(run, ms))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 2/3. THE PRESS — one handler opens the window and retargets both components plus the
|
|
394
|
+
// authored-delay box.
|
|
395
|
+
at(T_PRESS, () => {
|
|
396
|
+
firePress()
|
|
397
|
+
setStage(
|
|
398
|
+
`press fired: ${PRESS_A_TARGET} / ${PRESS_B_TARGET} on two components, ` +
|
|
399
|
+
`${DELAY_TARGET} on the authored delay-0.5 box — all inside the window`,
|
|
400
|
+
)
|
|
401
|
+
})
|
|
402
|
+
at(T_DELAY_EARLY, () => {
|
|
403
|
+
delayEarlyRef.current = traces.delay.latest
|
|
404
|
+
setStage(
|
|
405
|
+
'delay leg: +150ms sample taken (well before the authored 500ms — must read the target)',
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// 4. THE RELOCK — ~100ms post-press, past the two-frame relock: an ordinary short tween
|
|
410
|
+
// must FLY. The row self-finalizes when the terminal's settle-edge marshal lands (or
|
|
411
|
+
// the deadline passes): the flight witness is the existence of intermediate committed
|
|
412
|
+
// samples — immune to the flight's start latency, unlike a fixed-offset mid sample.
|
|
413
|
+
at(T_RELOCK_RETARGET, () => {
|
|
414
|
+
const stamp = now()
|
|
415
|
+
setRelockTarget(RELOCK_TARGET)
|
|
416
|
+
setStage(`relock leg: 0 → ${RELOCK_TARGET} on a plain 0.5s tween, started 100ms post-press`)
|
|
417
|
+
pollLanding(
|
|
418
|
+
traces.relock,
|
|
419
|
+
RELOCK_TARGET,
|
|
420
|
+
stamp + SETTLE_DEADLINE_MS,
|
|
421
|
+
now,
|
|
422
|
+
trackRaf,
|
|
423
|
+
(landing) => {
|
|
424
|
+
const post = traces.relock.samples.filter((s) => s.at >= stamp)
|
|
425
|
+
const interior = post.filter(
|
|
426
|
+
(s) => s.value > 0 + FLIGHT_MARGIN && s.value < RELOCK_TARGET - FLIGHT_MARGIN,
|
|
427
|
+
)
|
|
428
|
+
const gap = stamp - pressStampRef.current
|
|
429
|
+
let bad: string | null = null
|
|
430
|
+
if (interior.length === 0) {
|
|
431
|
+
bad =
|
|
432
|
+
landing !== null
|
|
433
|
+
? `ZERO intermediate committed samples — the retarget committed INSTANTLY ` +
|
|
434
|
+
`${gap.toFixed(0)}ms post-press (readback +${(landing.at - stamp).toFixed(0)}ms): ` +
|
|
435
|
+
'the window is stuck open (the relock never fired)'
|
|
436
|
+
: `no intermediate samples AND the terminal never landed within the ` +
|
|
437
|
+
`${SETTLE_DEADLINE_MS}ms deadline (latest ${traces.relock.latest.toFixed(1)}) — ` +
|
|
438
|
+
'the post-window tween did not fly'
|
|
439
|
+
} else if (landing === null) {
|
|
440
|
+
bad =
|
|
441
|
+
`the flight flew (${interior.length} intermediate samples) but the terminal ` +
|
|
442
|
+
`${RELOCK_TARGET}'s marshal never landed within the ${SETTLE_DEADLINE_MS}ms ` +
|
|
443
|
+
`deadline (latest ${traces.relock.latest.toFixed(1)}) — a genuinely-missing ` +
|
|
444
|
+
"settle-edge marshal (the 7a finding-B class), not the card's timing"
|
|
445
|
+
}
|
|
446
|
+
finalize(
|
|
447
|
+
ROW_RELOCK,
|
|
448
|
+
bad === null,
|
|
449
|
+
bad ??
|
|
450
|
+
`the retarget FLEW: ${interior.length} intermediate committed samples ` +
|
|
451
|
+
`(${interior[0]!.value.toFixed(1)} → ${interior[interior.length - 1]!.value.toFixed(1)}, ` +
|
|
452
|
+
`strictly between 0 and ${RELOCK_TARGET}) — a stuck window commits instantly with ` +
|
|
453
|
+
`ZERO intermediates; the terminal ${RELOCK_TARGET} landed at +${(landing!.at - stamp).toFixed(0)}ms ` +
|
|
454
|
+
'post-retarget (the 500ms flight + the settle-edge delivery — the landing ' +
|
|
455
|
+
'latency is DISCLOSED, not gated to a fixed offset: the settle frame’s emission ' +
|
|
456
|
+
'is unit-pinned, its device delivery lags — the 7a settle-notification ' +
|
|
457
|
+
`finding’s family); the window closed on schedule (the retarget fired ` +
|
|
458
|
+
`${gap.toFixed(0)}ms post-press, past the two-post-render-frame relock)`,
|
|
459
|
+
)
|
|
460
|
+
setStage('relock row finalized — back-to-back pair next')
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
at(T_PRESS_FINALIZE, () => {
|
|
466
|
+
const pressStamp = pressStampRef.current
|
|
467
|
+
const chain = pressChainRef.current
|
|
468
|
+
const sameTick = pressSameTickRef.current
|
|
469
|
+
const chainText = chain
|
|
470
|
+
.map((c, i) => `f${i + 1}=(${c.a.toFixed(1)},${c.b.toFixed(1)},${c.d.toFixed(1)})`)
|
|
471
|
+
.join(' ')
|
|
472
|
+
|
|
473
|
+
// Row 2 — both components committed instantly.
|
|
474
|
+
const frameA = targetFrame(
|
|
475
|
+
chain.map((c) => c.a),
|
|
476
|
+
PRESS_A_TARGET,
|
|
477
|
+
)
|
|
478
|
+
const frameB = targetFrame(
|
|
479
|
+
chain.map((c) => c.b),
|
|
480
|
+
PRESS_B_TARGET,
|
|
481
|
+
)
|
|
482
|
+
const flightA = flightSample(traces.pressA, pressStamp, 0, PRESS_A_TARGET)
|
|
483
|
+
const flightB = flightSample(traces.pressB, pressStamp, PRESS_B_TARGET, 0)
|
|
484
|
+
const committedA = Math.abs(traces.pressA.latest - PRESS_A_TARGET) <= EPS
|
|
485
|
+
const committedB = Math.abs(traces.pressB.latest - PRESS_B_TARGET) <= EPS
|
|
486
|
+
let badPress: string | null = null
|
|
487
|
+
if (sameTick === null || sameTick.some((value) => Math.abs(value - 0) > EPS)) {
|
|
488
|
+
badPress = `a same-tick read at the press reads [${sameTick?.map((v) => v.toFixed(1)).join(', ') ?? 'none'}], want origins [0, 0, 0] — a synchronous commit`
|
|
489
|
+
} else if (frameA === 0 || frameA > 4 || frameB === 0 || frameB > 4) {
|
|
490
|
+
badPress = `a target never landed within the 4-frame band (A frame ${frameA}, B frame ${frameB}; chain: ${chainText || 'empty'})`
|
|
491
|
+
} else if (flightA !== undefined) {
|
|
492
|
+
badPress = `component A flew (sample ${flightA.value.toFixed(1)} at +${flightA.at.toFixed(0)}ms) — the window did not cover it`
|
|
493
|
+
} else if (flightB !== undefined) {
|
|
494
|
+
badPress = `component B flew (sample ${flightB.value.toFixed(1)} at +${flightB.at.toFixed(0)}ms) — the window is not global`
|
|
495
|
+
} else if (!committedA || !committedB) {
|
|
496
|
+
badPress = `committed values A=${traces.pressA.latest.toFixed(1)} B=${traces.pressB.latest.toFixed(1)}, want ${PRESS_A_TARGET} / ${PRESS_B_TARGET}`
|
|
497
|
+
}
|
|
498
|
+
finalize(
|
|
499
|
+
ROW_PRESS,
|
|
500
|
+
badPress === null,
|
|
501
|
+
badPress ??
|
|
502
|
+
`same-tick reads [${sameTick!.map((v) => v.toFixed(1)).join(', ')}] = origins; ` +
|
|
503
|
+
`component A committed ${PRESS_A_TARGET} at rAF frame ${frameA}, component B ` +
|
|
504
|
+
`committed ${PRESS_B_TARGET} at frame ${frameB} (chain A/B/D: ${chainText}); no ` +
|
|
505
|
+
'flight on either — the window is GLOBAL across components',
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
// Row 3 — the authored 500ms delay was zeroed: the +150ms sample already reads the
|
|
509
|
+
// target, and the commit landed inside the same band as the undelayed boxes.
|
|
510
|
+
const frameD = targetFrame(
|
|
511
|
+
chain.map((c) => c.d),
|
|
512
|
+
DELAY_TARGET,
|
|
513
|
+
)
|
|
514
|
+
const early = delayEarlyRef.current
|
|
515
|
+
const flightD = flightSample(traces.delay, pressStamp, 0, DELAY_TARGET)
|
|
516
|
+
const committedD = Math.abs(traces.delay.latest - DELAY_TARGET) <= EPS
|
|
517
|
+
let badDelay: string | null = null
|
|
518
|
+
if (early === null || Math.abs(early - DELAY_TARGET) > EPS) {
|
|
519
|
+
badDelay = `the +150ms sample reads ${fmt(early)}, want target ${DELAY_TARGET} — the authored 500ms delay was NOT zeroed inside the window`
|
|
520
|
+
} else if (frameD === 0 || frameD > 4) {
|
|
521
|
+
badDelay = `the delayed box's target never landed within the 4-frame band (chain: ${chainText || 'empty'})`
|
|
522
|
+
} else if (flightD !== undefined) {
|
|
523
|
+
badDelay = `the delayed box flew (sample ${flightD.value.toFixed(1)}) — a generator ran despite the window`
|
|
524
|
+
} else if (!committedD) {
|
|
525
|
+
badDelay = `the delayed box committed ${traces.delay.latest.toFixed(1)}, want ${DELAY_TARGET}`
|
|
526
|
+
}
|
|
527
|
+
finalize(
|
|
528
|
+
ROW_DELAY,
|
|
529
|
+
badDelay === null,
|
|
530
|
+
badDelay ??
|
|
531
|
+
`+150ms sample ${early!.toFixed(1)} = target ${DELAY_TARGET} (well before the ` +
|
|
532
|
+
`authored 500ms), committed at rAF frame ${frameD} — the window zeroed the authored ` +
|
|
533
|
+
"{ type: 'tween', duration: 0.6, delay: 0.5 } (the pin's global-flag asymmetry: " +
|
|
534
|
+
'the authored type:false honors delay, the global flag does not)',
|
|
535
|
+
)
|
|
536
|
+
setStage('press rows finalized — the relock poll and the back-to-back pair remain')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
// 5. BACK-TO-BACK — two calls one frame apart; the token guard keeps the window open for
|
|
540
|
+
// the second. Each call's commit lands on its own deadline poll; the row finalizes once
|
|
541
|
+
// BOTH polls resolved.
|
|
542
|
+
const finalizeB2B = (): void => {
|
|
543
|
+
const landingA = b2bLandingARef.current
|
|
544
|
+
const landingB = b2bLandingBRef.current
|
|
545
|
+
if (landingA === undefined || landingB === undefined) return
|
|
546
|
+
const stampA = b2bStampARef.current
|
|
547
|
+
const stampB = b2bStampBRef.current
|
|
548
|
+
const sameTickA = b2bSameTickARef.current
|
|
549
|
+
const sameTickB = b2bSameTickBRef.current
|
|
550
|
+
const postA = traces.b2bA.samples.filter((s) => s.at >= stampA).map((s) => s.value.toFixed(1))
|
|
551
|
+
const postB = traces.b2bB.samples.filter((s) => s.at >= stampB).map((s) => s.value.toFixed(1))
|
|
552
|
+
const flightA = flightSample(traces.b2bA, stampA, B2B_A_TARGET, 0)
|
|
553
|
+
const flightB = flightSample(traces.b2bB, stampB, 0, B2B_B_TARGET)
|
|
554
|
+
let bad: string | null = null
|
|
555
|
+
if (
|
|
556
|
+
sameTickA === null ||
|
|
557
|
+
Math.abs(sameTickA - 0) > EPS ||
|
|
558
|
+
sameTickB === null ||
|
|
559
|
+
Math.abs(sameTickB - 0) > EPS
|
|
560
|
+
) {
|
|
561
|
+
bad = `a same-tick read reads A=${fmt(sameTickA)} B=${fmt(sameTickB)}, want origins 0 — a synchronous commit`
|
|
562
|
+
} else if (landingA === null) {
|
|
563
|
+
bad = `call A's commit readback never landed within the ${COMMIT_DEADLINE_MS}ms deadline (post-call onUpdates [${postA.join(', ') || 'none'}])`
|
|
564
|
+
} else if (landingB === null) {
|
|
565
|
+
bad = `call B's commit readback never landed within the ${COMMIT_DEADLINE_MS}ms deadline (post-call onUpdates [${postB.join(', ') || 'none'}])`
|
|
566
|
+
} else if (flightA !== undefined) {
|
|
567
|
+
bad = `call A's box flew (sample ${flightA.value.toFixed(1)})`
|
|
568
|
+
} else if (flightB !== undefined) {
|
|
569
|
+
bad = `call B's box flew (sample ${flightB.value.toFixed(1)} at +${flightB.at.toFixed(0)}ms) — the token guard failed: the stale unlock closed the window under the second call`
|
|
570
|
+
}
|
|
571
|
+
finalize(
|
|
572
|
+
ROW_B2B,
|
|
573
|
+
bad === null,
|
|
574
|
+
bad ??
|
|
575
|
+
`call A committed ${B2B_A_TARGET} (readback +${(landingA!.at - stampA).toFixed(0)}ms, ` +
|
|
576
|
+
`rAF frame ${landingA!.frames} of its poll); call B — one frame later — committed ` +
|
|
577
|
+
`${B2B_B_TARGET} (+${(landingB!.at - stampB).toFixed(0)}ms, frame ${landingB!.frames}); ` +
|
|
578
|
+
`post-call onUpdates A[${postA.join(', ')}] B[${postB.join(', ')}] — no flight on ` +
|
|
579
|
+
"either: the second call's token kept the window open (the stale first unlock " +
|
|
580
|
+
'bailed)',
|
|
581
|
+
)
|
|
582
|
+
setStage('back-to-back row finalized — clean-run verdict next')
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
at(T_B2B, () => {
|
|
586
|
+
b2bStampARef.current = now()
|
|
587
|
+
b2bLandingARef.current = undefined
|
|
588
|
+
b2bLandingBRef.current = undefined
|
|
589
|
+
startInstantTransition(() => setB2bTargetA(B2B_A_TARGET))
|
|
590
|
+
b2bSameTickARef.current = traces.b2bA.latest
|
|
591
|
+
pollLanding(
|
|
592
|
+
traces.b2bA,
|
|
593
|
+
B2B_A_TARGET,
|
|
594
|
+
b2bStampARef.current + COMMIT_DEADLINE_MS,
|
|
595
|
+
now,
|
|
596
|
+
trackRaf,
|
|
597
|
+
(landing) => {
|
|
598
|
+
b2bLandingARef.current = landing
|
|
599
|
+
finalizeB2B()
|
|
600
|
+
},
|
|
601
|
+
)
|
|
602
|
+
// The second call ONE FRAME later — the pin's stale-token shape: the first call's unlock
|
|
603
|
+
// (scheduled two frames out) must bail against the newer token, or it closes the window
|
|
604
|
+
// this call extended.
|
|
605
|
+
trackRaf(
|
|
606
|
+
requestAnimationFrame(() => {
|
|
607
|
+
b2bStampBRef.current = now()
|
|
608
|
+
startInstantTransition(() => setB2bTargetB(B2B_B_TARGET))
|
|
609
|
+
b2bSameTickBRef.current = traces.b2bB.latest
|
|
610
|
+
pollLanding(
|
|
611
|
+
traces.b2bB,
|
|
612
|
+
B2B_B_TARGET,
|
|
613
|
+
b2bStampBRef.current + COMMIT_DEADLINE_MS,
|
|
614
|
+
now,
|
|
615
|
+
trackRaf,
|
|
616
|
+
(landing) => {
|
|
617
|
+
b2bLandingBRef.current = landing
|
|
618
|
+
finalizeB2B()
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
}),
|
|
622
|
+
)
|
|
623
|
+
setStage(`back-to-back: call A → ${B2B_A_TARGET}, call B one frame later → ${B2B_B_TARGET}`)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
// 6. CLEAN RUN — the console tap saw zero refusal reports across the whole timeline.
|
|
627
|
+
at(T_CLEAN, () => {
|
|
628
|
+
const reports = reportsRef.current
|
|
629
|
+
finalize(
|
|
630
|
+
ROW_CLEAN,
|
|
631
|
+
reports.length === 0,
|
|
632
|
+
reports.length === 0
|
|
633
|
+
? `0 refusal reports through the console tap across ${now().toFixed(0)}ms of ` +
|
|
634
|
+
'the mount commit, the press, the delay/relock legs, and the back-to-back pair'
|
|
635
|
+
: `${reports.length} report(s): ${reports
|
|
636
|
+
.map((message) => `"${message.slice(0, 80)}…"`)
|
|
637
|
+
.join(' | ')}`,
|
|
638
|
+
)
|
|
639
|
+
})
|
|
640
|
+
at(T_DONE, () => setStage('done'))
|
|
641
|
+
|
|
642
|
+
return () => {
|
|
643
|
+
for (const timer of timers) clearTimeout(timer)
|
|
644
|
+
for (const raf of rafsRef.current) cancelAnimationFrame(raf)
|
|
645
|
+
console.error = originalError
|
|
646
|
+
}
|
|
647
|
+
// All captured values are refs or stable setters; the timeline runs once per mount.
|
|
648
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
649
|
+
}, [])
|
|
650
|
+
|
|
651
|
+
const done = ROW_ORDER.every((id) => rows[id] !== undefined)
|
|
652
|
+
const failing = ROW_ORDER.filter((id) => rows[id] !== undefined && !rows[id]!.pass)
|
|
653
|
+
const verdict = !done ? 'RUNNING' : failing.length === 0 ? 'PASS' : 'FAIL'
|
|
654
|
+
const passed = Object.keys(rows).length - failing.length
|
|
655
|
+
|
|
656
|
+
return (
|
|
657
|
+
<ScrollView style={styles.scroll} contentContainerStyle={styles.container}>
|
|
658
|
+
<Text style={styles.title}>U7b instant-transition checks</Text>
|
|
659
|
+
<Text style={styles.subtitle}>
|
|
660
|
+
useInstantTransition global window — the pin example verbatim ({severity} severity)
|
|
661
|
+
</Text>
|
|
662
|
+
<View
|
|
663
|
+
style={[
|
|
664
|
+
styles.banner,
|
|
665
|
+
verdict === 'PASS' ? styles.pass : verdict === 'FAIL' ? styles.fail : styles.running,
|
|
666
|
+
]}
|
|
667
|
+
>
|
|
668
|
+
<Text style={styles.bannerText}>{verdict}</Text>
|
|
669
|
+
</View>
|
|
670
|
+
<Text style={styles.tally}>
|
|
671
|
+
{done
|
|
672
|
+
? `${passed} PASS / ${failing.length} FAIL of ${ROW_ORDER.length} rows`
|
|
673
|
+
: `${Object.keys(rows).length}/${ROW_ORDER.length} rows complete — RUNNING`}
|
|
674
|
+
</Text>
|
|
675
|
+
<Text style={styles.tallyDetail}>
|
|
676
|
+
{done
|
|
677
|
+
? failing.length === 0
|
|
678
|
+
? 'no failing rows'
|
|
679
|
+
: `FAILING: ${failing.join(' | ')}`
|
|
680
|
+
: `stage: ${stage}`}
|
|
681
|
+
</Text>
|
|
682
|
+
<Text style={styles.status}>
|
|
683
|
+
stage: {stage} · presses={pressCount} · mount x={live.mount ?? '0.0'} · A=
|
|
684
|
+
{live.pressA ?? '0.0'} B=
|
|
685
|
+
{live.pressB ?? '0.0'} · delay={live.delay ?? '0.0'} · relock={live.relock ?? '0.0'} · b2b=
|
|
686
|
+
{live.b2bA ?? '0.0'}/{live.b2bB ?? '0.0'}
|
|
687
|
+
</Text>
|
|
688
|
+
|
|
689
|
+
<Pressable onPress={firePress} style={styles.button} accessibilityLabel="u7b-press-button">
|
|
690
|
+
<Text style={styles.buttonText}>PRESS (auto-fires at +{T_PRESS}ms)</Text>
|
|
691
|
+
</Pressable>
|
|
692
|
+
|
|
693
|
+
{/* The subjects. The white box is the pin example verbatim (useInstantTransition.tsx:4-9,
|
|
694
|
+
18): 100×100 white, initial={false}, animate={{ x }}. The press pair are two DIFFERENT
|
|
695
|
+
components retargeted inside one window; the delay box carries the authored 500ms
|
|
696
|
+
delay; the relock box flies a plain 0.5s tween after the window closes; the b2b pair
|
|
697
|
+
takes the two back-to-back calls. onUpdate on each is the committed-values readback. */}
|
|
698
|
+
<View style={styles.stageRow}>
|
|
699
|
+
<MotionView
|
|
700
|
+
initial={false}
|
|
701
|
+
animate={{ x }}
|
|
702
|
+
onUpdate={(latest) => {
|
|
703
|
+
const value = readX(latest)
|
|
704
|
+
if (value !== null) record('mount', value)
|
|
705
|
+
}}
|
|
706
|
+
style={styles.mountBox}
|
|
707
|
+
accessibilityLabel="u7b-mount-subject"
|
|
708
|
+
/>
|
|
709
|
+
<PressSubjectA target={pressTargetA} onSample={(value) => record('pressA', value)} />
|
|
710
|
+
<PressSubjectB target={pressTargetB} onSample={(value) => record('pressB', value)} />
|
|
711
|
+
</View>
|
|
712
|
+
<View style={styles.stageRow}>
|
|
713
|
+
<MotionView
|
|
714
|
+
animate={{ x: delayTarget }}
|
|
715
|
+
initial={false}
|
|
716
|
+
transition={{ type: 'tween', duration: 0.6, delay: 0.5 }}
|
|
717
|
+
onUpdate={(latest) => {
|
|
718
|
+
const value = readX(latest)
|
|
719
|
+
if (value !== null) record('delay', value)
|
|
720
|
+
}}
|
|
721
|
+
style={styles.delayBox}
|
|
722
|
+
accessibilityLabel="u7b-delay-subject"
|
|
723
|
+
/>
|
|
724
|
+
<MotionView
|
|
725
|
+
animate={{ x: relockTarget }}
|
|
726
|
+
initial={false}
|
|
727
|
+
transition={{ type: 'tween', duration: 0.5 }}
|
|
728
|
+
onUpdate={(latest) => {
|
|
729
|
+
const value = readX(latest)
|
|
730
|
+
if (value !== null) record('relock', value)
|
|
731
|
+
}}
|
|
732
|
+
style={styles.relockBox}
|
|
733
|
+
accessibilityLabel="u7b-relock-subject"
|
|
734
|
+
/>
|
|
735
|
+
<MotionView
|
|
736
|
+
animate={{ x: b2bTargetA }}
|
|
737
|
+
initial={false}
|
|
738
|
+
onUpdate={(latest) => {
|
|
739
|
+
const value = readX(latest)
|
|
740
|
+
if (value !== null) record('b2bA', value)
|
|
741
|
+
}}
|
|
742
|
+
style={styles.b2bBoxA}
|
|
743
|
+
accessibilityLabel="u7b-b2b-subject-a"
|
|
744
|
+
/>
|
|
745
|
+
<MotionView
|
|
746
|
+
animate={{ x: b2bTargetB }}
|
|
747
|
+
initial={false}
|
|
748
|
+
onUpdate={(latest) => {
|
|
749
|
+
const value = readX(latest)
|
|
750
|
+
if (value !== null) record('b2bB', value)
|
|
751
|
+
}}
|
|
752
|
+
style={styles.b2bBoxB}
|
|
753
|
+
accessibilityLabel="u7b-b2b-subject-b"
|
|
754
|
+
/>
|
|
755
|
+
</View>
|
|
756
|
+
|
|
757
|
+
{ROW_ORDER.map((id) => {
|
|
758
|
+
const row = rows[id]
|
|
759
|
+
return (
|
|
760
|
+
<View key={id} style={styles.row}>
|
|
761
|
+
<Text
|
|
762
|
+
style={[
|
|
763
|
+
styles.verdict,
|
|
764
|
+
row === undefined ? styles.pending : row.pass ? styles.ok : styles.bad,
|
|
765
|
+
]}
|
|
766
|
+
>
|
|
767
|
+
{row === undefined ? '…' : row.pass ? 'PASS' : 'FAIL'}
|
|
768
|
+
</Text>
|
|
769
|
+
<View style={styles.rowBody}>
|
|
770
|
+
<Text style={styles.rowId}>{id}</Text>
|
|
771
|
+
<Text style={styles.rowDetail} numberOfLines={4}>
|
|
772
|
+
{row === undefined ? 'pending' : row.detail}
|
|
773
|
+
</Text>
|
|
774
|
+
</View>
|
|
775
|
+
</View>
|
|
776
|
+
)
|
|
777
|
+
})}
|
|
778
|
+
</ScrollView>
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const styles = StyleSheet.create({
|
|
783
|
+
scroll: { flex: 1, backgroundColor: '#0b0b12' },
|
|
784
|
+
container: { paddingVertical: 72, paddingHorizontal: 8 },
|
|
785
|
+
title: { color: '#fff', fontSize: 22, fontWeight: '700', textAlign: 'center' },
|
|
786
|
+
subtitle: { color: '#8a8aa0', fontSize: 12, marginTop: 6, textAlign: 'center' },
|
|
787
|
+
banner: {
|
|
788
|
+
alignSelf: 'center',
|
|
789
|
+
paddingHorizontal: 36,
|
|
790
|
+
paddingVertical: 10,
|
|
791
|
+
borderRadius: 10,
|
|
792
|
+
marginTop: 18,
|
|
793
|
+
marginBottom: 10,
|
|
794
|
+
},
|
|
795
|
+
pass: { backgroundColor: '#1d7a3e' },
|
|
796
|
+
fail: { backgroundColor: '#a32633' },
|
|
797
|
+
running: { backgroundColor: '#7a641d' },
|
|
798
|
+
bannerText: { color: '#fff', fontSize: 22, fontWeight: '800', letterSpacing: 2 },
|
|
799
|
+
tally: { color: '#e6e6f0', fontSize: 14, fontWeight: '700', textAlign: 'center' },
|
|
800
|
+
tallyDetail: { color: '#f87171', fontSize: 11, marginTop: 3, textAlign: 'center' },
|
|
801
|
+
status: {
|
|
802
|
+
color: '#c9c9d8',
|
|
803
|
+
fontSize: 11,
|
|
804
|
+
marginTop: 8,
|
|
805
|
+
textAlign: 'center',
|
|
806
|
+
fontFamily: 'Menlo',
|
|
807
|
+
},
|
|
808
|
+
button: {
|
|
809
|
+
alignSelf: 'center',
|
|
810
|
+
marginTop: 14,
|
|
811
|
+
paddingHorizontal: 20,
|
|
812
|
+
paddingVertical: 8,
|
|
813
|
+
borderRadius: 8,
|
|
814
|
+
backgroundColor: '#2a2a3a',
|
|
815
|
+
},
|
|
816
|
+
buttonText: { color: '#c9c9d8', fontSize: 12, fontWeight: '700' },
|
|
817
|
+
stageRow: {
|
|
818
|
+
flexDirection: 'row',
|
|
819
|
+
marginTop: 18,
|
|
820
|
+
alignItems: 'center',
|
|
821
|
+
justifyContent: 'space-evenly',
|
|
822
|
+
},
|
|
823
|
+
// The pin example's style verbatim (useInstantTransition.tsx:4-9): 100×100 white.
|
|
824
|
+
mountBox: { width: 100, height: 100, backgroundColor: 'white', borderRadius: 12 },
|
|
825
|
+
pressBoxA: { width: 60, height: 60, backgroundColor: '#3b82f6', borderRadius: 10 },
|
|
826
|
+
pressBoxB: { width: 60, height: 60, backgroundColor: '#f59e0b', borderRadius: 10 },
|
|
827
|
+
delayBox: { width: 60, height: 60, backgroundColor: '#a855f7', borderRadius: 10 },
|
|
828
|
+
relockBox: { width: 60, height: 60, backgroundColor: '#22c55e', borderRadius: 10 },
|
|
829
|
+
b2bBoxA: { width: 60, height: 60, backgroundColor: '#ec4899', borderRadius: 10 },
|
|
830
|
+
b2bBoxB: { width: 60, height: 60, backgroundColor: '#14b8a6', borderRadius: 10 },
|
|
831
|
+
row: { flexDirection: 'row', marginTop: 10 },
|
|
832
|
+
verdict: { width: 52, fontSize: 12, fontWeight: '800', paddingTop: 1 },
|
|
833
|
+
ok: { color: '#4ade80' },
|
|
834
|
+
bad: { color: '#f87171' },
|
|
835
|
+
pending: { color: '#8a8aa0' },
|
|
836
|
+
rowBody: { flex: 1 },
|
|
837
|
+
rowId: { color: '#e6e6f0', fontSize: 13, fontWeight: '600' },
|
|
838
|
+
rowDetail: { color: '#6f6f85', fontSize: 11, marginTop: 2 },
|
|
839
|
+
})
|