Skip to content

Commit a4f774b

Browse files
myk1ytedelauna
andauthored
perf(webview): replace the debounce package with a local leading-edge helper (#2268)
* perf(webview): replace the debounce package with a local leading-edge helper What: add `webview-ui/src/utils/leadingEdgeDebounce.ts`, a 200-byte helper that runs its callback on the leading edge and ignores calls until `wait` ms have passed since the most recent one, plus a `.clear()` that cancels the pending window. `useScrollLifecycle`'s `scrollToBottomSmooth` now uses it instead of `debounce(fn, 10, { immediate: true })`. The `debounce` dependency is removed from `webview-ui/package.json` and the lockfile. Why: `[email protected]` was a 7 KB package pulled in for a single 10 ms leading-edge window. `react-use`'s `useDebounce` is trailing-only and cannot express it, so the dependency could not be unified away in that direction. A local helper carries the whole used surface (leading call + `.clear()`) in 200 bytes of output. Impact: entry bundle 6,187,772 B -> 6,187,049 B (-723 B). The bundled `debounce` module (900 B) is replaced by the helper (200 B); the rest of the delta is module-wrapper overhead. Behaviour is pinned by a characterization suite that passed unchanged against both implementations. The characterization suite was written and run against the real package first (17 tests green through a seam delegating to `debounce`), then the seam was replaced by the local helper and the same 17 tests passed without edits. * test(webview): assert the unmount timer count before advancing timers What: move the vi.getTimerCount() check to directly after unmount() in the debounce unmount test, and document on LeadingEdgeDebounced<F> that suppressed trailing calls return undefined. Why: expired timers stop counting, so the old trailing assertion passed even with scrollToBottomSmooth.clear() removed from the unmount cleanup; the type also swallowed ReturnType<F> to undefined without saying so. Impact: the unmount cleanup is now pinned, and the helper's return contract is stated where callers read it. * chore(webview): block imports of the removed debounce package --------- Co-authored-by: Elliott de Launay <[email protected]>
1 parent f8aa881 commit a4f774b

7 files changed

Lines changed: 377 additions & 13 deletions

File tree

‎pnpm-lock.yaml‎

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎webview-ui/eslint.config.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export default [
2424
"@typescript-eslint/no-explicit-any": "off",
2525
"react/prop-types": "off",
2626
"react/display-name": "off",
27+
"no-restricted-imports": [
28+
"error",
29+
{
30+
paths: [
31+
{
32+
name: "debounce",
33+
message:
34+
"Use @src/utils/leadingEdgeDebounce for leading-edge, or useDebounceEffect for trailing.",
35+
},
36+
],
37+
},
38+
],
2739
},
2840
},
2941
{

‎webview-ui/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
"clsx": "^2.1.1",
4343
"cmdk": "^1.0.0",
4444
"date-fns": "^4.1.0",
45-
"debounce": "^2.1.1",
4645
"diff": "^5.2.0",
4746
"fast-deep-equal": "^3.1.3",
4847
"fzf": "^0.5.2",
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Characterization suite for `useScrollLifecycle`'s debounced scroll command.
3+
*
4+
* `scrollToBottomSmooth` is a leading-edge debounce (10 ms window) that is
5+
* `.clear()`ed on unmount. This file pins that observable behaviour so the
6+
* underlying debounce implementation can be swapped without drifting.
7+
*
8+
* Phase 1 of the swap ran this file against the `debounce` package; phase 2
9+
* runs it against the local `leadingEdgeDebounce` helper. Only the hook's
10+
* import changed; every assertion and timing here is unchanged.
11+
*/
12+
import { act, renderHook } from "@testing-library/react"
13+
import type { VirtuosoHandle } from "react-virtuoso"
14+
15+
import { useScrollLifecycle } from "../useScrollLifecycle"
16+
17+
const SMOOTH_WINDOW_MS = 10
18+
19+
type ScrollCall = { index: "LAST"; align: "end"; behavior: "auto" | "smooth" }
20+
21+
function makeVirtuosoRef() {
22+
const scrollToIndex = vi.fn<(options: ScrollCall) => void>()
23+
const ref = { current: { scrollToIndex } as unknown as VirtuosoHandle }
24+
return { ref, scrollToIndex }
25+
}
26+
27+
function makeContainerRef() {
28+
const element = document.createElement("div")
29+
document.body.appendChild(element)
30+
return { current: element }
31+
}
32+
33+
function renderScrollLifecycle(
34+
overrides: Partial<Parameters<typeof useScrollLifecycle>[0]> = {},
35+
virtuosoRef = makeVirtuosoRef(),
36+
) {
37+
const hook = renderHook(() =>
38+
useScrollLifecycle({
39+
virtuosoRef: virtuosoRef.ref,
40+
scrollContainerRef: makeContainerRef(),
41+
taskTs: undefined,
42+
isStreaming: false,
43+
isHidden: false,
44+
hasTask: true,
45+
...overrides,
46+
}),
47+
)
48+
return { ...hook, scrollToIndex: virtuosoRef.scrollToIndex }
49+
}
50+
51+
/** Drives the hook into ANCHORED_FOLLOWING with `isAtBottomRef` set. */
52+
function enterAnchoredFollowing(result: { current: ReturnType<typeof useScrollLifecycle> }) {
53+
act(() => {
54+
result.current.atBottomStateChangeCallback(true)
55+
})
56+
expect(result.current.scrollPhaseRef.current).toBe("ANCHORED_FOLLOWING")
57+
expect(result.current.isAtBottomRef.current).toBe(true)
58+
}
59+
60+
const smoothCalls = (scrollToIndex: ReturnType<typeof makeVirtuosoRef>["scrollToIndex"]) =>
61+
scrollToIndex.mock.calls.filter(([options]) => options.behavior === "smooth")
62+
63+
describe("useScrollLifecycle debounced scroll command", () => {
64+
beforeEach(() => {
65+
vi.useFakeTimers()
66+
})
67+
68+
afterEach(() => {
69+
vi.useRealTimers()
70+
document.body.innerHTML = ""
71+
})
72+
73+
test("a taller row in anchored follow scrolls smoothly on the leading edge", () => {
74+
const { result, scrollToIndex } = renderScrollLifecycle()
75+
enterAnchoredFollowing(result)
76+
77+
act(() => {
78+
result.current.handleRowHeightChange(true)
79+
})
80+
81+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
82+
expect(smoothCalls(scrollToIndex)[0][0]).toEqual({ index: "LAST", align: "end", behavior: "smooth" })
83+
})
84+
85+
test("a burst of taller rows collapses into one smooth scroll", () => {
86+
const { result, scrollToIndex } = renderScrollLifecycle()
87+
enterAnchoredFollowing(result)
88+
89+
act(() => {
90+
result.current.handleRowHeightChange(true)
91+
vi.advanceTimersByTime(3)
92+
result.current.handleRowHeightChange(true)
93+
vi.advanceTimersByTime(3)
94+
result.current.handleRowHeightChange(true)
95+
})
96+
97+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
98+
})
99+
100+
test("the smooth scroll is never deferred to the trailing edge", () => {
101+
const { result, scrollToIndex } = renderScrollLifecycle()
102+
enterAnchoredFollowing(result)
103+
104+
act(() => {
105+
result.current.handleRowHeightChange(true)
106+
})
107+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
108+
109+
act(() => {
110+
vi.advanceTimersByTime(SMOOTH_WINDOW_MS * 100)
111+
})
112+
113+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
114+
})
115+
116+
test("the window reopens once 10 ms have elapsed since the last taller row", () => {
117+
const { result, scrollToIndex } = renderScrollLifecycle()
118+
enterAnchoredFollowing(result)
119+
120+
act(() => {
121+
result.current.handleRowHeightChange(true)
122+
})
123+
act(() => {
124+
vi.advanceTimersByTime(SMOOTH_WINDOW_MS)
125+
})
126+
act(() => {
127+
result.current.handleRowHeightChange(true)
128+
})
129+
130+
expect(smoothCalls(scrollToIndex)).toHaveLength(2)
131+
})
132+
133+
test("a shorter row scrolls immediately with behavior auto, not through the debounce", () => {
134+
const { result, scrollToIndex } = renderScrollLifecycle()
135+
enterAnchoredFollowing(result)
136+
137+
act(() => {
138+
result.current.handleRowHeightChange(false)
139+
})
140+
141+
expect(scrollToIndex).toHaveBeenCalledWith({ index: "LAST", align: "end", behavior: "auto" })
142+
expect(smoothCalls(scrollToIndex)).toHaveLength(0)
143+
})
144+
145+
test("unmount clears the pending window so a late timer cannot scroll", () => {
146+
const { result, scrollToIndex, unmount } = renderScrollLifecycle()
147+
enterAnchoredFollowing(result)
148+
149+
act(() => {
150+
result.current.handleRowHeightChange(true)
151+
})
152+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
153+
154+
unmount()
155+
156+
// Checked before advancing: expired timers stop counting, so a removed
157+
// clear() would still pass the assertion at the end of this test.
158+
expect(vi.getTimerCount()).toBe(0)
159+
160+
act(() => {
161+
vi.advanceTimersByTime(SMOOTH_WINDOW_MS * 100)
162+
})
163+
164+
expect(smoothCalls(scrollToIndex)).toHaveLength(1)
165+
})
166+
167+
test("no smooth scroll is issued while the user browses history", () => {
168+
const { result, scrollToIndex } = renderScrollLifecycle()
169+
170+
act(() => {
171+
result.current.handleRowHeightChange(true)
172+
})
173+
174+
expect(smoothCalls(scrollToIndex)).toHaveLength(0)
175+
})
176+
})

‎webview-ui/src/hooks/useScrollLifecycle.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
1515
import { useEvent } from "react-use"
16-
import debounce from "debounce"
1716
import type { VirtuosoHandle } from "react-virtuoso"
1817

18+
import { leadingEdgeDebounce } from "@src/utils/leadingEdgeDebounce"
19+
1920
const HYDRATION_WINDOW_MS = 600
2021
const HYDRATION_RETRY_WINDOW_MS = 160
2122

@@ -147,10 +148,9 @@ export function useScrollLifecycle({
147148

148149
const scrollToBottomSmooth = useMemo(
149150
() =>
150-
debounce(
151+
leadingEdgeDebounce(
151152
() => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }),
152153
10,
153-
{ immediate: true },
154154
),
155155
[virtuosoRef],
156156
)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Characterization suite for the leading-edge debounce used by
3+
* `useScrollLifecycle` (`scrollToBottomSmooth`).
4+
*
5+
* Phase 1 of the dependency swap ran this file against a `leadingEdgeDebounce`
6+
* module that delegated to the `debounce` package. Phase 2 ran the same file,
7+
* unchanged, against the local implementation. Every assertion, timing and
8+
* test name is byte-identical between the two phases.
9+
*/
10+
import { leadingEdgeDebounce } from "../leadingEdgeDebounce"
11+
12+
const WAIT = 10
13+
14+
describe("leadingEdgeDebounce (used surface: leading edge + clear)", () => {
15+
beforeEach(() => {
16+
vi.useFakeTimers()
17+
})
18+
19+
afterEach(() => {
20+
vi.useRealTimers()
21+
})
22+
23+
test("invokes on the leading edge, synchronously", () => {
24+
const fn = vi.fn()
25+
const debounced = leadingEdgeDebounce(fn, WAIT)
26+
27+
debounced()
28+
29+
expect(fn).toHaveBeenCalledTimes(1)
30+
// No timer advance: the call happened at t=0.
31+
expect(vi.getTimerCount()).toBe(1)
32+
})
33+
34+
test("suppresses a burst of calls inside the window down to one invocation", () => {
35+
const fn = vi.fn()
36+
const debounced = leadingEdgeDebounce(fn, WAIT)
37+
38+
debounced()
39+
vi.advanceTimersByTime(3)
40+
debounced()
41+
vi.advanceTimersByTime(3)
42+
debounced()
43+
44+
expect(fn).toHaveBeenCalledTimes(1)
45+
})
46+
47+
test("never invokes on the trailing edge", () => {
48+
const fn = vi.fn()
49+
const debounced = leadingEdgeDebounce(fn, WAIT)
50+
51+
debounced()
52+
vi.advanceTimersByTime(WAIT * 100)
53+
54+
expect(fn).toHaveBeenCalledTimes(1)
55+
})
56+
57+
test("re-invocation inside the window suppresses the call and extends the window", () => {
58+
const fn = vi.fn()
59+
const debounced = leadingEdgeDebounce(fn, WAIT)
60+
61+
debounced() // t=0, fires
62+
vi.advanceTimersByTime(5)
63+
debounced() // t=5, suppressed
64+
65+
// t=10: the original window would have closed here, but the t=5 call
66+
// restarted it, so this call is still suppressed.
67+
vi.advanceTimersByTime(5)
68+
debounced()
69+
expect(fn).toHaveBeenCalledTimes(1)
70+
71+
// t=20: `wait` has elapsed since the last call at t=10.
72+
vi.advanceTimersByTime(10)
73+
debounced()
74+
expect(fn).toHaveBeenCalledTimes(2)
75+
})
76+
77+
test("reopens the window once `wait` has elapsed since the last call", () => {
78+
const fn = vi.fn()
79+
const debounced = leadingEdgeDebounce(fn, WAIT)
80+
81+
debounced()
82+
vi.advanceTimersByTime(WAIT)
83+
debounced()
84+
vi.advanceTimersByTime(WAIT)
85+
debounced()
86+
87+
expect(fn).toHaveBeenCalledTimes(3)
88+
})
89+
90+
test("clear() cancels the pending timer", () => {
91+
const fn = vi.fn()
92+
const debounced = leadingEdgeDebounce(fn, WAIT)
93+
94+
debounced()
95+
expect(vi.getTimerCount()).toBe(1)
96+
97+
debounced.clear()
98+
99+
expect(vi.getTimerCount()).toBe(0)
100+
vi.advanceTimersByTime(WAIT * 100)
101+
expect(fn).toHaveBeenCalledTimes(1)
102+
})
103+
104+
test("clear() reopens the window so the next call fires immediately", () => {
105+
const fn = vi.fn()
106+
const debounced = leadingEdgeDebounce(fn, WAIT)
107+
108+
debounced()
109+
debounced.clear()
110+
debounced()
111+
112+
expect(fn).toHaveBeenCalledTimes(2)
113+
})
114+
115+
test("clear() is a no-op when nothing is pending", () => {
116+
const fn = vi.fn()
117+
const debounced = leadingEdgeDebounce(fn, WAIT)
118+
119+
expect(() => debounced.clear()).not.toThrow()
120+
expect(vi.getTimerCount()).toBe(0)
121+
expect(fn).not.toHaveBeenCalled()
122+
})
123+
124+
test("forwards arguments to the leading call", () => {
125+
const fn = vi.fn<(a: string, b: number) => void>()
126+
const debounced = leadingEdgeDebounce(fn, WAIT)
127+
128+
debounced("LAST", 7)
129+
130+
expect(fn).toHaveBeenCalledWith("LAST", 7)
131+
})
132+
133+
test("returns the leading call's return value", () => {
134+
const debounced = leadingEdgeDebounce(() => "result", WAIT)
135+
136+
expect(debounced()).toBe("result")
137+
})
138+
})

0 commit comments

Comments
 (0)