@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,260 @@
1
+ // REQ-API-037 — pure scroll-fed value helpers (pin setProgress / createScrollUpdater law).
2
+ // RN-free so node-side probes and the hook share one authority: progress is offset / maxOffset
3
+ // (zero range → 0), and applying offsets writes the four motion values without a React render.
4
+
5
+ import { describeValue, type MotionValue } from '@unrulysystems/native-motion-core'
6
+
7
+ export const USE_SCROLL_OWNER = 'useScroll' as const
8
+
9
+ export interface ScrollMotionValues {
10
+ readonly scrollX: MotionValue<number>
11
+ readonly scrollY: MotionValue<number>
12
+ readonly scrollXProgress: MotionValue<number>
13
+ readonly scrollYProgress: MotionValue<number>
14
+ }
15
+
16
+ export interface ScrollOffsets {
17
+ readonly xOffset: number
18
+ readonly yOffset: number
19
+ readonly xMaxOffset: number
20
+ readonly yMaxOffset: number
21
+ }
22
+
23
+ /** Pin setProgress: zero offset OR zero max → 0; else offset / maxOffset (0→1). */
24
+ export function scrollProgress(offset: number, maxOffset: number): number {
25
+ return !offset || !maxOffset ? 0 : offset / maxOffset
26
+ }
27
+
28
+ export function applyScrollOffsets(values: ScrollMotionValues, offsets: ScrollOffsets): void {
29
+ values.scrollX.set(offsets.xOffset)
30
+ values.scrollY.set(offsets.yOffset)
31
+ values.scrollXProgress.set(scrollProgress(offsets.xOffset, offsets.xMaxOffset))
32
+ values.scrollYProgress.set(scrollProgress(offsets.yOffset, offsets.yMaxOffset))
33
+ }
34
+
35
+ export type ScrollSourceKind = 'missing' | 'pending' | 'non-scrollable' | 'scrollable'
36
+
37
+ /**
38
+ * Duck-type a hydrated host as scrollable: RN ScrollView exposes `scrollTo`; animated refs
39
+ * expose `getScrollableNode` / native tags. A plain View has none of these.
40
+ */
41
+ export function classifyScrollSource(node: unknown): ScrollSourceKind {
42
+ if (node === null || node === undefined) return 'missing'
43
+ if (typeof node !== 'object') return 'non-scrollable'
44
+ const candidate = node as Record<string, unknown>
45
+ if (typeof candidate.scrollTo === 'function') return 'scrollable'
46
+ if (typeof candidate.getScrollableNode === 'function') return 'scrollable'
47
+ if (typeof candidate.scrollToOffset === 'function') return 'scrollable'
48
+ // AnimatedRef current often wraps the host; accept observe-capable refs at the ref level
49
+ // (checked separately) and treat a host with native tag methods as scrollable.
50
+ if (typeof candidate.getNativeScrollRef === 'function') return 'scrollable'
51
+ return 'non-scrollable'
52
+ }
53
+
54
+ export interface UseScrollOptions {
55
+ readonly container?: { readonly current: unknown } | null | undefined
56
+ /**
57
+ * Pin intersection-target progress. Native refuses this option this rung (deferred
58
+ * capability) — never silently ignored. Web forwards to the pin.
59
+ */
60
+ readonly target?: { readonly current: unknown } | null | undefined
61
+ readonly axis?: 'x' | 'y' | undefined
62
+ /**
63
+ * Pin scroll-offset intersection list. Native refuses this option this rung (deferred
64
+ * capability — container progress is always offset/maxOffset). Web forwards to the pin.
65
+ */
66
+ readonly offset?: readonly unknown[] | undefined
67
+ }
68
+
69
+ const ACCEPTED_OPTION_KEYS = new Set(['container', 'target', 'axis', 'offset'])
70
+
71
+ export interface ScrollGate {
72
+ readonly severity: 'development' | 'production'
73
+ readonly report: (error: Error) => void
74
+ }
75
+
76
+ export function refuseScroll(gate: ScrollGate, message: string): void {
77
+ const error = new Error(message)
78
+ if (gate.severity === 'development') throw error
79
+ gate.report(error)
80
+ }
81
+
82
+ /**
83
+ * Native entry-gate for useScroll options. Pin vocabulary keys are accepted at the key
84
+ * level so unknown keys still fail loud; `target` / `offset` are then refused as deferred
85
+ * capabilities (never silently no-op — major c4a8f2e91b03). Web has its own gate and
86
+ * forwards both to the pin.
87
+ */
88
+ export function gateUseScrollOptions(
89
+ options: Readonly<Record<string, unknown>> | undefined,
90
+ gate: ScrollGate,
91
+ ): UseScrollOptions {
92
+ if (options === undefined || options === null) return {}
93
+ if (typeof options !== 'object' || Array.isArray(options)) {
94
+ refuseScroll(
95
+ gate,
96
+ `${USE_SCROLL_OWNER}: options must be a plain object (REQ-API-037) — got ${describeValue(options)}.`,
97
+ )
98
+ return {}
99
+ }
100
+ for (const key of Object.keys(options)) {
101
+ if (options[key] === undefined) continue
102
+ if (!ACCEPTED_OPTION_KEYS.has(key)) {
103
+ refuseScroll(
104
+ gate,
105
+ `${USE_SCROLL_OWNER}: option '${key}' is outside the contracted useScroll surface ` +
106
+ '(REQ-API-037: container/target/axis/offset) — it fails loud rather than silently doing nothing.',
107
+ )
108
+ }
109
+ }
110
+ // Deferred on native: intersection-target progress and pin offset ranges. Refusing here
111
+ // (not stripping) is the fail-loud law — deleting these refuses must fail the suite.
112
+ if (options.target !== undefined) {
113
+ refuseScroll(
114
+ gate,
115
+ `${USE_SCROLL_OWNER}: option 'target' (intersection-target progress) is a deferred ` +
116
+ 'capability on the native entry — not implemented this rung (REQ-API-037). Use ' +
117
+ 'container-only scroll progress, or the web entry which forwards target to the pin.',
118
+ )
119
+ }
120
+ if (options.offset !== undefined) {
121
+ refuseScroll(
122
+ gate,
123
+ `${USE_SCROLL_OWNER}: option 'offset' (scroll-offset ranges) is a deferred capability ` +
124
+ 'on the native entry — not implemented this rung (REQ-API-037). Native container ' +
125
+ 'progress is always offset/maxOffset; the web entry forwards offset to the pin.',
126
+ )
127
+ }
128
+ const axis = options.axis
129
+ if (axis !== undefined && axis !== 'x' && axis !== 'y') {
130
+ refuseScroll(
131
+ gate,
132
+ `${USE_SCROLL_OWNER}: invalid axis = ${describeValue(axis)} — expected "x" | "y" (REQ-API-037).`,
133
+ )
134
+ }
135
+ return {
136
+ ...(options.container !== undefined
137
+ ? { container: options.container as UseScrollOptions['container'] }
138
+ : {}),
139
+ ...(axis === 'x' || axis === 'y' ? { axis } : {}),
140
+ }
141
+ }
142
+
143
+ /**
144
+ * A container can bind on the UI thread only when the ref is a reanimated animated ref
145
+ * (`useAnimatedRef`) that exposes `observe` for native-tag registration.
146
+ */
147
+ export function isBindableScrollContainer(container: unknown): boolean {
148
+ // useAnimatedRef is a callback function with `.observe` / `.current` — typeof is
149
+ // 'function', not 'object'. Rejecting non-objects made every real animated ref
150
+ // look like a plain ScrollView host (U7i device: unbindable refuse, y=0).
151
+ if (container === null || container === undefined) return false
152
+ if (typeof container !== 'object' && typeof container !== 'function') return false
153
+ return typeof (container as { observe?: unknown }).observe === 'function'
154
+ }
155
+
156
+ /**
157
+ * Resolve whether the call has a feedable scroll source. Native has no window viewport, so a
158
+ * call without a container is missing. A defined ref with null current is pending (hydrate later);
159
+ * a hydrated non-scrollable fails loud. Bindability (`observe`) is checked separately so the
160
+ * pure source classification stays host-shape only.
161
+ */
162
+ export function resolveNativeScrollSource(
163
+ options: UseScrollOptions,
164
+ gate: ScrollGate,
165
+ ): { readonly kind: ScrollSourceKind; readonly node: unknown } {
166
+ const container = options.container
167
+ if (container === null || container === undefined) {
168
+ refuseScroll(
169
+ gate,
170
+ `${USE_SCROLL_OWNER}: native entry requires a container ref to a scrollable source ` +
171
+ '(there is no window viewport analog; REQ-API-037) — pass { container: scrollRef }.',
172
+ )
173
+ return { kind: 'missing', node: null }
174
+ }
175
+ const node = container.current
176
+ if (node === null || node === undefined) {
177
+ return { kind: 'pending', node: null }
178
+ }
179
+ const kind = classifyScrollSource(node)
180
+ if (kind === 'non-scrollable') {
181
+ refuseScroll(
182
+ gate,
183
+ `${USE_SCROLL_OWNER}: container.current is not a scrollable source ` +
184
+ `(got ${describeValue(node)}; expected a ScrollView / scrollable host — REQ-API-037).`,
185
+ )
186
+ }
187
+ return { kind, node }
188
+ }
189
+
190
+ /** Owned refusal when a hydrated container cannot register UI-thread scroll events. */
191
+ export function refuseUnbindableScrollContainer(gate: ScrollGate): void {
192
+ refuseScroll(
193
+ gate,
194
+ `${USE_SCROLL_OWNER}: container must be a react-native-reanimated useAnimatedRef ` +
195
+ '(exposes observe) so scroll binds on the UI thread — a plain ScrollView / useRef cannot ' +
196
+ 'register native scroll events (REQ-API-037). Pass useAnimatedRef(ScrollView).',
197
+ )
198
+ }
199
+
200
+ /** Pin-equivalent: container ref defined but never hydrated after the settle window. */
201
+ export function refuseUnhydratedScrollContainer(gate: ScrollGate): void {
202
+ refuseScroll(
203
+ gate,
204
+ `${USE_SCROLL_OWNER}: container ref is defined but not hydrated (pin use-scroll-ref; ` +
205
+ 'REQ-API-037) — attach the ref to a mounted scrollable host before useScroll settles.',
206
+ )
207
+ }
208
+
209
+ export type AnimatedScrollContainer = {
210
+ readonly current: unknown
211
+ observe?: (callback: (tag: number | null) => (() => void) | void) => () => void
212
+ }
213
+
214
+ /**
215
+ * Pin-equivalent settle for a provided container ref: unhydrated → refuse; hydrated without
216
+ * observe → refuse; observe → register via onTag. Pure so unit probes delete the refusal and
217
+ * go red without a React act AggregateError.
218
+ */
219
+ export function settleNativeScrollBinding(
220
+ container: AnimatedScrollContainer,
221
+ gate: ScrollGate,
222
+ onTag: (tag: number) => (() => void) | void,
223
+ ): (() => void) | undefined {
224
+ // Bindable animated refs observe-wait even while current is null. useAnimatedRef.observe
225
+ // fires immediately with a null tag, then re-invokes once the host attaches. Refusing
226
+ // on that first null (or on a one-microtask window) drops the subscription before the
227
+ // tag exists, so device onScroll never reaches the value channel.
228
+ if (isBindableScrollContainer(container)) {
229
+ return container.observe!((tag) => {
230
+ if (tag === null || tag === undefined) return
231
+ const node = container.current
232
+ if (node === null || node === undefined) return
233
+ const kind = classifyScrollSource(node)
234
+ if (kind === 'non-scrollable') {
235
+ refuseScroll(
236
+ gate,
237
+ `${USE_SCROLL_OWNER}: container.current is not a scrollable source ` +
238
+ `(got ${describeValue(node)}; expected a ScrollView / scrollable host — REQ-API-037).`,
239
+ )
240
+ return
241
+ }
242
+ return onTag(tag)
243
+ })
244
+ }
245
+ if (container.current === null || container.current === undefined) {
246
+ refuseUnhydratedScrollContainer(gate)
247
+ return undefined
248
+ }
249
+ const kind = classifyScrollSource(container.current)
250
+ if (kind === 'non-scrollable') {
251
+ refuseScroll(
252
+ gate,
253
+ `${USE_SCROLL_OWNER}: container.current is not a scrollable source ` +
254
+ `(got ${describeValue(container.current)}; expected a ScrollView / scrollable host — REQ-API-037).`,
255
+ )
256
+ return undefined
257
+ }
258
+ refuseUnbindableScrollContainer(gate)
259
+ return undefined
260
+ }
@@ -0,0 +1,151 @@
1
+ // The layout-animation crossing's payload law (iOS AUTO SIGABRT wedge investigation). Worklets'
2
+ // SerializableObject::toJSValue recurses with NO cycle guard and NO memoization: a cyclic or
3
+ // effectively unbounded record captured into a produced worklet does not fail at schedule time —
4
+ // it pegs the main thread forever inside materialization (the soak's wedge stack:
5
+ // SerializableObject::toJSValue recursing on the main-thread UI scheduler, process alive). Every
6
+ // record handed to a produced worklet's closure must therefore be a FINITE, ACYCLIC graph of
7
+ // primitives, plain records, arrays, Maps/Sets, and {value} cells — this walker is the boundary
8
+ // assertion, run at the factory edge so a violating payload fails loud at the SOURCE (dev:
9
+ // throw; prod: report + degrade) instead of pegging the UI scheduler two minutes into a walk.
10
+
11
+ // Budgets sit orders of magnitude above the real produced payloads (tens of nodes) and orders
12
+ // below anything that could keep a materialization busy: a payload past either bound is a bug,
13
+ // never a legitimate config. The node budget counts EVERY visited entry (seal r12, F1 —
14
+ // primitives included; seal r13, F1 — terminal functions and opaque branded leaves included):
15
+ // Worklets' serializer recurses every container entry exactly like this walker, so a flat
16
+ // container of 5,000 primitives, functions, or branded leaves is as unbounded as a deep graph.
17
+ const MAX_PAYLOAD_NODES = 4096
18
+ const MAX_PAYLOAD_DEPTH = 128
19
+
20
+ // Genuinely branded host/shareable values — the ONLY payloads the worklets serializer passes
21
+ // through without recursive descent, with its predicates matched EXACTLY (seal r5 F1, r6 F2):
22
+ // - host objects and TurboModule-like objects carry the REANIMATED_MAGIC_KEY brand
23
+ // (serializable.native.ts:26-33 isHostObject / :57-60 isTurboModuleLike);
24
+ // - __serializableRef === true — strict true (:35-45 isSerializableRef);
25
+ // - __synchronizableRef — TRUTHY (isSynchronizable.ts:12);
26
+ // - __workletContextObjectFactory — TRUTHY and only on a PLAIN object (:175).
27
+ // NOT branded (and therefore walked): __shareableRef — isShareable.ts is only exported, the
28
+ // serializer has no branch for it, so a plain record carrying the marker is recursively
29
+ // serialized; __remoteFunction on a RECORD — the RemoteFunction identity applies inside
30
+ // `isFunction` only (:152), and functions are handled by visit's own branch. A falsy marker
31
+ // (false/null/0) brands NOTHING — the serializer recursively enumerates the record, cycle and
32
+ // all — and every other unbranded object is traversed by this walker for the same reason (the
33
+ // LEGACY path descends Object.entries at :813-820).
34
+ const HOST_OBJECT_MAGIC_KEY = 'REANIMATED_MAGIC_KEY'
35
+
36
+ function isOpaqueLeaf(value: object): boolean {
37
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return true
38
+ if (HOST_OBJECT_MAGIC_KEY in value) return true
39
+ const prototype = Object.getPrototypeOf(value) as object | null
40
+ if (prototype !== null && HOST_OBJECT_MAGIC_KEY in prototype) return true
41
+ const record = value as Record<string, unknown>
42
+ if (record['__serializableRef'] === true) return true
43
+ if (record['__synchronizableRef']) return true
44
+ return (
45
+ Object.getPrototypeOf(value) === Object.prototype && !!record['__workletContextObjectFactory']
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Walk the payload graph; returns a short violation string naming the offending path
51
+ * (`cycle at …` / `budget at …`), or null when the payload is finite, acyclic, and in budget.
52
+ * Complexity is O(nodes visited) with early exit — the factory edge calls this per build, so the
53
+ * walk must never allocate unboundedly itself: the visiting set and node counter bound it.
54
+ */
55
+ export function findUnsafeSerializablePayloadPath(root: unknown): string | null {
56
+ // `visiting` is the current-path set (cycle detection): an object already on the walk's own
57
+ // stack is a back-edge. DAG-shaped sharing (the same record referenced twice) is legal — the
58
+ // budget, not the set, bounds it — so nodes leave the set when their subtree completes.
59
+ const visiting = new Set<object>()
60
+ let nodes = 0
61
+
62
+ function visit(value: unknown, path: string, depth: number): string | null {
63
+ // Every visit counts BEFORE any terminal classification (seal r12 F1: primitives; seal r13
64
+ // F1: functions and opaque branded leaves): Worklets' serializer recurses every container
65
+ // entry regardless of how terminal its per-entry serialization is, so the walk's bound must
66
+ // cover every entry it can reach — no allocation is added (the counter and the entry's path
67
+ // string already exist).
68
+ nodes += 1
69
+ if (nodes > MAX_PAYLOAD_NODES) return `budget at ${path} (>${MAX_PAYLOAD_NODES} nodes)`
70
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return null
71
+ if (typeof value === 'function') {
72
+ const fn = value as unknown as Record<string, unknown>
73
+ // The RemoteFunction identity: a TRUTHY __remoteFunction on a FUNCTION is already
74
+ // serialized (serializable.native.ts:152); a plain UNTAGGED function becomes one
75
+ // (cloneNonWorkletFunction, :156-160) — both terminal, never walked.
76
+ if (fn['__remoteFunction']) return null
77
+ if (!fn['__workletHash']) return null
78
+ // A worklet (isWorkletFunction: truthy __workletHash on a function — workletFunction.ts):
79
+ // cloneWorklet recurses its enumerable properties — __closure AND __initData included
80
+ // (:428-481, :345-365) — so this walker must descend through the same properties: the
81
+ // cycle/budget law applies to the captured closure exactly as to any record.
82
+ if (visiting.has(value)) return `cycle at ${path}`
83
+ if (depth > MAX_PAYLOAD_DEPTH) return `budget at ${path} (>${MAX_PAYLOAD_DEPTH} deep)`
84
+ visiting.add(value)
85
+ for (const [key, entry] of Object.entries(value)) {
86
+ const violation = visit(entry, path === '' ? key : `${path}.${key}`, depth + 1)
87
+ if (violation !== null) return violation
88
+ }
89
+ visiting.delete(value)
90
+ return null
91
+ }
92
+ // Container types must be walked BEFORE the opaque-leaf test: Array/Map/Set prototypes are
93
+ // not Object.prototype, so the leaf test would otherwise skip them and hide their cycles.
94
+ const isContainer = Array.isArray(value) || value instanceof Map || value instanceof Set
95
+ if (!isContainer && isOpaqueLeaf(value)) return null
96
+ if (visiting.has(value)) return `cycle at ${path}`
97
+ if (depth > MAX_PAYLOAD_DEPTH) return `budget at ${path} (>${MAX_PAYLOAD_DEPTH} deep)`
98
+ visiting.add(value)
99
+ if (Array.isArray(value)) {
100
+ for (let index = 0; index < value.length; index += 1) {
101
+ const violation = visit(value[index], `${path}[${index}]`, depth + 1)
102
+ if (violation !== null) return violation
103
+ }
104
+ } else if (value instanceof Map) {
105
+ for (const [key, entry] of value) {
106
+ const keyViolation = visit(key, `${path}.<key>`, depth + 1)
107
+ if (keyViolation !== null) return keyViolation
108
+ const entryViolation = visit(entry, `${path}.${String(key)}`, depth + 1)
109
+ if (entryViolation !== null) return entryViolation
110
+ }
111
+ } else if (value instanceof Set) {
112
+ let index = 0
113
+ for (const entry of value) {
114
+ const violation = visit(entry, `${path}.<set ${index}>`, depth + 1)
115
+ if (violation !== null) return violation
116
+ index += 1
117
+ }
118
+ } else {
119
+ for (const [key, entry] of Object.entries(value)) {
120
+ const violation = visit(entry, path === '' ? key : `${path}.${key}`, depth + 1)
121
+ if (violation !== null) return violation
122
+ }
123
+ }
124
+ visiting.delete(value)
125
+ return null
126
+ }
127
+
128
+ return visit(root, '', 0)
129
+ }
130
+
131
+ // The ambient severity law, mirrored from motion/severity.ts (this module stays motion-import-
132
+ // free like the rest of the driver/motion boundary): development throws, production reports
133
+ // through the console channel and lets the caller DEGRADE — a refused payload never reaches the
134
+ // UI scheduler, so the refusal, not the materialization, is the failure surface.
135
+ declare const __DEV__: boolean | undefined
136
+
137
+ /**
138
+ * The factory-edge assertion. Returns the violation string (so the caller can degrade to its
139
+ * safe path), or null when the payload is clean. Dev lane throws at the source; production
140
+ * reports the Error and continues into the caller's degradation — never a silent hand-off.
141
+ */
142
+ export function refuseUnsafeSerializablePayload(payload: unknown, label: string): string | null {
143
+ const violation = findUnsafeSerializablePayloadPath(payload)
144
+ if (violation === null) return null
145
+ const error = new Error(
146
+ `native-motion: unsafe layout-animation payload (${label}): ${violation} — refusing to hand it to the UI scheduler (iOS AUTO SIGABRT wedge law)`,
147
+ )
148
+ if (typeof __DEV__ === 'undefined' || __DEV__ !== false) throw error
149
+ console.error(error)
150
+ return violation
151
+ }
@@ -0,0 +1,16 @@
1
+ // Ambient severity for the native runtime (the ratified severity law: development throws;
2
+ // production reports through the error channel and refuses the offending property). React
3
+ // Native defines __DEV__; anywhere it is absent (vitest, node) the default is development —
4
+ // fail-loud is the safe default.
5
+ import type { NativeSeverity } from './motionViewController'
6
+
7
+ declare const __DEV__: boolean | undefined
8
+
9
+ export function ambientNativeSeverity(): NativeSeverity {
10
+ return typeof __DEV__ !== 'undefined' && __DEV__ === false ? 'production' : 'development'
11
+ }
12
+
13
+ /* The default error channel: loud in the console, never swallowed (the web shim's pattern). */
14
+ export function consoleReporter(error: Error): void {
15
+ console.error(error)
16
+ }