@unrulysystems/native-motion 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. package/src/web.ts +98 -0
@@ -0,0 +1,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
+ }