@octane-xplat/motion 0.9.0 → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  import { act, createRoot, useEffect } from 'octane'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
- import { motion, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
3
+ import { motion, Presence, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
4
4
 
5
5
  function Box(props: any) @{
6
6
  <motion.View id="box" initial={{ x: 20 }} animate={props.target} transition={{ duration: 0 }} onAnimationComplete={props.done} />
@@ -133,3 +133,236 @@ it('observes live system reduced-motion changes', () => {
133
133
  expect((mounted.container.querySelector('#user-reduced') as HTMLElement).style.transform).toContain('translateX(100px)')
134
134
  mounted.close(); expect(callbacks.size).toBe(0); vi.unstubAllGlobals()
135
135
  })
136
+
137
+ import { useAnimate } from './index.web'
138
+ import { View } from '@octane-xplat/ui'
139
+
140
+ function TapScale() @{
141
+ <motion.Pressable id="tap" animate={{ scale: 1 }} whileTap={{ scale: 1.5 }} whileTapTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
142
+ }
143
+
144
+ describe('upstream-shaped props', () => {
145
+ it('animates whileTap on pointerdown and returns on release', () => {
146
+ const mounted = mount(<TapScale />)
147
+ const node = mounted.container.querySelector('#tap') as HTMLElement
148
+ node.dispatchEvent(new Event('pointerdown', { bubbles: true }))
149
+ expect(node.style.transform).toContain('scale(1.5)')
150
+ node.dispatchEvent(new Event('pointerup', { bubbles: true }))
151
+ expect(node.style.transform).toContain('scale(1)')
152
+ mounted.close()
153
+ })
154
+
155
+ it('animates whileFocus on focus and returns on blur', () => {
156
+ const mounted = mount(<TapFocusCase />)
157
+ const node = mounted.container.querySelector('#focusable') as HTMLElement
158
+ node.dispatchEvent(new Event('focus'))
159
+ expect(node.style.transform).toContain('translateX(20px)')
160
+ node.dispatchEvent(new Event('blur'))
161
+ expect(node.style.transform).toContain('translateX(0px)')
162
+ mounted.close()
163
+ })
164
+
165
+ it('fires onAnimationStart and onUpdate for declarative runs', () => {
166
+ const mounted = mount(<CallbackCase />)
167
+ expect(callbackState.started).toBe(1)
168
+ expect(callbackState.updated).toBeGreaterThan(0)
169
+ mounted.close()
170
+ callbackState.started = 0
171
+ callbackState.updated = 0
172
+ })
173
+
174
+ it('wraps an arbitrary leaf with motion.create', () => {
175
+ const mounted = mount(<CreatedCase />)
176
+ const node = mounted.container.querySelector('#created') as HTMLElement
177
+ expect(node.style.transform).toContain('translateX(30px)')
178
+ mounted.close()
179
+ })
180
+
181
+ it('animates through useAnimate scope and a MotionValue directly', async () => {
182
+ const mounted = mount(<ScopedCase />)
183
+ const node = mounted.container.querySelector('#scoped') as HTMLElement
184
+ await scopedApi.animate({ x: 40 }, { duration: 0 })
185
+ expect(node.style.transform).toContain('translateX(40px)')
186
+ const direct = scopedApi.animate(scopedApi.value, 77, { duration: 0 })
187
+ await direct.finished
188
+ expect(scopedApi.value.get()).toBe(77)
189
+ mounted.close()
190
+ })
191
+ })
192
+
193
+ const callbackState = { started: 0, updated: 0 }
194
+ function CallbackCase() @{
195
+ <motion.View
196
+ id="callbacks"
197
+ animate={{ x: 5 }}
198
+ transition={{ duration: 0 }}
199
+ onAnimationStart={() => callbackState.started++}
200
+ onUpdate={() => callbackState.updated++}
201
+ />
202
+ }
203
+
204
+ function TapFocusCase() @{
205
+ <motion.View id="focusable" animate={{ x: 0 }} whileFocus={{ x: 20 }} whileFocusTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
206
+ }
207
+
208
+ const MotionCard = motion.create(function Card(props: any) @{
209
+ <View {...props} />
210
+ })
211
+
212
+ function CreatedCase() @{
213
+ <MotionCard id="created" animate={{ x: 30 }} transition={{ duration: 0 }} />
214
+ }
215
+
216
+ const scopedApi: { animate: any; value: MotionValue } = { animate: undefined, value: undefined as any }
217
+ function ScopedCase() @{
218
+ const [scope, animate] = useAnimate()
219
+ const value = useMotionValue(0)
220
+ useEffect(() => {
221
+ scopedApi.animate = animate
222
+ scopedApi.value = value
223
+ }, [])
224
+
225
+ <motion.View id="scoped" ref={scope} />
226
+ }
227
+
228
+ function DragBox(props: any) @{
229
+ <MotionConfig reducedMotion="never"><motion.View id="drag" {...props} /></MotionConfig>
230
+ }
231
+
232
+ function pointer(node: HTMLElement, type: string, x: number, y = 0, pointerId = 1) {
233
+ const event = new MouseEvent(type, { clientX: x, clientY: y, button: 0, bubbles: true })
234
+ Object.defineProperty(event, 'pointerId', { value: pointerId })
235
+ node.dispatchEvent(event)
236
+ }
237
+
238
+ describe('declarative pointer drag', () => {
239
+ it('writes a bound MotionValue live, captures one pointer, clamps, and cleans up', () => {
240
+ const x = new MotionValue(10, clock)
241
+ const started = vi.fn(), moved = vi.fn(), ended = vi.fn()
242
+ const mounted = mount(<DragBox drag="x" style={{ x }} dragConstraints={{ left: -30, right: 30 }} dragElastic={false} dragMomentum={false} onDragStart={started} onDrag={moved} onDragEnd={ended} />)
243
+ const node = mounted.container.querySelector('#drag') as HTMLElement
244
+ node.setPointerCapture = vi.fn()
245
+ expect(node.style.touchAction).toBe('pan-y')
246
+ pointer(node, 'pointerdown', 0)
247
+ pointer(node, 'pointermove', 4)
248
+ expect(started).not.toHaveBeenCalled()
249
+ pointer(node, 'pointermove', 100, 0, 2)
250
+ expect(x.get()).toBe(10)
251
+ pointer(node, 'pointermove', 100)
252
+ expect(x.get()).toBe(30)
253
+ expect(node.style.transform).toContain('translateX(30px)')
254
+ pointer(node, 'pointerup', 100)
255
+ expect(started).toHaveBeenCalledTimes(1)
256
+ expect(ended).toHaveBeenCalledTimes(1)
257
+ expect(node.setPointerCapture).toHaveBeenCalledWith(1)
258
+ mounted.close()
259
+ pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', -100)
260
+ expect(x.get()).toBe(30)
261
+ expect(node.style.touchAction).toBe('')
262
+ x.destroy()
263
+ })
264
+
265
+ it('updates drag axis and disables listeners on prop changes', () => {
266
+ const mounted = mount(<DragBox drag="x" dragMomentum={false} />)
267
+ const node = mounted.container.querySelector('#drag') as HTMLElement
268
+ act(() => mounted.root.render(<DragBox drag="y" dragMomentum={false} />))
269
+ expect(node.style.touchAction).toBe('pan-x')
270
+ pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30); pointer(node, 'pointerup', 0, 30)
271
+ expect(node.style.transform).toContain('translateY(30px)')
272
+ act(() => mounted.root.render(<DragBox drag={false} />))
273
+ pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 80)
274
+ expect(node.style.transform).toContain('translateY(30px)')
275
+ mounted.close()
276
+ })
277
+
278
+ it('yields perpendicular movement and reports active cancellation once', () => {
279
+ const end = vi.fn(), start = vi.fn()
280
+ const mounted = mount(<DragBox drag="x" dragMomentum={false} onDragStart={start} onDragEnd={end} />)
281
+ const node = mounted.container.querySelector('#drag') as HTMLElement
282
+ pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30)
283
+ expect(start).not.toHaveBeenCalled()
284
+ pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 30)
285
+ pointer(node, 'pointercancel', 30); pointer(node, 'lostpointercapture', 30)
286
+ expect(end).toHaveBeenCalledTimes(1)
287
+ expect(end.mock.calls[0][1].cancelled).toBe(true)
288
+ mounted.close()
289
+ })
290
+ })
291
+
292
+ function VariantTree(props: any) @{
293
+ <motion.View initial="hidden" animate={props.label} variants={{ hidden: { x: -10 }, show: { x: 10, transition: { duration: 0, when: 'beforeChildren' } }, hide: { x: 0, transition: { duration: 0 } } }}>
294
+ <motion.View id="variant-child" custom={props.custom} variants={{ hidden: { x: -20 }, show: (custom: number) => ({ x: custom, transition: { duration: 0 } }), hide: { x: 0, transition: { duration: 0 } } }} />
295
+ <motion.View id="variant-explicit" animate={{ x: 99 }} transition={{ duration: 0 }} />
296
+ </motion.View>
297
+ }
298
+
299
+ function VariantGesture() @{
300
+ <motion.View id="variant-gesture" animate={['base', 'position']} whileTap="tap" whileFocus="focus" variants={{ base: { scale: 1, transition: { duration: 0 } }, position: { x: 5 }, tap: { scale: 2, transition: { duration: 0 } }, focus: { x: 30, transition: { duration: 0 } } }} />
301
+ }
302
+
303
+ it('inherits labels and initial targets while explicit animate remains independent', async () => {
304
+ const mounted = mount(<VariantTree label="show" custom={40} />)
305
+ const child = mounted.container.querySelector('#variant-child') as HTMLElement
306
+ await act(async () => {})
307
+ expect(child.style.transform).toContain('translateX(40px)')
308
+ expect((mounted.container.querySelector('#variant-explicit') as HTMLElement).style.transform).toContain('translateX(99px)')
309
+ act(() => mounted.root.render(<VariantTree label="hide" custom={40} />))
310
+ await act(async () => {})
311
+ expect(mounted.container.querySelector('#variant-child')).toBe(child)
312
+ expect(child.style.transform).toContain('translateX(0px)')
313
+ mounted.close()
314
+ })
315
+
316
+ it('resolves gesture labels and restores the resolved animate target', () => {
317
+ const mounted = mount(<VariantGesture />)
318
+ const host = mounted.container.querySelector('#variant-gesture') as HTMLElement
319
+ host.dispatchEvent(new Event('pointerdown'))
320
+ expect(host.style.transform).toContain('scale(2)')
321
+ host.dispatchEvent(new Event('pointerup'))
322
+ expect(host.style.transform).toContain('scale(1)')
323
+ host.dispatchEvent(new Event('focus'))
324
+ expect(host.style.transform).toContain('translateX(30px)')
325
+ host.dispatchEvent(new Event('blur'))
326
+ expect(host.style.transform).toContain('translateX(5px)')
327
+ mounted.close()
328
+ })
329
+
330
+ it('retargets an inherited resolver when custom changes without a new parent label', async () => {
331
+ const mounted = mount(<VariantTree label="show" custom={40} />)
332
+ await act(async () => {})
333
+ act(() => mounted.root.render(<VariantTree label="show" custom={60} />))
334
+ await act(async () => {})
335
+ expect((mounted.container.querySelector('#variant-child') as HTMLElement).style.transform).toContain('translateX(60px)')
336
+ mounted.close()
337
+ })
338
+
339
+ function InitialVariantTree() @{
340
+ <motion.View initial={false} animate="show" variants={{ show: { x: 10 } }}>
341
+ <motion.View id="initial-variant" variants={{ show: { x: 90 } }} />
342
+ </motion.View>
343
+ }
344
+
345
+ it('inherits initial=false and seeds child destinations without playback', () => {
346
+ const mounted = mount(<InitialVariantTree />)
347
+ expect((mounted.container.querySelector('#initial-variant') as HTMLElement).style.transform).toContain('translateX(90px)')
348
+ mounted.close()
349
+ })
350
+
351
+ function VariantExit(props: any) @{
352
+ <Presence present={props.present} onExitComplete={props.done}>
353
+ <motion.View id="variant-exit" initial={false} animate="show" exit={['hide', 'offset']} variants={{ show: { x: 0 }, hide: { x: 70, transition: { duration: 0.1 } }, offset: { x: 100 } }} transition={{ duration: 20 }} />
354
+ </Presence>
355
+ }
356
+
357
+ it('uses exit label arrays and their transition overrides under Presence', async () => {
358
+ vi.useFakeTimers()
359
+ const done = vi.fn()
360
+ const mounted = mount(<VariantExit present={true} done={done} />)
361
+ act(() => mounted.root.render(<VariantExit present={false} done={done} />))
362
+ expect(mounted.container.querySelector('#variant-exit')).not.toBeNull()
363
+ await act(async () => { await vi.advanceTimersByTimeAsync(160) })
364
+ expect(mounted.container.querySelector('#variant-exit')).toBeNull()
365
+ expect(done).toHaveBeenCalledOnce()
366
+ mounted.close()
367
+ vi.useRealTimers()
368
+ })
package/src/controller.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { MotionValue } from './value'
2
2
  import type { Clock } from './clock-types'
