Files
PaperClipAI/ui/src/lib/issue-chat-scroll.test.ts
T
Doyeon BaekandClaude Opus 4.8 4965dc834a fix(ui): don't window-scroll the desktop shell on comment submit (#7972) (#8041)
Fixes #7972.

## Thinking Path
- The reporter pinned the post-submit composer-viewport restore in
`ui/src/lib/issue-chat-scroll.ts`, which falls back to
`window.scrollBy(...)` when `#main-content` is not independently
scrollable — exactly the short-thread repro case.
- In the desktop shell the body is `overflow: hidden` (set in
`Layout.tsx`) inside a fixed-height `h-dvh` flex column, so a window
scroll never moves content: it translates the entire shell (sidebar
included) off-screen, and a plain reload does not restore it. On mobile
(`min-h-dvh`, `body { overflow: visible }`) and the auth-free perf
fixture the page genuinely scrolls, so the window IS the correct target
there.
- A prior attempt forced `resolveIssueChatScrollTarget` to always use
`#main-content`; that path is a no-op on a non-overflowing container and
is sensitive to a stale `ui/dist`/`.vite` cache, which likely masked the
result. Gating the window-scroll itself is the precise root-cause fix
and covers both restore call sites (`queueViewportRestore` and the
`[messages]` layout effect) since both route through one function.

## What Changed
- Added `isWindowScrollable(doc, win)` to `issue-chat-scroll.ts`: the
window is a valid scroll target only when the document body is not
clipped. It checks both the `overflow` shorthand and the `overflow-y`
longhand (some engines, incl. jsdom, do not derive the longhand from the
shorthand in computed style).
- Gated the `window.scrollBy` fallback in
`restoreComposerViewportSnapshot` behind `isWindowScrollable`; on the
desktop shell there is nothing to restore, so the scroll position is
left untouched.
- Added unit tests for the desktop-shell (no window scroll) case and for
`isWindowScrollable`.

## Verification
- `ui $ vitest run src/lib/issue-chat-scroll.test.ts` → 6 passed (2 new
+ existing window/element restore tests still green).
- `ui $ vitest run src/components/IssueChatThread.test.tsx` → 55 passed
(consumer regression).

## Risks
- Low. Behaviour only changes when the resolved target is `window` AND
the document body is clipped — i.e. the desktop shell, where the
previous behaviour was the bug. Mobile and the perf fixture keep window
scrolling unchanged (body not clipped → `isWindowScrollable` true).

## Model Used
claude-opus-4-8

---

- [x] I searched the GitHub PRs for similar or duplicate PRs and
confirmed this is not a duplicate.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:05:57 -07:00

132 lines
4.0 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
captureComposerViewportSnapshot,
isWindowScrollable,
restoreComposerViewportSnapshot,
shouldPreserveComposerViewport,
} from "./issue-chat-scroll";
function mockTop(element: HTMLElement, top: number) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
top,
bottom: top + 48,
left: 0,
right: 0,
width: 0,
height: 48,
x: 0,
y: top,
toJSON: () => ({}),
} as DOMRect);
}
describe("issue-chat-scroll", () => {
it("restores page scroll when the composer shifts in the viewport", () => {
const composer = document.createElement("div");
document.body.appendChild(composer);
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
mockTop(composer, 420);
const snapshot = captureComposerViewportSnapshot(composer);
mockTop(composer, 560);
restoreComposerViewportSnapshot(snapshot, composer);
expect(scrollByMock).toHaveBeenCalledWith({ top: 140, left: 0, behavior: "auto" });
scrollByMock.mockRestore();
composer.remove();
});
it("does not scroll the window when the document body is overflow:hidden (desktop shell)", () => {
// The desktop app shell pins the body to overflow:hidden inside a fixed
// h-dvh flex column, so a window scroll would translate the whole shell
// (sidebar included) off-screen — paperclipai/paperclip#7972.
const composer = document.createElement("div");
document.body.appendChild(composer);
document.body.style.overflow = "hidden";
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
mockTop(composer, 420);
const snapshot = captureComposerViewportSnapshot(composer);
mockTop(composer, 560);
restoreComposerViewportSnapshot(snapshot, composer);
expect(scrollByMock).not.toHaveBeenCalled();
scrollByMock.mockRestore();
document.body.style.overflow = "";
composer.remove();
});
it("reports the document as scrollable only when the body is not clipped", () => {
document.body.style.overflow = "";
expect(isWindowScrollable()).toBe(true);
document.body.style.overflow = "hidden";
expect(isWindowScrollable()).toBe(false);
document.body.style.overflow = "";
});
it("restores main-content scroll when the layout uses an internal scroller", () => {
const mainContent = document.createElement("main");
mainContent.id = "main-content";
mainContent.style.overflowY = "auto";
Object.defineProperty(mainContent, "scrollHeight", {
configurable: true,
value: 1800,
});
Object.defineProperty(mainContent, "clientHeight", {
configurable: true,
value: 900,
});
mainContent.scrollTop = 240;
document.body.appendChild(mainContent);
const composer = document.createElement("div");
document.body.appendChild(composer);
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
mockTop(composer, 300);
const snapshot = captureComposerViewportSnapshot(composer);
mockTop(composer, 380);
restoreComposerViewportSnapshot(snapshot, composer);
expect(mainContent.scrollTop).toBe(320);
expect(scrollByMock).not.toHaveBeenCalled();
scrollByMock.mockRestore();
composer.remove();
mainContent.remove();
});
it("does not preserve the composer viewport just because the composer is visible", () => {
const composer = document.createElement("div");
document.body.appendChild(composer);
mockTop(composer, 540);
expect(shouldPreserveComposerViewport(composer)).toBe(false);
composer.remove();
});
it("preserves the composer viewport when focus stays inside the composer", () => {
const composer = document.createElement("div");
const input = document.createElement("textarea");
composer.appendChild(input);
document.body.appendChild(composer);
mockTop(composer, 1200);
input.focus();
expect(shouldPreserveComposerViewport(composer)).toBe(true);
composer.remove();
});
});