@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,435 @@
|
|
|
1
|
+
// The ELEMENT transition boundary (r7 major 41b089c8c0af; lane law r9 762e9d0a4c77,
|
|
2
|
+
// consumer-set law r11 3a1b5478e6c2; T18-b REQ-API-050): the `transition` prop feeds every
|
|
3
|
+
// animation consumer the element mounts, and ONE authored config must execute IDENTICALLY on
|
|
4
|
+
// all of them (the web engine applies it whole). It gates once here: core validateTransition
|
|
5
|
+
// (shape, vocabulary, ranges, derived-config probe) plus the native option laws — `velocity` is
|
|
6
|
+
// executed by BOTH native lanes since T21 F3 (property spring seeding, REQ-SPRING-007; the
|
|
7
|
+
// layout flight's toSpringConfig) and refuses only on a layoutId flight (spring physics,
|
|
8
|
+
// family-3); `delay` is executable by the layout builder AND
|
|
9
|
+
// by the property lane (T18-b taught it the REQ-TIMING-004 rebase), so any element with a
|
|
10
|
+
// layout or property consumer accepts it and both lanes delay identically. A layoutId flight
|
|
11
|
+
// still refuses it — our FLIP machinery never reads the element transition's delay while the
|
|
12
|
+
// pin passes transition options into projection flights: a recorded divergence, demand-gated
|
|
13
|
+
// (T18-b L5). A WHOLLY consumer-less element stores the shape-validated config DORMANT
|
|
14
|
+
// (T23 D): the pin consumes the element transition lazily (getDefaultTransition), so nothing
|
|
15
|
+
// executes and nothing is silently inert — G-INV-8 is scoped to elements with at least one
|
|
16
|
+
// mounted consumer that cannot execute the option; a consumer mounting on a later render
|
|
17
|
+
// re-gates the same snapshot with real consumers.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
InvalidTransitionError,
|
|
21
|
+
captureTransition,
|
|
22
|
+
TARGET_PROPERTY_KEYS,
|
|
23
|
+
validateTransitionRefusal,
|
|
24
|
+
type Transition,
|
|
25
|
+
} from '@unrulysystems/native-motion-core'
|
|
26
|
+
import {
|
|
27
|
+
keyframeTimingRefusal,
|
|
28
|
+
resolveTransitionForKey,
|
|
29
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
30
|
+
import type { StyleValueGate } from './styleValueBinding'
|
|
31
|
+
import type { MotionComponentId } from './motionViewController'
|
|
32
|
+
|
|
33
|
+
const capturedElementTransitions = new WeakSet<object>()
|
|
34
|
+
const genericallyValidatedElementTransitions = new WeakSet<object>()
|
|
35
|
+
const TRANSITION_MAP_KEYS = new Set<string>(['default', 'layout', ...TARGET_PROPERTY_KEYS])
|
|
36
|
+
|
|
37
|
+
function hasTransitionMap(transition: Transition): boolean {
|
|
38
|
+
return Reflect.ownKeys(transition).some(
|
|
39
|
+
(key) => typeof key === 'string' && TRANSITION_MAP_KEYS.has(key),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function transitionMapSourceKey(transition: Transition, key: string): string {
|
|
44
|
+
const source = transition as Record<PropertyKey, unknown>
|
|
45
|
+
if (
|
|
46
|
+
TRANSITION_MAP_KEYS.has(key) &&
|
|
47
|
+
Object.hasOwn(source, key) &&
|
|
48
|
+
source[key] !== undefined &&
|
|
49
|
+
source[key] !== null
|
|
50
|
+
)
|
|
51
|
+
return key
|
|
52
|
+
if (Object.hasOwn(source, 'default') && source.default !== undefined && source.default !== null)
|
|
53
|
+
return 'default'
|
|
54
|
+
return 'root'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sanitizedTransitionMap(
|
|
58
|
+
transition: Transition,
|
|
59
|
+
invalidSources: ReadonlySet<string>,
|
|
60
|
+
): Transition | undefined {
|
|
61
|
+
// Removing a selected source changes the resolver's fallback path. Unless every newly exposed
|
|
62
|
+
// fallback has been gated for the same mounted consumer, preserving the map would let an invalid
|
|
63
|
+
// root/default option cross the production boundary without a report. Refuse the map atomically.
|
|
64
|
+
if (invalidSources.size > 0) return undefined
|
|
65
|
+
const source = transition as Record<PropertyKey, unknown>
|
|
66
|
+
const out: Record<PropertyKey, unknown> = Object.create(null)
|
|
67
|
+
const keepRoot = !invalidSources.has('root')
|
|
68
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
69
|
+
if (typeof key === 'string' && TRANSITION_MAP_KEYS.has(key)) {
|
|
70
|
+
if (!invalidSources.has(key)) {
|
|
71
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key)
|
|
72
|
+
if (descriptor !== undefined)
|
|
73
|
+
Object.defineProperty(out, key, {
|
|
74
|
+
value: descriptor.value,
|
|
75
|
+
enumerable: descriptor.enumerable === true,
|
|
76
|
+
configurable: true,
|
|
77
|
+
writable: true,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (!keepRoot) continue
|
|
83
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key)
|
|
84
|
+
if (descriptor !== undefined)
|
|
85
|
+
Object.defineProperty(out, key, {
|
|
86
|
+
value: descriptor.value,
|
|
87
|
+
enumerable: descriptor.enumerable === true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
writable: true,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
return Reflect.ownKeys(out).length === 0 ? undefined : (Object.freeze(out) as Transition)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A plain-object transition can be captured into a frozen snapshot; a malformed SHAPE (primitive, array,
|
|
96
|
+
// prototype-carrying) must stay raw so core's validateTransition throws its typed refusal rather than a
|
|
97
|
+
// capture laundering it into an empty `{}`.
|
|
98
|
+
function isPlainTransition(value: Transition): boolean {
|
|
99
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
100
|
+
const proto = Object.getPrototypeOf(value) as unknown
|
|
101
|
+
return proto === Object.prototype || proto === null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Capture the authored element transition before more than one supplying boundary needs it. Motion.View
|
|
105
|
+
// passes this ONE snapshot to the render-time gesture gate, the element consumer-set gate, and the
|
|
106
|
+
// mount-time gesture gate, so validation and execution never observe different caller-owned truths.
|
|
107
|
+
export function captureElementTransition(value: Transition | undefined): Transition | undefined {
|
|
108
|
+
if (value === undefined) return undefined
|
|
109
|
+
if (!isPlainTransition(value)) return value
|
|
110
|
+
// Motion.View has already captured this snapshot for an earlier supplying boundary. Preserve identity
|
|
111
|
+
// so the gesture gate validates exactly the transition later handed to layout/property consumers.
|
|
112
|
+
if (capturedElementTransitions.has(value)) return value
|
|
113
|
+
const snapshot = captureTransition(value)
|
|
114
|
+
capturedElementTransitions.add(snapshot)
|
|
115
|
+
return snapshot
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Settle the generic core transition contract before a supplied snapshot can become an effective
|
|
119
|
+
// fallback for another surface. Gesture-state targets inherit the element transition, so passing a
|
|
120
|
+
// malformed raw value into their keyframe predicate would make an implementation detail (`.times`)
|
|
121
|
+
// throw before the established typed transition refusal. Successful snapshots are remembered only to
|
|
122
|
+
// avoid rerunning the pure validation when the consumer-set gate receives this exact captured truth.
|
|
123
|
+
export function gateElementTransitionShape(
|
|
124
|
+
value: Transition | undefined,
|
|
125
|
+
gate: StyleValueGate,
|
|
126
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
127
|
+
): Transition | undefined {
|
|
128
|
+
if (value === undefined) return undefined
|
|
129
|
+
// Capture runs OUTSIDE the try (review major 4e229b): a FOREIGN accessor/proxy fault must preserve
|
|
130
|
+
// its identity instead of being reported as an ordinary malformed transition.
|
|
131
|
+
const snapshot = captureElementTransition(value)
|
|
132
|
+
if (snapshot === undefined) return undefined
|
|
133
|
+
if (
|
|
134
|
+
typeof snapshot === 'object' &&
|
|
135
|
+
snapshot !== null &&
|
|
136
|
+
genericallyValidatedElementTransitions.has(snapshot)
|
|
137
|
+
) {
|
|
138
|
+
return snapshot
|
|
139
|
+
}
|
|
140
|
+
// RETURN-shaped validation is intentional: a returned refusal is the transition boundary's typed
|
|
141
|
+
// input error, while a dependency fault thrown by validation must retain its identity rather than
|
|
142
|
+
// being mistaken for a production-reportable malformed transition.
|
|
143
|
+
const refusal = validateTransitionRefusal(snapshot, { componentId })
|
|
144
|
+
if (refusal !== null) {
|
|
145
|
+
if (gate.severity === 'development') throw refusal
|
|
146
|
+
gate.report(refusal)
|
|
147
|
+
return undefined // production: refuse the malformed prop before any fallback consumes it
|
|
148
|
+
}
|
|
149
|
+
if (typeof snapshot === 'object' && snapshot !== null) {
|
|
150
|
+
genericallyValidatedElementTransitions.add(snapshot)
|
|
151
|
+
}
|
|
152
|
+
return snapshot
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The repeat-family key this transition authored, or null. `repeat` is named first so the common
|
|
156
|
+
// refusal points at the option the author actually reached for; the companions are still named
|
|
157
|
+
// when they appear alone (core validation refuses that pairing, but this lane law must not depend
|
|
158
|
+
// on the order the two gates run in).
|
|
159
|
+
const REPEAT_FAMILY_KEYS = ['repeat', 'repeatType', 'repeatDelay'] as const
|
|
160
|
+
|
|
161
|
+
function repeatFamilyKey(transition: Transition): (typeof REPEAT_FAMILY_KEYS)[number] | null {
|
|
162
|
+
for (const key of REPEAT_FAMILY_KEYS) {
|
|
163
|
+
if (transition[key] !== undefined) return key
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function gateElementTransition(
|
|
169
|
+
value: Transition | undefined,
|
|
170
|
+
context: {
|
|
171
|
+
readonly consumers: {
|
|
172
|
+
/** True when the layout BUILDER consumes the element transition (layout prop truthy). */
|
|
173
|
+
readonly layout: boolean
|
|
174
|
+
/** True when property commands consume it (animate keys or gesture layers mounted). */
|
|
175
|
+
readonly property: boolean
|
|
176
|
+
/**
|
|
177
|
+
* Family-3: true when layoutId projection flight consumes duration/ease tween timing.
|
|
178
|
+
* Does not execute delay or spring physics options.
|
|
179
|
+
*/
|
|
180
|
+
readonly layoutId?: boolean
|
|
181
|
+
/** Concrete property keys let a map resolve only the consumers this element actually owns. */
|
|
182
|
+
readonly propertyKeys?: readonly string[]
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
gate: StyleValueGate,
|
|
186
|
+
componentId: MotionComponentId = '<Motion.View>',
|
|
187
|
+
): Transition | undefined {
|
|
188
|
+
// This second stage applies only the native consumer-set law. Generic shape/vocabulary/range
|
|
189
|
+
// validation was already settled by `gateElementTransitionShape` before any gesture can inherit the
|
|
190
|
+
// transition; direct callers still receive that authority through this wrapper.
|
|
191
|
+
const snapshot = gateElementTransitionShape(value, gate, componentId)
|
|
192
|
+
if (snapshot === undefined) return undefined
|
|
193
|
+
if (hasTransitionMap(snapshot)) {
|
|
194
|
+
const propertyKeys = context.consumers.propertyKeys
|
|
195
|
+
const candidates = new Map<
|
|
196
|
+
string,
|
|
197
|
+
{
|
|
198
|
+
readonly transition: Transition
|
|
199
|
+
consumers: {
|
|
200
|
+
layout: boolean
|
|
201
|
+
property: boolean
|
|
202
|
+
layoutId?: boolean
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
>()
|
|
206
|
+
const addCandidate = (
|
|
207
|
+
selectorKey: string,
|
|
208
|
+
consumers: { layout: boolean; property: boolean; layoutId?: boolean },
|
|
209
|
+
): void => {
|
|
210
|
+
const sourceKey = transitionMapSourceKey(snapshot, selectorKey)
|
|
211
|
+
const existing = candidates.get(sourceKey)
|
|
212
|
+
if (existing !== undefined) {
|
|
213
|
+
existing.consumers.layout ||= consumers.layout
|
|
214
|
+
existing.consumers.property ||= consumers.property
|
|
215
|
+
existing.consumers.layoutId ||= consumers.layoutId === true
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
candidates.set(sourceKey, {
|
|
219
|
+
transition: resolveTransitionForKey(snapshot, selectorKey),
|
|
220
|
+
consumers: { ...consumers },
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Select the concrete mounted consumers first. Generic validation above still checks every
|
|
225
|
+
// authored branch's shape/ranges; these lane rules apply only to branches this host actually
|
|
226
|
+
// executes, so an unused repeat/keyframe bag cannot poison a valid map.
|
|
227
|
+
if (context.consumers.layout || context.consumers.layoutId === true) {
|
|
228
|
+
addCandidate('layout', {
|
|
229
|
+
layout: true,
|
|
230
|
+
property: false,
|
|
231
|
+
...(context.consumers.layoutId === true ? { layoutId: true } : {}),
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
if (context.consumers.property) {
|
|
235
|
+
const selectedPropertyKeys =
|
|
236
|
+
propertyKeys === undefined || propertyKeys.length === 0 ? ['__root__'] : propertyKeys
|
|
237
|
+
for (const key of selectedPropertyKeys) {
|
|
238
|
+
addCandidate(key, { layout: false, property: true })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (candidates.size === 0) {
|
|
242
|
+
addCandidate('__root__', { layout: false, property: false })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const invalidSources = new Set<string>()
|
|
246
|
+
for (const [sourceKey, candidate] of candidates) {
|
|
247
|
+
const gated = gateElementTransition(
|
|
248
|
+
candidate.transition,
|
|
249
|
+
{ consumers: candidate.consumers },
|
|
250
|
+
gate,
|
|
251
|
+
componentId,
|
|
252
|
+
)
|
|
253
|
+
if (gated === undefined) invalidSources.add(sourceKey)
|
|
254
|
+
}
|
|
255
|
+
return sanitizedTransitionMap(snapshot, invalidSources)
|
|
256
|
+
}
|
|
257
|
+
const layoutId = context.consumers.layoutId === true
|
|
258
|
+
// T23 D (transition-config): a WHOLLY consumer-less element — no layout, no property owner,
|
|
259
|
+
// no layoutId (the map path's synthesized __root__ candidate included) — stores the
|
|
260
|
+
// shape-validated snapshot DORMANT. The per-option consumer-execution laws below need a
|
|
261
|
+
// consumer to be inert AGAINST; with none mounted the config waits, exactly like the pin's
|
|
262
|
+
// lazy getDefaultTransition consumption, and any later-mounted consumer re-gates it.
|
|
263
|
+
if (!context.consumers.layout && !context.consumers.property && !layoutId) {
|
|
264
|
+
return snapshot
|
|
265
|
+
}
|
|
266
|
+
let refusal: Error | null = null
|
|
267
|
+
// `velocity` is EXECUTED by both native lanes since T21 F3: the property lane's spring
|
|
268
|
+
// seeding (authored velocity overrides the live reseed, REQ-SPRING-007) and the layout
|
|
269
|
+
// flight's spring config (toSpringConfig carries it). The pre-T21 unconditional inert
|
|
270
|
+
// refusal is retired (review r1 major 2); layoutId flights still refuse it below with the
|
|
271
|
+
// rest of the spring-physics family.
|
|
272
|
+
// T18-a L4 (REQ-API-049): the repeat family has exactly one executable owner, a PROPERTY
|
|
273
|
+
// command. A layout/layoutId flight animates one snapshot delta, so repeating it would replay
|
|
274
|
+
// the same decay N times — degenerate at best, and it would fold repeat semantics into the
|
|
275
|
+
// layout engine's completion accounting. The pin lets the option flow into a flight; we refuse
|
|
276
|
+
// it loud instead of executing something meaningless (recorded divergence). The split case is
|
|
277
|
+
// refused for the same reason `times` is: one authored transition must execute identically on
|
|
278
|
+
// every consumer it reaches.
|
|
279
|
+
if (refusal === null) {
|
|
280
|
+
const repeated = repeatFamilyKey(snapshot)
|
|
281
|
+
if (repeated !== null) {
|
|
282
|
+
if (!context.consumers.property) {
|
|
283
|
+
refusal = new InvalidTransitionError(
|
|
284
|
+
componentId,
|
|
285
|
+
repeated,
|
|
286
|
+
snapshot[repeated],
|
|
287
|
+
'only a property animation can repeat — a layout/layoutId flight animates a single ' +
|
|
288
|
+
'snapshot delta, so repeating it would replay the same decay (T18-a L4 / G-INV-8, ' +
|
|
289
|
+
'shippedSurface.ts)',
|
|
290
|
+
)
|
|
291
|
+
} else if (context.consumers.layout || layoutId) {
|
|
292
|
+
refusal = new InvalidTransitionError(
|
|
293
|
+
componentId,
|
|
294
|
+
repeated,
|
|
295
|
+
snapshot[repeated],
|
|
296
|
+
'the repeat family cannot split across shared layout/layoutId and property consumers ' +
|
|
297
|
+
'— one authored transition must execute identically on every consumer, and a ' +
|
|
298
|
+
'flight never repeats (T18-a L4 / G-INV-8)',
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// U7a: the authored instant lane (`type: false`) is PROPERTY-lane only — the property lane
|
|
304
|
+
// commits the final keyframe on the driver's next update tick (the pin's makeAnimationInstant,
|
|
305
|
+
// motion-dom@12.42.2 motion-value.ts:89-98,122-136). A layout/layoutId flight has no
|
|
306
|
+
// instant-commit wiring, so the option refuses loud here rather than silently flying the
|
|
307
|
+
// default spring (G-INV-8) — a recorded divergence, demand-gated like the layoutId `delay`
|
|
308
|
+
// refusal below. The mixed-consumer arm is the split-execution law: one authored transition
|
|
309
|
+
// must execute identically on every consumer, and a flight cannot execute this one.
|
|
310
|
+
if (refusal === null && snapshot.type === false && (context.consumers.layout || layoutId)) {
|
|
311
|
+
refusal = new InvalidTransitionError(
|
|
312
|
+
componentId,
|
|
313
|
+
'type',
|
|
314
|
+
false,
|
|
315
|
+
'a layout/layoutId flight cannot execute type: false — the authored instant lane is ' +
|
|
316
|
+
'property-only (the property lane commits the final keyframe on the next update tick); ' +
|
|
317
|
+
'a recorded divergence, demand-gated to a layout-instant successor rung ' +
|
|
318
|
+
'(U7a / G-INV-8, shippedSurface.ts)',
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
// `times` and a per-segment `ease` list have exactly one executable owner: a property
|
|
322
|
+
// keyframe-array command. Layout / layoutId flights have no such target.
|
|
323
|
+
if (refusal === null && !context.consumers.property) {
|
|
324
|
+
refusal = keyframeTimingRefusal(componentId, '<layout>', 0, snapshot)
|
|
325
|
+
}
|
|
326
|
+
// Split-execution ban: property can consume keyframe timing while layout/layoutId cannot
|
|
327
|
+
// (2vqwp8 83b5e1d407ac — layoutId+property was missing from the mixed check).
|
|
328
|
+
if (refusal === null && context.consumers.property && (context.consumers.layout || layoutId)) {
|
|
329
|
+
const timingRefusal = keyframeTimingRefusal(componentId, '<property>', 0, snapshot)
|
|
330
|
+
if (timingRefusal !== null) {
|
|
331
|
+
refusal = new InvalidTransitionError(
|
|
332
|
+
componentId,
|
|
333
|
+
timingRefusal.key,
|
|
334
|
+
timingRefusal.value,
|
|
335
|
+
'keyframe-only timing cannot split across shared layout/layoutId and property ' +
|
|
336
|
+
'consumers — one authored transition must execute identically on every consumer ' +
|
|
337
|
+
'(REQ-API-033 law d / family-3)',
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Family-3 + T22 layoutId flight: typed springs (type:'spring', physics or time-defined)
|
|
342
|
+
// execute as a per-flight spring (REQ-LAYOUT-023 a). Two refusals survive: `velocity` — the
|
|
343
|
+
// pin's own projection forces velocity: 0, so executing an authored number would EXCEED the
|
|
344
|
+
// pin, and silently dropping it is the G-INV-8 class; and physics options WITHOUT
|
|
345
|
+
// type:'spring' — no resolvable flight config (the pin runs that bag as an untyped tween
|
|
346
|
+
// that silently ignores the physics; we refuse loud instead). U7c (REQ-SPRING-014):
|
|
347
|
+
// `restDelta`/`restSpeed` join the physics family — executed under an explicit type:'spring'
|
|
348
|
+
// (toSpringConfig carries them into the flight spring), refused in the untyped bag below.
|
|
349
|
+
if (refusal === null && layoutId && snapshot.velocity !== undefined) {
|
|
350
|
+
refusal = new InvalidTransitionError(
|
|
351
|
+
componentId,
|
|
352
|
+
'velocity',
|
|
353
|
+
snapshot.velocity,
|
|
354
|
+
'layoutId projection flight refuses velocity — the pinned projection forces velocity 0 ' +
|
|
355
|
+
'on every flight (REQ-LAYOUT-023 b / G-INV-8)',
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
if (
|
|
359
|
+
refusal === null &&
|
|
360
|
+
layoutId &&
|
|
361
|
+
snapshot.type !== 'spring' &&
|
|
362
|
+
(snapshot.stiffness !== undefined ||
|
|
363
|
+
snapshot.damping !== undefined ||
|
|
364
|
+
snapshot.mass !== undefined ||
|
|
365
|
+
snapshot.bounce !== undefined ||
|
|
366
|
+
snapshot.visualDuration !== undefined ||
|
|
367
|
+
snapshot.restDelta !== undefined ||
|
|
368
|
+
snapshot.restSpeed !== undefined)
|
|
369
|
+
) {
|
|
370
|
+
const springKey =
|
|
371
|
+
snapshot.stiffness !== undefined
|
|
372
|
+
? 'stiffness'
|
|
373
|
+
: snapshot.damping !== undefined
|
|
374
|
+
? 'damping'
|
|
375
|
+
: snapshot.mass !== undefined
|
|
376
|
+
? 'mass'
|
|
377
|
+
: snapshot.bounce !== undefined
|
|
378
|
+
? 'bounce'
|
|
379
|
+
: snapshot.visualDuration !== undefined
|
|
380
|
+
? 'visualDuration'
|
|
381
|
+
: snapshot.restDelta !== undefined
|
|
382
|
+
? 'restDelta'
|
|
383
|
+
: 'restSpeed'
|
|
384
|
+
const springValue = snapshot[springKey]
|
|
385
|
+
refusal = new InvalidTransitionError(
|
|
386
|
+
componentId,
|
|
387
|
+
springKey,
|
|
388
|
+
springValue,
|
|
389
|
+
'layoutId projection flight executes spring options only under an explicit ' +
|
|
390
|
+
"type: 'spring' — an untyped physics bag has no resolvable flight config " +
|
|
391
|
+
'(REQ-LAYOUT-023 b / G-INV-8)',
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
// Ease-only / easings-only without duration or type:tween does not resolve to flight timing
|
|
395
|
+
// (layoutIdFlightTiming) — refuse rather than silent spring fallback (tjynor e30b75a4c19f).
|
|
396
|
+
if (
|
|
397
|
+
refusal === null &&
|
|
398
|
+
layoutId &&
|
|
399
|
+
snapshot.duration === undefined &&
|
|
400
|
+
snapshot.type !== 'tween' &&
|
|
401
|
+
(snapshot.ease !== undefined || snapshot.easings !== undefined || snapshot.easing !== undefined)
|
|
402
|
+
) {
|
|
403
|
+
const easeKey =
|
|
404
|
+
snapshot.ease !== undefined ? 'ease' : snapshot.easings !== undefined ? 'easings' : 'easing'
|
|
405
|
+
refusal = new InvalidTransitionError(
|
|
406
|
+
componentId,
|
|
407
|
+
easeKey,
|
|
408
|
+
snapshot.ease ?? snapshot.easings ?? snapshot.easing,
|
|
409
|
+
'layoutId projection flight requires duration or type:tween with ease — ease-only ' +
|
|
410
|
+
'options are residual (REQ-LAYOUT-023 / G-INV-8)',
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
if (refusal === null && layoutId && snapshot.delay !== undefined) {
|
|
414
|
+
// T18-b (REQ-API-050 / packet L5): the property lane executes the delay rebase, so the
|
|
415
|
+
// split-execution refusal is RETIRED — an element mixing layout and property consumers
|
|
416
|
+
// delays both lanes identically, matching the web engine. The consumer-less arm retired
|
|
417
|
+
// at T23 D (the dormant accept above returns before any option law). What remains is the
|
|
418
|
+
// layoutId arm, restated as the divergence it is: the pin passes transition options into
|
|
419
|
+
// projection flights; our FLIP machinery never reads the element transition's delay.
|
|
420
|
+
// Recorded divergence, demand-gated to a successor rung — not a "cannot execute".
|
|
421
|
+
refusal = new InvalidTransitionError(
|
|
422
|
+
componentId,
|
|
423
|
+
'delay',
|
|
424
|
+
snapshot.delay,
|
|
425
|
+
'a layoutId projection flight never reads the element transition\u2019s delay — not ' +
|
|
426
|
+
'executed, a recorded divergence from the pin (whose projection flights consume ' +
|
|
427
|
+
'transition options); demand-gated to a layoutId-flight-delay successor rung ' +
|
|
428
|
+
'(REQ-API-050, T18-b L5, shippedSurface.ts)',
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
if (refusal === null) return snapshot
|
|
432
|
+
if (gate.severity === 'development') throw refusal
|
|
433
|
+
gate.report(refusal)
|
|
434
|
+
return undefined // production: refuse the prop, mount with the default transition
|
|
435
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// REQ-API-039 — useAnimate on the native entry: [scope, animate] with unmount-stop of tracked
|
|
2
|
+
// animations (pin useUnmountEffect law). Scoped animate resolves selectors inside scope.current
|
|
3
|
+
// and routes every command through the prepared seam (imperativeAnimate.ts).
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo } from 'react'
|
|
6
|
+
import { useMotionConfig } from './MotionConfig'
|
|
7
|
+
import {
|
|
8
|
+
createScopedAnimate,
|
|
9
|
+
type AnimateFn,
|
|
10
|
+
type AnimationScope,
|
|
11
|
+
USE_ANIMATE_OWNER,
|
|
12
|
+
} from './imperativeAnimate'
|
|
13
|
+
import { ambientNativeSeverity, consoleReporter } from './severity'
|
|
14
|
+
import { useValueChannel } from './valueHooks'
|
|
15
|
+
|
|
16
|
+
export type { AnimateFn, AnimationPlaybackControls, AnimationScope } from './imperativeAnimate'
|
|
17
|
+
|
|
18
|
+
/** Shallow public pair — mirrors web useAnimate (REQ-API-039; dual-entry type-identity). */
|
|
19
|
+
export type UseAnimateReturn<T = unknown> = readonly [AnimationScope<T>, AnimateFn]
|
|
20
|
+
|
|
21
|
+
export function useAnimate<T = unknown>(): UseAnimateReturn<T> {
|
|
22
|
+
const scope = useMemo<AnimationScope<T>>(
|
|
23
|
+
() => ({
|
|
24
|
+
current: null,
|
|
25
|
+
animations: [],
|
|
26
|
+
}),
|
|
27
|
+
[],
|
|
28
|
+
)
|
|
29
|
+
const { skipAnimations } = useMotionConfig()
|
|
30
|
+
const channel = useValueChannel()
|
|
31
|
+
const severity = ambientNativeSeverity()
|
|
32
|
+
const animate = useMemo(
|
|
33
|
+
() =>
|
|
34
|
+
createScopedAnimate({
|
|
35
|
+
scope: scope as AnimationScope,
|
|
36
|
+
channel,
|
|
37
|
+
skipAnimations,
|
|
38
|
+
gate: { severity, report: consoleReporter, owner: USE_ANIMATE_OWNER },
|
|
39
|
+
}),
|
|
40
|
+
[scope, channel, skipAnimations, severity],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Pin useUnmountEffect law: every animation tracked on scope.animations stops on unmount.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
return () => {
|
|
46
|
+
for (const animation of scope.animations) animation.stop()
|
|
47
|
+
scope.animations.length = 0
|
|
48
|
+
}
|
|
49
|
+
}, [scope])
|
|
50
|
+
|
|
51
|
+
return [scope, animate]
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// REQ-API-040 — useCycle on the native entry: pin-faithful cycle through a fixed item list.
|
|
2
|
+
// Pin: framer-motion use-cycle.ts (motion@12.42.2) — [current, cycle]; cycle() advances with
|
|
3
|
+
// wrap; cycle(i) jumps to index. Web re-exports motion/react's useCycle (entry parity).
|
|
4
|
+
|
|
5
|
+
import { useCallback, useRef, useState } from 'react'
|
|
6
|
+
|
|
7
|
+
export type Cycle = (i?: number) => void
|
|
8
|
+
export type CycleState<T> = [T, Cycle]
|
|
9
|
+
|
|
10
|
+
/** Pin wrap (motion-utils): map v into [min, max). */
|
|
11
|
+
function wrap(min: number, max: number, v: number): number {
|
|
12
|
+
const rangeSize = max - min
|
|
13
|
+
if (rangeSize === 0) return min
|
|
14
|
+
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cycles through a series of values (Motion-shaped). Returns `[current, cycle]` where
|
|
19
|
+
* `cycle()` advances to the next item (wrapping) and `cycle(i)` jumps to index `i`.
|
|
20
|
+
*
|
|
21
|
+
* Zero items refuse (REQ-API-040) — the pin would leave undefined state; native fails loud.
|
|
22
|
+
*/
|
|
23
|
+
export function useCycle<T>(...items: T[]): CycleState<T> {
|
|
24
|
+
if (items.length === 0) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'useCycle: at least one item is required (REQ-API-040) — an empty cycle has no current state.',
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const index = useRef(0)
|
|
31
|
+
const [item, setItem] = useState(items[index.current] as T)
|
|
32
|
+
|
|
33
|
+
const runCycle = useCallback(
|
|
34
|
+
(next?: number) => {
|
|
35
|
+
index.current = typeof next !== 'number' ? wrap(0, items.length, index.current + 1) : next
|
|
36
|
+
setItem(items[index.current] as T)
|
|
37
|
+
},
|
|
38
|
+
// Pin: items.length first so identity churn still matches on length; then items.
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- pin dependency law
|
|
40
|
+
[items.length, ...items],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return [item, runCycle]
|
|
44
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// U7b — useInstantTransition + disableInstantTransitions on the native entry (the
|
|
2
|
+
// instant-transition lane's hook surface). Pin: framer-motion@12.42.2
|
|
3
|
+
// utils/use-instant-transition.ts:9-45 — the hook returns a callback that opens the GLOBAL
|
|
4
|
+
// instant window (native's MotionGlobalConfig.instantAnimations analogue, driver/instantWindow),
|
|
5
|
+
// force-renders the calling component, runs the user's callback, and relocks TWO post-render
|
|
6
|
+
// frames later under the render-count TOKEN guard: a back-to-back call bumps the token, so the
|
|
7
|
+
// stale unlock bails and the window extends (the pin's own test,
|
|
8
|
+
// use-instant-transition.test.tsx:150-176, is the model). There is deliberately NO frame
|
|
9
|
+
// cancellation: an unmounted caller's pending unlock still closes the window, so unmount never
|
|
10
|
+
// leaks it. The window itself (skip generator, final keyframe next update tick, delay ZEROED —
|
|
11
|
+
// F2's asymmetry vs the authored type:false) is consulted at the driver's JS-side transition
|
|
12
|
+
// resolution and pinned in workletDriver.lanes.test.ts. Web re-exports the raw pin.
|
|
13
|
+
//
|
|
14
|
+
// Two native deltas from the pin's mechanics, neither semantic: the force-render is a plain
|
|
15
|
+
// useReducer dispatch (the repo's own idiom — the pin's frame.postRender deferral exists for
|
|
16
|
+
// pre-18 batching; React 18 batches the callback's own setStates into the same commit), and the
|
|
17
|
+
// two post-render frames are nested requestAnimationFrames (native has no motion-dom frame
|
|
18
|
+
// loop). The returned callback is STABLE across the forced renders (the packet's contract): the
|
|
19
|
+
// count the token derives from rides a ref, so identity never changes.
|
|
20
|
+
|
|
21
|
+
import { useCallback, useEffect, useReducer, useRef } from 'react'
|
|
22
|
+
import { closeInstantWindow, openInstantWindow } from '../driver/instantWindow'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The pin's `useInstantTransition`: a callback that runs `callback` inside the global instant
|
|
26
|
+
* window — every animation starting inside it (any component, any motion value) skips its
|
|
27
|
+
* generator and commits its final keyframe on the driver's next update tick, with `delay`
|
|
28
|
+
* zeroed. The window closes two post-render frames after the last call.
|
|
29
|
+
*/
|
|
30
|
+
export function useInstantTransition(): (callback: () => void) => void {
|
|
31
|
+
const [forcedRenderCount, forceUpdate] = useReducer((count: number) => count + 1, 0)
|
|
32
|
+
const forcedRenderCountRef = useRef(forcedRenderCount)
|
|
33
|
+
forcedRenderCountRef.current = forcedRenderCount
|
|
34
|
+
const unlockOnFrameRef = useRef(-1)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// Relock after TWO animation frames — one would unblock before the forced render's
|
|
38
|
+
// animations even start (the pin's double frame.postRender).
|
|
39
|
+
requestAnimationFrame(() =>
|
|
40
|
+
requestAnimationFrame(() => {
|
|
41
|
+
// The token guard: a back-to-back call recorded a NEWER token after this effect
|
|
42
|
+
// scheduled, so this stale unlock bails — the window extends instead of closing
|
|
43
|
+
// early (pin :150-176). No cancellation on unmount: the latest pending unlock
|
|
44
|
+
// still matches its token and closes the window, so unmount never leaks it.
|
|
45
|
+
if (forcedRenderCount !== unlockOnFrameRef.current) return
|
|
46
|
+
closeInstantWindow()
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
}, [forcedRenderCount])
|
|
50
|
+
|
|
51
|
+
return useCallback(
|
|
52
|
+
(callback: () => void) => {
|
|
53
|
+
openInstantWindow()
|
|
54
|
+
forceUpdate()
|
|
55
|
+
callback()
|
|
56
|
+
unlockOnFrameRef.current = forcedRenderCountRef.current + 1
|
|
57
|
+
},
|
|
58
|
+
[forceUpdate],
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The pin's sibling export (use-instant-transition.ts:43-45): clears the global instant window
|
|
64
|
+
* immediately — the escape hatch for a stuck window.
|
|
65
|
+
*/
|
|
66
|
+
export function disableInstantTransitions(): void {
|
|
67
|
+
closeInstantWindow()
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// U7a (the REQ-API-027-adjacent hook row) — useReducedMotion on the native entry: the pin's
|
|
2
|
+
// ACTUAL semantics, not its docblock's. Pin: framer-motion@12.42.2
|
|
3
|
+
// utils/reduced-motion/use-reduced-motion.ts:37-58 — a mount-time SNAPSHOT of the OS
|
|
4
|
+
// reduced-motion preference, frozen for the hook's lifetime: a frozen `useState` initial with NO
|
|
5
|
+
// setter, NO effect, NO subscription (the docblock's "actively respond to changes" is stale; the
|
|
6
|
+
// file's own TODO concedes it). The fidelity doctrine forbids "improving" on that, so native
|
|
7
|
+
// takes NO `reduceMotionChanged` subscription either (packet F1 — the no-re-render test is the
|
|
8
|
+
// unit's fidelity pin). Web re-exports the raw pin (native-motion-web/src/index.ts).
|
|
9
|
+
//
|
|
10
|
+
// The one platform difference is the async transient (packet F2): RN's answer is a promise
|
|
11
|
+
// where the pin's `matchMedia` read is synchronous, so the first-render value is `false` — the
|
|
12
|
+
// pin's own matchMedia-absent fallback (reduced-motion/index.ts:19) — and the resolved OS value
|
|
13
|
+
// replaces it ONCE, inside the mount's snapshot window.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useState } from 'react'
|
|
16
|
+
import { AccessibilityInfo } from 'react-native'
|
|
17
|
+
|
|
18
|
+
declare const __DEV__: boolean | undefined
|
|
19
|
+
|
|
20
|
+
// The pin's warnOnce (motion-utils): one console.warn per key, dev-only.
|
|
21
|
+
const warnedKeys = new Set<string>()
|
|
22
|
+
function warnOnce(condition: boolean, message: string, key: string): void {
|
|
23
|
+
if (condition || warnedKeys.has(key)) return
|
|
24
|
+
warnedKeys.add(key)
|
|
25
|
+
console.warn(message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the OS reduced-motion preference was enabled at mount, as a frozen snapshot. First
|
|
30
|
+
* render returns `false`; the resolved OS value lands once, asynchronously. The return type is
|
|
31
|
+
* the pin's wider `boolean | null` shape (the pin's `null` is its SSR/no-window read; native has
|
|
32
|
+
* no SSR host, so the value is never null in practice).
|
|
33
|
+
*/
|
|
34
|
+
export function useReducedMotion(): boolean | null {
|
|
35
|
+
const [shouldReduceMotion, setShouldReduceMotion] = useState<boolean | null>(false)
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
// The MotionConfig cancel-flag pattern (MotionConfig.tsx:350-377), minus its subscription:
|
|
38
|
+
// ONE read per mount, one state update on resolve, swallowed after unmount.
|
|
39
|
+
let cancelled = false
|
|
40
|
+
void AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
|
41
|
+
if (!cancelled) setShouldReduceMotion(enabled === true)
|
|
42
|
+
})
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
if (typeof __DEV__ === 'undefined' || __DEV__ !== false) {
|
|
48
|
+
// The pin's dev-only warnOnce, verbatim message + key (use-reduced-motion.ts:45-51). It
|
|
49
|
+
// fires from the render body like the pin's — on the resolve re-render, never on the
|
|
50
|
+
// first (false) frame.
|
|
51
|
+
warnOnce(
|
|
52
|
+
shouldReduceMotion !== true,
|
|
53
|
+
'You have Reduced Motion enabled on your device. Animations may not appear as expected.',
|
|
54
|
+
'reduced-motion-disabled',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return shouldReduceMotion
|
|
58
|
+
}
|