3
- import type { Target, MotionKey, Transition } from './types'
4
- import type { AnimationResult } from './engine'
3
+ import type { HostAdapter, DelegatedRun } from './host-types'
4
+ import type { Target, MotionKey, TransitionInput } from './types'
5
+ import { isBezierEase, isOrchestrated, resolveTransition, type AnimationResult } from './engine'
5
6
 
6
7
  export const keys: MotionKey[] = ['opacity', 'x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
7
8
  export const defaults: Required<Target> = {
@@ -24,10 +25,13 @@ export function validateTarget(target?: Target) {
24
25
 
25
26
  export class Controller {
26
27
  readonly values = new Map<MotionKey, MotionValue>()
27
- private write?: (target: Target) => void
28
+ /** Per-write snapshot callback (rAF-rate during delegated and JS runs). */
29
+ onUpdate?: (snapshot: Target) => void
30
+ private adapter?: HostAdapter
31
+ private delegated?: { run: DelegatedRun; frame: number; trackSample: () => void }
28
32
  private generation = 0
29
33
  constructor(private clock: Clock) {}
30
- private value(key: MotionKey) {
34
+ value(key: MotionKey) {
31
35
  let value = this.values.get(key)
32
36
  if (!value) {
33
37
  value = new MotionValue(defaults[key], this.clock)
@@ -37,15 +41,27 @@ export class Controller {
37
41
 
38
42
  return value
39
43
  }
40
- attach(write: (target: Target) => void) {
41
- this.write = write
44
+ attach(adapter: HostAdapter) {
45
+ this.adapter = adapter
42
46
  this.flush()
43
47
  }
48
+ private cancelDelegated() {
49
+ if (!this.delegated) {
50
+ return
51
+ }
52
+
53
+ this.clock.cancel(this.delegated.frame)
54
+ this.delegated.trackSample()
55
+ this.delegated.run.cancel()
56
+ this.delegated = undefined
57
+ }
44
58
  snapshot(): Target {
45
59
  return Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
46
60
  }
47
61
  flush() {
48
- this.write?.(Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()])))
62
+ const snapshot = Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
63
+ this.adapter?.write(snapshot)
64
+ this.onUpdate?.(snapshot)
49
65
  }
50
66
  set(key: MotionKey, next: number) {
51
67
  this.value(key).jump(next)
@@ -58,19 +74,114 @@ export class Controller {
58
74
  }
59
75
  async animate(
60
76
  target: Target,
61
- transition: Transition,
77
+ transition: TransitionInput,
62
78
  reduced: boolean,
63
79
  ): Promise<AnimationResult> {
64
80
  validateTarget(target)
65
81
  const generation = ++this.generation
82
+ this.cancelDelegated()
66
83
  // A new destination owns the entire declarative animation, including removed keys.
67
84
  for (const value of this.values.values()) {
68
85
  value.stop('replaced')
69
86
  }
70
87
 
88
+ if (
89
+ !reduced &&
90
+ !isOrchestrated(transition) &&
91
+ transition.type !== 'spring' &&
92
+ (transition.duration ?? 0.3) > 0 &&
93
+ (transition.repeat ?? 0) === 0 &&
94
+ isBezierEase(transition.ease) &&
95
+ this.adapter?.delegate
96
+ ) {
97
+ const dest: Target = { ...this.snapshot(), ...target }
98
+ const run = this.adapter.delegate({
99
+ target,
100
+ dest,
101
+ eff: {
102
+ opacity: dest.opacity,
103
+ x: dest.x,
104
+ y: dest.y,
105
+ scaleX: (dest.scale ?? 1) * (dest.scaleX ?? 1),
106
+ scaleY: (dest.scale ?? 1) * (dest.scaleY ?? 1),
107
+ rotate: dest.rotate,
108
+ },
109
+ transition,
110
+ })
111
+
112
+ if (run) {
113
+ const trackSample = () => {
114
+ const eff = run.sample()
115
+ for (const key of keys) {
116
+ if (target[key] === undefined) {
117
+ continue
118
+ }
119
+
120
+ let next: number | undefined
121
+ if (key === 'scale') {
122
+ next = (eff.scaleX ?? 1) / (dest.scaleX || 1)
123
+ } else if (key === 'scaleX') {
124
+ next = (eff.scaleX ?? 1) / (dest.scale || 1)
125
+ } else if (key === 'scaleY') {
126
+ next = (eff.scaleY ?? 1) / (dest.scale || 1)
127
+ } else {
128
+ next = eff[key]
129
+ }
130
+
131
+ if (next !== undefined && Number.isFinite(next)) {
132
+ this.value(key).track(next)
133
+ }
134
+ }
135
+
136
+ this.onUpdate?.(this.snapshot())
137
+ }
138
+
139
+ const entry = { run, frame: 0, trackSample }
140
+ const tick = () => {
141
+ if (this.delegated !== entry) {
142
+ return
143
+ }
144
+
145
+ trackSample()
146
+ entry.frame = this.clock.request(tick)
147
+ }
148
+
149
+ entry.frame = this.clock.request(tick)
150
+ this.delegated = entry
151
+ const result = await run.finished
152
+ // Only this run cleans up its own sampler — an interrupted run must
153
+ // not clobber the replacement's delegated state.
154
+ if (this.delegated === entry) {
155
+ this.clock.cancel(entry.frame)
156
+ this.delegated = undefined
157
+ }
158
+
159
+ if (generation !== this.generation) {
160
+ return 'replaced'
161
+ }
162
+
163
+ if (result !== 'finished') {
164
+ return 'cancelled'
165
+ }
166
+
167
+ for (const key of keys) {
168
+ if (dest[key] !== undefined) {
169
+ this.value(key).jump(dest[key]!)
170
+ }
171
+ }
172
+
173
+ return 'finished'
174
+ }
175
+ }
176
+
71
177
  const jobs = Object.entries(target).map(([key, to]) => {
72
178
  const value = this.value(key as MotionKey)
73
- return value.animate(to, reduced && key !== 'opacity' ? { duration: 0 } : transition).finished
179
+ return value.animate(
180
+ to,
181
+ reduced && key !== 'opacity'
182
+ ? { duration: 0 }
183
+ : resolveTransition(transition, key as MotionKey),
184
+ ).finished
74
185
  })
75
186
 
76
187
  const results = await Promise.all(jobs)
@@ -82,6 +193,7 @@ export class Controller {
82
193
  }
83
194
  stop() {
84
195
  ++this.generation
196
+ this.cancelDelegated()
85
197
  for (const value of this.values.values()) {
86
198
  value.stop()
87
199
  }
@@ -93,6 +205,6 @@ export class Controller {
93
205
  }
94
206
 
95
207
  this.values.clear()
96
- this.write = undefined
208
+ this.adapter = undefined
97
209
  }
98
210
  }
@@ -0,0 +1,269 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Controller } from './controller'
3
+ import type { Clock } from './clock-types'
4
+ import type { DelegatedRequest, DelegatedRun, HostAdapter } from './host-types'
5
+ import type { Target } from './types'
6
+
7
+ function fakeClock() {
8
+ let time = 0,
9
+ id = 0
10
+
11
+ const callbacks = new Map<number, () => void>()
12
+ const clock: Clock = {
13
+ now: () => time,
14
+ request: (fn) => {
15
+ callbacks.set(++id, fn)
16
+ return id
17
+ },
18
+ cancel: (key) => {
19
+ callbacks.delete(key)
20
+ },
21
+ }
22
+
23
+ return {
24
+ clock,
25
+ advance(ms: number) {
26
+ time += ms
27
+ const pending = [...callbacks.values()]
28
+ callbacks.clear()
29
+ pending.forEach((fn) => fn())
30
+ },
31
+ count: () => callbacks.size,
32
+ }
33
+ }
34
+
35
+ interface FakeRun extends DelegatedRun {
36
+ resolve(result: 'finished' | 'cancelled'): void
37
+ setEff(next: Target): void
38
+ cancelCalls: number
39
+ }
40
+
41
+ function fakeRun(eff: Target): FakeRun {
42
+ let resolve!: (result: 'finished' | 'cancelled') => void
43
+ let current = { ...eff }
44
+ const run: FakeRun = {
45
+ finished: new Promise((done) => {
46
+ resolve = done
47
+ }),
48
+ sample: () => ({ ...current }),
49
+ cancelCalls: 0,
50
+ cancel() {
51
+ run.cancelCalls++
52
+ run.resolve('cancelled')
53
+ },
54
+ resolve,
55
+ setEff(next) {
56
+ current = { ...next }
57
+ },
58
+ }
59
+
60
+ return run
61
+ }
62
+
63
+ function stubHost(onDelegate?: (req: DelegatedRequest) => DelegatedRun | null) {
64
+ const written: Target[] = []
65
+ const adapter: HostAdapter = {
66
+ read: () => ({ opacity: 1, x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 }),
67
+ write: (target) => {
68
+ written.push({ ...target })
69
+ },
70
+ restore: () => {},
71
+ delegate: onDelegate,
72
+ }
73
+
74
+ return { adapter, written }
75
+ }
76
+
77
+ describe('delegated runs', () => {
78
+ it('hands a tween to the host driver and resolves finished at the destination', async () => {
79
+ const time = fakeClock()
80
+ const controller = new Controller(time.clock)
81
+ let request: DelegatedRequest | undefined
82
+ const run = fakeRun({ x: 0 })
83
+ const { adapter } = stubHost((req) => {
84
+ request = req
85
+ return run
86
+ })
87
+
88
+ controller.attach(adapter)
89
+ controller.seed({ x: 0 })
90
+
91
+ const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
92
+ expect(request?.eff.x).toBe(100)
93
+ expect(request?.target.x).toBe(100)
94
+
95
+ run.setEff({ x: 40 })
96
+ time.advance(16)
97
+ run.resolve('finished')
98
+ expect(await done).toBe('finished')
99
+ expect(controller.values.get('x')?.get()).toBe(100)
100
+ expect(time.count()).toBe(0)
101
+ })
102
+
103
+ it('folds scale into the effective axis and tracks samples back', async () => {
104
+ const time = fakeClock()
105
+ const controller = new Controller(time.clock)
106
+ let request: DelegatedRequest | undefined
107
+ const run = fakeRun({ scaleX: 0.5, scaleY: 0.5 })
108
+ const { adapter } = stubHost((req) => {
109
+ request = req
110
+ return run
111
+ })
112
+
113
+ controller.attach(adapter)
114
+ controller.seed({ scaleX: 0.5 })
115
+
116
+ const done = controller.animate({ scale: 2 }, { duration: 0.4 }, false)
117
+ // Retained scaleX=0.5 folds into the effective destination.
118
+ expect(request?.eff.scaleX).toBe(1)
119
+ expect(request?.eff.scaleY).toBe(2)
120
+ run.setEff({ scaleX: 0.9, scaleY: 0.9 })
121
+ time.advance(16)
122
+ // scale channel tracks effective / retained scaleX.
123
+ expect(controller.values.get('scale')?.get()).toBeCloseTo(1.8)
124
+ run.resolve('finished')
125
+ expect(await done).toBe('finished')
126
+ expect(controller.values.get('scale')?.get()).toBe(2)
127
+ })
128
+
129
+ it('interrupts a delegated run with a tracked sample and resolves replaced', async () => {
130
+ const time = fakeClock()
131
+ const controller = new Controller(time.clock)
132
+ const first = fakeRun({ x: 0 })
133
+ const second = fakeRun({ x: 30 })
134
+ const runs = [first, second]
135
+ let calls = 0
136
+ const { adapter } = stubHost(() => runs[calls++ % runs.length])
137
+ controller.attach(adapter)
138
+ controller.seed({ x: 0 })
139
+
140
+ const doneFirst = controller.animate({ x: 100 }, { duration: 0.5 }, false)
141
+ first.setEff({ x: 30 })
142
+ time.advance(16)
143
+ const doneSecond = controller.animate({ x: -50 }, { duration: 0.5 }, false)
144
+ expect(first.cancelCalls).toBe(1)
145
+ // The interruption sample is written into the channel value.
146
+ expect(controller.values.get('x')?.get()).toBe(30)
147
+ expect(await doneFirst).toBe('replaced')
148
+ second.setEff({ x: -20 })
149
+ time.advance(16)
150
+ expect(controller.values.get('x')?.get()).toBe(-20)
151
+ second.resolve('finished')
152
+ expect(await doneSecond).toBe('finished')
153
+ expect(controller.values.get('x')?.get()).toBe(-50)
154
+ })
155
+
156
+ it('keeps springs on the JS engine', async () => {
157
+ const time = fakeClock()
158
+ const controller = new Controller(time.clock)
159
+ let delegated = false
160
+ const { adapter } = stubHost(() => {
161
+ delegated = true
162
+ return null
163
+ })
164
+
165
+ controller.attach(adapter)
166
+ controller.seed({ x: 0 })
167
+
168
+ const done = controller.animate(
169
+ { x: 100 },
170
+ { type: 'spring', stiffness: 100, damping: 10 },
171
+ false,
172
+ )
173
+
174
+ expect(delegated).toBe(false)
175
+ for (let i = 0; i < 400; i++) {
176
+ time.advance(16)
177
+ }
178
+
179
+ expect(await done).toBe('finished')
180
+ expect(controller.values.get('x')?.get()).toBe(100)
181
+ })
182
+
183
+ it('keeps reduced-motion runs on the JS engine for instant settle', async () => {
184
+ const time = fakeClock()
185
+ const controller = new Controller(time.clock)
186
+ let delegated = false
187
+ const { adapter, written } = stubHost(() => {
188
+ delegated = true
189
+ return null
190
+ })
191
+
192
+ controller.attach(adapter)
193
+ controller.seed({ x: 0 })
194
+
195
+ const done = controller.animate({ x: 50 }, { duration: 0.5 }, true)
196
+ expect(delegated).toBe(false)
197
+ expect(await done).toBe('finished')
198
+ expect(controller.values.get('x')?.get()).toBe(50)
199
+ expect(written[written.length - 1]?.x).toBe(50)
200
+ })
201
+
202
+ it('resolves replaced when the delegated run is stopped', async () => {
203
+ const time = fakeClock()
204
+ const controller = new Controller(time.clock)
205
+ const run = fakeRun({ x: 0 })
206
+ const { adapter } = stubHost(() => run)
207
+ controller.attach(adapter)
208
+ controller.seed({ x: 0 })
209
+
210
+ const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
211
+ controller.stop()
212
+ expect(run.cancelCalls).toBe(1)
213
+ expect(await done).toBe('replaced')
214
+ })
215
+
216
+ it.each([
217
+ ['per-key transitions', { default: { duration: 0.5 }, x: { duration: 1 } }],
218
+ ['a non-bezier ease', { duration: 0.5, ease: 'backOut' as const }],
219
+ ['a repeating run', { duration: 0.5, repeat: 2 }],
220
+ ])('stays on the JS engine for %s', async (_label, transition) => {
221
+ const time = fakeClock()
222
+ const controller = new Controller(time.clock)
223
+ let delegated = false
224
+ const { adapter } = stubHost(() => {
225
+ delegated = true
226
+ return null
227
+ })
228
+
229
+ controller.attach(adapter)
230
+ controller.seed({ x: 0 })
231
+
232
+ const done = controller.animate({ x: 100 }, transition as any, false)
233
+ expect(delegated).toBe(false)
234
+ for (let i = 0; i < 500; i++) {
235
+ time.advance(16)
236
+ }
237
+
238
+ expect(await done).toBe('finished')
239
+ expect(controller.values.get('x')?.get()).toBe(100)
240
+ })
241
+
242
+ it('applies per-key overrides to each channel independently', async () => {
243
+ const time = fakeClock()
244
+ const controller = new Controller(time.clock)
245
+ const { adapter } = stubHost()
246
+ controller.attach(adapter)
247
+ controller.seed({ x: 0, y: 0 })
248
+
249
+ const done = controller.animate(
250
+ { x: 100, y: 100 },
251
+ { default: { duration: 0.1, ease: 'linear' }, x: { duration: 0.3 } },
252
+ false,
253
+ )
254
+
255
+ for (let i = 0; i < 8; i++) {
256
+ time.advance(16)
257
+ }
258
+
259
+ // y finished at 100ms; x still travelling on its 300ms override.
260
+ expect(controller.values.get('y')?.get()).toBe(100)
261
+ expect(controller.values.get('x')?.get()).toBeLessThan(100)
262
+ for (let i = 0; i < 30; i++) {
263
+ time.advance(16)
264
+ }
265
+
266
+ expect(await done).toBe('finished')
267
+ expect(controller.values.get('x')?.get()).toBe(100)
268
+ })
269
+ })