Skip to content

Commit 6ed2377

Browse files
committed
fix(ui): stop the running spring when its options change
1 parent 2df3a69 commit 6ed2377

3 files changed

Lines changed: 45 additions & 13 deletions

File tree

‎packages/app/test-browser/motion-spring.test.ts‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,58 @@
11
import { expect, test } from "bun:test"
22
import { useSpring } from "@opencode/ui/motion-spring"
3-
import { createRoot, createSignal } from "solid-js"
3+
import { createEffect, createRoot, createSignal } from "solid-js"
4+
5+
// Records every value the spring emits and resolves once it reaches `settled`.
6+
function watch(progress: () => number, settled: number) {
7+
const seen: number[] = []
8+
const { promise, resolve } = Promise.withResolvers<void>()
9+
10+
createEffect(() => {
11+
const value = progress()
12+
13+
seen.push(value)
14+
15+
if (value === settled) resolve()
16+
})
17+
18+
return { seen, settled: promise }
19+
}
420

521
test("springs progress to its target", async () => {
622
const state = createRoot((dispose) => {
723
const [target, setTarget] = createSignal(0)
8-
const progress = useSpring(target, { visualDuration: 0.1, bounce: 0 })
24+
const progress = useSpring(target, { visualDuration: 0.3, bounce: 0 })
925

10-
return { dispose, progress, setTarget }
26+
return { dispose, progress, setTarget, ...watch(progress, 1) }
1127
})
1228

13-
const seen: number[] = []
14-
1529
state.setTarget(1)
30+
await Promise.race([state.settled, Bun.sleep(2000)])
1631

17-
const deadline = Date.now() + 1000
32+
expect(state.progress()).toBe(1)
33+
expect(state.seen.some((value) => value > 0 && value < 1)).toBe(true)
34+
expect(state.seen.every((value) => value <= 1)).toBe(true)
35+
state.dispose()
36+
})
37+
38+
test("new spring options replace the running spring", async () => {
39+
const state = createRoot((dispose) => {
40+
const [target, setTarget] = createSignal(0)
41+
const [duration, setDuration] = createSignal(4)
42+
const progress = useSpring(target, () => ({ visualDuration: duration(), bounce: 0 }))
43+
44+
return { dispose, progress, setTarget, setDuration, ...watch(progress, 1) }
45+
})
46+
47+
state.setTarget(1)
48+
await Bun.sleep(100)
49+
expect(state.progress()).toBeLessThan(0.5)
1850

19-
while (state.progress() !== 1 && Date.now() < deadline) {
20-
await new Promise((resolve) => setTimeout(resolve, 16))
21-
seen.push(state.progress())
22-
}
51+
// The old four-second spring would still be far from its target after a second.
52+
state.setDuration(0.1)
53+
await Promise.race([state.settled, Bun.sleep(1000)])
2354

2455
expect(state.progress()).toBe(1)
25-
expect(seen.some((value) => value > 0 && value < 1)).toBe(true)
26-
expect(seen.every((value) => value <= 1)).toBe(true)
2756
state.dispose()
2857
})
2958

‎packages/session-ui/src/tools/tool-renderer.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function ShellSubmessage(props: { text: string; animate?: boolean }) {
7373
}
7474

7575
if (valueRef) {
76-
valueRef.animate(
76+
valueRef.animate?.(
7777
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] },
7878
{ duration: 320, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
7979
)

‎packages/ui/src/components/motion-spring.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export function useSpring<K>(target: () => number, options?: Opt | (() => Opt),
4747
if (eq(config, next)) return
4848
config = next
4949
stop()
50+
// attachSpring's cleanup only unsubscribes from the source; the running animation keeps ticking until stopped.
51+
spring.stop()
5052
stop = attachSpring(spring, source, next)
53+
spring.set(source.get())
5154
setValue(spring.get())
5255
})
5356

0 commit comments

Comments
 (0)