@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,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
|
+
}
|