@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,901 @@
|
|
|
1
|
+
// The public value-channel hooks on the native entry (REQ-API-025/-026/-027): pinned-oracle names
|
|
2
|
+
// and semantics over CORE's graph — useMotionValue returns the exact graph value the driver seam
|
|
3
|
+
// reads (never a wrapper copy), useTransform derives on core's JS-side graph, useSpring couples a
|
|
4
|
+
// follower through core's spring resolution, useVelocity mirrors core's velocity law. The web
|
|
5
|
+
// entry re-exports motion/react's own hooks under the same names (type-identity namespace parity).
|
|
6
|
+
//
|
|
7
|
+
// Transform parameters re-apply on every commit like the pinned hook (the dependency set alone
|
|
8
|
+
// is fixed at first render); spring coupling identity is fixed per mount. Subscriptions attach
|
|
9
|
+
// per effect lifetime, so a StrictMode replay detaches and re-attaches without orphaning work.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createContext,
|
|
13
|
+
createElement,
|
|
14
|
+
useContext,
|
|
15
|
+
useInsertionEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from 'react'
|
|
19
|
+
import {
|
|
20
|
+
collectMotionValueReads,
|
|
21
|
+
mix,
|
|
22
|
+
parseValue,
|
|
23
|
+
resolveSpringGenerator,
|
|
24
|
+
transformValue,
|
|
25
|
+
type MotionValue,
|
|
26
|
+
type MotionValueEvent,
|
|
27
|
+
type SpringConfig,
|
|
28
|
+
} from '@unrulysystems/native-motion-core'
|
|
29
|
+
import {
|
|
30
|
+
createDirectPublicMotionValue,
|
|
31
|
+
type MotionValueDriverPort,
|
|
32
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
33
|
+
import { createValueChannel, type ValueChannel } from './valueChannel'
|
|
34
|
+
|
|
35
|
+
// ——— channel injection ———————————————————————————————————————————————————————————————————————
|
|
36
|
+
|
|
37
|
+
const ValueChannelContext = createContext<ValueChannel | null>(null)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Private native observation boundary for an identity-channel MotionValue's transported cache.
|
|
41
|
+
* The cache owns velocity authority; observer frames only decide when React-side consumers
|
|
42
|
+
* re-read it. Keeping both behind one injected port makes frame cadence deterministic without a
|
|
43
|
+
* synchronous source read or a wall-clock timer.
|
|
44
|
+
*/
|
|
45
|
+
export interface ValueHooksIdentityChannelPort {
|
|
46
|
+
now(): number
|
|
47
|
+
readTransportedCachedVelocity(source: MotionValue<number>, atTimestampMs: number): number
|
|
48
|
+
subscribeTransportedChange(source: MotionValue<number>, listener: () => void): () => void
|
|
49
|
+
requestObserverFrame(callback: (atTimestampMs: number) => void): () => void
|
|
50
|
+
observeSynchronousCrossings(): ValueHooksIdentityChannelCrossingObservation
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ValueHooksIdentityChannelCrossingObservation {
|
|
54
|
+
readonly synchronousJsToUiReadCount: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ValueHooksIdentityChannelCrossingObserver {
|
|
58
|
+
observe(): ValueHooksIdentityChannelCrossingObservation
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CanonicalValueHooksIdentityRegistration {
|
|
62
|
+
readonly source: MotionValue<number>
|
|
63
|
+
readonly driverPort: MotionValueDriverPort<number>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CreateCanonicalValueHooksIdentityChannelPortOptions {
|
|
67
|
+
readonly registrations: readonly CanonicalValueHooksIdentityRegistration[]
|
|
68
|
+
readonly clock: { now(): number }
|
|
69
|
+
readonly observerScheduler: {
|
|
70
|
+
requestObserverFrame(callback: (atTimestampMs: number) => void): () => void
|
|
71
|
+
}
|
|
72
|
+
/** Read-only instrumentation owned by the crossing adapter, never by the cache-read callback. */
|
|
73
|
+
readonly crossingObserver: ValueHooksIdentityChannelCrossingObserver
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ValueHooksIdentityChannelInvariant =
|
|
77
|
+
| 'UNREGISTERED_CANONICAL_SOURCE'
|
|
78
|
+
| 'MISMATCHED_CANONICAL_SOURCE'
|
|
79
|
+
| 'MISSING_CANONICAL_CHANNEL_PORT'
|
|
80
|
+
| 'DUPLICATE_CANONICAL_CHANNEL_PORT'
|
|
81
|
+
|
|
82
|
+
export class ValueHooksIdentityChannelInvariantError extends Error {
|
|
83
|
+
override name = 'ValueHooksIdentityChannelInvariantError'
|
|
84
|
+
readonly code = 'NATIVE_MOTION_VALUE_HOOKS_IDENTITY_CHANNEL_INVARIANT' as const
|
|
85
|
+
readonly invariant: ValueHooksIdentityChannelInvariant
|
|
86
|
+
|
|
87
|
+
constructor(invariant: ValueHooksIdentityChannelInvariant) {
|
|
88
|
+
super(`native-motion: value-hooks identity-channel invariant '${invariant}' failed`)
|
|
89
|
+
this.invariant = invariant
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function canonicalRegistrationFor(
|
|
94
|
+
registrations: readonly CanonicalValueHooksIdentityRegistration[],
|
|
95
|
+
source: MotionValue<number>,
|
|
96
|
+
): CanonicalValueHooksIdentityRegistration {
|
|
97
|
+
const registration = registrations.find((candidate) => candidate.source === source)
|
|
98
|
+
if (registration === undefined) {
|
|
99
|
+
throw new ValueHooksIdentityChannelInvariantError('UNREGISTERED_CANONICAL_SOURCE')
|
|
100
|
+
}
|
|
101
|
+
if (registration.driverPort.value !== source) {
|
|
102
|
+
throw new ValueHooksIdentityChannelInvariantError('MISMATCHED_CANONICAL_SOURCE')
|
|
103
|
+
}
|
|
104
|
+
return registration
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Production-shaped adapter from the exact-identity CORE port to hook observation. Cache reads
|
|
109
|
+
* stay on JS; the separately owned crossing observer makes that claim measurable rather than
|
|
110
|
+
* letting an injected read callback self-attest.
|
|
111
|
+
*/
|
|
112
|
+
export function createCanonicalValueHooksIdentityChannelPort(
|
|
113
|
+
options: CreateCanonicalValueHooksIdentityChannelPortOptions,
|
|
114
|
+
): ValueHooksIdentityChannelPort {
|
|
115
|
+
return {
|
|
116
|
+
now: () => options.clock.now(),
|
|
117
|
+
readTransportedCachedVelocity(source, atTimestampMs) {
|
|
118
|
+
return canonicalRegistrationFor(options.registrations, source).driverPort.readCachedVelocity(
|
|
119
|
+
atTimestampMs,
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
subscribeTransportedChange(source, listener) {
|
|
123
|
+
canonicalRegistrationFor(options.registrations, source)
|
|
124
|
+
return source.subscribe(() => listener())
|
|
125
|
+
},
|
|
126
|
+
requestObserverFrame: (callback) => options.observerScheduler.requestObserverFrame(callback),
|
|
127
|
+
observeSynchronousCrossings: () => options.crossingObserver.observe(),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type ValueHooksIdentityChannelResolution =
|
|
132
|
+
| { readonly kind: 'EXPLICIT_PROVIDER'; readonly port: ValueHooksIdentityChannelPort }
|
|
133
|
+
| { readonly kind: 'CANONICAL_VALUE_CHANNEL'; readonly port: ValueHooksIdentityChannelPort }
|
|
134
|
+
|
|
135
|
+
const canonicalIdentityPorts = new WeakMap<ValueChannel, ValueHooksIdentityChannelPort>()
|
|
136
|
+
|
|
137
|
+
/** Registers the one canonical identity observer for a channel; cleanup is identity-guarded. */
|
|
138
|
+
export function registerCanonicalValueHooksIdentityChannelPort(
|
|
139
|
+
channel: ValueChannel,
|
|
140
|
+
port: ValueHooksIdentityChannelPort,
|
|
141
|
+
): () => void {
|
|
142
|
+
if (canonicalIdentityPorts.has(channel)) {
|
|
143
|
+
throw new ValueHooksIdentityChannelInvariantError('DUPLICATE_CANONICAL_CHANNEL_PORT')
|
|
144
|
+
}
|
|
145
|
+
canonicalIdentityPorts.set(channel, port)
|
|
146
|
+
return () => {
|
|
147
|
+
if (canonicalIdentityPorts.get(channel) === port) canonicalIdentityPorts.delete(channel)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The production resolution seam `useVelocity` must consume; explicit injection is test-only. */
|
|
152
|
+
export function resolveValueHooksIdentityChannelPort(
|
|
153
|
+
channel: ValueChannel,
|
|
154
|
+
explicitPort: ValueHooksIdentityChannelPort | null,
|
|
155
|
+
): ValueHooksIdentityChannelResolution {
|
|
156
|
+
if (explicitPort !== null) return { kind: 'EXPLICIT_PROVIDER', port: explicitPort }
|
|
157
|
+
const port = canonicalIdentityPorts.get(channel)
|
|
158
|
+
if (port === undefined) {
|
|
159
|
+
throw new ValueHooksIdentityChannelInvariantError('MISSING_CANONICAL_CHANNEL_PORT')
|
|
160
|
+
}
|
|
161
|
+
return { kind: 'CANONICAL_VALUE_CHANNEL', port }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ValueHooksIdentityChannelContext = createContext<ValueHooksIdentityChannelPort | null>(null)
|
|
165
|
+
|
|
166
|
+
export function ValueHooksIdentityChannelProvider(props: {
|
|
167
|
+
readonly port: ValueHooksIdentityChannelPort
|
|
168
|
+
readonly children?: ReactNode
|
|
169
|
+
}): ReturnType<typeof createElement> {
|
|
170
|
+
return createElement(
|
|
171
|
+
ValueHooksIdentityChannelContext.Provider,
|
|
172
|
+
{ value: props.port },
|
|
173
|
+
props.children,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The app-default runtime: ONE module singleton so values compose across the whole tree (the
|
|
178
|
+
// pinned oracle's global frameloop model). Lazy so tests that always inject never create it.
|
|
179
|
+
let defaultChannel: ValueChannel | null = null
|
|
180
|
+
/**
|
|
181
|
+
* The one app-default graph. Imperative commands import this resolver instead of creating a
|
|
182
|
+
* sibling singleton, so standalone animation and hook-created MotionValues share interruption
|
|
183
|
+
* and settle accounting.
|
|
184
|
+
*/
|
|
185
|
+
export function resolveDefaultValueChannel(): ValueChannel {
|
|
186
|
+
if (defaultChannel === null) defaultChannel = createValueChannel()
|
|
187
|
+
return defaultChannel
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Test seam for deterministic frames; production always uses the lazy app-default graph. */
|
|
191
|
+
export function setDefaultValueChannelForTests(channel: ValueChannel | null): void {
|
|
192
|
+
defaultChannel = channel
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function ValueChannelProvider(props: {
|
|
196
|
+
readonly channel: ValueChannel
|
|
197
|
+
readonly children?: ReactNode
|
|
198
|
+
}): ReturnType<typeof createElement> {
|
|
199
|
+
return createElement(ValueChannelContext.Provider, { value: props.channel }, props.children)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function useValueChannel(): ValueChannel {
|
|
203
|
+
return useContext(ValueChannelContext) ?? resolveDefaultValueChannel()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ——— useMotionValue (REQ-API-025) ————————————————————————————————————————————————————————————
|
|
207
|
+
|
|
208
|
+
export function useMotionValue<T = number>(initial: T): MotionValue<T> {
|
|
209
|
+
const channel = useValueChannel()
|
|
210
|
+
const ref = useRef<MotionValue<T> | null>(null)
|
|
211
|
+
if (ref.current === null) ref.current = createDirectPublicMotionValue(channel.graph, initial)
|
|
212
|
+
return ref.current
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Native subsystem factory for graph-fed values such as scroll offsets. It is intentionally absent
|
|
217
|
+
* from the package entry: only a consumer's direct `useMotionValue` call receives public-direct
|
|
218
|
+
* provenance.
|
|
219
|
+
*/
|
|
220
|
+
export function useInternalMotionValue<T = number>(initial: T): MotionValue<T> {
|
|
221
|
+
const channel = useValueChannel()
|
|
222
|
+
const ref = useRef<MotionValue<T> | null>(null)
|
|
223
|
+
if (ref.current === null) ref.current = channel.graph.motionValue(initial)
|
|
224
|
+
return ref.current
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ——— useMotionValueEvent (REQ-API-027) ———————————————————————————————————————————————————————
|
|
228
|
+
|
|
229
|
+
export function useMotionValueEvent<T>(
|
|
230
|
+
value: MotionValue<T>,
|
|
231
|
+
event: 'change',
|
|
232
|
+
handler: (latest: T) => void,
|
|
233
|
+
): void
|
|
234
|
+
export function useMotionValueEvent<T>(
|
|
235
|
+
value: MotionValue<T>,
|
|
236
|
+
event: Exclude<MotionValueEvent, 'change'>,
|
|
237
|
+
handler: () => void,
|
|
238
|
+
): void
|
|
239
|
+
export function useMotionValueEvent<T>(
|
|
240
|
+
value: MotionValue<T>,
|
|
241
|
+
event: MotionValueEvent,
|
|
242
|
+
handler: ((latest: T) => void) | (() => void),
|
|
243
|
+
): void {
|
|
244
|
+
// Latest-ref so the subscription never holds a stale closure (the presence-rig pattern).
|
|
245
|
+
// Payload typing follows the pinned union (r2 major a66e69c0f004): 'change' delivers the
|
|
246
|
+
// latest value; the lifecycle events carry no payload. The change handler FORWARDS the
|
|
247
|
+
// event's own payload (r16 major 2) — a later re-read of mutable state corrupts event
|
|
248
|
+
// identity under reentrant writes.
|
|
249
|
+
const latest = useRef(handler)
|
|
250
|
+
latest.current = handler
|
|
251
|
+
useInsertionEffect(() => {
|
|
252
|
+
if (event === 'change') {
|
|
253
|
+
return value.on('change', (latestValue: T) =>
|
|
254
|
+
(latest.current as (payload: T) => void)(latestValue),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
return value.on(event as Exclude<MotionValueEvent, 'change'>, () =>
|
|
258
|
+
(latest.current as () => void)(),
|
|
259
|
+
)
|
|
260
|
+
}, [value, event])
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ——— useTransform (REQ-API-027, the five pinned public forms) ————————————————————————————————
|
|
264
|
+
|
|
265
|
+
export type SingleTransformer<I, O> = (input: I) => O
|
|
266
|
+
export type MultiTransformer<I, O> = (inputs: I[]) => O
|
|
267
|
+
export type EasingFunction = (progress: number) => number
|
|
268
|
+
|
|
269
|
+
// Generic like the pinned motion-dom TransformOptions<T> (r13 major 4 — the name must resolve
|
|
270
|
+
// identically through both entries, including the generic form); `never` default keeps the
|
|
271
|
+
// bare name valid, matching the web entry's re-surfaced default.
|
|
272
|
+
export interface TransformOptions<T = never> {
|
|
273
|
+
/** Clamp input to the range (the pinned default). */
|
|
274
|
+
readonly clamp?: boolean
|
|
275
|
+
/**
|
|
276
|
+
* Easing between each pair of range stops (pinned contract: an array must be one item
|
|
277
|
+
* shorter than the ranges — one easing per segment).
|
|
278
|
+
*/
|
|
279
|
+
readonly ease?: EasingFunction | readonly EasingFunction[]
|
|
280
|
+
/** Custom per-segment interpolator, overriding the typed mix (pinned contract). */
|
|
281
|
+
readonly mixer?: (from: T, to: T) => (progress: number) => unknown
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// A faithful port of the pinned interpolate (motion-dom utils/interpolate.ts at the §B pin,
|
|
285
|
+
// review r3): single-stop and equal-two-output constants; the zero-delta first-segment guard;
|
|
286
|
+
// a highest→lowest input reverses BOTH ranges but NOT the ease array (ease[i] belongs to the
|
|
287
|
+
// i-th segment of the REVERSED ranges); mixers are created ONCE per segment with the easing
|
|
288
|
+
// piped in; clamp bounds the INPUT to the range ends. The typed default mixer rides core's
|
|
289
|
+
// parity-pinned value types (numbers lerp; strings parse once per endpoint and mix per
|
|
290
|
+
// family). One deliberate, recorded divergence: a short ease array is a LOUD failure here —
|
|
291
|
+
// the pin silently pipes a broken segment (fail-loud law over pinned undefined behavior).
|
|
292
|
+
function buildInterpolator(
|
|
293
|
+
inputRangeAuthored: readonly number[],
|
|
294
|
+
outputRangeAuthored: readonly (number | string)[],
|
|
295
|
+
options: TransformOptions,
|
|
296
|
+
): (input: number) => unknown {
|
|
297
|
+
// The shipped mixing families are number and string (core parseValue's parity-pinned
|
|
298
|
+
// types). Objects/composites belong to the value-breadth successor work — without a custom
|
|
299
|
+
// mixer they fail TYPED here (r14 major 9), never as parseValue's raw internals.
|
|
300
|
+
if (options.mixer === undefined) {
|
|
301
|
+
for (const output of outputRangeAuthored) {
|
|
302
|
+
if (typeof output !== 'number' && typeof output !== 'string') {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`useTransform: output ${JSON.stringify(output)} is outside the shipped mixing ` +
|
|
305
|
+
'families (number | string) — object/composite outputs are deferred to the ' +
|
|
306
|
+
'value-breadth rung; supply options.mixer to interpolate them (REQ-API-027, ' +
|
|
307
|
+
'specs/R4-VALUE-CHANNEL-BUILD-PACKET.md).',
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (inputRangeAuthored.length !== outputRangeAuthored.length) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`useTransform: inputRange and outputRange must be the same length ` +
|
|
315
|
+
`(got ${inputRangeAuthored.length}/${outputRangeAuthored.length}) — REQ-API-027`,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
if (inputRangeAuthored.length === 1) return () => outputRangeAuthored[0]
|
|
319
|
+
if (inputRangeAuthored.length === 2 && outputRangeAuthored[0] === outputRangeAuthored[1]) {
|
|
320
|
+
return () => outputRangeAuthored[1]
|
|
321
|
+
}
|
|
322
|
+
const isZeroDeltaRange = inputRangeAuthored[0] === inputRangeAuthored[1]
|
|
323
|
+
const descending = inputRangeAuthored[0]! > inputRangeAuthored[inputRangeAuthored.length - 1]!
|
|
324
|
+
const input = descending ? [...inputRangeAuthored].reverse() : inputRangeAuthored
|
|
325
|
+
const output = descending ? [...outputRangeAuthored].reverse() : outputRangeAuthored
|
|
326
|
+
|
|
327
|
+
const segments = input.length - 1
|
|
328
|
+
const easings: readonly EasingFunction[] | null =
|
|
329
|
+
options.ease === undefined
|
|
330
|
+
? null
|
|
331
|
+
: Array.isArray(options.ease)
|
|
332
|
+
? options.ease
|
|
333
|
+
: Array.from({ length: segments }, () => options.ease as EasingFunction)
|
|
334
|
+
if (easings !== null && easings.length !== segments) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`useTransform: an ease array must be one item shorter than the ranges ` +
|
|
337
|
+
`(${segments} segments, got ${easings.length} easings) — the pinned contract`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
const mixerFactory = options.mixer as
|
|
341
|
+
| ((from: number | string, to: number | string) => (progress: number) => unknown)
|
|
342
|
+
| undefined
|
|
343
|
+
// Mixers once per segment (the pinned createMixers), easing piped into each.
|
|
344
|
+
const mixers: Array<(progress: number) => unknown> = []
|
|
345
|
+
for (let i = 0; i < segments; i++) {
|
|
346
|
+
const from = output[i]!
|
|
347
|
+
const to = output[i + 1]!
|
|
348
|
+
let segmentMixer: (progress: number) => unknown
|
|
349
|
+
if (mixerFactory !== undefined) {
|
|
350
|
+
segmentMixer = mixerFactory(from, to)
|
|
351
|
+
} else if (typeof from === 'number' && typeof to === 'number') {
|
|
352
|
+
segmentMixer = (progress) => from + (to - from) * progress
|
|
353
|
+
} else {
|
|
354
|
+
const typedFrom = parseValue(typeof from === 'number' ? String(from) : from)
|
|
355
|
+
const typedTo = parseValue(typeof to === 'number' ? String(to) : to)
|
|
356
|
+
segmentMixer = (progress) => transformValue(mix(typedFrom, typedTo, progress))
|
|
357
|
+
}
|
|
358
|
+
if (easings !== null) {
|
|
359
|
+
const easing = easings[i]!
|
|
360
|
+
const baseMixer = segmentMixer
|
|
361
|
+
segmentMixer = (progress) => baseMixer(easing(progress))
|
|
362
|
+
}
|
|
363
|
+
mixers.push(segmentMixer)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const interpolator = (value: number): unknown => {
|
|
367
|
+
if (isZeroDeltaRange && value < input[0]!) return output[0]
|
|
368
|
+
let i = 0
|
|
369
|
+
if (mixers.length > 1) {
|
|
370
|
+
for (; i < input.length - 2; i++) {
|
|
371
|
+
if (value < input[i + 1]!) break
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const from = input[i]!
|
|
375
|
+
const to = input[i + 1]!
|
|
376
|
+
// Pinned motion-utils progress: a zero-delta segment reports progress 1, not 0.
|
|
377
|
+
const progress = to === from ? 1 : (value - from) / (to - from)
|
|
378
|
+
return mixers[i]!(progress)
|
|
379
|
+
}
|
|
380
|
+
const clampInput = options.clamp !== false
|
|
381
|
+
return clampInput
|
|
382
|
+
? (value: number) =>
|
|
383
|
+
interpolator(Math.min(Math.max(value, input[0]!), input[input.length - 1]!))
|
|
384
|
+
: interpolator
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function useTransform<O extends number | string>(
|
|
388
|
+
value: MotionValue<number>,
|
|
389
|
+
inputRange: readonly number[],
|
|
390
|
+
outputRange: readonly O[],
|
|
391
|
+
// The generic FLOWS into the options (r16 major 4, the pinned TransformOptions<O>): a
|
|
392
|
+
// custom mixer's inferred params are O, never `never`.
|
|
393
|
+
options?: TransformOptions<O>,
|
|
394
|
+
): MotionValue<O>
|
|
395
|
+
// The documented mixer EXCEPTION (r15 7a0e46b8f213): arbitrary output types compile when — and
|
|
396
|
+
// only when — a custom `mixer` interpolates them (`mixer` REQUIRED here; the narrowed form
|
|
397
|
+
// above owns the mixer-less calls). Runtime already routes every segment through the supplied
|
|
398
|
+
// mixer, so no runtime branch changes.
|
|
399
|
+
export function useTransform<O>(
|
|
400
|
+
value: MotionValue<number>,
|
|
401
|
+
inputRange: readonly number[],
|
|
402
|
+
outputRange: readonly O[],
|
|
403
|
+
options: Omit<TransformOptions<O>, 'mixer'> & {
|
|
404
|
+
readonly mixer: (from: O, to: O) => (progress: number) => unknown
|
|
405
|
+
},
|
|
406
|
+
): MotionValue<O>
|
|
407
|
+
// The pinned OUTPUT-MAP form (r11): one input, an object of output ranges, a record of derived
|
|
408
|
+
// values back — keys must stay constant across renders (the pinned contract).
|
|
409
|
+
export function useTransform<T extends Record<string, readonly (number | string)[]>>(
|
|
410
|
+
value: MotionValue<number>,
|
|
411
|
+
inputRange: readonly number[],
|
|
412
|
+
outputMap: T,
|
|
413
|
+
// Pinned: TransformOptions<T[keyof T][number]> — the mixer sees the union of output types.
|
|
414
|
+
options?: TransformOptions<T[keyof T][number]>,
|
|
415
|
+
): { [K in keyof T]: MotionValue<T[K][number]> }
|
|
416
|
+
export function useTransform<I, O>(
|
|
417
|
+
input: MotionValue<I>,
|
|
418
|
+
transformer: SingleTransformer<I, O>,
|
|
419
|
+
): MotionValue<O>
|
|
420
|
+
export function useTransform<I, O>(
|
|
421
|
+
input: readonly MotionValue<I>[],
|
|
422
|
+
transformer: MultiTransformer<I, O>,
|
|
423
|
+
): MotionValue<O>
|
|
424
|
+
export function useTransform<O>(transformer: () => O): MotionValue<O>
|
|
425
|
+
export function useTransform(
|
|
426
|
+
first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
|
|
427
|
+
second?: unknown,
|
|
428
|
+
third?: unknown,
|
|
429
|
+
fourth?: unknown,
|
|
430
|
+
): MotionValue<unknown> | Record<string, MotionValue<unknown>> {
|
|
431
|
+
const channel = useValueChannel()
|
|
432
|
+
// The output-map form derives ONE value per key over a shared input (r11, the pinned fifth
|
|
433
|
+
// form); every other form derives a single value. Both ride the same handle lifecycle.
|
|
434
|
+
const isOutputMap =
|
|
435
|
+
isMotionValue(first) &&
|
|
436
|
+
Array.isArray(second) &&
|
|
437
|
+
typeof third === 'object' &&
|
|
438
|
+
third !== null &&
|
|
439
|
+
!Array.isArray(third)
|
|
440
|
+
// Computes ride refs refreshed EVERY render, so re-parameterized ranges/transformers take
|
|
441
|
+
// effect like the pinned hook (r2 — never silently init-once); the dependency set and (for
|
|
442
|
+
// the map form) the KEY SET stay fixed at first render (the pinned contract).
|
|
443
|
+
const computesRef = useRef<Record<string, () => unknown>>({})
|
|
444
|
+
if (isOutputMap) {
|
|
445
|
+
const source = first as MotionValue<number>
|
|
446
|
+
const inputRange = second as readonly number[]
|
|
447
|
+
const outputMap = third as Record<string, readonly (number | string)[]>
|
|
448
|
+
const options = (fourth as TransformOptions | undefined) ?? {}
|
|
449
|
+
for (const [key, outputRange] of Object.entries(outputMap)) {
|
|
450
|
+
const interpolate = buildInterpolator(inputRange, outputRange, options)
|
|
451
|
+
computesRef.current[key] = () => interpolate(source.get())
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
computesRef.current[SINGLE_KEY] = resolveTransformCompute(first, second, third, fourth)
|
|
455
|
+
}
|
|
456
|
+
const ref = useRef<Record<string, DerivedHandle> | null>(null)
|
|
457
|
+
if (ref.current === null) {
|
|
458
|
+
const handles: Record<string, DerivedHandle> = {}
|
|
459
|
+
const dependencies = resolveTransformDependencies(first)
|
|
460
|
+
for (const key of Object.keys(computesRef.current)) {
|
|
461
|
+
handles[key] = deriveFrom(channel, dependencies, () => computesRef.current[key]!())
|
|
462
|
+
// Render-phase creation leaves NO live input subscription (r8): an aborted concurrent
|
|
463
|
+
// render or a discarded StrictMode render must never retain the sources. The initial
|
|
464
|
+
// value was computed at construction; the commit's attach below resubscribes and resyncs.
|
|
465
|
+
handles[key].detach()
|
|
466
|
+
}
|
|
467
|
+
ref.current = handles
|
|
468
|
+
} else {
|
|
469
|
+
// Re-parameterization applies SYNCHRONOUSLY during render like the pinned hook (r4: a
|
|
470
|
+
// consumer reading during the same render pass must see the new mapping, not wait for a
|
|
471
|
+
// passive effect). The outputs are non-React stores, so a render-phase resync is legal.
|
|
472
|
+
for (const handle of Object.values(ref.current)) handle.refresh()
|
|
473
|
+
}
|
|
474
|
+
// Subscriptions attach per INSERTION-effect lifetime (r8 — live before any layout effect can
|
|
475
|
+
// write); a StrictMode replay detaches and RE-attaches the same output values (r3).
|
|
476
|
+
useInsertionEffect(() => {
|
|
477
|
+
const handles = Object.values(ref.current!)
|
|
478
|
+
for (const handle of handles) handle.attach()
|
|
479
|
+
return () => {
|
|
480
|
+
for (const handle of handles) handle.detach()
|
|
481
|
+
}
|
|
482
|
+
}, [])
|
|
483
|
+
// The record itself is identity-stable across renders (r12 major b27f0d61ac48 — the pinned
|
|
484
|
+
// hook's useConstant): consumers hold it in dependency arrays and memo comparisons.
|
|
485
|
+
const recordRef = useRef<Record<string, MotionValue<unknown>> | null>(null)
|
|
486
|
+
if (isOutputMap) {
|
|
487
|
+
if (recordRef.current === null) {
|
|
488
|
+
const out: Record<string, MotionValue<unknown>> = {}
|
|
489
|
+
for (const [key, handle] of Object.entries(ref.current)) out[key] = handle.value
|
|
490
|
+
recordRef.current = out
|
|
491
|
+
}
|
|
492
|
+
return recordRef.current
|
|
493
|
+
}
|
|
494
|
+
return ref.current[SINGLE_KEY]!.value
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// The single-output forms store their one compute/handle under this reserved key.
|
|
498
|
+
const SINGLE_KEY = '__single__'
|
|
499
|
+
|
|
500
|
+
// The dependency set per pinned form, resolved ONCE at first render.
|
|
501
|
+
function resolveTransformDependencies(
|
|
502
|
+
first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
|
|
503
|
+
): readonly MotionValue<unknown>[] {
|
|
504
|
+
if (typeof first === 'function') return [...collectMotionValueReads(first).reads]
|
|
505
|
+
if (Array.isArray(first)) return first as readonly MotionValue<unknown>[]
|
|
506
|
+
return [first as MotionValue<unknown>]
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The compute per pinned form, rebuilt every render from the CURRENT arguments.
|
|
510
|
+
function resolveTransformCompute(
|
|
511
|
+
first: MotionValue<unknown> | readonly MotionValue<unknown>[] | (() => unknown),
|
|
512
|
+
second?: unknown,
|
|
513
|
+
third?: unknown,
|
|
514
|
+
fourth?: unknown,
|
|
515
|
+
): () => unknown {
|
|
516
|
+
// Form 4 — reactive compute.
|
|
517
|
+
if (typeof first === 'function') return first as () => unknown
|
|
518
|
+
// Form 1 — range mapping.
|
|
519
|
+
if (isMotionValue(first) && Array.isArray(second)) {
|
|
520
|
+
const interpolate = buildInterpolator(
|
|
521
|
+
second as readonly number[],
|
|
522
|
+
third as readonly (number | string)[],
|
|
523
|
+
(fourth as TransformOptions | undefined) ?? {},
|
|
524
|
+
)
|
|
525
|
+
const source = first as MotionValue<number>
|
|
526
|
+
return () => interpolate(source.get())
|
|
527
|
+
}
|
|
528
|
+
// Form 3 — multi-input function.
|
|
529
|
+
if (Array.isArray(first)) {
|
|
530
|
+
const inputs = first as readonly MotionValue<unknown>[]
|
|
531
|
+
const transformer = second as MultiTransformer<unknown, unknown>
|
|
532
|
+
return () => transformer(inputs.map((input) => input.get()))
|
|
533
|
+
}
|
|
534
|
+
// Form 2 — single-input function.
|
|
535
|
+
const input = first as MotionValue<unknown>
|
|
536
|
+
const transformer = second as SingleTransformer<unknown, unknown>
|
|
537
|
+
return () => transformer(input.get())
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isMotionValue(candidate: unknown): candidate is MotionValue<unknown> {
|
|
541
|
+
return (
|
|
542
|
+
typeof candidate === 'object' &&
|
|
543
|
+
candidate !== null &&
|
|
544
|
+
typeof (candidate as MotionValue<unknown>).get === 'function' &&
|
|
545
|
+
typeof (candidate as MotionValue<unknown>).subscribe === 'function' &&
|
|
546
|
+
typeof (candidate as MotionValue<unknown>).getVelocity === 'function'
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
interface DerivedHandle {
|
|
551
|
+
readonly value: MotionValue<unknown>
|
|
552
|
+
attach(): void
|
|
553
|
+
detach(): void
|
|
554
|
+
refresh(): void
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// EVERY derivation rides CORE's derived graph (r5: string/color inputs included — the graph's
|
|
558
|
+
// DerivedSource contract is structural over any MotionValue<T>), so frame-coalesced topological
|
|
559
|
+
// flushing makes ALL diamonds emit one final value (REQ-VALUE-013), and the re-attachable
|
|
560
|
+
// lifecycle (attach/detach/refresh) survives StrictMode replays with pending dirt CANCELLED on
|
|
561
|
+
// detach. No subscription fallback exists — one derivation authority.
|
|
562
|
+
function deriveFrom(
|
|
563
|
+
channel: ValueChannel,
|
|
564
|
+
dependencies: readonly MotionValue<unknown>[],
|
|
565
|
+
compute: () => unknown,
|
|
566
|
+
): DerivedHandle {
|
|
567
|
+
const value = channel.graph.derived(dependencies, () => compute())
|
|
568
|
+
return {
|
|
569
|
+
value,
|
|
570
|
+
attach: () => value.attachInputs(),
|
|
571
|
+
detach: () => value.detachInputs(),
|
|
572
|
+
refresh: () => value.refresh(),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ——— useMotionTemplate (REQ-API-025, U5) ———————————————————————————————————————————————————————
|
|
577
|
+
|
|
578
|
+
// A faithful port of the pinned tagged-template hook (framer-motion/src/value/
|
|
579
|
+
// use-motion-template.ts:28-52 at the §B pin) over the same deriveFrom machinery useTransform
|
|
580
|
+
// rides — exactly as the pin's delegates to useCombineMotionValues (use-combine-values.ts:7-45):
|
|
581
|
+
// the FIRST value computes synchronously at creation, the compute is rebuilt EVERY render so
|
|
582
|
+
// static (non-MotionValue) interpolations read the CURRENT closure (the pin's buildValue), only
|
|
583
|
+
// MotionValue interpolations are dependencies, and attach/detach follows the insertion-effect
|
|
584
|
+
// lifetime (the pinned unsubscribe + cancelFrame cleanup).
|
|
585
|
+
export function useMotionTemplate(
|
|
586
|
+
fragments: TemplateStringsArray,
|
|
587
|
+
...values: Array<MotionValue<unknown> | number | string>
|
|
588
|
+
): MotionValue<string> {
|
|
589
|
+
const channel = useValueChannel()
|
|
590
|
+
const buildRef = useRef<() => string>(() => '')
|
|
591
|
+
buildRef.current = () => {
|
|
592
|
+
let output = ``
|
|
593
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
594
|
+
output += fragments[i]
|
|
595
|
+
const value = values[i]
|
|
596
|
+
// The pinned truthiness check verbatim (use-motion-template.ts:43): a falsy static
|
|
597
|
+
// interpolation (0, '') is OMITTED, not rendered.
|
|
598
|
+
if (value) output += (isMotionValue(value) ? value.get() : value) as string | number
|
|
599
|
+
}
|
|
600
|
+
return output
|
|
601
|
+
}
|
|
602
|
+
const ref = useRef<DerivedHandle | null>(null)
|
|
603
|
+
if (ref.current === null) {
|
|
604
|
+
// The dependency set is fixed at first render like useTransform's (the pinned contract);
|
|
605
|
+
// render-phase creation holds NO live input subscription (r8).
|
|
606
|
+
ref.current = deriveFrom(channel, values.filter(isMotionValue), () => buildRef.current())
|
|
607
|
+
ref.current.detach()
|
|
608
|
+
} else {
|
|
609
|
+
// Re-captured statics apply SYNCHRONOUSLY during render (r4; the pin calls updateValue()
|
|
610
|
+
// during render) — a same-pass reader sees the new statics, not one commit later.
|
|
611
|
+
ref.current.refresh()
|
|
612
|
+
}
|
|
613
|
+
useInsertionEffect(() => {
|
|
614
|
+
const handle = ref.current!
|
|
615
|
+
handle.attach()
|
|
616
|
+
return () => handle.detach()
|
|
617
|
+
}, [])
|
|
618
|
+
return ref.current.value as MotionValue<string>
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ——— useSpring (REQ-API-027) —————————————————————————————————————————————————————————————————
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The PUBLIC spring options (r12 major 9d31a6c8f0e2): a standalone type in the pinned public
|
|
625
|
+
* units — `duration` is SECONDS like motion's SpringOptions, never core's ms-based SpringConfig
|
|
626
|
+
* (an internal type that must not leak through the public API). `skipInitialAnimation` is the
|
|
627
|
+
* pinned FollowValueOptions member: the first tracked-source change jumps instead of animating.
|
|
628
|
+
*/
|
|
629
|
+
export interface SpringValueOptions {
|
|
630
|
+
readonly stiffness?: number
|
|
631
|
+
readonly damping?: number
|
|
632
|
+
readonly mass?: number
|
|
633
|
+
/** Spring duration in SECONDS (the pinned public unit). */
|
|
634
|
+
readonly duration?: number
|
|
635
|
+
/** 0 = no bounce, 1 = extremely bouncy (pinned semantics; pairs with `duration`). */
|
|
636
|
+
readonly bounce?: number
|
|
637
|
+
/** Perceptual duration in seconds; overrides `duration` when set (pinned semantics). */
|
|
638
|
+
readonly visualDuration?: number
|
|
639
|
+
readonly velocity?: number
|
|
640
|
+
/** Rest threshold on speed, units/sec (pinned member; follow default 0.01). */
|
|
641
|
+
readonly restSpeed?: number
|
|
642
|
+
/** Rest threshold on distance-to-target, value units (pinned member; follow default 0.001). */
|
|
643
|
+
readonly restDelta?: number
|
|
644
|
+
/** The first change from a tracked source JUMPS instead of animating (pinned member). */
|
|
645
|
+
readonly skipInitialAnimation?: boolean
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const MILLISECONDS_PER_SECOND = 1000
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The ONE public→core unit boundary (r12): seconds convert to core's ms here and nowhere else.
|
|
652
|
+
* `visualDuration` passes through — core's SpringConfig already takes it in seconds.
|
|
653
|
+
*/
|
|
654
|
+
// The pinned follow-path settle defaults (motion-dom follow-value.ts at the SS B pin:
|
|
655
|
+
// `restDelta: 0.001, restSpeed: 0.01, ...options`): applied before the caller's options so an
|
|
656
|
+
// explicit member always wins.
|
|
657
|
+
const FOLLOW_REST_DELTA = 0.001
|
|
658
|
+
const FOLLOW_REST_SPEED = 0.01
|
|
659
|
+
|
|
660
|
+
export function resolveSpringValueConfig(options: SpringValueOptions): SpringConfig {
|
|
661
|
+
const config: {
|
|
662
|
+
stiffness?: number
|
|
663
|
+
damping?: number
|
|
664
|
+
mass?: number
|
|
665
|
+
duration?: number
|
|
666
|
+
bounce?: number
|
|
667
|
+
visualDuration?: number
|
|
668
|
+
velocity?: number
|
|
669
|
+
restSpeed?: number
|
|
670
|
+
restDelta?: number
|
|
671
|
+
} = {}
|
|
672
|
+
if (options.stiffness !== undefined) config.stiffness = options.stiffness
|
|
673
|
+
if (options.damping !== undefined) config.damping = options.damping
|
|
674
|
+
if (options.mass !== undefined) config.mass = options.mass
|
|
675
|
+
if (options.duration !== undefined) {
|
|
676
|
+
config.duration = options.duration * MILLISECONDS_PER_SECOND
|
|
677
|
+
}
|
|
678
|
+
if (options.bounce !== undefined) config.bounce = options.bounce
|
|
679
|
+
if (options.visualDuration !== undefined) config.visualDuration = options.visualDuration
|
|
680
|
+
if (options.velocity !== undefined) config.velocity = options.velocity
|
|
681
|
+
if (options.restSpeed !== undefined) config.restSpeed = options.restSpeed
|
|
682
|
+
if (options.restDelta !== undefined) config.restDelta = options.restDelta
|
|
683
|
+
return config
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The pinned velocity override (r14 major 7 — follow-value.ts spreads `...options` AFTER the
|
|
687
|
+
// sampled velocity, so an explicit `velocity` option replaces the seed's). Carry/zero by
|
|
688
|
+
// resolution type (REQ-SPRING-007) still applies downstream.
|
|
689
|
+
// The follow path's settle defaults ride the PUBLIC options (pinned spread order — defaults
|
|
690
|
+
// first, the caller's members win), keeping resolveSpringValueConfig a pure unit mapping.
|
|
691
|
+
function resolveFollowSpringConfig(options: SpringValueOptions): SpringConfig {
|
|
692
|
+
return resolveSpringValueConfig({
|
|
693
|
+
restDelta: FOLLOW_REST_DELTA,
|
|
694
|
+
restSpeed: FOLLOW_REST_SPEED,
|
|
695
|
+
...options,
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function withSeedVelocity(
|
|
700
|
+
factory: ReturnType<typeof resolveSpringGenerator>,
|
|
701
|
+
velocity: number | undefined,
|
|
702
|
+
): ReturnType<typeof resolveSpringGenerator> {
|
|
703
|
+
if (velocity === undefined) return factory
|
|
704
|
+
return (seed) => factory({ from: seed.from, velocity })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function useSpring(
|
|
708
|
+
source: MotionValue<number> | number,
|
|
709
|
+
options: SpringValueOptions = {},
|
|
710
|
+
): MotionValue<number> {
|
|
711
|
+
const channel = useValueChannel()
|
|
712
|
+
// The LATEST options ride a ref (r12 major 19a46e8b3c75): a retarget after a rerender uses
|
|
713
|
+
// the current config, never the first render's capture.
|
|
714
|
+
const optionsRef = useRef(options)
|
|
715
|
+
optionsRef.current = options
|
|
716
|
+
const ref = useRef<MotionValue<number> | null>(null)
|
|
717
|
+
const followerRef = useRef<MotionValue<number> | null>(null)
|
|
718
|
+
// The LIVE tracked-source unsubscribe (r15 91d5aa6cb730): destroy() must detach it like the
|
|
719
|
+
// pinned attachFollow teardown — a source outliving a destroyed follower must never animate
|
|
720
|
+
// the corpse. Written by the attach effect; read by the wrapper's destroy.
|
|
721
|
+
const sourceDetachRef = useRef<(() => void) | undefined>(undefined)
|
|
722
|
+
if (ref.current === null) {
|
|
723
|
+
const initial = isMotionValue(source) ? (source as MotionValue<number>).get() : source
|
|
724
|
+
const follower = channel.graph.motionValue(initial)
|
|
725
|
+
followerRef.current = follower
|
|
726
|
+
const springTo = (target: number): void => {
|
|
727
|
+
channel.graph.animate(
|
|
728
|
+
follower,
|
|
729
|
+
withSeedVelocity(
|
|
730
|
+
resolveSpringGenerator(target, resolveFollowSpringConfig(optionsRef.current)),
|
|
731
|
+
optionsRef.current.velocity,
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
// The returned value IS the follower for reads/subscriptions; `set` retargets the coupling
|
|
736
|
+
// spring (the pinned useSpring semantic) and `jump` teleports — both delegate every other
|
|
737
|
+
// behavior to the ONE underlying graph value (no parallel state, REQ-DRIVER-029).
|
|
738
|
+
ref.current = {
|
|
739
|
+
get: () => follower.get(),
|
|
740
|
+
set: springTo,
|
|
741
|
+
// Core jump owns the pinned order (update -> notify -> stop; r16 major 1) — a wrapper
|
|
742
|
+
// pre-stop would emit the cancellation BEFORE the change event, reversing the oracle.
|
|
743
|
+
jump: (value: number) => follower.jump(value),
|
|
744
|
+
subscribe: (listener) => follower.subscribe(listener),
|
|
745
|
+
getVelocity: () => follower.getVelocity(),
|
|
746
|
+
// The two-overload public `on` (r16 major 5): delegate by narrowing the event — the
|
|
747
|
+
// wrapper adds no shape of its own.
|
|
748
|
+
on: ((event: MotionValueEvent, listener: (latest: number) => void) =>
|
|
749
|
+
event === 'change'
|
|
750
|
+
? follower.on('change', listener)
|
|
751
|
+
: follower.on(event, listener as unknown as () => void)) as MotionValue<number>['on'],
|
|
752
|
+
stop: () => follower.stop(),
|
|
753
|
+
isAnimating: () => follower.isAnimating(),
|
|
754
|
+
destroy: () => {
|
|
755
|
+
// Pinned attachFollow removes the source listener when the follower is destroyed.
|
|
756
|
+
sourceDetachRef.current?.()
|
|
757
|
+
sourceDetachRef.current = undefined
|
|
758
|
+
follower.destroy()
|
|
759
|
+
},
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Serialized options as the attach dependency — the PINNED re-parameterization law
|
|
763
|
+
// (use-follow-value.ts at the §B pin attaches on `[value, JSON.stringify(options)]`): an
|
|
764
|
+
// options change re-attaches the coupling, which also resets skipInitialAnimation's
|
|
765
|
+
// first-change tracking exactly like the pinned attachFollow lifetime.
|
|
766
|
+
const serializedOptions = JSON.stringify(options)
|
|
767
|
+
useInsertionEffect(() => {
|
|
768
|
+
const follower = followerRef.current!
|
|
769
|
+
// Pinned FollowValueOptions.skipInitialAnimation: the FIRST tracked change per attach
|
|
770
|
+
// lifetime jumps; subsequent changes animate.
|
|
771
|
+
let sawFirstChange = false
|
|
772
|
+
const unsubscribe = isMotionValue(source)
|
|
773
|
+
? (source as MotionValue<number>).subscribe(() => {
|
|
774
|
+
const target = (source as MotionValue<number>).get()
|
|
775
|
+
const skipFirst = optionsRef.current.skipInitialAnimation === true && !sawFirstChange
|
|
776
|
+
sawFirstChange = true
|
|
777
|
+
if (skipFirst) {
|
|
778
|
+
follower.jump(target)
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
channel.graph.animate(
|
|
782
|
+
follower,
|
|
783
|
+
withSeedVelocity(
|
|
784
|
+
resolveSpringGenerator(target, resolveFollowSpringConfig(optionsRef.current)),
|
|
785
|
+
optionsRef.current.velocity,
|
|
786
|
+
),
|
|
787
|
+
)
|
|
788
|
+
})
|
|
789
|
+
: undefined
|
|
790
|
+
sourceDetachRef.current = unsubscribe
|
|
791
|
+
return () => {
|
|
792
|
+
// The PINNED attachFollow cleanup shape (r14 major 8): a TRACKED source's cleanup only
|
|
793
|
+
// removes listeners — an options-change reattach must never freeze a mid-flight
|
|
794
|
+
// follower (the next source change animates under the new options). The DIRECT form's
|
|
795
|
+
// cleanup IS stopAnimation in the pin, so an options change stops its in-flight spring.
|
|
796
|
+
if (unsubscribe !== undefined) unsubscribe()
|
|
797
|
+
else follower.stop()
|
|
798
|
+
if (sourceDetachRef.current === unsubscribe) sourceDetachRef.current = undefined
|
|
799
|
+
}
|
|
800
|
+
// Coupling identity is fixed per (channel, options) attach lifetime; `source` identity is
|
|
801
|
+
// fixed at first render like the pinned hook's `value`.
|
|
802
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
803
|
+
}, [serializedOptions])
|
|
804
|
+
useInsertionEffect(() => {
|
|
805
|
+
const follower = followerRef.current!
|
|
806
|
+
return () => {
|
|
807
|
+
// UNMOUNT stops the coupling spring for BOTH forms — the r2 major 58aa03d1a law: a
|
|
808
|
+
// source outliving its consumer must not keep animating orphaned work in the shared
|
|
809
|
+
// graph. (Options-change reattaches above deliberately do not pass through here.)
|
|
810
|
+
follower.stop()
|
|
811
|
+
}
|
|
812
|
+
}, [])
|
|
813
|
+
return ref.current
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ——— useVelocity (REQ-API-027) ———————————————————————————————————————————————————————————————
|
|
817
|
+
|
|
818
|
+
export function useVelocity(source: MotionValue<number>): MotionValue<number> {
|
|
819
|
+
const channel = useValueChannel()
|
|
820
|
+
const explicitPort = useContext(ValueHooksIdentityChannelContext)
|
|
821
|
+
// The identity-channel cache port resolves ONCE at first render (the pinned dependency-fixing
|
|
822
|
+
// contract): an explicit provider wins (test-only); the canonical channel registration is the
|
|
823
|
+
// production seam. Without either, the source is an ordinary graph value and the legacy
|
|
824
|
+
// core-graph observation below applies — cache-only observation needs no fallback crossing.
|
|
825
|
+
const resolutionRef = useRef<ValueHooksIdentityChannelResolution | null>(null)
|
|
826
|
+
if (
|
|
827
|
+
resolutionRef.current === null &&
|
|
828
|
+
(explicitPort !== null || canonicalIdentityPorts.has(channel))
|
|
829
|
+
) {
|
|
830
|
+
resolutionRef.current = resolveValueHooksIdentityChannelPort(channel, explicitPort)
|
|
831
|
+
}
|
|
832
|
+
const resolution = resolutionRef.current
|
|
833
|
+
const ref = useRef<MotionValue<number> | null>(null)
|
|
834
|
+
// Seed from the CURRENT source velocity (r12 major 5ce847a2d913, the pinned hook's
|
|
835
|
+
// `useMotionValue(value.getVelocity())`): mounting against an already-moving source must not
|
|
836
|
+
// report a phantom zero until the next change arrives. The identity channel reads the
|
|
837
|
+
// TRANSPORTED CACHE (zero synchronous UI crossings, Decision 13) — never source.getVelocity().
|
|
838
|
+
if (ref.current === null) {
|
|
839
|
+
ref.current = channel.graph.motionValue(
|
|
840
|
+
resolution === null
|
|
841
|
+
? source.getVelocity()
|
|
842
|
+
: resolution.port.readTransportedCachedVelocity(source, resolution.port.now()),
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
const out = ref.current
|
|
846
|
+
useInsertionEffect(() => {
|
|
847
|
+
if (resolution !== null) {
|
|
848
|
+
const port = resolution.port
|
|
849
|
+
// Cache-only observer cadence: observer frames re-read the transported cache — never a
|
|
850
|
+
// synchronous source read, never a wall-clock timer, never an animationComplete
|
|
851
|
+
// subscription (a completion has no cache authority; staleness decay is the only zero).
|
|
852
|
+
let cancelFrame: (() => void) | null = null
|
|
853
|
+
const arm = (): void => {
|
|
854
|
+
if (cancelFrame !== null) return
|
|
855
|
+
cancelFrame = port.requestObserverFrame((atTimestampMs) => {
|
|
856
|
+
cancelFrame = null
|
|
857
|
+
const cached = port.readTransportedCachedVelocity(source, atTimestampMs)
|
|
858
|
+
out.set(cached)
|
|
859
|
+
if (cached !== 0) arm()
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
const unsubscribe = port.subscribeTransportedChange(source, () => {
|
|
863
|
+
const cached = port.readTransportedCachedVelocity(source, port.now())
|
|
864
|
+
out.set(cached)
|
|
865
|
+
if (cached !== 0) arm()
|
|
866
|
+
})
|
|
867
|
+
// Arm only while nonzero: a zero cache needs no decay re-reads.
|
|
868
|
+
if (out.get() !== 0) arm()
|
|
869
|
+
return () => {
|
|
870
|
+
if (cancelFrame !== null) cancelFrame()
|
|
871
|
+
unsubscribe()
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// A manual sample goes stale silently — change events stop arriving, so a nonzero velocity
|
|
875
|
+
// would otherwise persist forever (review r1 major 5). Each sample arms ONE bounded trailing
|
|
876
|
+
// re-read past core's staleness window; getVelocity() itself reports the decay to zero.
|
|
877
|
+
let trailing: ReturnType<typeof setTimeout> | null = null
|
|
878
|
+
const sample = (): void => {
|
|
879
|
+
out.set(source.getVelocity())
|
|
880
|
+
if (trailing !== null) clearTimeout(trailing)
|
|
881
|
+
trailing = setTimeout(() => {
|
|
882
|
+
trailing = null
|
|
883
|
+
const decayed = source.getVelocity()
|
|
884
|
+
out.set(decayed)
|
|
885
|
+
channel.kick()
|
|
886
|
+
}, VELOCITY_DECAY_RESAMPLE_MS)
|
|
887
|
+
}
|
|
888
|
+
const unsubscribeChange = source.subscribe(sample)
|
|
889
|
+
// Core's rest law: an animation ending puts the value at rest — mirror the zero explicitly.
|
|
890
|
+
const unsubscribeComplete = source.on('animationComplete', () => out.set(0))
|
|
891
|
+
return () => {
|
|
892
|
+
if (trailing !== null) clearTimeout(trailing)
|
|
893
|
+
unsubscribeChange()
|
|
894
|
+
unsubscribeComplete()
|
|
895
|
+
}
|
|
896
|
+
}, [source, out, channel, resolution])
|
|
897
|
+
return out
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Just past core's MAX_VELOCITY_DELTA_MS staleness window, so the re-read observes the decay.
|
|
901
|
+
const VELOCITY_DECAY_RESAMPLE_MS = 60
|