solidjs-motion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2627 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/default-values.d.ts +6 -0
  8. package/dist/src/index.d.ts +16 -0
  9. package/dist/src/motion-config.d.ts +14 -0
  10. package/dist/src/motion-proxy.d.ts +103 -0
  11. package/dist/src/presence-context.d.ts +4 -0
  12. package/dist/src/presence.d.ts +95 -0
  13. package/dist/src/primitives/createDrag.d.ts +16 -0
  14. package/dist/src/primitives/createDragControls.d.ts +30 -0
  15. package/dist/src/primitives/createGestures.d.ts +8 -0
  16. package/dist/src/primitives/createInView.d.ts +51 -0
  17. package/dist/src/primitives/createMotion.d.ts +82 -0
  18. package/dist/src/primitives/createPan.d.ts +83 -0
  19. package/dist/src/primitives/createScroll.d.ts +40 -0
  20. package/dist/src/primitives/gesture-state.d.ts +108 -0
  21. package/dist/src/primitives/motion-value.d.ts +111 -0
  22. package/dist/src/primitives/value-registry.d.ts +34 -0
  23. package/dist/src/reduced-motion.d.ts +29 -0
  24. package/dist/src/style.d.ts +79 -0
  25. package/dist/src/types.d.ts +374 -0
  26. package/dist/src/use-motion.d.ts +35 -0
  27. package/dist/src/variants.d.ts +64 -0
  28. package/package.json +78 -0
  29. package/src/default-values.ts +52 -0
  30. package/src/index.ts +60 -0
  31. package/src/motion-config.tsx +37 -0
  32. package/src/motion-proxy.tsx +377 -0
  33. package/src/presence-context.ts +32 -0
  34. package/src/presence.tsx +466 -0
  35. package/src/primitives/createDrag.ts +670 -0
  36. package/src/primitives/createDragControls.ts +101 -0
  37. package/src/primitives/createGestures.ts +145 -0
  38. package/src/primitives/createInView.ts +124 -0
  39. package/src/primitives/createMotion.ts +638 -0
  40. package/src/primitives/createPan.ts +338 -0
  41. package/src/primitives/createScroll.ts +101 -0
  42. package/src/primitives/gesture-state.ts +772 -0
  43. package/src/primitives/motion-value.ts +328 -0
  44. package/src/primitives/value-registry.ts +114 -0
  45. package/src/reduced-motion.ts +51 -0
  46. package/src/style.ts +266 -0
  47. package/src/types.ts +538 -0
  48. package/src/use-motion.tsx +412 -0
  49. package/src/variants.ts +134 -0
