Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
176 changes: 176 additions & 0 deletions webview-ui/src/hooks/__tests__/useScrollLifecycle.debounce.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof useScrollLifecycle>[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<typeof useScrollLifecycle> }) {
act(() => {
result.current.atBottomStateChangeCallback(true)
})
expect(result.current.scrollPhaseRef.current).toBe("ANCHORED_FOLLOWING")
expect(result.current.isAtBottomRef.current).toBe(true)
}

const smoothCalls = (scrollToIndex: ReturnType<typeof makeVirtuosoRef>["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)
})
})
6 changes: 3 additions & 3 deletions webview-ui/src/hooks/useScrollLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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],
)
Expand Down
138 changes: 138 additions & 0 deletions webview-ui/src/utils/__tests__/leadingEdgeDebounce.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading
Loading