@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,1302 @@
|
|
|
1
|
+
// The L3 no-flash layout transition (specs/L3-BUILD-PACKET.md §path B): a custom Reanimated
|
|
2
|
+
// LayoutAnimationFunction whose commit-hook `initialValues` paint the first post-commit frame at
|
|
3
|
+
// the OLD rect (zero wrong frames — proven by probe 2), and whose per-channel animations step
|
|
4
|
+
// CORE's generators (no forked math, REQ-DRIVER-003) toward the new layout. The inversion
|
|
5
|
+
// is expressed in origin-space by the commit hook itself (initialValues = current rect ≡ the
|
|
6
|
+
// FLIP inversion at t0); core owns the TRAJECTORY: `resolveSpringGenerator` or `timingGenerator`
|
|
7
|
+
// seeds each channel, `stepProp` advances it (dt-clamped internally, REQ-CLOCK-003), and a
|
|
8
|
+
// mid-flight spring re-seeds via `retargetSpring` from the live (value, velocity) — C0/C1
|
|
9
|
+
// continuity, the session's own interruption law. Reanimated's layout channels are UI-thread
|
|
10
|
+
// paint props, not
|
|
11
|
+
// layout-authoritative style: probe 2 measured that the committed layout stays truthful during
|
|
12
|
+
// the flight (REQ-LAYOUT-001 posture, no measurement feedback). Distinct from the forbidden
|
|
13
|
+
// sharedTransitionTag machinery, which stays off.
|
|
14
|
+
//
|
|
15
|
+
// Structure: `buildCoreLayoutChannel` is the PURE AnimationObject builder (deterministically
|
|
16
|
+
// driven by the checks rows via manual onStart/onFrame); `defineAnimation` wraps it only at the
|
|
17
|
+
// transition boundary (Reanimated resolves the definition worklet-side).
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
resolveSpringGenerator,
|
|
21
|
+
retargetSpring,
|
|
22
|
+
stepProp,
|
|
23
|
+
timingGenerator,
|
|
24
|
+
type Generator,
|
|
25
|
+
type ProjectionTree,
|
|
26
|
+
type SpringConfig,
|
|
27
|
+
type TimingConfig,
|
|
28
|
+
} from '@unrulysystems/native-motion-core'
|
|
29
|
+
import {
|
|
30
|
+
defineAnimation,
|
|
31
|
+
makeMutable,
|
|
32
|
+
type AnimationObject,
|
|
33
|
+
type LayoutAnimationFunction,
|
|
34
|
+
} from 'react-native-reanimated'
|
|
35
|
+
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'
|
|
36
|
+
import {
|
|
37
|
+
compareMappedTransformKeys,
|
|
38
|
+
nativeTransformEntryName,
|
|
39
|
+
type MappedTransformKey,
|
|
40
|
+
} from './composeTransform'
|
|
41
|
+
import { refuseUnsafeSerializablePayload } from './serializablePayload'
|
|
42
|
+
|
|
43
|
+
// The channel animation's mutable state, decorated by Reanimated at runtime. `prop` mirrors the
|
|
44
|
+
// driver's PropStep shape (generator + accumulated elapsed) so the trajectory is core's exactly.
|
|
45
|
+
export interface CoreLayoutChannelAnimation extends AnimationObject<number> {
|
|
46
|
+
onStart: (
|
|
47
|
+
animation: CoreLayoutChannelAnimation,
|
|
48
|
+
value: number,
|
|
49
|
+
now: number,
|
|
50
|
+
previous: CoreLayoutChannelAnimation | null | undefined,
|
|
51
|
+
) => void
|
|
52
|
+
onFrame: (animation: CoreLayoutChannelAnimation, now: number) => boolean
|
|
53
|
+
current: number
|
|
54
|
+
lastTimestamp: number
|
|
55
|
+
delayUntil: number
|
|
56
|
+
prop: { generator: Generator; elapsed: number; value: number; done: boolean } | null
|
|
57
|
+
completionReported: boolean
|
|
58
|
+
projectionCompletionReported: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type LayoutGeneratorConfig =
|
|
62
|
+
| { readonly type: 'spring'; readonly config: SpringConfig; readonly delayMs?: number }
|
|
63
|
+
| { readonly type: 'tween'; readonly config: TimingConfig; readonly delayMs?: number }
|
|
64
|
+
|
|
65
|
+
// One stale projection-tree report as the tree's drain surfaces it (iOS AUTO SIGABRT Phase 1.5):
|
|
66
|
+
// typed off the core tree's drain so the declaration sets of the core layout barrels stay pinned.
|
|
67
|
+
type StaleProjectionNodeReport = ReturnType<ProjectionTree['drainStaleReports']>[number]
|
|
68
|
+
|
|
69
|
+
// Deliver every drained stale-tree report to RN through the established scheduleOnRN severity
|
|
70
|
+
// channel (Phase 1 pattern). A quiet frame drains the shared frozen empty array — the index loop
|
|
71
|
+
// allocates nothing (REQ-DRIVER-021; for..of is banned here for the iterator).
|
|
72
|
+
function reportStaleProjectionReports(
|
|
73
|
+
projection: { readonly tree: Pick<ProjectionTree, 'drainStaleReports'> },
|
|
74
|
+
onStaleProjectionNodeRefusal?: (report: StaleProjectionNodeReport) => void,
|
|
75
|
+
): void {
|
|
76
|
+
'worklet'
|
|
77
|
+
const staleReports = projection.tree.drainStaleReports()
|
|
78
|
+
for (let index = 0; index < staleReports.length; index += 1) {
|
|
79
|
+
if (onStaleProjectionNodeRefusal !== undefined)
|
|
80
|
+
scheduleOnRN(onStaleProjectionNodeRefusal, staleReports[index]!)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Retire ONE projection host-frame channel at its terminal edge — completion OR unseeded
|
|
85
|
+
// retirement (iOS AUTO SIGABRT seal r2, F2). Generation-scoped: at zero remaining channels the
|
|
86
|
+
// frame record and its completion owner release, exactly like the normal completion path. A
|
|
87
|
+
// module-level worklet so the produced channel's closure stays primitive-only (a nested callable
|
|
88
|
+
// capture serializes a second worklet graph — undefined on the iOS UI runtime).
|
|
89
|
+
function retireProjectionHostFrameChannel(
|
|
90
|
+
driverId: number,
|
|
91
|
+
componentId: string,
|
|
92
|
+
generation: number,
|
|
93
|
+
ownerKey?: number,
|
|
94
|
+
): void {
|
|
95
|
+
'worklet'
|
|
96
|
+
const projectionGlobal = globalThis as ProjectionHostUiGlobal
|
|
97
|
+
const driverFrames = projectionGlobal.__nativeMotionProjectionHostFrames?.[driverId]
|
|
98
|
+
const frame = driverFrames?.frames[componentId]
|
|
99
|
+
if (frame === undefined || frame.generation !== generation) return
|
|
100
|
+
frame.remainingChannels -= 1
|
|
101
|
+
if (frame.remainingChannels > 0) return
|
|
102
|
+
delete driverFrames!.frames[componentId]
|
|
103
|
+
if (ownerKey === undefined) return
|
|
104
|
+
const owners = projectionGlobal.__nativeMotionProjectionHostFrameOwners
|
|
105
|
+
const owner = owners?.[ownerKey]
|
|
106
|
+
if (
|
|
107
|
+
owner?.driverId === driverId &&
|
|
108
|
+
owner.componentId === componentId &&
|
|
109
|
+
owner.generation === generation
|
|
110
|
+
)
|
|
111
|
+
delete owners![ownerKey]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Retire ONE transform-mirror flight channel at its terminal edge — completion OR unseeded
|
|
115
|
+
// retirement (seal r2, F2). Generation-scoped: at zero remaining channels the flight record
|
|
116
|
+
// releases, so no mirror stays awake on a flight its host has finished.
|
|
117
|
+
function retireLayoutTransformMirrorFlightChannel(key: number, generation: number): void {
|
|
118
|
+
'worklet'
|
|
119
|
+
const flights = (globalThis as LayoutTransformMirrorUiGlobal)
|
|
120
|
+
.__nativeMotionLayoutTransformMirrorFlights
|
|
121
|
+
const flight = flights?.flights[key]
|
|
122
|
+
if (flight === undefined || flight.generation !== generation) return
|
|
123
|
+
flight.remainingChannels -= 1
|
|
124
|
+
if (flight.remainingChannels === 0) delete flights!.flights[key]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Seal-r15 F2 proof witnesses for the layout-completion callback (the settle-gap card): plain
|
|
128
|
+
// primitive counters on the UI global — they never ride the channel under test, so a dead
|
|
129
|
+
// crossing cannot hide them. Seal-r16 F2: mutations are gated on the arm flag (set only when
|
|
130
|
+
// the proof app's marker is present); ordinary settle/completion paths allocate nothing. The
|
|
131
|
+
// JS-side last-pushed cell is filled from MotionView's layoutCompletion (engine-internal
|
|
132
|
+
// reader) — this worklet body must not capture non-worklet helpers (worklet-closures gate).
|
|
133
|
+
interface ProofLayoutCallbackUiGlobal {
|
|
134
|
+
__nativeMotionProofWitnessArmed?: boolean
|
|
135
|
+
__nativeMotionProofLayoutCallbackInvocations?: number
|
|
136
|
+
__nativeMotionProofLayoutCallbackEpochDrops?: number
|
|
137
|
+
__nativeMotionProofLayoutCallbackCancelled?: number
|
|
138
|
+
__nativeMotionProofLayoutOnCompleteScheduled?: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A property-driver transform that must remain visible while Reanimated owns the same host's
|
|
143
|
+
* layout frame. Reanimated's layout proxy receives only geometry values, so each layout frame
|
|
144
|
+
* otherwise replaces the driver's whole `transform` array. The mirror makes that array part of
|
|
145
|
+
* the layout result and reads the driver's UI-runtime value at the same frame boundary.
|
|
146
|
+
*/
|
|
147
|
+
export interface LayoutTransformMirror {
|
|
148
|
+
readonly key: MappedTransformKey
|
|
149
|
+
readonly shared: { readonly value: number }
|
|
150
|
+
// The driver owns both values on the UI runtime. `target` changes with the currently active
|
|
151
|
+
// command (rather than capturing the base `animate` target), while `active` keeps gesture
|
|
152
|
+
// holds live until their release command takes ownership.
|
|
153
|
+
readonly target: { readonly value: number }
|
|
154
|
+
readonly active: { readonly value: boolean }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Primitive address of the ancestor-owned UI projection ledger. The layout callback captures this
|
|
159
|
+
* data only; it never captures a callable worklet or crosses back to JS on a host frame.
|
|
160
|
+
*/
|
|
161
|
+
export interface LayoutProjectionHostFeed {
|
|
162
|
+
readonly driverId: number
|
|
163
|
+
readonly componentId: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type LayoutTransformMirrorAnimation = AnimationObject<string | number> & {
|
|
167
|
+
current: string | number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface MirrorConfigurableLayoutTransition extends LayoutAnimationFunction {
|
|
171
|
+
configureLayoutTransformMirrors(enabled: boolean): void
|
|
172
|
+
configureProjectionHostFeed(feed: LayoutProjectionHostFeed | null): void
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Configure a concrete host transition with its UI-resident projection-tree address. */
|
|
176
|
+
export function configureLayoutProjectionHostFeed(
|
|
177
|
+
transition: LayoutAnimationFunction,
|
|
178
|
+
feed: LayoutProjectionHostFeed | null,
|
|
179
|
+
): void {
|
|
180
|
+
;(transition as Partial<MirrorConfigurableLayoutTransition>).configureProjectionHostFeed?.(feed)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Guest-side SharedValue writes (`cell.value = …` from JS) ride reanimated's modify-scheduling
|
|
184
|
+
// lane: each write becomes scheduleOnUI(() => { mutable.value = … }), then worklets'
|
|
185
|
+
// flushUIQueue wraps that job as createSerializable(() => workletFunction(...workletArgs)).
|
|
186
|
+
// The live hang tail was exactly that shape:
|
|
187
|
+
// /__closure/workletFunction/__closure/mutable/__closure
|
|
188
|
+
// (PROOF §SIGABRT-1 wedge recurrence Q3). Layout-enabled hosts call configureProjectionHostFeed
|
|
189
|
+
// on every render — guest writes there would mint that graph on the gallery/lightbox path.
|
|
190
|
+
// Module-level worklet + primitive args (M1-r19 / reassert shape): the UI lane mutates the
|
|
191
|
+
// cells; the guest only serializes a named worklet and branded SharedValue refs.
|
|
192
|
+
function setProjectionHostFeedCellsOnUI(
|
|
193
|
+
driverIdCell: { value: number },
|
|
194
|
+
componentIdCell: { value: string },
|
|
195
|
+
driverId: number,
|
|
196
|
+
componentId: string,
|
|
197
|
+
): void {
|
|
198
|
+
'worklet'
|
|
199
|
+
driverIdCell.value = driverId
|
|
200
|
+
componentIdCell.value = componentId
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createLayoutTransformMirror(
|
|
204
|
+
mirror: LayoutTransformMirror,
|
|
205
|
+
completionKey: number | undefined,
|
|
206
|
+
flightGeneration: number | undefined,
|
|
207
|
+
): LayoutTransformMirrorAnimation {
|
|
208
|
+
'worklet'
|
|
209
|
+
return {
|
|
210
|
+
onStart(animation) {
|
|
211
|
+
'worklet'
|
|
212
|
+
const current = mirror.shared.value
|
|
213
|
+
animation.current =
|
|
214
|
+
mirror.key === 'rotate' ||
|
|
215
|
+
mirror.key === 'rotateX' ||
|
|
216
|
+
mirror.key === 'rotateY' ||
|
|
217
|
+
mirror.key === 'skewX' ||
|
|
218
|
+
mirror.key === 'skewY'
|
|
219
|
+
? `${current}deg`
|
|
220
|
+
: current
|
|
221
|
+
},
|
|
222
|
+
onFrame(animation) {
|
|
223
|
+
'worklet'
|
|
224
|
+
const current = mirror.shared.value
|
|
225
|
+
animation.current =
|
|
226
|
+
mirror.key === 'rotate' ||
|
|
227
|
+
mirror.key === 'rotateX' ||
|
|
228
|
+
mirror.key === 'rotateY' ||
|
|
229
|
+
mirror.key === 'skewX' ||
|
|
230
|
+
mirror.key === 'skewY'
|
|
231
|
+
? `${current}deg`
|
|
232
|
+
: current
|
|
233
|
+
// Reanimated permanently retires an animation after its first `true` frame. A property can
|
|
234
|
+
// be idle at host-flight start yet activate later through a gesture, exit, or bound value;
|
|
235
|
+
// keep this transform mirror awake until that real geometry flight ends. This nested frame
|
|
236
|
+
// worklet must not capture another callable worklet: Reanimated serializes those closures
|
|
237
|
+
// independently and iOS resolved the helper as undefined at runtime. Read the primitive
|
|
238
|
+
// UI-global ledger directly instead.
|
|
239
|
+
if (completionKey !== undefined && flightGeneration !== undefined) {
|
|
240
|
+
const flight = (globalThis as LayoutTransformMirrorUiGlobal)
|
|
241
|
+
.__nativeMotionLayoutTransformMirrorFlights?.flights[completionKey]
|
|
242
|
+
if (flight?.generation === flightGeneration) return false
|
|
243
|
+
}
|
|
244
|
+
// Property and layout consume the same gated transition. Keep the layout's transform
|
|
245
|
+
// entry live until the property lane reaches its target; then both complete together.
|
|
246
|
+
return !mirror.active.value && Math.abs(current - mirror.target.value) <= 0.000_001
|
|
247
|
+
},
|
|
248
|
+
current:
|
|
249
|
+
mirror.key === 'rotate' ||
|
|
250
|
+
mirror.key === 'rotateX' ||
|
|
251
|
+
mirror.key === 'rotateY' ||
|
|
252
|
+
mirror.key === 'skewX' ||
|
|
253
|
+
mirror.key === 'skewY'
|
|
254
|
+
? `${mirror.shared.value}deg`
|
|
255
|
+
: mirror.shared.value,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Preserve transform targets from the driver while a host-layout transition writes its own frame.
|
|
261
|
+
* A transform key absent from this list remains solely driver-owned, so ordinary property-only
|
|
262
|
+
* animation never acquires a layout dependency.
|
|
263
|
+
*/
|
|
264
|
+
export function mirrorLayoutTransformValues(
|
|
265
|
+
transition: LayoutAnimationFunction,
|
|
266
|
+
mirrors: readonly LayoutTransformMirror[],
|
|
267
|
+
completionEpoch?: LayoutCompletionEpoch,
|
|
268
|
+
): LayoutAnimationFunction | undefined {
|
|
269
|
+
// The host transition creates its UI-global ledger only when an actual transform mirror needs
|
|
270
|
+
// to observe it. `MotionView` always visits this adapter, including the zero-mirror case.
|
|
271
|
+
const mirrorConfigurable = transition as Partial<MirrorConfigurableLayoutTransition>
|
|
272
|
+
mirrorConfigurable.configureLayoutTransformMirrors?.(mirrors.length > 0)
|
|
273
|
+
if (mirrors.length === 0) return transition
|
|
274
|
+
// The wedge law (iOS AUTO SIGABRT): every record the produced wrapper captures must be a
|
|
275
|
+
// finite, acyclic payload — a cyclic/unbounded mirror graph would peg the main thread inside
|
|
276
|
+
// SerializableObject::toJSValue, not fail at schedule time. Dev throws at this factory edge;
|
|
277
|
+
// production reports and the mirror lane is REFUSED (seal r7, F2): undefined means no layout
|
|
278
|
+
// transition at all — the host keeps its last valid static layout, never an unmirrored
|
|
279
|
+
// transition substituted silently (that fallback erases/freezes live transforms).
|
|
280
|
+
if (refuseUnsafeSerializablePayload(mirrors, 'mirrors') !== null) return undefined
|
|
281
|
+
// Do not leak authoring/mount order into the host proxy. `composeTransform` is the ordinary
|
|
282
|
+
// lane's canonical producer; this mirror follows the same single source of truth.
|
|
283
|
+
const orderedMirrors = [...mirrors].sort((a, b) => compareMappedTransformKeys(a.key, b.key))
|
|
284
|
+
// A NAMED declarator (iOS AUTO SIGABRT seal, F1): the closure graph roots produced worklets by
|
|
285
|
+
// declarator name — an anonymous wrapper would take a positional plugin alias and evade the
|
|
286
|
+
// closure roots entirely (the exact device-only invisibility the seal fixes).
|
|
287
|
+
const mirroredTransition: LayoutAnimationFunction = (values) => {
|
|
288
|
+
'worklet'
|
|
289
|
+
const result = transition(values)
|
|
290
|
+
const completionKey = completionEpoch?.key
|
|
291
|
+
const flightGeneration =
|
|
292
|
+
completionKey === undefined
|
|
293
|
+
? undefined
|
|
294
|
+
: currentLayoutTransformMirrorFlightGeneration(completionKey)
|
|
295
|
+
return {
|
|
296
|
+
...result,
|
|
297
|
+
animations: {
|
|
298
|
+
...result.animations,
|
|
299
|
+
transform: orderedMirrors.map((mirror) => ({
|
|
300
|
+
[nativeTransformEntryName(mirror.key)]: createLayoutTransformMirror(
|
|
301
|
+
mirror,
|
|
302
|
+
completionKey,
|
|
303
|
+
flightGeneration,
|
|
304
|
+
),
|
|
305
|
+
})),
|
|
306
|
+
},
|
|
307
|
+
} as unknown as ReturnType<LayoutAnimationFunction>
|
|
308
|
+
}
|
|
309
|
+
return mirroredTransition
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A layout transition function is read-current on every React render, while Reanimated may still
|
|
313
|
+
// deliver a terminal callback from the prior host commit. The host key crosses rebuilt functions
|
|
314
|
+
// as a primitive; the mutable epoch lives only in the UI runtime because Worklets deserializes a
|
|
315
|
+
// fresh ordinary-object capture for every rebuilt function.
|
|
316
|
+
export interface LayoutCompletionEpoch {
|
|
317
|
+
readonly key: number
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let nextLayoutCompletionEpochKey = 1
|
|
321
|
+
|
|
322
|
+
// The UI-global owner registry needs a hard bound: cancellation and teardown release owners,
|
|
323
|
+
// while a pathological live tree fails loud instead of retaining unbounded completion state.
|
|
324
|
+
// Never evict a live owner — that would silently suppress its public callback.
|
|
325
|
+
export const MAX_ACTIVE_LAYOUT_COMPLETION_OWNERS = 512
|
|
326
|
+
|
|
327
|
+
// Transform mirrors have a separate UI-global lifetime from public completion callbacks. Bound
|
|
328
|
+
// them independently: never evict a live mirror, because its host can still receive an authority
|
|
329
|
+
// change before its geometry settles.
|
|
330
|
+
export const MAX_ACTIVE_LAYOUT_TRANSFORM_MIRROR_FLIGHTS = 512
|
|
331
|
+
|
|
332
|
+
// Host-frame records are separate UI-global state from completion/mirror owners. They are active
|
|
333
|
+
// only while a host layout channel can still publish geometry into the projection tree, and must
|
|
334
|
+
// never become an unbounded tombstone registry when flights are cancelled or hosts unmount.
|
|
335
|
+
export const MAX_ACTIVE_PROJECTION_HOST_FRAMES = 512
|
|
336
|
+
|
|
337
|
+
export function createLayoutCompletionEpoch(): LayoutCompletionEpoch {
|
|
338
|
+
return { key: nextLayoutCompletionEpochKey++ }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface LayoutCompletionUiGlobal {
|
|
342
|
+
__nativeMotionLayoutCompletionState?: {
|
|
343
|
+
nextGeneration: number
|
|
344
|
+
readonly activeEpochs: Record<number, number>
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A host layout flight and its transform mirrors run as independent Reanimated animations. This
|
|
349
|
+
// UI-global ledger gives them one primitive, generation-guarded lifecycle without serializing a
|
|
350
|
+
// mutable capture into each worklet. A superseding host flight replaces the record, so old channel
|
|
351
|
+
// completions cannot wake or finish the new flight.
|
|
352
|
+
interface LayoutTransformMirrorUiGlobal {
|
|
353
|
+
__nativeMotionLayoutTransformMirrorFlights?: {
|
|
354
|
+
nextGeneration: number
|
|
355
|
+
readonly flights: Record<number, { readonly generation: number; remainingChannels: number }>
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The host layout callback runs inside Reanimated, while the projection tree is owned by the
|
|
360
|
+
// driver's UI-global ledger. Keep the bridge structural and primitive-only: importing/capturing
|
|
361
|
+
// driver worklets here would serialize a second callable graph and reproduces the iOS crash this
|
|
362
|
+
// module is specifically designed to avoid.
|
|
363
|
+
interface ProjectionHostFrame {
|
|
364
|
+
readonly generation: number
|
|
365
|
+
currentOriginX: number
|
|
366
|
+
currentOriginY: number
|
|
367
|
+
currentWidth: number
|
|
368
|
+
currentHeight: number
|
|
369
|
+
readonly targetOriginX: number
|
|
370
|
+
readonly targetOriginY: number
|
|
371
|
+
readonly targetWidth: number
|
|
372
|
+
readonly targetHeight: number
|
|
373
|
+
remainingChannels: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface ProjectionHostFrames {
|
|
377
|
+
nextGeneration: number
|
|
378
|
+
readonly frames: Record<string, ProjectionHostFrame>
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The layout callback is serialized to Reanimated's UI runtime before a host can begin a
|
|
383
|
+
* geometry flight. A lexical `let` mutated by render-time configuration is cloned as its initial
|
|
384
|
+
* `null` value into that worklet, which silently drops the parent frame on device. These two
|
|
385
|
+
* cells are the deliberate cross-runtime address: they are set at the React edge and read only
|
|
386
|
+
* as primitive values in the frame callback.
|
|
387
|
+
*/
|
|
388
|
+
interface ProjectionHostFeedCells {
|
|
389
|
+
readonly driverId: { value: number }
|
|
390
|
+
readonly componentId: { value: string }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
interface ProjectionHostUiGlobal {
|
|
394
|
+
__nativeMotionProjectionHostFrames?: Record<number, ProjectionHostFrames>
|
|
395
|
+
__nativeMotionProjectionHostFrameOwners?: Record<
|
|
396
|
+
number,
|
|
397
|
+
{ readonly driverId: number; readonly componentId: string; readonly generation: number }
|
|
398
|
+
>
|
|
399
|
+
__nativeMotionDrivers?: Record<
|
|
400
|
+
number,
|
|
401
|
+
{
|
|
402
|
+
readonly layout: {
|
|
403
|
+
readonly projection: {
|
|
404
|
+
readonly tree: {
|
|
405
|
+
setHostFrame(
|
|
406
|
+
componentId: string,
|
|
407
|
+
currentX: number,
|
|
408
|
+
currentY: number,
|
|
409
|
+
currentWidth: number,
|
|
410
|
+
currentHeight: number,
|
|
411
|
+
targetX: number,
|
|
412
|
+
targetY: number,
|
|
413
|
+
targetWidth: number,
|
|
414
|
+
targetHeight: number,
|
|
415
|
+
): void
|
|
416
|
+
clearProjection(componentId: string): void
|
|
417
|
+
project(): void
|
|
418
|
+
output(componentId: string): {
|
|
419
|
+
readonly translateX: number
|
|
420
|
+
readonly translateY: number
|
|
421
|
+
readonly scaleX: number
|
|
422
|
+
readonly scaleY: number
|
|
423
|
+
readonly borderRadius: { readonly x: number; readonly y: number } | null
|
|
424
|
+
readonly targetDeltaScaleX: number
|
|
425
|
+
readonly targetDeltaScaleY: number
|
|
426
|
+
readonly treeScaleX: number
|
|
427
|
+
readonly treeScaleY: number
|
|
428
|
+
}
|
|
429
|
+
drainStaleReports(): readonly StaleProjectionNodeReport[]
|
|
430
|
+
}
|
|
431
|
+
readonly records: readonly {
|
|
432
|
+
readonly componentId: string
|
|
433
|
+
readonly outputs: {
|
|
434
|
+
readonly translateX: { value: number }
|
|
435
|
+
readonly translateY: { value: number }
|
|
436
|
+
readonly scaleX: { value: number }
|
|
437
|
+
readonly scaleY: { value: number }
|
|
438
|
+
readonly borderRadiusX: { value: number }
|
|
439
|
+
readonly borderRadiusY: { value: number }
|
|
440
|
+
readonly targetDeltaScaleX: { value: number }
|
|
441
|
+
readonly targetDeltaScaleY: { value: number }
|
|
442
|
+
readonly treeScaleX: { value: number }
|
|
443
|
+
readonly treeScaleY: { value: number }
|
|
444
|
+
}
|
|
445
|
+
}[]
|
|
446
|
+
dirty: boolean
|
|
447
|
+
}
|
|
448
|
+
} | null
|
|
449
|
+
}
|
|
450
|
+
>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function layoutTransformMirrorFlights(): NonNullable<
|
|
454
|
+
LayoutTransformMirrorUiGlobal['__nativeMotionLayoutTransformMirrorFlights']
|
|
455
|
+
> {
|
|
456
|
+
'worklet'
|
|
457
|
+
const global = globalThis as LayoutTransformMirrorUiGlobal
|
|
458
|
+
if (global.__nativeMotionLayoutTransformMirrorFlights === undefined) {
|
|
459
|
+
global.__nativeMotionLayoutTransformMirrorFlights = { nextGeneration: 0, flights: {} }
|
|
460
|
+
}
|
|
461
|
+
return global.__nativeMotionLayoutTransformMirrorFlights
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Keep this module-scope worklet primitive-only. In particular, do not return a worklet closure
|
|
465
|
+
// from here for `defineAnimation` to capture: Worklets would serialize that nested graph per host
|
|
466
|
+
// flight, recreating the iOS AB-BA wedge that attempt 15 intentionally removed.
|
|
467
|
+
function beginLayoutTransformMirrorFlight(key: number, channelCount: number): number | undefined {
|
|
468
|
+
'worklet'
|
|
469
|
+
const state = layoutTransformMirrorFlights()
|
|
470
|
+
if (
|
|
471
|
+
state.flights[key] === undefined &&
|
|
472
|
+
Object.keys(state.flights).length >= MAX_ACTIVE_LAYOUT_TRANSFORM_MIRROR_FLIGHTS
|
|
473
|
+
) {
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
476
|
+
state.nextGeneration += 1
|
|
477
|
+
const generation = state.nextGeneration
|
|
478
|
+
state.flights[key] = { generation, remainingChannels: channelCount }
|
|
479
|
+
return generation
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function currentLayoutTransformMirrorFlightGeneration(key: number): number | undefined {
|
|
483
|
+
'worklet'
|
|
484
|
+
return (globalThis as LayoutTransformMirrorUiGlobal).__nativeMotionLayoutTransformMirrorFlights
|
|
485
|
+
?.flights[key]?.generation
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function beginLayoutCompletionFlight(key: number): number | undefined {
|
|
489
|
+
'worklet'
|
|
490
|
+
const global = globalThis as LayoutCompletionUiGlobal
|
|
491
|
+
if (global.__nativeMotionLayoutCompletionState === undefined) {
|
|
492
|
+
global.__nativeMotionLayoutCompletionState = {
|
|
493
|
+
nextGeneration: 0,
|
|
494
|
+
activeEpochs: {},
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const state = global.__nativeMotionLayoutCompletionState
|
|
498
|
+
// Generations are globally monotonic, rather than per-key counters discarded on completion.
|
|
499
|
+
// A late A callback can therefore never equal a later C callback after B completed and released
|
|
500
|
+
// this host key. The map holds ACTIVE owners only: an entry is released by its own terminal
|
|
501
|
+
// callback, and a new flight on the same host supersedes the old one. Do not evict another
|
|
502
|
+
// active host to impose an arbitrary cache limit — that would suppress its real completion.
|
|
503
|
+
if (
|
|
504
|
+
state.activeEpochs[key] === undefined &&
|
|
505
|
+
Object.keys(state.activeEpochs).length >= MAX_ACTIVE_LAYOUT_COMPLETION_OWNERS
|
|
506
|
+
) {
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
state.nextGeneration += 1
|
|
510
|
+
const next = state.nextGeneration
|
|
511
|
+
state.activeEpochs[key] = next
|
|
512
|
+
return next
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isCurrentLayoutCompletionFlight(key: number, epoch: number): boolean {
|
|
516
|
+
'worklet'
|
|
517
|
+
const global = globalThis as LayoutCompletionUiGlobal
|
|
518
|
+
return global.__nativeMotionLayoutCompletionState?.activeEpochs[key] === epoch
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function completeLayoutCompletionFlight(key: number, epoch: number): void {
|
|
522
|
+
'worklet'
|
|
523
|
+
const global = globalThis as LayoutCompletionUiGlobal
|
|
524
|
+
if (global.__nativeMotionLayoutCompletionState?.activeEpochs[key] === epoch)
|
|
525
|
+
delete global.__nativeMotionLayoutCompletionState.activeEpochs[key]
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The public start callback's per-LOGICAL-flight qualification ledger (U7e). The pin
|
|
529
|
+
// fires onLayoutAnimationStart from didUpdate when the rounded target changed or no
|
|
530
|
+
// animation is active (create-projection-node.ts:527-560; boxEqualsRounded). A start-
|
|
531
|
+
// driven rerender rebuilds the worklet and Reanimated cancels the replaced animation —
|
|
532
|
+
// that rebound is the SAME flight, so cancel must NOT release this ledger (device proof
|
|
533
|
+
// 870c5b64 / f841bc52). Settle also must not: Reanimated re-runs the layout function
|
|
534
|
+
// at complete (iOS 21e910d0: starts@[+2037,+5063]), which is extra host granularity,
|
|
535
|
+
// not a pin didUpdate. A later React generation overwrites and starts (review M2).
|
|
536
|
+
// Lifetime teardown still releases.
|
|
537
|
+
interface LayoutFlightStartUiGlobal {
|
|
538
|
+
__nativeMotionLayoutFlightStarts?: {
|
|
539
|
+
readonly flights: Record<number, { generation: number }>
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function beginLayoutFlightStart(key: number, generation: number): boolean {
|
|
544
|
+
'worklet'
|
|
545
|
+
const global = globalThis as LayoutFlightStartUiGlobal
|
|
546
|
+
if (global.__nativeMotionLayoutFlightStarts === undefined) {
|
|
547
|
+
global.__nativeMotionLayoutFlightStarts = { flights: {} }
|
|
548
|
+
}
|
|
549
|
+
const flights = global.__nativeMotionLayoutFlightStarts.flights
|
|
550
|
+
const active = flights[key]
|
|
551
|
+
// Same React didUpdate (own geometry + inherited parent generation): a start-driven
|
|
552
|
+
// rerender or a second Yoga commit can carry a different rounded box (iOS 66f7ccfc
|
|
553
|
+
// flight A; Android C+15ms). That is not a pin retarget. A new generation is.
|
|
554
|
+
if (active !== undefined && active.generation === generation) return false
|
|
555
|
+
flights[key] = { generation }
|
|
556
|
+
return true
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function clearLayoutCompletionEpoch(key: number): void {
|
|
560
|
+
'worklet'
|
|
561
|
+
const global = globalThis as LayoutCompletionUiGlobal
|
|
562
|
+
if (global.__nativeMotionLayoutCompletionState !== undefined)
|
|
563
|
+
delete global.__nativeMotionLayoutCompletionState.activeEpochs[key]
|
|
564
|
+
// Inlined start-qualification release: this worklet crosses by reference through scheduleOnUI,
|
|
565
|
+
// so its body stays primitive-only (the projection release below carries the same law).
|
|
566
|
+
const flightStartGlobal = globalThis as LayoutFlightStartUiGlobal
|
|
567
|
+
if (flightStartGlobal.__nativeMotionLayoutFlightStarts !== undefined)
|
|
568
|
+
delete flightStartGlobal.__nativeMotionLayoutFlightStarts.flights[key]
|
|
569
|
+
const mirrorGlobal = globalThis as LayoutTransformMirrorUiGlobal
|
|
570
|
+
if (mirrorGlobal.__nativeMotionLayoutTransformMirrorFlights !== undefined)
|
|
571
|
+
delete mirrorGlobal.__nativeMotionLayoutTransformMirrorFlights.flights[key]
|
|
572
|
+
// Inlined projection host-frame + tree release: this worklet crosses to the UI runtime by
|
|
573
|
+
// reference through scheduleOnUI, so its body must stay primitive-only — a helper worklet
|
|
574
|
+
// call resolves undefined after the crossing. Cancel/teardown must clear BOTH the UI-global
|
|
575
|
+
// registry and the tree cell so cancelled mid-flight geometry cannot be re-projected
|
|
576
|
+
// (review major c7e4a91f2b08).
|
|
577
|
+
const projectionGlobal = globalThis as ProjectionHostUiGlobal
|
|
578
|
+
const owner = projectionGlobal.__nativeMotionProjectionHostFrameOwners?.[key]
|
|
579
|
+
if (owner !== undefined) {
|
|
580
|
+
const frame =
|
|
581
|
+
projectionGlobal.__nativeMotionProjectionHostFrames?.[owner.driverId]?.frames[
|
|
582
|
+
owner.componentId
|
|
583
|
+
]
|
|
584
|
+
if (frame !== undefined && frame.generation === owner.generation)
|
|
585
|
+
delete projectionGlobal.__nativeMotionProjectionHostFrames?.[owner.driverId]?.frames[
|
|
586
|
+
owner.componentId
|
|
587
|
+
]
|
|
588
|
+
delete projectionGlobal.__nativeMotionProjectionHostFrameOwners?.[key]
|
|
589
|
+
const projection = projectionGlobal.__nativeMotionDrivers?.[owner.driverId]?.layout?.projection
|
|
590
|
+
if (projection !== undefined) {
|
|
591
|
+
projection.tree.clearProjection(owner.componentId)
|
|
592
|
+
projection.tree.project()
|
|
593
|
+
for (let index = 0; index < projection.records.length; index += 1) {
|
|
594
|
+
const record = projection.records[index]!
|
|
595
|
+
const output = projection.tree.output(record.componentId)
|
|
596
|
+
record.outputs.translateX.value = output.translateX
|
|
597
|
+
record.outputs.translateY.value = output.translateY
|
|
598
|
+
record.outputs.scaleX.value = output.scaleX
|
|
599
|
+
record.outputs.scaleY.value = output.scaleY
|
|
600
|
+
record.outputs.borderRadiusX.value = output.borderRadius?.x ?? Number.NaN
|
|
601
|
+
record.outputs.borderRadiusY.value = output.borderRadius?.y ?? Number.NaN
|
|
602
|
+
}
|
|
603
|
+
projection.dirty = false
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// React owns host teardown while the completion registry lives on UI. Layout enablement owns a
|
|
609
|
+
// key lifetime: a disabled→enabled host receives a fresh key, so delayed old-lifetime cleanup
|
|
610
|
+
// cannot release the replacement flight's owner.
|
|
611
|
+
export function teardownLayoutCompletionEpoch(epoch: LayoutCompletionEpoch): void {
|
|
612
|
+
scheduleOnUI(clearLayoutCompletionEpoch, epoch.key)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Pure builder for one layout channel (originX/originY/width/height) moving toward `to` with
|
|
616
|
+
// core's math. Springs re-seed through retargetSpring for C1 velocity carry. Tweens seed core's
|
|
617
|
+
// timing generator from the host-provided live value; timing intentionally ignores velocity.
|
|
618
|
+
export function buildCoreLayoutChannel(
|
|
619
|
+
to: number,
|
|
620
|
+
transition: LayoutGeneratorConfig,
|
|
621
|
+
mirrorFlightKey?: number,
|
|
622
|
+
mirrorFlightGeneration?: number,
|
|
623
|
+
projectionDriverId?: number,
|
|
624
|
+
projectionComponentId?: string,
|
|
625
|
+
projectionGeneration?: number,
|
|
626
|
+
projectionChannel?: 'originX' | 'originY' | 'width' | 'height',
|
|
627
|
+
onUnseededChannelRefusal?: (code: string) => void,
|
|
628
|
+
onStaleProjectionNodeRefusal?: (report: StaleProjectionNodeReport) => void,
|
|
629
|
+
): CoreLayoutChannelAnimation {
|
|
630
|
+
'worklet'
|
|
631
|
+
return {
|
|
632
|
+
onStart(animation, value, now, previous) {
|
|
633
|
+
'worklet'
|
|
634
|
+
// A live previous channel means this layout change interrupted an in-flight one: re-seed
|
|
635
|
+
// from its (value, velocity) via retargetSpring — never from rest (the C1 law). A settled
|
|
636
|
+
// or absent previous seeds from rest at the host-provided start value. Reanimated passes
|
|
637
|
+
// `undefined` (not null) when there is no previous animation — check both, or the very
|
|
638
|
+
// first commit crashes the UI runtime (observed on-device, packet Findings).
|
|
639
|
+
let generator: Generator
|
|
640
|
+
if (transition.type === 'tween') {
|
|
641
|
+
generator = timingGenerator(to, transition.config)({ from: value, velocity: 0 })
|
|
642
|
+
} else {
|
|
643
|
+
const livePrevious =
|
|
644
|
+
previous !== null &&
|
|
645
|
+
previous !== undefined &&
|
|
646
|
+
previous.prop !== null &&
|
|
647
|
+
!previous.prop.done
|
|
648
|
+
? previous.prop
|
|
649
|
+
: null
|
|
650
|
+
generator =
|
|
651
|
+
livePrevious !== null
|
|
652
|
+
? retargetSpring(livePrevious.generator, livePrevious.elapsed, to, transition.config)
|
|
653
|
+
: resolveSpringGenerator(to, transition.config)({ from: value, velocity: 0 })
|
|
654
|
+
}
|
|
655
|
+
// Motion's JSAnimation subtracts its signed delay from currentTime before sampling. A
|
|
656
|
+
// negative delay therefore starts this channel partway through CORE's trajectory, rather
|
|
657
|
+
// than merely making its positive-delay hold deadline lie in the past.
|
|
658
|
+
const delayMs = transition.delayMs ?? 0
|
|
659
|
+
animation.prop = {
|
|
660
|
+
generator,
|
|
661
|
+
elapsed: delayMs < 0 ? -delayMs : 0,
|
|
662
|
+
value,
|
|
663
|
+
done: false,
|
|
664
|
+
}
|
|
665
|
+
animation.lastTimestamp = now
|
|
666
|
+
// Only a positive delay holds frames. Negative delay is represented by the seeded CORE
|
|
667
|
+
// elapsed clock above, so every channel starts from the same advanced trajectory point.
|
|
668
|
+
animation.delayUntil = now + (delayMs > 0 ? delayMs : 0)
|
|
669
|
+
animation.current = value
|
|
670
|
+
},
|
|
671
|
+
onFrame(animation, now) {
|
|
672
|
+
'worklet'
|
|
673
|
+
const prop = animation.prop
|
|
674
|
+
if (prop === null) {
|
|
675
|
+
// A frame before onStart is a Reanimated contract violation — but a UI-thread throw is
|
|
676
|
+
// a bare std::terminate on device (iOS AUTO SIGABRT), never a refusal. Report the
|
|
677
|
+
// unseeded channel to RN through the established scheduleOnRN severity channel and
|
|
678
|
+
// retire it: returning true ends the animation, preserving the last valid visual
|
|
679
|
+
// state (never seed or coast a corrupt channel).
|
|
680
|
+
if (onUnseededChannelRefusal !== undefined)
|
|
681
|
+
scheduleOnRN(onUnseededChannelRefusal, 'layout-channel-unseeded')
|
|
682
|
+
// Terminal for EVERY owner (seal r2, F2): retire this channel from both flight ledgers
|
|
683
|
+
// exactly as a completion would — generation-scoped — so neither ledger waits forever
|
|
684
|
+
// on a channel that never started (mirrors would stay awake; the seeded host-frame
|
|
685
|
+
// record would be retained on a still-mounted host).
|
|
686
|
+
if (
|
|
687
|
+
projectionDriverId !== undefined &&
|
|
688
|
+
projectionComponentId !== undefined &&
|
|
689
|
+
projectionGeneration !== undefined
|
|
690
|
+
)
|
|
691
|
+
retireProjectionHostFrameChannel(
|
|
692
|
+
projectionDriverId,
|
|
693
|
+
projectionComponentId,
|
|
694
|
+
projectionGeneration,
|
|
695
|
+
mirrorFlightKey,
|
|
696
|
+
)
|
|
697
|
+
if (mirrorFlightKey !== undefined && mirrorFlightGeneration !== undefined)
|
|
698
|
+
retireLayoutTransformMirrorFlightChannel(mirrorFlightKey, mirrorFlightGeneration)
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
const frameStart = animation.lastTimestamp
|
|
702
|
+
animation.lastTimestamp = now
|
|
703
|
+
if (now < animation.delayUntil) return false
|
|
704
|
+
// Delay is a clock offset around CORE's generator, identical for every channel. Once the
|
|
705
|
+
// window ends, only the post-delay slice reaches stepProp; trajectory math stays CORE's.
|
|
706
|
+
const activeStart = frameStart < animation.delayUntil ? animation.delayUntil : frameStart
|
|
707
|
+
stepProp(prop, now - activeStart)
|
|
708
|
+
animation.current = prop.value
|
|
709
|
+
// A host-layout channel is the only faithful per-frame source for its geometry. The frame
|
|
710
|
+
// record and projection tree both live on UI; this is deliberately direct global mutation,
|
|
711
|
+
// not a nested callable-worklet capture or a JS↔UI crossing.
|
|
712
|
+
if (
|
|
713
|
+
projectionDriverId !== undefined &&
|
|
714
|
+
projectionComponentId !== undefined &&
|
|
715
|
+
projectionGeneration !== undefined &&
|
|
716
|
+
projectionChannel !== undefined
|
|
717
|
+
) {
|
|
718
|
+
const projectionGlobal = globalThis as ProjectionHostUiGlobal
|
|
719
|
+
const frame =
|
|
720
|
+
projectionGlobal.__nativeMotionProjectionHostFrames?.[projectionDriverId]?.frames[
|
|
721
|
+
projectionComponentId
|
|
722
|
+
]
|
|
723
|
+
if (frame !== undefined && frame.generation === projectionGeneration) {
|
|
724
|
+
switch (projectionChannel) {
|
|
725
|
+
case 'originX':
|
|
726
|
+
frame.currentOriginX = prop.value
|
|
727
|
+
break
|
|
728
|
+
case 'originY':
|
|
729
|
+
frame.currentOriginY = prop.value
|
|
730
|
+
break
|
|
731
|
+
case 'width':
|
|
732
|
+
frame.currentWidth = prop.value
|
|
733
|
+
break
|
|
734
|
+
case 'height':
|
|
735
|
+
frame.currentHeight = prop.value
|
|
736
|
+
break
|
|
737
|
+
}
|
|
738
|
+
const projection =
|
|
739
|
+
projectionGlobal.__nativeMotionDrivers?.[projectionDriverId]?.layout?.projection
|
|
740
|
+
if (projection !== undefined) {
|
|
741
|
+
projection.tree.setHostFrame(
|
|
742
|
+
projectionComponentId,
|
|
743
|
+
frame.currentOriginX,
|
|
744
|
+
frame.currentOriginY,
|
|
745
|
+
frame.currentWidth,
|
|
746
|
+
frame.currentHeight,
|
|
747
|
+
frame.targetOriginX,
|
|
748
|
+
frame.targetOriginY,
|
|
749
|
+
frame.targetWidth,
|
|
750
|
+
frame.targetHeight,
|
|
751
|
+
)
|
|
752
|
+
projection.tree.project()
|
|
753
|
+
for (let index = 0; index < projection.records.length; index += 1) {
|
|
754
|
+
const record = projection.records[index]!
|
|
755
|
+
const output = projection.tree.output(record.componentId)
|
|
756
|
+
record.outputs.translateX.value = output.translateX
|
|
757
|
+
record.outputs.translateY.value = output.translateY
|
|
758
|
+
record.outputs.scaleX.value = output.scaleX
|
|
759
|
+
record.outputs.scaleY.value = output.scaleY
|
|
760
|
+
record.outputs.borderRadiusX.value = output.borderRadius?.x ?? Number.NaN
|
|
761
|
+
record.outputs.borderRadiusY.value = output.borderRadius?.y ?? Number.NaN
|
|
762
|
+
}
|
|
763
|
+
projection.dirty = false
|
|
764
|
+
// Drain the tree's stale-report ring on this frame edge (iOS AUTO SIGABRT Phase
|
|
765
|
+
// 1.5): teardown races surface through the RN severity channel, never a UI throw.
|
|
766
|
+
reportStaleProjectionReports(projection, onStaleProjectionNodeRefusal)
|
|
767
|
+
}
|
|
768
|
+
if (prop.done && !animation.projectionCompletionReported) {
|
|
769
|
+
animation.projectionCompletionReported = true
|
|
770
|
+
retireProjectionHostFrameChannel(
|
|
771
|
+
projectionDriverId,
|
|
772
|
+
projectionComponentId,
|
|
773
|
+
projectionGeneration,
|
|
774
|
+
mirrorFlightKey,
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
} else if (frame === undefined) {
|
|
778
|
+
// The host detached mid-flight and its frame record is gone (iOS AUTO SIGABRT Phase
|
|
779
|
+
// 1.5): retire the torn-down flight at this frame boundary — prop.done runs the
|
|
780
|
+
// completion bookkeeping below, no further frames are scheduled for the host, and the
|
|
781
|
+
// last painted value (animation.current, already advanced this frame) is preserved.
|
|
782
|
+
prop.done = true
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (prop.done && !animation.completionReported) {
|
|
786
|
+
animation.completionReported = true
|
|
787
|
+
if (mirrorFlightKey !== undefined && mirrorFlightGeneration !== undefined) {
|
|
788
|
+
// This onFrame belongs to Reanimated's deferred defineAnimation factory. The mirror
|
|
789
|
+
// flight ledger retires through a module-level worklet so this closure stays
|
|
790
|
+
// primitive-only (a nested callable serializes a second worklet graph — undefined on
|
|
791
|
+
// the iOS UI runtime). The registry data itself is UI-global.
|
|
792
|
+
retireLayoutTransformMirrorFlightChannel(mirrorFlightKey, mirrorFlightGeneration)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return prop.done
|
|
796
|
+
},
|
|
797
|
+
current: to,
|
|
798
|
+
lastTimestamp: 0,
|
|
799
|
+
delayUntil: 0,
|
|
800
|
+
prop: null,
|
|
801
|
+
completionReported: false,
|
|
802
|
+
projectionCompletionReported: false,
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// The deterministic verification screen owns this spring-specific seam. Preserve it as a thin
|
|
807
|
+
// wrapper so the generic channel adds tween support without invalidating the existing spring proof.
|
|
808
|
+
export function buildCoreSpringChannel(
|
|
809
|
+
to: number,
|
|
810
|
+
spring: SpringConfig,
|
|
811
|
+
): CoreLayoutChannelAnimation {
|
|
812
|
+
'worklet'
|
|
813
|
+
return buildCoreLayoutChannel(to, { type: 'spring', config: spring })
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The default flight spring — the ratified session default (LAYOUT_PROJECTION_SPRING profile).
|
|
817
|
+
const DEFAULT_SPRING: SpringConfig = { stiffness: 550, damping: 45 }
|
|
818
|
+
const DEFAULT_TRANSITION: LayoutGeneratorConfig = {
|
|
819
|
+
type: 'spring',
|
|
820
|
+
config: DEFAULT_SPRING,
|
|
821
|
+
delayMs: 0,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export type LayoutTransitionChannels = 'both' | 'position' | 'size' | 'preserve-aspect'
|
|
825
|
+
|
|
826
|
+
// Motion's pinned projection oracle treats aspect ratios within 0.2 as preserved. This constant
|
|
827
|
+
// configures channel selection only; CORE remains the sole owner of each selected channel's math.
|
|
828
|
+
const PRESERVED_ASPECT_RATIO_TOLERANCE = 0.2
|
|
829
|
+
|
|
830
|
+
// One channel wrapped for Reanimated's layout-animation slot. The `starting` decoration value is
|
|
831
|
+
// the channel's current paint value; the factory builds the pure channel worklet-side.
|
|
832
|
+
function coreLayoutChannel(
|
|
833
|
+
to: number,
|
|
834
|
+
transition: LayoutGeneratorConfig,
|
|
835
|
+
mirrorFlightKey?: number,
|
|
836
|
+
mirrorFlightGeneration?: number,
|
|
837
|
+
projectionDriverId?: number,
|
|
838
|
+
projectionComponentId?: string,
|
|
839
|
+
projectionGeneration?: number,
|
|
840
|
+
projectionChannel?: 'originX' | 'originY' | 'width' | 'height',
|
|
841
|
+
onUnseededChannelRefusal?: (code: string) => void,
|
|
842
|
+
onStaleProjectionNodeRefusal?: (report: StaleProjectionNodeReport) => void,
|
|
843
|
+
): number {
|
|
844
|
+
'worklet'
|
|
845
|
+
// A NAMED declarator (iOS AUTO SIGABRT seal, F1 — the closure graph roots produced worklets by
|
|
846
|
+
// declarator name; an inline arrow would take a positional plugin alias and evade the roots).
|
|
847
|
+
// Captures stay primitives only: the lifecycle mutation stays in the module-scope worklet
|
|
848
|
+
// above, never in a dynamically-created completion worklet captured by this factory.
|
|
849
|
+
const coreLayoutAnimationFactory = (): CoreLayoutChannelAnimation => {
|
|
850
|
+
'worklet'
|
|
851
|
+
return buildCoreLayoutChannel(
|
|
852
|
+
to,
|
|
853
|
+
transition,
|
|
854
|
+
mirrorFlightKey,
|
|
855
|
+
mirrorFlightGeneration,
|
|
856
|
+
projectionDriverId,
|
|
857
|
+
projectionComponentId,
|
|
858
|
+
projectionGeneration,
|
|
859
|
+
projectionChannel,
|
|
860
|
+
onUnseededChannelRefusal,
|
|
861
|
+
onStaleProjectionNodeRefusal,
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
return defineAnimation<CoreLayoutChannelAnimation>(
|
|
865
|
+
to,
|
|
866
|
+
coreLayoutAnimationFactory,
|
|
867
|
+
) as unknown as number
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Build the no-flash layout transition: initialValues = the current (pre-commit) rect — the
|
|
871
|
+
// first post-commit frame paints the OLD rect — and channel animations = core generators toward
|
|
872
|
+
// the target rect.
|
|
873
|
+
export function createNoFlashLayoutTransition(
|
|
874
|
+
generatorConfig: LayoutGeneratorConfig = DEFAULT_TRANSITION,
|
|
875
|
+
channels: LayoutTransitionChannels = 'both',
|
|
876
|
+
onComplete?: () => void,
|
|
877
|
+
onStart?: () => void,
|
|
878
|
+
completionEpoch: LayoutCompletionEpoch = createLayoutCompletionEpoch(),
|
|
879
|
+
onOwnerLimitRefusal?: () => void,
|
|
880
|
+
onMirrorFlightLimitRefusal?: () => void,
|
|
881
|
+
onUnseededChannelRefusal?: (code: string) => void,
|
|
882
|
+
onProjectionHostFrameCapRefusal?: (code: string) => void,
|
|
883
|
+
onStaleProjectionNodeRefusal?: (report: StaleProjectionNodeReport) => void,
|
|
884
|
+
// The PUBLIC layout-flight start callback (U7e): unlike the internal onStart (which runs at
|
|
885
|
+
// EVERY commit — the combined-flight reset must not weaken), this crossing is qualified per
|
|
886
|
+
// logical flight (beginLayoutFlightStart: React generation, the pin's didUpdate analogue).
|
|
887
|
+
onFlightStart?: () => void,
|
|
888
|
+
// Incremented when the React layout signature changes (mode / authored transition /
|
|
889
|
+
// layout-relevant style). The primitive is the test/fallback capture; the mutable cell
|
|
890
|
+
// is the live didUpdate read so a mid-flight retarget on a STALE layout function still
|
|
891
|
+
// sees the new generation (Android 57acf50c dropped C's start — the in-flight worklet
|
|
892
|
+
// still held B's captured generation).
|
|
893
|
+
layoutFlightGeneration = 0,
|
|
894
|
+
layoutFlightGenerationCell?: { value: number },
|
|
895
|
+
): LayoutAnimationFunction | undefined {
|
|
896
|
+
// The wedge law (iOS AUTO SIGABRT): the generator config rides the produced worklet's closure
|
|
897
|
+
// into the UI runtime — it must be a finite, acyclic payload. Dev throws at the factory edge;
|
|
898
|
+
// production reports and the layout request is REFUSED (seal r7, F2): undefined means no
|
|
899
|
+
// transition at all — the host keeps its last valid static layout, never a pinned default
|
|
900
|
+
// transition substituted silently (an invalid authored transition running with different
|
|
901
|
+
// timing).
|
|
902
|
+
if (refuseUnsafeSerializablePayload(generatorConfig, 'generatorConfig') !== null) return undefined
|
|
903
|
+
const safeGeneratorConfig = generatorConfig
|
|
904
|
+
// Extract before entering the worklet. The key is primitive capture data, so Worklets preserves
|
|
905
|
+
// identity across rebuilt layout functions even though it clones the containing JS object.
|
|
906
|
+
const completionKey = completionEpoch.key
|
|
907
|
+
const flightGeneration = layoutFlightGeneration
|
|
908
|
+
const flightGenerationCell = layoutFlightGenerationCell
|
|
909
|
+
let hasLayoutTransformMirrors = false
|
|
910
|
+
// `configureProjectionHostFeed` runs after this function is constructed. Unlike a lexical
|
|
911
|
+
// variable, a Reanimated mutable stays shared when `layoutTransition` is serialized, so the
|
|
912
|
+
// root host's frame reaches the same ancestor-owned tree as its nested descendants.
|
|
913
|
+
const projectionHostFeed: ProjectionHostFeedCells = {
|
|
914
|
+
driverId: makeMutable(-1),
|
|
915
|
+
componentId: makeMutable(''),
|
|
916
|
+
}
|
|
917
|
+
const layoutTransition: MirrorConfigurableLayoutTransition = (values) => {
|
|
918
|
+
'worklet'
|
|
919
|
+
|
|
920
|
+
// Only public completion owners enter the UI registry. Layout-only flights have no terminal
|
|
921
|
+
// callback that could release an entry, so registering them would retain a dead owner and
|
|
922
|
+
// eventually interfere with unrelated hosts.
|
|
923
|
+
const flightEpoch =
|
|
924
|
+
onComplete === undefined ? undefined : beginLayoutCompletionFlight(completionKey)
|
|
925
|
+
if (onComplete !== undefined && flightEpoch === undefined && onOwnerLimitRefusal !== undefined)
|
|
926
|
+
scheduleOnRN(onOwnerLimitRefusal)
|
|
927
|
+
|
|
928
|
+
const beginMirrorFlight = (channelCount: number): number | undefined => {
|
|
929
|
+
'worklet'
|
|
930
|
+
if (!hasLayoutTransformMirrors) return undefined
|
|
931
|
+
const generation = beginLayoutTransformMirrorFlight(completionKey, channelCount)
|
|
932
|
+
if (generation === undefined && onMirrorFlightLimitRefusal !== undefined)
|
|
933
|
+
scheduleOnRN(onMirrorFlightLimitRefusal)
|
|
934
|
+
return generation
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// A combined layout/layoutId host owns a completion epoch per ACTUAL host flight, never per
|
|
938
|
+
// React render. This UI commit edge is the faithful start boundary for Reanimated layout.
|
|
939
|
+
if (onStart !== undefined) scheduleOnRN(onStart)
|
|
940
|
+
// The PUBLIC start crossing is qualified per LOGICAL flight (React generation — the pin's
|
|
941
|
+
// didUpdate analogue). The internal onStart above keeps firing per commit — the F1
|
|
942
|
+
// combined-flight reset is never weakened by the qualification.
|
|
943
|
+
const liveGeneration =
|
|
944
|
+
flightGenerationCell !== undefined ? flightGenerationCell.value : flightGeneration
|
|
945
|
+
if (onFlightStart !== undefined && beginLayoutFlightStart(completionKey, liveGeneration))
|
|
946
|
+
scheduleOnRN(onFlightStart)
|
|
947
|
+
|
|
948
|
+
// `preserve-aspect` follows Motion's documented conditional: a material ratio change snaps
|
|
949
|
+
// size to the host layout and animates position only. The 0.2 comparison matches the pinned
|
|
950
|
+
// oracle's `shouldAnimatePositionOnly`; it selects channels and never forks trajectory math.
|
|
951
|
+
const currentAspectRatio = values.currentWidth / values.currentHeight
|
|
952
|
+
const targetAspectRatio = values.targetWidth / values.targetHeight
|
|
953
|
+
const positionOnly =
|
|
954
|
+
channels === 'position' ||
|
|
955
|
+
(channels === 'preserve-aspect' &&
|
|
956
|
+
!(Math.abs(currentAspectRatio - targetAspectRatio) <= PRESERVED_ASPECT_RATIO_TOLERANCE))
|
|
957
|
+
|
|
958
|
+
// Register the host's initial/current rectangle in a UI-global record. The frame channels
|
|
959
|
+
// below mutate only their scalar field and flush the ancestor tree from that same record.
|
|
960
|
+
// A new host commit replaces the generation, so stale completion frames cannot restart it.
|
|
961
|
+
const projectionChannelCount = positionOnly || channels === 'size' ? 2 : 4
|
|
962
|
+
const configuredProjectionDriverId = projectionHostFeed.driverId.value
|
|
963
|
+
const configuredProjectionComponentId = projectionHostFeed.componentId.value
|
|
964
|
+
const projectionDriverId =
|
|
965
|
+
configuredProjectionDriverId >= 0 ? configuredProjectionDriverId : undefined
|
|
966
|
+
const projectionComponentId =
|
|
967
|
+
configuredProjectionComponentId.length > 0 ? configuredProjectionComponentId : undefined
|
|
968
|
+
let projectionGeneration: number | undefined
|
|
969
|
+
// Capacity pressure is a bounded DEGRADATION, never a process kill (iOS AUTO SIGABRT): the
|
|
970
|
+
// projection host-frame cap refuses ONLY the projection enhancement/flight for the excess
|
|
971
|
+
// host — it takes the plain layout path, one cap report reaches RN through scheduleOnRN,
|
|
972
|
+
// and the animation itself completes. The 512 cap itself stands.
|
|
973
|
+
let projectionCapRefused = false
|
|
974
|
+
const projectionGlobal = globalThis as ProjectionHostUiGlobal
|
|
975
|
+
if (projectionDriverId !== undefined && projectionComponentId !== undefined) {
|
|
976
|
+
if (projectionGlobal.__nativeMotionProjectionHostFrames === undefined)
|
|
977
|
+
projectionGlobal.__nativeMotionProjectionHostFrames = {}
|
|
978
|
+
if (projectionGlobal.__nativeMotionProjectionHostFrameOwners === undefined)
|
|
979
|
+
projectionGlobal.__nativeMotionProjectionHostFrameOwners = {}
|
|
980
|
+
if (
|
|
981
|
+
projectionGlobal.__nativeMotionProjectionHostFrameOwners[completionKey] === undefined &&
|
|
982
|
+
Object.keys(projectionGlobal.__nativeMotionProjectionHostFrameOwners).length >=
|
|
983
|
+
MAX_ACTIVE_PROJECTION_HOST_FRAMES
|
|
984
|
+
) {
|
|
985
|
+
projectionCapRefused = true
|
|
986
|
+
if (onProjectionHostFrameCapRefusal !== undefined)
|
|
987
|
+
scheduleOnRN(onProjectionHostFrameCapRefusal, 'projection-host-frame-cap')
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (
|
|
991
|
+
!projectionCapRefused &&
|
|
992
|
+
projectionDriverId !== undefined &&
|
|
993
|
+
projectionComponentId !== undefined
|
|
994
|
+
) {
|
|
995
|
+
// The ids-defined block above always initializes these maps first — asserted, never re-checked.
|
|
996
|
+
const hostFrames = projectionGlobal.__nativeMotionProjectionHostFrames!
|
|
997
|
+
const hostFrameOwners = projectionGlobal.__nativeMotionProjectionHostFrameOwners!
|
|
998
|
+
let driverFrames = hostFrames[projectionDriverId]
|
|
999
|
+
if (driverFrames === undefined) {
|
|
1000
|
+
driverFrames = { nextGeneration: 0, frames: {} }
|
|
1001
|
+
hostFrames[projectionDriverId] = driverFrames
|
|
1002
|
+
}
|
|
1003
|
+
driverFrames.nextGeneration += 1
|
|
1004
|
+
projectionGeneration = driverFrames.nextGeneration
|
|
1005
|
+
driverFrames.frames[projectionComponentId] = {
|
|
1006
|
+
generation: projectionGeneration,
|
|
1007
|
+
currentOriginX: channels === 'size' ? values.targetOriginX : values.currentOriginX,
|
|
1008
|
+
currentOriginY: channels === 'size' ? values.targetOriginY : values.currentOriginY,
|
|
1009
|
+
currentWidth: positionOnly ? values.targetWidth : values.currentWidth,
|
|
1010
|
+
currentHeight: positionOnly ? values.targetHeight : values.currentHeight,
|
|
1011
|
+
targetOriginX: values.targetOriginX,
|
|
1012
|
+
targetOriginY: values.targetOriginY,
|
|
1013
|
+
targetWidth: values.targetWidth,
|
|
1014
|
+
targetHeight: values.targetHeight,
|
|
1015
|
+
remainingChannels: projectionChannelCount,
|
|
1016
|
+
}
|
|
1017
|
+
hostFrameOwners[completionKey] = {
|
|
1018
|
+
driverId: projectionDriverId,
|
|
1019
|
+
componentId: projectionComponentId,
|
|
1020
|
+
generation: projectionGeneration,
|
|
1021
|
+
}
|
|
1022
|
+
// Positive-delay channels do not enter onFrame until their hold expires. Seed the exact
|
|
1023
|
+
// initial host rect now so targetless descendants counter-scale from the first painted
|
|
1024
|
+
// hold frame instead of visibly inheriting the parent's inverted scale.
|
|
1025
|
+
const projection =
|
|
1026
|
+
projectionGlobal.__nativeMotionDrivers?.[projectionDriverId]?.layout?.projection
|
|
1027
|
+
const frame = driverFrames.frames[projectionComponentId]
|
|
1028
|
+
if (projection !== undefined && frame !== undefined) {
|
|
1029
|
+
projection.tree.setHostFrame(
|
|
1030
|
+
projectionComponentId,
|
|
1031
|
+
frame.currentOriginX,
|
|
1032
|
+
frame.currentOriginY,
|
|
1033
|
+
frame.currentWidth,
|
|
1034
|
+
frame.currentHeight,
|
|
1035
|
+
frame.targetOriginX,
|
|
1036
|
+
frame.targetOriginY,
|
|
1037
|
+
frame.targetWidth,
|
|
1038
|
+
frame.targetHeight,
|
|
1039
|
+
)
|
|
1040
|
+
projection.tree.project()
|
|
1041
|
+
for (let index = 0; index < projection.records.length; index += 1) {
|
|
1042
|
+
const record = projection.records[index]!
|
|
1043
|
+
const output = projection.tree.output(record.componentId)
|
|
1044
|
+
record.outputs.translateX.value = output.translateX
|
|
1045
|
+
record.outputs.translateY.value = output.translateY
|
|
1046
|
+
record.outputs.scaleX.value = output.scaleX
|
|
1047
|
+
record.outputs.scaleY.value = output.scaleY
|
|
1048
|
+
record.outputs.borderRadiusX.value = output.borderRadius?.x ?? Number.NaN
|
|
1049
|
+
record.outputs.borderRadiusY.value = output.borderRadius?.y ?? Number.NaN
|
|
1050
|
+
}
|
|
1051
|
+
projection.dirty = false
|
|
1052
|
+
// The commit edge drains the tree's stale-report ring too (iOS AUTO SIGABRT Phase 1.5):
|
|
1053
|
+
// teardown races from superseded lanes surface through the RN severity channel.
|
|
1054
|
+
reportStaleProjectionReports(projection, onStaleProjectionNodeRefusal)
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Reanimated owns the commit-hook lifecycle, so its terminal callback is the only faithful
|
|
1059
|
+
// settle edge for `layout`. Cancellation and success both release the projection host record:
|
|
1060
|
+
// a cancelled frame must not keep feeding obsolete geometry into a later tree traversal.
|
|
1061
|
+
let completionDelivered = false
|
|
1062
|
+
const callback =
|
|
1063
|
+
onComplete === undefined
|
|
1064
|
+
? undefined
|
|
1065
|
+
: (finished: boolean): void => {
|
|
1066
|
+
'worklet'
|
|
1067
|
+
// Seal-r15 F2 proof witnesses (primitive counters, never the channel under test;
|
|
1068
|
+
// seal-r16 F2: only when the proof app armed the UI cells — zero-cost otherwise):
|
|
1069
|
+
// invocations vs epoch-guard drops vs cancellations vs onComplete scheduled — each
|
|
1070
|
+
// pair names a different dead link. No scheduleOnRN of proof helpers here: the
|
|
1071
|
+
// worklet-closures gate forbids capturing non-worklet module functions; JS-side
|
|
1072
|
+
// last-pushed cells are filled from MotionView.layoutCompletion.
|
|
1073
|
+
const proofGlobal = globalThis as ProofLayoutCallbackUiGlobal
|
|
1074
|
+
const proofArmed = proofGlobal.__nativeMotionProofWitnessArmed === true
|
|
1075
|
+
if (proofArmed) {
|
|
1076
|
+
proofGlobal.__nativeMotionProofLayoutCallbackInvocations =
|
|
1077
|
+
(proofGlobal.__nativeMotionProofLayoutCallbackInvocations ?? 0) + 1
|
|
1078
|
+
}
|
|
1079
|
+
if (
|
|
1080
|
+
flightEpoch === undefined ||
|
|
1081
|
+
!isCurrentLayoutCompletionFlight(completionKey, flightEpoch)
|
|
1082
|
+
) {
|
|
1083
|
+
if (proofArmed) {
|
|
1084
|
+
proofGlobal.__nativeMotionProofLayoutCallbackEpochDrops =
|
|
1085
|
+
(proofGlobal.__nativeMotionProofLayoutCallbackEpochDrops ?? 0) + 1
|
|
1086
|
+
}
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
// Same primitive-only rule as clearLayoutCompletionEpoch: nested worklet bodies must
|
|
1090
|
+
// not call helper worklets. Cancel and success both release the host-frame registry
|
|
1091
|
+
// AND clear the tree cell so cancelled mid-flight geometry cannot be re-projected.
|
|
1092
|
+
const projectionGlobal = globalThis as ProjectionHostUiGlobal
|
|
1093
|
+
const projectionOwner =
|
|
1094
|
+
projectionGlobal.__nativeMotionProjectionHostFrameOwners?.[completionKey]
|
|
1095
|
+
if (projectionOwner !== undefined) {
|
|
1096
|
+
const projectionFrame =
|
|
1097
|
+
projectionGlobal.__nativeMotionProjectionHostFrames?.[projectionOwner.driverId]
|
|
1098
|
+
?.frames[projectionOwner.componentId]
|
|
1099
|
+
if (
|
|
1100
|
+
projectionFrame !== undefined &&
|
|
1101
|
+
projectionFrame.generation === projectionOwner.generation
|
|
1102
|
+
)
|
|
1103
|
+
delete projectionGlobal.__nativeMotionProjectionHostFrames?.[
|
|
1104
|
+
projectionOwner.driverId
|
|
1105
|
+
]?.frames[projectionOwner.componentId]
|
|
1106
|
+
delete projectionGlobal.__nativeMotionProjectionHostFrameOwners?.[completionKey]
|
|
1107
|
+
const projection =
|
|
1108
|
+
projectionGlobal.__nativeMotionDrivers?.[projectionOwner.driverId]?.layout
|
|
1109
|
+
?.projection
|
|
1110
|
+
if (projection !== undefined) {
|
|
1111
|
+
projection.tree.clearProjection(projectionOwner.componentId)
|
|
1112
|
+
projection.tree.project()
|
|
1113
|
+
for (let index = 0; index < projection.records.length; index += 1) {
|
|
1114
|
+
const record = projection.records[index]!
|
|
1115
|
+
const output = projection.tree.output(record.componentId)
|
|
1116
|
+
record.outputs.translateX.value = output.translateX
|
|
1117
|
+
record.outputs.translateY.value = output.translateY
|
|
1118
|
+
record.outputs.scaleX.value = output.scaleX
|
|
1119
|
+
record.outputs.scaleY.value = output.scaleY
|
|
1120
|
+
record.outputs.borderRadiusX.value = output.borderRadius?.x ?? Number.NaN
|
|
1121
|
+
record.outputs.borderRadiusY.value = output.borderRadius?.y ?? Number.NaN
|
|
1122
|
+
}
|
|
1123
|
+
projection.dirty = false
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (!finished) {
|
|
1127
|
+
if (proofArmed) {
|
|
1128
|
+
proofGlobal.__nativeMotionProofLayoutCallbackCancelled =
|
|
1129
|
+
(proofGlobal.__nativeMotionProofLayoutCallbackCancelled ?? 0) + 1
|
|
1130
|
+
}
|
|
1131
|
+
completeLayoutCompletionFlight(completionKey, flightEpoch)
|
|
1132
|
+
// Do not release the start ledger on cancel: a start-driven rerender rebuilds
|
|
1133
|
+
// this worklet and Reanimated cancels the replaced animation — that rebound
|
|
1134
|
+
// must not re-arm the public callback (device proof f841bc52). Settle also
|
|
1135
|
+
// keeps the ledger (iOS 21e910d0 re-fire-at-settle). A later React generation
|
|
1136
|
+
// overwrites; lifetime teardown still releases.
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
if (completionDelivered) return
|
|
1140
|
+
completionDelivered = true
|
|
1141
|
+
completeLayoutCompletionFlight(completionKey, flightEpoch)
|
|
1142
|
+
// Do not release the start ledger on settle: Reanimated re-invokes this
|
|
1143
|
+
// worklet at complete with the same React generation (iOS 21e910d0 flight A
|
|
1144
|
+
// starts=2 at +2037,+5063). That is extra host-commit granularity, not a
|
|
1145
|
+
// pin didUpdate. A later React generation overwrites the ledger in
|
|
1146
|
+
// beginLayoutFlightStart. Teardown still releases.
|
|
1147
|
+
if (proofArmed) {
|
|
1148
|
+
proofGlobal.__nativeMotionProofLayoutOnCompleteScheduled =
|
|
1149
|
+
(proofGlobal.__nativeMotionProofLayoutOnCompleteScheduled ?? 0) + 1
|
|
1150
|
+
}
|
|
1151
|
+
scheduleOnRN(onComplete)
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (positionOnly) {
|
|
1155
|
+
const mirrorFlightGeneration = beginMirrorFlight(2)
|
|
1156
|
+
return {
|
|
1157
|
+
initialValues: {
|
|
1158
|
+
originX: values.currentOriginX,
|
|
1159
|
+
originY: values.currentOriginY,
|
|
1160
|
+
},
|
|
1161
|
+
animations: {
|
|
1162
|
+
originX: coreLayoutChannel(
|
|
1163
|
+
values.targetOriginX,
|
|
1164
|
+
safeGeneratorConfig,
|
|
1165
|
+
completionKey,
|
|
1166
|
+
mirrorFlightGeneration,
|
|
1167
|
+
projectionDriverId,
|
|
1168
|
+
projectionComponentId,
|
|
1169
|
+
projectionGeneration,
|
|
1170
|
+
'originX',
|
|
1171
|
+
onUnseededChannelRefusal,
|
|
1172
|
+
onStaleProjectionNodeRefusal,
|
|
1173
|
+
),
|
|
1174
|
+
originY: coreLayoutChannel(
|
|
1175
|
+
values.targetOriginY,
|
|
1176
|
+
safeGeneratorConfig,
|
|
1177
|
+
completionKey,
|
|
1178
|
+
mirrorFlightGeneration,
|
|
1179
|
+
projectionDriverId,
|
|
1180
|
+
projectionComponentId,
|
|
1181
|
+
projectionGeneration,
|
|
1182
|
+
'originY',
|
|
1183
|
+
onUnseededChannelRefusal,
|
|
1184
|
+
onStaleProjectionNodeRefusal,
|
|
1185
|
+
),
|
|
1186
|
+
},
|
|
1187
|
+
...(callback === undefined ? {} : { callback }),
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (channels === 'size') {
|
|
1192
|
+
const mirrorFlightGeneration = beginMirrorFlight(2)
|
|
1193
|
+
return {
|
|
1194
|
+
initialValues: {
|
|
1195
|
+
width: values.currentWidth,
|
|
1196
|
+
height: values.currentHeight,
|
|
1197
|
+
},
|
|
1198
|
+
animations: {
|
|
1199
|
+
width: coreLayoutChannel(
|
|
1200
|
+
values.targetWidth,
|
|
1201
|
+
safeGeneratorConfig,
|
|
1202
|
+
completionKey,
|
|
1203
|
+
mirrorFlightGeneration,
|
|
1204
|
+
projectionDriverId,
|
|
1205
|
+
projectionComponentId,
|
|
1206
|
+
projectionGeneration,
|
|
1207
|
+
'width',
|
|
1208
|
+
onUnseededChannelRefusal,
|
|
1209
|
+
onStaleProjectionNodeRefusal,
|
|
1210
|
+
),
|
|
1211
|
+
height: coreLayoutChannel(
|
|
1212
|
+
values.targetHeight,
|
|
1213
|
+
safeGeneratorConfig,
|
|
1214
|
+
completionKey,
|
|
1215
|
+
mirrorFlightGeneration,
|
|
1216
|
+
projectionDriverId,
|
|
1217
|
+
projectionComponentId,
|
|
1218
|
+
projectionGeneration,
|
|
1219
|
+
'height',
|
|
1220
|
+
onUnseededChannelRefusal,
|
|
1221
|
+
onStaleProjectionNodeRefusal,
|
|
1222
|
+
),
|
|
1223
|
+
},
|
|
1224
|
+
...(callback === undefined ? {} : { callback }),
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const mirrorFlightGeneration = beginMirrorFlight(4)
|
|
1229
|
+
return {
|
|
1230
|
+
initialValues: {
|
|
1231
|
+
originX: values.currentOriginX,
|
|
1232
|
+
originY: values.currentOriginY,
|
|
1233
|
+
width: values.currentWidth,
|
|
1234
|
+
height: values.currentHeight,
|
|
1235
|
+
},
|
|
1236
|
+
animations: {
|
|
1237
|
+
originX: coreLayoutChannel(
|
|
1238
|
+
values.targetOriginX,
|
|
1239
|
+
safeGeneratorConfig,
|
|
1240
|
+
completionKey,
|
|
1241
|
+
mirrorFlightGeneration,
|
|
1242
|
+
projectionDriverId,
|
|
1243
|
+
projectionComponentId,
|
|
1244
|
+
projectionGeneration,
|
|
1245
|
+
'originX',
|
|
1246
|
+
onUnseededChannelRefusal,
|
|
1247
|
+
onStaleProjectionNodeRefusal,
|
|
1248
|
+
),
|
|
1249
|
+
originY: coreLayoutChannel(
|
|
1250
|
+
values.targetOriginY,
|
|
1251
|
+
safeGeneratorConfig,
|
|
1252
|
+
completionKey,
|
|
1253
|
+
mirrorFlightGeneration,
|
|
1254
|
+
projectionDriverId,
|
|
1255
|
+
projectionComponentId,
|
|
1256
|
+
projectionGeneration,
|
|
1257
|
+
'originY',
|
|
1258
|
+
onUnseededChannelRefusal,
|
|
1259
|
+
onStaleProjectionNodeRefusal,
|
|
1260
|
+
),
|
|
1261
|
+
width: coreLayoutChannel(
|
|
1262
|
+
values.targetWidth,
|
|
1263
|
+
safeGeneratorConfig,
|
|
1264
|
+
completionKey,
|
|
1265
|
+
mirrorFlightGeneration,
|
|
1266
|
+
projectionDriverId,
|
|
1267
|
+
projectionComponentId,
|
|
1268
|
+
projectionGeneration,
|
|
1269
|
+
'width',
|
|
1270
|
+
onUnseededChannelRefusal,
|
|
1271
|
+
onStaleProjectionNodeRefusal,
|
|
1272
|
+
),
|
|
1273
|
+
height: coreLayoutChannel(
|
|
1274
|
+
values.targetHeight,
|
|
1275
|
+
safeGeneratorConfig,
|
|
1276
|
+
completionKey,
|
|
1277
|
+
mirrorFlightGeneration,
|
|
1278
|
+
projectionDriverId,
|
|
1279
|
+
projectionComponentId,
|
|
1280
|
+
projectionGeneration,
|
|
1281
|
+
'height',
|
|
1282
|
+
onUnseededChannelRefusal,
|
|
1283
|
+
onStaleProjectionNodeRefusal,
|
|
1284
|
+
),
|
|
1285
|
+
},
|
|
1286
|
+
...(callback === undefined ? {} : { callback }),
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
layoutTransition.configureLayoutTransformMirrors = (enabled) => {
|
|
1290
|
+
hasLayoutTransformMirrors = enabled
|
|
1291
|
+
}
|
|
1292
|
+
layoutTransition.configureProjectionHostFeed = (feed) => {
|
|
1293
|
+
scheduleOnUI(
|
|
1294
|
+
setProjectionHostFeedCellsOnUI,
|
|
1295
|
+
projectionHostFeed.driverId,
|
|
1296
|
+
projectionHostFeed.componentId,
|
|
1297
|
+
feed?.driverId ?? -1,
|
|
1298
|
+
feed?.componentId ?? '',
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
return layoutTransition
|
|
1302
|
+
}
|