@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,845 @@
|
|
|
1
|
+
// @frame-path — executes inside the UI-runtime frame step; allocation-gated (REQ-DRIVER-021).
|
|
2
|
+
// The layout-identity engine the driver hosts on the UI runtime
|
|
3
|
+
// (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md §Where the engine lives). RN-free like uiLayoutGraph
|
|
4
|
+
// so vitest exercises the exact functions the UI runtime executes: workletDriver.ts owns the
|
|
5
|
+
// crossings, shared-value creation, and the frame callback; this module owns the state machine —
|
|
6
|
+
// holder outputs, lifecycle routing, the per-frame write set, and the exactly-once releasable
|
|
7
|
+
// edge. Registry/session come from the file-tagged worklet-layout subpath ONLY (root-entry imports
|
|
8
|
+
// inside UI worklets are the proven segfault trap).
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createProjectionTree,
|
|
12
|
+
createLayoutIdentityRegistry,
|
|
13
|
+
type IdentityHolderHandle,
|
|
14
|
+
type IdentityHolderWrite,
|
|
15
|
+
type LayoutIdentityFlightOptions,
|
|
16
|
+
type LayoutIdentityRegistry,
|
|
17
|
+
type LayoutIdentityRole,
|
|
18
|
+
type LayoutIdToken,
|
|
19
|
+
type ProjectionTree,
|
|
20
|
+
type SpringConfig,
|
|
21
|
+
type TimingConfig,
|
|
22
|
+
type WindowSpaceRect,
|
|
23
|
+
} from '@unrulysystems/native-motion-core/worklet-layout'
|
|
24
|
+
import { createUiLayoutGraph, type UiLayoutGraph } from './uiLayoutGraph'
|
|
25
|
+
|
|
26
|
+
// The .value cell contract shared with Reanimated's SharedValue<number> — the engine only ever
|
|
27
|
+
// assigns .value, so vitest drives it with plain objects and the driver passes real shared values.
|
|
28
|
+
export interface MutableNumber {
|
|
29
|
+
value: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One holder's per-frame outputs, in substrate terms (top-left anchored transform, crossfade
|
|
33
|
+
// opacity, z-lift as 0/1 for zIndex/elevation mapping).
|
|
34
|
+
export interface UiLayoutHolderOutputs {
|
|
35
|
+
readonly translateX: MutableNumber
|
|
36
|
+
readonly translateY: MutableNumber
|
|
37
|
+
readonly scaleX: MutableNumber
|
|
38
|
+
readonly scaleY: MutableNumber
|
|
39
|
+
readonly opacity: MutableNumber
|
|
40
|
+
readonly zLifted: MutableNumber
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Projection output is intentionally separate from layoutId holder output. Layout identity owns
|
|
44
|
+
// shared-holder crossfades; this ledger owns component ancestry and parent-relative correction.
|
|
45
|
+
// Both are consumed by MotionView's single animated-style mapper, so no per-frame JS crossing is
|
|
46
|
+
// introduced when both capabilities coexist on one host.
|
|
47
|
+
export interface UiProjectionOutputs {
|
|
48
|
+
readonly translateX: MutableNumber
|
|
49
|
+
readonly translateY: MutableNumber
|
|
50
|
+
readonly scaleX: MutableNumber
|
|
51
|
+
readonly scaleY: MutableNumber
|
|
52
|
+
// Reanimated accepts a two-axis percentage radius as a string, but the UI ledger stays numeric
|
|
53
|
+
// and lets the host mapper format it. NaN is the explicit "no corrected radius" sentinel.
|
|
54
|
+
readonly borderRadiusX: MutableNumber
|
|
55
|
+
readonly borderRadiusY: MutableNumber
|
|
56
|
+
// U7g (REQ-LAYOUT-024): pin scale-corrector context factors copied from the tree each frame.
|
|
57
|
+
readonly targetDeltaScaleX: MutableNumber
|
|
58
|
+
readonly targetDeltaScaleY: MutableNumber
|
|
59
|
+
readonly treeScaleX: MutableNumber
|
|
60
|
+
readonly treeScaleY: MutableNumber
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface UiProjectionRecord {
|
|
64
|
+
readonly componentId: string
|
|
65
|
+
readonly outputs: UiProjectionOutputs
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The layoutTransition flight registry's UI-global shape, mirrored structurally (this module
|
|
69
|
+
// stays on the worklet-layout subpath only — root-entry imports inside UI worklets are the proven
|
|
70
|
+
// segfault trap). Detach consults it to cancel a torn-down host's in-flight projection flight.
|
|
71
|
+
interface ProjectionFlightRegistryUiGlobal {
|
|
72
|
+
__nativeMotionProjectionHostFrames?: Record<
|
|
73
|
+
number,
|
|
74
|
+
{
|
|
75
|
+
readonly nextGeneration: number
|
|
76
|
+
readonly frames: Record<string, { readonly generation: number }>
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
__nativeMotionProjectionHostFrameOwners?: Record<
|
|
80
|
+
number,
|
|
81
|
+
{ readonly driverId: number; readonly componentId: string; readonly generation: number }
|
|
82
|
+
>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface UiProjectionState {
|
|
86
|
+
readonly tree: ProjectionTree
|
|
87
|
+
readonly records: UiProjectionRecord[]
|
|
88
|
+
// Live radius feeds (seal r13, F4): an animated/gesture/bound radius channel's driver cell —
|
|
89
|
+
// the layout step reads it every frame so the tree's correction answers the CURRENT authored
|
|
90
|
+
// radius (read-current, seal r7 F5), not the mount-time flattened style. Lifecycle-edge only:
|
|
91
|
+
// attach/detach push and compact; the per-frame path reads by index.
|
|
92
|
+
readonly radiusFeeds: UiProjectionRadiusFeed[]
|
|
93
|
+
dirty: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface UiProjectionRadiusFeed {
|
|
97
|
+
readonly componentId: string
|
|
98
|
+
cell: MutableNumber
|
|
99
|
+
// The last value actually fed to the tree (null = never fed / fed null): the per-frame loop
|
|
100
|
+
// marks the step dirty only on a real transition, so a settled radius costs one compare.
|
|
101
|
+
lastFed: number | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface UiLayoutHolderRecord {
|
|
105
|
+
readonly id: string
|
|
106
|
+
readonly token: LayoutIdToken
|
|
107
|
+
// The enter()-minted pre-resolved handle: writeHolder's per-frame readInto dereferences it
|
|
108
|
+
// O(1) instead of re-resolving the token per painted holder per frame (round-7 major 4).
|
|
109
|
+
readonly handle: IdentityHolderHandle
|
|
110
|
+
readonly outputs: UiLayoutHolderOutputs
|
|
111
|
+
// The component-tree record that this holder's layoutId transform already paints. A lead writes
|
|
112
|
+
// its live transform into that tree so muggle descendants can correct it without applying the
|
|
113
|
+
// same FLIP transform a second time to the holder itself.
|
|
114
|
+
readonly projectionComponentId: string | null
|
|
115
|
+
// Latest common-space measurement for this mounted holder. The UI layer owns this narrow
|
|
116
|
+
// mirror because the core registry deliberately keeps holder geometry private; it lets the
|
|
117
|
+
// completion wire distinguish a comparable projection flight from a sole static commit.
|
|
118
|
+
rect: WindowSpaceRect | null
|
|
119
|
+
// Once this holder has been the current lead with a measured predecessor, its identity owns a
|
|
120
|
+
// real projection flight. It remains true through later retargets, but cannot make a holder
|
|
121
|
+
// current again after another token supersedes it.
|
|
122
|
+
ownsProjectionFlight: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface UiLayoutCoupling {
|
|
126
|
+
readonly id: string
|
|
127
|
+
readonly token: LayoutIdToken
|
|
128
|
+
readonly key: number
|
|
129
|
+
// Exactly-once latch: set on the emit frame so trailing frames (the callback runs until the
|
|
130
|
+
// RN-side settle edge lands) can never re-deliver, and the post-emit compaction needs no
|
|
131
|
+
// snapshot allocation (REQ-DRIVER-015).
|
|
132
|
+
fired: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Holders grouped per identity id: the per-frame write set is decided per ID (the registry's
|
|
136
|
+
// settledness is per id), so one active flight touches only its own holders — not every mounted
|
|
137
|
+
// holder on the driver (REQ-DRIVER-015's O(active) floor, review round 4 major 6).
|
|
138
|
+
interface UiLayoutIdGroup {
|
|
139
|
+
readonly id: string
|
|
140
|
+
readonly records: UiLayoutHolderRecord[]
|
|
141
|
+
// `records` retains reverse-flight departures, so it cannot identify the current lead for an
|
|
142
|
+
// instant commit after the registry has already settled. This mirrors mounted membership only.
|
|
143
|
+
readonly currentTokens: LayoutIdToken[]
|
|
144
|
+
// Membership marker for `activeGroups` (the invariant: active ⟺ the group is in that list).
|
|
145
|
+
// True from any lifecycle edge (attach/commit/detach may open work) until a step observes the
|
|
146
|
+
// id settled AND has written the exact rest pose. While true the id joins the driver's
|
|
147
|
+
// in-flight accounting — REQ-LAYOUT-019: an unmeasured pending pairing has NO graph animation
|
|
148
|
+
// yet must hold the settle edge open (review round 4 major 4).
|
|
149
|
+
active: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface UiLayoutState {
|
|
153
|
+
readonly graph: UiLayoutGraph
|
|
154
|
+
readonly registry: LayoutIdentityRegistry
|
|
155
|
+
// The owning worklet driver's identity: projection-flight cancellation at detach is scoped to
|
|
156
|
+
// it (seal r3, F4) — sibling drivers may host the same component ids.
|
|
157
|
+
readonly driverId: number
|
|
158
|
+
readonly groups: UiLayoutIdGroup[]
|
|
159
|
+
// The per-frame scan set (review round 5 major 1): groups enter at lifecycle edges and leave at
|
|
160
|
+
// their settle frame, so the frame loop walks O(active) groups — a settled mounted identity
|
|
161
|
+
// costs ZERO per-frame work, not even a flag check (REQ-DRIVER-015's O(active) floor).
|
|
162
|
+
readonly activeGroups: UiLayoutIdGroup[]
|
|
163
|
+
readonly couplings: UiLayoutCoupling[]
|
|
164
|
+
// Release keys emitted by a frame. The driver drains this primitive queue directly to the RN
|
|
165
|
+
// dispatcher so the UI frame never captures a nested release worklet. Capacity is prefilled at
|
|
166
|
+
// the coupling attach edge (couplings.length + releaseCount — each pending coupling fires at
|
|
167
|
+
// most once per frame); the frame writes `releaseKeys[releaseCount++]` and both drains reset
|
|
168
|
+
// only `releaseCount` (REQ-DRIVER-021: no in-frame push/truncation growth).
|
|
169
|
+
readonly releaseKeys: number[]
|
|
170
|
+
releaseCount: number
|
|
171
|
+
// The one per-frame read scratch: registry.readInto() refreshes it per painted holder — no
|
|
172
|
+
// IdentityRead/Transform records on the hot path (review round 5 major 2, REQ-DRIVER-015).
|
|
173
|
+
readonly read: IdentityHolderWrite
|
|
174
|
+
// Every Motion host joins this tree; only an actual layout participant contributes a delta.
|
|
175
|
+
// Keeping the tree beside the identity engine makes both paths UI-runtime resident and lets one
|
|
176
|
+
// frame callback produce all layout output before MotionView consumes its shared values.
|
|
177
|
+
readonly projection: UiProjectionState
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// alloc-ok: construction — one layout slot per driver, created at first attach.
|
|
181
|
+
// `driverId` scopes projection-flight cancellation at detach; -1 means no driver identity
|
|
182
|
+
// (a bare engine harness — cancellation is then a no-op, never cross-driver).
|
|
183
|
+
export function createUiLayoutState(spring?: SpringConfig, driverId = -1): UiLayoutState {
|
|
184
|
+
'worklet'
|
|
185
|
+
const graph = createUiLayoutGraph()
|
|
186
|
+
const registry = createLayoutIdentityRegistry({
|
|
187
|
+
graph,
|
|
188
|
+
...(spring === undefined ? {} : { spring }),
|
|
189
|
+
})
|
|
190
|
+
return {
|
|
191
|
+
graph,
|
|
192
|
+
registry,
|
|
193
|
+
driverId,
|
|
194
|
+
groups: [],
|
|
195
|
+
activeGroups: [],
|
|
196
|
+
couplings: [],
|
|
197
|
+
releaseKeys: [],
|
|
198
|
+
releaseCount: 0,
|
|
199
|
+
read: { translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, opacity: 0, zLifted: false },
|
|
200
|
+
// The frame-edge drain is this UI tree's declared report-delivery surface (seal r8, F2) —
|
|
201
|
+
// a callback cannot cross from the UI runtime, so the driver drains the ring onto the
|
|
202
|
+
// scheduleOnRN severity channel at every frame edge.
|
|
203
|
+
projection: {
|
|
204
|
+
tree: createProjectionTree({ delivery: 'drain' }),
|
|
205
|
+
records: [],
|
|
206
|
+
radiusFeeds: [],
|
|
207
|
+
dirty: false,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function projectionRecord(
|
|
213
|
+
state: UiProjectionState,
|
|
214
|
+
componentId: string,
|
|
215
|
+
): UiProjectionRecord | undefined {
|
|
216
|
+
'worklet'
|
|
217
|
+
for (let index = 0; index < state.records.length; index += 1) {
|
|
218
|
+
const record = state.records[index]!
|
|
219
|
+
if (record.componentId === componentId) return record
|
|
220
|
+
}
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Register one Motion host in the UI-resident projection tree at mount. */
|
|
225
|
+
export function uiProjectionAttach(
|
|
226
|
+
state: UiLayoutState,
|
|
227
|
+
componentId: string,
|
|
228
|
+
parentId: string | null,
|
|
229
|
+
outputs: UiProjectionOutputs,
|
|
230
|
+
projectionParticipant: boolean,
|
|
231
|
+
borderRadius: number | null = null,
|
|
232
|
+
): void {
|
|
233
|
+
'worklet'
|
|
234
|
+
const existing = projectionRecord(state.projection, componentId)
|
|
235
|
+
if (existing !== undefined) {
|
|
236
|
+
// React's same-state rerender keeps the original UI output cells/live flight intact.
|
|
237
|
+
state.projection.tree.register(componentId, parentId, projectionParticipant)
|
|
238
|
+
state.projection.tree.setBorderRadius(componentId, borderRadius)
|
|
239
|
+
state.projection.dirty = true
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
state.projection.tree.register(componentId, parentId, projectionParticipant)
|
|
243
|
+
state.projection.tree.setBorderRadius(componentId, borderRadius)
|
|
244
|
+
// alloc-ok: lifecycle-edge — host mount; one projection record per componentId, never per frame.
|
|
245
|
+
state.projection.records.push({ componentId, outputs })
|
|
246
|
+
state.projection.dirty = true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Register, replace, or clear (null cell) a host's live radius feed (seal r13, F4). */
|
|
250
|
+
export function uiSetProjectionRadiusFeed(
|
|
251
|
+
state: UiLayoutState,
|
|
252
|
+
componentId: string,
|
|
253
|
+
cell: MutableNumber | null,
|
|
254
|
+
): void {
|
|
255
|
+
'worklet'
|
|
256
|
+
for (let index = 0; index < state.projection.radiusFeeds.length; index += 1) {
|
|
257
|
+
const feed = state.projection.radiusFeeds[index]!
|
|
258
|
+
if (feed.componentId !== componentId) continue
|
|
259
|
+
if (cell === null) {
|
|
260
|
+
// Write-index-free single removal at a lifecycle edge: swap-remove keeps the loop
|
|
261
|
+
// allocation-free (a radius channel disappearing clears the feed — the tree's radius
|
|
262
|
+
// then answers only the attach-time static, and the next frame's null transition marks
|
|
263
|
+
// the correction null through lastFed).
|
|
264
|
+
feed.lastFed = null
|
|
265
|
+
state.projection.radiusFeeds[index] =
|
|
266
|
+
state.projection.radiusFeeds[state.projection.radiusFeeds.length - 1]!
|
|
267
|
+
state.projection.radiusFeeds.length -= 1
|
|
268
|
+
state.projection.tree.setBorderRadius(componentId, null)
|
|
269
|
+
state.projection.dirty = true
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
feed.cell = cell
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (cell === null) return
|
|
276
|
+
// alloc-ok: lifecycle-edge — one feed per hosted radius channel, never per frame.
|
|
277
|
+
state.projection.radiusFeeds.push({ componentId, cell, lastFed: null })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Remove a host after its descendants have detached; core asserts invalid tree teardown loudly.
|
|
281
|
+
* alloc-ok: lifecycle-edge — host detach; the owner-registry scan (Object.keys + the loop)
|
|
282
|
+
* runs only at teardown, never per frame. */
|
|
283
|
+
export function uiProjectionDetach(state: UiLayoutState, componentId: string): void {
|
|
284
|
+
'worklet'
|
|
285
|
+
state.projection.tree.unregister(componentId)
|
|
286
|
+
// Cancel the detached host's in-flight projection flight (iOS AUTO SIGABRT Phase 1.5): its
|
|
287
|
+
// host-frame record and completion owner are deleted HERE, so the next channel frame observes
|
|
288
|
+
// the vanished record and retires at that frame boundary — no further frames are scheduled
|
|
289
|
+
// for the torn-down host, and no stale setHostFrame can fire into the unregistered node.
|
|
290
|
+
// Scoped to THIS driver's identity (seal r3, F4): distinct drivers may legally host the same
|
|
291
|
+
// component id, and a detach must never cancel a sibling driver's live flight.
|
|
292
|
+
const registry = globalThis as ProjectionFlightRegistryUiGlobal
|
|
293
|
+
const owners = registry.__nativeMotionProjectionHostFrameOwners
|
|
294
|
+
if (owners !== undefined) {
|
|
295
|
+
for (const key of Object.keys(owners)) {
|
|
296
|
+
const ownerKey = Number(key)
|
|
297
|
+
const owner = owners[ownerKey]!
|
|
298
|
+
if (owner.componentId !== componentId || owner.driverId !== state.driverId) continue
|
|
299
|
+
const driverFrames = registry.__nativeMotionProjectionHostFrames?.[owner.driverId]
|
|
300
|
+
if (
|
|
301
|
+
driverFrames !== undefined &&
|
|
302
|
+
driverFrames.frames[componentId]?.generation === owner.generation
|
|
303
|
+
) {
|
|
304
|
+
delete driverFrames.frames[componentId]
|
|
305
|
+
}
|
|
306
|
+
delete owners[ownerKey]
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
let writeIndex = 0
|
|
310
|
+
for (let index = 0; index < state.projection.records.length; index += 1) {
|
|
311
|
+
const record = state.projection.records[index]!
|
|
312
|
+
if (record.componentId !== componentId) {
|
|
313
|
+
state.projection.records[writeIndex] = record
|
|
314
|
+
writeIndex += 1
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
state.projection.records.length = writeIndex
|
|
318
|
+
// The host's live radius feed leaves with its node (seal r13, F4) — same lifecycle-edge
|
|
319
|
+
// compaction discipline as the records above.
|
|
320
|
+
let feedWriteIndex = 0
|
|
321
|
+
for (let index = 0; index < state.projection.radiusFeeds.length; index += 1) {
|
|
322
|
+
const feed = state.projection.radiusFeeds[index]!
|
|
323
|
+
if (feed.componentId !== componentId) {
|
|
324
|
+
state.projection.radiusFeeds[feedWriteIndex] = feed
|
|
325
|
+
feedWriteIndex += 1
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
state.projection.radiusFeeds.length = feedWriteIndex
|
|
329
|
+
state.projection.dirty = true
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Feed a host-layout FLIP sample into the tree; source/target are already same-frame rects. */
|
|
333
|
+
export function uiProjectionSetProjection(
|
|
334
|
+
state: UiLayoutState,
|
|
335
|
+
componentId: string,
|
|
336
|
+
source: {
|
|
337
|
+
readonly x: number
|
|
338
|
+
readonly y: number
|
|
339
|
+
readonly width: number
|
|
340
|
+
readonly height: number
|
|
341
|
+
},
|
|
342
|
+
target: {
|
|
343
|
+
readonly x: number
|
|
344
|
+
readonly y: number
|
|
345
|
+
readonly width: number
|
|
346
|
+
readonly height: number
|
|
347
|
+
},
|
|
348
|
+
progress: number,
|
|
349
|
+
): void {
|
|
350
|
+
'worklet'
|
|
351
|
+
state.projection.tree.setProjection(componentId, source, target, progress)
|
|
352
|
+
state.projection.dirty = true
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Feed the host's CURRENT layout-animation frame into the projection tree. Reanimated owns the
|
|
357
|
+
* easing and may retarget an in-flight channel, so deriving progress on the JS thread would both
|
|
358
|
+
* skew the visual frame and add a forbidden crossing. The tree treats this as a host-painted
|
|
359
|
+
* frame: it carries parent-relative geometry to descendants without replaying the host FLIP.
|
|
360
|
+
*/
|
|
361
|
+
export function uiProjectionSetHostFrame(
|
|
362
|
+
state: UiLayoutState,
|
|
363
|
+
componentId: string,
|
|
364
|
+
current: {
|
|
365
|
+
readonly x: number
|
|
366
|
+
readonly y: number
|
|
367
|
+
readonly width: number
|
|
368
|
+
readonly height: number
|
|
369
|
+
},
|
|
370
|
+
target: {
|
|
371
|
+
readonly x: number
|
|
372
|
+
readonly y: number
|
|
373
|
+
readonly width: number
|
|
374
|
+
readonly height: number
|
|
375
|
+
},
|
|
376
|
+
): void {
|
|
377
|
+
'worklet'
|
|
378
|
+
state.projection.tree.setHostFrame(
|
|
379
|
+
componentId,
|
|
380
|
+
current.x,
|
|
381
|
+
current.y,
|
|
382
|
+
current.width,
|
|
383
|
+
current.height,
|
|
384
|
+
target.x,
|
|
385
|
+
target.y,
|
|
386
|
+
target.width,
|
|
387
|
+
target.height,
|
|
388
|
+
)
|
|
389
|
+
state.projection.dirty = true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function uiProjectionStep(state: UiProjectionState): boolean {
|
|
393
|
+
'worklet'
|
|
394
|
+
// Live radius feeds (seal r13, F4): the property driver's step already advanced these cells
|
|
395
|
+
// this frame (uiStepAll runs before the layout step in the same frame callback), so the
|
|
396
|
+
// tree's correction answers the frame-CURRENT authored/driver radius — read-current
|
|
397
|
+
// ownership (seal r7, F5). A non-finite or negative value feeds null (the "no corrected
|
|
398
|
+
// radius" sentinel — the authored radius is retained, never a bogus correction). Unchanged
|
|
399
|
+
// cells never mark the step dirty: a settled radius costs one compare per frame.
|
|
400
|
+
for (let index = 0; index < state.radiusFeeds.length; index += 1) {
|
|
401
|
+
const feed = state.radiusFeeds[index]!
|
|
402
|
+
const current = feed.cell.value
|
|
403
|
+
const fed = Number.isFinite(current) && current >= 0 ? current : null
|
|
404
|
+
if (fed !== feed.lastFed) {
|
|
405
|
+
feed.lastFed = fed
|
|
406
|
+
state.tree.setBorderRadius(feed.componentId, fed)
|
|
407
|
+
state.dirty = true
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (!state.dirty) return false
|
|
411
|
+
state.tree.project()
|
|
412
|
+
// No nested visitor worklet: the tree and output cells are UI-global state. Iteration occurs
|
|
413
|
+
// after the tree's depth-ordered calculation so every record reads its coherent final output.
|
|
414
|
+
for (let index = 0; index < state.records.length; index += 1) {
|
|
415
|
+
const record = state.records[index]!
|
|
416
|
+
const output = state.tree.output(record.componentId)
|
|
417
|
+
record.outputs.translateX.value = output.translateX
|
|
418
|
+
record.outputs.translateY.value = output.translateY
|
|
419
|
+
record.outputs.scaleX.value = output.scaleX
|
|
420
|
+
record.outputs.scaleY.value = output.scaleY
|
|
421
|
+
record.outputs.borderRadiusX.value = output.borderRadius?.x ?? Number.NaN
|
|
422
|
+
record.outputs.borderRadiusY.value = output.borderRadius?.y ?? Number.NaN
|
|
423
|
+
record.outputs.targetDeltaScaleX.value = output.targetDeltaScaleX
|
|
424
|
+
record.outputs.targetDeltaScaleY.value = output.targetDeltaScaleY
|
|
425
|
+
record.outputs.treeScaleX.value = output.treeScaleX
|
|
426
|
+
record.outputs.treeScaleY.value = output.treeScaleY
|
|
427
|
+
}
|
|
428
|
+
state.dirty = false
|
|
429
|
+
return true
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// alloc-ok: lifecycle-edge — attach/commit/detach lookups only; the per-frame scan walks
|
|
433
|
+
// state.activeGroups by index.
|
|
434
|
+
function findGroup(state: UiLayoutState, id: string): UiLayoutIdGroup | undefined {
|
|
435
|
+
'worklet'
|
|
436
|
+
for (const group of state.groups) if (group.id === id) return group
|
|
437
|
+
return undefined
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// alloc-ok: lifecycle-edge — get-or-create at attach only.
|
|
441
|
+
function groupOf(state: UiLayoutState, id: string): UiLayoutIdGroup {
|
|
442
|
+
'worklet'
|
|
443
|
+
const existing = findGroup(state, id)
|
|
444
|
+
if (existing !== undefined) return existing
|
|
445
|
+
// Created INACTIVE: every lifecycle edge activates through activateGroup so the active flag
|
|
446
|
+
// and the activeGroups membership can never skew.
|
|
447
|
+
const group: UiLayoutIdGroup = { id, records: [], currentTokens: [], active: false }
|
|
448
|
+
state.groups.push(group)
|
|
449
|
+
return group
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The ONLY writer of `active: true` — the flag mirrors activeGroups membership, so a re-activated
|
|
453
|
+
// already-active group must not double-enter the scan set (the step would write it twice).
|
|
454
|
+
function activateGroup(state: UiLayoutState, group: UiLayoutIdGroup): void {
|
|
455
|
+
'worklet'
|
|
456
|
+
if (group.active) return
|
|
457
|
+
group.active = true
|
|
458
|
+
state.activeGroups.push(group)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Drop a group from the scan set at a non-step edge (a detach that empties the id). Write-index
|
|
462
|
+
// compaction — splice allocates its removed-elements array (REQ-DRIVER-015).
|
|
463
|
+
function deactivateGroup(state: UiLayoutState, group: UiLayoutIdGroup): void {
|
|
464
|
+
'worklet'
|
|
465
|
+
if (!group.active) return
|
|
466
|
+
group.active = false
|
|
467
|
+
let w = 0
|
|
468
|
+
for (let i = 0; i < state.activeGroups.length; i++) {
|
|
469
|
+
const g = state.activeGroups[i]!
|
|
470
|
+
if (g !== group) {
|
|
471
|
+
state.activeGroups[w] = g
|
|
472
|
+
w += 1
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
state.activeGroups.length = w
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function writeHolder(state: UiLayoutState, record: UiLayoutHolderRecord): void {
|
|
479
|
+
'worklet'
|
|
480
|
+
// One registry call, zero allocation: readInto refreshes the state's scratch in place
|
|
481
|
+
// (read()+zLifted() built three records and evaluated the settle check twice per holder).
|
|
482
|
+
state.registry.readInto(record.handle, state.read)
|
|
483
|
+
record.outputs.translateX.value = state.read.translateX
|
|
484
|
+
record.outputs.translateY.value = state.read.translateY
|
|
485
|
+
record.outputs.scaleX.value = state.read.scaleX
|
|
486
|
+
record.outputs.scaleY.value = state.read.scaleY
|
|
487
|
+
record.outputs.opacity.value = state.read.opacity
|
|
488
|
+
record.outputs.zLifted.value = state.read.zLifted ? 1 : 0
|
|
489
|
+
// Only a true projection lead publishes into the tree. Sole/non-lead holders must remain
|
|
490
|
+
// muggle conduits (frameTreeCenterOffset / treeScale pass-through); identity setDirectProjection
|
|
491
|
+
// would force projectionPaintedByHost and break descendant counter-scale (review major
|
|
492
|
+
// c4f8a2b19e70).
|
|
493
|
+
if (record.projectionComponentId !== null && record.ownsProjectionFlight) {
|
|
494
|
+
state.projection.tree.setDirectProjection(
|
|
495
|
+
record.projectionComponentId,
|
|
496
|
+
state.read.translateX,
|
|
497
|
+
state.read.translateY,
|
|
498
|
+
state.read.scaleX,
|
|
499
|
+
state.read.scaleY,
|
|
500
|
+
)
|
|
501
|
+
state.projection.dirty = true
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Drop output records the registry no longer tracks (holds() false edge). Membership only
|
|
506
|
+
// changes through leave() — a trailing leave, the second leave of an exiting holder, or a full
|
|
507
|
+
// id drop — so pruning runs at the detach edge, never per frame. Write-index compaction: splice
|
|
508
|
+
// allocates its removed-elements array, which is banned on the UI hot path (REQ-DRIVER-015).
|
|
509
|
+
function pruneDepartedRecords(state: UiLayoutState, group: UiLayoutIdGroup): void {
|
|
510
|
+
'worklet'
|
|
511
|
+
let w = 0
|
|
512
|
+
for (let i = 0; i < group.records.length; i++) {
|
|
513
|
+
const record = group.records[i]!
|
|
514
|
+
if (state.registry.holds(record.id, record.token)) {
|
|
515
|
+
group.records[w] = record
|
|
516
|
+
w += 1
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
group.records.length = w
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// U6 W4 verification witness (the device proof cards' crossfade row): the LIVE painted opacity
|
|
523
|
+
// of every record the identity still holds — the mounted lead AND an exiting retained follower,
|
|
524
|
+
// never a departed ghost (pruneDepartedRecords keeps records exactly while the registry holds
|
|
525
|
+
// them). Reads the cells the frame loop writes, so the witness is what the holders paint.
|
|
526
|
+
// alloc-ok: lifecycle-edge — sampled by verification screens at timer edges, never the per-frame path.
|
|
527
|
+
export function uiLayoutOpacityWitness(
|
|
528
|
+
state: UiLayoutState,
|
|
529
|
+
id: string,
|
|
530
|
+
): { token: string; opacity: number }[] {
|
|
531
|
+
'worklet'
|
|
532
|
+
const group = findGroup(state, id)
|
|
533
|
+
if (group === undefined) return []
|
|
534
|
+
const witness: { token: string; opacity: number }[] = []
|
|
535
|
+
for (let i = 0; i < group.records.length; i++) {
|
|
536
|
+
const record = group.records[i]!
|
|
537
|
+
witness.push({ token: String(record.token), opacity: record.outputs.opacity.value })
|
|
538
|
+
}
|
|
539
|
+
return witness
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function removeCurrentToken(group: UiLayoutIdGroup, token: LayoutIdToken): void {
|
|
543
|
+
'worklet'
|
|
544
|
+
let writeIndex = 0
|
|
545
|
+
for (let index = 0; index < group.currentTokens.length; index += 1) {
|
|
546
|
+
const current = group.currentTokens[index]!
|
|
547
|
+
if (current !== token) {
|
|
548
|
+
group.currentTokens[writeIndex] = current
|
|
549
|
+
writeIndex += 1
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
group.currentTokens.length = writeIndex
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// A current lead can project only from a measured current predecessor. Exiting records are not
|
|
556
|
+
// candidates: core's holder stack has already removed them, so their geometry cannot start this
|
|
557
|
+
// mounted holder's flight.
|
|
558
|
+
function hasMeasuredCurrentPredecessor(group: UiLayoutIdGroup, token: LayoutIdToken): boolean {
|
|
559
|
+
'worklet'
|
|
560
|
+
for (let currentIndex = 0; currentIndex < group.currentTokens.length; currentIndex += 1) {
|
|
561
|
+
const currentToken = group.currentTokens[currentIndex]!
|
|
562
|
+
if (currentToken === token) continue
|
|
563
|
+
for (let recordIndex = 0; recordIndex < group.records.length; recordIndex += 1) {
|
|
564
|
+
const record = group.records[recordIndex]!
|
|
565
|
+
if (record.token === currentToken && record.rect !== null) return true
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function recordFor(group: UiLayoutIdGroup, token: LayoutIdToken): UiLayoutHolderRecord | undefined {
|
|
572
|
+
'worklet'
|
|
573
|
+
for (let index = 0; index < group.records.length; index += 1) {
|
|
574
|
+
const record = group.records[index]!
|
|
575
|
+
if (record.token === token) return record
|
|
576
|
+
}
|
|
577
|
+
return undefined
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Shared constants so lifecycle edges never allocate a fresh options object (REQ-DRIVER-021).
|
|
581
|
+
const INSTANT_FLIGHT = Object.freeze({ instant: true as const })
|
|
582
|
+
// Empty options bag: core treats `options !== undefined` without timing as clear-to-spring
|
|
583
|
+
// (identity commitRect read-current law). Distinct from omitted options (retain live mode).
|
|
584
|
+
const CLEAR_FLIGHT = Object.freeze({}) as LayoutIdentityFlightOptions
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Build registry flight options from primitive edge args (worklet-safe; no options object close-over).
|
|
588
|
+
* alloc-ok: lifecycle-edge — attach/commit/detach only; never per frame.
|
|
589
|
+
*
|
|
590
|
+
* `optionsPresent` preserves the core tri-state (tjynor 6a42f09de8c1, T22 spring alike):
|
|
591
|
+
* - false → omit options (retain live session config)
|
|
592
|
+
* - true + timing/spring null → empty options (clear authored config → default spring)
|
|
593
|
+
* - true + timing OR spring → set that flight config (single-outcome upstream, L1)
|
|
594
|
+
*/
|
|
595
|
+
function flightOptions(
|
|
596
|
+
instant: boolean,
|
|
597
|
+
timing: TimingConfig | null,
|
|
598
|
+
spring: SpringConfig | null,
|
|
599
|
+
optionsPresent: boolean,
|
|
600
|
+
): LayoutIdentityFlightOptions | undefined {
|
|
601
|
+
'worklet'
|
|
602
|
+
if (!optionsPresent) return undefined
|
|
603
|
+
if (timing !== null && spring !== null) {
|
|
604
|
+
// Malformed pair (review r2 minor 2): pass BOTH through — the registry's
|
|
605
|
+
// assertSingleFlightMode owns the fail-loud rejection (single-flight-mode law); a
|
|
606
|
+
// preference here would silently drop one config.
|
|
607
|
+
return instant ? { instant: true, timing, spring } : { timing, spring }
|
|
608
|
+
}
|
|
609
|
+
if (instant && timing === null && spring === null) return INSTANT_FLIGHT
|
|
610
|
+
if (instant)
|
|
611
|
+
return timing !== null ? { instant: true, timing } : { instant: true, spring: spring! }
|
|
612
|
+
if (timing !== null) return { timing }
|
|
613
|
+
if (spring !== null) return { spring }
|
|
614
|
+
return CLEAR_FLIGHT
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// alloc-ok: lifecycle-edge — holder mount; builds the holder record once.
|
|
618
|
+
// `instant` (REQ-API-035): settle the shared flight + crossfade on this edge under MotionConfig
|
|
619
|
+
// reducedMotion / skipAnimations / isStatic — pin type:false, no multi-frame layoutId flight.
|
|
620
|
+
// `timing` (family-3): duration/ease tween for the shared projection flight when authored.
|
|
621
|
+
// `optionsPresent` (family-3): JS edge restated policy (even when timing is cleared).
|
|
622
|
+
export function uiLayoutAttach(
|
|
623
|
+
state: UiLayoutState,
|
|
624
|
+
id: string,
|
|
625
|
+
token: LayoutIdToken,
|
|
626
|
+
rect: WindowSpaceRect | null,
|
|
627
|
+
outputs: UiLayoutHolderOutputs,
|
|
628
|
+
instant = false,
|
|
629
|
+
projectionComponentId: string | null = null,
|
|
630
|
+
timing: TimingConfig | null = null,
|
|
631
|
+
spring: SpringConfig | null = null,
|
|
632
|
+
optionsPresent = false,
|
|
633
|
+
): LayoutIdentityRole {
|
|
634
|
+
'worklet'
|
|
635
|
+
const opts = flightOptions(instant, timing, spring, optionsPresent)
|
|
636
|
+
const { role, handle } =
|
|
637
|
+
opts === undefined
|
|
638
|
+
? state.registry.enter(id, token, rect)
|
|
639
|
+
: state.registry.enter(id, token, rect, opts)
|
|
640
|
+
const group = groupOf(state, id)
|
|
641
|
+
const record: UiLayoutHolderRecord = {
|
|
642
|
+
id,
|
|
643
|
+
token,
|
|
644
|
+
handle,
|
|
645
|
+
outputs,
|
|
646
|
+
projectionComponentId,
|
|
647
|
+
rect,
|
|
648
|
+
// Lead owns the projection flight. Exclusive remount handoff is lead without concurrent
|
|
649
|
+
// predecessor records in the UI group (core registry handoff), so do not require a measured
|
|
650
|
+
// group predecessor — that would leave ownsProjectionFlight false and starve completion.
|
|
651
|
+
ownsProjectionFlight: role === 'lead',
|
|
652
|
+
}
|
|
653
|
+
group.records.push(record)
|
|
654
|
+
group.currentTokens.push(token)
|
|
655
|
+
activateGroup(state, group) // an entering pairing may open a flight — or a pending measurement
|
|
656
|
+
writeHolder(state, record) // paint the mount frame — attach must not wait for the next step
|
|
657
|
+
return role
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// alloc-ok: lifecycle-edge — lead commit only; the optional flight-options object is not per-frame.
|
|
661
|
+
export function uiLayoutCommit(
|
|
662
|
+
state: UiLayoutState,
|
|
663
|
+
id: string,
|
|
664
|
+
token: LayoutIdToken,
|
|
665
|
+
rect: WindowSpaceRect,
|
|
666
|
+
instant = false,
|
|
667
|
+
timing: TimingConfig | null = null,
|
|
668
|
+
spring: SpringConfig | null = null,
|
|
669
|
+
optionsPresent = false,
|
|
670
|
+
): boolean {
|
|
671
|
+
'worklet'
|
|
672
|
+
const group = findGroup(state, id)
|
|
673
|
+
const record = group === undefined ? undefined : recordFor(group, token)
|
|
674
|
+
const currentLead = group?.currentTokens[group.currentTokens.length - 1] === token
|
|
675
|
+
// Mirror the core's actual geometry precondition before it receives this measured edge. A
|
|
676
|
+
// sole holder (or a current lead whose predecessors never measured) has no comparable rect to
|
|
677
|
+
// project from; force-instant changes timing only and must not convert that static commit into
|
|
678
|
+
// layout-completion ownership.
|
|
679
|
+
if (record !== undefined) {
|
|
680
|
+
record.rect = rect
|
|
681
|
+
if (currentLead && hasMeasuredCurrentPredecessor(group!, token)) {
|
|
682
|
+
record.ownsProjectionFlight = true
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// fail-loud first: unknown ids/holders never mark work. Flight options only on edges.
|
|
686
|
+
const opts = flightOptions(instant, timing, spring, optionsPresent)
|
|
687
|
+
if (opts === undefined) state.registry.commitRect(id, token, rect)
|
|
688
|
+
else state.registry.commitRect(id, token, rect, opts)
|
|
689
|
+
if (group !== undefined) activateGroup(state, group) // a lead commit starts or retargets the flight
|
|
690
|
+
return currentLead && record?.ownsProjectionFlight === true
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// alloc-ok: lifecycle-edge — holder unmount; the optional flight-options object is not per-frame.
|
|
694
|
+
export function uiLayoutDetach(
|
|
695
|
+
state: UiLayoutState,
|
|
696
|
+
id: string,
|
|
697
|
+
token: LayoutIdToken,
|
|
698
|
+
instant = false,
|
|
699
|
+
timing: TimingConfig | null = null,
|
|
700
|
+
spring: SpringConfig | null = null,
|
|
701
|
+
optionsPresent = false,
|
|
702
|
+
): void {
|
|
703
|
+
'worklet'
|
|
704
|
+
const opts = flightOptions(instant, timing, spring, optionsPresent)
|
|
705
|
+
if (opts === undefined) state.registry.leave(id, token)
|
|
706
|
+
else state.registry.leave(id, token, opts)
|
|
707
|
+
const group = findGroup(state, id)
|
|
708
|
+
if (group === undefined) return // leave() throws for unknown ids — nothing tracked here
|
|
709
|
+
removeCurrentToken(group, token)
|
|
710
|
+
pruneDepartedRecords(state, group)
|
|
711
|
+
if (group.records.length === 0) {
|
|
712
|
+
// No painted holders left for this id: drop the group — from the scan set FIRST (a stale
|
|
713
|
+
// active entry would be stepped against a registry that no longer tracks the id), then from
|
|
714
|
+
// the mounted list (write-index compaction, see above).
|
|
715
|
+
deactivateGroup(state, group)
|
|
716
|
+
let w = 0
|
|
717
|
+
for (let i = 0; i < state.groups.length; i++) {
|
|
718
|
+
const g = state.groups[i]!
|
|
719
|
+
if (g !== group) {
|
|
720
|
+
state.groups[w] = g
|
|
721
|
+
w += 1
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
state.groups.length = w
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
activateGroup(state, group) // a lead leaving begins the reverse flight / crossfade
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Register a releasable→safeToRemove coupling. Checked against the registry AT registration on
|
|
731
|
+
// the UI runtime: a holder that is already releasable emits NOW instead of storing — a stored
|
|
732
|
+
// coupling with no frames scheduled would strand the callback (the immediate-release frontier,
|
|
733
|
+
// L2 review cycle 3 major 7, re-checked here because the JS-side pre-check races the scheduled
|
|
734
|
+
// registration).
|
|
735
|
+
// alloc-ok: lifecycle-edge — release-coupling registration at the presence edge.
|
|
736
|
+
export function uiLayoutCouple(
|
|
737
|
+
state: UiLayoutState,
|
|
738
|
+
id: string,
|
|
739
|
+
token: LayoutIdToken,
|
|
740
|
+
key: number,
|
|
741
|
+
emitRelease: (key: number) => void,
|
|
742
|
+
): void {
|
|
743
|
+
'worklet'
|
|
744
|
+
if (state.registry.releasable(id, token)) {
|
|
745
|
+
emitRelease(key)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
state.couplings.push({ id, token, key, fired: false })
|
|
749
|
+
// Prefill release capacity at the lifecycle edge (alloc-ok): a frame can emit at most one key
|
|
750
|
+
// per pending coupling on top of any not-yet-drained residue, so
|
|
751
|
+
// capacity >= couplings.length + releaseCount keeps every in-frame indexed write in bounds.
|
|
752
|
+
const required = state.couplings.length + state.releaseCount
|
|
753
|
+
if (required > state.releaseKeys.length) state.releaseKeys[required - 1] = 0
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function uiLayoutUncouple(state: UiLayoutState, key: number): void {
|
|
757
|
+
'worklet'
|
|
758
|
+
let w = 0
|
|
759
|
+
for (let i = 0; i < state.couplings.length; i++) {
|
|
760
|
+
const coupling = state.couplings[i]!
|
|
761
|
+
if (coupling.key !== key) {
|
|
762
|
+
state.couplings[w] = coupling
|
|
763
|
+
w += 1
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
state.couplings.length = w
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// The driver's in-flight accounting for the layout engine: graph work OR any id not yet observed
|
|
770
|
+
// settled. REQ-LAYOUT-019 — the registry's settledness (which holds an unmeasured pending pairing
|
|
771
|
+
// open) is part of this answer, never the graph alone. Used by the frame step's return AND the
|
|
772
|
+
// RN-side settle-edge re-check, so both consult one definition.
|
|
773
|
+
export function uiLayoutActive(state: UiLayoutState): boolean {
|
|
774
|
+
'worklet'
|
|
775
|
+
// O(1): activeGroups IS the in-flight id set — no scan over mounted groups (round 5 major 1).
|
|
776
|
+
return state.activeGroups.length > 0 || !state.graph.isSettled()
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// One frame: advance the layout graph, refresh the ACTIVE ids' holder outputs, then carry any
|
|
780
|
+
// releasable edges. Returns whether the layout engine still has in-flight work (joins the
|
|
781
|
+
// driver's REQ-DRIVER-020 settle edge). Hot-path discipline (REQ-DRIVER-015): no allocation on
|
|
782
|
+
// any per-frame path — write-index compaction instead of splice/snapshot copies — and per-frame
|
|
783
|
+
// work scales with ACTIVE identities only: the loop walks activeGroups, so a settled mounted id
|
|
784
|
+
// is not even flag-checked (an id's holders are written while it is in flight plus one final
|
|
785
|
+
// rest-pose frame at its settle edge, where it leaves the scan set).
|
|
786
|
+
export function uiLayoutStep(state: UiLayoutState, dtMs: number): boolean {
|
|
787
|
+
'worklet'
|
|
788
|
+
// Age exclusive-remount handoffs before graph step so a same-commit successor attach
|
|
789
|
+
// (onLayout deferred to this frame) still sees the handoff, while long-delayed remounts expire.
|
|
790
|
+
state.registry.tickExclusiveHandoffs()
|
|
791
|
+
state.graph.frame(dtMs)
|
|
792
|
+
let w = 0
|
|
793
|
+
for (let i = 0; i < state.activeGroups.length; i++) {
|
|
794
|
+
const group = state.activeGroups[i]!
|
|
795
|
+
const settled = state.registry.isSettled(group.id)
|
|
796
|
+
// Write while unsettled AND on the settle frame itself — the flip frame lands the exact rest
|
|
797
|
+
// pose (identity transform, opacity 1, z-lift 0); afterwards the id costs nothing per frame.
|
|
798
|
+
// Indexed loops throughout the frame path: for..of allocates its iterator on Hermes.
|
|
799
|
+
for (let r = 0; r < group.records.length; r++) writeHolder(state, group.records[r]!)
|
|
800
|
+
if (settled) group.active = false
|
|
801
|
+
else {
|
|
802
|
+
state.activeGroups[w] = group
|
|
803
|
+
w += 1
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
state.activeGroups.length = w
|
|
807
|
+
// Holder writes update direct layoutId transforms first; the tree projects only after every
|
|
808
|
+
// current lead published its frame, so a targetless descendant observes that same UI frame.
|
|
809
|
+
const projectionWrote = uiProjectionStep(state.projection)
|
|
810
|
+
const anyActive = w > 0
|
|
811
|
+
// The fired latch (not removal order) carries exactly-once: the frame callback keeps running
|
|
812
|
+
// until the RN-side settle edge lands, and an unlatched coupling would re-emit on every
|
|
813
|
+
// trailing frame — duplicate safeToRemove deliveries, the fail-open the live-leg review round
|
|
814
|
+
// established (2026-07-09). Keys are queued for the driver to cross directly via scheduleOnRN,
|
|
815
|
+
// avoiding a nested worklet closure inside the UI frame's serialized graph.
|
|
816
|
+
let anyFired = false
|
|
817
|
+
for (let i = 0; i < state.couplings.length; i++) {
|
|
818
|
+
const coupling = state.couplings[i]!
|
|
819
|
+
if (coupling.fired) continue
|
|
820
|
+
if (!state.registry.releasable(coupling.id, coupling.token)) continue
|
|
821
|
+
coupling.fired = true
|
|
822
|
+
anyFired = true
|
|
823
|
+
state.releaseKeys[state.releaseCount] = coupling.key
|
|
824
|
+
state.releaseCount += 1
|
|
825
|
+
}
|
|
826
|
+
if (anyFired) {
|
|
827
|
+
let w = 0
|
|
828
|
+
for (let i = 0; i < state.couplings.length; i++) {
|
|
829
|
+
const coupling = state.couplings[i]!
|
|
830
|
+
if (!coupling.fired) {
|
|
831
|
+
state.couplings[w] = coupling
|
|
832
|
+
w += 1
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
state.couplings.length = w
|
|
836
|
+
}
|
|
837
|
+
// Keep the frame loop alive while exclusive-remount handoffs age out (no active group after
|
|
838
|
+
// sole leave would otherwise stop steps before the 8-frame grace completes).
|
|
839
|
+
return (
|
|
840
|
+
projectionWrote ||
|
|
841
|
+
anyActive ||
|
|
842
|
+
!state.graph.isSettled() ||
|
|
843
|
+
state.registry.hasPendingExclusiveHandoffs()
|
|
844
|
+
)
|
|
845
|
+
}
|