diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ca6b8edc6..c9de073f77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,9 +791,6 @@ importers: date-fns: specifier: ^4.1.0 version: 4.4.0 - debounce: - specifier: ^2.1.1 - version: 2.2.0 diff: specifier: ^5.2.0 version: 5.2.2 @@ -4686,10 +4683,6 @@ packages: dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - debounce@2.2.0: - resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} - engines: {node: '>=18'} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -13182,8 +13175,6 @@ snapshots: dayjs@1.11.23: {} - debounce@2.2.0: {} - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 diff --git a/webview-ui/package.json b/webview-ui/package.json index 2b455b95e4..5cc9a26367 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -43,7 +43,6 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^4.1.0", - "debounce": "^2.1.1", "diff": "^5.2.0", "fast-deep-equal": "^3.1.3", "fzf": "^0.5.2", diff --git a/webview-ui/src/hooks/__tests__/useScrollLifecycle.debounce.spec.ts b/webview-ui/src/hooks/__tests__/useScrollLifecycle.debounce.spec.ts new file mode 100644 index 0000000000..080df00726 --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useScrollLifecycle.debounce.spec.ts @@ -0,0 +1,176 @@ +/** + * Characterization suite for `useScrollLifecycle`'s debounced scroll command. + * + * `scrollToBottomSmooth` is a leading-edge debounce (10 ms window) that is + * `.clear()`ed on unmount. This file pins that observable behaviour so the + * underlying debounce implementation can be swapped without drifting. + * + * Phase 1 of the swap ran this file against the `debounce` package; phase 2 + * runs it against the local `leadingEdgeDebounce` helper. Only the hook's + * import changed; every assertion and timing here is unchanged. + */ +import { act, renderHook } from "@testing-library/react" +import type { VirtuosoHandle } from "react-virtuoso" + +import { useScrollLifecycle } from "../useScrollLifecycle" + +const SMOOTH_WINDOW_MS = 10 + +type ScrollCall = { index: "LAST"; align: "end"; behavior: "auto" | "smooth" } + +function makeVirtuosoRef() { + const scrollToIndex = vi.fn<(options: ScrollCall) => void>() + const ref = { current: { scrollToIndex } as unknown as VirtuosoHandle } + return { ref, scrollToIndex } +} + +function makeContainerRef() { + const element = document.createElement("div") + document.body.appendChild(element) + return { current: element } +} + +function renderScrollLifecycle( + overrides: Partial[0]> = {}, + virtuosoRef = makeVirtuosoRef(), +) { + const hook = renderHook(() => + useScrollLifecycle({ + virtuosoRef: virtuosoRef.ref, + scrollContainerRef: makeContainerRef(), + taskTs: undefined, + isStreaming: false, + isHidden: false, + hasTask: true, + ...overrides, + }), + ) + return { ...hook, scrollToIndex: virtuosoRef.scrollToIndex } +} + +/** Drives the hook into ANCHORED_FOLLOWING with `isAtBottomRef` set. */ +function enterAnchoredFollowing(result: { current: ReturnType }) { + act(() => { + result.current.atBottomStateChangeCallback(true) + }) + expect(result.current.scrollPhaseRef.current).toBe("ANCHORED_FOLLOWING") + expect(result.current.isAtBottomRef.current).toBe(true) +} + +const smoothCalls = (scrollToIndex: ReturnType["scrollToIndex"]) => + scrollToIndex.mock.calls.filter(([options]) => options.behavior === "smooth") + +describe("useScrollLifecycle debounced scroll command", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + document.body.innerHTML = "" + }) + + test("a taller row in anchored follow scrolls smoothly on the leading edge", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(true) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + expect(smoothCalls(scrollToIndex)[0][0]).toEqual({ index: "LAST", align: "end", behavior: "smooth" }) + }) + + test("a burst of taller rows collapses into one smooth scroll", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(true) + vi.advanceTimersByTime(3) + result.current.handleRowHeightChange(true) + vi.advanceTimersByTime(3) + result.current.handleRowHeightChange(true) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + }) + + test("the smooth scroll is never deferred to the trailing edge", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(true) + }) + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + + act(() => { + vi.advanceTimersByTime(SMOOTH_WINDOW_MS * 100) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + }) + + test("the window reopens once 10 ms have elapsed since the last taller row", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(true) + }) + act(() => { + vi.advanceTimersByTime(SMOOTH_WINDOW_MS) + }) + act(() => { + result.current.handleRowHeightChange(true) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(2) + }) + + test("a shorter row scrolls immediately with behavior auto, not through the debounce", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(false) + }) + + expect(scrollToIndex).toHaveBeenCalledWith({ index: "LAST", align: "end", behavior: "auto" }) + expect(smoothCalls(scrollToIndex)).toHaveLength(0) + }) + + test("unmount clears the pending window so a late timer cannot scroll", () => { + const { result, scrollToIndex, unmount } = renderScrollLifecycle() + enterAnchoredFollowing(result) + + act(() => { + result.current.handleRowHeightChange(true) + }) + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + + unmount() + + // Checked before advancing: expired timers stop counting, so a removed + // clear() would still pass the assertion at the end of this test. + expect(vi.getTimerCount()).toBe(0) + + act(() => { + vi.advanceTimersByTime(SMOOTH_WINDOW_MS * 100) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(1) + }) + + test("no smooth scroll is issued while the user browses history", () => { + const { result, scrollToIndex } = renderScrollLifecycle() + + act(() => { + result.current.handleRowHeightChange(true) + }) + + expect(smoothCalls(scrollToIndex)).toHaveLength(0) + }) +}) diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts index 8a560f15f9..fe582e8bb5 100644 --- a/webview-ui/src/hooks/useScrollLifecycle.ts +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -13,9 +13,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" -import debounce from "debounce" import type { VirtuosoHandle } from "react-virtuoso" +import { leadingEdgeDebounce } from "@src/utils/leadingEdgeDebounce" + const HYDRATION_WINDOW_MS = 600 const HYDRATION_RETRY_WINDOW_MS = 160 @@ -147,10 +148,9 @@ export function useScrollLifecycle({ const scrollToBottomSmooth = useMemo( () => - debounce( + leadingEdgeDebounce( () => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }), 10, - { immediate: true }, ), [virtuosoRef], ) diff --git a/webview-ui/src/utils/__tests__/leadingEdgeDebounce.spec.ts b/webview-ui/src/utils/__tests__/leadingEdgeDebounce.spec.ts new file mode 100644 index 0000000000..5d4133997f --- /dev/null +++ b/webview-ui/src/utils/__tests__/leadingEdgeDebounce.spec.ts @@ -0,0 +1,138 @@ +/** + * Characterization suite for the leading-edge debounce used by + * `useScrollLifecycle` (`scrollToBottomSmooth`). + * + * Phase 1 of the dependency swap ran this file against a `leadingEdgeDebounce` + * module that delegated to the `debounce` package. Phase 2 ran the same file, + * unchanged, against the local implementation. Every assertion, timing and + * test name is byte-identical between the two phases. + */ +import { leadingEdgeDebounce } from "../leadingEdgeDebounce" + +const WAIT = 10 + +describe("leadingEdgeDebounce (used surface: leading edge + clear)", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("invokes on the leading edge, synchronously", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + + expect(fn).toHaveBeenCalledTimes(1) + // No timer advance: the call happened at t=0. + expect(vi.getTimerCount()).toBe(1) + }) + + test("suppresses a burst of calls inside the window down to one invocation", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + vi.advanceTimersByTime(3) + debounced() + vi.advanceTimersByTime(3) + debounced() + + expect(fn).toHaveBeenCalledTimes(1) + }) + + test("never invokes on the trailing edge", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + vi.advanceTimersByTime(WAIT * 100) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + test("re-invocation inside the window suppresses the call and extends the window", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() // t=0, fires + vi.advanceTimersByTime(5) + debounced() // t=5, suppressed + + // t=10: the original window would have closed here, but the t=5 call + // restarted it, so this call is still suppressed. + vi.advanceTimersByTime(5) + debounced() + expect(fn).toHaveBeenCalledTimes(1) + + // t=20: `wait` has elapsed since the last call at t=10. + vi.advanceTimersByTime(10) + debounced() + expect(fn).toHaveBeenCalledTimes(2) + }) + + test("reopens the window once `wait` has elapsed since the last call", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + vi.advanceTimersByTime(WAIT) + debounced() + vi.advanceTimersByTime(WAIT) + debounced() + + expect(fn).toHaveBeenCalledTimes(3) + }) + + test("clear() cancels the pending timer", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + expect(vi.getTimerCount()).toBe(1) + + debounced.clear() + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(WAIT * 100) + expect(fn).toHaveBeenCalledTimes(1) + }) + + test("clear() reopens the window so the next call fires immediately", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced() + debounced.clear() + debounced() + + expect(fn).toHaveBeenCalledTimes(2) + }) + + test("clear() is a no-op when nothing is pending", () => { + const fn = vi.fn() + const debounced = leadingEdgeDebounce(fn, WAIT) + + expect(() => debounced.clear()).not.toThrow() + expect(vi.getTimerCount()).toBe(0) + expect(fn).not.toHaveBeenCalled() + }) + + test("forwards arguments to the leading call", () => { + const fn = vi.fn<(a: string, b: number) => void>() + const debounced = leadingEdgeDebounce(fn, WAIT) + + debounced("LAST", 7) + + expect(fn).toHaveBeenCalledWith("LAST", 7) + }) + + test("returns the leading call's return value", () => { + const debounced = leadingEdgeDebounce(() => "result", WAIT) + + expect(debounced()).toBe("result") + }) +}) diff --git a/webview-ui/src/utils/leadingEdgeDebounce.ts b/webview-ui/src/utils/leadingEdgeDebounce.ts new file mode 100644 index 0000000000..202a33a1dc --- /dev/null +++ b/webview-ui/src/utils/leadingEdgeDebounce.ts @@ -0,0 +1,48 @@ +/** + * Leading-edge debounce: runs `fn` immediately, then ignores calls until `wait` + * ms have passed since the most recent call. + * + * This is the only debounce shape `useScrollLifecycle` needs: a smooth scroll + * must start on the leading edge so the burst of row-height changes that + * follows a single render collapses into one scroll command. `react-use`'s + * `useDebounce` is trailing-only and cannot express it. + * + * `.clear()` cancels the pending window; the next call after `.clear()` runs + * immediately, matching the `debounce` package's `{ immediate: true }` mode. + * + * The return type is `undefined` on suppressed trailing calls: only the leading + * call forwards `fn`'s result, so callers must not depend on a value there. + */ +export type LeadingEdgeDebounced unknown> = F & { + clear: () => void +} + +export function leadingEdgeDebounce unknown>( + fn: F, + wait: number, +): LeadingEdgeDebounced { + let timeoutId: ReturnType | undefined + + const debounced = ((...args: Parameters): ReturnType | undefined => { + const isLeadingCall = timeoutId === undefined + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(() => { + timeoutId = undefined + }, wait) + + return isLeadingCall ? (fn(...args) as ReturnType) : undefined + }) as LeadingEdgeDebounced + + debounced.clear = () => { + if (timeoutId === undefined) { + return + } + clearTimeout(timeoutId) + timeoutId = undefined + } + + return debounced +}