@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,3860 @@
|
|
|
1
|
+
// The real native driver (specs/M2.1-BUILD-PACKET.md, Milestone 3): core's `Driver` seam implemented
|
|
2
|
+
// over the Reanimated UI runtime. The substrate meets the graph exactly at this seam — commands cross
|
|
3
|
+
// JS→UI once each (O(1), REQ-DRIVER-013), the frame loop advances everything UI-side with core's own
|
|
4
|
+
// `stepProp` + generators (REQ-DRIVER-003: no forked math — the crossing chain is worklet-tagged core
|
|
5
|
+
// dist, see packages/core/scripts/tag-worklet-dist.mjs), and each element's props mirror into shared
|
|
6
|
+
// values that animated styles read.
|
|
7
|
+
//
|
|
8
|
+
// Frame source (REQ-DRIVER-011): the LIVE loop is a single driver-owned `useFrameCallback` — real
|
|
9
|
+
// vsync (Choreographer / CADisplayLink) — owned by the `useWorkletDriver` hook. It is deliberately
|
|
10
|
+
// NOT a UI-runtime `requestAnimationFrame` chain: that is a software poll (animationQueuePollingRate),
|
|
11
|
+
// wrong on 120Hz and drift-prone — review cycle 1 rejected exactly that. Settle-cancel
|
|
12
|
+
// (REQ-DRIVER-020): when every element quiesces, the UI side notifies JS ONCE (settleNotified guard)
|
|
13
|
+
// and JS deactivates the frame callback after a synchronous re-check (a command racing the settle edge
|
|
14
|
+
// must win); a deactivated callback costs zero per-frame work — the M2.0 JS-thread driver burned CPU
|
|
15
|
+
// forever and is deleted.
|
|
16
|
+
//
|
|
17
|
+
// Command→trajectory resolution is split across the runtimes on plain-data lines: the JS side converts
|
|
18
|
+
// the public `Transition` with the same COMPONENT converters the reference driver uses
|
|
19
|
+
// (`toSpringConfig` / `toTimingConfig`), and the UI side CONSTRUCTS the generator closure where it
|
|
20
|
+
// runs — a closure cannot cross runtimes (packet §Grounding), plain configs can.
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef } from 'react'
|
|
23
|
+
import {
|
|
24
|
+
makeMutable,
|
|
25
|
+
useFrameCallback,
|
|
26
|
+
type FrameInfo,
|
|
27
|
+
type SharedValue,
|
|
28
|
+
} from 'react-native-reanimated'
|
|
29
|
+
import { executeOnUIRuntimeSync, scheduleOnRN, scheduleOnUI } from 'react-native-worklets'
|
|
30
|
+
import {
|
|
31
|
+
buildRepeatedGenerator,
|
|
32
|
+
delayGenerator,
|
|
33
|
+
inertiaGenerator,
|
|
34
|
+
resolveSpring,
|
|
35
|
+
resolveSpringGenerator,
|
|
36
|
+
toDelayMs,
|
|
37
|
+
toRepeatFoldOptions,
|
|
38
|
+
retargetSpring,
|
|
39
|
+
stepProp,
|
|
40
|
+
timingGenerator,
|
|
41
|
+
toKeyframesConfig,
|
|
42
|
+
toSpringConfig,
|
|
43
|
+
type Driver,
|
|
44
|
+
type ElementHandle,
|
|
45
|
+
type Generator,
|
|
46
|
+
type InertiaTransition,
|
|
47
|
+
type RepeatFoldOptions,
|
|
48
|
+
type RepeatLeg,
|
|
49
|
+
type KeyframesConfig,
|
|
50
|
+
type LivePropState,
|
|
51
|
+
type PropStep,
|
|
52
|
+
type PropTargetTo,
|
|
53
|
+
type PropTransition,
|
|
54
|
+
type SpringConfig,
|
|
55
|
+
type TimingConfig,
|
|
56
|
+
type Transition,
|
|
57
|
+
} from '@unrulysystems/native-motion-core'
|
|
58
|
+
import {
|
|
59
|
+
adaptPreparedDriver,
|
|
60
|
+
applyPathProgressInto,
|
|
61
|
+
instantFinalKeyframe,
|
|
62
|
+
instantGenerator,
|
|
63
|
+
isDurationZeroInstantTransition,
|
|
64
|
+
keyframeOffsetAt,
|
|
65
|
+
keyframeSegmentEase,
|
|
66
|
+
isRepeatFoldRefusalIdentity,
|
|
67
|
+
isTimingLaneTransition,
|
|
68
|
+
iterationMeasurementLane,
|
|
69
|
+
repeatRefusalMessage,
|
|
70
|
+
toTimingLaneConfig,
|
|
71
|
+
REPEAT_REFUSAL_KEY,
|
|
72
|
+
REPEAT_REFUSAL_NAME,
|
|
73
|
+
type ArcScratch,
|
|
74
|
+
type PathChannelGeometry,
|
|
75
|
+
type PathProgressSample,
|
|
76
|
+
type PreparedDriver,
|
|
77
|
+
type PreparedPropTarget,
|
|
78
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
79
|
+
// Type-only: the runtime registry/session enter the UI runtime inside uiLayoutEngine, which
|
|
80
|
+
// imports them from the file-tagged worklet-layout subpath (the root-entry import above never
|
|
81
|
+
// reaches a UI worklet from this file).
|
|
82
|
+
import type {
|
|
83
|
+
LayoutIdentityRole,
|
|
84
|
+
LayoutIdToken,
|
|
85
|
+
WindowSpaceRect,
|
|
86
|
+
} from '@unrulysystems/native-motion-core/worklet-layout'
|
|
87
|
+
import { assertSetupNotReplayedOnDisposed } from './strictModeReplay'
|
|
88
|
+
import { isInstantWindowOpen } from './instantWindow'
|
|
89
|
+
import {
|
|
90
|
+
createUiLayoutState,
|
|
91
|
+
uiLayoutAttach,
|
|
92
|
+
uiLayoutCommit,
|
|
93
|
+
uiLayoutCouple,
|
|
94
|
+
uiLayoutDetach,
|
|
95
|
+
uiLayoutOpacityWitness,
|
|
96
|
+
uiLayoutStep,
|
|
97
|
+
uiLayoutUncouple,
|
|
98
|
+
uiProjectionAttach,
|
|
99
|
+
uiProjectionDetach,
|
|
100
|
+
uiProjectionSetProjection,
|
|
101
|
+
uiSetProjectionRadiusFeed,
|
|
102
|
+
type UiLayoutHolderOutputs,
|
|
103
|
+
type UiProjectionOutputs,
|
|
104
|
+
type UiLayoutState,
|
|
105
|
+
} from './uiLayoutEngine'
|
|
106
|
+
|
|
107
|
+
// A transition resolved JS-side to plain data so it can cross to the UI runtime. Mirrors the reference
|
|
108
|
+
// driver's start/retarget selection (spring is the fallback; tween goes through TIMING; REQ-TIMING-006:
|
|
109
|
+
// an authored duration-ONLY bag with no `type` also resolves 'tween', the pin's keyframes generator).
|
|
110
|
+
// `keyframes`
|
|
111
|
+
// carries the multi-keyframe timing config (REQ-API-033) resolved through the SAME COMPONENT converter
|
|
112
|
+
// (`toKeyframesConfig`) the reference driver uses, so an ARRAY target selects it UI-side with zero
|
|
113
|
+
// resolution skew between the two driver implementations — the target shape, not `kind`, selects it.
|
|
114
|
+
// `kind` is the SCALAR generator selector (spring is the fallback — an omitted type WITHOUT
|
|
115
|
+
// `duration` resolves to 'spring'; an omitted type WITH `duration` resolves to 'tween',
|
|
116
|
+
// REQ-TIMING-006). `explicitSpring` preserves whether the type was LITERALLY 'spring' (vs defaulted), which
|
|
117
|
+
// the keyframe-ARRAY branch needs: an array's default is a keyframes tween, so only an EXPLICIT spring
|
|
118
|
+
// routes an array to spring physics — collapsing omitted→spring for arrays skews vs the reference
|
|
119
|
+
// driver (review major 8). Tween is never a spring, so its `explicitSpring` is the literal `false`.
|
|
120
|
+
type PlainTransition =
|
|
121
|
+
| {
|
|
122
|
+
readonly kind: 'spring'
|
|
123
|
+
readonly config: SpringConfig
|
|
124
|
+
readonly keyframes: KeyframesConfig
|
|
125
|
+
readonly explicitSpring: boolean
|
|
126
|
+
// T18-a (REQ-API-049): the repeat fold's options, resolved JS-side to plain scalars, or null
|
|
127
|
+
// when the fold is inert. An authored `Transition` never crosses the marshal.
|
|
128
|
+
readonly repeat: RepeatFoldOptions | null
|
|
129
|
+
// T18-b (REQ-TIMING-004): the delay rebase's shift in MS, resolved JS-side through the same
|
|
130
|
+
// `toDelayMs` seconds→ms crossing the reference driver uses. Plain scalar — the authored
|
|
131
|
+
// seconds never cross. (Inertia is command-layer-only and carries no authored delay, so its
|
|
132
|
+
// arm omits this — the same totality law as `repeat`.)
|
|
133
|
+
readonly delayMs: number
|
|
134
|
+
}
|
|
135
|
+
| {
|
|
136
|
+
readonly kind: 'tween'
|
|
137
|
+
readonly config: TimingConfig
|
|
138
|
+
readonly keyframes: KeyframesConfig
|
|
139
|
+
readonly explicitSpring: false
|
|
140
|
+
readonly repeat: RepeatFoldOptions | null
|
|
141
|
+
readonly delayMs: number
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
// U7a + transition-default-selection F5: the instant lane (the pin's makeAnimationInstant,
|
|
145
|
+
// motion-dom@12.42.2 motion-value.ts:89-98,122-136) — the guard is type-independent:
|
|
146
|
+
// `type: false` (U7a) OR `duration === 0 && !repeatDelay` (F5). NO trajectory generator —
|
|
147
|
+
// the final keyframe commits on the driver's next update tick (never synchronously at
|
|
148
|
+
// command time), and the authored delay is honored through the shared rebase (only the
|
|
149
|
+
// pin's GLOBAL instant flag zeroes delay — 7b's window, not this lane). The start/retarget
|
|
150
|
+
// seams never fold it, so it carries no repeat/keyframes fields.
|
|
151
|
+
readonly kind: 'instant'
|
|
152
|
+
readonly delayMs: number
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
// R16 (REQ-GESTURE-023): the gesture free-drag release's two-phase inertia descriptor.
|
|
156
|
+
// Command-layer only (never authored); plain scalar data so the marshal rides verbatim.
|
|
157
|
+
readonly kind: 'inertia'
|
|
158
|
+
readonly config: PlainInertiaConfig
|
|
159
|
+
readonly explicitSpring: false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The inertia descriptor as plain worklet-marshalable data. The bounds ride verbatim from the
|
|
163
|
+
// prepared descriptor: `undefined` for an absent bound (no constraints on the release, or the
|
|
164
|
+
// side omitted) — an undefined-valued optional DOES cross the marshal, benign since the UI side
|
|
165
|
+
// treats it as an absent bound; the ±Infinity sentinel for the open side of a resolved
|
|
166
|
+
// constraint set (the drag-constraints law); or a finite number.
|
|
167
|
+
type PlainInertiaConfig = {
|
|
168
|
+
readonly power: number
|
|
169
|
+
readonly timeConstant: number
|
|
170
|
+
readonly bounceStiffness: number
|
|
171
|
+
readonly bounceDamping: number
|
|
172
|
+
readonly restDelta: number
|
|
173
|
+
readonly restSpeed: number
|
|
174
|
+
readonly min: number | undefined
|
|
175
|
+
readonly max: number | undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type PlainCommand =
|
|
179
|
+
| {
|
|
180
|
+
readonly kind: 'start'
|
|
181
|
+
// `velocity` rides the marshal verbatim (plain data): the REQ-DRIVER-023 generator seed. `to` is a
|
|
182
|
+
// scalar destination OR a keyframe array the value interpolates THROUGH (REQ-API-033, R8). The
|
|
183
|
+
// prepared per-prop `transition` is marshaled to PlainTransition JS-side (REQ-DRIVER-030).
|
|
184
|
+
readonly targets: Readonly<Record<string, PlainStartTarget>>
|
|
185
|
+
// The gesture RELEASE edge (review r8 major 24): true ⇒ this start ends the hold on its targets;
|
|
186
|
+
// false ⇒ a declarative start that CONTENDS on a gesture-held prop (drifts the live value
|
|
187
|
+
// without clearing the hold, REQ-DRIVER-026). Plain boolean, marshaled.
|
|
188
|
+
readonly releasesGesture: boolean
|
|
189
|
+
readonly path?: {
|
|
190
|
+
readonly fromX: number
|
|
191
|
+
readonly fromY: number
|
|
192
|
+
readonly toX: number
|
|
193
|
+
readonly toY: number
|
|
194
|
+
readonly peak: number
|
|
195
|
+
readonly signedStrength: number
|
|
196
|
+
readonly rotationScale: number
|
|
197
|
+
readonly transition: PlainTransition
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
| {
|
|
201
|
+
readonly kind: 'retarget'
|
|
202
|
+
readonly targets: Readonly<Record<string, number>>
|
|
203
|
+
readonly transition: PlainTransition
|
|
204
|
+
readonly targetTransitions?: Readonly<Record<string, PlainTransition>>
|
|
205
|
+
}
|
|
206
|
+
| { readonly kind: 'stop' }
|
|
207
|
+
|
|
208
|
+
// The per-target payload crossing to the UI runtime: `to`/`from`/`velocity` plain data, plus the
|
|
209
|
+
// OPTIONAL per-prop transition ALREADY marshaled to PlainTransition JS-side (R8 M2 per-prop defaults).
|
|
210
|
+
type PlainStartTarget = {
|
|
211
|
+
readonly to: PropTargetTo
|
|
212
|
+
readonly from: number
|
|
213
|
+
readonly velocity: number
|
|
214
|
+
readonly transition: PlainTransition
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Marshal an already-complete REQ-DRIVER-030 target snapshot to UI-runtime data. Caller accessors,
|
|
218
|
+
// mutation detachment, velocity defaulting, and transition fallback were all handled once by core's
|
|
219
|
+
// adapter; this backend performs only the host-specific config conversion.
|
|
220
|
+
function marshalStartTargets(
|
|
221
|
+
targets: Readonly<Record<string, PreparedPropTarget>>,
|
|
222
|
+
marshalTransition: (transition: PropTransition) => PlainTransition,
|
|
223
|
+
): Readonly<Record<string, PlainStartTarget>> {
|
|
224
|
+
const out: Record<string, PlainStartTarget> = {}
|
|
225
|
+
for (const key of Object.keys(targets)) {
|
|
226
|
+
const t = targets[key]!
|
|
227
|
+
// defineProperty preserves an own `__proto__` key without changing this plain object's prototype;
|
|
228
|
+
// the worklets serializer receives the same ordinary-object shape as before REQ-DRIVER-030.
|
|
229
|
+
Object.defineProperty(out, key, {
|
|
230
|
+
enumerable: true,
|
|
231
|
+
value: Object.freeze({
|
|
232
|
+
to: t.to,
|
|
233
|
+
from: t.from,
|
|
234
|
+
velocity: t.velocity,
|
|
235
|
+
transition: marshalTransition(t.transition),
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
return Object.freeze(out)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Core deliberately prepares target maps with a null prototype so an own `__proto__` key remains data.
|
|
243
|
+
// Worklets 0.10.2 serializes an ordinary record only when its prototype is Object.prototype, so rebuild
|
|
244
|
+
// that map at the host crossing without using assignment (which would invoke the legacy __proto__ setter).
|
|
245
|
+
export function marshalRetargetTargets(
|
|
246
|
+
targets: Readonly<Record<string, number>>,
|
|
247
|
+
): Readonly<Record<string, number>> {
|
|
248
|
+
const out: Record<string, number> = {}
|
|
249
|
+
for (const key of Object.keys(targets)) {
|
|
250
|
+
Object.defineProperty(out, key, { enumerable: true, value: targets[key]! })
|
|
251
|
+
}
|
|
252
|
+
return Object.freeze(out)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function marshalRetargetTransitions(
|
|
256
|
+
transitions: Readonly<Record<string, PropTransition>> | undefined,
|
|
257
|
+
marshalTransition: (transition: PropTransition) => PlainTransition,
|
|
258
|
+
): Readonly<Record<string, PlainTransition>> | undefined {
|
|
259
|
+
if (transitions === undefined) return undefined
|
|
260
|
+
const out: Record<string, PlainTransition> = {}
|
|
261
|
+
for (const key of Object.keys(transitions)) {
|
|
262
|
+
Object.defineProperty(out, key, {
|
|
263
|
+
enumerable: true,
|
|
264
|
+
value: marshalTransition(transitions[key]!),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
return Object.freeze(out)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// One element's live UI-side state. Plain records (not Map) — everything here is created and mutated on
|
|
271
|
+
// the UI runtime only; `shared` are the Reanimated shared values animated styles bind to.
|
|
272
|
+
interface UiElementState {
|
|
273
|
+
active: boolean
|
|
274
|
+
// Per-prop gesture hold count (REQ-DRIVER-024, G-INV-7, review r7 major 23): the number of props a
|
|
275
|
+
// gesture holds. A `write` establishes a hold; only the MATCHING release/stop command clears it —
|
|
276
|
+
// an unrelated command is a PARTIAL overlay and must not release another prop's hold. While any
|
|
277
|
+
// prop is held the element is UNSETTLED (a finger is down, REQ-GESTURE-002) — the live analog of
|
|
278
|
+
// the M1 session's ActivityHandle — so the frame loop emits no spurious settle edge mid-drag. A
|
|
279
|
+
// count (not a boolean) keeps the @frame-path quiesce check O(1) + allocation-free (REQ-DRIVER-021).
|
|
280
|
+
heldCount: number
|
|
281
|
+
readonly committed: Record<string, number>
|
|
282
|
+
// Gesture-phase live velocity per held prop (REQ-DRIVER-025, G-INV-3, review r4 c14e15): the
|
|
283
|
+
// platform velocity the most recent `write` carried, mirrored from the reference driver. A prop
|
|
284
|
+
// WITH an entry here is held; uiLiveFor reports it (no generator ⇒ this is the live velocity) so
|
|
285
|
+
// the release-edge C1 seam is continuous; cleared per prop by the matching command; a
|
|
286
|
+
// settled/never-written prop has no entry ⇒ 0.
|
|
287
|
+
readonly heldVelocity: Record<string, number>
|
|
288
|
+
// Last recognizer-write version applied per prop. A live `write` is scheduled while `liveFor`
|
|
289
|
+
// crosses synchronously, so the JS side compares this marker with its queued-write mirror to
|
|
290
|
+
// provide read-your-writes without replaying or reordering the UI lane (REQ-DRIVER-024/-025).
|
|
291
|
+
readonly writeVersion: Record<string, number>
|
|
292
|
+
readonly props: Record<string, PropStep>
|
|
293
|
+
// A layout host proxy owns a complete transform array while it is in flight. Each property
|
|
294
|
+
// therefore exposes its live command state so the proxy mirror follows the ACTUAL gesture,
|
|
295
|
+
// keyframe, exit, or bound-value authority instead of a render-time animate snapshot.
|
|
296
|
+
readonly layoutMirror: Record<string, WorkletDriverPropertyState>
|
|
297
|
+
// Contention target per gesture-held prop (REQ-DRIVER-026), mirrored from the reference driver: the
|
|
298
|
+
// declarative `to` + transition a held prop animates toward while the finger owns it. A finger write
|
|
299
|
+
// RE-ANCHORS the contending generator to the freshly-committed finger value so the drift continues
|
|
300
|
+
// FROM the finger's position, never jumping back to the generator's stale trajectory (review r10
|
|
301
|
+
// major 29, G-INV-2). Cleared at release/stop and when the finger takes over a settled contention.
|
|
302
|
+
readonly contending: Record<string, { to: PropTargetTo; transition: PlainTransition }>
|
|
303
|
+
// The frame loop's iteration list for `props`, rebuilt at command edges (uiApplyCommand) —
|
|
304
|
+
// Object.keys per element per vsync is frame-executed garbage (REQ-DRIVER-021).
|
|
305
|
+
propKeys: string[]
|
|
306
|
+
readonly shared: Record<string, SharedValue<number>>
|
|
307
|
+
// T23 C: the onUpdate marshal arm. When armed, the frame step emits ONE primitive payload
|
|
308
|
+
// per frame to JS while any committed value changed since the last emission (the pin's
|
|
309
|
+
// value-change cadence, VisualElement.ts:574-586). lastNotified is the change discriminator.
|
|
310
|
+
onUpdateArmed: boolean
|
|
311
|
+
readonly lastNotified: Record<string, number>
|
|
312
|
+
// The live onUpdate callback's registration generation, mirrored from the JS arm/disarm edge.
|
|
313
|
+
// A staged frame entry carries it so the JS envelope drain drops an entry whose listener
|
|
314
|
+
// registration changed between commit and drain (packet §2a live-generation lookup).
|
|
315
|
+
registrationGeneration: number
|
|
316
|
+
// Off-frame-prefilled member slots for the sealed frame envelope (REQ-DRIVER-021): capacity
|
|
317
|
+
// grows only where propKeys is rebuilt (command/registration edge); uiEmitFrameValues fills
|
|
318
|
+
// keys/values by index and never constructs a Record or grows these in-frame.
|
|
319
|
+
readonly frameKeys: string[]
|
|
320
|
+
readonly frameValues: number[]
|
|
321
|
+
path: {
|
|
322
|
+
progress: PropStep
|
|
323
|
+
geometry: PathChannelGeometry
|
|
324
|
+
// Caller-owned command-edge scratch for the allocation-free arc samplers (REQ-DRIVER-021,
|
|
325
|
+
// packet §6 arc-return-records repair): uiStepAll writes these slots every frame.
|
|
326
|
+
sample: PathProgressSample
|
|
327
|
+
scratch: ArcScratch
|
|
328
|
+
} | null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `Driver.write` is the sole production event-write seam (REQ-DRIVER-029). Gesture writes hold
|
|
332
|
+
// until their release command; bound MotionValues are instantaneous retargets with no release.
|
|
333
|
+
// The shape travels with that one write call so the UI owner can preserve both laws without a
|
|
334
|
+
// binding-only side channel.
|
|
335
|
+
export type WorkletDriverWriteShape = 'gesture-hold' | 'bound-value-retarget'
|
|
336
|
+
|
|
337
|
+
export type WorkletDriver = Omit<Driver, 'write'> & {
|
|
338
|
+
write(
|
|
339
|
+
handle: ElementHandle,
|
|
340
|
+
values: Readonly<Record<string, number>>,
|
|
341
|
+
velocities?: Readonly<Record<string, number>>,
|
|
342
|
+
eventShape?: WorkletDriverWriteShape,
|
|
343
|
+
): void
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Give the fold's refusal the SAME identity the reference backend gives it (fix-up review MAJOR).
|
|
348
|
+
*
|
|
349
|
+
* `buildRepeatedGenerator` returns a bare `Error` — it is shared with the UI runtime, where a throw
|
|
350
|
+
* is a `std::terminate`, so it never constructs a severity. The reference backend re-wraps that into
|
|
351
|
+
* an `InvalidTransitionError` naming `repeat`; this backend cannot construct that class here (a core
|
|
352
|
+
* class inside a worklet), and would gain nothing if it could, because `uiGuardLane` marshals only
|
|
353
|
+
* `{name, message}` to RN. So it reproduces the two fields the crossing actually carries, from the
|
|
354
|
+
* shared builder, and `driverParity.differential.test.ts` compares the two backends' refusals
|
|
355
|
+
* directly — the trajectory differential is blind to a fold that BOTH backends refuse, which is how
|
|
356
|
+
* this one survived a round.
|
|
357
|
+
*/
|
|
358
|
+
// alloc-ok: lifecycle-edge — built only on the refusal path, once per rejected animate command.
|
|
359
|
+
function uiRepeatRefusal(repeat: number, refusal: Error): Error {
|
|
360
|
+
'worklet'
|
|
361
|
+
const named = new Error(repeatRefusalMessage(repeat, refusal.message)) as Error & { key: string }
|
|
362
|
+
named.name = REPEAT_REFUSAL_NAME
|
|
363
|
+
// The offending property. `uiGuard` marshals it beside the name, so the severity router
|
|
364
|
+
// recognizes this refusal after the crossing exactly as it recognizes the reference backend's.
|
|
365
|
+
named.key = REPEAT_REFUSAL_KEY
|
|
366
|
+
return named
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The dual-authority bound-value refusal's marshalled identity (REQ-DRIVER-034). The message is
|
|
371
|
+
* byte-identical to what the synchronous lane throws — the adapter's catch matches its prefix —
|
|
372
|
+
* but a name is what survives the scheduled lane's `{name, message, key}` marshal, where an
|
|
373
|
+
* `instanceof` cannot. Thrown ONLY from `uiApplyWrite`'s validate-all-keys phase (before ANY
|
|
374
|
+
* mutation), so a live-lane guard that recognizes it knows the element is exactly as the refused
|
|
375
|
+
* write found it.
|
|
376
|
+
*/
|
|
377
|
+
const BOUND_VALUE_WRITE_REFUSAL_NAME = 'BoundValueWriteRefusal'
|
|
378
|
+
|
|
379
|
+
// alloc-ok: lifecycle-edge — built only on the refusal path, once per refused retarget write.
|
|
380
|
+
function uiBoundValueWriteRefusal(key: string): Error {
|
|
381
|
+
'worklet'
|
|
382
|
+
const named = new Error(
|
|
383
|
+
`cannot write bound value '${key}': it is owned by a gesture or declarative command.`,
|
|
384
|
+
) as Error & { key: string }
|
|
385
|
+
named.name = BOUND_VALUE_WRITE_REFUSAL_NAME
|
|
386
|
+
// The offending property. `uiGuard` marshals it beside the name, so the JS-side delivery names
|
|
387
|
+
// the same key the synchronous rethrow carries.
|
|
388
|
+
named.key = key
|
|
389
|
+
return named
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The scheduled lane's recognition half, decided on the marshalled record (structural, like the fold refusal's). */
|
|
393
|
+
function isBoundValueWriteRefusalIdentity(name: unknown): boolean {
|
|
394
|
+
'worklet'
|
|
395
|
+
return name === BOUND_VALUE_WRITE_REFUSAL_NAME
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** UI-runtime state a layout-transform mirror reads without crossing back to JS. */
|
|
399
|
+
export interface WorkletDriverPropertyState {
|
|
400
|
+
readonly active: SharedValue<boolean>
|
|
401
|
+
readonly target: SharedValue<number>
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface UiDriverState {
|
|
405
|
+
readonly driverId: number
|
|
406
|
+
readonly elements: UiElementState[]
|
|
407
|
+
// One settle notification per in-flight→idle edge (REQ-DRIVER-020). Starts true (nothing in
|
|
408
|
+
// flight); any start/retarget command re-arms it.
|
|
409
|
+
settleNotified: boolean
|
|
410
|
+
// The JS-owned command/lifecycle epoch that opened the current live run. Carrying this plain
|
|
411
|
+
// number back with the UI settle edge lets JS reject a stale edge without reading a SharedValue
|
|
412
|
+
// (which itself can synchronously cross back into the UI runtime).
|
|
413
|
+
loopEpoch: number
|
|
414
|
+
// The layout-identity engine (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md), created lazily at the
|
|
415
|
+
// first layout attach so element-only drivers pay nothing for it.
|
|
416
|
+
layout: UiLayoutState | null
|
|
417
|
+
// Per-identity settle watchers. These are intentionally separate from the driver's global
|
|
418
|
+
// quiescence edge: one unrelated property animation or pending identity must not delay a
|
|
419
|
+
// mounted layoutId completion for an identity that has already reached rest.
|
|
420
|
+
layoutSettleWatchers: UiLayoutSettleWatcher[]
|
|
421
|
+
// The reusable sealed-envelope emission transaction (REQ-DRIVER-021) — constructed with the
|
|
422
|
+
// driver record, prefilled at command/registration edges, sealed at most once per frame.
|
|
423
|
+
readonly emission: UiFrameEmissionState
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
interface UiLayoutSettleWatcher {
|
|
427
|
+
readonly id: string
|
|
428
|
+
readonly key: number
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- Sealed frame mailbox envelope (REQ-DRIVER-021, packet §2a closed-envelope law) --------------
|
|
432
|
+
// One UI frame stages every demanded entry into the driver's reusable emission transaction with
|
|
433
|
+
// indexed writes into off-frame-prefilled slots, then the seal edge serializes exactly ONE closed
|
|
434
|
+
// envelope across one scheduleOnRN(dispatchChannelMailboxTurnEnvelope, envelope) crossing. The
|
|
435
|
+
// entry shapes structurally mirror the motion layer's ChannelMailboxEntry vocabulary (the driver
|
|
436
|
+
// layer stays motion-import-free); values here are numbers only, so the element member sequence
|
|
437
|
+
// carries number tags alone.
|
|
438
|
+
|
|
439
|
+
interface UiFrameMailboxElementMember {
|
|
440
|
+
readonly key: string
|
|
441
|
+
readonly kind: 'number'
|
|
442
|
+
readonly value: number
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
type UiFrameMailboxEntry =
|
|
446
|
+
| {
|
|
447
|
+
readonly kind: 'ELEMENT_HOST_COMMIT'
|
|
448
|
+
readonly index: number
|
|
449
|
+
readonly driverId: number
|
|
450
|
+
readonly handleId: number
|
|
451
|
+
readonly registrationGeneration: number
|
|
452
|
+
readonly commitSequence: number
|
|
453
|
+
readonly values: readonly UiFrameMailboxElementMember[]
|
|
454
|
+
}
|
|
455
|
+
| {
|
|
456
|
+
readonly kind: 'LAYOUT_RELEASE'
|
|
457
|
+
readonly index: number
|
|
458
|
+
readonly layoutRegistrationId: number
|
|
459
|
+
}
|
|
460
|
+
| {
|
|
461
|
+
readonly kind: 'PROJECTION_STALE'
|
|
462
|
+
readonly index: number
|
|
463
|
+
readonly report: { readonly source: string; readonly code: string; readonly detail: string }
|
|
464
|
+
}
|
|
465
|
+
| { readonly kind: 'LAYOUT_SETTLE'; readonly index: number; readonly layoutAnimationId: number }
|
|
466
|
+
| {
|
|
467
|
+
readonly kind: 'DRIVER_SETTLE'
|
|
468
|
+
readonly index: number
|
|
469
|
+
readonly driverId: number
|
|
470
|
+
readonly schedulerEpoch: number
|
|
471
|
+
}
|
|
472
|
+
// The driver's last envelope: the UI record is deleted right after this seals, so nothing more
|
|
473
|
+
// can be sealed for the driver and JS may forget its mailbox on receipt — in order, behind every
|
|
474
|
+
// envelope sealed before the dispose crossing landed.
|
|
475
|
+
| { readonly kind: 'DRIVER_DISPOSED'; readonly index: number; readonly driverId: number }
|
|
476
|
+
|
|
477
|
+
interface UiFrameMailboxEnvelope {
|
|
478
|
+
readonly kind: 'CHANNEL_MAILBOX_TURN_ENVELOPE'
|
|
479
|
+
readonly mailboxOrigin: 'UI'
|
|
480
|
+
readonly mailboxDrainId: number
|
|
481
|
+
readonly driverId: number
|
|
482
|
+
readonly eventCount: number
|
|
483
|
+
readonly endIndex: number
|
|
484
|
+
readonly entries: readonly UiFrameMailboxEntry[]
|
|
485
|
+
readonly closingMarker: { readonly kind: 'CHANNEL_MAILBOX_END'; readonly index: number }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The reusable per-driver emission transaction: every slot array is prefilled at a command or
|
|
489
|
+
// registration edge; a frame only fills by index and bumps counts. The sole allocation is the
|
|
490
|
+
// closed-envelope serialization at the seal edge (alloc-ok:mailbox-envelope-emission).
|
|
491
|
+
interface UiFrameEmissionState {
|
|
492
|
+
// Changed-element entry slots (capacity = elements.length, grown at the register edge).
|
|
493
|
+
readonly elementHandles: number[]
|
|
494
|
+
readonly elementGenerations: number[]
|
|
495
|
+
readonly elementCommitSequences: number[]
|
|
496
|
+
readonly elementMemberCounts: number[]
|
|
497
|
+
elementEntryCount: number
|
|
498
|
+
// Layout-settle staging (capacity = layoutSettleWatchers.length, grown at subscription).
|
|
499
|
+
readonly layoutSettledKeys: number[]
|
|
500
|
+
layoutSettledCount: number
|
|
501
|
+
// The driver settle edge: -1 when no settle is staged this frame.
|
|
502
|
+
driverSettleEpoch: number
|
|
503
|
+
// Set by the dispose crossing for the driver's final envelope.
|
|
504
|
+
disposeStaged: boolean
|
|
505
|
+
// Monotonic per-driver envelope id (doubles as the origin-scoped mailbox drain id).
|
|
506
|
+
commitSequence: number
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The JS callback registry deliberately lives outside each worklet closure. A per-identity
|
|
510
|
+
// listener can close over the MotionView lifecycle, whose unsubscribe path points back to this
|
|
511
|
+
// registry. Capturing that Map from the UI-frame worklet turns the listener graph cyclic while
|
|
512
|
+
// iOS serializes it. UI worklets carry only the driver/key pair; these JS dispatchers recover the
|
|
513
|
+
// live callback after the RN crossing.
|
|
514
|
+
interface LayoutCallbackRegistry {
|
|
515
|
+
readonly release: Map<number, () => void>
|
|
516
|
+
readonly settled: Map<number, () => void>
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const layoutCallbacksByDriver = new Map<number, LayoutCallbackRegistry>()
|
|
520
|
+
|
|
521
|
+
// The global driver settle edge shares the layout emitter's ownership rule: a UI worklet carries
|
|
522
|
+
// only a driver id and a primitive epoch. Keeping the hook-local callback on JS avoids serializing
|
|
523
|
+
// React state, the frame callback, or a callback registry back into the UI frame closure.
|
|
524
|
+
const driverSettleCallbacks = new Map<number, (epoch: number) => void>()
|
|
525
|
+
|
|
526
|
+
// `scheduleOnRN` callbacks originate from UI worklets. Worklets can invoke the JS target before the
|
|
527
|
+
// UI dispatcher has unwound, so application callbacks must never run in that native callback extent:
|
|
528
|
+
// a synchronous read (`committed`, `liveFor`, or a layout query) would re-enter `runSyncSerialized`
|
|
529
|
+
// and recreate the iOS AB-BA lock inversion. Every driver-owned UI→JS dispatcher therefore first
|
|
530
|
+
// moves delivery onto the next JS task. A promise microtask is insufficient: Hermes can drain it
|
|
531
|
+
// while the synchronous JSI dispatchEventToJS invocation is still unwinding. Keep this
|
|
532
|
+
// JS-thread-local depth around the deferred
|
|
533
|
+
// delivery too: commands preserve the existing queued re-entry law, and the depth remains correct
|
|
534
|
+
// if a consumer synchronously induces a second driver callback.
|
|
535
|
+
let uiOriginatedCallbackDepth = 0
|
|
536
|
+
|
|
537
|
+
// React Native's event emitter and the worklet frame dispatcher both enter JavaScript while the
|
|
538
|
+
// UI runtime still owns the inbound call. Keep one marker for both origins: every mutating driver
|
|
539
|
+
// crossing consults it before selecting its synchronous verification lane.
|
|
540
|
+
export function runNativeEventCallback<T>(callback: () => T): T {
|
|
541
|
+
uiOriginatedCallbackDepth += 1
|
|
542
|
+
try {
|
|
543
|
+
return callback()
|
|
544
|
+
} finally {
|
|
545
|
+
uiOriginatedCallbackDepth -= 1
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function deliverUiOriginatedCallback(callback: () => void): void {
|
|
550
|
+
runNativeEventCallback(callback)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function deferUiOriginatedCallback(callback: () => void): void {
|
|
554
|
+
// A zero-delay task is a scheduling boundary, not a retry: it preserves FIFO delivery among
|
|
555
|
+
// one frame's notifications while guaranteeing the native UI→JS dispatcher has returned.
|
|
556
|
+
setTimeout(() => deliverUiOriginatedCallback(callback), 0)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The deferral seam is exported for the motion layer's scheduleOnRN severity reporters (seal
|
|
560
|
+
// r3, F1): a JS throw or report delivered inside the native UI→JS dispatch extent is the AB-BA
|
|
561
|
+
// class — every severity delivery lands one JS task later, exactly like the settle dispatches.
|
|
562
|
+
export { deferUiOriginatedCallback }
|
|
563
|
+
|
|
564
|
+
// An RN host event's layoutId measure/commit path needs a stronger boundary than the user
|
|
565
|
+
// callback marker above: the lifecycle currently returns synchronous role/flight results, so it
|
|
566
|
+
// runs in the next FIFO task after `dispatchEventToJS` has unwound rather than attempting a
|
|
567
|
+
// partial asynchronous layout API with fabricated return values.
|
|
568
|
+
export function deferNativeEventWork(callback: () => void): void {
|
|
569
|
+
setTimeout(callback, 0)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function deliverDriverSettled(driverId: number, epoch: number): void {
|
|
573
|
+
const callback = driverSettleCallbacks.get(driverId)
|
|
574
|
+
callback?.(epoch)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function deliverLayoutRelease(driverId: number, key: number): void {
|
|
578
|
+
const registry = layoutCallbacksByDriver.get(driverId)
|
|
579
|
+
const callback = registry?.release.get(key)
|
|
580
|
+
if (callback === undefined) return
|
|
581
|
+
registry!.release.delete(key)
|
|
582
|
+
callback()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function deliverLayoutSettled(driverId: number, key: number): void {
|
|
586
|
+
const registry = layoutCallbacksByDriver.get(driverId)
|
|
587
|
+
const callback = registry?.settled.get(key)
|
|
588
|
+
if (callback === undefined) return
|
|
589
|
+
registry!.settled.delete(key)
|
|
590
|
+
callback()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface ElementFrameValueCallbackRegistration {
|
|
594
|
+
readonly generation: number
|
|
595
|
+
readonly callback: (values: Readonly<Record<string, number>>) => void
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const elementFrameValueCallbacks = new Map<
|
|
599
|
+
number,
|
|
600
|
+
Map<number, ElementFrameValueCallbackRegistration>
|
|
601
|
+
>()
|
|
602
|
+
let elementFrameValueRegistrationGeneration = 0
|
|
603
|
+
const acceptedFrameMailboxDrainIdByDriver = new Map<number, number>()
|
|
604
|
+
|
|
605
|
+
function nextElementFrameValueRegistrationGeneration(): number {
|
|
606
|
+
if (elementFrameValueRegistrationGeneration >= Number.MAX_SAFE_INTEGER) {
|
|
607
|
+
throw new Error('frame-value listener registration generation exhausted.')
|
|
608
|
+
}
|
|
609
|
+
elementFrameValueRegistrationGeneration += 1
|
|
610
|
+
return elementFrameValueRegistrationGeneration
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function validateFrameMailboxEnvelope(envelope: UiFrameMailboxEnvelope): void {
|
|
614
|
+
if (
|
|
615
|
+
envelope.kind !== 'CHANNEL_MAILBOX_TURN_ENVELOPE' ||
|
|
616
|
+
envelope.mailboxOrigin !== 'UI' ||
|
|
617
|
+
envelope.eventCount <= 0 ||
|
|
618
|
+
envelope.eventCount !== envelope.entries.length ||
|
|
619
|
+
envelope.endIndex !== envelope.eventCount - 1 ||
|
|
620
|
+
envelope.closingMarker.kind !== 'CHANNEL_MAILBOX_END' ||
|
|
621
|
+
envelope.closingMarker.index !== envelope.endIndex
|
|
622
|
+
) {
|
|
623
|
+
throw new Error('invalid closed driver-frame mailbox envelope.')
|
|
624
|
+
}
|
|
625
|
+
for (let index = 0; index < envelope.entries.length; index += 1) {
|
|
626
|
+
const entry = envelope.entries[index]!
|
|
627
|
+
if (entry.index !== index) throw new Error('non-contiguous driver-frame mailbox envelope.')
|
|
628
|
+
if (
|
|
629
|
+
(entry.kind === 'ELEMENT_HOST_COMMIT' ||
|
|
630
|
+
entry.kind === 'DRIVER_SETTLE' ||
|
|
631
|
+
entry.kind === 'DRIVER_DISPOSED') &&
|
|
632
|
+
entry.driverId !== envelope.driverId
|
|
633
|
+
) {
|
|
634
|
+
throw new Error('cross-driver entry in driver-frame mailbox envelope.')
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const accepted = acceptedFrameMailboxDrainIdByDriver.get(envelope.driverId) ?? 0
|
|
638
|
+
if (envelope.mailboxDrainId !== accepted + 1) {
|
|
639
|
+
throw new Error('duplicate or out-of-order driver-frame mailbox envelope.')
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function drainFrameMailboxEnvelope(envelope: UiFrameMailboxEnvelope): void {
|
|
644
|
+
validateFrameMailboxEnvelope(envelope)
|
|
645
|
+
acceptedFrameMailboxDrainIdByDriver.set(envelope.driverId, envelope.mailboxDrainId)
|
|
646
|
+
for (const entry of envelope.entries) {
|
|
647
|
+
switch (entry.kind) {
|
|
648
|
+
case 'ELEMENT_HOST_COMMIT': {
|
|
649
|
+
// The live-generation lookup precedes public record materialization: a listener replaced
|
|
650
|
+
// after UI commit cannot receive its predecessor registration's queued frame.
|
|
651
|
+
const registration = elementFrameValueCallbacks.get(entry.driverId)?.get(entry.handleId)
|
|
652
|
+
if (
|
|
653
|
+
registration === undefined ||
|
|
654
|
+
registration.generation !== entry.registrationGeneration
|
|
655
|
+
) {
|
|
656
|
+
break
|
|
657
|
+
}
|
|
658
|
+
const values: Record<string, number> = {}
|
|
659
|
+
for (const member of entry.values) {
|
|
660
|
+
Object.defineProperty(values, member.key, {
|
|
661
|
+
enumerable: true,
|
|
662
|
+
value: member.value,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
registration.callback(values)
|
|
666
|
+
break
|
|
667
|
+
}
|
|
668
|
+
case 'LAYOUT_RELEASE':
|
|
669
|
+
deliverLayoutRelease(envelope.driverId, entry.layoutRegistrationId)
|
|
670
|
+
break
|
|
671
|
+
case 'PROJECTION_STALE':
|
|
672
|
+
deliverProjectionStaleReport(entry.report)
|
|
673
|
+
break
|
|
674
|
+
case 'LAYOUT_SETTLE':
|
|
675
|
+
deliverLayoutSettled(envelope.driverId, entry.layoutAnimationId)
|
|
676
|
+
break
|
|
677
|
+
case 'DRIVER_SETTLE': {
|
|
678
|
+
// Proof seam witness (no-op when the hook is unset): receipt and the UI emission count
|
|
679
|
+
// are observed only after the envelope receipt has crossed the AB-BA task boundary.
|
|
680
|
+
const marker = (globalThis as ProofMarkerJsGlobal).__nativeMotionProofMarker
|
|
681
|
+
if (marker !== undefined) {
|
|
682
|
+
mirrorProofSettleEmissionToJs(uiProofSettleEmissionCount())
|
|
683
|
+
marker(`settle-dispatch driver=${entry.driverId} epoch=${entry.schedulerEpoch}`)
|
|
684
|
+
}
|
|
685
|
+
deliverDriverSettled(entry.driverId, entry.schedulerEpoch)
|
|
686
|
+
break
|
|
687
|
+
}
|
|
688
|
+
case 'DRIVER_DISPOSED':
|
|
689
|
+
// The UI record is gone and this envelope validated in order behind everything sealed
|
|
690
|
+
// before it, so the accepted drain id has nothing left to guard. Forgetting it at
|
|
691
|
+
// dispose() instead reset the counter under an envelope still in flight, which validated
|
|
692
|
+
// as "duplicate or out-of-order" and killed the app on the presence card (Android bug
|
|
693
|
+
// bash 2026-09-05).
|
|
694
|
+
acceptedFrameMailboxDrainIdByDriver.delete(entry.driverId)
|
|
695
|
+
break
|
|
696
|
+
default: {
|
|
697
|
+
const exhaustive: never = entry
|
|
698
|
+
throw new Error(`unrecognized driver-frame mailbox entry: ${String(exhaustive)}`)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// The arrival-ordered drain queue. Receipts keep the one AB-BA deferral (the flush runs one JS
|
|
705
|
+
// task after the native dispatch extent, entry handlers in envelope order, no second scheduling
|
|
706
|
+
// boundary), but the DRAIN ORDER is this queue's — never the host timer's. Every receipt used to
|
|
707
|
+
// defer its drain behind its OWN setTimeout(0); RN's JavaTimerManager holds timers in a
|
|
708
|
+
// PriorityQueue ordered by targetTime alone (JavaTimerManager.kt, no timer-id tie-break), so two
|
|
709
|
+
// receipts registering in the same catch-up millisecond under host contention could fire
|
|
710
|
+
// inverted — the later envelope then validates one ahead of the accepted counter, the counter
|
|
711
|
+
// never passes the gap, and every later envelope of the driver fails "duplicate or out-of-order"
|
|
712
|
+
// (bugbash 2026-09-08, three AUTO-walk deaths around cards 96/97 with 459 ms stretched frames).
|
|
713
|
+
const pendingFrameMailboxEnvelopes: UiFrameMailboxEnvelope[] = []
|
|
714
|
+
let frameMailboxFlushScheduled = false
|
|
715
|
+
|
|
716
|
+
function flushFrameMailboxEnvelopes(): void {
|
|
717
|
+
frameMailboxFlushScheduled = false
|
|
718
|
+
while (pendingFrameMailboxEnvelopes.length > 0) {
|
|
719
|
+
drainFrameMailboxEnvelope(pendingFrameMailboxEnvelopes.shift()!)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function dispatchChannelMailboxTurnEnvelope(envelope: UiFrameMailboxEnvelope): void {
|
|
724
|
+
pendingFrameMailboxEnvelopes.push(envelope)
|
|
725
|
+
if (frameMailboxFlushScheduled) return
|
|
726
|
+
frameMailboxFlushScheduled = true
|
|
727
|
+
deferUiOriginatedCallback(flushFrameMailboxEnvelopes)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
interface PendingGestureWrite extends LivePropState {
|
|
731
|
+
readonly version: number
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Per-driver UI-runtime registry, namespaced on the worklet global by a JS-side id so two drivers never
|
|
735
|
+
// share state. Lazily created UI-side; never serialized back. dispose() deletes the record —
|
|
736
|
+
// component unmount must not strand holder outputs and graph state in the worklet global
|
|
737
|
+
// (review round 4 major 5).
|
|
738
|
+
interface UiManualGroupSlot {
|
|
739
|
+
lastCommittedTimestampMs: number
|
|
740
|
+
pendingAbsoluteTimestampMs: number
|
|
741
|
+
attendeeCount: number
|
|
742
|
+
attendees: number[]
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
interface UiGlobal {
|
|
746
|
+
__nativeMotionDrivers?: Record<number, UiDriverState>
|
|
747
|
+
// Host layout channels keep only primitive current-frame records here. It shares the driver's
|
|
748
|
+
// lifetime: disposal removes the record so a remounted gallery never inherits stale geometry.
|
|
749
|
+
__nativeMotionProjectionHostFrames?: Record<number, unknown>
|
|
750
|
+
// Command-edge-prefilled manual group slots. Frame lookup is peek-only.
|
|
751
|
+
__nativeMotionManualGroups?: Record<number, UiManualGroupSlot>
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function uiDriverState(driverId: number): UiDriverState {
|
|
755
|
+
'worklet'
|
|
756
|
+
const g = globalThis as UiGlobal
|
|
757
|
+
if (g.__nativeMotionDrivers === undefined) g.__nativeMotionDrivers = {}
|
|
758
|
+
let state = g.__nativeMotionDrivers[driverId]
|
|
759
|
+
if (state === undefined) {
|
|
760
|
+
state = {
|
|
761
|
+
driverId,
|
|
762
|
+
elements: [],
|
|
763
|
+
settleNotified: true,
|
|
764
|
+
loopEpoch: 0,
|
|
765
|
+
layout: null,
|
|
766
|
+
layoutSettleWatchers: [],
|
|
767
|
+
emission: {
|
|
768
|
+
elementHandles: [],
|
|
769
|
+
elementGenerations: [],
|
|
770
|
+
elementCommitSequences: [],
|
|
771
|
+
elementMemberCounts: [],
|
|
772
|
+
elementEntryCount: 0,
|
|
773
|
+
layoutSettledKeys: [],
|
|
774
|
+
layoutSettledCount: 0,
|
|
775
|
+
driverSettleEpoch: -1,
|
|
776
|
+
disposeStaged: false,
|
|
777
|
+
commitSequence: 0,
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
g.__nativeMotionDrivers[driverId] = state
|
|
781
|
+
}
|
|
782
|
+
return state
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// @frame-path — non-creating read for the per-frame paths: after dispose() a late vsync or
|
|
786
|
+
// settle-edge re-check must observe "gone" — the get-or-create above would silently resurrect an
|
|
787
|
+
// empty record and re-leak it.
|
|
788
|
+
function uiDriverStatePeek(driverId: number): UiDriverState | null {
|
|
789
|
+
'worklet'
|
|
790
|
+
const g = globalThis as UiGlobal
|
|
791
|
+
if (g.__nativeMotionDrivers === undefined) return null
|
|
792
|
+
return g.__nativeMotionDrivers[driverId] ?? null
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// A JS error thrown across `executeOnUIRuntimeSync` is FATAL on react-native-worklets 0.5.1
|
|
796
|
+
// Release builds: the sync return path dereferences state the pending Hermes exception left
|
|
797
|
+
// behind and the process dies with SIGSEGV (fault addr 0x28, mqt_v_js) instead of rethrowing —
|
|
798
|
+
// no red screen, no log line (packet finding 2026-07-10: bisected to the layout-screens rewire
|
|
799
|
+
// 3494387, the first tree whose registry fail-loud throws crossed inside a sync worklet;
|
|
800
|
+
// reproduced on both platforms; the un-workletized-crossing SIGSEGV draft gains a sibling).
|
|
801
|
+
// Engine ops therefore NEVER throw across the boundary: the crossing worklet catches via
|
|
802
|
+
// `uiGuard`, marshals `{name, message}` back as plain data, and the JS side rethrows the same
|
|
803
|
+
// typed error through `unwrapUiOutcome`. Every caller keeps the exact fail-loud contract
|
|
804
|
+
// (name + message verbatim); only the transport is defensive.
|
|
805
|
+
//
|
|
806
|
+
// Shape: the crossing worklet is passed DIRECTLY to `executeOnUIRuntimeSync` (the pattern this
|
|
807
|
+
// file relies on everywhere), the module-scope worklet `uiGuard` (same capture class as
|
|
808
|
+
// uiDriverState) does the catching, and the fallible body is a UI-BORN closure created inside
|
|
809
|
+
// the crossing worklet — no generic `wrap(fn)` helper, so no worklet-in-closure serialization
|
|
810
|
+
// is ever required. The scheduled lane (`scheduleOnUI`) cannot rethrow synchronously and keeps
|
|
811
|
+
// its pre-existing loud UI-runtime failure via uiGuardLane — a recorded follow-up (packet).
|
|
812
|
+
// `key` completes the refusal's IDENTITY across the crossing. Class identity cannot be marshalled,
|
|
813
|
+
// so a consumer recognizes a typed refusal structurally (`isRepeatFoldRefusal`) — and it reads
|
|
814
|
+
// `name` AND `key`. Carrying only `name` and `message` left the shipping backend's refusal
|
|
815
|
+
// unrecognizable to the severity router, which then rethrew it uncaught out of a React lifecycle:
|
|
816
|
+
// the exact defect the router was added to prevent (T18-a round-9 review BLOCKING 1). A plain
|
|
817
|
+
// string rides the marshal; `undefined` means the failure named no property.
|
|
818
|
+
type UiOutcome<T> =
|
|
819
|
+
| { readonly ok: true; readonly value: T }
|
|
820
|
+
| {
|
|
821
|
+
readonly ok: false
|
|
822
|
+
readonly name: string
|
|
823
|
+
readonly message: string
|
|
824
|
+
readonly key: string | undefined
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// alloc-ok: construction — reusable UI outcome slots filled in place on every crossing; the
|
|
828
|
+
// frame path never mints a fresh {ok,value} / {ok,name,message,key} record.
|
|
829
|
+
const uiGuardSuccessSlot: { ok: true; value: unknown } = { ok: true, value: undefined }
|
|
830
|
+
const uiGuardFailureSlot: {
|
|
831
|
+
ok: false
|
|
832
|
+
name: string
|
|
833
|
+
message: string
|
|
834
|
+
key: string | undefined
|
|
835
|
+
} = { ok: false, name: 'Error', message: '', key: undefined }
|
|
836
|
+
|
|
837
|
+
function uiGuard<T>(run: () => T): UiOutcome<T> {
|
|
838
|
+
'worklet'
|
|
839
|
+
try {
|
|
840
|
+
uiGuardSuccessSlot.value = run()
|
|
841
|
+
return uiGuardSuccessSlot as UiOutcome<T>
|
|
842
|
+
} catch (e) {
|
|
843
|
+
if (!(e instanceof Error)) {
|
|
844
|
+
uiGuardFailureSlot.name = 'Error'
|
|
845
|
+
uiGuardFailureSlot.message = String(e)
|
|
846
|
+
uiGuardFailureSlot.key = undefined
|
|
847
|
+
return uiGuardFailureSlot
|
|
848
|
+
}
|
|
849
|
+
const key = (e as Error & { key?: unknown }).key
|
|
850
|
+
uiGuardFailureSlot.name = e.name
|
|
851
|
+
uiGuardFailureSlot.message = e.message
|
|
852
|
+
uiGuardFailureSlot.key = typeof key === 'string' ? key : undefined
|
|
853
|
+
return uiGuardFailureSlot
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// DECLARATION ORDER IS LOAD-BEARING for every worklet below: the workletizer emits each one as
|
|
858
|
+
// a non-hoisted const, so a worklet may only reference worklets declared ABOVE it — a forward
|
|
859
|
+
// capture is `undefined` at module load under Hermes and dies as "undefined is not a function"
|
|
860
|
+
// on the UI runtime, with no JS stack. Pinned by check-worklet-forward-refs.test.ts.
|
|
861
|
+
|
|
862
|
+
function uiSetMirrorActive(element: UiElementState, key: string, active: boolean): void {
|
|
863
|
+
'worklet'
|
|
864
|
+
const mirror = element.layoutMirror[key]
|
|
865
|
+
if (mirror !== undefined) mirror.active.value = active
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function uiSetMirrorTarget(element: UiElementState, key: string, target: number): void {
|
|
869
|
+
'worklet'
|
|
870
|
+
const mirror = element.layoutMirror[key]
|
|
871
|
+
if (mirror !== undefined) mirror.target.value = target
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Two-lane guard for crossings that ride `cross()`: the manual (sync) lane marshals the outcome
|
|
875
|
+
// back for a JS-side rethrow; the live (scheduled) lane cannot marshal synchronously — and must
|
|
876
|
+
// NEVER rethrow on the UI runtime (a bare std::terminate on iOS — the AUTO SIGABRT class, payload
|
|
877
|
+
// agent-2026-07-29-967871). A live-lane failure marshals {name, message, code} to RN through the
|
|
878
|
+
// same scheduleOnRN severity channel the settle dispatches ride (loud there — development throws
|
|
879
|
+
// on the RN side after unwind, production reports) and retires the affected element's flight:
|
|
880
|
+
// its props/holds clear so it settles at its committed values — the last valid visual state,
|
|
881
|
+
// never a half-applied frame continuing silently.
|
|
882
|
+
function uiGuardLane(
|
|
883
|
+
manual: boolean,
|
|
884
|
+
run: () => void,
|
|
885
|
+
driverId: number,
|
|
886
|
+
affectedHandle: number | undefined,
|
|
887
|
+
// Whether `run` commits ATOMICALLY — builds everything before mutating anything, so a refusal
|
|
888
|
+
// leaves the element exactly as it found it. Only the command lane does; `uiApplyWrite`
|
|
889
|
+
// re-anchors a contending generator inside its per-key mutation loop, so a refusal there is
|
|
890
|
+
// half-applied and MUST still retire. Passed explicitly at every site (never defaulted) so a new
|
|
891
|
+
// lane has to state its own answer rather than inherit a wrong one (round-10 BLOCKING 2).
|
|
892
|
+
commitsAtomically: boolean,
|
|
893
|
+
): UiOutcome<void> {
|
|
894
|
+
'worklet'
|
|
895
|
+
const outcome = uiGuard(run)
|
|
896
|
+
if (!manual && !outcome.ok) {
|
|
897
|
+
// REQ-DRIVER-034: a dual-authority refusal of a bound-value retarget is a designed operating
|
|
898
|
+
// condition of the ADMITTED pin pattern (a bound MotionValue animated on the same key), not a
|
|
899
|
+
// lane fault. The synchronous lane hands it to the adapter's catch-and-latch; the scheduled
|
|
900
|
+
// lane cannot marshal a throw back, so it delivers the refusal to the driver's JS subscribers
|
|
901
|
+
// (the adapter's latch) instead. The refusal throws from the write's validate-BEFORE-mutate
|
|
902
|
+
// phase, so the element is exactly as the refused write found it — no retire, no severity
|
|
903
|
+
// report while a subscriber exists, and the admitted flight runs on. The crossing stays
|
|
904
|
+
// fire-and-forget O(1) (REQ-DRIVER-013): one extra scheduleOnRN on the refusal path only.
|
|
905
|
+
if (isBoundValueWriteRefusalIdentity(outcome.name)) {
|
|
906
|
+
scheduleOnRN(
|
|
907
|
+
notifyBoundValueWriteRefusal,
|
|
908
|
+
driverId,
|
|
909
|
+
affectedHandle ?? -1,
|
|
910
|
+
outcome.key ?? '',
|
|
911
|
+
outcome.message,
|
|
912
|
+
)
|
|
913
|
+
return outcome
|
|
914
|
+
}
|
|
915
|
+
scheduleOnRN(
|
|
916
|
+
dispatchDriverLaneFailure,
|
|
917
|
+
driverId,
|
|
918
|
+
outcome.name,
|
|
919
|
+
outcome.message,
|
|
920
|
+
'driver-lane-failure',
|
|
921
|
+
// Definite string across the marshal — `undefined` is not a value this crossing carries
|
|
922
|
+
// (the `releasesGesture` precedent, r8 major 24). Empty means the failure named no property.
|
|
923
|
+
outcome.key ?? '',
|
|
924
|
+
)
|
|
925
|
+
// A FOLD refusal from an ATOMIC lane is not a lane fault. It is a typed, per-property refusal
|
|
926
|
+
// raised in the build phase, so the element is exactly as the refused command found it and
|
|
927
|
+
// every other prop is healthy. Retiring destroyed all of them — the direct negation of the
|
|
928
|
+
// ratified severity law's "remaining keys proceed" (round-9 BLOCKING 2). Every other failure,
|
|
929
|
+
// and every failure from a lane that mutates as it goes, leaves UI state of unknown validity
|
|
930
|
+
// and still retires.
|
|
931
|
+
const perPropertyRefusal =
|
|
932
|
+
commitsAtomically && isRepeatFoldRefusalIdentity(outcome.name, outcome.key)
|
|
933
|
+
if (perPropertyRefusal) {
|
|
934
|
+
// Nothing to retire — but the refused command still consumed a loop epoch, and the JS side
|
|
935
|
+
// already re-armed the vsync callback for it. The retire used to re-arm the settle edge as a
|
|
936
|
+
// side effect; without it a refusal on an ALREADY-SETTLED element leaves `settleNotified`
|
|
937
|
+
// latched true from the previous settle, so `frameStep` never reaches its quiescent branch:
|
|
938
|
+
// the loop runs forever and the settle dispatch never fires (round-10 BLOCKING 1).
|
|
939
|
+
// PEEK, never get-or-create: a refusal must not mint a record for a driver that has been
|
|
940
|
+
// disposed. The retire arm is reached only with a live handle, so it cannot; this arm has no
|
|
941
|
+
// such guarantee once a future lane passes `commitsAtomically` (round-10 review observation).
|
|
942
|
+
const state = uiDriverStatePeek(driverId)
|
|
943
|
+
if (state !== null) state.settleNotified = false
|
|
944
|
+
} else if (affectedHandle !== undefined) {
|
|
945
|
+
const state = uiDriverState(driverId)
|
|
946
|
+
const element = state.elements[affectedHandle]
|
|
947
|
+
if (element !== undefined) {
|
|
948
|
+
// The retire mirrors the stop command's COMPLETE semantics (review 0f37d35ab9cd): the
|
|
949
|
+
// collections are readonly properties, so keys are deleted rather than the cells
|
|
950
|
+
// reassigned; propKeys is emptied (propKeys mirrors props at every mutation edge — a
|
|
951
|
+
// stale entry breaks uiStepAll's invariant); and every mirror active flag clears with
|
|
952
|
+
// its target reset to committed, so the layout mirror lane can END — a latched
|
|
953
|
+
// active=true would withhold a concurrent layout flight indefinitely. Committed values
|
|
954
|
+
// stay — the last valid visual state.
|
|
955
|
+
for (const key of Object.keys(element.props)) delete element.props[key]
|
|
956
|
+
for (const key of Object.keys(element.contending)) delete element.contending[key]
|
|
957
|
+
for (const key of Object.keys(element.heldVelocity)) delete element.heldVelocity[key]
|
|
958
|
+
for (const key of Object.keys(element.layoutMirror)) {
|
|
959
|
+
uiSetMirrorActive(element, key, false)
|
|
960
|
+
const committed = element.committed[key]
|
|
961
|
+
if (committed !== undefined) uiSetMirrorTarget(element, key, committed)
|
|
962
|
+
}
|
|
963
|
+
element.active = false
|
|
964
|
+
element.heldCount = 0
|
|
965
|
+
element.propKeys.length = 0
|
|
966
|
+
state.settleNotified = false // re-arm the settle edge so the retirement notifies
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return outcome
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// The RN-side severity-law reporter for live driver-lane failures (iOS AUTO SIGABRT Phase 2):
|
|
974
|
+
// scheduled from the UI lane like the settle dispatches. Development throws HERE on the RN side
|
|
975
|
+
// (the standing severity law); production reports through the error channel. Mirrors
|
|
976
|
+
// motion/severity.ts's ambient law — the driver layer stays motion-import-free, and the law is
|
|
977
|
+
// __DEV__-defined so the mirror cannot drift.
|
|
978
|
+
declare const __DEV__: boolean | undefined
|
|
979
|
+
|
|
980
|
+
function dispatchDriverLaneFailure(
|
|
981
|
+
driverId: number,
|
|
982
|
+
name: string,
|
|
983
|
+
message: string,
|
|
984
|
+
code: string,
|
|
985
|
+
// Empty means the failure named no property — `undefined` never crosses this marshal.
|
|
986
|
+
key: string,
|
|
987
|
+
): void {
|
|
988
|
+
const error = new Error(
|
|
989
|
+
`worklet driver ${String(driverId)}: [${code}] ${name}: ${message}`,
|
|
990
|
+
) as Error & { name: string; key?: string }
|
|
991
|
+
// The refusal's identity survives to the RN side too: a consumer's error channel receives the
|
|
992
|
+
// same `{name, key}` the synchronous lane's rethrow carries, so one predicate recognizes a fold
|
|
993
|
+
// refusal wherever it arrives from.
|
|
994
|
+
if (key !== '') {
|
|
995
|
+
error.name = name
|
|
996
|
+
error.key = key
|
|
997
|
+
}
|
|
998
|
+
// Severity delivery defers one JS task (seal r3, F1): this scheduleOnRN target runs inside
|
|
999
|
+
// the native UI→JS dispatch extent, and a JS throw crossing that extent is the AB-BA class —
|
|
1000
|
+
// the same deferral seam the settle dispatches ride. Development still throws, production
|
|
1001
|
+
// still reports — one task later, after the dispatcher unwinds.
|
|
1002
|
+
deferUiOriginatedCallback(() => {
|
|
1003
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
|
|
1004
|
+
console.error(error)
|
|
1005
|
+
})
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// REQ-DRIVER-034: per-driver JS subscribers to the scheduled lane's dual-authority bound-value
|
|
1009
|
+
// refusals. Keyed by driverId because the UI→RN marshal carries primitives only; the driver's
|
|
1010
|
+
// `onBoundValueWriteRefusal` is the only writer and `dispose()` the only bulk deleter.
|
|
1011
|
+
const boundValueWriteRefusalListenersByDriverId = new Map<
|
|
1012
|
+
number,
|
|
1013
|
+
Set<(handle: number, key: string) => void>
|
|
1014
|
+
>()
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* The scheduled lane's dual-authority refusal delivery (REQ-DRIVER-034). The live lane cannot
|
|
1018
|
+
* marshal the refusal back into the adapter's synchronous catch, so it is delivered to the
|
|
1019
|
+
* driver's subscribers — the adapter latches `delegateRefusesOwnedWrites` and owned writes stay
|
|
1020
|
+
* with the flight. A refusal with NO subscriber is a consumer riding the retarget lane without
|
|
1021
|
+
* its designed terminal: it reports through the same severity channel any lane failure takes.
|
|
1022
|
+
* Delivery defers one JS task like every UI-originated dispatch (the seal-r3 AB-BA law).
|
|
1023
|
+
*/
|
|
1024
|
+
function notifyBoundValueWriteRefusal(
|
|
1025
|
+
driverId: number,
|
|
1026
|
+
handle: number,
|
|
1027
|
+
key: string,
|
|
1028
|
+
message: string,
|
|
1029
|
+
): void {
|
|
1030
|
+
const listeners = boundValueWriteRefusalListenersByDriverId.get(driverId)
|
|
1031
|
+
if (listeners === undefined || listeners.size === 0) {
|
|
1032
|
+
dispatchDriverLaneFailure(
|
|
1033
|
+
driverId,
|
|
1034
|
+
BOUND_VALUE_WRITE_REFUSAL_NAME,
|
|
1035
|
+
message,
|
|
1036
|
+
'driver-lane-failure',
|
|
1037
|
+
key,
|
|
1038
|
+
)
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
deferUiOriginatedCallback(() => {
|
|
1042
|
+
for (const listener of Array.from(listeners)) listener(handle, key)
|
|
1043
|
+
})
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// The RN-side severity-law reporter for drained stale projection-tree reports (iOS AUTO SIGABRT
|
|
1047
|
+
// F2): a stale-tree event on a KNOWN-then-torn-down id is a ROUTINE teardown race, not a contract
|
|
1048
|
+
// violation — development warns, production reports an Error through the console channel, and
|
|
1049
|
+
// neither lane throws (the tree already no-oped the stale update at its last valid visual state).
|
|
1050
|
+
// THREE codes escalate (seal r7 F4, seal r8 F2, seal r9 F1) plus the ring's own overflow record
|
|
1051
|
+
// (seal r9 F3 — contained reports dropped before delivery): a never-registered id is a
|
|
1052
|
+
// wrong-forest/mistyped publish, an invalid-frame-input report is malformed input on a LIVE
|
|
1053
|
+
// registered node (stale ids are reported before validation ever runs), and a non-finite-output
|
|
1054
|
+
// report is undefined scale geometry on a live node (refused, frozen at the last valid finite
|
|
1055
|
+
// state) — fail-loud classes, so development throws, deferred one JS task through the same
|
|
1056
|
+
// seal-r3 seam as dispatchDriverLaneFailure, and production reports. The report type is a
|
|
1057
|
+
// structural mirror of the core tree's drain record (the worklet-layout barrel keeps the core
|
|
1058
|
+
// layout declaration sets pinned), and the law mirrors motion/severity.ts exactly like
|
|
1059
|
+
// dispatchDriverLaneFailure — the driver layer stays motion-import-free.
|
|
1060
|
+
function deliverProjectionStaleReport(report: {
|
|
1061
|
+
readonly source: string
|
|
1062
|
+
readonly code: string
|
|
1063
|
+
readonly detail: string
|
|
1064
|
+
}): void {
|
|
1065
|
+
const message = `[${report.code}] ${report.detail} (projection tree, iOS AUTO SIGABRT campaign)`
|
|
1066
|
+
if (
|
|
1067
|
+
report.code === 'projection-unregistered-node' ||
|
|
1068
|
+
report.code === 'projection-invalid-frame-input' ||
|
|
1069
|
+
report.code === 'projection-non-finite-output' ||
|
|
1070
|
+
report.code === 'projection-report-overflow'
|
|
1071
|
+
) {
|
|
1072
|
+
const refusal = new Error(message)
|
|
1073
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw refusal
|
|
1074
|
+
console.error(refusal)
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) {
|
|
1078
|
+
console.warn(message)
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
console.error(new Error(message))
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function unwrapUiOutcome<T>(outcome: UiOutcome<T>): T {
|
|
1085
|
+
if (outcome.ok) return outcome.value
|
|
1086
|
+
const error = new Error(outcome.message) as Error & { key?: string }
|
|
1087
|
+
error.name = outcome.name
|
|
1088
|
+
// Reattached so the rethrown error carries the same identity the reference backend's typed
|
|
1089
|
+
// refusal does — `{name, key}` is what a consumer branches on (`isRepeatFoldRefusal`).
|
|
1090
|
+
if (outcome.key !== undefined) error.key = outcome.key
|
|
1091
|
+
throw error
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Async half of `cross`: named worklet so scheduleOnUI does not mint an anonymous UI lambda
|
|
1095
|
+
// that captures another worklet (diamond-DAG serialization class). `work` is a schedule arg.
|
|
1096
|
+
function invokeAsyncCrossWork(work: (synchronous: boolean) => UiOutcome<void>): void {
|
|
1097
|
+
'worklet'
|
|
1098
|
+
work(false)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Live count of UI-runtime driver records — the disposal path's observable (device proofs assert
|
|
1102
|
+
// it stays bounded across remounts; a leak here grows monotonically, review round 4 major 5).
|
|
1103
|
+
export function uiDriverStateCount(): number {
|
|
1104
|
+
return unwrapUiOutcome(
|
|
1105
|
+
executeOnUIRuntimeSync(() => {
|
|
1106
|
+
'worklet'
|
|
1107
|
+
return uiGuard(() => {
|
|
1108
|
+
const g = globalThis as UiGlobal
|
|
1109
|
+
if (g.__nativeMotionDrivers === undefined) return 0
|
|
1110
|
+
return Object.keys(g.__nativeMotionDrivers).length
|
|
1111
|
+
})
|
|
1112
|
+
})(),
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Settle-channel forensics (iOS AUTO SIGABRT settle-gap investigation, seal-r15 F2; seal-r16
|
|
1117
|
+
// F2 moves the public surface behind `@unrulysystems/native-motion/verification`). The JS-side
|
|
1118
|
+
// marker is installed by the proof app's ExamplesScreen; unset elsewhere, so every push is a
|
|
1119
|
+
// no-op in ordinary builds. UI-side counters are PRIMITIVE UI-global cells that never ride the
|
|
1120
|
+
// channel under test — and they only mutate when the proof app has armed the witness (the
|
|
1121
|
+
// marker install at driver mount arms UI; ordinary consumers never arm, so settle/completion
|
|
1122
|
+
// paths stay zero-cost).
|
|
1123
|
+
interface ProofSettleChannelUiGlobal {
|
|
1124
|
+
__nativeMotionProofWitnessArmed?: boolean
|
|
1125
|
+
__nativeMotionProofSettleEmissions?: number
|
|
1126
|
+
__nativeMotionProofLayoutCallbackInvocations?: number
|
|
1127
|
+
__nativeMotionProofLayoutCallbackEpochDrops?: number
|
|
1128
|
+
__nativeMotionProofLayoutCallbackCancelled?: number
|
|
1129
|
+
__nativeMotionProofLayoutOnCompleteScheduled?: number
|
|
1130
|
+
}
|
|
1131
|
+
type ProofMarkerJsGlobal = {
|
|
1132
|
+
__nativeMotionProofMarker?: (marker: string) => void
|
|
1133
|
+
__nativeMotionLastProofSettleEmissions?: number
|
|
1134
|
+
__nativeMotionLastProofLayoutCallback?: string
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function isProofMarkerInstalled(): boolean {
|
|
1138
|
+
return (globalThis as ProofMarkerJsGlobal).__nativeMotionProofMarker !== undefined
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Arm the UI-runtime witness cells. Called from driver mount when the proof marker is present,
|
|
1142
|
+
// and from the verification entry for isolation tests / verification screens.
|
|
1143
|
+
export function armUiProofWitnesses(): void {
|
|
1144
|
+
unwrapUiOutcome(
|
|
1145
|
+
executeOnUIRuntimeSync(() => {
|
|
1146
|
+
'worklet'
|
|
1147
|
+
return uiGuard(() => {
|
|
1148
|
+
;(globalThis as ProofSettleChannelUiGlobal).__nativeMotionProofWitnessArmed = true
|
|
1149
|
+
})
|
|
1150
|
+
})(),
|
|
1151
|
+
)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Mirror the latest UI settle-emission count onto the JS global + marker. Called from the
|
|
1155
|
+
// JS-side settle dispatch (not from the UI worklet) so the worklet-closures gate never sees a
|
|
1156
|
+
// non-worklet helper capture on the frame path. The public gallery reads the JS cell without
|
|
1157
|
+
// importing engine-internal readers (purity ban: only App.tsx may import `/verification`).
|
|
1158
|
+
function mirrorProofSettleEmissionToJs(count: number): void {
|
|
1159
|
+
const g = globalThis as ProofMarkerJsGlobal
|
|
1160
|
+
g.__nativeMotionLastProofSettleEmissions = count
|
|
1161
|
+
g.__nativeMotionProofMarker?.(`ui-settle-emissions=${count}`)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// The UI-side settle-emission witness count, read synchronously (never through the channel
|
|
1165
|
+
// under test). Verification/internal only — not on the consumer-facing package root.
|
|
1166
|
+
export function uiProofSettleEmissionCount(): number {
|
|
1167
|
+
return unwrapUiOutcome(
|
|
1168
|
+
executeOnUIRuntimeSync(() => {
|
|
1169
|
+
'worklet'
|
|
1170
|
+
return uiGuard(
|
|
1171
|
+
() => (globalThis as ProofSettleChannelUiGlobal).__nativeMotionProofSettleEmissions ?? 0,
|
|
1172
|
+
)
|
|
1173
|
+
})(),
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// The layout-completion callback's witness counters packed for one proof-log line —
|
|
1178
|
+
// invocations / epoch-guard drops / cancellations / onComplete scheduled (seal-r15 F2). A
|
|
1179
|
+
// zero invocation count names Reanimated's completion delivery; invocations > scheduled names
|
|
1180
|
+
// our guard or the cancel path; scheduled > JS receipt names the crossing.
|
|
1181
|
+
export function uiProofLayoutCallbackWitness(): string {
|
|
1182
|
+
return unwrapUiOutcome(
|
|
1183
|
+
executeOnUIRuntimeSync(() => {
|
|
1184
|
+
'worklet'
|
|
1185
|
+
return uiGuard(() => {
|
|
1186
|
+
const g = globalThis as ProofSettleChannelUiGlobal
|
|
1187
|
+
return `${g.__nativeMotionProofLayoutCallbackInvocations ?? 0}/${g.__nativeMotionProofLayoutCallbackEpochDrops ?? 0}/${g.__nativeMotionProofLayoutCallbackCancelled ?? 0}/${g.__nativeMotionProofLayoutOnCompleteScheduled ?? 0}`
|
|
1188
|
+
})
|
|
1189
|
+
})(),
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Last-pushed JS-side copies (filled by scheduleOnRN when armed). The proof gallery reads these
|
|
1194
|
+
// without a package import; verification screens can use the sync readers above instead.
|
|
1195
|
+
export function lastPushedProofSettleEmissionCount(): number {
|
|
1196
|
+
return (globalThis as ProofMarkerJsGlobal).__nativeMotionLastProofSettleEmissions ?? 0
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export function lastPushedProofLayoutCallbackWitness(): string {
|
|
1200
|
+
return (globalThis as ProofMarkerJsGlobal).__nativeMotionLastProofLayoutCallback ?? '0/0/0/0'
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function uiLayoutSlot(state: UiDriverState, spring: SpringConfig | undefined): UiLayoutState {
|
|
1204
|
+
'worklet'
|
|
1205
|
+
if (state.layout === null) state.layout = createUiLayoutState(spring, state.driverId)
|
|
1206
|
+
return state.layout
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function uiElementAt(state: UiDriverState, handle: number): UiElementState {
|
|
1210
|
+
'worklet'
|
|
1211
|
+
const element = state.elements[handle]
|
|
1212
|
+
if (element === undefined) {
|
|
1213
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the lane's
|
|
1214
|
+
// designed transport (Phase 2); this throw is the payload, never the delivery.
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
`unknown element handle ${String(handle)}: it was never registered on this driver.`,
|
|
1217
|
+
)
|
|
1218
|
+
}
|
|
1219
|
+
return element
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Generator construction ON the UI runtime — the reference driver's startGenerator/retargetGenerator,
|
|
1223
|
+
// verbatim semantics (spring default, tween positional reseed, spring retarget carries velocity).
|
|
1224
|
+
// `velocity` is the REQ-DRIVER-023 seed (the gesture release's platform handoff, unscaled); a tween is
|
|
1225
|
+
// position-based and ignores it by construction — explicit 0 so the contract reads at the seam.
|
|
1226
|
+
// A CONTENDING target drifts FROM the committed finger value — never a backward jump off the finger
|
|
1227
|
+
// (REQ-DRIVER-026 / G-INV-2, review major 25; mirror of the reference driver's anchorContentionToLive).
|
|
1228
|
+
// A keyframe array's non-null FIRST element would restart near keyframes[0], rewinding the drift;
|
|
1229
|
+
// re-seed the first keyframe from-current (null-first, R8-F2) so the trajectory runs the live value →
|
|
1230
|
+
// remaining keyframes. A scalar and an already-null-first array are unchanged.
|
|
1231
|
+
function uiAnchorContentionToLive(to: PropTargetTo): PropTargetTo {
|
|
1232
|
+
'worklet'
|
|
1233
|
+
if (typeof to === 'number' || to[0] === null) return to
|
|
1234
|
+
return Object.freeze([null, ...to.slice(1)])
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const UI_KEYFRAME_DEFAULT_DURATION_MS = 300
|
|
1238
|
+
const UI_KEYFRAME_VELOCITY_SAMPLE_MS = 5
|
|
1239
|
+
|
|
1240
|
+
interface UiKeyframeSegment {
|
|
1241
|
+
readonly start: number
|
|
1242
|
+
readonly end: number
|
|
1243
|
+
readonly to: number
|
|
1244
|
+
readonly generator: Generator | null
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function uiKeyframesValueAt(
|
|
1248
|
+
segments: readonly UiKeyframeSegment[],
|
|
1249
|
+
first: number,
|
|
1250
|
+
firstTime: number,
|
|
1251
|
+
last: number,
|
|
1252
|
+
lastTime: number,
|
|
1253
|
+
elapsedMs: number,
|
|
1254
|
+
): number {
|
|
1255
|
+
'worklet'
|
|
1256
|
+
if (elapsedMs <= firstTime) return first
|
|
1257
|
+
if (elapsedMs >= lastTime) return last
|
|
1258
|
+
for (let index = 0; index < segments.length; index++) {
|
|
1259
|
+
const segment = segments[index]
|
|
1260
|
+
if (segment === undefined) break
|
|
1261
|
+
if (elapsedMs <= segment.end) {
|
|
1262
|
+
if (segment.generator === null) return segment.to
|
|
1263
|
+
return segment.generator.sample(elapsedMs - segment.start).value
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return last
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// UI-safe counterpart of core's public keyframesGenerator. `uiStartGenerator` receives only targets
|
|
1270
|
+
// and configs that the JS-side adapter has already validated and normalized, so it must not cross
|
|
1271
|
+
// core's raw public-boundary capture/refusal helpers. File-tagging that public module made the
|
|
1272
|
+
// Worklets plugin rewrite ordinary typed-error classes to absent `__classFactory` calls on device.
|
|
1273
|
+
// This constructor retains the pinned timing semantics for valid prepared input while closing that
|
|
1274
|
+
// boundary: each segment delegates easing math to the already UI-safe timingGenerator.
|
|
1275
|
+
//
|
|
1276
|
+
// alloc-ok: command edge. Segment generators and their mutable samples are constructed once; the
|
|
1277
|
+
// returned sampler mutates only its reused output record on the frame path.
|
|
1278
|
+
export function uiKeyframesGenerator(
|
|
1279
|
+
keyframes: readonly (number | null)[],
|
|
1280
|
+
config: KeyframesConfig,
|
|
1281
|
+
from: number,
|
|
1282
|
+
): Generator {
|
|
1283
|
+
'worklet'
|
|
1284
|
+
const count = keyframes.length
|
|
1285
|
+
const duration = config.duration ?? UI_KEYFRAME_DEFAULT_DURATION_MS
|
|
1286
|
+
const values: number[] = []
|
|
1287
|
+
for (let index = 0; index < count; index++) {
|
|
1288
|
+
const keyframe = keyframes[index]
|
|
1289
|
+
if (keyframe === undefined)
|
|
1290
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN (Phase 2); a total
|
|
1291
|
+
// target guard the JS-side boundary already enforces, never a UI delivery.
|
|
1292
|
+
throw new Error(`prepared keyframes target is missing index ${String(index)}`)
|
|
1293
|
+
values.push(keyframe === null ? from : keyframe)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const segments: UiKeyframeSegment[] = []
|
|
1297
|
+
for (let index = 0; index < count - 1; index++) {
|
|
1298
|
+
const segmentFrom = values[index]
|
|
1299
|
+
const segmentTo = values[index + 1]
|
|
1300
|
+
if (segmentFrom === undefined || segmentTo === undefined)
|
|
1301
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN (Phase 2); a total
|
|
1302
|
+
// target guard the JS-side boundary already enforces, never a UI delivery.
|
|
1303
|
+
throw new Error(`prepared keyframes target is incomplete at segment ${String(index)}`)
|
|
1304
|
+
const start = keyframeOffsetAt(config.times, index, count) * duration
|
|
1305
|
+
const end = keyframeOffsetAt(config.times, index + 1, count) * duration
|
|
1306
|
+
const span = end - start
|
|
1307
|
+
segments.push({
|
|
1308
|
+
start,
|
|
1309
|
+
end,
|
|
1310
|
+
to: segmentTo,
|
|
1311
|
+
// A duplicated offset is legal: core treats it as an instantaneous transition. Avoid
|
|
1312
|
+
// timingGenerator's t/0 path and preserve the same exact `to` result here.
|
|
1313
|
+
generator:
|
|
1314
|
+
span <= 0
|
|
1315
|
+
? null
|
|
1316
|
+
: timingGenerator(segmentTo, {
|
|
1317
|
+
duration: span,
|
|
1318
|
+
ease: keyframeSegmentEase(config.ease, index),
|
|
1319
|
+
})({ from: segmentFrom, velocity: 0 }),
|
|
1320
|
+
})
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const firstSegment = segments[0]
|
|
1324
|
+
const lastSegment = segments[segments.length - 1]
|
|
1325
|
+
const first = firstSegment === undefined ? from : values[0]!
|
|
1326
|
+
const last = lastSegment === undefined ? first : lastSegment.to
|
|
1327
|
+
const firstTime = firstSegment?.start ?? 0
|
|
1328
|
+
const lastTime = lastSegment?.end ?? 0
|
|
1329
|
+
|
|
1330
|
+
const out = { value: first, velocity: 0, done: false }
|
|
1331
|
+
return {
|
|
1332
|
+
sample(elapsedMs: number) {
|
|
1333
|
+
const current = uiKeyframesValueAt(segments, first, firstTime, last, lastTime, elapsedMs)
|
|
1334
|
+
const previousElapsedMs = Math.max(elapsedMs - UI_KEYFRAME_VELOCITY_SAMPLE_MS, 0)
|
|
1335
|
+
const elapsedDeltaMs = elapsedMs - previousElapsedMs
|
|
1336
|
+
out.value = elapsedMs >= duration ? last : current
|
|
1337
|
+
out.velocity = elapsedDeltaMs
|
|
1338
|
+
? ((current -
|
|
1339
|
+
uiKeyframesValueAt(segments, first, firstTime, last, lastTime, previousElapsedMs)) /
|
|
1340
|
+
elapsedDeltaMs) *
|
|
1341
|
+
1000
|
|
1342
|
+
: 0
|
|
1343
|
+
out.done = elapsedMs >= duration
|
|
1344
|
+
return out
|
|
1345
|
+
},
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// R8-F1 (UI-runtime mirror of the reference driver's springKeyframeGenerator): a keyframe ARRAY under
|
|
1350
|
+
// an EXPLICIT spring. A spring interpolates EXACTLY two keyframes — the pinned Motion throws for more.
|
|
1351
|
+
// Two run spring physics from keyframes[0] (the explicit origin; null first reads `from`, R8-F2) to
|
|
1352
|
+
// keyframes[1]; a longer array FAILS LOUD naming the successor so it never reaches the spring generator.
|
|
1353
|
+
function uiSpringKeyframeGenerator(
|
|
1354
|
+
config: SpringConfig,
|
|
1355
|
+
keyframes: readonly (number | null)[],
|
|
1356
|
+
from: number,
|
|
1357
|
+
velocity: number,
|
|
1358
|
+
): Generator {
|
|
1359
|
+
'worklet'
|
|
1360
|
+
if (keyframes.length !== 2) {
|
|
1361
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — the R8-F1 refusal marshals to RN through the
|
|
1362
|
+
// lane's designed transport (Phase 2); the message names the successor, never a UI delivery.
|
|
1363
|
+
throw new Error(
|
|
1364
|
+
`a spring transition cannot interpolate through ${keyframes.length} keyframes — a spring ` +
|
|
1365
|
+
'animates exactly two (origin → target). Keyframe index 2 (the successor) is unreachable; ' +
|
|
1366
|
+
'use a tween for a multi-keyframe array (REQ-API-033, R8-F1).',
|
|
1367
|
+
)
|
|
1368
|
+
}
|
|
1369
|
+
const origin = keyframes[0]
|
|
1370
|
+
const target = keyframes[1]
|
|
1371
|
+
// Validation guarantees a length-2 array whose only legal null is index 0 and whose target parses;
|
|
1372
|
+
// the guard keeps this TOTAL under noUncheckedIndexedAccess (never hit for a valid target).
|
|
1373
|
+
if (origin === undefined || target === undefined || target === null) {
|
|
1374
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN (Phase 2); a total
|
|
1375
|
+
// shape guard the JS-side validation already enforces, never a UI delivery.
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
'malformed two-keyframe spring array — a non-null numeric target is required (REQ-API-033).',
|
|
1378
|
+
)
|
|
1379
|
+
}
|
|
1380
|
+
const seedFrom = origin === null ? from : origin
|
|
1381
|
+
return resolveSpringGenerator(target, config)({ from: seedFrom, velocity })
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// The transition's own resolved iteration length in ms, or null when only the scan can measure it —
|
|
1385
|
+
// the UI mirror of the reference driver's `knownIterationDurationMs` (the pin's `calculatedDuration`
|
|
1386
|
+
// rule). Configs were resolved JS-side, so this reads them directly.
|
|
1387
|
+
function uiKnownIterationDurationMs(transition: PlainTransition, to: PropTargetTo): number | null {
|
|
1388
|
+
'worklet'
|
|
1389
|
+
// Narrows the plain-data union off the inertia arm (and the U7a instant arm, which never
|
|
1390
|
+
// reaches the fold that consumes this) as well as answering the lane's first question.
|
|
1391
|
+
if (transition.kind === 'inertia' || transition.kind === 'instant') return null
|
|
1392
|
+
// Core's ONE lane rule, never re-derived here — this backend held the second parallel copy that
|
|
1393
|
+
// the T18-a fix-up review found drifting (round 3 MAJOR).
|
|
1394
|
+
const lane = iterationMeasurementLane(
|
|
1395
|
+
typeof to === 'number',
|
|
1396
|
+
transition.explicitSpring,
|
|
1397
|
+
transition.kind === 'tween',
|
|
1398
|
+
)
|
|
1399
|
+
if (lane === 'keyframes') return transition.keyframes.duration ?? null
|
|
1400
|
+
if (lane === 'timing') return transition.config.duration ?? null
|
|
1401
|
+
const resolved = resolveSpring(transition.config)
|
|
1402
|
+
return resolved.calculatedDuration !== null && resolved.calculatedDuration > 0
|
|
1403
|
+
? resolved.calculatedDuration
|
|
1404
|
+
: null
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function uiUnrepeatedGenerator(
|
|
1408
|
+
transition: PlainTransition,
|
|
1409
|
+
to: PropTargetTo,
|
|
1410
|
+
from: number,
|
|
1411
|
+
velocity: number,
|
|
1412
|
+
reversed: boolean,
|
|
1413
|
+
): Generator {
|
|
1414
|
+
'worklet'
|
|
1415
|
+
// U7a: the instant lane — no trajectory generator runs; the final keyframe commits past t=0
|
|
1416
|
+
// (the command edge seeds committed from sample(0), so nothing moves at command time). Never
|
|
1417
|
+
// folded, so `reversed` is always false here; the arm stays total over the union regardless.
|
|
1418
|
+
if (transition.kind === 'instant') {
|
|
1419
|
+
return instantGenerator(instantFinalKeyframe(to))({ from, velocity: 0 })
|
|
1420
|
+
}
|
|
1421
|
+
if (typeof to !== 'number' && reversed && transition.kind !== 'inertia') {
|
|
1422
|
+
// Resolve from-current nulls BEFORE reversing (R8-F2), mirroring the reference driver: the pin
|
|
1423
|
+
// mirrors the RESOLVED keyframes, and a null that slid to the tail would re-read `from` at the
|
|
1424
|
+
// wrong end. `times`/`ease` are deliberately untouched — the pin reverses keyframes only.
|
|
1425
|
+
const resolvedKeyframes: number[] = []
|
|
1426
|
+
for (let index = to.length - 1; index >= 0; index--) {
|
|
1427
|
+
const value = to[index]
|
|
1428
|
+
resolvedKeyframes.push(value === null || value === undefined ? from : value)
|
|
1429
|
+
}
|
|
1430
|
+
const mirroredFrom = resolvedKeyframes[0] ?? from
|
|
1431
|
+
if (transition.explicitSpring) {
|
|
1432
|
+
return uiSpringKeyframeGenerator(
|
|
1433
|
+
transition.config,
|
|
1434
|
+
resolvedKeyframes,
|
|
1435
|
+
mirroredFrom,
|
|
1436
|
+
-velocity,
|
|
1437
|
+
)
|
|
1438
|
+
}
|
|
1439
|
+
return uiKeyframesGenerator(resolvedKeyframes, transition.keyframes, mirroredFrom)
|
|
1440
|
+
}
|
|
1441
|
+
if (typeof to !== 'number') {
|
|
1442
|
+
if (transition.kind === 'inertia') {
|
|
1443
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — a keyframe array under an inertia transition
|
|
1444
|
+
// is a command-shape defect the release never produces (R16); fail loud, never a silent
|
|
1445
|
+
// spring/keyframe misroute.
|
|
1446
|
+
throw new Error(
|
|
1447
|
+
'a keyframe array cannot run under an inertia transition (REQ-GESTURE-023, R16).',
|
|
1448
|
+
)
|
|
1449
|
+
}
|
|
1450
|
+
// A keyframe ARRAY routes by `explicitSpring`, NOT `kind` — the UI-runtime mirror of the reference
|
|
1451
|
+
// driver's `transition.type === 'spring'` (REQ-API-033, R8-F1; no cross-engine skew). Only a
|
|
1452
|
+
// LITERAL spring interpolates a keyframe array as spring (two → physics; >2 → fail loud naming the
|
|
1453
|
+
// successor); a tween OR the DEFAULT (an omitted type, whether it resolved 'tween' by REQ-TIMING-006
|
|
1454
|
+
// or stayed the 'spring' fallback) routes the
|
|
1455
|
+
// array THROUGH the keyframes generator (position-based, seed velocity dropped, null-first reads
|
|
1456
|
+
// `from`). Configs were resolved JS-side (toSpringConfig/toKeyframesConfig), identical here.
|
|
1457
|
+
if (transition.explicitSpring)
|
|
1458
|
+
return uiSpringKeyframeGenerator(transition.config, to, from, velocity)
|
|
1459
|
+
return uiKeyframesGenerator(to, transition.keyframes, from)
|
|
1460
|
+
}
|
|
1461
|
+
if (transition.kind === 'inertia') {
|
|
1462
|
+
// R16 (REQ-GESTURE-023): the two-phase generator recomputes its ideal from the seed and owns
|
|
1463
|
+
// the walls; `to` is the mirror terminal only. The seed velocity is the platform release
|
|
1464
|
+
// velocity, momentum-gated upstream (REQ-GESTURE-027).
|
|
1465
|
+
return inertiaGenerator({
|
|
1466
|
+
power: transition.config.power,
|
|
1467
|
+
timeConstant: transition.config.timeConstant,
|
|
1468
|
+
bounceStiffness: transition.config.bounceStiffness,
|
|
1469
|
+
bounceDamping: transition.config.bounceDamping,
|
|
1470
|
+
restDelta: transition.config.restDelta,
|
|
1471
|
+
restSpeed: transition.config.restSpeed,
|
|
1472
|
+
...(transition.config.min !== undefined ? { min: transition.config.min } : {}),
|
|
1473
|
+
...(transition.config.max !== undefined ? { max: transition.config.max } : {}),
|
|
1474
|
+
})({ from, velocity })
|
|
1475
|
+
}
|
|
1476
|
+
// The scalar mirrored leg: origin and target swap and the seed velocity negates (the pin builds
|
|
1477
|
+
// its second generator with `velocity: -velocity`). Mirrors the reference driver exactly.
|
|
1478
|
+
const scalarTo = reversed ? from : to
|
|
1479
|
+
const scalarFrom = reversed ? to : from
|
|
1480
|
+
const scalarVelocity = reversed ? -velocity : velocity
|
|
1481
|
+
if (transition.kind === 'tween') {
|
|
1482
|
+
// transition-default-selection F5: a zero-length timing base takes the pin's
|
|
1483
|
+
// makeAnimationInstant SHAPE — hold the seed at t ≤ 0, commit the final keyframe past it —
|
|
1484
|
+
// mirroring the reference driver's `unrepeatedGenerator`. Only repeatDelay-bearing bags reach
|
|
1485
|
+
// this (a zero duration without one resolved to kind:'instant' JS-side); it keeps the fold's
|
|
1486
|
+
// zero-length plays from committing at the command edge.
|
|
1487
|
+
if (transition.config.duration === 0) {
|
|
1488
|
+
return instantGenerator(scalarTo)({ from: scalarFrom, velocity: 0 })
|
|
1489
|
+
}
|
|
1490
|
+
return timingGenerator(scalarTo, transition.config)({ from: scalarFrom, velocity: 0 })
|
|
1491
|
+
}
|
|
1492
|
+
return resolveSpringGenerator(
|
|
1493
|
+
scalarTo,
|
|
1494
|
+
transition.config,
|
|
1495
|
+
)({
|
|
1496
|
+
from: scalarFrom,
|
|
1497
|
+
velocity: scalarVelocity,
|
|
1498
|
+
})
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// T18-a (REQ-DRIVER-031): the UI-runtime entry that applies the repeat fold. It calls core's SHARED
|
|
1502
|
+
// `buildRepeatedGenerator` with its own unrepeated construction — the identical call the reference
|
|
1503
|
+
// driver makes. Parity with that backend is enforced by `driverParity.differential.test.ts`, which
|
|
1504
|
+
// executes both over the same commands and compares trajectories frame for frame; sharing the call
|
|
1505
|
+
// is not enough on its own (both seams shared it and still skewed on retarget). An inertia release
|
|
1506
|
+
// never carries authored options, so it never folds.
|
|
1507
|
+
function uiStartGenerator(
|
|
1508
|
+
transition: PlainTransition,
|
|
1509
|
+
to: PropTargetTo,
|
|
1510
|
+
from: number,
|
|
1511
|
+
velocity: number,
|
|
1512
|
+
): Generator {
|
|
1513
|
+
'worklet'
|
|
1514
|
+
// U7a: the instant lane never folds (the pin's shouldSkip ignores repeat outright), so it
|
|
1515
|
+
// narrows off with inertia here; its authored delay still crosses through `delayMs` below.
|
|
1516
|
+
const fold =
|
|
1517
|
+
transition.kind === 'inertia' || transition.kind === 'instant' ? null : transition.repeat
|
|
1518
|
+
// T18-b (REQ-TIMING-004 / REQ-DRIVER-032): the delay rebase wraps AROUND the fold-or-generator,
|
|
1519
|
+
// through core's SHARED `delayGenerator` — the identical call the reference driver makes at
|
|
1520
|
+
// both phases. An inertia release carries no authored delay (its arm omits `delayMs`, the same
|
|
1521
|
+
// totality law as `repeat`). Parity is held by the executing differential floor, not by the
|
|
1522
|
+
// shared call alone.
|
|
1523
|
+
const delayMs = transition.kind === 'inertia' ? 0 : transition.delayMs
|
|
1524
|
+
if (fold === null)
|
|
1525
|
+
return delayGenerator(uiUnrepeatedGenerator(transition, to, from, velocity, false), delayMs)
|
|
1526
|
+
// Legs built EAGERLY and passed as values (never a factory invoked on UI — the wrong-runtime
|
|
1527
|
+
// crash class the closures gate fails closed on); identical shape to the reference driver.
|
|
1528
|
+
const leg: RepeatLeg =
|
|
1529
|
+
fold.repeatType === 'mirror'
|
|
1530
|
+
? { type: 'mirror', mirrored: uiUnrepeatedGenerator(transition, to, from, velocity, true) }
|
|
1531
|
+
: { type: fold.repeatType }
|
|
1532
|
+
const folded = buildRepeatedGenerator(
|
|
1533
|
+
fold.repeat,
|
|
1534
|
+
leg,
|
|
1535
|
+
fold.repeatDelayMs,
|
|
1536
|
+
uiUnrepeatedGenerator(transition, to, from, velocity, false),
|
|
1537
|
+
uiKnownIterationDurationMs(transition, to),
|
|
1538
|
+
)
|
|
1539
|
+
if (folded instanceof Error) {
|
|
1540
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — the T18-a L3 refusal marshals to RN through the
|
|
1541
|
+
// lane's designed transport, naming the unexecutable fold rather than running a NaN trajectory.
|
|
1542
|
+
throw uiRepeatRefusal(fold.repeat, folded)
|
|
1543
|
+
}
|
|
1544
|
+
return delayGenerator(folded, delayMs)
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function uiUnrepeatedRetargetGenerator(
|
|
1548
|
+
transition: PlainTransition,
|
|
1549
|
+
live: PropStep,
|
|
1550
|
+
to: number,
|
|
1551
|
+
): Generator {
|
|
1552
|
+
'worklet'
|
|
1553
|
+
// U7a: the instant lane at the retarget seam — commit the new target past t=0 from the live
|
|
1554
|
+
// value. The retarget's own authored delay wraps this in uiRetargetGenerator, exactly like
|
|
1555
|
+
// the reference driver's retargetGenerator.
|
|
1556
|
+
if (transition.kind === 'instant') {
|
|
1557
|
+
return instantGenerator(to)({ from: live.value, velocity: 0 })
|
|
1558
|
+
}
|
|
1559
|
+
if (transition.kind === 'tween') {
|
|
1560
|
+
// F5: the zero-length timing base takes the instant SHAPE here too — the same routing as
|
|
1561
|
+
// `uiUnrepeatedGenerator`, seeded from the live value (no cross-seam skew).
|
|
1562
|
+
if (transition.config.duration === 0) {
|
|
1563
|
+
return instantGenerator(to)({ from: live.value, velocity: 0 })
|
|
1564
|
+
}
|
|
1565
|
+
return timingGenerator(to, transition.config)({ from: live.value, velocity: 0 })
|
|
1566
|
+
}
|
|
1567
|
+
if (transition.kind === 'inertia') {
|
|
1568
|
+
// R16: a retarget into an inertia transition re-seeds the two-phase generator from the LIVE
|
|
1569
|
+
// generator's sample — the same continuity law as a spring retarget (C0/C1).
|
|
1570
|
+
const at = live.generator.sample(live.elapsed)
|
|
1571
|
+
return inertiaGenerator({
|
|
1572
|
+
power: transition.config.power,
|
|
1573
|
+
timeConstant: transition.config.timeConstant,
|
|
1574
|
+
bounceStiffness: transition.config.bounceStiffness,
|
|
1575
|
+
bounceDamping: transition.config.bounceDamping,
|
|
1576
|
+
restDelta: transition.config.restDelta,
|
|
1577
|
+
restSpeed: transition.config.restSpeed,
|
|
1578
|
+
...(transition.config.min !== undefined ? { min: transition.config.min } : {}),
|
|
1579
|
+
...(transition.config.max !== undefined ? { max: transition.config.max } : {}),
|
|
1580
|
+
})({ from: at.value, velocity: at.velocity })
|
|
1581
|
+
}
|
|
1582
|
+
return retargetSpring(live.generator, live.elapsed, to, transition.config)
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* T18-a fix-up (review BLOCKER): the retarget seam carries the fold, identically to
|
|
1587
|
+
* `uiStartGenerator`. An endless repeat is never `done`, so every changed scalar animate key
|
|
1588
|
+
* arrives HERE rather than at `start` — dropping the fold stopped the loop permanently on the
|
|
1589
|
+
* first retarget. Declared below `uiUnrepeatedRetargetGenerator` because the workletizer emits
|
|
1590
|
+
* each worklet as a non-hoisted const (see the declaration-order note above).
|
|
1591
|
+
*/
|
|
1592
|
+
function uiRetargetGenerator(transition: PlainTransition, live: PropStep, to: number): Generator {
|
|
1593
|
+
'worklet'
|
|
1594
|
+
const base = uiUnrepeatedRetargetGenerator(transition, live, to)
|
|
1595
|
+
// U7a: the instant lane never folds — same narrowing as uiStartGenerator.
|
|
1596
|
+
const fold =
|
|
1597
|
+
transition.kind === 'inertia' || transition.kind === 'instant' ? null : transition.repeat
|
|
1598
|
+
// T18-b (REQ-DRIVER-032): a retarget applies its OWN delay — the pin starts a fresh animation
|
|
1599
|
+
// per retarget — wrapping the (possibly folded) trajectory AROUND the fold, identical to the
|
|
1600
|
+
// reference driver's retargetGenerator. A mid-delay retarget reseeds from the held first
|
|
1601
|
+
// keyframe at velocity 0, which is exactly what the outgoing delay wrapper reports while held.
|
|
1602
|
+
const delayMs = transition.kind === 'inertia' ? 0 : transition.delayMs
|
|
1603
|
+
if (fold === null) return delayGenerator(base, delayMs)
|
|
1604
|
+
const leg: RepeatLeg =
|
|
1605
|
+
fold.repeatType === 'mirror'
|
|
1606
|
+
? { type: 'mirror', mirrored: uiUnrepeatedGenerator(transition, to, live.value, 0, true) }
|
|
1607
|
+
: { type: fold.repeatType }
|
|
1608
|
+
const folded = buildRepeatedGenerator(
|
|
1609
|
+
fold.repeat,
|
|
1610
|
+
leg,
|
|
1611
|
+
fold.repeatDelayMs,
|
|
1612
|
+
base,
|
|
1613
|
+
uiKnownIterationDurationMs(transition, to),
|
|
1614
|
+
)
|
|
1615
|
+
if (folded instanceof Error) {
|
|
1616
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — the L3 refusal marshals to RN through the
|
|
1617
|
+
// lane's designed transport, exactly as the start seam does.
|
|
1618
|
+
throw uiRepeatRefusal(fold.repeat, folded)
|
|
1619
|
+
}
|
|
1620
|
+
return delayGenerator(folded, delayMs)
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// Clear the gesture hold on ONE prop (the matching release/stop owns it now). Idempotent — clearing
|
|
1624
|
+
// an unheld prop is a no-op, so heldCount stays exact (review r7 major 23). Command-edge only.
|
|
1625
|
+
function uiReleaseHold(element: UiElementState, key: string): void {
|
|
1626
|
+
'worklet'
|
|
1627
|
+
if (element.heldVelocity[key] !== undefined) {
|
|
1628
|
+
delete element.heldVelocity[key]
|
|
1629
|
+
element.heldCount--
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function uiTerminalTarget(to: PropTargetTo): number {
|
|
1634
|
+
'worklet'
|
|
1635
|
+
if (typeof to === 'number') return to
|
|
1636
|
+
for (let index = to.length - 1; index >= 0; index--) {
|
|
1637
|
+
const value = to[index]
|
|
1638
|
+
if (value !== null && value !== undefined) return value
|
|
1639
|
+
}
|
|
1640
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN (Phase 2); a total
|
|
1641
|
+
// shape guard the JS-side boundary already enforces, never a UI delivery.
|
|
1642
|
+
throw new Error('a property command needs a numeric terminal target for its layout mirror.')
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// alloc-ok: command-edge — grow an element's frame member slots to cover every committed key the
|
|
1646
|
+
// frame can stage (propKeys plus the path-claimed x/y/pathRotation extras). Called wherever
|
|
1647
|
+
// propKeys is rebuilt; uiEmitFrameValues fills by index and never grows these in-frame.
|
|
1648
|
+
function uiPrefillElementFrameSlots(element: UiElementState): void {
|
|
1649
|
+
'worklet'
|
|
1650
|
+
const required = element.propKeys.length + 3
|
|
1651
|
+
while (element.frameKeys.length < required) {
|
|
1652
|
+
element.frameKeys.push('')
|
|
1653
|
+
element.frameValues.push(0)
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function uiApplyCommand(state: UiDriverState, handle: number, command: PlainCommand): void {
|
|
1658
|
+
'worklet'
|
|
1659
|
+
const element = uiElementAt(state, handle)
|
|
1660
|
+
switch (command.kind) {
|
|
1661
|
+
case 'start': {
|
|
1662
|
+
// Only a gesture RELEASE (`releasesGesture`) or a `stop` ends a hold. A declarative start on a
|
|
1663
|
+
// gesture-held prop CONTENDS instead of being skipped (REQ-DRIVER-026, Motion's shared-value
|
|
1664
|
+
// model): it seeds a generator that drifts the live value while the finger still owns the prop,
|
|
1665
|
+
// so a mid-drag `animate` change moves the element and the release reads the drifted origin. The
|
|
1666
|
+
// finger's release velocity is preserved (uiLiveFor held-velocity precedence, REQ-DRIVER-025);
|
|
1667
|
+
// the hold clears only at release/stop. Mirror of the reference driver.
|
|
1668
|
+
const releasesGesture = command.releasesGesture === true
|
|
1669
|
+
// PHASE 1 — build every prop's generator WITHOUT mutating UI state; a construction that throws
|
|
1670
|
+
// (an explicit spring on a >2-keyframe array, R8-F1) aborts the WHOLE command before ANY prop
|
|
1671
|
+
// commits (fail-closed atomicity — mirror of the reference driver; review major 19).
|
|
1672
|
+
const plans: {
|
|
1673
|
+
key: string
|
|
1674
|
+
stillHeld: boolean
|
|
1675
|
+
seededValue: number
|
|
1676
|
+
generator: Generator
|
|
1677
|
+
to: PropTargetTo
|
|
1678
|
+
transition: PlainTransition
|
|
1679
|
+
}[] = []
|
|
1680
|
+
for (const key of Object.keys(command.targets)) {
|
|
1681
|
+
const target = command.targets[key]! // key came from Object.keys
|
|
1682
|
+
// A gesture-RELEASE start takes over the prop (hold clears in phase 2), so it is treated as NOT
|
|
1683
|
+
// still-held and uses the command's `from`. A CONTENDING start on a prop the finger still holds
|
|
1684
|
+
// drifts from the CURRENT live value (never `from`) so committed does not jump off the finger.
|
|
1685
|
+
const stillHeld = !releasesGesture && element.heldVelocity[key] !== undefined
|
|
1686
|
+
const from = stillHeld ? element.committed[key]! : target.from
|
|
1687
|
+
// The per-prop transition wins over the command-level one for THIS prop (R8 M2), mirror of the
|
|
1688
|
+
// reference driver. The prepared target's transition is complete; no backend fallback remains.
|
|
1689
|
+
const transition = target.transition
|
|
1690
|
+
// A still-held (contending) start drifts from the finger value: seed the first keyframe
|
|
1691
|
+
// from-current so it never rewinds to keyframes[0] (major 25); a non-held start plays the
|
|
1692
|
+
// array's own origin verbatim. Mirror of the reference driver.
|
|
1693
|
+
const generator = uiStartGenerator(
|
|
1694
|
+
transition,
|
|
1695
|
+
stillHeld ? uiAnchorContentionToLive(target.to) : target.to,
|
|
1696
|
+
from,
|
|
1697
|
+
target.velocity,
|
|
1698
|
+
)
|
|
1699
|
+
// Seed committed/value/shared from the generator's TICK-0, not the raw `from` (REQ-API-033 seed
|
|
1700
|
+
// law; review major dfeaa4bf4bcf). A scalar generator samples `from` at t=0 (no-op). A
|
|
1701
|
+
// still-held start keeps the finger-authoritative committed. sample(0) is a pure read.
|
|
1702
|
+
const seededValue = stillHeld ? from : generator.sample(0).value
|
|
1703
|
+
plans.push({ key, stillHeld, seededValue, generator, to: target.to, transition })
|
|
1704
|
+
}
|
|
1705
|
+
// PHASE 2 — commit every plan. No throw path here, so UI state mutates atomically.
|
|
1706
|
+
for (const plan of plans) {
|
|
1707
|
+
if (releasesGesture) uiReleaseHold(element, plan.key)
|
|
1708
|
+
element.props[plan.key] = {
|
|
1709
|
+
generator: plan.generator,
|
|
1710
|
+
elapsed: 0,
|
|
1711
|
+
value: plan.seededValue,
|
|
1712
|
+
done: false,
|
|
1713
|
+
}
|
|
1714
|
+
element.committed[plan.key] = plan.seededValue
|
|
1715
|
+
const shared = element.shared[plan.key]
|
|
1716
|
+
if (shared !== undefined) shared.value = plan.seededValue
|
|
1717
|
+
uiSetMirrorTarget(element, plan.key, uiTerminalTarget(plan.to))
|
|
1718
|
+
uiSetMirrorActive(element, plan.key, true)
|
|
1719
|
+
// Track the contention target so a later finger write re-anchors this generator (major 29);
|
|
1720
|
+
// `plan.to` was already captured at command time (major 20). A released/non-held start owns the
|
|
1721
|
+
// prop outright, so clear any prior contention.
|
|
1722
|
+
if (plan.stillHeld)
|
|
1723
|
+
element.contending[plan.key] = { to: plan.to, transition: plan.transition }
|
|
1724
|
+
else delete element.contending[plan.key]
|
|
1725
|
+
}
|
|
1726
|
+
// Command-edge rebuild of the frame loop's iteration list (REQ-DRIVER-021): Object.keys here
|
|
1727
|
+
// is O(1) per command; in uiStepAll it was per element per vsync.
|
|
1728
|
+
if (command.path !== undefined) {
|
|
1729
|
+
const geometry = command.path
|
|
1730
|
+
const generator = uiStartGenerator(geometry.transition, 1000, 0, 0)
|
|
1731
|
+
const seeded = generator.sample(0).value
|
|
1732
|
+
element.path = {
|
|
1733
|
+
progress: { generator, elapsed: 0, value: seeded, done: false },
|
|
1734
|
+
geometry: {
|
|
1735
|
+
fromX: geometry.fromX,
|
|
1736
|
+
fromY: geometry.fromY,
|
|
1737
|
+
toX: geometry.toX,
|
|
1738
|
+
toY: geometry.toY,
|
|
1739
|
+
peak: geometry.peak,
|
|
1740
|
+
signedStrength: geometry.signedStrength,
|
|
1741
|
+
rotationScale: geometry.rotationScale,
|
|
1742
|
+
},
|
|
1743
|
+
sample: { x: 0, y: 0, pathRotation: 0 },
|
|
1744
|
+
scratch: { control: { x: 0, y: 0 }, sample: { x: 0, y: 0, rotate: 0 } },
|
|
1745
|
+
}
|
|
1746
|
+
delete element.props.x
|
|
1747
|
+
delete element.props.y
|
|
1748
|
+
delete element.props.pathRotation
|
|
1749
|
+
applyPathProgressInto(
|
|
1750
|
+
seeded,
|
|
1751
|
+
element.path.geometry,
|
|
1752
|
+
false,
|
|
1753
|
+
element.path.sample,
|
|
1754
|
+
element.path.scratch,
|
|
1755
|
+
)
|
|
1756
|
+
const sample = element.path.sample
|
|
1757
|
+
if (element.committed.x !== undefined) {
|
|
1758
|
+
element.committed.x = sample.x
|
|
1759
|
+
const sharedX = element.shared.x
|
|
1760
|
+
if (sharedX !== undefined) sharedX.value = sample.x
|
|
1761
|
+
}
|
|
1762
|
+
if (element.committed.y !== undefined) {
|
|
1763
|
+
element.committed.y = sample.y
|
|
1764
|
+
const sharedY = element.shared.y
|
|
1765
|
+
if (sharedY !== undefined) sharedY.value = sample.y
|
|
1766
|
+
}
|
|
1767
|
+
if (geometry.rotationScale && element.committed.pathRotation !== undefined) {
|
|
1768
|
+
element.committed.pathRotation = sample.pathRotation
|
|
1769
|
+
const sharedR = element.shared.pathRotation
|
|
1770
|
+
if (sharedR !== undefined) sharedR.value = sample.pathRotation
|
|
1771
|
+
}
|
|
1772
|
+
} else if (element.path !== null) {
|
|
1773
|
+
if (element.path.geometry.rotationScale && element.committed.pathRotation !== undefined) {
|
|
1774
|
+
element.committed.pathRotation = 0
|
|
1775
|
+
const sharedR = element.shared.pathRotation
|
|
1776
|
+
if (sharedR !== undefined) sharedR.value = 0
|
|
1777
|
+
}
|
|
1778
|
+
element.path = null
|
|
1779
|
+
}
|
|
1780
|
+
element.propKeys = Object.keys(element.props)
|
|
1781
|
+
uiPrefillElementFrameSlots(element)
|
|
1782
|
+
element.active = element.propKeys.length > 0 || element.heldCount > 0 || element.path !== null
|
|
1783
|
+
state.settleNotified = false // re-arm the settle edge
|
|
1784
|
+
break
|
|
1785
|
+
}
|
|
1786
|
+
case 'retarget': {
|
|
1787
|
+
// PHASE 1 — build every prop's generator WITHOUT mutating UI state, exactly as `start` does.
|
|
1788
|
+
// A retarget can be refused from BELOW (a fold re-measures its iteration from the LIVE value,
|
|
1789
|
+
// so a `repeat` that `start` accepted can be unexecutable here), and the severity router's
|
|
1790
|
+
// per-key re-issue is built on "a refused command is as if it had never been issued". This
|
|
1791
|
+
// backend committed per key inside the build loop, so a refusal on a later key left every
|
|
1792
|
+
// earlier key already re-seeded — the reference backend's invariant, absent on the backend
|
|
1793
|
+
// that ships (T18-a round-9 review BLOCKING 2).
|
|
1794
|
+
const plans: {
|
|
1795
|
+
key: string
|
|
1796
|
+
to: number
|
|
1797
|
+
from: number
|
|
1798
|
+
generator: Generator
|
|
1799
|
+
held: boolean
|
|
1800
|
+
}[] = []
|
|
1801
|
+
for (const key of Object.keys(command.targets)) {
|
|
1802
|
+
const to = command.targets[key]! // key came from Object.keys
|
|
1803
|
+
const transition = command.targetTransitions?.[key] ?? command.transition
|
|
1804
|
+
// A retarget is never the gesture release. A retarget on a gesture-HELD prop CONTENDS
|
|
1805
|
+
// (REQ-DRIVER-026): it seeds a fresh generator from the finger-authoritative COMMITTED value
|
|
1806
|
+
// (not the possibly-stale generator value — a finger write may have advanced committed past it,
|
|
1807
|
+
// review r10 major 29) toward the new target, and records the contention so a later write
|
|
1808
|
+
// re-anchors it. A non-held prop retargets normally with velocity continuity. Same fail-loud
|
|
1809
|
+
// rule: a retarget of a prop never started/registered is a caller bug, never a silent 0. Mirror
|
|
1810
|
+
// of the reference driver.
|
|
1811
|
+
const held = element.heldVelocity[key] !== undefined
|
|
1812
|
+
if (held) {
|
|
1813
|
+
const from = element.committed[key]! // held ⇒ written ⇒ committed exists
|
|
1814
|
+
plans.push({
|
|
1815
|
+
key,
|
|
1816
|
+
to,
|
|
1817
|
+
from,
|
|
1818
|
+
generator: uiStartGenerator(transition, to, from, 0),
|
|
1819
|
+
held,
|
|
1820
|
+
})
|
|
1821
|
+
continue
|
|
1822
|
+
}
|
|
1823
|
+
const live = element.props[key]
|
|
1824
|
+
if (live !== undefined && !live.done) {
|
|
1825
|
+
plans.push({
|
|
1826
|
+
key,
|
|
1827
|
+
to,
|
|
1828
|
+
from: live.value,
|
|
1829
|
+
generator: uiRetargetGenerator(transition, live, to),
|
|
1830
|
+
held,
|
|
1831
|
+
})
|
|
1832
|
+
continue
|
|
1833
|
+
}
|
|
1834
|
+
const base = live !== undefined ? live.value : element.committed[key]
|
|
1835
|
+
if (base === undefined) {
|
|
1836
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the
|
|
1837
|
+
// lane's designed transport (Phase 2); this throw is the payload, never the delivery.
|
|
1838
|
+
throw new Error(
|
|
1839
|
+
`cannot retarget '${key}': it was never registered or started on this element.`,
|
|
1840
|
+
)
|
|
1841
|
+
}
|
|
1842
|
+
plans.push({
|
|
1843
|
+
key,
|
|
1844
|
+
to,
|
|
1845
|
+
from: base,
|
|
1846
|
+
generator: uiStartGenerator(transition, to, base, 0),
|
|
1847
|
+
held,
|
|
1848
|
+
})
|
|
1849
|
+
}
|
|
1850
|
+
// PHASE 2 — commit every plan. No throw path here, so UI state mutates atomically.
|
|
1851
|
+
for (const plan of plans) {
|
|
1852
|
+
if (plan.held)
|
|
1853
|
+
element.contending[plan.key] = {
|
|
1854
|
+
to: plan.to,
|
|
1855
|
+
transition: command.targetTransitions?.[plan.key] ?? command.transition,
|
|
1856
|
+
}
|
|
1857
|
+
else delete element.contending[plan.key]
|
|
1858
|
+
element.props[plan.key] = {
|
|
1859
|
+
generator: plan.generator,
|
|
1860
|
+
elapsed: 0,
|
|
1861
|
+
value: plan.from,
|
|
1862
|
+
done: false,
|
|
1863
|
+
}
|
|
1864
|
+
element.committed[plan.key] = plan.from
|
|
1865
|
+
uiSetMirrorTarget(element, plan.key, plan.to)
|
|
1866
|
+
uiSetMirrorActive(element, plan.key, true)
|
|
1867
|
+
}
|
|
1868
|
+
element.propKeys = Object.keys(element.props) // command-edge rebuild (REQ-DRIVER-021)
|
|
1869
|
+
uiPrefillElementFrameSlots(element)
|
|
1870
|
+
// Recompute from what remains in flight: a held retarget seeds a contending generator and a held
|
|
1871
|
+
// element stays active regardless (heldCount > 0). Mirror of the reference driver.
|
|
1872
|
+
element.active = element.propKeys.length > 0 || element.heldCount > 0
|
|
1873
|
+
state.settleNotified = false // re-arm the settle edge
|
|
1874
|
+
break
|
|
1875
|
+
}
|
|
1876
|
+
case 'stop': {
|
|
1877
|
+
// Halt every animation AND release every gesture hold (stop = halt everything).
|
|
1878
|
+
if (element.path !== null) {
|
|
1879
|
+
if (element.path.geometry.rotationScale && element.committed.pathRotation !== undefined) {
|
|
1880
|
+
element.committed.pathRotation = 0
|
|
1881
|
+
const sharedR = element.shared.pathRotation
|
|
1882
|
+
if (sharedR !== undefined) sharedR.value = 0
|
|
1883
|
+
}
|
|
1884
|
+
element.path = null
|
|
1885
|
+
}
|
|
1886
|
+
for (const key of Object.keys(element.props)) delete element.props[key]
|
|
1887
|
+
for (const key of Object.keys(element.heldVelocity)) delete element.heldVelocity[key]
|
|
1888
|
+
for (const key of Object.keys(element.contending)) delete element.contending[key]
|
|
1889
|
+
for (const key of Object.keys(element.layoutMirror)) {
|
|
1890
|
+
uiSetMirrorActive(element, key, false)
|
|
1891
|
+
const committed = element.committed[key]
|
|
1892
|
+
if (committed !== undefined) uiSetMirrorTarget(element, key, committed)
|
|
1893
|
+
}
|
|
1894
|
+
element.heldCount = 0
|
|
1895
|
+
element.propKeys.length = 0
|
|
1896
|
+
element.active = false
|
|
1897
|
+
break
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// The gesture write lane ON the UI runtime (REQ-DRIVER-024): commit event-driven values and
|
|
1903
|
+
// interrupt any in-flight animation on the written props through ONE entry point — committed
|
|
1904
|
+
// state, the shared values the style binds, and animation state can never skew. EVENT-edge, not
|
|
1905
|
+
// frame-path: Object.keys here is O(1) per recognizer event (the uiApplyCommand precedent), and
|
|
1906
|
+
// the frame loop never calls this.
|
|
1907
|
+
function uiApplyWrite(
|
|
1908
|
+
state: UiDriverState,
|
|
1909
|
+
handle: number,
|
|
1910
|
+
values: Readonly<Record<string, number>>,
|
|
1911
|
+
velocities: Readonly<Record<string, number>> | undefined,
|
|
1912
|
+
writeVersion: number,
|
|
1913
|
+
eventShape: WorkletDriverWriteShape,
|
|
1914
|
+
): void {
|
|
1915
|
+
'worklet'
|
|
1916
|
+
const element = uiElementAt(state, handle)
|
|
1917
|
+
// Validate ALL keys before mutating ANY — a malformed write never half-applies (fail-closed).
|
|
1918
|
+
for (const key of Object.keys(values)) {
|
|
1919
|
+
if (!(key in element.committed)) {
|
|
1920
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the lane's
|
|
1921
|
+
// designed transport (Phase 2); this throw is the payload, never the delivery.
|
|
1922
|
+
throw new Error(`cannot write '${key}': it was never registered on this element.`)
|
|
1923
|
+
}
|
|
1924
|
+
if (
|
|
1925
|
+
eventShape === 'bound-value-retarget' &&
|
|
1926
|
+
(element.heldVelocity[key] !== undefined || element.props[key] !== undefined)
|
|
1927
|
+
) {
|
|
1928
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the lane's
|
|
1929
|
+
// designed transport (Phase 2); this throw is the payload, never the delivery. The NAMED
|
|
1930
|
+
// refusal (validation phase, nothing mutated) is what the live arm routes to the adapter's
|
|
1931
|
+
// refusal handling instead of retiring an admitted flight (REQ-DRIVER-034).
|
|
1932
|
+
throw uiBoundValueWriteRefusal(key)
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
for (const key of Object.keys(values)) {
|
|
1936
|
+
const finger = values[key]!
|
|
1937
|
+
if (eventShape === 'bound-value-retarget') {
|
|
1938
|
+
element.committed[key] = finger
|
|
1939
|
+
const shared = element.shared[key]
|
|
1940
|
+
if (shared !== undefined) shared.value = finger
|
|
1941
|
+
uiSetMirrorTarget(element, key, finger)
|
|
1942
|
+
uiSetMirrorActive(element, key, false)
|
|
1943
|
+
continue
|
|
1944
|
+
}
|
|
1945
|
+
if (element.heldVelocity[key] === undefined) element.heldCount++ // count a NEW hold once
|
|
1946
|
+
element.committed[key] = finger
|
|
1947
|
+
element.writeVersion[key] = writeVersion
|
|
1948
|
+
const shared = element.shared[key]
|
|
1949
|
+
if (shared !== undefined) shared.value = finger
|
|
1950
|
+
uiSetMirrorTarget(element, key, finger)
|
|
1951
|
+
uiSetMirrorActive(element, key, true)
|
|
1952
|
+
// Refresh the per-prop held platform velocity so uiLiveFor reports it while held (G-INV-3 C1 seam,
|
|
1953
|
+
// held-velocity precedence over any contending generator); omitted ⇒ 0.
|
|
1954
|
+
element.heldVelocity[key] = velocities?.[key] ?? 0
|
|
1955
|
+
// Reconcile the prop's generator with the finger write (REQ-DRIVER-026). A LIVE declarative
|
|
1956
|
+
// contention is RE-ANCHORED to the freshly-committed finger value so it drifts FROM here — never a
|
|
1957
|
+
// backward jump to the generator's stale trajectory (review r10 major 29, G-INV-2). Otherwise the
|
|
1958
|
+
// finger owns the value outright: a plain grab interrupts the pre-existing animation
|
|
1959
|
+
// (REQ-GESTURE-012), and a SETTLED contention is finished, so its generator is dropped (else
|
|
1960
|
+
// uiStepAll keeps forcing its value). Mirror of the reference driver.
|
|
1961
|
+
const contention = element.contending[key]
|
|
1962
|
+
const prop = element.props[key]
|
|
1963
|
+
if (contention !== undefined && prop !== undefined && !prop.done) {
|
|
1964
|
+
element.props[key] = {
|
|
1965
|
+
// Re-anchor FROM the finger: seed a keyframe array's first element from-current so the drift
|
|
1966
|
+
// continues from `finger`, never rewinding to keyframes[0] (major 25). Mirror of reference.
|
|
1967
|
+
generator: uiStartGenerator(
|
|
1968
|
+
contention.transition,
|
|
1969
|
+
uiAnchorContentionToLive(contention.to),
|
|
1970
|
+
finger,
|
|
1971
|
+
0,
|
|
1972
|
+
),
|
|
1973
|
+
elapsed: 0,
|
|
1974
|
+
value: finger,
|
|
1975
|
+
done: false,
|
|
1976
|
+
}
|
|
1977
|
+
} else {
|
|
1978
|
+
if (prop !== undefined) delete element.props[key]
|
|
1979
|
+
delete element.contending[key]
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
element.propKeys = Object.keys(element.props) // command-edge rebuild (REQ-DRIVER-021)
|
|
1983
|
+
uiPrefillElementFrameSlots(element)
|
|
1984
|
+
if (eventShape === 'bound-value-retarget') {
|
|
1985
|
+
// A MotionValue set has no release edge. It commits through the same write lane, but is
|
|
1986
|
+
// immediately quiescent unless another property was already active.
|
|
1987
|
+
element.active = element.propKeys.length > 0 || element.heldCount > 0
|
|
1988
|
+
return
|
|
1989
|
+
}
|
|
1990
|
+
// A gesture write holds the element unsettled (G-INV-7): a finger is down driving it, so it stays
|
|
1991
|
+
// active with no animation until the matching release command clears the hold — never a spurious
|
|
1992
|
+
// settle edge mid-drag (the reviewer-reproduced re-grab defect).
|
|
1993
|
+
element.active = true
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// The grab live-read ON the UI runtime (REQ-DRIVER-025). An in-flight prop re-samples its
|
|
1997
|
+
// generator at the current elapsed — pure by elapsed time (REQ-CORE-001), scalars copied out
|
|
1998
|
+
// immediately (the sample record is generator-owned and reused). Settled/never-animated ⇒
|
|
1999
|
+
// velocity 0. Gesture-edge only, never frame-executed.
|
|
2000
|
+
function uiLiveFor(
|
|
2001
|
+
state: UiDriverState,
|
|
2002
|
+
handle: number,
|
|
2003
|
+
key: string,
|
|
2004
|
+
): LivePropState & { readonly writeVersion: number } {
|
|
2005
|
+
'worklet'
|
|
2006
|
+
const element = uiElementAt(state, handle)
|
|
2007
|
+
// HELD-VELOCITY PRECEDENCE (REQ-DRIVER-025): a held (finger-down) prop reports the platform finger
|
|
2008
|
+
// velocity its write carried EVEN when a contending declarative generator is in flight
|
|
2009
|
+
// (REQ-DRIVER-026) — the release seed is the finger's number, never the generator's. The value is
|
|
2010
|
+
// the live committed number (finger position, or the contending animation's drift). Read BEFORE the
|
|
2011
|
+
// generator branch so contention can't leak the generator velocity into the release seam (per-prop
|
|
2012
|
+
// since review r7 major 23). Mirror of the reference driver.
|
|
2013
|
+
const heldV = element.heldVelocity[key]
|
|
2014
|
+
if (heldV !== undefined) {
|
|
2015
|
+
const base = element.committed[key]
|
|
2016
|
+
if (base === undefined) {
|
|
2017
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the lane's
|
|
2018
|
+
// designed transport (Phase 2); this throw is the payload, never the delivery.
|
|
2019
|
+
throw new Error(`cannot read '${key}': it was never registered on this element.`)
|
|
2020
|
+
}
|
|
2021
|
+
return { value: base, velocity: heldV, writeVersion: element.writeVersion[key] ?? 0 }
|
|
2022
|
+
}
|
|
2023
|
+
const live = element.props[key]
|
|
2024
|
+
if (live !== undefined && !live.done) {
|
|
2025
|
+
const sample = live.generator.sample(live.elapsed)
|
|
2026
|
+
return {
|
|
2027
|
+
value: sample.value,
|
|
2028
|
+
velocity: sample.velocity,
|
|
2029
|
+
writeVersion: element.writeVersion[key] ?? 0,
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const base = element.committed[key]
|
|
2033
|
+
if (base === undefined) {
|
|
2034
|
+
// ui-throw-ok: guarded by uiGuard/uiGuardLane — failures marshal to RN through the lane's
|
|
2035
|
+
// designed transport (Phase 2); this throw is the payload, never the delivery.
|
|
2036
|
+
throw new Error(`cannot read '${key}': it was never registered on this element.`)
|
|
2037
|
+
}
|
|
2038
|
+
// A settled or never-animated non-held prop has velocity 0 (REQ-DRIVER-025).
|
|
2039
|
+
return { value: base, velocity: 0, writeVersion: element.writeVersion[key] ?? 0 }
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// @frame-path — advance every active element by one frame (core's pure step), mirror into shared
|
|
2043
|
+
// values, quiesce settled elements. Returns whether ANY element is still in flight. Indexed loops
|
|
2044
|
+
// over the command-edge-maintained propKeys list: for..of iterators and Object.keys arrays per
|
|
2045
|
+
// element per vsync are frame-executed garbage (REQ-DRIVER-021).
|
|
2046
|
+
function uiStepAll(state: UiDriverState, elapsedMs: number): boolean {
|
|
2047
|
+
'worklet'
|
|
2048
|
+
let anyElementActive = false
|
|
2049
|
+
for (let e = 0; e < state.elements.length; e++) {
|
|
2050
|
+
const element = state.elements[e]!
|
|
2051
|
+
if (!element.active) continue
|
|
2052
|
+
let anyPropActive = false
|
|
2053
|
+
for (let i = 0; i < element.propKeys.length; i++) {
|
|
2054
|
+
const key = element.propKeys[i]!
|
|
2055
|
+
const prop = element.props[key]! // propKeys mirrors props at every command edge
|
|
2056
|
+
stepProp(prop, elapsedMs)
|
|
2057
|
+
element.committed[key] = prop.value
|
|
2058
|
+
const shared = element.shared[key]
|
|
2059
|
+
if (shared !== undefined) shared.value = prop.value
|
|
2060
|
+
if (prop.done && element.heldVelocity[key] === undefined) {
|
|
2061
|
+
uiSetMirrorTarget(element, key, prop.value)
|
|
2062
|
+
uiSetMirrorActive(element, key, false)
|
|
2063
|
+
}
|
|
2064
|
+
if (!prop.done) anyPropActive = true
|
|
2065
|
+
}
|
|
2066
|
+
if (element.path !== null) {
|
|
2067
|
+
const path = element.path
|
|
2068
|
+
stepProp(path.progress, elapsedMs)
|
|
2069
|
+
applyPathProgressInto(
|
|
2070
|
+
path.progress.value,
|
|
2071
|
+
path.geometry,
|
|
2072
|
+
path.progress.done,
|
|
2073
|
+
path.sample,
|
|
2074
|
+
path.scratch,
|
|
2075
|
+
)
|
|
2076
|
+
const sample = path.sample
|
|
2077
|
+
if (element.committed.x !== undefined) {
|
|
2078
|
+
element.committed.x = sample.x
|
|
2079
|
+
const sharedX = element.shared.x
|
|
2080
|
+
if (sharedX !== undefined) sharedX.value = sample.x
|
|
2081
|
+
}
|
|
2082
|
+
if (element.committed.y !== undefined) {
|
|
2083
|
+
element.committed.y = sample.y
|
|
2084
|
+
const sharedY = element.shared.y
|
|
2085
|
+
if (sharedY !== undefined) sharedY.value = sample.y
|
|
2086
|
+
}
|
|
2087
|
+
if (path.geometry.rotationScale && element.committed.pathRotation !== undefined) {
|
|
2088
|
+
element.committed.pathRotation = sample.pathRotation
|
|
2089
|
+
const sharedR = element.shared.pathRotation
|
|
2090
|
+
if (sharedR !== undefined) sharedR.value = sample.pathRotation
|
|
2091
|
+
}
|
|
2092
|
+
if (path.progress.done) element.path = null
|
|
2093
|
+
else anyPropActive = true
|
|
2094
|
+
}
|
|
2095
|
+
// Quiesce once every prop settles (REQ-DRIVER-020) — UNLESS a gesture still holds a prop (a
|
|
2096
|
+
// finger is down, no animation but unsettled, G-INV-7). heldCount is O(1) so this @frame-path
|
|
2097
|
+
// check allocates nothing (REQ-DRIVER-021). A held element stays active until the matching
|
|
2098
|
+
// release clears it, so no spurious settle edge fires mid-drag.
|
|
2099
|
+
if (!anyPropActive && element.heldCount === 0) element.active = false
|
|
2100
|
+
else anyElementActive = true
|
|
2101
|
+
}
|
|
2102
|
+
return anyElementActive
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// T23 C (@frame-path): fill the changed-element prefix of the reusable emission transaction.
|
|
2106
|
+
// Public records do not exist on UI: this path copies into command-edge-prefilled primitive slots,
|
|
2107
|
+
// and the single envelope seal serializes them after every participant has committed.
|
|
2108
|
+
function uiEmitFrameValues(state: UiDriverState): void {
|
|
2109
|
+
'worklet'
|
|
2110
|
+
const emission = state.emission
|
|
2111
|
+
for (let i = 0; i < state.elements.length; i++) {
|
|
2112
|
+
const element = state.elements[i]!
|
|
2113
|
+
if (!element.onUpdateArmed) continue
|
|
2114
|
+
let changed = false
|
|
2115
|
+
for (let k = 0; k < element.propKeys.length; k++) {
|
|
2116
|
+
const key = element.propKeys[k]!
|
|
2117
|
+
if (element.lastNotified[key] !== element.committed[key]) {
|
|
2118
|
+
changed = true
|
|
2119
|
+
break
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
// U8: path claims x/y/pathRotation out of `props` (they are not in propKeys). Still
|
|
2123
|
+
// emit them — the pin's onUpdate sees path-driven latest values.
|
|
2124
|
+
if (!changed) {
|
|
2125
|
+
if (element.committed.x !== undefined && element.lastNotified.x !== element.committed.x)
|
|
2126
|
+
changed = true
|
|
2127
|
+
else if (element.committed.y !== undefined && element.lastNotified.y !== element.committed.y)
|
|
2128
|
+
changed = true
|
|
2129
|
+
else if (
|
|
2130
|
+
element.committed.pathRotation !== undefined &&
|
|
2131
|
+
element.lastNotified.pathRotation !== element.committed.pathRotation
|
|
2132
|
+
)
|
|
2133
|
+
changed = true
|
|
2134
|
+
}
|
|
2135
|
+
if (!changed) continue
|
|
2136
|
+
let memberCount = 0
|
|
2137
|
+
for (let k = 0; k < element.propKeys.length; k++) {
|
|
2138
|
+
const key = element.propKeys[k]!
|
|
2139
|
+
const value = element.committed[key]!
|
|
2140
|
+
element.frameKeys[memberCount] = key
|
|
2141
|
+
element.frameValues[memberCount] = value
|
|
2142
|
+
memberCount += 1
|
|
2143
|
+
element.lastNotified[key] = value
|
|
2144
|
+
}
|
|
2145
|
+
if (element.committed.x !== undefined && element.props.x === undefined) {
|
|
2146
|
+
element.frameKeys[memberCount] = 'x'
|
|
2147
|
+
element.frameValues[memberCount] = element.committed.x
|
|
2148
|
+
memberCount += 1
|
|
2149
|
+
element.lastNotified.x = element.committed.x
|
|
2150
|
+
}
|
|
2151
|
+
if (element.committed.y !== undefined && element.props.y === undefined) {
|
|
2152
|
+
element.frameKeys[memberCount] = 'y'
|
|
2153
|
+
element.frameValues[memberCount] = element.committed.y
|
|
2154
|
+
memberCount += 1
|
|
2155
|
+
element.lastNotified.y = element.committed.y
|
|
2156
|
+
}
|
|
2157
|
+
if (element.committed.pathRotation !== undefined && element.props.pathRotation === undefined) {
|
|
2158
|
+
element.frameKeys[memberCount] = 'pathRotation'
|
|
2159
|
+
element.frameValues[memberCount] = element.committed.pathRotation
|
|
2160
|
+
memberCount += 1
|
|
2161
|
+
element.lastNotified.pathRotation = element.committed.pathRotation
|
|
2162
|
+
}
|
|
2163
|
+
const entryIndex = emission.elementEntryCount
|
|
2164
|
+
emission.elementHandles[entryIndex] = i
|
|
2165
|
+
emission.elementGenerations[entryIndex] = element.registrationGeneration
|
|
2166
|
+
emission.elementCommitSequences[entryIndex] = emission.commitSequence + 1
|
|
2167
|
+
emission.elementMemberCounts[entryIndex] = memberCount
|
|
2168
|
+
emission.elementEntryCount += 1
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function uiStageSettledLayoutWatchers(state: UiDriverState): void {
|
|
2173
|
+
'worklet'
|
|
2174
|
+
if (state.layout === null) return
|
|
2175
|
+
let retainedCount = 0
|
|
2176
|
+
for (let watcherIndex = 0; watcherIndex < state.layoutSettleWatchers.length; watcherIndex += 1) {
|
|
2177
|
+
const watcher = state.layoutSettleWatchers[watcherIndex]!
|
|
2178
|
+
if (state.layout.registry.isSettled(watcher.id)) {
|
|
2179
|
+
const stagedIndex = state.emission.layoutSettledCount
|
|
2180
|
+
state.emission.layoutSettledKeys[stagedIndex] = watcher.key
|
|
2181
|
+
state.emission.layoutSettledCount += 1
|
|
2182
|
+
} else {
|
|
2183
|
+
state.layoutSettleWatchers[retainedCount] = watcher
|
|
2184
|
+
retainedCount += 1
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
state.layoutSettleWatchers.length = retainedCount
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// alloc-ok:mailbox-envelope-emission — this is the one conditional UI serialization edge. Every
|
|
2191
|
+
// frame producer above fills reusable primitive slots; only a demanded non-empty turn constructs
|
|
2192
|
+
// the closed transport records and crosses once to the named JS receipt.
|
|
2193
|
+
function uiSealFrameEmission(
|
|
2194
|
+
state: UiDriverState,
|
|
2195
|
+
staleReports:
|
|
2196
|
+
| readonly {
|
|
2197
|
+
readonly source: string
|
|
2198
|
+
readonly code: string
|
|
2199
|
+
readonly detail: string
|
|
2200
|
+
}[]
|
|
2201
|
+
| null,
|
|
2202
|
+
): void {
|
|
2203
|
+
'worklet'
|
|
2204
|
+
const emission = state.emission
|
|
2205
|
+
const layoutReleaseCount = state.layout?.releaseCount ?? 0
|
|
2206
|
+
const staleReportCount = staleReports?.length ?? 0
|
|
2207
|
+
const driverSettleCount = emission.driverSettleEpoch < 0 ? 0 : 1
|
|
2208
|
+
const disposeCount = emission.disposeStaged ? 1 : 0
|
|
2209
|
+
const eventCount =
|
|
2210
|
+
emission.elementEntryCount +
|
|
2211
|
+
layoutReleaseCount +
|
|
2212
|
+
staleReportCount +
|
|
2213
|
+
emission.layoutSettledCount +
|
|
2214
|
+
driverSettleCount +
|
|
2215
|
+
disposeCount
|
|
2216
|
+
if (eventCount === 0) return
|
|
2217
|
+
|
|
2218
|
+
const mailboxDrainId = emission.commitSequence + 1
|
|
2219
|
+
const entries = Array.from({ length: eventCount }) as UiFrameMailboxEntry[]
|
|
2220
|
+
let entryIndex = 0
|
|
2221
|
+
for (let stagedIndex = 0; stagedIndex < emission.elementEntryCount; stagedIndex += 1) {
|
|
2222
|
+
const handle = emission.elementHandles[stagedIndex]!
|
|
2223
|
+
const element = state.elements[handle]!
|
|
2224
|
+
const memberCount = emission.elementMemberCounts[stagedIndex]!
|
|
2225
|
+
const values = Array.from({ length: memberCount }) as UiFrameMailboxElementMember[]
|
|
2226
|
+
for (let memberIndex = 0; memberIndex < memberCount; memberIndex += 1) {
|
|
2227
|
+
values[memberIndex] = {
|
|
2228
|
+
key: element.frameKeys[memberIndex]!,
|
|
2229
|
+
kind: 'number',
|
|
2230
|
+
value: element.frameValues[memberIndex]!,
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
entries[entryIndex] = {
|
|
2234
|
+
kind: 'ELEMENT_HOST_COMMIT',
|
|
2235
|
+
index: entryIndex,
|
|
2236
|
+
driverId: state.driverId,
|
|
2237
|
+
handleId: handle,
|
|
2238
|
+
registrationGeneration: emission.elementGenerations[stagedIndex]!,
|
|
2239
|
+
commitSequence: emission.elementCommitSequences[stagedIndex]!,
|
|
2240
|
+
values,
|
|
2241
|
+
}
|
|
2242
|
+
entryIndex += 1
|
|
2243
|
+
}
|
|
2244
|
+
if (state.layout !== null) {
|
|
2245
|
+
for (let releaseIndex = 0; releaseIndex < layoutReleaseCount; releaseIndex += 1) {
|
|
2246
|
+
entries[entryIndex] = {
|
|
2247
|
+
kind: 'LAYOUT_RELEASE',
|
|
2248
|
+
index: entryIndex,
|
|
2249
|
+
layoutRegistrationId: state.layout.releaseKeys[releaseIndex]!,
|
|
2250
|
+
}
|
|
2251
|
+
entryIndex += 1
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
if (staleReports !== null) {
|
|
2255
|
+
for (let reportIndex = 0; reportIndex < staleReportCount; reportIndex += 1) {
|
|
2256
|
+
entries[entryIndex] = {
|
|
2257
|
+
kind: 'PROJECTION_STALE',
|
|
2258
|
+
index: entryIndex,
|
|
2259
|
+
report: staleReports[reportIndex]!,
|
|
2260
|
+
}
|
|
2261
|
+
entryIndex += 1
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
for (let settledIndex = 0; settledIndex < emission.layoutSettledCount; settledIndex += 1) {
|
|
2265
|
+
entries[entryIndex] = {
|
|
2266
|
+
kind: 'LAYOUT_SETTLE',
|
|
2267
|
+
index: entryIndex,
|
|
2268
|
+
layoutAnimationId: emission.layoutSettledKeys[settledIndex]!,
|
|
2269
|
+
}
|
|
2270
|
+
entryIndex += 1
|
|
2271
|
+
}
|
|
2272
|
+
if (emission.driverSettleEpoch >= 0) {
|
|
2273
|
+
entries[entryIndex] = {
|
|
2274
|
+
kind: 'DRIVER_SETTLE',
|
|
2275
|
+
index: entryIndex,
|
|
2276
|
+
driverId: state.driverId,
|
|
2277
|
+
schedulerEpoch: emission.driverSettleEpoch,
|
|
2278
|
+
}
|
|
2279
|
+
entryIndex += 1
|
|
2280
|
+
}
|
|
2281
|
+
if (emission.disposeStaged) {
|
|
2282
|
+
entries[entryIndex] = { kind: 'DRIVER_DISPOSED', index: entryIndex, driverId: state.driverId }
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
const endIndex = eventCount - 1
|
|
2286
|
+
const envelope: UiFrameMailboxEnvelope = {
|
|
2287
|
+
kind: 'CHANNEL_MAILBOX_TURN_ENVELOPE',
|
|
2288
|
+
mailboxOrigin: 'UI',
|
|
2289
|
+
mailboxDrainId,
|
|
2290
|
+
driverId: state.driverId,
|
|
2291
|
+
eventCount,
|
|
2292
|
+
endIndex,
|
|
2293
|
+
entries,
|
|
2294
|
+
closingMarker: { kind: 'CHANNEL_MAILBOX_END', index: endIndex },
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Close/reset before the host call: test hosts may invoke scheduleOnRN synchronously, and
|
|
2298
|
+
// callback reentry belongs to a later transaction even under that deliberately eager seam.
|
|
2299
|
+
emission.elementEntryCount = 0
|
|
2300
|
+
emission.layoutSettledCount = 0
|
|
2301
|
+
emission.driverSettleEpoch = -1
|
|
2302
|
+
emission.disposeStaged = false
|
|
2303
|
+
if (state.layout !== null) state.layout.releaseCount = 0
|
|
2304
|
+
scheduleOnRN(dispatchChannelMailboxTurnEnvelope, envelope)
|
|
2305
|
+
// The drain id is consumed only once the envelope has crossed. Advancing the counter at seal
|
|
2306
|
+
// start let a seal whose host crossing failed skip an id the JS side never receives, and every
|
|
2307
|
+
// later envelope validated as "duplicate or out-of-order" (bugbash 2026-09-08, AUTO walk).
|
|
2308
|
+
emission.commitSequence = mailboxDrainId
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function uiManualGroupPeek(groupId: number): UiManualGroupSlot | null {
|
|
2312
|
+
'worklet'
|
|
2313
|
+
const g = globalThis as UiGlobal
|
|
2314
|
+
if (g.__nativeMotionManualGroups === undefined) return null
|
|
2315
|
+
return g.__nativeMotionManualGroups[groupId] ?? null
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function uiPrefillManualGroupSlot(groupId: number, attendeeCapacity: number): void {
|
|
2319
|
+
'worklet'
|
|
2320
|
+
const g = globalThis as UiGlobal
|
|
2321
|
+
if (g.__nativeMotionManualGroups === undefined) g.__nativeMotionManualGroups = {}
|
|
2322
|
+
const existing = g.__nativeMotionManualGroups[groupId]
|
|
2323
|
+
if (existing !== undefined) {
|
|
2324
|
+
while (existing.attendees.length < attendeeCapacity) existing.attendees.push(-1)
|
|
2325
|
+
return
|
|
2326
|
+
}
|
|
2327
|
+
const attendees: number[] = []
|
|
2328
|
+
for (let i = 0; i < attendeeCapacity; i += 1) attendees.push(-1)
|
|
2329
|
+
g.__nativeMotionManualGroups[groupId] = {
|
|
2330
|
+
lastCommittedTimestampMs: 0,
|
|
2331
|
+
pendingAbsoluteTimestampMs: 0,
|
|
2332
|
+
attendeeCount: 0,
|
|
2333
|
+
attendees,
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function uiPrefillManualGroupSlotGuarded(
|
|
2338
|
+
groupId: number,
|
|
2339
|
+
attendeeCapacity: number,
|
|
2340
|
+
): UiOutcome<void> {
|
|
2341
|
+
'worklet'
|
|
2342
|
+
return uiGuard(() => {
|
|
2343
|
+
uiPrefillManualGroupSlot(groupId, attendeeCapacity)
|
|
2344
|
+
})
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function uiCopyManualAttendeesIntoSlots(
|
|
2348
|
+
groupId: number,
|
|
2349
|
+
orderedAttendees: readonly number[],
|
|
2350
|
+
): void {
|
|
2351
|
+
'worklet'
|
|
2352
|
+
const group = uiManualGroupPeek(groupId)
|
|
2353
|
+
if (group === null) return
|
|
2354
|
+
const count = orderedAttendees.length
|
|
2355
|
+
if (count > group.attendees.length) {
|
|
2356
|
+
// ui-throw-ok: guarded by uiGuard (uiExecuteManualFrame) — the manual lane crosses
|
|
2357
|
+
// synchronously, so the JS caller unwraps this as a loud lifecycle defect: a group step was
|
|
2358
|
+
// handed more attendees than its command-edge prefill sized.
|
|
2359
|
+
throw new Error('manual frame attendee batch exceeds preallocated group capacity.')
|
|
2360
|
+
}
|
|
2361
|
+
for (let i = 0; i < count; i += 1) group.attendees[i] = orderedAttendees[i]!
|
|
2362
|
+
group.attendeeCount = count
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function uiBeginManualFrame(
|
|
2366
|
+
groupId: number,
|
|
2367
|
+
elapsedMs: number,
|
|
2368
|
+
orderedAttendees: readonly number[],
|
|
2369
|
+
): void {
|
|
2370
|
+
'worklet'
|
|
2371
|
+
uiCopyManualAttendeesIntoSlots(groupId, orderedAttendees)
|
|
2372
|
+
const group = uiManualGroupPeek(groupId)
|
|
2373
|
+
if (group === null) return
|
|
2374
|
+
group.pendingAbsoluteTimestampMs = group.lastCommittedTimestampMs + elapsedMs
|
|
2375
|
+
for (let i = 0; i < group.attendeeCount; i += 1) {
|
|
2376
|
+
uiDriverStatePeek(group.attendees[i]!)
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function uiInvokeManualParticipant(groupId: number, elapsedMs: number, driverId: number): void {
|
|
2381
|
+
'worklet'
|
|
2382
|
+
const state = uiDriverStatePeek(driverId)
|
|
2383
|
+
if (state === null) return
|
|
2384
|
+
if (uiManualGroupPeek(groupId) === null) return
|
|
2385
|
+
// The caller's elapsed primitive steps the props directly. Deriving it as a difference of two
|
|
2386
|
+
// accumulated absolute timestamps drifts by ULPs, and a delay or repeat-fold boundary that the
|
|
2387
|
+
// reference driver crosses at an exact frame then lands one frame late here
|
|
2388
|
+
// (driverParity.differential.test.ts, the repeatDelay and endless-repeat retarget rows).
|
|
2389
|
+
uiStepAll(state, elapsedMs)
|
|
2390
|
+
if (state.layout !== null) {
|
|
2391
|
+
uiLayoutStep(state.layout, elapsedMs)
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function uiRevokeManualParticipant(groupId: number, driverId: number): void {
|
|
2396
|
+
'worklet'
|
|
2397
|
+
void groupId
|
|
2398
|
+
uiDriverStatePeek(driverId)
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function uiCommitManualFrame(groupId: number): void {
|
|
2402
|
+
'worklet'
|
|
2403
|
+
const group = uiManualGroupPeek(groupId)
|
|
2404
|
+
if (group === null) return
|
|
2405
|
+
group.lastCommittedTimestampMs = group.pendingAbsoluteTimestampMs
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
function uiExecuteManualFrame(
|
|
2409
|
+
groupId: number,
|
|
2410
|
+
elapsedMs: number,
|
|
2411
|
+
orderedAttendees: readonly number[],
|
|
2412
|
+
): UiOutcome<void> {
|
|
2413
|
+
'worklet'
|
|
2414
|
+
return uiGuard(() => {
|
|
2415
|
+
uiBeginManualFrame(groupId, elapsedMs, orderedAttendees)
|
|
2416
|
+
const group = uiManualGroupPeek(groupId)
|
|
2417
|
+
if (group === null) return
|
|
2418
|
+
for (let i = 0; i < group.attendeeCount; i += 1) {
|
|
2419
|
+
const driverId = group.attendees[i]!
|
|
2420
|
+
if (uiDriverStatePeek(driverId) === null) {
|
|
2421
|
+
uiRevokeManualParticipant(groupId, driverId)
|
|
2422
|
+
continue
|
|
2423
|
+
}
|
|
2424
|
+
uiInvokeManualParticipant(groupId, elapsedMs, driverId)
|
|
2425
|
+
const state = uiDriverStatePeek(driverId)
|
|
2426
|
+
if (state === null) continue
|
|
2427
|
+
uiEmitFrameValues(state)
|
|
2428
|
+
let staleReports:
|
|
2429
|
+
| readonly {
|
|
2430
|
+
readonly source: string
|
|
2431
|
+
readonly code: string
|
|
2432
|
+
readonly detail: string
|
|
2433
|
+
}[]
|
|
2434
|
+
| null = null
|
|
2435
|
+
if (state.layout !== null) {
|
|
2436
|
+
staleReports = state.layout.projection.tree.drainStaleReports()
|
|
2437
|
+
uiStageSettledLayoutWatchers(state)
|
|
2438
|
+
}
|
|
2439
|
+
uiSealFrameEmission(state, staleReports)
|
|
2440
|
+
}
|
|
2441
|
+
uiCommitManualFrame(groupId)
|
|
2442
|
+
})
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
class ManualFrameGroup {
|
|
2446
|
+
constructor(
|
|
2447
|
+
readonly groupId: number,
|
|
2448
|
+
readonly participantDriverIds: number[],
|
|
2449
|
+
) {}
|
|
2450
|
+
|
|
2451
|
+
get size(): number {
|
|
2452
|
+
return this.participantDriverIds.length
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
step(elapsedMs: number, invocationOrder?: readonly number[]): void {
|
|
2456
|
+
const orderedAttendees = invocationOrder ?? this.participantDriverIds
|
|
2457
|
+
// alloc-ok:manual-crossing — one frozen primitive attendee batch per group step
|
|
2458
|
+
const frozen = Object.freeze(orderedAttendees.slice())
|
|
2459
|
+
// The elapsed primitive crosses as authored; the UI slot owns the absolute clock so every
|
|
2460
|
+
// participant steps by exactly what the caller advanced (no timestamp round trip).
|
|
2461
|
+
unwrapUiOutcome(executeOnUIRuntimeSync(uiExecuteManualFrame)(this.groupId, elapsedMs, frozen))
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
export interface WorkletDriverOptions {
|
|
2466
|
+
// Fired on the JS thread once per in-flight→settled edge (every element quiesced, frame callback
|
|
2467
|
+
// deactivated). One O(1) UI→JS crossing per edge — never per frame.
|
|
2468
|
+
readonly onAllSettled?: (() => void) | undefined
|
|
2469
|
+
// When true the driver never runs a frame source; time advances only through explicit
|
|
2470
|
+
// `step(elapsedMs)` calls with SYNCHRONOUS command crossings. This is the deterministic mode the
|
|
2471
|
+
// on-device behavioral checks drive. Live drivers come from `useWorkletDriver` (REQ-DRIVER-011).
|
|
2472
|
+
readonly manualStepping?: boolean
|
|
2473
|
+
// The layout-identity flight + crossfade spring (one config drives both — registry law). Fixed
|
|
2474
|
+
// at the layout slot's lazy creation; the registry default applies when omitted.
|
|
2475
|
+
readonly layoutSpring?: SpringConfig
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// One layout holder's animated-style outputs: the top-left-anchored transform correction, the
|
|
2479
|
+
// crossfade opacity, and z-lift as 0/1 (the screen maps it to zIndex/elevation). Written UI-side
|
|
2480
|
+
// every frame while the layout engine is in flight — never from JS.
|
|
2481
|
+
export interface LayoutIdentityHolderShared {
|
|
2482
|
+
readonly translateX: SharedValue<number>
|
|
2483
|
+
readonly translateY: SharedValue<number>
|
|
2484
|
+
readonly scaleX: SharedValue<number>
|
|
2485
|
+
readonly scaleY: SharedValue<number>
|
|
2486
|
+
readonly opacity: SharedValue<number>
|
|
2487
|
+
readonly zLifted: SharedValue<number>
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** One entry of the U6 W4 opacity witness: a held holder's token and its live painted opacity. */
|
|
2491
|
+
export interface LayoutIdentityHolderOpacityWitness {
|
|
2492
|
+
readonly token: string
|
|
2493
|
+
readonly opacity: number
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// The driver's layout-identity surface (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md §The crossing
|
|
2497
|
+
// contract). Every lifecycle call crosses SYNCHRONOUSLY on one lane, so re-entry orderings
|
|
2498
|
+
// (detach then attach of the same token in one JS turn) can never reorder; per-frame work never
|
|
2499
|
+
// crosses. Rects are already validated + branded by the binding (fail-loud stays JS-side).
|
|
2500
|
+
/** Per-edge layout-identity options (REQ-API-035 MotionConfig policy → layoutId lane). */
|
|
2501
|
+
export interface WorkletLayoutFlightOptions {
|
|
2502
|
+
readonly instant?: boolean
|
|
2503
|
+
/** Component-tree address for a layoutId holder whose transform must feed descendants. */
|
|
2504
|
+
readonly projectionComponentId?: string
|
|
2505
|
+
/** Family-3: duration/ease (ms) for layoutId projection flight — data-only for worklets. */
|
|
2506
|
+
readonly timing?: import('@unrulysystems/native-motion-core').TimingConfig
|
|
2507
|
+
/** T22: per-flight spring (engine SpringConfig ms) — data-only for worklets; excludes timing. */
|
|
2508
|
+
readonly spring?: import('@unrulysystems/native-motion-core').SpringConfig
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
export interface WorkletProjectionRect {
|
|
2512
|
+
readonly x: number
|
|
2513
|
+
readonly y: number
|
|
2514
|
+
readonly width: number
|
|
2515
|
+
readonly height: number
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
export interface WorkletProjectionShared {
|
|
2519
|
+
readonly translateX: SharedValue<number>
|
|
2520
|
+
readonly translateY: SharedValue<number>
|
|
2521
|
+
readonly scaleX: SharedValue<number>
|
|
2522
|
+
readonly scaleY: SharedValue<number>
|
|
2523
|
+
// NaN denotes no radius correction. MotionView formats the actual host style at its boundary.
|
|
2524
|
+
readonly borderRadiusX: SharedValue<number>
|
|
2525
|
+
readonly borderRadiusY: SharedValue<number>
|
|
2526
|
+
readonly targetDeltaScaleX: SharedValue<number>
|
|
2527
|
+
readonly targetDeltaScaleY: SharedValue<number>
|
|
2528
|
+
readonly treeScaleX: SharedValue<number>
|
|
2529
|
+
readonly treeScaleY: SharedValue<number>
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/** Primitive address of one UI-runtime projection record for a host-layout frame callback. */
|
|
2533
|
+
export interface WorkletProjectionHostFeed {
|
|
2534
|
+
readonly driverId: number
|
|
2535
|
+
readonly componentId: string
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/** UI-runtime projection-tree lifecycle, separate from layoutId holder identity. */
|
|
2539
|
+
export interface WorkletDriverProjection {
|
|
2540
|
+
attach(
|
|
2541
|
+
componentId: string,
|
|
2542
|
+
parentId: string | null,
|
|
2543
|
+
projectionParticipant: boolean,
|
|
2544
|
+
borderRadius?: number | null,
|
|
2545
|
+
): void
|
|
2546
|
+
detach(componentId: string): void
|
|
2547
|
+
/**
|
|
2548
|
+
* Register, replace, or clear (null) the host's live radius feed (seal r13, F4): an
|
|
2549
|
+
* animated/gesture/bound radius channel's driver cell, read every frame so the tree's
|
|
2550
|
+
* correction answers the CURRENT authored radius. Unmount compaction rides detach.
|
|
2551
|
+
*/
|
|
2552
|
+
setProjectionRadiusFeed(componentId: string, cell: SharedValue<number> | null): void
|
|
2553
|
+
setProjection(
|
|
2554
|
+
componentId: string,
|
|
2555
|
+
source: WorkletProjectionRect,
|
|
2556
|
+
target: WorkletProjectionRect,
|
|
2557
|
+
progress: number,
|
|
2558
|
+
): void
|
|
2559
|
+
sharedFor(componentId: string): WorkletProjectionShared
|
|
2560
|
+
hostFeedFor(componentId: string): WorkletProjectionHostFeed
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
export interface WorkletDriverLayout {
|
|
2564
|
+
attach(
|
|
2565
|
+
id: string,
|
|
2566
|
+
token: LayoutIdToken,
|
|
2567
|
+
rect: WindowSpaceRect | null,
|
|
2568
|
+
options?: WorkletLayoutFlightOptions,
|
|
2569
|
+
): LayoutIdentityRole
|
|
2570
|
+
commitRect(
|
|
2571
|
+
id: string,
|
|
2572
|
+
token: LayoutIdToken,
|
|
2573
|
+
rect: WindowSpaceRect,
|
|
2574
|
+
options?: WorkletLayoutFlightOptions,
|
|
2575
|
+
): boolean
|
|
2576
|
+
// Returns whether this detach retained the holder as an exiting reverse-flight record. The
|
|
2577
|
+
// lifecycle uses this current edge result rather than an historical attach role.
|
|
2578
|
+
detach(id: string, token: LayoutIdToken, options?: WorkletLayoutFlightOptions): boolean
|
|
2579
|
+
// Get-or-create: styles bind at render time, before the first attach lands at onLayout; a
|
|
2580
|
+
// never-attached holder's record sits at rest pose.
|
|
2581
|
+
sharedFor(id: string, token: LayoutIdToken): LayoutIdentityHolderShared
|
|
2582
|
+
// U6 W4 verification witness (the device proof cards' crossfade row): the live painted opacity
|
|
2583
|
+
// of every holder record the identity still holds — the mounted lead AND an exiting retained
|
|
2584
|
+
// follower, never a departed ghost. A lifecycle-edge sync read, never per frame; an unknown or
|
|
2585
|
+
// dropped id witnesses empty.
|
|
2586
|
+
opacityWitnessFor(id: string): readonly LayoutIdentityHolderOpacityWitness[]
|
|
2587
|
+
releasable(id: string, token: LayoutIdToken): boolean
|
|
2588
|
+
isSettled(id: string): boolean
|
|
2589
|
+
holds(id: string, token: LayoutIdToken): boolean
|
|
2590
|
+
isLead(id: string, token: LayoutIdToken): boolean
|
|
2591
|
+
// Fires once when this identity reaches rest, independent of other active elements or layout
|
|
2592
|
+
// identities. An already-settled identity delivers synchronously at registration.
|
|
2593
|
+
subscribeSettled(id: string, onSettled: () => void): () => void
|
|
2594
|
+
// Couple an exiting holder's release to a presence callback: fires EXACTLY ONCE when the
|
|
2595
|
+
// registry reports the crossfade is done with the holder (first report wins on both runtimes);
|
|
2596
|
+
// returns an uncouple for unmount-before-settle.
|
|
2597
|
+
coupleRelease(id: string, token: LayoutIdToken, safeToRemove: () => void): () => void
|
|
2598
|
+
readonly projection: WorkletDriverProjection
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// The native driver + the binding surface the smoke/screens need beyond the core seam: per-prop shared
|
|
2602
|
+
// values for animated styles. `driver` satisfies core's `Driver` contract exactly.
|
|
2603
|
+
export interface WorkletDriverBinding {
|
|
2604
|
+
readonly driver: WorkletDriver
|
|
2605
|
+
sharedFor(handle: ElementHandle, key: string): SharedValue<number>
|
|
2606
|
+
/**
|
|
2607
|
+
* Legacy test/check-surface adapter. Production bindings call `driver.write` with the explicit
|
|
2608
|
+
* retarget shape; this adapter delegates to that same seam and never crosses to UI directly.
|
|
2609
|
+
*/
|
|
2610
|
+
writeBoundValue(handle: ElementHandle, values: Readonly<Record<string, number>>): void
|
|
2611
|
+
/**
|
|
2612
|
+
* REQ-DRIVER-034: subscribe to this driver's dual-authority `cannot write bound value`
|
|
2613
|
+
* refusals delivered on the SCHEDULED lane. The synchronous lane still throws the refusal
|
|
2614
|
+
* into the caller's catch; the scheduled lane cannot marshal a throw back, so the refusal is
|
|
2615
|
+
* delivered here (one JS task later, like every UI-originated dispatch) instead of retiring
|
|
2616
|
+
* the admitted flight. Returns the unsubscribe; unsubscribing after dispose() is a no-op.
|
|
2617
|
+
*/
|
|
2618
|
+
onBoundValueWriteRefusal(listener: (handle: ElementHandle, key: string) => void): () => void
|
|
2619
|
+
/** The current command/hold state for a mounted property, consumed only by layout mirrors. */
|
|
2620
|
+
propertyStateFor?(handle: ElementHandle, key: string): WorkletDriverPropertyState
|
|
2621
|
+
/**
|
|
2622
|
+
* T23 C: arm/clear the per-frame committed-values listener for one element (the onUpdate
|
|
2623
|
+
* marshal). While armed, JS receives ONE primitive payload per frame in which any committed
|
|
2624
|
+
* value changed — the whole registered record each time (the pin's latest-values contract).
|
|
2625
|
+
* Delivery is post-commit fire-and-forget observability; null clears the arm.
|
|
2626
|
+
*/
|
|
2627
|
+
setFrameValuesListener(
|
|
2628
|
+
handle: ElementHandle,
|
|
2629
|
+
listener: ((values: Readonly<Record<string, number>>) => void) | null,
|
|
2630
|
+
): void
|
|
2631
|
+
readonly layout: WorkletDriverLayout
|
|
2632
|
+
// Quiesce-before-unmount (review round 4 major 5): deletes this driver's UI-runtime state —
|
|
2633
|
+
// elements, the layout slot, every holder output. `useWorkletDriver` calls it automatically at
|
|
2634
|
+
// component unmount; manual (deterministic) drivers call it when their rig is done. Idempotent.
|
|
2635
|
+
//
|
|
2636
|
+
// Post-dispose contract: constructive calls (register/command/step/attach/couple/reads of element
|
|
2637
|
+
// state) fail loud — they are use-after-free. Teardown-convergent calls are DEFINED, not defects,
|
|
2638
|
+
// because React deletes a subtree parent-first: the owning hook's dispose() cleanup runs before
|
|
2639
|
+
// descendant unmount effects, which still call deactivate/detach on the way out (device-observed
|
|
2640
|
+
// release FATAL, 2026-07-10: assertLive→setActive→MotionView unmount inside
|
|
2641
|
+
// commitPassiveUnmountEffectsInsideOfDeletedTree). dispose() already deleted everything those
|
|
2642
|
+
// calls would touch, so: setActive(false) and layout.detach no-op; a late async measureInWindow
|
|
2643
|
+
// completion's layout.commitRect is dropped; layout.releasable/isSettled read true (the
|
|
2644
|
+
// registry's dropped-id semantics extended to a dropped driver).
|
|
2645
|
+
dispose(): void
|
|
2646
|
+
// Liveness read (REQ-DRIVER-022's non-throwing class): true once dispose() ran. The hook's
|
|
2647
|
+
// effect setup consults it to fail loud on a StrictMode replay (strictModeReplay.ts).
|
|
2648
|
+
isDisposed(): boolean
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// Internal live-loop wiring, provided only by `useWorkletDriver`: activate the (single) frame
|
|
2652
|
+
// callback when a command lands.
|
|
2653
|
+
interface LiveLoopHooks {
|
|
2654
|
+
readonly nextEpoch: () => number
|
|
2655
|
+
readonly startLoop: () => void
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
let nextDriverId = 0
|
|
2659
|
+
const identityValueChannelDriverIds = new WeakMap<WorkletDriverBinding, number>()
|
|
2660
|
+
|
|
2661
|
+
/** Engine-internal identity lookup; the public/native binding shape does not expose driver ids. */
|
|
2662
|
+
export function identityValueChannelDriverIdForBinding(
|
|
2663
|
+
binding: WorkletDriverBinding,
|
|
2664
|
+
): number | null {
|
|
2665
|
+
return identityValueChannelDriverIds.get(binding) ?? null
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function createDriverCore(
|
|
2669
|
+
options: WorkletDriverOptions,
|
|
2670
|
+
live: LiveLoopHooks | null,
|
|
2671
|
+
): {
|
|
2672
|
+
binding: WorkletDriverBinding
|
|
2673
|
+
driverId: number
|
|
2674
|
+
} {
|
|
2675
|
+
const driverId = nextDriverId++
|
|
2676
|
+
const manual = options.manualStepping === true
|
|
2677
|
+
const privateGroup = new ManualFrameGroup(driverId, [driverId])
|
|
2678
|
+
if (manual) {
|
|
2679
|
+
unwrapUiOutcome(executeOnUIRuntimeSync(uiPrefillManualGroupSlotGuarded)(driverId, 1))
|
|
2680
|
+
}
|
|
2681
|
+
if (!manual && live === null) {
|
|
2682
|
+
// REQ-DRIVER-011: the live frame source is the hook-owned useFrameCallback; there is no
|
|
2683
|
+
// frame-source-less live mode to fall back to.
|
|
2684
|
+
throw new Error(
|
|
2685
|
+
'live worklet drivers are created via useWorkletDriver(); use manualStepping for the deterministic mode.',
|
|
2686
|
+
)
|
|
2687
|
+
}
|
|
2688
|
+
// JS-side mirror of each element's shared values so sharedFor() needs no UI-runtime round trip.
|
|
2689
|
+
const sharedByHandle: Record<string, SharedValue<number>>[] = []
|
|
2690
|
+
const layoutMirrorByHandle: Record<string, WorkletDriverPropertyState>[] = []
|
|
2691
|
+
// The live write lane is asynchronous but gesture-edge reads are synchronous. Mirror only the
|
|
2692
|
+
// newest queued recognizer write per prop; `liveFor` uses the UI-applied version to distinguish a
|
|
2693
|
+
// genuinely pending write from an applied write whose value may since have drifted under
|
|
2694
|
+
// contention. The mirror never mutates UI state, so command/write/release ordering stays intact.
|
|
2695
|
+
const pendingWriteByHandle: Record<string, PendingGestureWrite>[] = []
|
|
2696
|
+
// T23 (device crash 3): the JS-side registration snapshot per handle. Live mode QUEUES the
|
|
2697
|
+
// register cross while committed() reads through the immediate sync lane, so a same-turn
|
|
2698
|
+
// read (a label entrance's discrete/color retarget in the mounting render) outruns the
|
|
2699
|
+
// push. A never-stepped element's committed values ARE its registration values — steps run
|
|
2700
|
+
// on the UI runtime BEHIND the same queue — so serving the snapshot is exact, never a
|
|
2701
|
+
// fallback; truly-unknown handles stay loud via this array's own range.
|
|
2702
|
+
const registeredCommittedByHandle: Record<string, number>[] = []
|
|
2703
|
+
let nextGestureWriteVersion = 1
|
|
2704
|
+
|
|
2705
|
+
// Crossing discipline: the live path schedules (fire-and-forget, O(1) per command,
|
|
2706
|
+
// REQ-DRIVER-013); the manual/verification path normally crosses SYNCHRONOUSLY so
|
|
2707
|
+
// command→step→read sequences are strictly ordered. The exception is a command transitively
|
|
2708
|
+
// invoked by a UI→JS callback: it queues even in manual mode to avoid a `runSyncSerialized`
|
|
2709
|
+
// re-entry while the UI thread is in `triggerUI`. A queued command preserves FIFO order with its
|
|
2710
|
+
// originating callback; direct manual calls retain their deterministic synchronous contract.
|
|
2711
|
+
// Crossing works return a UiOutcome (their bodies ride uiGuardLane): the sync lane unwraps it
|
|
2712
|
+
// JS-side; the scheduled lane's failures marshal to RN through the settle-dispatch channel and
|
|
2713
|
+
// retire the affected element (iOS AUTO SIGABRT Phase 2) — never a UI-runtime rethrow.
|
|
2714
|
+
function cross(work: (synchronous: boolean) => UiOutcome<void>): void {
|
|
2715
|
+
const synchronous = manual && uiOriginatedCallbackDepth === 0
|
|
2716
|
+
if (synchronous) unwrapUiOutcome(executeOnUIRuntimeSync(() => work(true))())
|
|
2717
|
+
// Named worklet + worklet arg (not an anonymous UI lambda that captures another worklet —
|
|
2718
|
+
// diamond-DAG hang class). Pass `work` as a scheduleOnUI argument (branded worklet ref).
|
|
2719
|
+
else scheduleOnUI(invokeAsyncCrossWork, work)
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Disposal latch: CONSTRUCTIVE use after dispose() is a lifecycle defect — fail loud instead of
|
|
2723
|
+
// silently resurrecting an empty UI record through uiDriverState's get-or-create. Teardown-
|
|
2724
|
+
// convergent calls (setActive(false), layout.detach, a late commitRect, liveness reads) are
|
|
2725
|
+
// instead defined no-ops/terminal values — see the WorkletDriverBinding.dispose() contract.
|
|
2726
|
+
let disposed = false
|
|
2727
|
+
function assertLive(operation: string): void {
|
|
2728
|
+
if (disposed) {
|
|
2729
|
+
throw new Error(
|
|
2730
|
+
`worklet driver ${String(driverId)} is disposed: ${operation} after dispose() is a lifecycle defect.`,
|
|
2731
|
+
)
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// JS-side Transition → plain-data resolution, single-sourced through the COMPONENT converters —
|
|
2736
|
+
// identical selection logic to the reference driver's startGenerator (spring is the fallback;
|
|
2737
|
+
// 'tween' for an explicit tween OR an authored duration-only untyped bag, REQ-TIMING-006).
|
|
2738
|
+
function toPlainTransition(rawTransition: PropTransition): PlainTransition {
|
|
2739
|
+
// The shared core adapter already detached and froze this transition. Re-capturing here would
|
|
2740
|
+
// reintroduce backend-specific materialization after the REQ-DRIVER-030 boundary.
|
|
2741
|
+
const transition = rawTransition
|
|
2742
|
+
// U7b: the GLOBAL instant window (the pin's MotionGlobalConfig.instantAnimations consult,
|
|
2743
|
+
// motion-dom@12.42.2 motion-value.ts:100-109) — while open, EVERY starting transition takes
|
|
2744
|
+
// the U7a instant commit with delay ZEROED (the pin zeroes delay only for the global flag;
|
|
2745
|
+
// the authored type:false below honors it — F2). Read HERE, JS-side, before the marshaling —
|
|
2746
|
+
// the pin's inertia lane honors the flag too, so this precedes every kind.
|
|
2747
|
+
if (isInstantWindowOpen()) {
|
|
2748
|
+
return { kind: 'instant', delayMs: 0 }
|
|
2749
|
+
}
|
|
2750
|
+
// A controller-resolved default (DefaultTransition, consult agent-2026-07-19-e715fd) normalizes
|
|
2751
|
+
// HERE, JS-side, mirroring the reference driver's normalizeTransition (no cross-driver skew): a
|
|
2752
|
+
// `type:'keyframes'` default → an equivalent tween (an ABSENT `ease` is preserved so the keyframes
|
|
2753
|
+
// generator keeps its intrinsic per-segment easeInOut for the 800ms default); a spring default
|
|
2754
|
+
// carries `restSpeed` into the SpringConfig settle threshold. Since U7c (REQ-SPRING-014) an
|
|
2755
|
+
// authored spring may carry restSpeed/restDelta too — the same extraction reads either, and
|
|
2756
|
+
// toSpringConfig's own passthrough agrees. The UI runtime stays config-only; it never sees the
|
|
2757
|
+
// union.
|
|
2758
|
+
const restSpeed =
|
|
2759
|
+
transition.type === 'spring' && 'restSpeed' in transition ? transition.restSpeed : undefined
|
|
2760
|
+
const authored: Transition | InertiaTransition =
|
|
2761
|
+
transition.type === 'keyframes'
|
|
2762
|
+
? {
|
|
2763
|
+
type: 'tween',
|
|
2764
|
+
duration: transition.duration,
|
|
2765
|
+
...(transition.ease !== undefined ? { ease: transition.ease } : {}),
|
|
2766
|
+
...(transition.delay !== undefined ? { delay: transition.delay } : {}),
|
|
2767
|
+
...(transition.repeat !== undefined ? { repeat: transition.repeat } : {}),
|
|
2768
|
+
...(transition.repeatType !== undefined ? { repeatType: transition.repeatType } : {}),
|
|
2769
|
+
...(transition.repeatDelay !== undefined
|
|
2770
|
+
? { repeatDelay: transition.repeatDelay }
|
|
2771
|
+
: {}),
|
|
2772
|
+
}
|
|
2773
|
+
: transition
|
|
2774
|
+
// Every plain transition carries the keyframes config too (REQ-API-033): a keyframe ARRAY target
|
|
2775
|
+
// selects it UI-side, matching the reference driver's `toKeyframesConfig`. `explicitSpring` carries
|
|
2776
|
+
// the LITERAL type so the array branch can distinguish an explicit spring from the omitted-type
|
|
2777
|
+
// fallback (which resolves to `kind: 'spring'` when no `duration` is authored but must route
|
|
2778
|
+
// arrays to keyframes — review major 8).
|
|
2779
|
+
// An inertia transition never interpolates a keyframe array — it short-circuits BEFORE the
|
|
2780
|
+
// keyframes converter, which would refuse the unknown type (R16).
|
|
2781
|
+
if (authored.type === 'inertia')
|
|
2782
|
+
return {
|
|
2783
|
+
kind: 'inertia',
|
|
2784
|
+
config: {
|
|
2785
|
+
power: authored.power,
|
|
2786
|
+
timeConstant: authored.timeConstant,
|
|
2787
|
+
bounceStiffness: authored.bounceStiffness,
|
|
2788
|
+
bounceDamping: authored.bounceDamping,
|
|
2789
|
+
restDelta: authored.restDelta,
|
|
2790
|
+
restSpeed: authored.restSpeed,
|
|
2791
|
+
min: authored.min,
|
|
2792
|
+
max: authored.max,
|
|
2793
|
+
},
|
|
2794
|
+
explicitSpring: false,
|
|
2795
|
+
}
|
|
2796
|
+
// U7a + transition-default-selection F5: the instant lane short-circuits BEFORE the keyframes
|
|
2797
|
+
// converter (which owns no `false` type) — the pin's guard is type-independent
|
|
2798
|
+
// (`type: false` OR `duration === 0 && !repeatDelay`, motion-value.ts:89-98). Plain data
|
|
2799
|
+
// only: the delay rebase shift in ms.
|
|
2800
|
+
if (authored.type === false || isDurationZeroInstantTransition(authored)) {
|
|
2801
|
+
return { kind: 'instant', delayMs: toDelayMs(authored) }
|
|
2802
|
+
}
|
|
2803
|
+
const keyframes = toKeyframesConfig(authored)
|
|
2804
|
+
// T18-a (REQ-API-049): the repeat family resolves JS-side into plain scalars, exactly like every
|
|
2805
|
+
// other config — the UI runtime receives numbers and a string, never the authored object. `null`
|
|
2806
|
+
// (not `undefined`) marks an inert fold so the marshaled shape stays a closed, total record.
|
|
2807
|
+
const repeat = toRepeatFoldOptions(authored)
|
|
2808
|
+
// T18-b (REQ-TIMING-004 / REQ-API-050): the delay shift crosses seconds→ms HERE, JS-side,
|
|
2809
|
+
// through the same `toDelayMs` boundary the reference driver reads — the UI runtime receives
|
|
2810
|
+
// a plain number, never the authored seconds.
|
|
2811
|
+
const delayMs = toDelayMs(authored)
|
|
2812
|
+
if (authored.type === 'tween' || isTimingLaneTransition(authored))
|
|
2813
|
+
return {
|
|
2814
|
+
kind: 'tween',
|
|
2815
|
+
// REQ-TIMING-006 + transition-default-selection F1/F6: the timing lane — a typed tween OR
|
|
2816
|
+
// an authored duration-ONLY bag OR a defined ease-only bag (F6's `isTimingLaneTransition`,
|
|
2817
|
+
// the pin's defined-typeless lane at the 300ms default, JSAnimation.ts:106-116) — resolves
|
|
2818
|
+
// through the ONE config builder (`toTimingLaneConfig`): the pin's `animateMotionValue`
|
|
2819
|
+
// `ease: "easeOut"` base (motion-dom@12.42.2 motion-value.ts:45-49) whenever no ease
|
|
2820
|
+
// spelling is authored, the authored ease verbatim for the ease-only form. The generator
|
|
2821
|
+
// default [0.25, 0.1, 0.35, 1] (REQ-TIMING-002) no longer governs an authored
|
|
2822
|
+
// typed tween; it survives only as the pin's per-key default tween. Never the old
|
|
2823
|
+
// omitted-type duration-spring (device-measured peak 1.046, u7a-2026-08-18), and never the
|
|
2824
|
+
// REQ-SPRING-003 generic spring for an ease-only bag.
|
|
2825
|
+
config: toTimingLaneConfig(authored),
|
|
2826
|
+
keyframes,
|
|
2827
|
+
explicitSpring: false,
|
|
2828
|
+
repeat,
|
|
2829
|
+
delayMs,
|
|
2830
|
+
}
|
|
2831
|
+
return {
|
|
2832
|
+
kind: 'spring',
|
|
2833
|
+
config:
|
|
2834
|
+
restSpeed !== undefined
|
|
2835
|
+
? { ...toSpringConfig(authored), restSpeed }
|
|
2836
|
+
: toSpringConfig(authored),
|
|
2837
|
+
keyframes,
|
|
2838
|
+
explicitSpring: authored.type === 'spring',
|
|
2839
|
+
repeat,
|
|
2840
|
+
delayMs,
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
const backend: Omit<PreparedDriver, 'write'> & {
|
|
2845
|
+
write(
|
|
2846
|
+
handle: ElementHandle,
|
|
2847
|
+
values: Readonly<Record<string, number>>,
|
|
2848
|
+
velocities?: Readonly<Record<string, number>>,
|
|
2849
|
+
eventShape?: WorkletDriverWriteShape,
|
|
2850
|
+
): void
|
|
2851
|
+
} = {
|
|
2852
|
+
register(initial) {
|
|
2853
|
+
assertLive('register')
|
|
2854
|
+
const shared: Record<string, SharedValue<number>> = {}
|
|
2855
|
+
const layoutMirror: Record<string, WorkletDriverPropertyState> = {}
|
|
2856
|
+
for (const key of Object.keys(initial)) {
|
|
2857
|
+
shared[key] = makeMutable(initial[key]!)
|
|
2858
|
+
layoutMirror[key] = { active: makeMutable(false), target: makeMutable(initial[key]!) }
|
|
2859
|
+
}
|
|
2860
|
+
const handle = sharedByHandle.length as ElementHandle
|
|
2861
|
+
sharedByHandle.push(shared)
|
|
2862
|
+
layoutMirrorByHandle.push(layoutMirror)
|
|
2863
|
+
pendingWriteByHandle.push({})
|
|
2864
|
+
registeredCommittedByHandle.push({ ...initial })
|
|
2865
|
+
const committed = { ...initial }
|
|
2866
|
+
cross((synchronous) => {
|
|
2867
|
+
'worklet'
|
|
2868
|
+
return uiGuardLane(
|
|
2869
|
+
synchronous,
|
|
2870
|
+
() => {
|
|
2871
|
+
const state = uiDriverState(driverId)
|
|
2872
|
+
state.elements.push({
|
|
2873
|
+
active: false,
|
|
2874
|
+
heldCount: 0,
|
|
2875
|
+
committed,
|
|
2876
|
+
heldVelocity: {},
|
|
2877
|
+
writeVersion: {},
|
|
2878
|
+
props: {},
|
|
2879
|
+
contending: {},
|
|
2880
|
+
propKeys: [],
|
|
2881
|
+
shared,
|
|
2882
|
+
layoutMirror,
|
|
2883
|
+
onUpdateArmed: false,
|
|
2884
|
+
lastNotified: {},
|
|
2885
|
+
registrationGeneration: 0,
|
|
2886
|
+
frameKeys: [],
|
|
2887
|
+
frameValues: [],
|
|
2888
|
+
path: null,
|
|
2889
|
+
})
|
|
2890
|
+
// alloc-ok: registration edge — grow the emission transaction's entry capacity to
|
|
2891
|
+
// cover the new element; the frame only fills these slots by index.
|
|
2892
|
+
state.emission.elementHandles.push(0)
|
|
2893
|
+
state.emission.elementGenerations.push(0)
|
|
2894
|
+
state.emission.elementCommitSequences.push(0)
|
|
2895
|
+
state.emission.elementMemberCounts.push(0)
|
|
2896
|
+
},
|
|
2897
|
+
driverId,
|
|
2898
|
+
undefined,
|
|
2899
|
+
false, // register: a fresh element push; the fold cannot reach this lane
|
|
2900
|
+
)
|
|
2901
|
+
})
|
|
2902
|
+
return handle
|
|
2903
|
+
},
|
|
2904
|
+
|
|
2905
|
+
command(handle, command) {
|
|
2906
|
+
assertLive('command')
|
|
2907
|
+
let plain: PlainCommand
|
|
2908
|
+
if (command.kind === 'stop') {
|
|
2909
|
+
plain = { kind: 'stop' }
|
|
2910
|
+
} else if (command.kind === 'start') {
|
|
2911
|
+
plain = {
|
|
2912
|
+
kind: 'start',
|
|
2913
|
+
targets: marshalStartTargets(command.targets, toPlainTransition),
|
|
2914
|
+
// Carry the release marker across the marshal as a definite boolean (plain data, no undefined
|
|
2915
|
+
// over the worklet boundary) so the UI runtime honors ownership (r8 major 24).
|
|
2916
|
+
releasesGesture: command.releasesGesture,
|
|
2917
|
+
...(command.path === undefined
|
|
2918
|
+
? {}
|
|
2919
|
+
: {
|
|
2920
|
+
path: {
|
|
2921
|
+
fromX: command.path.fromX,
|
|
2922
|
+
fromY: command.path.fromY,
|
|
2923
|
+
toX: command.path.toX,
|
|
2924
|
+
toY: command.path.toY,
|
|
2925
|
+
peak: command.path.peak,
|
|
2926
|
+
signedStrength: command.path.signedStrength,
|
|
2927
|
+
rotationScale: command.path.rotationScale,
|
|
2928
|
+
transition: toPlainTransition(command.path.transition),
|
|
2929
|
+
},
|
|
2930
|
+
}),
|
|
2931
|
+
}
|
|
2932
|
+
} else {
|
|
2933
|
+
plain = {
|
|
2934
|
+
kind: 'retarget',
|
|
2935
|
+
targets: marshalRetargetTargets(command.targets),
|
|
2936
|
+
transition: toPlainTransition(command.transition),
|
|
2937
|
+
...(command.targetTransitions === undefined
|
|
2938
|
+
? {}
|
|
2939
|
+
: {
|
|
2940
|
+
targetTransitions: marshalRetargetTransitions(
|
|
2941
|
+
command.targetTransitions,
|
|
2942
|
+
toPlainTransition,
|
|
2943
|
+
)!,
|
|
2944
|
+
}),
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
const loopEpoch = live !== null && plain.kind !== 'stop' ? live.nextEpoch() : null
|
|
2948
|
+
cross((synchronous) => {
|
|
2949
|
+
'worklet'
|
|
2950
|
+
return uiGuardLane(
|
|
2951
|
+
synchronous,
|
|
2952
|
+
() => {
|
|
2953
|
+
const state = uiDriverState(driverId)
|
|
2954
|
+
// Stamp the epoch BEFORE the apply: a failed command re-arms the settle edge from
|
|
2955
|
+
// inside uiGuardLane — with a retire for a genuine lane fault, without one for a fold
|
|
2956
|
+
// refusal (which mutated nothing) — and that re-arm must not be dropped as stale by
|
|
2957
|
+
// the epoch gate, because the failed command is still the latest intent (seal r2, F1).
|
|
2958
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
2959
|
+
uiApplyCommand(state, handle, plain)
|
|
2960
|
+
},
|
|
2961
|
+
driverId,
|
|
2962
|
+
handle as number,
|
|
2963
|
+
// uiApplyCommand builds every generator before committing anything, on both the start
|
|
2964
|
+
// and retarget cases, so a refusal here leaves the element exactly as it found it.
|
|
2965
|
+
true,
|
|
2966
|
+
)
|
|
2967
|
+
})
|
|
2968
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
2969
|
+
},
|
|
2970
|
+
|
|
2971
|
+
step(elapsedMs) {
|
|
2972
|
+
assertLive('step')
|
|
2973
|
+
// Legacy single-member adapter: the group owns the one JS→UI crossing. Multi-member groups
|
|
2974
|
+
// must call ManualFrameGroup.step directly.
|
|
2975
|
+
if (privateGroup.size !== 1) {
|
|
2976
|
+
throw new Error(
|
|
2977
|
+
`worklet driver ${String(driverId)} belongs to a multi-member ManualFrameGroup: call group.step instead of driver.step.`,
|
|
2978
|
+
)
|
|
2979
|
+
}
|
|
2980
|
+
privateGroup.step(elapsedMs)
|
|
2981
|
+
},
|
|
2982
|
+
|
|
2983
|
+
committed(handle) {
|
|
2984
|
+
assertLive('committed')
|
|
2985
|
+
// Unknown-handle loudness moves JS-side (this driver assigned every valid handle); the
|
|
2986
|
+
// UI read returns null while the register cross is still queued and the snapshot serves.
|
|
2987
|
+
const registered = registeredCommittedByHandle[handle as number]
|
|
2988
|
+
if (registered === undefined) {
|
|
2989
|
+
throw new Error(
|
|
2990
|
+
`unknown element handle ${String(handle)}: it was never registered on this driver.`,
|
|
2991
|
+
)
|
|
2992
|
+
}
|
|
2993
|
+
const outcome = unwrapUiOutcome(
|
|
2994
|
+
executeOnUIRuntimeSync(() => {
|
|
2995
|
+
'worklet'
|
|
2996
|
+
return uiGuard(() => {
|
|
2997
|
+
const state = uiDriverState(driverId)
|
|
2998
|
+
if ((handle as number) >= state.elements.length) return null
|
|
2999
|
+
return { ...uiElementAt(state, handle as number).committed }
|
|
3000
|
+
})
|
|
3001
|
+
})(),
|
|
3002
|
+
)
|
|
3003
|
+
return outcome === null ? { ...registered } : outcome
|
|
3004
|
+
},
|
|
3005
|
+
|
|
3006
|
+
setActive(handle, active) {
|
|
3007
|
+
// Deleted-tree ordering: the owner's dispose() runs before a child MotionView's unmount calls
|
|
3008
|
+
// setActive(false). The element record is already gone — deactivation is subsumed, and the
|
|
3009
|
+
// early return must not cross (uiDriverState's get-or-create would resurrect the record).
|
|
3010
|
+
// Activation stays fail-loud: animating a disposed driver is use-after-free.
|
|
3011
|
+
if (disposed && !active) return
|
|
3012
|
+
assertLive('setActive')
|
|
3013
|
+
const loopEpoch = active && live !== null ? live.nextEpoch() : null
|
|
3014
|
+
cross((synchronous) => {
|
|
3015
|
+
'worklet'
|
|
3016
|
+
return uiGuardLane(
|
|
3017
|
+
synchronous,
|
|
3018
|
+
() => {
|
|
3019
|
+
const state = uiDriverState(driverId)
|
|
3020
|
+
uiElementAt(state, handle as number).active = active
|
|
3021
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3022
|
+
},
|
|
3023
|
+
driverId,
|
|
3024
|
+
handle as number,
|
|
3025
|
+
false, // setActive: a flag write, not a build; the fold cannot reach this lane
|
|
3026
|
+
)
|
|
3027
|
+
})
|
|
3028
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3029
|
+
},
|
|
3030
|
+
|
|
3031
|
+
isActive(handle) {
|
|
3032
|
+
assertLive('isActive')
|
|
3033
|
+
return unwrapUiOutcome(
|
|
3034
|
+
executeOnUIRuntimeSync(() => {
|
|
3035
|
+
'worklet'
|
|
3036
|
+
return uiGuard(() => {
|
|
3037
|
+
return uiElementAt(uiDriverState(driverId), handle as number).active
|
|
3038
|
+
})
|
|
3039
|
+
})(),
|
|
3040
|
+
)
|
|
3041
|
+
},
|
|
3042
|
+
|
|
3043
|
+
// The one event-write lane (REQ-DRIVER-024/-029): recognizer writes and bound-value retargets
|
|
3044
|
+
// ride the same ordered crossing. The shape decides ownership; no secondary binding seam.
|
|
3045
|
+
// Neither event starts an animation (the declarative/release command arms the loop).
|
|
3046
|
+
write(handle, values, velocities, eventShape = 'gesture-hold') {
|
|
3047
|
+
assertLive('write')
|
|
3048
|
+
const writeVersion =
|
|
3049
|
+
eventShape === 'gesture-hold' ? nextGestureWriteVersion++ : nextGestureWriteVersion
|
|
3050
|
+
const keys = Object.keys(values)
|
|
3051
|
+
const registered = sharedByHandle[handle as number]
|
|
3052
|
+
const pending = pendingWriteByHandle[handle as number]
|
|
3053
|
+
// Do not mirror a malformed multi-prop write: uiApplyWrite validates the whole write before
|
|
3054
|
+
// mutation, so reflecting only its valid subset would manufacture a state the UI rejects.
|
|
3055
|
+
let canMirror = registered !== undefined && pending !== undefined
|
|
3056
|
+
for (const key of keys) {
|
|
3057
|
+
if (registered?.[key] === undefined) canMirror = false
|
|
3058
|
+
}
|
|
3059
|
+
cross((synchronous) => {
|
|
3060
|
+
'worklet'
|
|
3061
|
+
return uiGuardLane(
|
|
3062
|
+
synchronous,
|
|
3063
|
+
() => {
|
|
3064
|
+
uiApplyWrite(
|
|
3065
|
+
uiDriverState(driverId),
|
|
3066
|
+
handle as number,
|
|
3067
|
+
values,
|
|
3068
|
+
velocities,
|
|
3069
|
+
writeVersion,
|
|
3070
|
+
eventShape,
|
|
3071
|
+
)
|
|
3072
|
+
},
|
|
3073
|
+
driverId,
|
|
3074
|
+
handle as number,
|
|
3075
|
+
// uiApplyWrite re-anchors a contending generator INSIDE its per-key mutation loop, so
|
|
3076
|
+
// a refusal there is half-applied and must retire (round-10 BLOCKING 2).
|
|
3077
|
+
false,
|
|
3078
|
+
)
|
|
3079
|
+
})
|
|
3080
|
+
if (eventShape === 'gesture-hold' && canMirror && pending !== undefined) {
|
|
3081
|
+
for (const key of keys) {
|
|
3082
|
+
pending[key] = {
|
|
3083
|
+
version: writeVersion,
|
|
3084
|
+
value: values[key]!,
|
|
3085
|
+
velocity: velocities?.[key] ?? 0,
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
},
|
|
3090
|
+
|
|
3091
|
+
// The grab live-read (REQ-DRIVER-025): synchronous — the begin seed is needed before the next
|
|
3092
|
+
// recognizer event lands. O(1) per gesture edge. A queued recognizer write is covered by the
|
|
3093
|
+
// per-prop version fence below, so terminal reads cannot overtake it. A sync read can still
|
|
3094
|
+
// overtake a JUST-scheduled release command; it then reads the pre-release value — exactly what
|
|
3095
|
+
// the element still renders, so an immediate re-grab's C0 seam holds.
|
|
3096
|
+
liveFor(handle, key) {
|
|
3097
|
+
assertLive('liveFor')
|
|
3098
|
+
const liveRead = unwrapUiOutcome(
|
|
3099
|
+
executeOnUIRuntimeSync(() => {
|
|
3100
|
+
'worklet'
|
|
3101
|
+
return uiGuard(() => {
|
|
3102
|
+
return uiLiveFor(uiDriverState(driverId), handle as number, key)
|
|
3103
|
+
})
|
|
3104
|
+
})(),
|
|
3105
|
+
)
|
|
3106
|
+
const pendingByKey = pendingWriteByHandle[handle as number]
|
|
3107
|
+
if (pendingByKey !== undefined) {
|
|
3108
|
+
const pending = pendingByKey[key]
|
|
3109
|
+
if (pending === undefined) return { value: liveRead.value, velocity: liveRead.velocity }
|
|
3110
|
+
if (liveRead.writeVersion < pending.version) {
|
|
3111
|
+
return { value: pending.value, velocity: pending.velocity }
|
|
3112
|
+
}
|
|
3113
|
+
delete pendingByKey[key]
|
|
3114
|
+
}
|
|
3115
|
+
return { value: liveRead.value, velocity: liveRead.velocity }
|
|
3116
|
+
},
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
const preparedDriver = adaptPreparedDriver(backend, () => {
|
|
3120
|
+
assertLive('command')
|
|
3121
|
+
})
|
|
3122
|
+
const driver: WorkletDriver = {
|
|
3123
|
+
...preparedDriver,
|
|
3124
|
+
write(handle, values, velocities, eventShape = 'gesture-hold') {
|
|
3125
|
+
if (eventShape === 'gesture-hold') {
|
|
3126
|
+
preparedDriver.write(handle, values, velocities)
|
|
3127
|
+
return
|
|
3128
|
+
}
|
|
3129
|
+
backend.write(handle, values, velocities, eventShape)
|
|
3130
|
+
},
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// ——— The layout-identity surface (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md) ———
|
|
3134
|
+
// Lifecycle crossings are SYNCHRONOUS on one lane: they are O(1) per mount/commit/unmount edge,
|
|
3135
|
+
// and a scheduled lane would let a re-attach of a token overtake its own detach. Per-frame work
|
|
3136
|
+
// never crosses; the frame callback (or manual step) carries release edges via scheduleOnRN.
|
|
3137
|
+
const layoutSpring = options.layoutSpring
|
|
3138
|
+
const layoutSharedByHolder = new Map<string, LayoutIdentityHolderShared>()
|
|
3139
|
+
// Projection-tree output belongs to a concrete Motion component, not a layoutId holder. Keep
|
|
3140
|
+
// its shared cells on JS solely for render-time lookup; their values are written only by the UI
|
|
3141
|
+
// ledger in uiLayoutEngine.
|
|
3142
|
+
const projectionSharedByComponent = new Map<string, WorkletProjectionShared>()
|
|
3143
|
+
let nextCouplingKey = 0
|
|
3144
|
+
const layoutCallbacks: LayoutCallbackRegistry = {
|
|
3145
|
+
release: new Map<number, () => void>(),
|
|
3146
|
+
settled: new Map<number, () => void>(),
|
|
3147
|
+
}
|
|
3148
|
+
layoutCallbacksByDriver.set(driverId, layoutCallbacks)
|
|
3149
|
+
let nextSettleWatcherKey = 0
|
|
3150
|
+
const layoutReleaseCallbacks = layoutCallbacks.release
|
|
3151
|
+
const layoutSettleCallbacks = layoutCallbacks.settled
|
|
3152
|
+
|
|
3153
|
+
// JSON-encode the pair: unambiguous for any id/token content (no separator to collide with) and
|
|
3154
|
+
// text-safe (review round 2026-07-09: a NUL-byte separator made this file classify as binary).
|
|
3155
|
+
function layoutHolderKey(id: string, token: LayoutIdToken): string {
|
|
3156
|
+
return JSON.stringify([id, String(token)])
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// Get-or-create: animated styles must bind holder outputs at RENDER time, before the first
|
|
3160
|
+
// attach lands at onLayout — a throwing lookup would invert the render→layout order. A record
|
|
3161
|
+
// for a never-attached holder just sits at rest pose (identity transform, opacity 1, no lift).
|
|
3162
|
+
function ensureHolderShared(id: string, token: LayoutIdToken): LayoutIdentityHolderShared {
|
|
3163
|
+
const holderKey = layoutHolderKey(id, token)
|
|
3164
|
+
let shared = layoutSharedByHolder.get(holderKey)
|
|
3165
|
+
if (shared === undefined) {
|
|
3166
|
+
shared = {
|
|
3167
|
+
translateX: makeMutable(0),
|
|
3168
|
+
translateY: makeMutable(0),
|
|
3169
|
+
scaleX: makeMutable(1),
|
|
3170
|
+
scaleY: makeMutable(1),
|
|
3171
|
+
opacity: makeMutable(1),
|
|
3172
|
+
zLifted: makeMutable(0),
|
|
3173
|
+
}
|
|
3174
|
+
layoutSharedByHolder.set(holderKey, shared)
|
|
3175
|
+
}
|
|
3176
|
+
return shared
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function ensureProjectionShared(componentId: string): WorkletProjectionShared {
|
|
3180
|
+
let shared = projectionSharedByComponent.get(componentId)
|
|
3181
|
+
if (shared === undefined) {
|
|
3182
|
+
shared = {
|
|
3183
|
+
translateX: makeMutable(0),
|
|
3184
|
+
translateY: makeMutable(0),
|
|
3185
|
+
scaleX: makeMutable(1),
|
|
3186
|
+
scaleY: makeMutable(1),
|
|
3187
|
+
borderRadiusX: makeMutable(Number.NaN),
|
|
3188
|
+
borderRadiusY: makeMutable(Number.NaN),
|
|
3189
|
+
targetDeltaScaleX: makeMutable(1),
|
|
3190
|
+
targetDeltaScaleY: makeMutable(1),
|
|
3191
|
+
treeScaleX: makeMutable(1),
|
|
3192
|
+
treeScaleY: makeMutable(1),
|
|
3193
|
+
}
|
|
3194
|
+
projectionSharedByComponent.set(componentId, shared)
|
|
3195
|
+
}
|
|
3196
|
+
return shared
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
const layout: WorkletDriverLayout = {
|
|
3200
|
+
projection: {
|
|
3201
|
+
attach(componentId, parentId, projectionParticipant, borderRadius = null) {
|
|
3202
|
+
assertLive('layout.projection.attach')
|
|
3203
|
+
const outputs = ensureProjectionShared(componentId) as unknown as UiProjectionOutputs
|
|
3204
|
+
unwrapUiOutcome(
|
|
3205
|
+
executeOnUIRuntimeSync(() => {
|
|
3206
|
+
'worklet'
|
|
3207
|
+
return uiGuard(() => {
|
|
3208
|
+
uiProjectionAttach(
|
|
3209
|
+
uiLayoutSlot(uiDriverState(driverId), layoutSpring),
|
|
3210
|
+
componentId,
|
|
3211
|
+
parentId,
|
|
3212
|
+
outputs,
|
|
3213
|
+
projectionParticipant,
|
|
3214
|
+
borderRadius,
|
|
3215
|
+
)
|
|
3216
|
+
})
|
|
3217
|
+
})(),
|
|
3218
|
+
)
|
|
3219
|
+
},
|
|
3220
|
+
|
|
3221
|
+
detach(componentId) {
|
|
3222
|
+
// React's child-before-parent cleanup is a tree invariant. A disposed driver already
|
|
3223
|
+
// owns all records, so a trailing unmount is convergent just like layout.detach.
|
|
3224
|
+
if (disposed) return
|
|
3225
|
+
assertLive('layout.projection.detach')
|
|
3226
|
+
projectionSharedByComponent.delete(componentId)
|
|
3227
|
+
unwrapUiOutcome(
|
|
3228
|
+
executeOnUIRuntimeSync(() => {
|
|
3229
|
+
'worklet'
|
|
3230
|
+
return uiGuard(() => {
|
|
3231
|
+
uiProjectionDetach(uiLayoutSlot(uiDriverState(driverId), layoutSpring), componentId)
|
|
3232
|
+
})
|
|
3233
|
+
})(),
|
|
3234
|
+
)
|
|
3235
|
+
},
|
|
3236
|
+
|
|
3237
|
+
setProjectionRadiusFeed(componentId, cell) {
|
|
3238
|
+
assertLive('layout.projection.setProjectionRadiusFeed')
|
|
3239
|
+
unwrapUiOutcome(
|
|
3240
|
+
executeOnUIRuntimeSync(() => {
|
|
3241
|
+
'worklet'
|
|
3242
|
+
return uiGuard(() => {
|
|
3243
|
+
uiSetProjectionRadiusFeed(
|
|
3244
|
+
uiLayoutSlot(uiDriverState(driverId), layoutSpring),
|
|
3245
|
+
componentId,
|
|
3246
|
+
cell,
|
|
3247
|
+
)
|
|
3248
|
+
})
|
|
3249
|
+
})(),
|
|
3250
|
+
)
|
|
3251
|
+
},
|
|
3252
|
+
|
|
3253
|
+
setProjection(componentId, source, target, progress) {
|
|
3254
|
+
assertLive('layout.projection.setProjection')
|
|
3255
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3256
|
+
unwrapUiOutcome(
|
|
3257
|
+
executeOnUIRuntimeSync(() => {
|
|
3258
|
+
'worklet'
|
|
3259
|
+
return uiGuard(() => {
|
|
3260
|
+
const state = uiDriverState(driverId)
|
|
3261
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3262
|
+
uiProjectionSetProjection(
|
|
3263
|
+
uiLayoutSlot(state, layoutSpring),
|
|
3264
|
+
componentId,
|
|
3265
|
+
source,
|
|
3266
|
+
target,
|
|
3267
|
+
progress,
|
|
3268
|
+
)
|
|
3269
|
+
})
|
|
3270
|
+
})(),
|
|
3271
|
+
)
|
|
3272
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3273
|
+
},
|
|
3274
|
+
|
|
3275
|
+
sharedFor(componentId) {
|
|
3276
|
+
assertLive('layout.projection.sharedFor')
|
|
3277
|
+
return ensureProjectionShared(componentId)
|
|
3278
|
+
},
|
|
3279
|
+
|
|
3280
|
+
hostFeedFor(componentId) {
|
|
3281
|
+
assertLive('layout.projection.hostFeedFor')
|
|
3282
|
+
// This is only an address. The host-layout channel reads/writes the UI-global ledger
|
|
3283
|
+
// directly on every frame; no function or JS→UI crossing crosses this boundary.
|
|
3284
|
+
return { driverId, componentId }
|
|
3285
|
+
},
|
|
3286
|
+
},
|
|
3287
|
+
|
|
3288
|
+
attach(id, token, rect, options) {
|
|
3289
|
+
assertLive('layout.attach')
|
|
3290
|
+
const shared = ensureHolderShared(id, token)
|
|
3291
|
+
const outputs = shared as unknown as UiLayoutHolderOutputs
|
|
3292
|
+
// Capture primitives so the worklet closure does not close over `options`.
|
|
3293
|
+
// optionsPresent: retain (omit) vs clear ({} without timing) vs set (tjynor).
|
|
3294
|
+
const optionsPresent = options !== undefined
|
|
3295
|
+
const instant = options?.instant === true
|
|
3296
|
+
const projectionComponentId = options?.projectionComponentId ?? null
|
|
3297
|
+
const timing = options?.timing ?? null
|
|
3298
|
+
const spring = options?.spring ?? null
|
|
3299
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3300
|
+
const role = unwrapUiOutcome(
|
|
3301
|
+
executeOnUIRuntimeSync(() => {
|
|
3302
|
+
'worklet'
|
|
3303
|
+
return uiGuard(() => {
|
|
3304
|
+
const state = uiDriverState(driverId)
|
|
3305
|
+
state.settleNotified = false // an entering pairing may begin a flight — re-arm the edge
|
|
3306
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3307
|
+
return uiLayoutAttach(
|
|
3308
|
+
uiLayoutSlot(state, layoutSpring),
|
|
3309
|
+
id,
|
|
3310
|
+
token,
|
|
3311
|
+
rect,
|
|
3312
|
+
outputs,
|
|
3313
|
+
instant,
|
|
3314
|
+
projectionComponentId,
|
|
3315
|
+
timing,
|
|
3316
|
+
spring,
|
|
3317
|
+
optionsPresent,
|
|
3318
|
+
)
|
|
3319
|
+
})
|
|
3320
|
+
})(),
|
|
3321
|
+
)
|
|
3322
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3323
|
+
return role
|
|
3324
|
+
},
|
|
3325
|
+
|
|
3326
|
+
commitRect(id, token, rect, options) {
|
|
3327
|
+
// measureInWindow completes async, so a commit can land after the tree unmounted and the
|
|
3328
|
+
// owner disposed. The registry it would commit into is gone — drop the stale measurement
|
|
3329
|
+
// (same late-completion class as the frame callback's post-dispose vsync peek); throwing
|
|
3330
|
+
// here would detonate inside the host's measurement callback.
|
|
3331
|
+
if (disposed) return false
|
|
3332
|
+
assertLive('layout.commitRect')
|
|
3333
|
+
const optionsPresent = options !== undefined
|
|
3334
|
+
const instant = options?.instant === true
|
|
3335
|
+
const timing = options?.timing ?? null
|
|
3336
|
+
const spring = options?.spring ?? null
|
|
3337
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3338
|
+
const committedLead = unwrapUiOutcome(
|
|
3339
|
+
executeOnUIRuntimeSync(() => {
|
|
3340
|
+
'worklet'
|
|
3341
|
+
return uiGuard(() => {
|
|
3342
|
+
const state = uiDriverState(driverId)
|
|
3343
|
+
state.settleNotified = false // a lead commit retargets the flight
|
|
3344
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3345
|
+
return uiLayoutCommit(
|
|
3346
|
+
uiLayoutSlot(state, layoutSpring),
|
|
3347
|
+
id,
|
|
3348
|
+
token,
|
|
3349
|
+
rect,
|
|
3350
|
+
instant,
|
|
3351
|
+
timing,
|
|
3352
|
+
spring,
|
|
3353
|
+
optionsPresent,
|
|
3354
|
+
)
|
|
3355
|
+
})
|
|
3356
|
+
})(),
|
|
3357
|
+
)
|
|
3358
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3359
|
+
return committedLead
|
|
3360
|
+
},
|
|
3361
|
+
|
|
3362
|
+
detach(id, token, options) {
|
|
3363
|
+
// Same teardown-convergence rule as setActive(false): dispose() dropped the registry and
|
|
3364
|
+
// every holder, which subsumes leave(). A descendant detaching after the owner disposed is
|
|
3365
|
+
// legal deleted-tree ordering, not use-after-free.
|
|
3366
|
+
if (disposed) return false
|
|
3367
|
+
assertLive('layout.detach')
|
|
3368
|
+
const optionsPresent = options !== undefined
|
|
3369
|
+
const instant = options?.instant === true
|
|
3370
|
+
const timing = options?.timing ?? null
|
|
3371
|
+
const spring = options?.spring ?? null
|
|
3372
|
+
// A retained layout holder receives its final detach from the UI-originated release
|
|
3373
|
+
// callback. It does not need the `holds()` result at that point (the release already won),
|
|
3374
|
+
// so queue the cleanup rather than synchronously re-entering the UI registry while the UI
|
|
3375
|
+
// runtime is still dispatching its callback. Ordinary lifecycle detaches retain their
|
|
3376
|
+
// exact synchronous result contract below.
|
|
3377
|
+
if (uiOriginatedCallbackDepth > 0) {
|
|
3378
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3379
|
+
cross((synchronous) => {
|
|
3380
|
+
'worklet'
|
|
3381
|
+
return uiGuardLane(
|
|
3382
|
+
synchronous,
|
|
3383
|
+
() => {
|
|
3384
|
+
const state = uiDriverState(driverId)
|
|
3385
|
+
state.settleNotified = false
|
|
3386
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3387
|
+
uiLayoutDetach(
|
|
3388
|
+
uiLayoutSlot(state, layoutSpring),
|
|
3389
|
+
id,
|
|
3390
|
+
token,
|
|
3391
|
+
instant,
|
|
3392
|
+
timing,
|
|
3393
|
+
spring,
|
|
3394
|
+
optionsPresent,
|
|
3395
|
+
)
|
|
3396
|
+
},
|
|
3397
|
+
driverId,
|
|
3398
|
+
undefined,
|
|
3399
|
+
false, // layout detach: no element handle; the fold cannot reach this lane
|
|
3400
|
+
)
|
|
3401
|
+
})
|
|
3402
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3403
|
+
return false
|
|
3404
|
+
}
|
|
3405
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3406
|
+
const exitingHolderRetained = unwrapUiOutcome(
|
|
3407
|
+
executeOnUIRuntimeSync(() => {
|
|
3408
|
+
'worklet'
|
|
3409
|
+
return uiGuard(() => {
|
|
3410
|
+
const state = uiDriverState(driverId)
|
|
3411
|
+
state.settleNotified = false // a lead leaving begins the reverse flight
|
|
3412
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3413
|
+
uiLayoutDetach(
|
|
3414
|
+
uiLayoutSlot(state, layoutSpring),
|
|
3415
|
+
id,
|
|
3416
|
+
token,
|
|
3417
|
+
instant,
|
|
3418
|
+
timing,
|
|
3419
|
+
spring,
|
|
3420
|
+
optionsPresent,
|
|
3421
|
+
)
|
|
3422
|
+
return state.layout!.registry.holds(id, token)
|
|
3423
|
+
})
|
|
3424
|
+
})(),
|
|
3425
|
+
)
|
|
3426
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3427
|
+
return exitingHolderRetained
|
|
3428
|
+
},
|
|
3429
|
+
|
|
3430
|
+
sharedFor(id, token) {
|
|
3431
|
+
assertLive('layout.sharedFor')
|
|
3432
|
+
return ensureHolderShared(id, token)
|
|
3433
|
+
},
|
|
3434
|
+
|
|
3435
|
+
releasable(id, token) {
|
|
3436
|
+
// Liveness queries are non-throwing by contract (a dropped id reads releasable). dispose()
|
|
3437
|
+
// drops everything, so the dropped-id axiom extends to the whole driver — an exit polling
|
|
3438
|
+
// across the teardown boundary converges instead of crashing.
|
|
3439
|
+
if (disposed) return true
|
|
3440
|
+
assertLive('layout.releasable')
|
|
3441
|
+
return unwrapUiOutcome(
|
|
3442
|
+
executeOnUIRuntimeSync(() => {
|
|
3443
|
+
'worklet'
|
|
3444
|
+
return uiGuard(() => {
|
|
3445
|
+
const state = uiDriverStatePeek(driverId)
|
|
3446
|
+
const slot = state === null ? null : state.layout
|
|
3447
|
+
// No slot means nothing was ever tracked — the registry's dropped-id semantics.
|
|
3448
|
+
return slot === null ? true : slot.registry.releasable(id, token)
|
|
3449
|
+
})
|
|
3450
|
+
})(),
|
|
3451
|
+
)
|
|
3452
|
+
},
|
|
3453
|
+
|
|
3454
|
+
isSettled(id) {
|
|
3455
|
+
// Same dropped-driver axiom as releasable(): nothing tracked means nothing in flight.
|
|
3456
|
+
if (disposed) return true
|
|
3457
|
+
assertLive('layout.isSettled')
|
|
3458
|
+
return unwrapUiOutcome(
|
|
3459
|
+
executeOnUIRuntimeSync(() => {
|
|
3460
|
+
'worklet'
|
|
3461
|
+
return uiGuard(() => {
|
|
3462
|
+
const state = uiDriverStatePeek(driverId)
|
|
3463
|
+
const slot = state === null ? null : state.layout
|
|
3464
|
+
return slot === null ? true : slot.registry.isSettled(id)
|
|
3465
|
+
})
|
|
3466
|
+
})(),
|
|
3467
|
+
)
|
|
3468
|
+
},
|
|
3469
|
+
|
|
3470
|
+
holds(id, token) {
|
|
3471
|
+
if (disposed) return false
|
|
3472
|
+
assertLive('layout.holds')
|
|
3473
|
+
return unwrapUiOutcome(
|
|
3474
|
+
executeOnUIRuntimeSync(() => {
|
|
3475
|
+
'worklet'
|
|
3476
|
+
return uiGuard(() => {
|
|
3477
|
+
const state = uiDriverStatePeek(driverId)
|
|
3478
|
+
const slot = state === null ? null : state.layout
|
|
3479
|
+
return slot === null ? false : slot.registry.holds(id, token)
|
|
3480
|
+
})
|
|
3481
|
+
})(),
|
|
3482
|
+
)
|
|
3483
|
+
},
|
|
3484
|
+
|
|
3485
|
+
isLead(id, token) {
|
|
3486
|
+
if (disposed) return false
|
|
3487
|
+
assertLive('layout.isLead')
|
|
3488
|
+
return unwrapUiOutcome(
|
|
3489
|
+
executeOnUIRuntimeSync(() => {
|
|
3490
|
+
'worklet'
|
|
3491
|
+
return uiGuard(() => {
|
|
3492
|
+
const state = uiDriverStatePeek(driverId)
|
|
3493
|
+
const slot = state === null ? null : state.layout
|
|
3494
|
+
return slot === null ? false : slot.registry.isLead(id, token)
|
|
3495
|
+
})
|
|
3496
|
+
})(),
|
|
3497
|
+
)
|
|
3498
|
+
},
|
|
3499
|
+
|
|
3500
|
+
opacityWitnessFor(id) {
|
|
3501
|
+
// Same dropped-driver axiom as isSettled(): a dropped driver holds nothing to witness.
|
|
3502
|
+
if (disposed) return []
|
|
3503
|
+
assertLive('layout.opacityWitnessFor')
|
|
3504
|
+
return unwrapUiOutcome(
|
|
3505
|
+
executeOnUIRuntimeSync(() => {
|
|
3506
|
+
'worklet'
|
|
3507
|
+
return uiGuard(() => {
|
|
3508
|
+
const state = uiDriverStatePeek(driverId)
|
|
3509
|
+
const slot = state === null ? null : state.layout
|
|
3510
|
+
return slot === null ? [] : uiLayoutOpacityWitness(slot, id)
|
|
3511
|
+
})
|
|
3512
|
+
})(),
|
|
3513
|
+
)
|
|
3514
|
+
},
|
|
3515
|
+
|
|
3516
|
+
subscribeSettled(id, onSettled) {
|
|
3517
|
+
assertLive('layout.subscribeSettled')
|
|
3518
|
+
const key = nextSettleWatcherKey++
|
|
3519
|
+
layoutSettleCallbacks.set(key, onSettled)
|
|
3520
|
+
const emittedNow = unwrapUiOutcome(
|
|
3521
|
+
executeOnUIRuntimeSync(() => {
|
|
3522
|
+
'worklet'
|
|
3523
|
+
return uiGuard(() => {
|
|
3524
|
+
const state = uiDriverState(driverId)
|
|
3525
|
+
const slot = state.layout
|
|
3526
|
+
if (slot === null || slot.registry.isSettled(id)) return true
|
|
3527
|
+
state.layoutSettleWatchers.push({ id, key })
|
|
3528
|
+
// alloc-ok: lifecycle edge — one staged-settle slot per registered watcher. The frame
|
|
3529
|
+
// writes by index and never grows this transport array.
|
|
3530
|
+
state.emission.layoutSettledKeys.push(0)
|
|
3531
|
+
return false
|
|
3532
|
+
})
|
|
3533
|
+
})(),
|
|
3534
|
+
)
|
|
3535
|
+
if (emittedNow) {
|
|
3536
|
+
deliverLayoutSettled(driverId, key)
|
|
3537
|
+
return () => {}
|
|
3538
|
+
}
|
|
3539
|
+
return () => {
|
|
3540
|
+
layoutSettleCallbacks.delete(key)
|
|
3541
|
+
unwrapUiOutcome(
|
|
3542
|
+
executeOnUIRuntimeSync(() => {
|
|
3543
|
+
'worklet'
|
|
3544
|
+
return uiGuard(() => {
|
|
3545
|
+
const state = uiDriverStatePeek(driverId)
|
|
3546
|
+
if (state === null) return
|
|
3547
|
+
let w = 0
|
|
3548
|
+
for (let i = 0; i < state.layoutSettleWatchers.length; i++) {
|
|
3549
|
+
const watcher = state.layoutSettleWatchers[i]!
|
|
3550
|
+
if (watcher.key !== key) {
|
|
3551
|
+
state.layoutSettleWatchers[w] = watcher
|
|
3552
|
+
w += 1
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
state.layoutSettleWatchers.length = w
|
|
3556
|
+
})
|
|
3557
|
+
})(),
|
|
3558
|
+
)
|
|
3559
|
+
}
|
|
3560
|
+
},
|
|
3561
|
+
|
|
3562
|
+
coupleRelease(id, token, safeToRemove) {
|
|
3563
|
+
assertLive('layout.coupleRelease')
|
|
3564
|
+
const key = nextCouplingKey++
|
|
3565
|
+
layoutReleaseCallbacks.set(key, safeToRemove)
|
|
3566
|
+
const loopEpoch = live?.nextEpoch() ?? null
|
|
3567
|
+
// Registration and the already-releasable check are ONE atomic UI hop (the engine re-checks
|
|
3568
|
+
// at registration), so the immediate-release frontier cannot race a frame in between.
|
|
3569
|
+
const emittedNow = unwrapUiOutcome(
|
|
3570
|
+
executeOnUIRuntimeSync(() => {
|
|
3571
|
+
'worklet'
|
|
3572
|
+
return uiGuard(() => {
|
|
3573
|
+
const state = uiDriverState(driverId)
|
|
3574
|
+
if (loopEpoch !== null) state.loopEpoch = loopEpoch
|
|
3575
|
+
let emitted = false
|
|
3576
|
+
uiLayoutCouple(uiLayoutSlot(state, layoutSpring), id, token, key, () => {
|
|
3577
|
+
emitted = true
|
|
3578
|
+
})
|
|
3579
|
+
return emitted
|
|
3580
|
+
})
|
|
3581
|
+
})(),
|
|
3582
|
+
)
|
|
3583
|
+
if (emittedNow) {
|
|
3584
|
+
deliverLayoutRelease(driverId, key)
|
|
3585
|
+
return () => {}
|
|
3586
|
+
}
|
|
3587
|
+
if (loopEpoch !== null) live!.startLoop()
|
|
3588
|
+
return () => {
|
|
3589
|
+
layoutReleaseCallbacks.delete(key)
|
|
3590
|
+
unwrapUiOutcome(
|
|
3591
|
+
executeOnUIRuntimeSync(() => {
|
|
3592
|
+
'worklet'
|
|
3593
|
+
return uiGuard(() => {
|
|
3594
|
+
const state = uiDriverStatePeek(driverId)
|
|
3595
|
+
const slot = state === null ? null : state.layout
|
|
3596
|
+
if (slot !== null) uiLayoutUncouple(slot, key)
|
|
3597
|
+
})
|
|
3598
|
+
})(),
|
|
3599
|
+
)
|
|
3600
|
+
}
|
|
3601
|
+
},
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
const binding: WorkletDriverBinding = {
|
|
3605
|
+
driver,
|
|
3606
|
+
sharedFor(handle, key) {
|
|
3607
|
+
assertLive('sharedFor')
|
|
3608
|
+
const shared = sharedByHandle[handle as number]?.[key]
|
|
3609
|
+
if (shared === undefined) {
|
|
3610
|
+
throw new Error(
|
|
3611
|
+
`no shared value for handle ${String(handle)} prop '${key}': register it first.`,
|
|
3612
|
+
)
|
|
3613
|
+
}
|
|
3614
|
+
return shared
|
|
3615
|
+
},
|
|
3616
|
+
writeBoundValue(handle, values) {
|
|
3617
|
+
driver.write(handle, values, undefined, 'bound-value-retarget')
|
|
3618
|
+
},
|
|
3619
|
+
onBoundValueWriteRefusal(listener) {
|
|
3620
|
+
assertLive('onBoundValueWriteRefusal')
|
|
3621
|
+
let listeners = boundValueWriteRefusalListenersByDriverId.get(driverId)
|
|
3622
|
+
if (listeners === undefined) {
|
|
3623
|
+
listeners = new Set()
|
|
3624
|
+
boundValueWriteRefusalListenersByDriverId.set(driverId, listeners)
|
|
3625
|
+
}
|
|
3626
|
+
const subscribed = listener as (handle: number, key: string) => void
|
|
3627
|
+
listeners.add(subscribed)
|
|
3628
|
+
return () => {
|
|
3629
|
+
listeners.delete(subscribed)
|
|
3630
|
+
if (listeners.size === 0) boundValueWriteRefusalListenersByDriverId.delete(driverId)
|
|
3631
|
+
}
|
|
3632
|
+
},
|
|
3633
|
+
setFrameValuesListener(handle, listener) {
|
|
3634
|
+
assertLive('setFrameValuesListener')
|
|
3635
|
+
// JS registry first (the crossing below is fire-and-forget): the listener must be
|
|
3636
|
+
// recoverable the moment the UI arm lands, and clearing must drop it even if the
|
|
3637
|
+
// disarm crossing is still queued.
|
|
3638
|
+
let byHandle = elementFrameValueCallbacks.get(driverId)
|
|
3639
|
+
if (byHandle === undefined) {
|
|
3640
|
+
byHandle = new Map()
|
|
3641
|
+
elementFrameValueCallbacks.set(driverId, byHandle)
|
|
3642
|
+
}
|
|
3643
|
+
const registrationGeneration = nextElementFrameValueRegistrationGeneration()
|
|
3644
|
+
if (listener === null) byHandle.delete(handle as number)
|
|
3645
|
+
else
|
|
3646
|
+
byHandle.set(handle as number, {
|
|
3647
|
+
generation: registrationGeneration,
|
|
3648
|
+
callback: listener,
|
|
3649
|
+
})
|
|
3650
|
+
const armed = listener !== null
|
|
3651
|
+
cross((synchronous) => {
|
|
3652
|
+
'worklet'
|
|
3653
|
+
return uiGuardLane(
|
|
3654
|
+
synchronous,
|
|
3655
|
+
() => {
|
|
3656
|
+
const element = uiElementAt(uiDriverState(driverId), handle as number)
|
|
3657
|
+
element.onUpdateArmed = armed
|
|
3658
|
+
element.registrationGeneration = registrationGeneration
|
|
3659
|
+
// The pin's cadence is value-change only, so an arm edge emits nothing by itself.
|
|
3660
|
+
// Disarm clears the change markers so a later re-arm cannot suppress its first
|
|
3661
|
+
// genuinely-changed frame against stale lastNotified values.
|
|
3662
|
+
if (!armed) {
|
|
3663
|
+
for (const key of Object.keys(element.lastNotified)) {
|
|
3664
|
+
delete element.lastNotified[key]
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
},
|
|
3668
|
+
driverId,
|
|
3669
|
+
undefined,
|
|
3670
|
+
false, // arm flag write: no build; the fold cannot reach this lane
|
|
3671
|
+
)
|
|
3672
|
+
})
|
|
3673
|
+
},
|
|
3674
|
+
propertyStateFor(handle, key) {
|
|
3675
|
+
assertLive('propertyStateFor')
|
|
3676
|
+
const state = layoutMirrorByHandle[handle as number]?.[key]
|
|
3677
|
+
if (state === undefined) {
|
|
3678
|
+
throw new Error(
|
|
3679
|
+
`no layout mirror state for handle ${String(handle)} prop '${key}': register it first.`,
|
|
3680
|
+
)
|
|
3681
|
+
}
|
|
3682
|
+
return state
|
|
3683
|
+
},
|
|
3684
|
+
layout,
|
|
3685
|
+
dispose() {
|
|
3686
|
+
if (disposed) return // idempotent — an unmount effect and an explicit call may both run
|
|
3687
|
+
disposed = true
|
|
3688
|
+
layoutReleaseCallbacks.clear() // a pending safeToRemove must not fire into an unmounted tree
|
|
3689
|
+
layoutSettleCallbacks.clear()
|
|
3690
|
+
layoutCallbacksByDriver.delete(driverId)
|
|
3691
|
+
driverSettleCallbacks.delete(driverId)
|
|
3692
|
+
elementFrameValueCallbacks.delete(driverId)
|
|
3693
|
+
boundValueWriteRefusalListenersByDriverId.delete(driverId)
|
|
3694
|
+
// The mailbox's accepted drain id is NOT forgotten here: envelopes the UI runtime sealed
|
|
3695
|
+
// before this crossing lands are still in flight, and they validate against it. The UI
|
|
3696
|
+
// side seals the driver's final DRIVER_DISPOSED envelope before deleting the record; JS
|
|
3697
|
+
// forgets the mailbox on that receipt, in order.
|
|
3698
|
+
cross((synchronous) => {
|
|
3699
|
+
'worklet'
|
|
3700
|
+
return uiGuardLane(
|
|
3701
|
+
synchronous,
|
|
3702
|
+
() => {
|
|
3703
|
+
const g = globalThis as UiGlobal
|
|
3704
|
+
const state = uiDriverStatePeek(driverId)
|
|
3705
|
+
if (state !== null) {
|
|
3706
|
+
state.emission.disposeStaged = true
|
|
3707
|
+
uiSealFrameEmission(state, null)
|
|
3708
|
+
}
|
|
3709
|
+
if (g.__nativeMotionDrivers !== undefined) delete g.__nativeMotionDrivers[driverId]
|
|
3710
|
+
if (g.__nativeMotionProjectionHostFrames !== undefined)
|
|
3711
|
+
delete g.__nativeMotionProjectionHostFrames[driverId]
|
|
3712
|
+
if (g.__nativeMotionManualGroups !== undefined)
|
|
3713
|
+
delete g.__nativeMotionManualGroups[driverId]
|
|
3714
|
+
},
|
|
3715
|
+
driverId,
|
|
3716
|
+
undefined,
|
|
3717
|
+
false, // dispose: no element to retire; the fold cannot reach this lane
|
|
3718
|
+
)
|
|
3719
|
+
})
|
|
3720
|
+
},
|
|
3721
|
+
isDisposed: () => disposed,
|
|
3722
|
+
}
|
|
3723
|
+
identityValueChannelDriverIds.set(binding, driverId)
|
|
3724
|
+
return { binding, driverId }
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
// Deterministic (manual-stepping) driver — the on-device verification surface. Live drivers MUST come
|
|
3728
|
+
// from `useWorkletDriver` (REQ-DRIVER-011), so `manualStepping: true` is required here.
|
|
3729
|
+
export function createWorkletDriver(options: WorkletDriverOptions): WorkletDriverBinding {
|
|
3730
|
+
return createDriverCore(options, null).binding
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// The LIVE native driver: one driver-owned `useFrameCallback` (real vsync, REQ-DRIVER-011) steps the
|
|
3734
|
+
// whole graph. Created inactive; a command activates it; the settle edge deactivates it.
|
|
3735
|
+
export function useWorkletDriver(options: WorkletDriverOptions = {}): WorkletDriverBinding {
|
|
3736
|
+
// The UI dispatcher is stable for the driver's lifetime, but its React callback is read-current:
|
|
3737
|
+
// a layoutId introduced after mount can install the Presence settle owner on a later render.
|
|
3738
|
+
const onAllSettledRef = useRef(options.onAllSettled)
|
|
3739
|
+
onAllSettledRef.current = options.onAllSettled
|
|
3740
|
+
|
|
3741
|
+
// A stale UI settle may be queued to JS while a new JS command has already started. The epoch
|
|
3742
|
+
// changes synchronously on that command edge, so the JS dispatcher can reject the stale settle
|
|
3743
|
+
// without a synchronous UI-runtime re-check (which deadlocks iOS when the frame is mid-crossing).
|
|
3744
|
+
const loopEpochRef = useRef(0)
|
|
3745
|
+
|
|
3746
|
+
// Create the driver core once, BEFORE the frame callback, so the worklet captures the plain
|
|
3747
|
+
// driverId directly. The startLoop/settle closures reference `frameCallback` lazily — they only run
|
|
3748
|
+
// after a command lands (post-render), when the const below is initialized.
|
|
3749
|
+
const stateRef = useRef<{
|
|
3750
|
+
binding: WorkletDriverBinding
|
|
3751
|
+
driverId: number
|
|
3752
|
+
} | null>(null)
|
|
3753
|
+
if (stateRef.current === null) {
|
|
3754
|
+
stateRef.current = createDriverCore(
|
|
3755
|
+
{ ...options, manualStepping: false },
|
|
3756
|
+
{
|
|
3757
|
+
nextEpoch: () => ++loopEpochRef.current,
|
|
3758
|
+
startLoop: () => frameCallback.setActive(true),
|
|
3759
|
+
},
|
|
3760
|
+
)
|
|
3761
|
+
// Seal-r16 F2: the proof app installs `__nativeMotionProofMarker` at module load; arm the
|
|
3762
|
+
// UI witness cells once so settle/completion counters stay zero-cost for ordinary consumers.
|
|
3763
|
+
if (isProofMarkerInstalled()) armUiProofWitnesses()
|
|
3764
|
+
}
|
|
3765
|
+
const driverId = stateRef.current.driverId
|
|
3766
|
+
|
|
3767
|
+
// The frame reaches this JS closure only through deliverDriverSettled() below. The primitive
|
|
3768
|
+
// epoch closes the command-vs-settle race without JS→UI `runSyncSerialized` re-entry from a
|
|
3769
|
+
// UI-originated dispatchEvent callback.
|
|
3770
|
+
const settleHandlerRef = useRef<((epoch: number) => void) | null>(null)
|
|
3771
|
+
if (settleHandlerRef.current === null) {
|
|
3772
|
+
settleHandlerRef.current = (settledEpoch) => {
|
|
3773
|
+
// Proof seam witness (no-op when the hook is unset): the epoch guard's verdict with both
|
|
3774
|
+
// epochs AND the driver id — delivered vs dropped-stale names whether the guard is the dead
|
|
3775
|
+
// link; the driver id attributes the verdict to the MotionView that owns it (finding B:
|
|
3776
|
+
// the 2026-08-18 capture could not attribute its three delivered lines).
|
|
3777
|
+
const proofMarker = (globalThis as ProofMarkerJsGlobal).__nativeMotionProofMarker
|
|
3778
|
+
if (loopEpochRef.current !== settledEpoch) {
|
|
3779
|
+
proofMarker?.(
|
|
3780
|
+
`settle-verdict dropped-stale driver=${driverId} settled=${settledEpoch} current=${loopEpochRef.current}`,
|
|
3781
|
+
)
|
|
3782
|
+
return
|
|
3783
|
+
}
|
|
3784
|
+
proofMarker?.(
|
|
3785
|
+
`settle-verdict delivered driver=${driverId} settled=${settledEpoch} current=${loopEpochRef.current}`,
|
|
3786
|
+
)
|
|
3787
|
+
frameCallback.setActive(false)
|
|
3788
|
+
onAllSettledRef.current?.()
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
driverSettleCallbacks.set(driverId, settleHandlerRef.current)
|
|
3792
|
+
|
|
3793
|
+
// @frame-path — THE per-frame step (REQ-DRIVER-011 single callback; REQ-DRIVER-021 scan root).
|
|
3794
|
+
// Starts inactive; per frame it advances the whole graph by the vsync-reported delta (null on
|
|
3795
|
+
// the first invocation → 0; a post-background gap is clamped by core's dt ceiling inside
|
|
3796
|
+
// stepProp, REQ-CLOCK-003). Peek, don't create: a vsync racing dispose() must not resurrect the
|
|
3797
|
+
// deleted record.
|
|
3798
|
+
const frameStep = (frame: FrameInfo) => {
|
|
3799
|
+
'worklet'
|
|
3800
|
+
const state = uiDriverStatePeek(driverId)
|
|
3801
|
+
if (state === null) return
|
|
3802
|
+
const dt = frame.timeSincePreviousFrame ?? 0
|
|
3803
|
+
const anyElementActive = uiStepAll(state, dt)
|
|
3804
|
+
uiEmitFrameValues(state)
|
|
3805
|
+
// The layout engine rides the SAME frame source (REQ-DRIVER-011) and joins the same settle
|
|
3806
|
+
// edge (REQ-DRIVER-020). Every conditional notification stages into the same primitive turn;
|
|
3807
|
+
// the seal below is the frame's only UI→JS crossing.
|
|
3808
|
+
let anyLayoutActive = false
|
|
3809
|
+
let staleReports:
|
|
3810
|
+
| readonly {
|
|
3811
|
+
readonly source: string
|
|
3812
|
+
readonly code: string
|
|
3813
|
+
readonly detail: string
|
|
3814
|
+
}[]
|
|
3815
|
+
| null = null
|
|
3816
|
+
if (state.layout !== null) {
|
|
3817
|
+
anyLayoutActive = uiLayoutStep(state.layout, dt)
|
|
3818
|
+
// The stale projection-report drain rides the SAME frame edge (F2): a layoutId-only scene
|
|
3819
|
+
// has no layoutTransition commit/frame drains, so every driver frame drains the tree's
|
|
3820
|
+
// ring to RN — a quiet frame gets the shared frozen empty array, allocating nothing.
|
|
3821
|
+
staleReports = state.layout.projection.tree.drainStaleReports()
|
|
3822
|
+
// Watch only identities with an interested completion owner. This is O(watchers), never a
|
|
3823
|
+
// scan of every mounted identity, and emits as soon as the watched id settles even while
|
|
3824
|
+
// another identity or property remains active.
|
|
3825
|
+
uiStageSettledLayoutWatchers(state)
|
|
3826
|
+
}
|
|
3827
|
+
if (!anyElementActive && !anyLayoutActive && !state.settleNotified) {
|
|
3828
|
+
state.settleNotified = true
|
|
3829
|
+
// The UI-side settle-emission witness (seal-r15 F2, proof-only; seal-r16 F2 gated): a
|
|
3830
|
+
// primitive counter on the UI global — never crossing on the channel under test. Armed
|
|
3831
|
+
// only when the proof app installed the marker (driver mount); ordinary consumers skip.
|
|
3832
|
+
// The JS-side last-pushed cell is filled from the envelope's DRIVER_SETTLE handler (not a
|
|
3833
|
+
// proof-only crossing — the frame retains its one-envelope traffic law).
|
|
3834
|
+
const proofGlobal = globalThis as ProofSettleChannelUiGlobal
|
|
3835
|
+
if (proofGlobal.__nativeMotionProofWitnessArmed === true) {
|
|
3836
|
+
proofGlobal.__nativeMotionProofSettleEmissions =
|
|
3837
|
+
(proofGlobal.__nativeMotionProofSettleEmissions ?? 0) + 1
|
|
3838
|
+
}
|
|
3839
|
+
state.emission.driverSettleEpoch = state.loopEpoch
|
|
3840
|
+
}
|
|
3841
|
+
uiSealFrameEmission(state, staleReports)
|
|
3842
|
+
}
|
|
3843
|
+
const frameCallback = useFrameCallback(frameStep, false)
|
|
3844
|
+
|
|
3845
|
+
// Quiesce-before-unmount (round 4 major 5): the component owning this driver is going away —
|
|
3846
|
+
// delete the UI-runtime state so remounts (gallery page changes, auto-mode key swaps) never
|
|
3847
|
+
// strand holder outputs and graph state in the worklet global. The binding survives re-renders
|
|
3848
|
+
// in stateRef. A StrictMode setup→cleanup→setup replay reaches this setup with the binding
|
|
3849
|
+
// already disposed — that replay is a deferred capability and fails loud (REQ-DRIVER-022;
|
|
3850
|
+
// BRIEF Decisions 2026-07-10), never a silent resurrection.
|
|
3851
|
+
useEffect(() => {
|
|
3852
|
+
const binding = stateRef.current!.binding
|
|
3853
|
+
assertSetupNotReplayedOnDisposed(binding.isDisposed())
|
|
3854
|
+
return () => {
|
|
3855
|
+
binding.dispose()
|
|
3856
|
+
}
|
|
3857
|
+
}, [])
|
|
3858
|
+
|
|
3859
|
+
return stateRef.current.binding
|
|
3860
|
+
}
|