@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,382 @@
1
+ // The presence binding's brain (specs/M2.3-BUILD-PACKET.md): the non-React layer AnimatePresence
2
+ // wraps thinly — the same controller/component split MotionView uses. It owns (a) the boundary
3
+ // guards (core validation FIRST, then the exit-keys ⊆ animate-keys spike guard — the M2.2 lessons:
4
+ // guard every phase, validation-first ordering), and (b) the pass-through to core's
5
+ // createPresenceController, which owns retention/settle/cancel/mode ordering. Checks drive THIS
6
+ // surface deterministically with a ManualClock/ManualScheduler graph; the React container adds only
7
+ // keyed-children plumbing and the exits-in-flight rAF stepper.
8
+ //
9
+ import {
10
+ InvalidTransitionError,
11
+ captureTransition,
12
+ validateTransitionRefusal,
13
+ createPresenceController,
14
+ springKeyframeCountRefusal,
15
+ type MotionGraph,
16
+ type PresenceChild,
17
+ type PresenceController,
18
+ type PresenceMode,
19
+ type PresenceState,
20
+ type ResolvedTargetValue,
21
+ type ResolvedValue,
22
+ type Target,
23
+ type Transition,
24
+ TARGET_PROPERTY_KEYS,
25
+ } from '@unrulysystems/native-motion-core'
26
+ import { resolveTransitionForKey } from '@unrulysystems/native-motion-core/internal-driver'
27
+ import { materializeTargetArrays, validateAgainstNativeHost } from './motionViewController'
28
+ import { NATIVE_PROPERTY_TRANSITION_OPTIONS, exitLaneInexecutableOption } from './shippedSurface'
29
+ import { keyframeTimingRefusal } from './keyframeTiming'
30
+
31
+ export type PresenceLiveValues = Readonly<Record<string, ResolvedValue>>
32
+
33
+ // Host-RESOLVED px endpoints for measure-length exit arrays (review 2f3a8e6c1d90), captured at the
34
+ // removal boundary from the child's own layout context — the same resolution the property lane
35
+ // performs. Mirrors core's PresenceResolvedExits structurally (the presence barrel does not export
36
+ // it, same as PresenceLiveValues above).
37
+ export type PresenceResolvedExits = Readonly<Record<string, readonly (number | null)[]>>
38
+
39
+ const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
40
+
41
+ function transitionMapSourceKey(transition: Transition, key: string): string {
42
+ const source = transition as Record<PropertyKey, unknown>
43
+ if (
44
+ TRANSITION_MAP_KEYS.has(key) &&
45
+ Object.hasOwn(source, key) &&
46
+ source[key] !== undefined &&
47
+ source[key] !== null
48
+ )
49
+ return key
50
+ if (Object.hasOwn(source, 'default') && source.default !== undefined && source.default !== null)
51
+ return 'default'
52
+ return 'root'
53
+ }
54
+
55
+ function hasTransitionMap(transition: Transition): boolean {
56
+ return Reflect.ownKeys(transition).some(
57
+ (key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
58
+ )
59
+ }
60
+
61
+ export interface PresenceBindingOptions {
62
+ readonly graph: MotionGraph
63
+ // Passed through verbatim — popLayout shares core's sync ordering; native geometry composition
64
+ // is applied by the presence renderer around the retained child.
65
+ readonly mode?: PresenceMode
66
+ readonly initial?: boolean
67
+ readonly onExitComplete?: () => void
68
+ }
69
+
70
+ // The controller surface the container consumes, plus the binding-level guards. Mirrors the
71
+ // controller names 1:1 so nothing is re-invented (REQ-PRESENCE-010 altitude).
72
+ export interface PresenceBinding {
73
+ // Boundary-guarded commit: validates every child's exit target through core FIRST
74
+ // (InvalidTargetError for unknown/off-host keys), then enforces exit-keys ⊆ animate-keys (an
75
+ // un-mounted exit key has no shared value at the MotionView altitude — fail loud HERE, never late
76
+ // in the driver), then hands the set to the controller. `resolvedExitsByKey` carries the host's
77
+ // px-resolved endpoints for measure-length exit arrays (review 2f3a8e6c1d90).
78
+ sync(
79
+ children: readonly PresenceChild[],
80
+ liveValuesByKey?: ReadonlyMap<string, PresenceLiveValues>,
81
+ resolvedExitsByKey?: ReadonlyMap<string, PresenceResolvedExits>,
82
+ ): void
83
+ mounted(): readonly string[]
84
+ stateOf(key: string): PresenceState
85
+ isPresent(key: string): boolean
86
+ safeToRemove(key: string): void
87
+ // Mid-exit manual-authority adoption (REQ-PRESENCE-001/013/014): a consumer registering into a
88
+ // live exit episode upgrades the retained controller record — see PresenceController.deferExit.
89
+ deferExit(key: string): void
90
+ resolvedExitTarget(key: string): Readonly<Record<string, ResolvedTargetValue>> | undefined
91
+ enterSuppressed(key: string): boolean
92
+ exitProgress(key: string): number | undefined
93
+ // True when the graph has no in-flight presence work — the container's stepper stop signal.
94
+ idle(): boolean
95
+ // Exposed for the container/checks; never re-wrapped.
96
+ readonly controller: PresenceController
97
+ }
98
+
99
+ // Boundary guard for one child, run BEFORE any controller mutation so an invalid commit never
100
+ // partially applies (fail-closed, REQ-API-013 altitude). Ordering is the contract (M2.2 review
101
+ // lessons): core validation FIRST — an unknown/off-host key in animate OR exit throws the pipeline's
102
+ // InvalidTargetError — then the spike's exit-keys ⊆ animate-keys guard (an un-mounted exit key has
103
+ // no shared value at the MotionView altitude; letting it through dies late in the driver).
104
+ /**
105
+ * The exit lane's transition law, exported for the container's severity gate (r11 major
106
+ * 1c3f4f0b4b77): core validateTransition plus the exit-lane vocabulary — the presence exit
107
+ * generator never reads delay or velocity; explicitly-undefined options read as absent
108
+ * (core semantics, r10 major 6ac18d94e2f7). Loud by design; severity callers wrap it.
109
+ */
110
+ export function exitLaneTransitionRefusal(
111
+ transition: PresenceChild['transition'],
112
+ componentId: string,
113
+ propertyKeys?: readonly string[],
114
+ ): Error | null {
115
+ if (transition === undefined) return null
116
+ const coreRefusal = validateTransitionRefusal(transition, { componentId })
117
+ if (coreRefusal !== null) return coreRefusal
118
+ if (hasTransitionMap(transition)) {
119
+ // Generic core validation above checks every authored branch's shape and ranges. The exit lane's
120
+ // executable vocabulary is narrower, so apply it only after the actual exit properties select a
121
+ // branch; unused root/default/named bags cannot reject a reachable exit.
122
+ if (propertyKeys === undefined || propertyKeys.length === 0) return null
123
+ const source = transition as Transition
124
+ const seen = new Set<string>()
125
+ for (const key of propertyKeys) {
126
+ const sourceKey = transitionMapSourceKey(source, key)
127
+ if (seen.has(sourceKey)) continue
128
+ seen.add(sourceKey)
129
+ const refusal = exitLaneTransitionRefusal(resolveTransitionForKey(source, key), componentId)
130
+ if (refusal !== null) return refusal
131
+ }
132
+ return null
133
+ }
134
+ // The exit generator's executable vocabulary is defined over every authored own field, not just
135
+ // enumerable ones. Core has already closed symbols/unknowns; inspect all retained string keys here
136
+ // so a non-enumerable lane-inert option cannot survive into the exit progress factory.
137
+ for (const option of Reflect.ownKeys(transition)) {
138
+ if (typeof option !== 'string') continue
139
+ const value = (transition as Record<string, unknown>)[option]
140
+ if (value === undefined) continue
141
+ // U7a: the authored instant lane (`type: false`) is property-lane only. The exit progress
142
+ // factory advances a 0→1 progress curve, not a per-value animation, so `type: false` would
143
+ // ride along as a default-duration tween — the silently-inert class this gate exists to
144
+ // refuse. Demand-gated divergence, same shape as the repeat family below.
145
+ if (option === 'type' && value === false) {
146
+ return new InvalidTransitionError(
147
+ componentId,
148
+ 'type',
149
+ false,
150
+ 'the exit lane cannot execute type: false — the exit generator advances a PROGRESS ' +
151
+ 'factory, not a per-value animation, so the authored instant lane never reaches it ' +
152
+ '(U7a / G-INV-8). Instant exit is a scoped successor rung; drive it on the property ' +
153
+ 'lane instead.',
154
+ )
155
+ }
156
+ if (exitLaneInexecutableOption(option)) {
157
+ return new InvalidTransitionError(
158
+ componentId,
159
+ option,
160
+ value,
161
+ `is not executable by the exit lane — the exit generator advances a PROGRESS factory, ` +
162
+ `not a sampled generator, so it never reads '${option}' and the option would ride ` +
163
+ 'along inert (G-INV-8). This family on the presence exit lane is a scoped successor ' +
164
+ `rung; drive it on the property lane instead (T18-a repeat, T18-b delay).`,
165
+ )
166
+ }
167
+ if (!NATIVE_PROPERTY_TRANSITION_OPTIONS.has(option)) {
168
+ return new InvalidTransitionError(
169
+ componentId,
170
+ option,
171
+ value,
172
+ `not executable by the exit lane (it drives: ` +
173
+ `${NATIVE_PROPERTY_TRANSITION_OPTIONS.options.join(', ')}) — a silently inert ` +
174
+ 'option is refused (G-INV-8, shippedSurface.ts)',
175
+ )
176
+ }
177
+ }
178
+ return null
179
+ }
180
+
181
+ export function validateExitLaneTransition(
182
+ transition: PresenceChild['transition'],
183
+ componentId: string,
184
+ propertyKeys?: readonly string[],
185
+ ): void {
186
+ const refusal = exitLaneTransitionRefusal(transition, componentId, propertyKeys)
187
+ if (refusal !== null) throw refusal
188
+ }
189
+
190
+ // R8-F1 for the exit/animate arrays under a VALID exit-lane spring (review major 46 — the direct-
191
+ // consumer backstop the public container gate mirrors): a spring interpolates exactly two keyframes.
192
+ // Runs only AFTER exitLaneTransitionRefusal passed, so an invalid transition (falls back to default)
193
+ // never triggers it. Loud by design — a container consumer refuses/reports before reaching here.
194
+ function assertExitArraySpringCompat(child: PresenceChild): void {
195
+ for (const target of [child.animate, child.exit]) {
196
+ if (target === undefined) continue
197
+ for (const [key, value] of Object.entries(target)) {
198
+ if (!Array.isArray(value)) continue
199
+ const transition =
200
+ child.transition === undefined
201
+ ? undefined
202
+ : resolveTransitionForKey(child.transition as Transition, key)
203
+ if (transition?.type !== 'spring') continue
204
+ const refusal = springKeyframeCountRefusal(value.length)
205
+ if (refusal !== null) throw refusal
206
+ }
207
+ }
208
+ }
209
+
210
+ // R8 M2-B (REQ-API-033 law d): the direct-consumer keyframe-timing backstop (mirrors the spring one
211
+ // above, review major 46). It validates offset/easing shapes for directly-`sync`'d children; count-
212
+ // mismatched `times` fall back to even offsets, missing segment easings are linear, extras are
213
+ // ignored, and keyframe-only timing still refuses as inert on a scalar member.
214
+ // The public container's exit/animate lanes route through gateTargetWithSeverity (keyBoundaryRefusal),
215
+ // so this covers only the DIRECT `createPresenceBinding` consumer. Loud by design (after the transition
216
+ // validated), per key.
217
+ function assertExitArrayKeyframeTiming(child: PresenceChild): void {
218
+ for (const target of [child.animate, child.exit]) {
219
+ if (target === undefined) continue
220
+ for (const [key, value] of Object.entries(target)) {
221
+ const transition =
222
+ child.transition === undefined
223
+ ? undefined
224
+ : resolveTransitionForKey(child.transition as Transition, key)
225
+ const refusal = keyframeTimingRefusal(
226
+ `<AnimatePresence child '${child.key}'>`,
227
+ key,
228
+ value,
229
+ transition,
230
+ )
231
+ if (refusal !== null) throw refusal
232
+ }
233
+ }
234
+ }
235
+
236
+ function guardChild(
237
+ child: PresenceChild,
238
+ consumesTransition: boolean,
239
+ reenteringActiveExit = false,
240
+ ): void {
241
+ // The captured transition seeds the presence graph's exit progress factory (r9 major
242
+ // 4fe88c31a121) — consumed when the child carries an exit target OR re-enters an ACTIVE
243
+ // exit episode (r11 major 6f0d2b6721ad: re-entry hands the incoming transition to
244
+ // progressFactory regardless of the current exit prop). Outside those cases presence
245
+ // never consumes it (r10 major b2e74f1309ac) and the child's own MotionView gate owns it.
246
+ if (consumesTransition) {
247
+ const consumedKeys = reenteringActiveExit
248
+ ? [...new Set([...Object.keys(child.animate ?? {}), ...Object.keys(child.exit ?? {})])]
249
+ : Object.keys(child.exit ?? {})
250
+ validateExitLaneTransition(child.transition, `<AnimatePresence child '${child.key}'>`, [
251
+ ...consumedKeys,
252
+ ])
253
+ }
254
+ if (child.animate !== undefined) validateAgainstNativeHost(child.animate)
255
+ if (child.exit !== undefined) {
256
+ validateAgainstNativeHost(child.exit)
257
+ // R7 law g (M2 r2 major 18): the mounted set is the UNION of the animate keys and every
258
+ // dictionary-named key the container derived — a dictionary key has a shared value even
259
+ // when no animate target names it. The backstop stays fail-closed outside the union.
260
+ const mountedKeys = new Set([...Object.keys(child.animate ?? {}), ...(child.mountedKeys ?? [])])
261
+ for (const key of Object.keys(child.exit)) {
262
+ if (!mountedKeys.has(key)) {
263
+ throw new Error(
264
+ `M2.3 scope: exit key '${key}' on child '${child.key}' was never mounted ` +
265
+ `(mounted keys: ${[...mountedKeys].join(', ') || 'none'}) — declare it in animate ` +
266
+ 'or the variants dictionary so a shared value exists ' +
267
+ '(specs/M2.3-BUILD-PACKET.md §semantics 6; REQ-API-032 law g).',
268
+ )
269
+ }
270
+ }
271
+ }
272
+ // R8-F1 backstop LAST (review major 46/58): the direct-consumer spring/keyframe-array compat check
273
+ // runs only AFTER core validated the transition AND the target SHAPE — a malformed target (e.g.
274
+ // `exit:{x:[]}`) throws its TYPED InvalidTargetError first (validation-first contract), never the
275
+ // generic cross-field spring error. An invalid transition already threw above; a well-formed array is
276
+ // all that reaches here.
277
+ if (consumesTransition) {
278
+ assertExitArraySpringCompat(child)
279
+ assertExitArrayKeyframeTiming(child)
280
+ }
281
+ }
282
+
283
+ // Hand the controller an IMMUTABLE snapshot of every keyframe array + the transition (review major 64):
284
+ // guardChild validates the caller-owned child, but `beginExit` RE-reads its arrays during exit-target
285
+ // resolution — an accessor-backed `[0,100]` returning `100` to the validator and `null` on the next read
286
+ // would forward `[0,null]` into retained state. The child is validated by the time it reaches here, so
287
+ // the snapshot mirrors exactly what validation saw (one-read / one-truth, the major-30/40 lineage).
288
+ function materializeChild(child: PresenceChild): PresenceChild {
289
+ const { animate, exit, transition, ...rest } = child
290
+ return {
291
+ ...rest,
292
+ ...(animate !== undefined ? { animate: materializeIfPlain(animate) } : {}),
293
+ ...(exit !== undefined ? { exit: materializeIfPlain(exit) } : {}),
294
+ ...(transition !== undefined ? { transition: captureTransitionIfPlain(transition) } : {}),
295
+ }
296
+ }
297
+
298
+ // Materialize a plain-object target's keyframe arrays; a MALFORMED target (null, primitive, array, or a
299
+ // PROTOTYPE-carrying object like `Date`/a class instance) passes through UNTOUCHED so guardChild's core
300
+ // validation throws the TYPED InvalidTargetError (materialize runs BEFORE validation for the one-read, so
301
+ // it must never crash on — nor LAUNDER into an empty record — the input validation is meant to reject).
302
+ function materializeIfPlain(target: Target): Target {
303
+ if (typeof target !== 'object' || target === null || Array.isArray(target)) return target
304
+ const proto = Object.getPrototypeOf(target) as unknown
305
+ if (proto !== Object.prototype && proto !== null) return target
306
+ return materializeTargetArrays(target)
307
+ }
308
+
309
+ // Capture the transition ONCE (review major 64): guardChild's validateExitLaneTransition + the controller's
310
+ // beginExit both read it, so a caller-owned accessor `duration` would be observed repeatedly and could
311
+ // change after sync. A MALFORMED shape (null, primitive, array, prototype-carrying) passes through so
312
+ // validateExitLaneTransition throws the TYPED refusal — the null-proto capture keeps an own `__proto__`.
313
+ function captureTransitionIfPlain(
314
+ transition: NonNullable<PresenceChild['transition']>,
315
+ ): NonNullable<PresenceChild['transition']> {
316
+ const t = transition as unknown
317
+ const proto = typeof t === 'object' && t !== null ? Object.getPrototypeOf(t) : undefined
318
+ if (
319
+ typeof t !== 'object' ||
320
+ t === null ||
321
+ Array.isArray(t) ||
322
+ (proto !== Object.prototype && proto !== null)
323
+ ) {
324
+ return transition
325
+ }
326
+ return captureTransition(transition)
327
+ }
328
+
329
+ export function createPresenceBinding(options: PresenceBindingOptions): PresenceBinding {
330
+ // Mode passes through verbatim: popLayout's geometry behavior is owned by the native presence
331
+ // renderer while the controller retains the same keyed lifecycle ordering.
332
+ const controller = createPresenceController({
333
+ graph: options.graph,
334
+ ...(options.mode === undefined ? {} : { mode: options.mode }),
335
+ ...(options.initial === undefined ? {} : { initial: options.initial }),
336
+ ...(options.onExitComplete === undefined ? {} : { onExitComplete: options.onExitComplete }),
337
+ })
338
+
339
+ return {
340
+ sync(children, liveValuesByKey, resolvedExitsByKey) {
341
+ // Guard EVERY child before the controller sees ANY of them — no partial commits. The
342
+ // consumption predicate reads PRE-sync state: a key currently exiting consumes the
343
+ // incoming transition on re-entry (r11 major 6f0d2b6721ad).
344
+ const mounted = controller.mountedKeys()
345
+ // Materialize each child into an IMMUTABLE snapshot FIRST, then validate + forward THAT (review
346
+ // major 64): validating the caller-owned child before snapshotting reads its accessor arrays twice
347
+ // (validation, then materialization), so the value validated could differ from the value retained.
348
+ // The snapshot is the ONE truth guardChild validates AND the controller resolves.
349
+ const snapshots = children.map(materializeChild)
350
+ for (const child of snapshots) {
351
+ const reenteringActiveExit =
352
+ mounted.includes(child.key) && controller.stateOf(child.key) === 'exiting'
353
+ guardChild(child, child.exit !== undefined || reenteringActiveExit, reenteringActiveExit)
354
+ }
355
+ const capturedLiveValues = new Map<string, PresenceLiveValues>()
356
+ for (const [key, values] of liveValuesByKey ?? []) {
357
+ capturedLiveValues.set(key, Object.freeze({ ...values }))
358
+ }
359
+ // The same one-read capture for host-resolved endpoints (review 2f3a8e6c1d90): clone + freeze
360
+ // every endpoint array so a caller-side mutation cannot rewrite the retained measurement.
361
+ const capturedResolvedExits = new Map<string, PresenceResolvedExits>()
362
+ for (const [key, exits] of resolvedExitsByKey ?? []) {
363
+ const frozen: Record<string, readonly (number | null)[]> = {}
364
+ for (const [property, endpoints] of Object.entries(exits)) {
365
+ frozen[property] = Object.freeze([...endpoints])
366
+ }
367
+ capturedResolvedExits.set(key, Object.freeze(frozen))
368
+ }
369
+ controller.syncChildren(snapshots, capturedLiveValues, capturedResolvedExits)
370
+ },
371
+ mounted: () => controller.mountedKeys(),
372
+ stateOf: (key) => controller.stateOf(key),
373
+ isPresent: (key) => controller.isPresent(key),
374
+ safeToRemove: (key) => controller.safeToRemove(key),
375
+ deferExit: (key) => controller.deferExit(key),
376
+ resolvedExitTarget: (key) => controller.resolvedExitTarget(key),
377
+ enterSuppressed: (key) => controller.enterSuppressed(key),
378
+ exitProgress: (key) => controller.exitProgress(key),
379
+ idle: () => options.graph.isSettled(),
380
+ controller,
381
+ }
382
+ }
@@ -0,0 +1,123 @@
1
+ // UI-global user scale-corrector map (REQ-LAYOUT-024). Named module-level worklet + primitive
2
+ // payloads only (AB-BA hang-class: no worklet-capturing-worklet). The correct function is
3
+ // passed as a worklet argument, never closed over.
4
+
5
+ import type { ScaleCorrector } from './addScaleCorrector'
6
+
7
+ interface MutableScaleCorrectionContext {
8
+ targetDelta: { x: { scale: number }; y: { scale: number } }
9
+ treeScale: { x: number; y: number }
10
+ }
11
+
12
+ interface InstalledCorrector {
13
+ readonly correct: ScaleCorrector
14
+ readonly applyTo: readonly string[] | null
15
+ }
16
+
17
+ interface ScaleCorrectorRegistryUiGlobal {
18
+ __nativeMotionScaleCorrectors?: Record<string, InstalledCorrector>
19
+ }
20
+
21
+ function scaleCorrectorRegistry(): NonNullable<
22
+ ScaleCorrectorRegistryUiGlobal['__nativeMotionScaleCorrectors']
23
+ > {
24
+ 'worklet'
25
+ const global = globalThis as ScaleCorrectorRegistryUiGlobal
26
+ if (global.__nativeMotionScaleCorrectors === undefined) {
27
+ global.__nativeMotionScaleCorrectors = {}
28
+ }
29
+ return global.__nativeMotionScaleCorrectors
30
+ }
31
+
32
+ export function installScaleCorrectorOnUI(
33
+ key: string,
34
+ correct: ScaleCorrector,
35
+ applyTo: readonly string[] | null,
36
+ ): void {
37
+ 'worklet'
38
+ scaleCorrectorRegistry()[key] = { correct, applyTo }
39
+ }
40
+
41
+ // One reused context object (REQ-DRIVER-021): correct() reads it synchronously, so per-call
42
+ // field mutation is safe. A fresh object per frame would break that law.
43
+ function scaleCorrectionContext(): MutableScaleCorrectionContext {
44
+ 'worklet'
45
+ const global = globalThis as ScaleCorrectorRegistryUiGlobal & {
46
+ __nativeMotionScaleCorrectionContext?: MutableScaleCorrectionContext
47
+ }
48
+ if (global.__nativeMotionScaleCorrectionContext === undefined) {
49
+ global.__nativeMotionScaleCorrectionContext = {
50
+ targetDelta: { x: { scale: 1 }, y: { scale: 1 } },
51
+ treeScale: { x: 1, y: 1 },
52
+ }
53
+ }
54
+ return global.__nativeMotionScaleCorrectionContext
55
+ }
56
+
57
+ /**
58
+ * Apply a registered corrector to one authored latest. `transformActive` is the pin's
59
+ * `transform !== "none"` gate. Missing corrector or inactive transform returns `latest`.
60
+ */
61
+ export function applyInstalledScaleCorrector(
62
+ key: string,
63
+ latest: number | string,
64
+ targetDeltaScaleX: number,
65
+ targetDeltaScaleY: number,
66
+ treeScaleX: number,
67
+ treeScaleY: number,
68
+ transformActive: boolean,
69
+ ): number | string {
70
+ 'worklet'
71
+ if (!transformActive) return latest
72
+ const installed = scaleCorrectorRegistry()[key]
73
+ if (installed === undefined) return latest
74
+ const context = scaleCorrectionContext()
75
+ context.targetDelta.x.scale = targetDeltaScaleX
76
+ context.targetDelta.y.scale = targetDeltaScaleY
77
+ context.treeScale.x = treeScaleX
78
+ context.treeScale.y = treeScaleY
79
+ return installed.correct(latest, context)
80
+ }
81
+
82
+ /**
83
+ * Pin create-projection-node.ts:2102-2106 — when `applyTo` is set, write the
84
+ * corrected value onto those keys and skip the source key; otherwise write source.
85
+ */
86
+ export function writeInstalledScaleCorrector(
87
+ out: Record<string, unknown>,
88
+ key: string,
89
+ latest: number | string,
90
+ targetDeltaScaleX: number,
91
+ targetDeltaScaleY: number,
92
+ treeScaleX: number,
93
+ treeScaleY: number,
94
+ transformActive: boolean,
95
+ ): void {
96
+ 'worklet'
97
+ const corrected = applyInstalledScaleCorrector(
98
+ key,
99
+ latest,
100
+ targetDeltaScaleX,
101
+ targetDeltaScaleY,
102
+ treeScaleX,
103
+ treeScaleY,
104
+ transformActive,
105
+ )
106
+ const applyTo = scaleCorrectorRegistry()[key]?.applyTo ?? null
107
+ if (applyTo !== null) {
108
+ for (let i = 0; i < applyTo.length; i++) {
109
+ out[applyTo[i]!] = corrected
110
+ }
111
+ return
112
+ }
113
+ out[key] = corrected
114
+ }
115
+
116
+ export function resetScaleCorrectorRegistryForTests(): void {
117
+ delete (globalThis as ScaleCorrectorRegistryUiGlobal).__nativeMotionScaleCorrectors
118
+ delete (
119
+ globalThis as ScaleCorrectorRegistryUiGlobal & {
120
+ __nativeMotionScaleCorrectionContext?: unknown
121
+ }
122
+ ).__nativeMotionScaleCorrectionContext
123
+ }