Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
84d83c4
fix(web): keep horizontal diff scrolls from navigating history
Oct 31, 2026
eb7d688
test(web): restore history spies before unmounting swipe navigation
Oct 31, 2026
a197931
fix(web): recognize a diff scroller remounted mid-swipe
Nov 1, 2026
0e48fd7
fix(web): retain scroll ownership when its viewport disappears
Nov 1, 2026
22ab86d
fix(web): preserve content ownership across late scroll begin
Nov 1, 2026
520b08a
Merge main to incorporate the shadow-root scroll fix
Nov 1, 2026
28b9236
test(web): retain closed-dialog coverage after merging main
Nov 1, 2026
544c8a6
fix(desktop): ignore motionless wheels before scroller ownership
Nov 1, 2026
5c5a93a
fix(desktop): wheels trailing a scroll end no longer claim the next g…
Nov 1, 2026
3f84177
fix(desktop): a scroll end delayed in IPC no longer drops the next ge…
Nov 1, 2026
8a98601
fix(desktop): a slow scroll-end IPC no longer drops the next gesture'…
Nov 1, 2026
68d89be
fix(desktop): content scroll claims hold until the pointer moves
Nov 1, 2026
ffbdbcd
fix(web): release content scroll claims on explicit interaction
Nov 1, 2026
e84ad64
fix(web): a wheel at a new pointer position starts a fresh swipe context
Nov 1, 2026
15160a0
fix(desktop): a swipe reversed past its start can navigate the other way
Nov 1, 2026
fab0a7d
test(desktop): preserve early swipe movement and history boundary rec…
Nov 1, 2026
9220955
fix(desktop): content appearing under a still pointer owns the swipe
Nov 1, 2026
21015dd
fix(desktop): content appearing after a history release can still cla…
Nov 1, 2026
6e7c323
Merge origin/main into issue-watch/28160
shivamhwp Nov 7, 2026
e1d13d5
fix(desktop): reduce the swipe guard to the edge rule and early conte…
shivamhwp Nov 7, 2026
33bf0c4
test(desktop): cover swipe targets that gain overflow and release ove…
shivamhwp Nov 7, 2026
b61067f
Merge origin/main into issue-watch/28160
shivamhwp Nov 7, 2026
1f497be
Merge remote-tracking branch 'github/main' into pr-28475
maria-rcks Nov 11, 2026
4d13dd0
test(web): a swipe from the tab strip's edge stays with the tab strip
maria-rcks Nov 11, 2026
b12e5c2
Merge branch 'main' into issue-watch/28160
shivamhwp Nov 12, 2026
f95e427
Merge remote-tracking branch 'origin/main' into issue-watch/28160
juliusmarminge Nov 12, 2026
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
155 changes: 105 additions & 50 deletions apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,129 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { render } from "../../test/render";
import { render, stubGlobal } from "../../test/render";
import { HistorySwipeNavigation } from "./HistorySwipeNavigation";

const navigateHistory = vi.hoisted(() => vi.fn());
vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory }));
type Scroller = "shadow" | "none" | "shadow-dialog";
type Step = readonly [
event: number | "begin" | "end" | "show-scroller" | "gain-overflow",
after?: "remove" | "close-dialog",
];

function swipe(target: Element, deltaX: number) {
for (const delta of [deltaX, 0]) {
target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true }));
}
function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) {
const panel = container.appendChild(document.createElement("div"));
const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel;
const parent =
scroller === "shadow-dialog"
? root.appendChild(Object.assign(document.createElement("div"), { role: "dialog" }))
: root;
const overflows = scroller === "shadow";
const viewport = parent.appendChild(document.createElement("div"));
viewport.style.cssText = `width: 200px; overflow-x: ${overflows ? "auto" : "visible"}`;
const content = viewport.appendChild(document.createElement("div"));
content.style.cssText = `height: 20px; width: ${overflows ? 1000 : 100}px`;
viewport.scrollLeft = scrollLeft;
return { viewport, content };
}