@@ -0,0 +1,772 @@
1
+ import { type AnimationPlaybackControls, animate, isMotionValue, type MotionValue } from "motion"
2
+ import { type Accessor, createEffect, createMemo, onCleanup, untrack } from "solid-js"
3
+ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
4
+ import { getMotionDefault } from "../default-values"
5
+ import { shouldReduceMotion } from "../reduced-motion"
6
+ import type {
7
+ AnimateValue,
8
+ MotionConfigContextValue,
9
+ MotionElement,
10
+ MotionOptions,
11
+ ResolvedValues,
12
+ Target,
13
+ Transition,
14
+ VariantContextValue,
15
+ } from "../types"
16
+ import { asVariantLabels, mergeTransition, resolveTarget } from "./createMotion"
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Solid-native fine-grained gesture state machine (ADR 0002).
20
+ //
21
+ // Implements three of the four jobs motion's createAnimationState handles:
22
+ // 1. Priority resolution — high-to-low among active states
23
+ // 2. Per-key handoff — when a higher-priority state deactivates, lower-priority
24
+ // states (or fallbacks) take over each key it was animating
25
+ // 3. Variant resolution — reuses Phase 1's resolveTarget for label→Target lookup
26
+ //
27
+ // Job 4 (parent-child variant inheritance via variantChildren) is handled
28
+ // reactively through Phase 1's VariantContext + Q4's active-gated label slots,
29
+ // NOT via this state machine — keeping the inheritance tree Solid-owned.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** State names, ordered low → high priority. Matches motion-dom's variantPriorityOrder. */
33
+ const STATE_NAMES = [
34
+ "animate",
35
+ "whileInView",
36
+ "whileHover",
37
+ "whilePress",
38
+ "whileFocus",
39
+ "whileDrag",
40
+ "exit",
41
+ ] as const
42
+
43
+ export type GestureStateName = (typeof STATE_NAMES)[number]
44
+
45
+ /** High → low priority for the winners walk. Materialized once. */
46
+ const PRIORITY_HIGH_TO_LOW: readonly GestureStateName[] = [...STATE_NAMES].reverse()
47
+
48
+ /** A key's resolved value plus the (optional) per-target transition that produced it. */
49
+ type WinnerEntry = {
50
+ value: unknown
51
+ transition: Transition | undefined
52
+ /** Which state contributed this key — used by the diff effect's onAnimationComplete bookkeeping. */
53
+ stateName: GestureStateName
54
+ }
55
+
56
+ export type SetActive = (state: GestureStateName, isActive: boolean) => void
57
+
58
+ /** The reactive store of active gesture flags, lifted to the caller for sharing. */
59
+ export type ActiveStore = Store<Record<GestureStateName, boolean>>
60
+ export type SetActiveStore = SetStoreFunction<Record<GestureStateName, boolean>>
61
+ export type ActiveStoreTuple = [ActiveStore, SetActiveStore]
62
+
63
+ export type CreateGestureStateMachineDeps = {
64
+ el: MotionElement
65
+ getOpts: () => MotionOptions
66
+ parentVariantCtx: VariantContextValue
67
+ motionConfig: MotionConfigContextValue
68
+ systemReducedMotion: Accessor<boolean>
69
+ /** Captured at construction. Used as the first stop in the removed-key fallback chain (Q7). */
70
+ initialTarget: Target | null
71
+ /**
72
+ * Optional external active store (Q4 — useMotion lifts this up so its
73
+ * `myVariantCtx` can read the same flags it propagates to descendants).
74
+ * When omitted, the state machine creates its own internal store —
75
+ * backward-compatible for `createMotion` direct users.
76
+ */
77
+ externalActiveStore?: ActiveStoreTuple
78
+ /**
79
+ * Phase 3 — when an enclosing `<Presence initial={false}>` is active, this
80
+ * is passed through to suppress the first-mount animate (mirrors the
81
+ * existing `initial: false` user-opt-out, but driven from above by
82
+ * Presence instead of by the user's own options).
83
+ */
84
+ suppressFirstMount?: boolean
85
+ /**
86
+ * Phase 3 — readiness gate for the first-mount animate when this motion
87
+ * element is wrapped in a real `<Presence>`. The state machine reads this
88
+ * on each iteration; when it's `false` AND we haven't run yet, the diff
89
+ * effect short-circuits (no animate dispatch, no MV subscriptions sealed).
90
+ * Presence flips it to `true` from its `onEnter` / `onChange.added`
91
+ * callback, at which point the effect re-runs and treats THAT iteration
92
+ * as the first.
93
+ *
94
+ * Outside a Presence (no-op default context), createMotion leaves this
95
+ * `undefined` — the state machine treats absence as `ready=true` and the
96
+ * existing eager-first-iteration behavior is unchanged.
97
+ *
98
+ * Rationale: real `motion.animate()` is a Web Animations API call that
99
+ * runs even on a disconnected element, but its terminal `commitStyles`
100
+ * silently no-ops when the element is off-DOM. For a `mode: "wait"` swap
101
+ * the new child is created BEFORE the old child's exit completes, so
102
+ * dispatching the first animate eagerly would let it complete in the
103
+ * detached state and the element would paint at its `initial` target
104
+ * when it finally enters the DOM. Deferring until `onEnter` (when
105
+ * transition-group has synchronously inserted the element via
106
+ * `setReturned`) closes that gap.
107
+ */
108
+ enterReady?: Accessor<boolean>
109
+ /**
110
+ * MV-in-style Stage 3 bridge. When provided, the diff effect calls this
111
+ * per animate-target key. A returned `MotionValue` routes the animation
112
+ * through that MV (`animate(mv, value, opts)`) — its change-subscription
113
+ * (in createMotion) composes `el.style` from the registry. `undefined`
114
+ * routes the key down the existing `animate(el, target, opts)` WAA path.
115
+ *
116
+ * createMotion only activates this when at least one external MV is
117
+ * registered (i.e., the user supplied `style: { scale: mv }`-shaped
118
+ * options). Inactive in the common case → 293 baseline tests stay on
119
+ * the original code path, their animateSpy assertions unaffected.
120
+ */
121
+ getValueForAnimate?: (key: string, fallback: unknown) => MotionValue<unknown> | undefined
122
+ }
123
+
124
+ export type GestureStateMachine = {
125
+ /** Imperatively toggle a gesture state. Triggers re-resolution + animate(). */
126
+ setActive: SetActive
127
+ /**
128
+ * Resolves when the next animate dispatched while `exit` is the highest-
129
+ * priority active driver state completes. If no exit animation is in
130
+ * flight AND no exit target is defined, resolves immediately.
131
+ *
132
+ * Used by `createMotion`'s presence registration: the registered
133
+ * `runExit` callable does `setActive("exit", true)` then awaits this.
134
+ * When the exit animation settles, `<Presence>` (or the hook) gets a
135
+ * resolved Promise and proceeds with DOM removal.
136
+ *
137
+ * Multiple concurrent waiters are supported — they all resolve from the
138
+ * same animation's completion.
139
+ */
140
+ onceExitComplete: () => Promise<void>
141
+ }
142
+
143
+ /**
144
+ * Construct the per-element gesture state machine.
145
+ *
146
+ * Wired primitives:
147
+ * - `createStore` for the seven active flags — Solid tracks per-path, so
148
+ * toggling `whileHover` doesn't dirty memos reading `whilePress`.
149
+ * - `createMemo` for `stateTargets` — cached, re-runs only when opts/parent
150
+ * context change.
151
+ * - `createMemo` for `winners` — same caching, re-runs when `active` flags or
152
+ * `stateTargets` change.
153
+ * - `createEffect` for the diff-and-animate loop — fires on `winners` change;
154
+ * compares against `lastApplied` to compute changed/removed keys.
155
+ * - `onCleanup` inside the effect for per-iteration MV subscriptions — scoped
156
+ * to each effect run (fires on re-run AND owner disposal). Same iteration-
157
+ * scoped cleanup pattern Phase 1 established.
158
+ */
159
+ export function createGestureStateMachine(
160
+ deps: CreateGestureStateMachineDeps,
161
+ ): GestureStateMachine {
162
+ const {
163
+ el,
164
+ getOpts,
165
+ parentVariantCtx,
166
+ motionConfig,
167
+ systemReducedMotion,
168
+ initialTarget,
169
+ externalActiveStore,
170
+ suppressFirstMount,
171
+ enterReady,
172
+ getValueForAnimate,
173
+ } = deps
174
+
175
+ // ---------- Active flags ----------
176
+ // `animate` defaults true: it's the baseline state (mirrors motion's
177
+ // createTypeState(true) for animate). All other states start inactive.
178
+ // If the caller provided an external store (Q4 — useMotion lifts this up
179
+ // so myVariantCtx can read the same flags), reuse it; else create our own.
180
+ const [active, setActiveStore] =
181
+ externalActiveStore ??
182
+ createStore<Record<GestureStateName, boolean>>({
183
+ animate: true,
184
+ whileInView: false,
185
+ whileHover: false,
186
+ whilePress: false,
187
+ whileFocus: false,
188
+ whileDrag: false,
189
+ exit: false,
190
+ })
191
+
192
+ // ---------- Per-state resolved targets ----------
193
+ // createMemo (not createComputed): reads only run when opts/parent change,
194
+ // and the value is cached for downstream consumers. The animate call is
195
+ // frame-async tolerant — the side-effect createEffect below handles timing.
196
+ const stateTargets = createMemo<Record<GestureStateName, Target | null>>(() => {
197
+ const opts = getOpts()
198
+ const variants = opts.variants
199
+ const custom = opts.custom ?? parentVariantCtx.custom?.()
200
+ return {
201
+ animate: resolveTarget(
202
+ opts.animate,
203
+ variants,
204
+ asVariantLabels(parentVariantCtx.animate?.()),
205
+ custom,
206
+ ),
207
+ whileInView: resolveTarget(
208
+ opts.inView,
209
+ variants,
210
+ asVariantLabels(parentVariantCtx.inView?.()),
211
+ custom,
212
+ ),
213
+ whileHover: resolveTarget(
214
+ opts.hover,
215
+ variants,
216
+ asVariantLabels(parentVariantCtx.hover?.()),
217
+ custom,
218
+ ),
219
+ whilePress: resolveTarget(
220
+ opts.press,
221
+ variants,
222
+ asVariantLabels(parentVariantCtx.press?.()),
223
+ custom,
224
+ ),
225
+ whileFocus: resolveTarget(
226
+ opts.focus,
227
+ variants,
228
+ asVariantLabels(parentVariantCtx.focus?.()),
229
+ custom,
230
+ ),
231
+ // whileDrag — resolved like any other gesture state's target. Drag's
232
+ // visual state composes with drag's translation through the shared
233
+ // VisualElement (Q5/C-lean).
234
+ whileDrag: resolveTarget(opts.whileDrag, variants, undefined, custom),
235
+ exit: resolveTarget(opts.exit, variants, asVariantLabels(parentVariantCtx.exit?.()), custom),
236
+ }
237
+ })
238
+
239
+ // ---------- Per-key winners (priority resolution + per-key claim) ----------
240
+ // Walks PRIORITY_HIGH_TO_LOW. A state is considered active if EITHER its
241
+ // own flag is true OR the parent's VariantContext provides a label for it
242
+ // (Q4 — gesture inheritance through context). The first active state that
243
+ // defines a key claims it; lower-priority states are skipped for that key.
244
+ //
245
+ // Q5/C-lean exclusion: when `drag` is enabled, `x` and `y` are owned by
246
+ // createDrag (it writes them to the VisualElement's MotionValues during
247
+ // pointer phase). Filter them out of the winners map so motion's animate
248
+ // (called from this effect) doesn't fight drag's writes. Other transform
249
+ // keys (scale, rotate, etc.) still flow normally — they compose with
250
+ // drag's translation through the shared VisualElement.
251
+ //
252
+ // EXCEPTION: when `exit` is active, exit's x/y MUST override drag's claim.
253
+ // Otherwise an element being dragged at the moment of unmount would
254
+ // exit-animate without translation (drag would silently win every frame),
255
+ // which contradicts the priority chain's stated semantic (exit is highest).
256
+ // Drag's pointer listeners will release anyway when the element unmounts;
257
+ // exit's translation reaches DOM until that happens.
258
+ const winners = createMemo<Record<string, WinnerEntry>>(() => {
259
+ const targets = stateTargets()
260
+ const dragEnabled = Boolean(getOpts().drag)
261
+ const out: Record<string, WinnerEntry> = {}
262
+ for (const stateName of PRIORITY_HIGH_TO_LOW) {
263
+ if (!isStateActive(stateName, active, parentVariantCtx)) continue
264
+ const target = targets[stateName]
265
+ if (!target) continue
266
+ for (const key in target) {
267
+ // `transition` is animation config, not a style key — never a winner.
268
+ if (key === "transition") continue
269
+ // Higher-priority state already won this key.
270
+ if (key in out) continue
271
+ // x/y are drag-owned when drag is enabled (Q5/C-lean) — unless exit
272
+ // is currently active, in which case exit's translation wins.
273
+ if (!active.exit && dragEnabled && (key === "x" || key === "y")) continue
274
+ out[key] = {
275
+ value: (target as Record<string, unknown>)[key],
276
+ transition: target.transition,
277
+ stateName,
278
+ }
279
+ }
280
+ }
281
+ return out
282
+ })
283
+
284
+ // ---------- Diff-and-animate effect ----------
285
+ // The single site that calls motion's animate(). Diffs winners against
286
+ // lastApplied to compute changed keys (a) and removed keys (b). Removed keys
287
+ // walk Q7's fallback chain: initial → motion default → null.
288
+ let prevControls: AnimationPlaybackControls | null = null
289
+ let lastApplied: Record<string, unknown> = {}
290
+ let isFirstRun = true
291
+
292
+ // ---------- onceExitComplete plumbing (Phase 3 — Presence integration) ----------
293
+ // Resolvers queued by `onceExitComplete()` waiters. Drain happens when an
294
+ // exit-driven animate dispatched from this effect resolves. Multiple waiters
295
+ // for the same exit batch all resolve from one drain.
296
+ let pendingExitResolvers: Array<() => void> = []
297
+ function drainPendingExitResolvers(): void {
298
+ const resolvers = pendingExitResolvers
299
+ pendingExitResolvers = []
300
+ for (const r of resolvers) r()
301
+ }
302
+
303
+ createEffect(() => {
304
+ const next = winners()
305
+ const opts = getOpts()
306
+
307
+ // Presence-aware readiness gate: when the surrounding `<Presence>` is
308
+ // still holding this element off-DOM (the new child during a mode="wait"
309
+ // swap, or the initial child before appear's enterTransition runs),
310
+ // we MUST NOT dispatch the first animate. Web Animations API will run
311
+ // it to completion off-DOM, then silently drop the final commitStyles —
312
+ // the element would paint at its `initial` target when it eventually
313
+ // enters the DOM. Skip the entire iteration (winners() above subscribed
314
+ // us to future changes); when Presence flips enterReady this effect
315
+ // re-runs and the iteration below treats THAT pass as the first.
316
+ if (isFirstRun && enterReady && !enterReady()) {
317
+ return
318
+ }
319
+
320
+ // First-mount guard: either the user opted out via `initial: false` OR
321
+ // an enclosing `<Presence initial={false}>` propagated suppression via
322
+ // `suppressFirstMount`. Either path seeds lastApplied so the next
323
+ // iteration treats current winners as already-applied. We fall through
324
+ // to the MV subscription loop so subsequent MV.set() drives animate.
325
+ let skipAnimate = false
326
+ if (isFirstRun && (untrack(() => opts.initial) === false || suppressFirstMount)) {
327
+ lastApplied = snapshotValues(next)
328
+ skipAnimate = true
329
+ }
330
+ isFirstRun = false
331
+
332
+ // Bail out completely only when there is nothing to do AND nothing has
333
+ // been applied yet (no lastApplied to revert). The previous version of
334
+ // this guard also bailed when `next` was empty even if lastApplied still
335
+ // held values from a previous gesture — preventing the removed-key
336
+ // fallback from reverting. The looser condition keeps the same early-out
337
+ // for the initial idle case while letting deactivation reverts run.
338
+ const bailOnNoTarget =
339
+ !skipAnimate &&
340
+ Object.keys(next).length === 0 &&
341
+ Object.keys(lastApplied).length === 0 &&
342
+ opts.animate === undefined &&
343
+ parentVariantCtx.animate?.() === undefined
344
+ if (bailOnNoTarget) return
345
+
346
+ // Compute changes: (a) keys with new/changed values, (b) removed keys.
347
+ const changes: Record<string, unknown> = {}
348
+ let mergedPerTargetTransition: Transition | undefined
349
+
350
+ if (!skipAnimate) {
351
+ for (const key in next) {
352
+ const entry = next[key]
353
+ // `noUncheckedIndexedAccess` widens record reads to `T | undefined`,
354
+ // but `for (key in obj)` only yields present keys — entry is real.
355
+ if (!entry) continue
356
+ if (lastApplied[key] !== entry.value) {
357
+ changes[key] = entry.value
358
+ // First non-undefined per-target transition wins. (If multiple winners
359
+ // contribute conflicting transitions, the highest-priority one already
360
+ // took precedence in the priority walk.)
361
+ mergedPerTargetTransition ??= entry.transition
362
+ }
363
+ }
364
+ for (const key in lastApplied) {
365
+ if (key in next) continue
366
+ // Removed-key fallback: own initial → motion default → null.
367
+ const initialValue =
368
+ initialTarget && key in (initialTarget as Record<string, unknown>)
369
+ ? (initialTarget as Record<string, unknown>)[key]
370
+ : undefined
371
+ changes[key] = initialValue !== undefined ? initialValue : getMotionDefault(key)
372
+ }
373
+ }
374
+
375
+ // Transition merge: MotionConfig default < user's transition < per-target
376
+ // transition < reduced-motion override (Phase 1's mergeTransition).
377
+ const reduced = shouldReduceMotion(motionConfig.reducedMotion(), systemReducedMotion())
378
+ const transition = mergeTransition(
379
+ motionConfig.transition(),
380
+ opts.transition,
381
+ mergedPerTargetTransition,
382
+ reduced,
383
+ )
384
+
385
+ // Track which animate value triggered this — used by onAnimationComplete.
386
+ // If `next` has any key from `animate` state, the effective value is opts.animate.
387
+ // If only gesture states are active, the highest-priority active state's value drives.
388
+ const driverState = highestActiveDriverState(next)
389
+ const effectiveAnimateValue = animateValueForState(driverState, opts, parentVariantCtx)
390
+
391
+ // Animate options builder — read at fire-time so reactive callback swaps
392
+ // apply between calls (per Phase 1 semantics). Closed over by the
393
+ // MV-on-change subscriptions below as well as the diff dispatch.
394
+ const buildAnimateOptions = () => ({
395
+ ...transition,
396
+ onPlay: opts.onAnimationStart ? () => untrack(() => opts.onAnimationStart?.()) : undefined,
397
+ onComplete: opts.onAnimationComplete
398
+ ? () =>
399
+ untrack(() => {
400
+ if (effectiveAnimateValue != null) {
401
+ opts.onAnimationComplete?.(effectiveAnimateValue)
402
+ }
403
+ })
404
+ : undefined,
405
+ onStop: opts.onAnimationCancel ? () => untrack(() => opts.onAnimationCancel?.()) : undefined,
406
+ onUpdate: opts.onUpdate
407
+ ? (latest: ResolvedValues) => untrack(() => opts.onUpdate?.(latest))
408
+ : undefined,
409
+ })
410
+
411
+ if (!skipAnimate && Object.keys(changes).length > 0) {
412
+ // Update lastApplied to the new winner snapshot (NOT including removed-
413
+ // key fallback values — those become "applied" only after the animation
414
+ // lands, but tracking that requires onUpdate plumbing. For diff purposes,
415
+ // we consider them applied immediately; if the user re-activates a state
416
+ // that brings the key back, the diff sees `lastApplied[key] = fallback`
417
+ // vs `next[key] = newValue` and animates correctly).
418
+ lastApplied = { ...lastApplied, ...changes }
419
+ // Then drop keys that don't appear in `next` from lastApplied so future
420
+ // re-removals don't compare against stale fallback values.
421
+ for (const key in lastApplied) {
422
+ if (!(key in next) && !(key in changes)) delete lastApplied[key]
423
+ }
424
+
425
+ // ---------- splitTarget: separate MotionValue refs from plain values ----------
426
+ // Preserved from Phase 1: motion's vanilla animate(el, target) doesn't
427
+ // subscribe to MotionValue refs in target values. We split and seed the
428
+ // animate call with snapshots; per-MV subscription happens below.
429
+ const { plain } = splitTarget(changes)
430
+
431
+ // ---------- Stage 3 bridge: split `plain` by routing destination ----------
432
+ // When createMotion's `getValueForAnimate` returns an MV for a key, the
433
+ // tween runs against that MV (transient or external) and the registry's
434
+ // writer composes el.style.transform. When it returns undefined (the
435
+ // common case — no style MVs), the key falls through to the existing
436
+ // `animate(el, target, opts)` WAA path. With NO routed keys, we make a
437
+ // single WAA call exactly like before, preserving the call shape that
438
+ // baseline tests assert against.
439
+ const routed: Array<{ mv: MotionValue<unknown>; value: unknown }> = []
440
+ const waaPlain: Record<string, unknown> = {}
441
+ for (const key in plain) {
442
+ const value = plain[key]
443
+ const fallback =
444
+ initialTarget && key in (initialTarget as Record<string, unknown>)
445
+ ? (initialTarget as Record<string, unknown>)[key]
446
+ : getMotionDefault(key)
447
+ const routedMV = getValueForAnimate?.(key, fallback)
448
+ if (routedMV) {
449
+ routed.push({ mv: routedMV, value })
450
+ } else {
451
+ waaPlain[key] = value
452
+ }
453
+ }
454
+
455
+ // Cancel any in-flight animation before kicking off the next one.
456
+ prevControls?.stop()
457
+ const animOpts = buildAnimateOptions()
458
+ if (routed.length === 0) {
459
+ // Pure WAA path — identical to the pre-Stage-3 behavior.
460
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate has a complex overloaded shape we can't tighten generically; the runtime call is correct.
461
+ prevControls = animate(el, waaPlain as any, animOpts)
462
+ } else {
463
+ // Bridge path — one tween per routed MV plus (optionally) a single
464
+ // WAA call for non-routed keys. `aggregateControls` combines them
465
+ // into a thenable that .stop()s each and resolves when all settle,
466
+ // so the exit-drain logic below works uniformly across both shapes.
467
+ const controls: AnimationPlaybackControls[] = []
468
+ for (const { mv, value } of routed) {
469
+ // biome-ignore lint/suspicious/noExplicitAny: same as above
470
+ controls.push(animate(mv as any, value as any, animOpts as any))
471
+ }
472
+ if (Object.keys(waaPlain).length > 0) {
473
+ // biome-ignore lint/suspicious/noExplicitAny: same as above
474
+ controls.push(animate(el, waaPlain as any, animOpts))
475
+ }
476
+ prevControls = aggregateControls(controls)
477
+ }
478
+
479
+ // Drain `onceExitComplete()` waiters when this dispatch is driven by
480
+ // the exit state — i.e., Presence is awaiting the unmount animation.
481
+ // motion's AnimationPlaybackControls is thenable at runtime (motion
482
+ // returns a thenable handle) but the public type doesn't surface
483
+ // `.then` — narrow via PromiseLike. The promise settles on natural
484
+ // completion OR cancellation (`.stop()` from a subsequent effect
485
+ // run). Both should drain — from the caller's perspective the exit
486
+ // animation is "done" either way. The freshness check ensures a stale
487
+ // dispatch doesn't drain a newer animation's waiters.
488
+ if (driverState === "exit") {
489
+ const dispatched = prevControls
490
+ const thenable = dispatched as unknown as PromiseLike<unknown>
491
+ thenable.then(() => {
492
+ if (prevControls === dispatched) drainPendingExitResolvers()
493
+ })
494
+ }
495
+ } else if (driverState === "exit") {
496
+ // Exit is the driver but no animate ran (target absent or no key diff).
497
+ // Drain immediately so any awaiting `runExit` callers don't hang.
498
+ drainPendingExitResolvers()
499
+ }
500
+
501
+ // ---------- MotionValue-in-target subscriptions ----------
502
+ // Walk `next` (the FULL winner set) rather than `changes` (the diff) and
503
+ // subscribe a per-key animate callback to each MV's `change` event. This
504
+ // loop runs on EVERY effect iteration that gets past the bailOnNoTarget
505
+ // guard above, including iterations that produced no diff.
506
+ //
507
+ // Bug fix: previously this loop lived inside the "has changes" branch.
508
+ // Sibling effects in createGestures (notably the inView wiring's
509
+ // setActive call after IntersectionObserver's first emission) can
510
+ // invalidate the winners memo without changing any actual value. On
511
+ // those re-runs, the iteration-scoped `onCleanup` from the prior run
512
+ // unsubscribed the MV listeners and we never reattached, dropping all
513
+ // future MV.set() → animate plumbing. Walking `next` here keeps the
514
+ // subscriptions in lockstep with the effect's lifetime.
515
+ for (const key in next) {
516
+ const entry = next[key]
517
+ if (!entry) continue
518
+ if (isMotionValue(entry.value)) {
519
+ const targetMV = entry.value as MotionValue<unknown>
520
+ onCleanup(
521
+ targetMV.on("change", (v) => {
522
+ // Stage 3: route through the registry the same way the main
523
+ // dispatch does, so a style MV the user also wrote into
524
+ // `animate` doesn't bypass the writer's transform composition.
525
+ const fallback =
526
+ initialTarget && key in (initialTarget as Record<string, unknown>)
527
+ ? (initialTarget as Record<string, unknown>)[key]
528
+ : getMotionDefault(key)
529
+ const routedMV = getValueForAnimate?.(key, fallback)
530
+ if (routedMV && routedMV !== targetMV) {
531
+ // biome-ignore lint/suspicious/noExplicitAny: motion's animate overload soup; runtime correct
532
+ animate(routedMV as any, v as any, buildAnimateOptions() as any)
533
+ } else {
534
+ // biome-ignore lint/suspicious/noExplicitAny: same as above
535
+ animate(el, { [key]: v } as any, buildAnimateOptions())
536
+ }
537
+ }),
538
+ )
539
+ }
540
+ }
541
+ })
542
+
543
+ // Owner-disposal cleanup: stop any in-flight animation.
544
+ onCleanup(() => prevControls?.stop())
545
+
546
+ function setActive(state: GestureStateName, isActive: boolean): void {
547
+ setActiveStore(state, isActive)
548
+ }
549
+
550
+ /**
551
+ * Phase 3 — Presence integration. Returns a Promise that resolves when the
552
+ * NEXT exit-driven animate dispatched by the diff effect completes, OR
553
+ * immediately if no exit target is configured (nothing to wait for).
554
+ *
555
+ * The typical caller is `createMotion`'s presence-registered `runExit`:
556
+ * it flips `setActive("exit", true)` then awaits this. The diff effect
557
+ * runs in the next microtask, dispatches the exit animation, and on its
558
+ * completion drains the pending resolvers.
559
+ *
560
+ * Multiple concurrent waiters are supported — they all resolve from the
561
+ * same animation's completion.
562
+ *
563
+ * Edge case: if the user reactively removes `opts.exit` AFTER this call
564
+ * but before the effect runs, the resolver will still be drained the
565
+ * next time exit drives a dispatch (or by the "no-animate but exit-
566
+ * driven" branch in the effect).
567
+ */
568
+ function onceExitComplete(): Promise<void> {
569
+ const exitTarget = untrack(() => stateTargets().exit)
570
+ if (exitTarget === null) return Promise.resolve()
571
+ return new Promise<void>((resolve) => {
572
+ pendingExitResolvers.push(resolve)
573
+ })
574
+ }
575
+
576
+ return { setActive, onceExitComplete }
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Helpers
581
+ // ---------------------------------------------------------------------------
582
+
583
+ /**
584
+ * Q4 — a state is considered active if EITHER its own flag is set OR the
585
+ * parent's VariantContext carries a label for it (the parent's gesture is
586
+ * active and propagating). The parent slots are themselves active-gated in
587
+ * `useMotion`'s `myVariantCtx`, so a defined return value here means the
588
+ * parent's gesture really is firing right now.
589
+ *
590
+ * `animate` and `exit` are special — their inheritance happens through the
591
+ * normal label-resolution path in `resolveTarget`, not through the active
592
+ * flag. We treat `animate` as always-active (matches motion's
593
+ * createTypeState(true)). `exit` is driven by the Presence context; the
594
+ * flag-based check is fine.
595
+ */
596
+ function isStateActive(
597
+ state: GestureStateName,
598
+ active: ActiveStore,
599
+ parent: VariantContextValue,
600
+ ): boolean {
601
+ if (active[state]) return true
602
+ switch (state) {
603
+ case "whileHover":
604
+ return parent.hover?.() !== undefined
605
+ case "whilePress":
606
+ return parent.press?.() !== undefined
607
+ case "whileFocus":
608
+ return parent.focus?.() !== undefined
609
+ case "whileInView":
610
+ return parent.inView?.() !== undefined
611
+ // Drag inheritance through context isn't wired in Phase 1's
612
+ // VariantContextValue (no `drag` slot). Commit 6 will revisit if needed.
613
+ case "whileDrag":
614
+ return false
615
+ case "animate":
616
+ case "exit":
617
+ return false
618
+ }
619
+ }
620
+
621
+ /** Convert a winners map into the flat value snapshot used by `lastApplied`. */
622
+ function snapshotValues(winners: Record<string, WinnerEntry>): Record<string, unknown> {
623
+ const out: Record<string, unknown> = {}
624
+ for (const key in winners) {
625
+ const entry = winners[key]
626
+ if (entry) out[key] = entry.value
627
+ }
628
+ return out
629
+ }
630
+
631
+ /**
632
+ * Phase 1's splitTarget: separate MotionValue refs in a target from plain
633
+ * values. Motion-vanilla `animate(el, target)` doesn't subscribe to MV refs
634
+ * passed in target — we handle that bridge ourselves.
635
+ */
636
+ function splitTarget(target: Record<string, unknown>): {
637
+ plain: Record<string, unknown>
638
+ motionValues: Array<{ key: string; mv: MotionValue<unknown> }>
639
+ } {
640
+ const plain: Record<string, unknown> = {}
641
+ const motionValues: Array<{ key: string; mv: MotionValue<unknown> }> = []
642
+ for (const key in target) {
643
+ const value = target[key]
644
+ if (value === undefined || value === null) {
645
+ plain[key] = value
646
+ continue
647
+ }
648
+ if (isMotionValue(value)) {
649
+ motionValues.push({ key, mv: value as MotionValue<unknown> })
650
+ plain[key] = (value as MotionValue<unknown>).get()
651
+ } else if (typeof value === "function") {
652
+ plain[key] = (value as () => unknown)()
653
+ } else if (Array.isArray(value)) {
654
+ plain[key] = value.map((v) => {
655
+ if (isMotionValue(v)) return (v as MotionValue<unknown>).get()
656
+ if (typeof v === "function") return (v as () => unknown)()
657
+ return v
658
+ })
659
+ } else {
660
+ plain[key] = value
661
+ }
662
+ }
663
+ return { plain, motionValues }
664
+ }
665
+
666
+ /**
667
+ * Return the highest-priority active state that contributed any key in the
668
+ * current winners map. Used to identify the "driver" for onAnimationComplete
669
+ * (which receives the AnimateValue that drove the animation).
670
+ *
671
+ * `animate` is the fallback when no gesture is contributing — matches Phase 1's
672
+ * effectiveAnimateValue semantic.
673
+ */
674
+ function highestActiveDriverState(winners: Record<string, WinnerEntry>): GestureStateName {
675
+ // Walk PRIORITY_HIGH_TO_LOW and find the first state name that appears.
676
+ for (const stateName of PRIORITY_HIGH_TO_LOW) {
677
+ for (const key in winners) {
678
+ const entry = winners[key]
679
+ if (entry && entry.stateName === stateName) return stateName
680
+ }
681
+ }
682
+ return "animate"
683
+ }
684
+
685
+ /**
686
+ * Look up the AnimateValue (Target | string | string[]) that corresponds to a
687
+ * given state — for onAnimationComplete's argument.
688
+ */
689
+ function animateValueForState(
690
+ state: GestureStateName,
691
+ opts: MotionOptions,
692
+ parentVariantCtx: VariantContextValue,
693
+ ): AnimateValue | undefined {
694
+ switch (state) {
695
+ case "animate":
696
+ return opts.animate ?? parentVariantCtx.animate?.()
697
+ case "whileHover":
698
+ return opts.hover ?? parentVariantCtx.hover?.()
699
+ case "whilePress":
700
+ return opts.press ?? parentVariantCtx.press?.()
701
+ case "whileFocus":
702
+ return opts.focus ?? parentVariantCtx.focus?.()
703
+ case "whileInView":
704
+ return opts.inView ?? parentVariantCtx.inView?.()
705
+ case "exit":
706
+ return opts.exit ?? parentVariantCtx.exit?.()
707
+ case "whileDrag":
708
+ return opts.whileDrag
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Combine N AnimationPlaybackControls into a single Thenable+stoppable handle.
714
+ *
715
+ * Used by the Stage 3 bridge when an animate dispatch fans out across per-MV
716
+ * `animate(mv, value, opts)` calls (one per routed key) plus an optional
717
+ * single `animate(el, target, opts)` for keys still on the WAA path. The
718
+ * gesture state machine treats `prevControls` as one handle: subsequent diff
719
+ * runs call `.stop()` on it to cancel the in-flight animation, and the exit
720
+ * drain awaits `.then(...)` to settle Presence's `onceExitComplete()` waiters.
721
+ * Aggregating lets both code paths stay uniform whether bridging fired one
722
+ * underlying motion call or six.
723
+ *
724
+ * The other AnimationPlaybackControls methods (pause/play/cancel/complete)
725
+ * fan out unchanged. `time`/`speed`/`duration` aren't aggregated — they're
726
+ * read-rare in our codebase and a meaningful aggregate isn't well-defined
727
+ * across heterogeneous animations.
728
+ */
729
+ function aggregateControls(
730
+ controls: readonly AnimationPlaybackControls[],
731
+ ): AnimationPlaybackControls {
732
+ // Cache the settle promise so multiple `.then` consumers don't each spawn a
733
+ // fresh Promise.all over the same controls. motion's AnimationPlaybackControls
734
+ // is thenable at runtime (the public type omits `.then`, hence the casts).
735
+ let settled: Promise<unknown[]> | null = null
736
+ const settle = (): Promise<unknown[]> => {
737
+ if (!settled) {
738
+ settled = Promise.all(controls.map((c) => c as unknown as PromiseLike<unknown>))
739
+ }
740
+ return settled
741
+ }
742
+ const forAll = (fn: (c: AnimationPlaybackControls) => void): void => {
743
+ for (const c of controls) fn(c)
744
+ }
745
+ const handle: Record<string, unknown> = {
746
+ stop: () => {
747
+ forAll((c) => c.stop())
748
+ },
749
+ pause: () => {
750
+ forAll((c) => c.pause())
751
+ },
752
+ play: () => {
753
+ forAll((c) => c.play())
754
+ },
755
+ cancel: () => {
756
+ forAll((c) => c.cancel())
757
+ },
758
+ complete: () => {
759
+ forAll((c) => c.complete())
760
+ },
761
+ speed: 1,
762
+ time: 0,
763
+ duration: controls.reduce(
764
+ (acc, c) => Math.max(acc, (c as { duration?: number }).duration ?? 0),
765
+ 0,
766
+ ),
767
+ // biome-ignore lint/suspicious/noThenProperty: structurally mirroring motion's AnimationPlaybackControls, which is intentionally thenable.
768
+ then: (onFulfilled?: unknown, onRejected?: unknown) =>
769
+ settle().then(onFulfilled as never, onRejected as never),
770
+ }
771
+ return handle as unknown as AnimationPlaybackControls
772
+ }