Compare commits

...

4 Commits

Author SHA1 Message Date
Bryan Qiu c2484418d7 fix(web): harden scroll restore against async content growth and Monaco clamps
The restore loop gave up as soon as the container's height held still
for one frame — but previews grow in bursts (async syntax highlighting,
image decode, lazy notebook cells), so a single stall stranded the
reader at the top. Replace the giveup with a 1.5s deadline that keeps
re-asserting the saved offset, and settle immediately on wheel/touch/
pointer input so the user is never fought for the scrollbar.

The Monaco surfaces saved onDidScrollChange offsets unconditionally, so
a not-yet-laid-out editor's clamp-to-0 event could permanently overwrite
the cached position. A shared attachEditorScrollRestore helper now
suppresses saves and re-asserts the target until it's reached, the user
scrolls, or the budget expires — the same contract as the DOM hook.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-29 13:11:10 -07:00
Bryan Qiu c1ed4f54a9 fix(web): keep the open file's scroll position across session switches
The app remembers which file is open per session and re-opens it in the
viewer on switch-back — at the top. The earlier fix only covered the
Files panel list, so what users actually saw (the open file's content)
still reset.

Extract the clamp-surviving restore logic into a shared useScrollRestore
hook (FilesPanel now consumes it) and wire persistence into every viewer
surface, keyed per conversation + path: the Monaco code editor and diff
viewer (via their scroll APIs), the FileViewer content area, the
markdown/notebook previews, and the TipTap markdown editor.

Verified end-to-end in a real browser: Playwright tests scroll, switch
sessions via the sidebar, switch back, and assert the offset returns —
for both the file tree and an open markdown file.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-29 12:07:56 -07:00
Bryan Qiu e89bba83a4 fix(web): survive the loading clamp when restoring file browser scroll
The first cut restored scrollTop once when isLoading turned false — but
the files queries are disabled (not loading) until the environment query
resolves, so the restore fired against the short placeholder, clamped to
0, and the clamp's scroll event overwrote the cached position.

Gate on data presence instead, re-assert the target via an
animation-frame loop until the container can hold it (or its height
stops changing), and keep saving off until the restore settles.
Also re-sync FolderTree's expanded-paths state from its cache when the
conversation changes without a remount — previously the tree kept the
prior conversation's expanded set, which also skewed content height at
restore time.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-29 12:07:56 -07:00
Bryan Qiu a02892e951 fix(web): preserve file browser scroll position across session switches
The Files panel's scroll container never tracked its position, so
switching conversations collapsed the list to a loading state and
clamped scrollTop back to 0 with nothing to restore it.

Cache scrollTop per conversation (and per Changed/All view) in a
module-level map — the same pattern FolderTree uses for expanded
paths — restoring it once the view's data is ready, and gating saves
on having restored first so the loading-state clamp can't overwrite
the cached value.

Co-authored-by: Isaac
Signed-off-by: Bryan Qiu <bryan.qiu@databricks.com>
2026-07-29 12:07:56 -07:00
16 changed files with 921 additions and 12 deletions
@@ -0,0 +1,165 @@
"""E2E: the Files panel keeps its scroll position across session switches.
The panel's scroll container clamps to the top while a newly-selected
session's file queries load; ``FilesPanel`` caches ``scrollTop`` per
conversation and restores it once the content is tall enough again. This
drives the real flow — scroll in session A, switch to session B via the
sidebar (client-side navigation, NOT ``page.goto``: a reload would reset
the module-level cache and dissolve the scenario), switch back — and
asserts the position survives.
"""
from __future__ import annotations
import re
import shutil
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page, expect
_REPO_ROOT = Path(__file__).resolve().parents[2]
# Enough files that the tree overflows the rail and can scroll well past 0.
_FILE_COUNT = 80
def _seed_file(base_url: str, session_id: str, path: str) -> None:
resp = httpx.put(
f"{base_url}/v1/sessions/{session_id}/resources/environments/default/filesystem/{path}",
json={"content": f"contents of {path}\n", "encoding": "utf-8"},
timeout=10.0,
)
resp.raise_for_status()
@pytest.fixture
def seeded_scroll_sessions(
seeded_session_pair: tuple[str, str, str],
) -> Iterator[tuple[str, str, str]]:
base_url, session_a, session_b = seeded_session_pair
for i in range(_FILE_COUNT):
_seed_file(base_url, session_a, f"scroll_file_{i:03d}.txt")
_seed_file(base_url, session_b, "other_session_file.txt")
try:
yield (base_url, session_a, session_b)
finally:
for session_id in (session_a, session_b):
shutil.rmtree(_REPO_ROOT / session_id, ignore_errors=True)
def test_files_panel_scroll_survives_session_switch(
page: Page,
seeded_scroll_sessions: tuple[str, str, str],
) -> None:
"""Scroll in A, switch to B and back via the sidebar: position restored."""
base_url, session_a, session_b = seeded_scroll_sessions
page.goto(f"{base_url}/c/{session_a}?view=explore")
rail = page.get_by_role("complementary", name="Workspace")
expect(rail.get_by_text("scroll_file_000.txt")).to_be_visible(timeout=30_000)
# Both sessions must be in the sidebar for the SPA switch.
expect(page.locator(f'a[href="/c/{session_b}"]')).to_be_visible(timeout=30_000)
section = rail.locator("section")
# Scroll partway down; a real scroll event fires and the panel saves it.
section.evaluate("el => { el.scrollTop = 300; }")
expect(section).to_have_js_property("scrollTop", 300)
# Switch to session B in the SPA and let its (near-empty) panel render.
page.locator(f'a[href="/c/{session_b}"]').click()
expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=15_000)
expect(rail.get_by_text("other_session_file.txt")).to_be_visible(timeout=30_000)
# Back to session A: once its tree is tall again, the position returns.
page.locator(f'a[href="/c/{session_a}"]').click()
expect(page).to_have_url(f"{base_url}/c/{session_a}", timeout=15_000)
expect(rail.get_by_text("scroll_file_000.txt")).to_be_visible(timeout=30_000)
expect(section).to_have_js_property("scrollTop", 300, timeout=10_000)
# Climb from the rendered markdown editor to whichever ancestor actually
# scrolls (the ref'd container in MarkdownRichTextViewer), then set / read
# its scrollTop. Re-run after a session round-trip: the viewer remounts, so
# the element must be re-found each time.
_FIND_EDITOR_SCROLLER_JS = """
const findScroller = () => {
for (const pm of document.querySelectorAll('.ProseMirror')) {
let el = pm;
while (el && el.scrollHeight <= el.clientHeight + 1) el = el.parentElement;
if (el && el !== document.documentElement && el.clientHeight > 0) return el;
}
return null;
};
"""
_SET_EDITOR_SCROLL_JS = f"""
(target) => {{
{_FIND_EDITOR_SCROLLER_JS}
const el = findScroller();
if (!el) return null;
el.scrollTop = target;
return el.scrollTop;
}}
"""
_GET_EDITOR_SCROLL_JS = f"""
() => {{
{_FIND_EDITOR_SCROLLER_JS}
const el = findScroller();
return el ? el.scrollTop : null;
}}
"""
def test_open_file_scroll_survives_session_switch(
page: Page,
seeded_scroll_sessions: tuple[str, str, str],
) -> None:
"""A file open in the viewer reopens at its saved scroll offset.
The app persists which file is open per session, so switching back
re-opens the viewer — historically at the top. Uses a long markdown
file (default view mode is the rich-text editor).
"""
base_url, session_a, session_b = seeded_scroll_sessions
long_md = "".join(
f"## Section {i}\n\nSome paragraph text for section {i}.\n\n" for i in range(120)
)
resp = httpx.put(
f"{base_url}/v1/sessions/{session_a}/resources/environments/default/filesystem/long_doc.md",
json={"content": long_md, "encoding": "utf-8"},
timeout=10.0,
)
resp.raise_for_status()
page.goto(f"{base_url}/c/{session_a}?view=explore")
rail = page.get_by_role("complementary", name="Workspace")
# `.first`: the file renders both as a tree row and in the changed strip.
row = rail.get_by_role("button", name=re.compile("long_doc.md")).first
expect(row).to_be_visible(timeout=30_000)
row.click()
expect(page.locator(".ProseMirror").first).to_be_visible(timeout=30_000)
# Scroll partway into the document.
assert page.evaluate(_SET_EDITOR_SCROLL_JS, 250) == 250
# Round-trip through session B via the sidebar (client-side navigation).
page.locator(f'a[href="/c/{session_b}"]').click()
expect(page).to_have_url(f"{base_url}/c/{session_b}", timeout=15_000)
expect(rail.get_by_text("other_session_file.txt")).to_be_visible(timeout=30_000)
page.locator(f'a[href="/c/{session_a}"]').click()
expect(page).to_have_url(f"{base_url}/c/{session_a}", timeout=15_000)
# The viewer re-opens the remembered file…
expect(page.locator(".ProseMirror").first).to_be_visible(timeout=30_000)
# …and returns to the saved offset once the content is tall enough.
restored = None
for _ in range(50):
restored = page.evaluate(_GET_EDITOR_SCROLL_JS)
if restored == 250:
break
page.wait_for_timeout(200)
assert restored == 250
+18 -2
View File
@@ -20,6 +20,7 @@ import {
useRef,
useState,
type RefObject,
type UIEvent,
} from "react";
import {
AtSignIcon,
@@ -62,6 +63,7 @@ import {
lineOverlapsSelection,
} from "./codeViewerHelpers";
import { NotebookPreview } from "./NotebookPreview";
import { useScrollRestore } from "./useScrollRestore";
import { PreviewSearchBar } from "./PreviewSearchBar";
import { renderLineTokens } from "./codeViewerRendering";
import { HtmlCommentViewer } from "./HtmlCommentViewer";
@@ -155,14 +157,17 @@ const MARKDOWN_COMPONENTS: Components = {
function MarkdownPreview({
content,
rootRef,
onScroll,
}: {
content: string;
rootRef?: RefObject<HTMLDivElement | null>;
onScroll?: (event: UIEvent<HTMLElement>) => void;
}) {
return (
<div
ref={rootRef}
data-preview-scroll
onScroll={onScroll}
className="markdown-preview px-6 py-4 overflow-auto h-full prose dark:prose-invert prose-sm max-w-none"
>
<ReactMarkdown
@@ -187,6 +192,8 @@ function PreviewWithSearch({
searchOpen,
onSearchHandled,
searchInputRef,
scrollKey,
scrollReady,
}: {
content: string;
isNotebook: boolean;
@@ -194,8 +201,15 @@ function PreviewWithSearch({
searchOpen: boolean;
onSearchHandled: () => void;
searchInputRef: RefObject<HTMLInputElement | null>;
/** Persist/restore the preview's scroll position under this cache key. */
scrollKey: string | null;
/** True once the file content backing the preview is present. */
scrollReady: boolean;
}) {
const previewRef = useRef<HTMLDivElement>(null);
// The preview div (not the FileViewer content area) is the real scroller
// here, so scroll persistence attaches to it directly.
const handleScroll = useScrollRestore(previewRef, scrollKey, scrollReady);
const bar = (
<PreviewSearchBar
containerRef={previewRef}
@@ -206,9 +220,9 @@ function PreviewWithSearch({
/>
);
const preview = isNotebook ? (
<NotebookPreview content={content} rootRef={previewRef} />
<NotebookPreview content={content} rootRef={previewRef} onScroll={handleScroll} />
) : (
<MarkdownPreview content={content} rootRef={previewRef} />
<MarkdownPreview content={content} rootRef={previewRef} onScroll={handleScroll} />
);
// The find bar sits above the preview; a truncated preview also shows the
// banner. The bar renders nothing when closed, so layout is unchanged then.
@@ -720,6 +734,8 @@ export function CodeViewer({
searchOpen={searchOpen}
onSearchHandled={handleSearchHandled}
searchInputRef={searchInputRef}
scrollKey={conversationId && path ? `viewer-preview:${conversationId}:${path}` : null}
scrollReady={fileQuery.data !== undefined}
/>
);
}
+18 -1
View File
@@ -95,6 +95,7 @@ import {
openHtmlArtifactInNewTab,
} from "./codeViewerHelpers";
import { CommentsPanel, type ActiveSelection } from "./CommentsPanel";
import { useScrollRestore } from "./useScrollRestore";
import { isPdfAnchor } from "./pdfCommentHelpers";
// Monaco diff is heavy (~MBs + worker); load it only when the diff view is
@@ -715,6 +716,18 @@ function FileViewerBody({
const viewMode: "editor" | "preview" | "source" | "diff" =
diffActive && isDiffAvailable ? "diff" : fileViewMode;
const diffViewActive = viewMode === "diff";
// Persist where the reader was in the content area (markdown source, plain
// text). The view mode is part of the key because each mode renders a
// different height, so sharing one offset across modes would drop the reader
// at an unrelated place after a toggle; the namespace is separate from the
// `viewer:` keys Monaco writes for its own internal scroller.
const contentScrollKey =
conversationId && path ? `viewer-content:${conversationId}:${viewMode}:${path}` : null;
const handleContentScroll = useScrollRestore(
contentAreaRef,
contentScrollKey,
fileQuery.data !== undefined,
);
// Measure the content area so the split toggle can hide when there isn't
// enough room for side-by-side. Only observe while the diff is shown — the
// ref element only exists then, and it's the only mode that cares.
@@ -1328,7 +1341,11 @@ function FileViewerBody({
</div>
<div className="min-h-0 flex-1 flex flex-col md:flex-row overflow-hidden">
<div ref={contentAreaRef} className="flex-1 overflow-y-auto min-w-0">
<div
ref={contentAreaRef}
onScroll={handleContentScroll}
className="flex-1 overflow-y-auto min-w-0"
>
{isDeletedFile && viewMode !== "diff" ? (
<div className="flex flex-col items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
<Trash2Icon className="size-5 opacity-40" />
+144
View File
@@ -15,6 +15,7 @@ import {
import { FilesPanel } from "./FilesPanel";
import { FilesPanelDrawer } from "./FilesPanelDrawer";
import { FolderTree } from "./FolderTree";
import { SCROLL_RESTORE_BUDGET_MS } from "./useScrollRestore";
vi.mock("@/hooks/useWorkspaceChangedFiles", () => ({
useWorkspaceAllFiles: vi.fn(),
@@ -1243,3 +1244,146 @@ describe("FilesPanel sort control", () => {
expect(screen.getByRole("button", { name: /^Sort:/ })).toBeInTheDocument();
});
});
describe("FilesPanel scroll position persistence", () => {
function renderAndGetScrollSection(conversationId: string, files: WorkspaceFile[]) {
const result = renderPanel({ conversationId, files });
const section = result.container.querySelector("section");
if (!section) throw new Error("scroll section not found");
return { result, section };
}
it("restores the saved scroll position when returning to a conversation", () => {
const files = Array.from({ length: 50 }, (_, i) => file(`file-${i}.ts`));
// Scroll in conversation A, then leave it.
const a = renderAndGetScrollSection("conv_scroll_a", files);
a.section.scrollTop = 120;
fireEvent.scroll(a.section);
a.result.unmount();
// Conversation B starts at the top, unaffected by A's position.
const b = renderAndGetScrollSection("conv_scroll_b", files);
expect(b.section.scrollTop).toBe(0);
b.result.unmount();
// Returning to A restores its saved position.
const back = renderAndGetScrollSection("conv_scroll_a", files);
expect(back.section.scrollTop).toBe(120);
});
it("does not let the loading clamp overwrite the saved position", async () => {
const files = Array.from({ length: 50 }, (_, i) => file(`file-${i}.ts`));
const conversationId = "conv_scroll_clamp";
// Scroll in the conversation, then leave it.
const first = renderAndGetScrollSection(conversationId, files);
first.section.scrollTop = 120;
fireEvent.scroll(first.section);
first.result.unmount();
// Revisit while the queries are still disabled (environment pending):
// data is undefined — not "loading" — and the short placeholder content
// clamps scrollTop to 0, which fires a scroll event.
const pending = {
data: undefined,
error: null,
isError: false,
isLoading: false,
};
useAllFilesMock.mockReturnValue(pending as unknown as ReturnType<typeof useWorkspaceAllFiles>);
useChangedFilesMock.mockReturnValue(
pending as unknown as ReturnType<typeof useWorkspaceChangedFiles>,
);
useDirectoryMock.mockReturnValue(directoryResult());
useEnvironmentMock.mockReturnValue(environmentResult(null));
useSearchMock.mockReturnValue(searchResult());
// Fresh JSX per render — reusing the same element would let React bail
// out of the re-render without re-reading the updated hook mocks.
const panel = () => (
<MemoryRouter initialEntries={[`/c/${conversationId}`]}>
<Routes>
<Route
path="/c/:conversationId"
element={
<FilesPanel
sort="recent"
onSortChange={vi.fn()}
flatView={false}
onFileSelect={vi.fn()}
onFlatViewChange={vi.fn()}
showHidden={false}
onShowHiddenChange={vi.fn()}
/>
}
/>
</Routes>
</MemoryRouter>
);
const view = render(panel());
const section = view.container.querySelector("section");
if (!section) throw new Error("scroll section not found");
section.scrollTop = 0;
fireEvent.scroll(section);
// Files arrive: the saved position survives the clamp and is restored.
useAllFilesMock.mockReturnValue(allFilesResult(files));
useChangedFilesMock.mockReturnValue(changedFilesResult([]));
view.rerender(panel());
expect(section.scrollTop).toBe(120);
// Let the restore's animation-frame loop settle (jsdom has no layout, so
// the target is never "reachable" — the loop runs until its time budget
// expires), then user scrolls are saved again.
const expired = performance.now() + SCROLL_RESTORE_BUDGET_MS + 1;
vi.spyOn(performance, "now").mockReturnValue(expired);
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
vi.mocked(performance.now).mockRestore();
section.scrollTop = 40;
fireEvent.scroll(section);
view.unmount();
const back = renderAndGetScrollSection(conversationId, files);
expect(back.section.scrollTop).toBe(40);
});
});
describe("FolderTree expanded state across conversation switches", () => {
function renderTree(conversationId: string, files: WorkspaceFile[]) {
useDirectoryMock.mockReturnValue(directoryResult());
const tree = (id: string) => (
<TooltipProvider>
<FolderTree
files={files}
isLoading={false}
isError={false}
error={null}
onFileSelect={vi.fn()}
conversationId={id}
showHidden={false}
changedFiles={[]}
sort="alpha"
/>
</TooltipProvider>
);
const view = render(tree(conversationId));
return { view, tree };
}
it("re-syncs expanded folders when switching conversations without remounting", () => {
const files = [file("src/App.tsx"), file("README.md")];
const { view, tree } = renderTree("conv_tree_resync_a", files);
// Collapse src/ in conversation A (expanded by default).
expect(screen.getByText("App.tsx")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /src\// }));
expect(screen.queryByText("App.tsx")).toBeNull();
// Switch to conversation B in place: defaults apply, src/ is expanded.
view.rerender(tree("conv_tree_resync_b"));
expect(screen.getByText("App.tsx")).toBeDefined();
// Switch back to A in place: its collapsed state is restored.
view.rerender(tree("conv_tree_resync_a"));
expect(screen.queryByText("App.tsx")).toBeNull();
});
});
+16 -1
View File
@@ -12,7 +12,7 @@ import {
SlidersHorizontalIcon,
XIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useParams } from "@/lib/routing";
import { useSessionHostOnline, useSessionRunnerOnline } from "@/hooks/RunnerHealthProvider";
import { useChatStore } from "@/store/chatStore";
@@ -33,6 +33,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { type ChangedSort, FlatFileList } from "./FlatFileList";
import { FolderTree } from "./FolderTree";
import { useScrollRestore } from "./useScrollRestore";
interface FilesPanelProps {
onFileSelect: (path: string) => void;
@@ -360,6 +361,18 @@ export function FilesPanel({
// Highlight the filters toggle when include/exclude carry a value.
const treeFiltersActive = treeInclude.trim().length > 0 || treeExclude.trim().length > 0;
// Persist/restore the list's scroll position across conversation and view
// switches. Keyed per conversation + view (Changed vs All) since the two
// lists have independent heights. Readiness is data presence rather than
// `isLoading` — the files queries are disabled (not loading) until the
// environment query resolves.
const scrollRef = useRef<HTMLElement>(null);
const scrollKey = conversationId
? `files:${conversationId}:${flatView ? "changed" : "all"}`
: null;
const dataReady = flatView ? changedQuery.data !== undefined : allFilesQuery.data !== undefined;
const handleScroll = useScrollRestore(scrollRef, scrollKey, dataReady);
return (
<div
className={cn(
@@ -492,11 +505,13 @@ export function FilesPanel({
</div>
)}
<section
ref={scrollRef}
className={cn(
"overflow-y-auto px-2 pb-2",
flatView ? "pt-1" : "pt-2",
fillHeight ? "min-h-0 flex-1" : "max-h-72",
)}
onScroll={handleScroll}
>
{flatView ? (
<FlatFileList
+15 -4
View File
@@ -1,5 +1,5 @@
import { ChevronRightIcon, FileIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
RunnerOfflineError,
type WorkspaceChangedFile,
@@ -244,10 +244,21 @@ export function FolderTree({
});
// When files arrive for the first time (async load) and no cache entry
// exists yet, compute and persist the default open set.
useEffect(() => {
// exists yet, compute and persist the default open set. Also re-sync from
// the cache when the panel switches conversations without remounting —
// otherwise the tree keeps the previous conversation's expanded set. A
// layout effect so the switch resolves before paint (no collapsed flash).
const expandedForRef = useRef(conversationId);
useLayoutEffect(() => {
if (!conversationId) return;
if (!files || expandedPathsCache.has(conversationId)) return;
const switched = expandedForRef.current !== conversationId;
expandedForRef.current = conversationId;
const cached = expandedPathsCache.get(conversationId);
if (cached) {
if (switched) setExpandedPaths(new Set(cached));
return;
}
if (!files) return;
const initial = defaultExpandedPaths(files);
expandedPathsCache.set(conversationId, initial);
setExpandedPaths(new Set(initial));
+10
View File
@@ -30,6 +30,7 @@ import { ToolbarPlugin } from "./MarkdownEditorToolbar";
import { TableHandles } from "./TableBubbleMenu";
import { TruncatedBanner } from "./TruncatedBanner";
import { useMarkdownEditorSync } from "./useMarkdownEditorSync";
import { useScrollRestore } from "./useScrollRestore";
import { useEditorAutoSave } from "./useEditorAutoSave";
import { MarkdownCommentPlugin } from "./MarkdownCommentPlugin";
import { MarkdownSearchBar } from "./MarkdownSearchBar";
@@ -243,6 +244,14 @@ function MarkdownRichTextViewerInner({
[],
);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Persist/restore the editor's scroll position across unmount/remount and
// session switches. Content is present at mount (the parent gates on the
// file query), so the restore is ready immediately.
const handleScrollPersist = useScrollRestore(
scrollContainerRef,
conversationId && path ? `viewer-mdedit:${conversationId}:${path}` : null,
true,
);
// Fall back to a local ref when the parent doesn't pass one (the toolbar
// path always does; this keeps the bar usable in isolation/tests).
const fallbackSearchInputRef = useRef<HTMLInputElement>(null);
@@ -463,6 +472,7 @@ function MarkdownRichTextViewerInner({
)}
<div
ref={scrollContainerRef}
onScroll={handleScrollPersist}
className="relative flex-1 overflow-auto px-8 py-6"
// Link following. The Link extension runs with openOnClick:false so a
// plain click in edit mode positions the cursor instead of navigating.
@@ -19,6 +19,9 @@ const h = vi.hoisted(() => ({
onChange: null as ((value: string | undefined, ev: unknown) => void) | null,
cmdS: null as (() => void) | null,
blur: null as (() => void) | null,
scrollTops: [] as number[],
scroll: null as ((e: { scrollTop: number }) => void) | null,
domNode: document.createElement("div"),
}));
// Minimal Monaco namespace: only the members handleMount touches.
@@ -34,6 +37,10 @@ interface FakeEditor {
getModel: () => { setEOL: () => void };
addCommand: (binding: number, handler: () => void) => void;
onDidBlurEditorWidget: (handler: () => void) => { dispose: () => void };
setScrollTop: (top: number) => void;
onDidScrollChange: (handler: (e: { scrollTop: number }) => void) => { dispose: () => void };
/** Real node so the restore can listen for the reader taking over scrolling. */
getDomNode: () => HTMLElement;
saveViewState: () => null;
restoreViewState: () => void;
getAction: () => { run: () => void };
@@ -61,6 +68,14 @@ function makeFakeEditor(initial: string): FakeEditor {
h.blur = handler;
return { dispose: () => {} };
},
setScrollTop: (top) => {
h.scrollTops.push(top);
},
onDidScrollChange: (handler) => {
h.scroll = handler;
return { dispose: () => {} };
},
getDomNode: () => h.domNode,
saveViewState: () => null,
restoreViewState: () => {},
getAction: () => ({ run: () => {} }),
@@ -109,6 +124,7 @@ vi.mock("@/hooks/useWriteFileContent", () => ({ useWriteFileContent: vi.fn() }))
vi.mock("@/hooks/RunnerHealthProvider", () => ({ useSessionRunnerOnline: vi.fn() }));
import { MonacoCodeEditor } from "./MonacoCodeEditor";
import { getSavedScrollTop, saveScrollTop } from "./useScrollRestore";
import * as writeHook from "@/hooks/useWriteFileContent";
import * as runnerHook from "@/hooks/RunnerHealthProvider";
@@ -166,6 +182,9 @@ beforeEach(() => {
h.onChange = null;
h.cmdS = null;
h.blur = null;
h.scrollTops = [];
h.scroll = null;
h.domNode = document.createElement("div");
mockWrite();
// Online → auto-save enabled.
vi.mocked(runnerHook.useSessionRunnerOnline).mockReturnValue(true);
@@ -290,3 +309,60 @@ describe("MonacoCodeEditor auto-save wiring (integration)", () => {
expect(mutateAsync).toHaveBeenCalledWith({ path: PATH, content: EDITED });
});
});
// Monaco scrolls internally, so the viewer drives the shared scroll cache from
// the editor's own scroll events rather than the DOM restore hook.
describe("MonacoCodeEditor scroll position persistence", () => {
const KEY = `viewer:conv_monaco_autosave:${PATH}`;
it("restores the saved offset on mount", async () => {
saveScrollTop(KEY, 320);
await renderMounted(makeEditor());
expect(h.scrollTops).toContain(320);
});
it("records the editor's offset as the user scrolls", async () => {
saveScrollTop(KEY, 0);
await renderMounted(makeEditor());
expect(h.scroll).not.toBeNull();
h.scroll?.({ scrollTop: 210 });
expect(getSavedScrollTop(KEY)).toBe(210);
});
it("keeps offsets separate per file", async () => {
saveScrollTop(KEY, 320);
saveScrollTop("viewer:conv_monaco_autosave:src/other.ts", 15);
await renderMounted(makeEditor());
// Reaching the restored offset ends the restore, so later scrolls persist.
h.scroll?.({ scrollTop: 320 });
h.scroll?.({ scrollTop: 44 });
expect(getSavedScrollTop(KEY)).toBe(44);
expect(getSavedScrollTop("viewer:conv_monaco_autosave:src/other.ts")).toBe(15);
});
it("does not let the mount-time clamp overwrite the saved offset", async () => {
saveScrollTop(KEY, 320);
await renderMounted(makeEditor());
h.scrollTops = [];
// Not laid out yet: Monaco clamps to 0 and reports it. Persisting that would
// destroy the reader's position, so the target is re-asserted instead.
h.scroll?.({ scrollTop: 0 });
expect(getSavedScrollTop(KEY)).toBe(320);
expect(h.scrollTops).toContain(320);
// Once the editor can hold the offset, saving resumes.
h.scroll?.({ scrollTop: 320 });
h.scroll?.({ scrollTop: 90 });
expect(getSavedScrollTop(KEY)).toBe(90);
});
it("stops re-asserting when the reader scrolls during the restore", async () => {
saveScrollTop(KEY, 320);
await renderMounted(makeEditor());
h.domNode.dispatchEvent(new Event("wheel"));
h.scroll?.({ scrollTop: 10 });
expect(getSavedScrollTop(KEY)).toBe(10);
});
});
@@ -79,6 +79,8 @@ interface FakeEditor {
getModel: () => { setEOL: () => void };
addCommand: () => void;
onDidBlurEditorWidget: () => { dispose: () => void };
setScrollTop: (top: number) => void;
onDidScrollChange: () => { dispose: () => void };
saveViewState: () => null;
restoreViewState: () => void;
getAction: (id: string) => { run: () => void } | undefined;
@@ -95,6 +97,8 @@ function makeFakeEditor(initial: string): FakeEditor {
getModel: () => ({ setEOL: () => {} }),
addCommand: () => {},
onDidBlurEditorWidget: () => ({ dispose: () => {} }),
setScrollTop: () => {},
onDidScrollChange: () => ({ dispose: () => {} }),
saveViewState: () => null,
restoreViewState: () => {},
// Only the find action is exercised here.
@@ -34,6 +34,8 @@ interface FakeEditor {
getModel: () => { setEOL: () => void };
addCommand: () => void;
onDidBlurEditorWidget: () => { dispose: () => void };
setScrollTop: (top: number) => void;
onDidScrollChange: () => { dispose: () => void };
saveViewState: () => null;
restoreViewState: () => void;
getAction: () => { run: () => void };
@@ -52,6 +54,8 @@ function makeFakeEditor(initial: string): FakeEditor {
getModel: () => ({ setEOL: () => {} }),
addCommand: () => {},
onDidBlurEditorWidget: () => ({ dispose: () => {} }),
setScrollTop: () => {},
onDidScrollChange: () => ({ dispose: () => {} }),
saveViewState: () => null,
restoreViewState: () => {},
getAction: () => ({ run: () => {} }),
+14
View File
@@ -42,6 +42,7 @@ import {
} from "./monacoSetup";
import type { monaco } from "./monacoSetup";
import { useMonacoCommentLayer, type CodeEditorInstance } from "./useMonacoCommentLayer";
import { attachEditorScrollRestore } from "./useScrollRestore";
import "./monacoCodeEditor.css";
type EditorOptions = EditorProps["options"];
@@ -287,6 +288,12 @@ function MonacoCodeEditorInner({
const flushRef = useRef(autoSave.flush);
flushRef.current = autoSave.flush;
// Monaco scrolls internally, so its offset is cached per conversation + file
// rather than via the DOM scroll-restore hook. Held in a ref so the mount-time
// onDidScrollChange subscription always writes the current file's key.
const scrollKeyRef = useRef("");
scrollKeyRef.current = `viewer:${conversationId}:${path}`;
const handleMount: OnMount = useCallback(
(editor, monaco) => {
editorInstanceRef.current = editor;
@@ -327,6 +334,13 @@ function MonacoCodeEditorInner({
setDirty(false);
if (viewState) ed.restoreViewState(viewState);
};
// Reopening a file (or switching sessions and back) lands where the user
// left off, and further scrolling is cached under the current file's key.
attachEditorScrollRestore(
editor,
() => scrollKeyRef.current,
() => editorInstanceRef.current === editor,
);
setMounted(true);
},
[setContentRef, setDirty, content],
+83 -2
View File
@@ -54,6 +54,7 @@ vi.mock("@/hooks/usePermissions", () => ({ useCanEdit: vi.fn(() => true) }));
import { MonacoDiffViewer } from "./MonacoDiffViewer";
import { codeFontFamilyForEditor, writeCodeFontSizePx } from "@/lib/codeFontPreferences";
import { getSavedScrollTop, saveScrollTop } from "./useScrollRestore";
function renderDiff(props: {
before: string | null;
@@ -78,6 +79,15 @@ function renderDiff(props: {
);
}
// The modified editor's scroll API, used by the viewer to persist the reader's
// place in the diff.
function scrollStubs() {
return {
setScrollTop: vi.fn(),
onDidScrollChange: vi.fn(() => ({ dispose: () => {} })),
};
}
beforeEach(() => {
h.diffProps = null;
h.onMount = null;
@@ -145,7 +155,7 @@ describe("MonacoDiffViewer", () => {
it("wires getModifiedEditor() into the comment layer on mount", async () => {
const setEOL = vi.fn();
const fakeModified = { getModel: () => ({ setEOL }) };
const fakeModified = { getModel: () => ({ setEOL }), ...scrollStubs() };
renderDiff({ before: "a", after: "b\r\n", layout: "split" });
await waitFor(() => expect(h.onMount).not.toBeNull());
@@ -170,9 +180,80 @@ describe("MonacoDiffViewer", () => {
expect(setEOL).toHaveBeenCalledWith(1);
});
it("restores and records the modified side's scroll offset", async () => {
saveScrollTop("viewer-diff:conv_1:src/a.ts", 260);
const setScrollTop = vi.fn();
const onDidScrollChange = vi.fn((_listener: (e: { scrollTop: number }) => void) => ({
dispose: () => {},
}));
const fakeModified = {
getModel: () => ({ setEOL: vi.fn() }),
setScrollTop,
onDidScrollChange,
getDomNode: () => document.createElement("div"),
};
renderDiff({ before: "a", after: "b", layout: "split" });
await waitFor(() => expect(h.onMount).not.toBeNull());
act(() => {
h.onMount?.(
{ getModifiedEditor: () => fakeModified } as unknown as Parameters<DiffOnMount>[0],
{
editor: { EndOfLineSequence: { LF: 0, CRLF: 1 } },
} as unknown as Parameters<DiffOnMount>[1],
);
});
// The reader's place in the diff is restored, and further scrolling is
// cached under the diff's own key.
expect(setScrollTop).toHaveBeenCalledWith(260);
const handler = onDidScrollChange.mock.calls[0]![0];
handler({ scrollTop: 260 });
handler({ scrollTop: 88 });
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(88);
});
it("does not let the mount-time clamp overwrite the diff's saved offset", async () => {
saveScrollTop("viewer-diff:conv_1:src/a.ts", 260);
const setScrollTop = vi.fn();
const onDidScrollChange = vi.fn((_listener: (e: { scrollTop: number }) => void) => ({
dispose: () => {},
}));
const fakeModified = {
getModel: () => ({ setEOL: vi.fn() }),
setScrollTop,
onDidScrollChange,
getDomNode: () => document.createElement("div"),
};
renderDiff({ before: "a", after: "b", layout: "split" });
await waitFor(() => expect(h.onMount).not.toBeNull());
act(() => {
h.onMount?.(
{ getModifiedEditor: () => fakeModified } as unknown as Parameters<DiffOnMount>[0],
{
editor: { EndOfLineSequence: { LF: 0, CRLF: 1 } },
} as unknown as Parameters<DiffOnMount>[1],
);
});
setScrollTop.mockClear();
// The panes aren't laid out yet, so Monaco reports the clamped 0; caching it
// would lose the reader's place, so the target is re-asserted instead.
const handler = onDidScrollChange.mock.calls[0]![0];
handler({ scrollTop: 0 });
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(260);
expect(setScrollTop).toHaveBeenCalledWith(260);
// Once the offset is reachable, saving resumes.
handler({ scrollTop: 260 });
handler({ scrollTop: 12 });
expect(getSavedScrollTop("viewer-diff:conv_1:src/a.ts")).toBe(12);
});
it("re-fonts the mounted diff editor when the code-font preference changes", async () => {
const updateOptions = vi.fn();
const fakeModified = { getModel: () => ({ setEOL: vi.fn() }) };
const fakeModified = { getModel: () => ({ setEOL: vi.fn() }), ...scrollStubs() };
const fakeDiff = { getModifiedEditor: () => fakeModified, updateOptions };
renderDiff({ before: "a", after: "b", layout: "split" });
await waitFor(() => expect(h.onMount).not.toBeNull());
+14
View File
@@ -26,6 +26,7 @@ import {
resolvedThemeToMonaco,
} from "./monacoSetup";
import { useMonacoCommentLayer, type CodeEditorInstance } from "./useMonacoCommentLayer";
import { attachEditorScrollRestore } from "./useScrollRestore";
import "./monacoCodeEditor.css";
interface MonacoDiffViewerProps {
@@ -107,6 +108,12 @@ export function MonacoDiffViewer({
const diffEditorRef = useRef<Parameters<DiffOnMount>[0] | null>(null);
const [mounted, setMounted] = useState(false);
// The diff scrolls inside Monaco, so its offset is cached per conversation +
// file rather than via the DOM scroll-restore hook. Kept in its own namespace
// so a file's diff and its editor view don't share one offset.
const scrollKeyRef = useRef("");
scrollKeyRef.current = `viewer-diff:${conversationId}:${path}`;
const handleMount: DiffOnMount = useCallback(
(diffEditor, monaco) => {
diffEditorRef.current = diffEditor;
@@ -121,6 +128,13 @@ export function MonacoDiffViewer({
? monaco.editor.EndOfLineSequence.CRLF
: monaco.editor.EndOfLineSequence.LF,
);
// Restore the reader's place in the diff and cache further scrolling under
// the diff's own key.
attachEditorScrollRestore(
modified,
() => scrollKeyRef.current,
() => modifiedEditorRef.current === modified,
);
setMounted(true);
},
[after],
+9 -2
View File
@@ -1,4 +1,4 @@
import type { RefObject } from "react";
import type { RefObject, UIEvent } from "react";
import type { BundledLanguage } from "shiki";
import AnsiDefault from "ansi-to-react";
import ReactMarkdown from "react-markdown";
@@ -213,9 +213,11 @@ function CodeCell({ cell, language }: { cell: NotebookCell; language: BundledLan
export function NotebookPreview({
content,
rootRef,
onScroll,
}: {
content: string;
rootRef?: RefObject<HTMLDivElement | null>;
onScroll?: (event: UIEvent<HTMLElement>) => void;
}) {
const { notebook, error } = parseNotebook(content);
@@ -234,7 +236,12 @@ export function NotebookPreview({
const language = langName as BundledLanguage;
return (
<div ref={rootRef} data-preview-scroll className="h-full space-y-4 overflow-auto px-6 py-4">
<div
ref={rootRef}
data-preview-scroll
onScroll={onScroll}
className="h-full space-y-4 overflow-auto px-6 py-4"
>
{(notebook.cells ?? []).map((cell, i) => {
if (cell.cell_type === "markdown") {
return (
+159
View File
@@ -0,0 +1,159 @@
// Tests for useScrollRestore — the shared scroll-position cache behind the
// Files panel and the file viewer:
//
// 1. Revisiting a key restores the offset saved before unmount.
// 2. A fresh key starts at the top, unaffected by another key's offset.
// 3. The loading clamp (scrollTop forced to 0 before the content is tall
// enough) cannot overwrite the saved offset.
// 4. The restore keeps trying while the content is still growing, and only
// gives up once its time budget expires.
// 5. Real user input (a wheel gesture) settles the restore immediately.
// 6. Saving resumes once the restore settles.
// 7. A null key disables persistence entirely.
import { useRef } from "react";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SCROLL_RESTORE_BUDGET_MS,
getSavedScrollTop,
saveScrollTop,
useScrollRestore,
} from "./useScrollRestore";
function Scroller({ scrollKey, ready }: { scrollKey: string | null; ready: boolean }) {
const ref = useRef<HTMLDivElement | null>(null);
const onScroll = useScrollRestore(ref, scrollKey, ready);
return <div ref={ref} data-testid="scroller" onScroll={onScroll} />;
}
function mount(scrollKey: string | null, ready = true) {
const view = render(<Scroller scrollKey={scrollKey} ready={ready} />);
const el = view.getByTestId("scroller");
return { view, el };
}
// The restore's budget is measured with performance.now(), so tests drive a
// controllable clock instead of waiting out real seconds.
let now = 0;
async function nextFrame() {
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
}
// jsdom has no layout (scrollHeight/clientHeight are 0), so a saved offset > 0
// is never reachable — the restore settles when the budget runs out.
async function settleRestore() {
now += SCROLL_RESTORE_BUDGET_MS + 1;
await nextFrame();
}
beforeEach(() => {
now = 0;
vi.spyOn(performance, "now").mockImplementation(() => now);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("useScrollRestore", () => {
it("restores the saved offset when a key is revisited", async () => {
const first = mount("view:a");
await settleRestore();
first.el.scrollTop = 120;
fireEvent.scroll(first.el);
first.view.unmount();
const again = mount("view:a");
expect(again.el.scrollTop).toBe(120);
});
it("starts a different key at the top", async () => {
const first = mount("view:b");
await settleRestore();
first.el.scrollTop = 200;
fireEvent.scroll(first.el);
first.view.unmount();
const other = mount("view:c");
expect(other.el.scrollTop).toBe(0);
});
it("does not let the loading clamp overwrite the saved offset", () => {
saveScrollTop("view:clamp", 90);
// Revisit: the container is still a short placeholder, so the browser
// clamps scrollTop to 0 and fires a scroll event.
const { el } = mount("view:clamp");
el.scrollTop = 0;
fireEvent.scroll(el);
expect(getSavedScrollTop("view:clamp")).toBe(90);
});
it("keeps retrying while the height is stable but the budget has not expired", async () => {
saveScrollTop("view:slow", 140);
const { el } = mount("view:slow");
// Async content (highlighting, images, lazy cells) can stall for several
// frames before growing; the restore must not surrender during the stall.
now += 100;
await nextFrame();
await nextFrame();
el.scrollTop = 0;
fireEvent.scroll(el);
expect(getSavedScrollTop("view:slow")).toBe(140);
});
it("settles as soon as the target is reachable", async () => {
saveScrollTop("view:reachable", 50);
const { el } = mount("view:reachable");
// Tall content: the offset fits, so no budget needs to be spent.
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
await nextFrame();
el.scrollTop = 60;
fireEvent.scroll(el);
expect(getSavedScrollTop("view:reachable")).toBe(60);
});
it("stops fighting the user when they scroll during a pending restore", async () => {
saveScrollTop("view:wheel", 300);
const { el } = mount("view:wheel");
fireEvent.wheel(el);
el.scrollTop = 12;
fireEvent.scroll(el);
expect(getSavedScrollTop("view:wheel")).toBe(12);
});
it("saves again once the restore has settled", async () => {
saveScrollTop("view:settle", 90);
const { el } = mount("view:settle");
await settleRestore();
el.scrollTop = 45;
fireEvent.scroll(el);
expect(getSavedScrollTop("view:settle")).toBe(45);
});
it("waits for `ready` before restoring", () => {
saveScrollTop("view:pending", 70);
const { el } = mount("view:pending", false);
expect(el.scrollTop).toBe(0);
});
it("persists nothing when the key is null", async () => {
const { el } = mount(null);
await settleRestore();
el.scrollTop = 30;
fireEvent.scroll(el);
expect(getSavedScrollTop("null")).toBeUndefined();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import type { RefObject, UIEvent } from "react";
/**
* Module-level cache so scroll positions survive unmount/remount and
* conversation switches within a JS session. Shared by the Files panel
* (keyed per conversation + view) and the file viewer surfaces (keyed per
* conversation + path).
*/
const scrollTopCache = new Map<string, number>();
/** Read a saved scroll offset (e.g. to seed a Monaco editor on mount). */
export function getSavedScrollTop(key: string): number | undefined {
return scrollTopCache.get(key);
}
/** Record a scroll offset (e.g. from Monaco's onDidScrollChange). */
export function saveScrollTop(key: string, scrollTop: number): void {
scrollTopCache.set(key, scrollTop);
}
/**
* How long a restore keeps re-asserting the saved offset while the content is
* still too short to hold it. Async work (syntax highlighting, image decode,
* lazy cells) grows the container in bursts with stalls in between, so the
* window has to outlast a stall. Shared with the Monaco surfaces.
*/
export const SCROLL_RESTORE_BUDGET_MS = 1500;
/** Pointer/touch/wheel input that means the user has taken over scrolling. */
const USER_SCROLL_EVENTS = ["wheel", "touchstart", "pointerdown"] as const;
/** The slice of a Monaco code editor the scroll restore needs. */
interface ScrollableEditor {
setScrollTop: (top: number) => void;
onDidScrollChange: (listener: (e: { scrollTop: number }) => void) => { dispose: () => void };
getDomNode?: () => HTMLElement | null;
}
/**
* Restore and persist a Monaco editor's own scroll offset (Monaco scrolls
* internally, so the DOM hook can't drive it).
*
* Same shape as `useScrollRestore`: the editor isn't laid out at mount, so the
* offset clamps to 0 and Monaco reports that clamp as a scroll event. Saving
* stays off — and the target is re-asserted — until the offset is actually
* reached, the user scrolls, or the restore budget runs out. Without that, the
* clamp would overwrite the reader's saved position with 0.
*
* @param editor The editor to restore (the modified side, for a diff).
* @param getKey Reads the current cache key (files can switch under one editor).
* @param isCurrent False once the editor has been replaced or torn down.
*/
export function attachEditorScrollRestore(
editor: ScrollableEditor,
getKey: () => string,
isCurrent: () => boolean,
): void {
const saved = getSavedScrollTop(getKey());
let pending =
saved !== undefined && saved > 0
? { target: saved, deadline: performance.now() + SCROLL_RESTORE_BUDGET_MS }
: null;
const dom = pending ? (editor.getDomNode?.() ?? null) : null;
const settle = () => {
pending = null;
for (const type of USER_SCROLL_EVENTS) dom?.removeEventListener(type, settle);
};
if (pending) {
editor.setScrollTop(pending.target);
requestAnimationFrame(() => {
if (pending && isCurrent()) editor.setScrollTop(pending.target);
});
for (const type of USER_SCROLL_EVENTS) dom?.addEventListener(type, settle, { passive: true });
}
editor.onDidScrollChange((e) => {
if (pending) {
if (Math.abs(e.scrollTop - pending.target) > 1 && performance.now() < pending.deadline) {
editor.setScrollTop(pending.target);
return;
}
settle();
}
saveScrollTop(getKey(), e.scrollTop);
});
}
/**
* Persist and restore a scroll container's position across key changes
* (conversation/file switches) and unmount/remount.
*
* Restoring is not a one-shot write: while new content loads the container
* is a short placeholder and the browser clamps scrollTop to 0, and the
* content then grows in steps, each of which can clamp again. So the
* restore waits for `ready`, then re-asserts the saved offset on every
* render and via an animation-frame loop until the container is tall
* enough to hold it, giving up once `SCROLL_RESTORE_BUDGET_MS` has passed.
* Any wheel/touch/pointer input settles it immediately so the user is never
* fought for the offset. Saving stays off until the restore settles so
* clamp-induced scroll events can't overwrite the cached value.
*
* @param ref The scrollable element.
* @param key Cache key for the current content (null disables persistence).
* @param ready True once the content backing the container is present.
* @returns An onScroll handler to attach to the container.
*/
export function useScrollRestore(
ref: RefObject<HTMLElement | null>,
key: string | null,
ready: boolean,
): (event: UIEvent<HTMLElement>) => void {
const pendingRef = useRef<{ target: number; deadline: number } | null>(null);
const keyRef = useRef<string | null>(null);
if (key !== keyRef.current) {
keyRef.current = key;
// The deadline belongs to the pending entry, not to an effect run, so
// re-renders during the restore can't keep extending the window.
pendingRef.current = key
? {
target: scrollTopCache.get(key) ?? 0,
deadline: performance.now() + SCROLL_RESTORE_BUDGET_MS,
}
: null;
}
// No dependency array: intentionally runs after every render — each
// content-growth step is another chance to reach the saved offset. Cleanup
// cancels the previous run's frame, so only one loop is ever live.
useLayoutEffect(() => {
const el = ref.current;
const pending = pendingRef.current;
if (!el || !pending || !ready) return;
let frame = 0;
function settleForUser() {
pendingRef.current = null;
cancelAnimationFrame(frame);
detach();
}
const detach = () => {
for (const type of USER_SCROLL_EVENTS) el.removeEventListener(type, settleForUser);
};
const attempt = () => {
// A user gesture (or a later render's loop) may have already settled it.
if (pendingRef.current !== pending) {
detach();
return;
}
el.scrollTop = pending.target;
const maxScroll = el.scrollHeight - el.clientHeight;
if (maxScroll >= pending.target || performance.now() >= pending.deadline) {
pendingRef.current = null;
detach();
return;
}
frame = requestAnimationFrame(attempt);
};
for (const type of USER_SCROLL_EVENTS) {
el.addEventListener(type, settleForUser, { passive: true });
}
attempt();
return () => {
cancelAnimationFrame(frame);
detach();
};
});
return useCallback((event: UIEvent<HTMLElement>) => {
if (keyRef.current && pendingRef.current === null) {
scrollTopCache.set(keyRef.current, event.currentTarget.scrollTop);
}
}, []);
}