describe("HistorySwipeNavigation", () => {
it.each([
["scrolling left inside a shadow-root scroller keeps the thread", "shadow", 100, -130, false],
["scrolling right inside a shadow-root scroller keeps the thread", "shadow", 100, 130, false],
it.each<[string, Scroller, number, readonly Step[], "back" | "forward" | null]>([
["scrolling left inside a shadow-root scroller", "shadow", 400, [[-130]], null],
["swiping left at a shadow-root scroller's left edge", "shadow", 0, [[-130]], null],
["a scroller removed after a small first event", "shadow", 400, [[1, "remove"], [130]], null],
[
"scrolling after a shadow scroller remount keeps the thread",
"shadow-remount",
100,
130,
false,
"a history gesture after a content scroll ends",
"shadow",
400,
[[1, "remove"], ["end"], ["begin"], [130]],
"forward",
],
["scrolling left inside a scroller keeps the thread", "light", 100, -130, false],
["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true],
["swiping back over a panel without overflow navigates", "none", 0, -130, true],
["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false],
] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => {
navigateHistory.mockReset();
[
"content appearing under tracking that a trailing history wheel started",
"none",
0,
[["begin"], [130], ["end"], [18], ["show-scroller"], ["begin"], [7], [130], ["end"]],
"forward",
],
[
"a gesture that starts in a dialog before it closes",
"shadow-dialog",
0,
[[-1, "close-dialog"], [-130]],
null,
],
[
"a history swipe over content replaced without overflow, released over a scroller",
"none",
0,
[[-60, "remove"], [-70], ["show-scroller"]],
"back",
],
[
"a history swipe whose target gains overflow",
"none",
0,
[[-60], ["gain-overflow"], [-70]],
"back",
],
])("%s", async (_name, scroller, scrollLeft, steps, navigates) => {
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
const listeners = { begin: new Set<() => void>(), end: new Set<() => void>() };
using _bridge = stubGlobal("desktopBridge", {
onTrackpadScrollBegin: (listener: () => void) => {
listeners.begin.add(listener);
return () => listeners.begin.delete(listener);
},
onTrackpadScrollEnd: (listener: () => void) => {
listeners.end.add(listener);
return () => listeners.end.delete(listener);
},
});
await using screen = await render(<HistorySwipeNavigation />);
const panel = screen.container.appendChild(document.createElement("div"));
const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel;
const viewport = root.appendChild(document.createElement("div"));
const noOverflow = scroller === "none" || scroller === "closed-dialog";
viewport.style.cssText = `width: 200px; overflow-x: ${noOverflow ? "visible" : "auto"}`;
const content = viewport.appendChild(document.createElement("div"));
content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`;
viewport.scrollLeft = scrollLeft;
using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
using forward = vi.spyOn(window.history, "forward").mockImplementation(() => undefined);
using _canGoBack = vi.spyOn(window.navigation, "canGoBack", "get").mockReturnValue(true);
using _canGoForward = vi.spyOn(window.navigation, "canGoForward", "get").mockReturnValue(true);
let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft);

if (scroller === "closed-dialog") {
panel.setAttribute("role", "dialog");
content.dispatchEvent(new WheelEvent("wheel", { deltaX: -1, bubbles: true, composed: true }));
panel.remove();
screen.container.appendChild(viewport);
for (const [event, after] of [...steps, [0] as const]) {
if (event === "begin") {
for (const listener of listeners.begin) listener();
} else if (event === "end") {
for (const listener of listeners.end) listener();
} else if (event === "show-scroller") {
({ viewport, content } = mountPanel(screen.container, "shadow", 400));
} else if (event === "gain-overflow") {
viewport.style.overflowX = "auto";
content.style.width = "1000px";
} else {
content.dispatchEvent(
new WheelEvent("wheel", { deltaX: event, bubbles: true, composed: true }),
);
}
if (after === "remove") {
viewport.remove();
({ viewport, content } = mountPanel(screen.container, "none", 0));
} else if (after === "close-dialog") {
viewport.parentElement!.remove();
screen.container.appendChild(viewport);
}
}

if (scroller === "shadow-remount") {
content.dispatchEvent(new WheelEvent("wheel", { deltaX: 1, bubbles: true, composed: true }));
const replacement = viewport.cloneNode(true) as HTMLDivElement;
viewport.replaceWith(replacement);
replacement.scrollLeft = scrollLeft;
swipe(replacement, deltaX);
} else {
swipe(content, deltaX);
}

expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0);
expect(back).toHaveBeenCalledTimes(navigates === "back" ? 1 : 0);
expect(forward).toHaveBeenCalledTimes(navigates === "forward" ? 1 : 0);
});

it("a horizontal mouse wheel tick off macOS keeps the thread", async () => {
navigateHistory.mockReset();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32");
await using screen = await render(<HistorySwipeNavigation />);
const content = screen.container.appendChild(document.createElement("div"));
content.style.cssText = "height: 20px; width: 100px";
using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined);
using _canGoBack = vi.spyOn(window.navigation, "canGoBack", "get").mockReturnValue(true);
const { content } = mountPanel(screen.container, "none", 0);

swipe(content, -120);
for (const deltaX of [-120, 0]) {
content.dispatchEvent(new WheelEvent("wheel", { deltaX, bubbles: true, composed: true }));
}

expect(navigateHistory).not.toHaveBeenCalled();
expect(back).not.toHaveBeenCalled();
});
});
41 changes: 26 additions & 15 deletions apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type SwipeGesture =
deltas: number[];
y: number;
bounds: SwipeBounds;
target: EventTarget | undefined;
}
// Not a swipe, or one already decided: the rest of its events are ignored.
| { kind: "ignored" };
Expand All @@ -37,30 +38,25 @@ function readMainContentBounds(): SwipeBounds {
return { left, top, width, height };
}

// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it.
function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean {
for (const element of path) {
if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue;
// The composed path reaches scrollers inside shadow roots, such as the diff viewer's.
// A scroller at its edge still owns the gesture, so history never takes a content scroll.
function isInHorizontalScroller(path: readonly EventTarget[]): boolean {
return path.some((element) => {
if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) return false;
const { overflowX } = getComputedStyle(element);
if (overflowX !== "auto" && overflowX !== "scroll") continue;
const canScroll =
deltaX < 0
? element.scrollLeft > 0
: element.scrollLeft + element.clientWidth < element.scrollWidth - 1;
if (canScroll) return true;
}
return false;
return overflowX === "auto" || overflowX === "scroll";
});
}

function lockSwipeAxis(
pending: Extract<SwipeGesture, { kind: "pending" }>,
target: EventTarget | undefined,
): Exclude<SwipeGesture, { kind: "pending" }> {
const { path, sumX, sumY } = pending;
if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" };
if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) {
return { kind: "ignored" };
}
if (canScrollHorizontally(path, sumX)) return { kind: "ignored" };
const direction = sumX < 0 ? "back" : "forward";
if (!canNavigateHistory(direction)) return { kind: "ignored" };
return {
Expand All @@ -70,6 +66,7 @@ function lockSwipeAxis(
deltas: [],
y: pending.y,
bounds: readMainContentBounds(),
target,
};
}

Expand Down Expand Up @@ -125,15 +122,29 @@ export function HistorySwipeNavigation() {
: { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 };
}
if (gesture.kind === "ignored") return;
if (event.deltaX === 0 && event.deltaY === 0) {
if (gesture.kind === "tracking") release();
return;
}
const path = event.composedPath();
// Pending wheels are checked while a scroller is still laid out, before it can be removed.
// Tracking rechecks only a new target, so overflow gained under a swipe stays history.
if (
(gesture.kind === "pending" || path[0] !== gesture.target) &&
isInHorizontalScroller(path)
) {
gesture = { kind: "ignored" };
return hideIndicator();
}
if (gesture.kind === "pending") {
gesture.sumX += event.deltaX;
gesture.sumY += event.deltaY;
if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return;
gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] });
gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...path] }, path[0]);
if (gesture.kind === "ignored") return;
} else {
gesture.target = path[0];
const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX;
if (event.deltaX === 0 && event.deltaY === 0) return release();
gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta];
if (isMomentumTail(gesture.deltas)) return release();
gesture.distance = Math.max(0, gesture.distance + delta);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/RightPanelTabs.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const surfaces: RightPanelSurface[] = Array.from({ length: 16 }, (_, index) => (
}));

describe("RightPanelTabs", () => {
it("a flick that scrolls the tab strip to its left edge keeps the thread, a swipe from the edge goes back", async () => {
it("a flick that scrolls the tab strip to its left edge and a swipe from that edge keep the thread", async () => {
navigateHistory.mockReset();
const { bridge, scroll } = trackpad();
using _bridge = stubGlobal("desktopBridge", bridge);
Expand Down Expand Up @@ -135,7 +135,7 @@ describe("RightPanelTabs", () => {
viewport.scrollLeft = 0;
scroll(viewport.querySelector("[data-active-tab]")!, [-40, -40, -40, -40, -40]);

expect(navigateHistory).toHaveBeenCalledExactlyOnceWith("back");
expect(navigateHistory).not.toHaveBeenCalled();
});
});

Expand Down
Loading