From 84d83c49a438a971c9629064d3650da570e3e4d6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 17:29:09 +0000 Subject: [PATCH 01/20] fix(web): keep horizontal diff scrolls from navigating history Swipe navigation stored event.target, which is retargeted to the shadow host, so the parent walk missed the diff viewer's scroller inside its shadow root. It also gave the gesture to history whenever the scroller had no room left in the swipe direction, so a scroll that reached an edge navigated away. Read the composed path from the gesture's first wheel event, and treat any horizontally overflowing auto/scroll element on it as owning the gesture regardless of scroll position. Dialog detection uses the same path. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 57 +++++++++++++++++++ .../src/components/HistorySwipeNavigation.tsx | 36 ++++++------ 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/HistorySwipeNavigation.browser.test.tsx diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx new file mode 100644 index 0000000000000..0992dccfc0fb8 --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; + +type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; +// Each step is one wheel event, optionally followed by the scroll it caused. +type Step = readonly [deltaX: number, scrollLeftAfter?: number]; + +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" || scroller === "light"; + 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<[string, Scroller, number, readonly Step[], "back" | "forward" | null]>([ + ["scrolling left inside a shadow-root scroller", "shadow", 400, [[-130]], null], + ["scrolling right inside a shadow-root scroller", "shadow", 400, [[130]], null], + ["swiping left at a shadow-root scroller's left edge", "shadow", 0, [[-130]], null], + ["swiping right at a shadow-root scroller's right edge", "shadow", 800, [[130]], null], + ["swiping left at a scroller's left edge", "light", 0, [[-130]], null], + ["swiping right at a scroller's right edge", "light", 800, [[130]], null], + ["scrolling left inside a scroller", "light", 400, [[-130]], null], + ["a small first event that reaches the left edge", "shadow", 3, [[-3, 0], [-130]], null], + ["a small first event that reaches the right edge", "light", 797, [[3, 800], [130]], null], + ["a scroll that reaches the edge mid-gesture", "light", 100, [[-100, 0], [-130]], null], + ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], + ["swiping left over content without overflow", "none", 0, [[-130]], "back"], + ["swiping right over content without overflow", "none", 0, [[130]], "forward"], + ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { + 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); + await using screen = await render(); + const { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); + + for (const [deltaX, scrollLeftAfter] of [...steps, [0] as const]) { + content.dispatchEvent(new WheelEvent("wheel", { deltaX, bubbles: true, composed: true })); + if (scrollLeftAfter !== undefined) viewport.scrollLeft = scrollLeftAfter; + } + + expect(back).toHaveBeenCalledTimes(navigates === "back" ? 1 : 0); + expect(forward).toHaveBeenCalledTimes(navigates === "forward" ? 1 : 0); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c020878b25363..64336c8f1516a 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -11,7 +11,7 @@ const MOMENTUM_SAMPLE_SIZE = 5; type SwipeBounds = { left: number; top: number; width: number; height: number }; type SwipeGesture = - | { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number } + | { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number } | { kind: "tracking"; direction: HistoryDirection; @@ -37,31 +37,27 @@ function readMainContentBounds(): SwipeBounds { return { left, top, width, height }; } -function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean { - for ( - let element = target instanceof Element ? target : null; - element; - element = element.parentElement - ) { - if (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 <= 1) { + 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, ): Exclude { - const { target, sumX, sumY } = pending; + const { path, sumX, sumY } = pending; if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; - if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" }; - if (canScrollHorizontally(target, sumX)) return { kind: "ignored" }; + if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { + return { kind: "ignored" }; + } + if (isInHorizontalScroller(path)) return { kind: "ignored" }; const direction = sumX < 0 ? "back" : "forward"; if (!canNavigateHistory(direction)) return { kind: "ignored" }; return { @@ -119,7 +115,7 @@ export function HistorySwipeNavigation() { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } - : { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 }; + : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; if (gesture.kind === "pending") { From eb7d68864440b11496647deae5f94a0f8ba0f6d1 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 17:35:05 +0000 Subject: [PATCH 02/20] test(web): restore history spies before unmounting swipe navigation --- apps/web/src/components/HistorySwipeNavigation.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 0992dccfc0fb8..d59272e076ece 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -39,11 +39,11 @@ describe("HistorySwipeNavigation", () => { ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { + await using screen = await render(); 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); - await using screen = await render(); const { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); for (const [deltaX, scrollLeftAfter] of [...steps, [0] as const]) { From a197931b91ed9dcce3ed7753eea3b15e43af23e0 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 01:11:49 +0000 Subject: [PATCH 03/20] fix(web): recognize a diff scroller remounted mid-swipe The gesture's ownership check read only the first wheel event's composed path. If the scroller was replaced before the axis locked, its detached element had no overflow, so a horizontal scroll in the new scroller could navigate history. Check the locking event's path too, keeping the first path so a dialog the gesture started in still blocks it. Co-authored-by: maria Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 26 +++++++++++++++---- .../src/components/HistorySwipeNavigation.tsx | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index d59272e076ece..49b26eebf1dfc 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -4,8 +4,8 @@ import { render } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; -// Each step is one wheel event, optionally followed by the scroll it caused. -type Step = readonly [deltaX: number, scrollLeftAfter?: number]; +// Each step is one wheel event, optionally followed by the scroll it caused or a remount. +type Step = readonly [deltaX: number, after?: number | "remount"]; function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) { const panel = container.appendChild(document.createElement("div")); @@ -35,6 +35,13 @@ describe("HistorySwipeNavigation", () => { ["a small first event that reaches the left edge", "shadow", 3, [[-3, 0], [-130]], null], ["a small first event that reaches the right edge", "light", 797, [[3, 800], [130]], null], ["a scroll that reaches the edge mid-gesture", "light", 100, [[-100, 0], [-130]], null], + [ + "a scroller remounted after a small first event", + "shadow", + 400, + [[1, "remount"], [130]], + null, + ], ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], @@ -44,11 +51,20 @@ describe("HistorySwipeNavigation", () => { 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); - const { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); + let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); - for (const [deltaX, scrollLeftAfter] of [...steps, [0] as const]) { + for (const [deltaX, after] of [...steps, [0] as const]) { content.dispatchEvent(new WheelEvent("wheel", { deltaX, bubbles: true, composed: true })); - if (scrollLeftAfter !== undefined) viewport.scrollLeft = scrollLeftAfter; + if (after === "remount") { + const replacement = viewport.cloneNode(true) as HTMLDivElement; + const { scrollLeft: previous } = viewport; + viewport.replaceWith(replacement); + replacement.scrollLeft = previous; + viewport = replacement; + content = replacement.firstElementChild as HTMLDivElement; + } else if (after !== undefined) { + viewport.scrollLeft = after; + } } expect(back).toHaveBeenCalledTimes(navigates === "back" ? 1 : 0); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 64336c8f1516a..ab76e1c079452 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -122,7 +122,8 @@ export function HistorySwipeNavigation() { 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); + // The first event's scroller may have been replaced; keep its path for dialog checks. + gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] }); if (gesture.kind === "ignored") return; } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; From 0e48fd7fd52df3e9a842ae319f704d43c3bdd7dd Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 01:48:02 +0000 Subject: [PATCH 04/20] fix(web): retain scroll ownership when its viewport disappears --- .../components/HistorySwipeNavigation.browser.test.tsx | 6 +++++- apps/web/src/components/HistorySwipeNavigation.tsx | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 49b26eebf1dfc..8c02441e0bb50 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -5,7 +5,7 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; // Each step is one wheel event, optionally followed by the scroll it caused or a remount. -type Step = readonly [deltaX: number, after?: number | "remount"]; +type Step = readonly [deltaX: number, after?: number | "remount" | "remove"]; function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) { const panel = container.appendChild(document.createElement("div")); @@ -42,6 +42,7 @@ describe("HistorySwipeNavigation", () => { [[1, "remount"], [130]], null, ], + ["a scroller removed after a small first event", "shadow", 400, [[1, "remove"], [130]], null], ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], @@ -62,6 +63,9 @@ describe("HistorySwipeNavigation", () => { replacement.scrollLeft = previous; viewport = replacement; content = replacement.firstElementChild as HTMLDivElement; + } else if (after === "remove") { + viewport.remove(); + ({ viewport, content } = mountPanel(screen.container, "none", 0)); } else if (after !== undefined) { viewport.scrollLeft = after; } diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index ab76e1c079452..aa67a26520fd1 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -57,7 +57,6 @@ function lockSwipeAxis( if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { return { kind: "ignored" }; } - if (isInHorizontalScroller(path)) return { kind: "ignored" }; const direction = sumX < 0 ? "back" : "forward"; if (!canNavigateHistory(direction)) return { kind: "ignored" }; return { @@ -119,11 +118,16 @@ export function HistorySwipeNavigation() { } if (gesture.kind === "ignored") return; if (gesture.kind === "pending") { + const path = event.composedPath(); + // Claim content scrolling while its layout still exists, before a possible remount. + if (isInHorizontalScroller(path)) { + gesture = { kind: "ignored" }; + return; + } gesture.sumX += event.deltaX; gesture.sumY += event.deltaY; if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; - // The first event's scroller may have been replaced; keep its path for dialog checks. - gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] }); + gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...path] }); if (gesture.kind === "ignored") return; } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; From 22ab86de4eefc537d2c728f100b16e1e0f37dff4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 02:17:49 +0000 Subject: [PATCH 05/20] fix(web): preserve content ownership across late scroll begin --- .../HistorySwipeNavigation.browser.test.tsx | 55 +++++++++++++++++-- .../src/components/HistorySwipeNavigation.tsx | 8 +-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 8c02441e0bb50..cabf78aa10214 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { render } from "../../test/render"; +import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; -// Each step is one wheel event, optionally followed by the scroll it caused or a remount. -type Step = readonly [deltaX: number, after?: number | "remount" | "remove"]; +type Step = readonly [ + event: number | "begin" | "end" | "zoom", + after?: number | "remount" | "remove", +]; function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) { const panel = container.appendChild(document.createElement("div")); @@ -43,10 +45,42 @@ describe("HistorySwipeNavigation", () => { null, ], ["a scroller removed after a small first event", "shadow", 400, [[1, "remove"], [130]], null], + [ + "a scroll begin arriving after the first wheel and scroller removal", + "shadow", + 400, + [[1, "remove"], ["begin"], [130]], + null, + ], + [ + "a new history gesture after a content scroll ends", + "shadow", + 400, + [[1, "remove"], ["end"], ["begin"], [130]], + "forward", + ], + [ + "a scroll begin after an ignored zoom wheel", + "none", + 0, + [["zoom"], ["begin"], [130]], + "forward", + ], ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { + 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(); using back = vi.spyOn(window.history, "back").mockImplementation(() => undefined); using forward = vi.spyOn(window.history, "forward").mockImplementation(() => undefined); @@ -54,8 +88,19 @@ describe("HistorySwipeNavigation", () => { using _canGoForward = vi.spyOn(window.navigation, "canGoForward", "get").mockReturnValue(true); let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); - for (const [deltaX, after] of [...steps, [0] as const]) { - content.dispatchEvent(new WheelEvent("wheel", { deltaX, bubbles: true, composed: true })); + for (const [event, after] of [...steps, [0] as const]) { + if (typeof event === "number" || event === "zoom") { + content.dispatchEvent( + new WheelEvent("wheel", { + deltaX: typeof event === "number" ? event : 0, + ctrlKey: event === "zoom", + bubbles: true, + composed: true, + }), + ); + } else { + for (const listener of listeners[event]) listener(); + } if (after === "remount") { const replacement = viewport.cloneNode(true) as HTMLDivElement; const { scrollLeft: previous } = viewport; diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index aa67a26520fd1..2069a812d5a5f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -21,7 +21,7 @@ type SwipeGesture = bounds: SwipeBounds; } // Not a swipe, or one already decided: the rest of its events are ignored. - | { kind: "ignored" }; + | { kind: "ignored"; scrollerOwned?: true }; type SwipeIndicatorState = { direction: HistoryDirection; progress: number; @@ -101,9 +101,9 @@ export function HistorySwipeNavigation() { gesture = { kind: "ignored" }; hideIndicator(); }; - // Chromium brackets each trackpad scroll, momentum included, with these. + // Scroll-begin IPC can arrive after the first wheel already gave ownership to content. const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { - if (gesture?.kind === "ignored") gesture = null; + if (gesture?.kind === "ignored" && !gesture.scrollerOwned) gesture = null; }); const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { if (gesture?.kind === "tracking") release(); @@ -121,7 +121,7 @@ export function HistorySwipeNavigation() { const path = event.composedPath(); // Claim content scrolling while its layout still exists, before a possible remount. if (isInHorizontalScroller(path)) { - gesture = { kind: "ignored" }; + gesture = { kind: "ignored", scrollerOwned: true }; return; } gesture.sumX += event.deltaX; From 28b9236333defabc47ac8c234cd83ef3ad631478 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 02:53:07 +0000 Subject: [PATCH 06/20] test(web): retain closed-dialog coverage after merging main --- .../HistorySwipeNavigation.browser.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index cabf78aa10214..a744a9c881633 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -6,7 +6,7 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; type Step = readonly [ event: number | "begin" | "end" | "zoom", - after?: number | "remount" | "remove", + after?: number | "remount" | "remove" | "close-dialog", ]; function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) { @@ -67,6 +67,13 @@ describe("HistorySwipeNavigation", () => { "forward", ], ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], + [ + "a gesture that starts in a dialog before it closes", + "shadow-dialog", + 0, + [[-1, "close-dialog"], [-130]], + null, + ], ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { @@ -111,6 +118,9 @@ describe("HistorySwipeNavigation", () => { } else if (after === "remove") { viewport.remove(); ({ viewport, content } = mountPanel(screen.container, "none", 0)); + } else if (after === "close-dialog") { + viewport.parentElement!.remove(); + screen.container.appendChild(viewport); } else if (after !== undefined) { viewport.scrollLeft = after; } From 544c8a66c9be42a08df68a6773a16e1acd45ab6b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 02:58:44 +0000 Subject: [PATCH 07/20] fix(desktop): ignore motionless wheels before scroller ownership --- .../src/components/HistorySwipeNavigation.browser.test.tsx | 7 +++++++ apps/web/src/components/HistorySwipeNavigation.tsx | 1 + 2 files changed, 8 insertions(+) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index a744a9c881633..158e1cc3088d5 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -59,6 +59,13 @@ describe("HistorySwipeNavigation", () => { [[1, "remove"], ["end"], ["begin"], [130]], "forward", ], + [ + "a history gesture after zero-delta contact on a scroller", + "shadow", + 400, + [[0, "remove"], ["begin"], [130]], + "forward", + ], [ "a scroll begin after an ignored zoom wheel", "none", diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 2069a812d5a5f..ba1ac00eab6c1 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -118,6 +118,7 @@ export function HistorySwipeNavigation() { } if (gesture.kind === "ignored") return; if (gesture.kind === "pending") { + if (event.deltaX === 0 && event.deltaY === 0) return; const path = event.composedPath(); // Claim content scrolling while its layout still exists, before a possible remount. if (isInHorizontalScroller(path)) { From 5c5a93a2b5a31a919ecbeaade7b7b13f16f7734a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 04:56:01 +0000 Subject: [PATCH 08/20] fix(desktop): wheels trailing a scroll end no longer claim the next gesture The scroll-end IPC can reach the renderer before the gesture's last wheel. That wheel then started a new gesture, claimed an overflowing scroller, and the claim survived the next scroll-begin, so the next history swipe was lost. Record when the end IPC arrives and drop wheels whose OS timestamp predates it. A new gesture's first wheel is newer than the end, so it can still claim a scroller before a late begin. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 35 +++++++++++++------ .../src/components/HistorySwipeNavigation.tsx | 4 +++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 158e1cc3088d5..6a93fa4ce1067 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -5,7 +5,8 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; type Step = readonly [ - event: number | "begin" | "end" | "zoom", + // "stale": a 1px wheel created before the last end, delivered after its IPC. + event: number | "begin" | "end" | "zoom" | "stale", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -59,6 +60,20 @@ describe("HistorySwipeNavigation", () => { [[1, "remove"], ["end"], ["begin"], [130]], "forward", ], + [ + "a history gesture after a stale wheel reaches a scroller", + "shadow", + 400, + [["begin"], [130], ["end"], ["stale", "remove"], ["begin"], [-130]], + "back", + ], + [ + "a scroll begin arriving after the next gesture's first wheel and scroller removal", + "shadow", + 400, + [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], + null, + ], [ "a history gesture after zero-delta contact on a scroller", "shadow", @@ -103,15 +118,15 @@ describe("HistorySwipeNavigation", () => { let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); for (const [event, after] of [...steps, [0] as const]) { - if (typeof event === "number" || event === "zoom") { - content.dispatchEvent( - new WheelEvent("wheel", { - deltaX: typeof event === "number" ? event : 0, - ctrlKey: event === "zoom", - bubbles: true, - composed: true, - }), - ); + if (event !== "begin" && event !== "end") { + const wheel = new WheelEvent("wheel", { + deltaX: typeof event === "number" ? event : event === "stale" ? 1 : 0, + ctrlKey: event === "zoom", + bubbles: true, + composed: true, + }); + if (event === "stale") Object.defineProperty(wheel, "timeStamp", { value: 0 }); + content.dispatchEvent(wheel); } else { for (const listener of listeners[event]) listener(); } diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index ba1ac00eab6c1..fc833029594d3 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -91,6 +91,7 @@ export function HistorySwipeNavigation() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes useEffect(() => { let gesture: SwipeGesture | null = null; + let endedAt = Number.NEGATIVE_INFINITY; const hideIndicator = () => setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. @@ -108,8 +109,11 @@ export function HistorySwipeNavigation() { const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { if (gesture?.kind === "tracking") release(); gesture = null; + endedAt = performance.now(); }); const onWheel = (event: WheelEvent) => { + // End IPC can overtake its gesture's last wheels; their OS timestamps predate its arrival. + if (event.timeStamp < endedAt) return; if (gesture === null) { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL From 3f841779588cdeb9da18c2a7722d353f3ad61998 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 05:17:23 +0000 Subject: [PATCH 09/20] fix(desktop): a scroll end delayed in IPC no longer drops the next gesture's first wheel Wheels created before the renderer received the end IPC were dropped as trailing wheels of the ended gesture. When the IPC was slow, that also dropped the next gesture's first wheel, so it could not claim a scroller before a late scroll-begin and the swipe navigated history. The main process now stamps scroll begin and end with its own clock. The renderer converts the stamp using the smallest receipt lag it has seen, since the two clocks share a rate but not an origin, and drops only wheels created before main saw the end. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preload.ts | 4 +- apps/desktop/src/window/DesktopWindow.ts | 4 +- .../HistorySwipeNavigation.browser.test.tsx | 40 ++++++++++++++----- .../src/components/HistorySwipeNavigation.tsx | 17 ++++++-- packages/contracts/src/ipc.ts | 5 ++- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..c0aa305ed4482 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -92,12 +92,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, onTrackpadScrollBegin: (listener) => { - const handler = () => listener(); + const handler = (_event: Electron.IpcRendererEvent, at: number) => listener(at); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); }, onTrackpadScrollEnd: (listener) => { - const handler = () => listener(); + const handler = (_event: Electron.IpcRendererEvent, at: number) => listener(at); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); }, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..af6f61b2ea130 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -661,9 +661,9 @@ export const make = Effect.gen(function* () { }); window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollBegin") { - window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); + window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL, performance.now()); } else if (input.type === "gestureScrollEnd") { - window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL, performance.now()); } }); // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 6a93fa4ce1067..c81066f8b42c4 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -4,9 +4,14 @@ import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; +// Main stamps gesture IPCs with its own performance.now(), whose origin differs from the page's. +const MAIN_CLOCK_AHEAD = 1_000_000; +// "late-end": main saw the end this long before its IPC reached the page. +const LATE_END_DELAY = 50; type Step = readonly [ // "stale": a 1px wheel created before the last end, delivered after its IPC. - event: number | "begin" | "end" | "zoom" | "stale", + // "fresh": a 1px wheel created after main saw a late end, before its IPC arrived. + event: number | "begin" | "end" | "late-end" | "zoom" | "stale" | "fresh", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -74,6 +79,13 @@ describe("HistorySwipeNavigation", () => { [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], null, ], + [ + "a next-gesture wheel created while the previous end IPC was in flight", + "shadow", + 400, + [["begin"], [130], ["late-end"], ["fresh", "remove"], ["begin"], [130]], + null, + ], [ "a history gesture after zero-delta contact on a scroller", "shadow", @@ -99,13 +111,14 @@ describe("HistorySwipeNavigation", () => { ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { - const listeners = { begin: new Set<() => void>(), end: new Set<() => void>() }; + type Listener = (at: number) => void; + const listeners = { begin: new Set(), end: new Set() }; using _bridge = stubGlobal("desktopBridge", { - onTrackpadScrollBegin: (listener: () => void) => { + onTrackpadScrollBegin: (listener: Listener) => { listeners.begin.add(listener); return () => listeners.begin.delete(listener); }, - onTrackpadScrollEnd: (listener: () => void) => { + onTrackpadScrollEnd: (listener: Listener) => { listeners.end.add(listener); return () => listeners.end.delete(listener); }, @@ -115,20 +128,29 @@ describe("HistorySwipeNavigation", () => { 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 now = 1000; + let lateEndAt = 0; + using _now = vi.spyOn(performance, "now").mockImplementation(() => now); let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); for (const [event, after] of [...steps, [0] as const]) { - if (event !== "begin" && event !== "end") { + now += 10; + if (event === "begin" || event === "end") { + for (const listener of listeners[event]) listener(now + MAIN_CLOCK_AHEAD); + } else if (event === "late-end") { + lateEndAt = now - LATE_END_DELAY; + for (const listener of listeners.end) listener(lateEndAt + MAIN_CLOCK_AHEAD); + } else { const wheel = new WheelEvent("wheel", { - deltaX: typeof event === "number" ? event : event === "stale" ? 1 : 0, + deltaX: typeof event === "number" ? event : event === "zoom" ? 0 : 1, ctrlKey: event === "zoom", bubbles: true, composed: true, }); - if (event === "stale") Object.defineProperty(wheel, "timeStamp", { value: 0 }); + const timeStamp = + event === "stale" ? 0 : event === "fresh" ? lateEndAt + LATE_END_DELAY / 2 : now; + Object.defineProperty(wheel, "timeStamp", { value: timeStamp }); content.dispatchEvent(wheel); - } else { - for (const listener of listeners[event]) listener(); } if (after === "remount") { const replacement = viewport.cloneNode(true) as HTMLDivElement; diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index fc833029594d3..c32d37799c256 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -92,6 +92,13 @@ export function HistorySwipeNavigation() { useEffect(() => { let gesture: SwipeGesture | null = null; let endedAt = Number.NEGATIVE_INFINITY; + // Main's clock shares the page's monotonic rate but not its origin. The smallest receipt lag + // seen bounds the offset, so a converted stamp lands no later than its own IPC's arrival. + let mainClockOffset = Number.POSITIVE_INFINITY; + const fromMainClock = (at: number) => { + mainClockOffset = Math.min(mainClockOffset, performance.now() - at); + return at + mainClockOffset; + }; const hideIndicator = () => setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. @@ -103,16 +110,18 @@ export function HistorySwipeNavigation() { hideIndicator(); }; // Scroll-begin IPC can arrive after the first wheel already gave ownership to content. - const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { + const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin((at) => { + fromMainClock(at); if (gesture?.kind === "ignored" && !gesture.scrollerOwned) gesture = null; }); - const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { + const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd((at) => { if (gesture?.kind === "tracking") release(); gesture = null; - endedAt = performance.now(); + endedAt = fromMainClock(at); }); const onWheel = (event: WheelEvent) => { - // End IPC can overtake its gesture's last wheels; their OS timestamps predate its arrival. + // End IPC can overtake its gesture's last wheels, and the next gesture's first wheel can + // be created before that IPC arrives. Their OS timestamps fall on either side of main's. if (event.timeStamp < endedAt) return; if (gesture === null) { gesture = diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f14452fec5a92..a62898c88739d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1018,8 +1018,9 @@ export interface DesktopBridge { getClientPlatform: () => string; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; - onTrackpadScrollBegin: (listener: () => void) => () => void; - onTrackpadScrollEnd: (listener: () => void) => () => void; + /** `at` is the main process's `performance.now()`, whose origin differs from the page's. */ + onTrackpadScrollBegin: (listener: (at: number) => void) => () => void; + onTrackpadScrollEnd: (listener: (at: number) => void) => () => void; /** * Renderer `window.focus()` cannot raise an Electron window, and on Wayland * only the main process can spend a notification click's activation token. From 8a986017d8371d35d953c32a79834a15cfd703af Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 05:37:23 +0000 Subject: [PATCH 10/20] fix(desktop): a slow scroll-end IPC no longer drops the next gesture's first wheel The renderer converted main's scroll-end stamp using the smallest IPC lag it had seen. That put the cutoff at main's end plus that lag, so whenever every IPC was slow, the next gesture's first wheel looked stale and was dropped. A scroller removed before the late scroll-begin then let the swipe navigate. Main now stamps the end with its wall clock, the one clock both processes share. The renderer subtracts its own wall-minus-monotonic offset when the IPC arrives, so the cutoff is main's end within 1ms whatever the IPC lag. Sleep and clock adjustments only matter if they land while the IPC is in flight. The begin IPC no longer carries a stamp. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preload.ts | 2 +- apps/desktop/src/window/DesktopWindow.ts | 5 ++- .../HistorySwipeNavigation.browser.test.tsx | 37 ++++++++++--------- .../src/components/HistorySwipeNavigation.tsx | 18 +++------ packages/contracts/src/ipc.ts | 4 +- 5 files changed, 32 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c0aa305ed4482..81f271aa89d97 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -92,7 +92,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, onTrackpadScrollBegin: (listener) => { - const handler = (_event: Electron.IpcRendererEvent, at: number) => listener(at); + const handler = () => listener(); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); }, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index af6f61b2ea130..e3ba0d0037ab5 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -305,6 +305,7 @@ export const make = Effect.gen(function* () { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; + const clock = yield* Clock.Clock; const backendReadyRef = yield* Ref.make(false); const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); @@ -661,9 +662,9 @@ export const make = Effect.gen(function* () { }); window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollBegin") { - window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL, performance.now()); + window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); } else if (input.type === "gestureScrollEnd") { - window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL, performance.now()); + window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL, clock.currentTimeMillisUnsafe()); } }); // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index c81066f8b42c4..bfb5de9b7cbcb 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -4,14 +4,14 @@ import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; -// Main stamps gesture IPCs with its own performance.now(), whose origin differs from the page's. -const MAIN_CLOCK_AHEAD = 1_000_000; -// "late-end": main saw the end this long before its IPC reached the page. -const LATE_END_DELAY = 50; +// The page's Date.now() minus its performance.now(); main stamps the end with Date.now(). +const WALL_CLOCK_AHEAD = 1_700_000_000_000; +// Every gesture IPC reaches the page this long after main sees the native event. +const IPC_LAG = 40; type Step = readonly [ - // "stale": a 1px wheel created before the last end, delivered after its IPC. - // "fresh": a 1px wheel created after main saw a late end, before its IPC arrived. - event: number | "begin" | "end" | "late-end" | "zoom" | "stale" | "fresh", + // "stale": a 1px wheel created before main saw the last end, delivered after its IPC. + // "fresh": a 1px wheel created after main saw the last end, before its IPC arrived. + event: number | "begin" | "end" | "zoom" | "stale" | "fresh", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -83,7 +83,7 @@ describe("HistorySwipeNavigation", () => { "a next-gesture wheel created while the previous end IPC was in flight", "shadow", 400, - [["begin"], [130], ["late-end"], ["fresh", "remove"], ["begin"], [130]], + [["begin"], [130], ["end"], ["fresh", "remove"], ["begin"], [130]], null, ], [ @@ -112,9 +112,9 @@ describe("HistorySwipeNavigation", () => { ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { type Listener = (at: number) => void; - const listeners = { begin: new Set(), end: new Set() }; + const listeners = { begin: new Set<() => void>(), end: new Set() }; using _bridge = stubGlobal("desktopBridge", { - onTrackpadScrollBegin: (listener: Listener) => { + onTrackpadScrollBegin: (listener: () => void) => { listeners.begin.add(listener); return () => listeners.begin.delete(listener); }, @@ -129,17 +129,20 @@ describe("HistorySwipeNavigation", () => { using _canGoBack = vi.spyOn(window.navigation, "canGoBack", "get").mockReturnValue(true); using _canGoForward = vi.spyOn(window.navigation, "canGoForward", "get").mockReturnValue(true); let now = 1000; - let lateEndAt = 0; + let endSeenAt = 0; using _now = vi.spyOn(performance, "now").mockImplementation(() => now); + using _wallNow = vi.spyOn(Date, "now").mockImplementation(() => now + WALL_CLOCK_AHEAD); let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); for (const [event, after] of [...steps, [0] as const]) { now += 10; - if (event === "begin" || event === "end") { - for (const listener of listeners[event]) listener(now + MAIN_CLOCK_AHEAD); - } else if (event === "late-end") { - lateEndAt = now - LATE_END_DELAY; - for (const listener of listeners.end) listener(lateEndAt + MAIN_CLOCK_AHEAD); + if (event === "begin") { + now += IPC_LAG; + for (const listener of listeners.begin) listener(); + } else if (event === "end") { + endSeenAt = now; + now += IPC_LAG; + for (const listener of listeners.end) listener(endSeenAt + WALL_CLOCK_AHEAD); } else { const wheel = new WheelEvent("wheel", { deltaX: typeof event === "number" ? event : event === "zoom" ? 0 : 1, @@ -148,7 +151,7 @@ describe("HistorySwipeNavigation", () => { composed: true, }); const timeStamp = - event === "stale" ? 0 : event === "fresh" ? lateEndAt + LATE_END_DELAY / 2 : now; + event === "stale" ? endSeenAt - 5 : event === "fresh" ? endSeenAt + IPC_LAG / 2 : now; Object.defineProperty(wheel, "timeStamp", { value: timeStamp }); content.dispatchEvent(wheel); } diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c32d37799c256..23b574a6c469c 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -92,13 +92,6 @@ export function HistorySwipeNavigation() { useEffect(() => { let gesture: SwipeGesture | null = null; let endedAt = Number.NEGATIVE_INFINITY; - // Main's clock shares the page's monotonic rate but not its origin. The smallest receipt lag - // seen bounds the offset, so a converted stamp lands no later than its own IPC's arrival. - let mainClockOffset = Number.POSITIVE_INFINITY; - const fromMainClock = (at: number) => { - mainClockOffset = Math.min(mainClockOffset, performance.now() - at); - return at + mainClockOffset; - }; const hideIndicator = () => setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. @@ -110,18 +103,19 @@ export function HistorySwipeNavigation() { hideIndicator(); }; // Scroll-begin IPC can arrive after the first wheel already gave ownership to content. - const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin((at) => { - fromMainClock(at); + const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { if (gesture?.kind === "ignored" && !gesture.scrollerOwned) gesture = null; }); const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd((at) => { if (gesture?.kind === "tracking") release(); gesture = null; - endedAt = fromMainClock(at); + // Wall time is the only clock main shares with the page. Converting on receipt keeps + // sleep and clock changes out of the offset, leaving Date.now()'s 1ms resolution. + endedAt = at - (Date.now() - performance.now()); }); const onWheel = (event: WheelEvent) => { - // End IPC can overtake its gesture's last wheels, and the next gesture's first wheel can - // be created before that IPC arrives. Their OS timestamps fall on either side of main's. + // End IPC can overtake its gesture's last wheels. Those were created before main saw the + // end; the next gesture's wheels usually come after, even when the IPC is slow. if (event.timeStamp < endedAt) return; if (gesture === null) { gesture = diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a62898c88739d..c083ee0016bc2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1018,8 +1018,8 @@ export interface DesktopBridge { getClientPlatform: () => string; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; - /** `at` is the main process's `performance.now()`, whose origin differs from the page's. */ - onTrackpadScrollBegin: (listener: (at: number) => void) => () => void; + onTrackpadScrollBegin: (listener: () => void) => () => void; + /** `at` is the main process's wall clock (`Date.now()`) when it saw the end. */ onTrackpadScrollEnd: (listener: (at: number) => void) => () => void; /** * Renderer `window.focus()` cannot raise an Electron window, and on Wayland From 68d89be0b8074cbfe4d74280ce0d3a4951a8c6d0 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 06:21:35 +0000 Subject: [PATCH 11/20] fix(desktop): content scroll claims hold until the pointer moves Scroll begin and end IPC travel separately from wheel events, and Chromium flushes a pending end just before the next gesture's first wheel, so neither IPC order nor timestamps can say which gesture a wheel belongs to. A horizontal scroller's claim now lasts until a wheel arrives at a different pointer position. Trackpad scrolling never moves the pointer, so a claim survives viewport removal, late begin and early end, and a history swipe elsewhere is never blocked by a trailing wheel. The main-process end timestamp and the page-side clock conversion are removed. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preload.ts | 2 +- apps/desktop/src/window/DesktopWindow.ts | 3 +- .../HistorySwipeNavigation.browser.test.tsx | 63 ++++++++----------- .../src/components/HistorySwipeNavigation.tsx | 25 ++++---- packages/contracts/src/ipc.ts | 3 +- 5 files changed, 42 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 81f271aa89d97..950ed972c6453 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -97,7 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); }, onTrackpadScrollEnd: (listener) => { - const handler = (_event: Electron.IpcRendererEvent, at: number) => listener(at); + const handler = () => listener(); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); }, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e3ba0d0037ab5..10801089841d1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -305,7 +305,6 @@ export const make = Effect.gen(function* () { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; - const clock = yield* Clock.Clock; const backendReadyRef = yield* Ref.make(false); const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); @@ -664,7 +663,7 @@ export const make = Effect.gen(function* () { if (input.type === "gestureScrollBegin") { window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); } else if (input.type === "gestureScrollEnd") { - window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL, clock.currentTimeMillisUnsafe()); + window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); } }); // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index bfb5de9b7cbcb..2fbfbc56797ab 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -4,14 +4,9 @@ import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; -// The page's Date.now() minus its performance.now(); main stamps the end with Date.now(). -const WALL_CLOCK_AHEAD = 1_700_000_000_000; -// Every gesture IPC reaches the page this long after main sees the native event. -const IPC_LAG = 40; type Step = readonly [ - // "stale": a 1px wheel created before main saw the last end, delivered after its IPC. - // "fresh": a 1px wheel created after main saw the last end, before its IPC arrived. - event: number | "begin" | "end" | "zoom" | "stale" | "fresh", + // "move": the pointer moves to content without overflow elsewhere. + event: number | "begin" | "end" | "zoom" | "move", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -59,31 +54,31 @@ describe("HistorySwipeNavigation", () => { null, ], [ - "a new history gesture after a content scroll ends", + "a history gesture elsewhere after a content scroll ends", "shadow", 400, - [[1, "remove"], ["end"], ["begin"], [130]], + [[1, "remove"], ["end"], ["move"], ["begin"], [130]], "forward", ], [ - "a history gesture after a stale wheel reaches a scroller", + "a history gesture elsewhere after a trailing wheel reaches a scroller", "shadow", 400, - [["begin"], [130], ["end"], ["stale", "remove"], ["begin"], [-130]], + [["begin"], [130], ["end"], [1, "remount"], ["move"], ["begin"], [-130]], "back", ], [ - "a scroll begin arriving after the next gesture's first wheel and scroller removal", + "the next gesture's first wheel arriving before the previous end, then scroller removal", "shadow", 400, - [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], + [["begin"], [130], [1, "remove"], ["end"], ["begin"], [130]], null, ], [ - "a next-gesture wheel created while the previous end IPC was in flight", + "a scroll begin arriving after the next gesture's first wheel and scroller removal", "shadow", 400, - [["begin"], [130], ["end"], ["fresh", "remove"], ["begin"], [130]], + [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], null, ], [ @@ -111,14 +106,13 @@ describe("HistorySwipeNavigation", () => { ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { - type Listener = (at: number) => void; - const listeners = { begin: new Set<() => void>(), end: new Set() }; + 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: Listener) => { + onTrackpadScrollEnd: (listener: () => void) => { listeners.end.add(listener); return () => listeners.end.delete(listener); }, @@ -128,32 +122,27 @@ describe("HistorySwipeNavigation", () => { 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 now = 1000; - let endSeenAt = 0; - using _now = vi.spyOn(performance, "now").mockImplementation(() => now); - using _wallNow = vi.spyOn(Date, "now").mockImplementation(() => now + WALL_CLOCK_AHEAD); let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); + let pointerX = 0; for (const [event, after] of [...steps, [0] as const]) { - now += 10; if (event === "begin") { - now += IPC_LAG; for (const listener of listeners.begin) listener(); } else if (event === "end") { - endSeenAt = now; - now += IPC_LAG; - for (const listener of listeners.end) listener(endSeenAt + WALL_CLOCK_AHEAD); + for (const listener of listeners.end) listener(); + } else if (event === "move") { + ({ viewport, content } = mountPanel(screen.container, "none", 0)); + pointerX += 100; } else { - const wheel = new WheelEvent("wheel", { - deltaX: typeof event === "number" ? event : event === "zoom" ? 0 : 1, - ctrlKey: event === "zoom", - bubbles: true, - composed: true, - }); - const timeStamp = - event === "stale" ? endSeenAt - 5 : event === "fresh" ? endSeenAt + IPC_LAG / 2 : now; - Object.defineProperty(wheel, "timeStamp", { value: timeStamp }); - content.dispatchEvent(wheel); + content.dispatchEvent( + new WheelEvent("wheel", { + deltaX: event === "zoom" ? 0 : event, + ctrlKey: event === "zoom", + clientX: pointerX, + bubbles: true, + composed: true, + }), + ); } if (after === "remount") { const replacement = viewport.cloneNode(true) as HTMLDivElement; diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 23b574a6c469c..58b6b974243fd 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -21,7 +21,7 @@ type SwipeGesture = bounds: SwipeBounds; } // Not a swipe, or one already decided: the rest of its events are ignored. - | { kind: "ignored"; scrollerOwned?: true }; + | { kind: "ignored" }; type SwipeIndicatorState = { direction: HistoryDirection; progress: number; @@ -91,7 +91,9 @@ export function HistorySwipeNavigation() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes useEffect(() => { let gesture: SwipeGesture | null = null; - let endedAt = Number.NEGATIVE_INFINITY; + // Begin and end IPC race the wheels, so they cannot say which gesture a wheel belongs to. + // Trackpad scrolling never moves the pointer: a content claim holds until it does. + let claimedAt: { x: number; y: number } | null = null; const hideIndicator = () => setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. @@ -102,21 +104,19 @@ export function HistorySwipeNavigation() { gesture = { kind: "ignored" }; hideIndicator(); }; - // Scroll-begin IPC can arrive after the first wheel already gave ownership to content. + // Chromium brackets each trackpad scroll, momentum included, with these. const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { - if (gesture?.kind === "ignored" && !gesture.scrollerOwned) gesture = null; + if (gesture?.kind === "ignored") gesture = null; }); - const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd((at) => { + const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { if (gesture?.kind === "tracking") release(); gesture = null; - // Wall time is the only clock main shares with the page. Converting on receipt keeps - // sleep and clock changes out of the offset, leaving Date.now()'s 1ms resolution. - endedAt = at - (Date.now() - performance.now()); }); const onWheel = (event: WheelEvent) => { - // End IPC can overtake its gesture's last wheels. Those were created before main saw the - // end; the next gesture's wheels usually come after, even when the IPC is slow. - if (event.timeStamp < endedAt) return; + if (claimedAt !== null) { + if (event.clientX === claimedAt.x && event.clientY === claimedAt.y) return; + claimedAt = null; + } if (gesture === null) { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL @@ -129,7 +129,8 @@ export function HistorySwipeNavigation() { const path = event.composedPath(); // Claim content scrolling while its layout still exists, before a possible remount. if (isInHorizontalScroller(path)) { - gesture = { kind: "ignored", scrollerOwned: true }; + claimedAt = { x: event.clientX, y: event.clientY }; + gesture = null; return; } gesture.sumX += event.deltaX; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c083ee0016bc2..f14452fec5a92 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1019,8 +1019,7 @@ export interface DesktopBridge { setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; onTrackpadScrollBegin: (listener: () => void) => () => void; - /** `at` is the main process's wall clock (`Date.now()`) when it saw the end. */ - onTrackpadScrollEnd: (listener: (at: number) => void) => () => void; + onTrackpadScrollEnd: (listener: () => void) => () => void; /** * Renderer `window.focus()` cannot raise an Electron window, and on Wayland * only the main process can spend a notification click's activation token. From ffbdbcdac8ed926d4933489f4cf5c5aca1203bf6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 06:30:43 +0000 Subject: [PATCH 12/20] fix(web): release content scroll claims on explicit interaction Keyboard shortcuts can close a diff without moving the pointer. Release its content claim on a non-modifier key or pointer movement so history gestures over the exposed chat recover. Keep passive remount protection across native scroll boundaries. Implemented with GPT-6.1 Sol in Codex. --- .../HistorySwipeNavigation.browser.test.tsx | 26 ++++++++++++++++++- .../src/components/HistorySwipeNavigation.tsx | 15 ++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 2fbfbc56797ab..78295b786f830 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -6,7 +6,7 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; type Step = readonly [ // "move": the pointer moves to content without overflow elsewhere. - event: number | "begin" | "end" | "zoom" | "move", + event: number | "begin" | "end" | "zoom" | "move" | "close-by-key" | "pointer-return", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -67,6 +67,20 @@ describe("HistorySwipeNavigation", () => { [["begin"], [130], ["end"], [1, "remount"], ["move"], ["begin"], [-130]], "back", ], + [ + "a history gesture at the same position after closing content by keyboard", + "shadow", + 400, + [[1], ["end"], ["close-by-key", "remove"], ["begin"], [-130]], + "back", + ], + [ + "a history gesture after moving the pointer away and back to removed content", + "shadow", + 400, + [[1, "remove"], ["end"], ["pointer-return"], ["begin"], [130]], + "forward", + ], [ "the next gesture's first wheel arriving before the previous end, then scroller removal", "shadow", @@ -133,6 +147,16 @@ describe("HistorySwipeNavigation", () => { } else if (event === "move") { ({ viewport, content } = mountPanel(screen.container, "none", 0)); pointerX += 100; + } else if (event === "close-by-key") { + content.dispatchEvent( + new KeyboardEvent("keydown", { key: "d", metaKey: true, bubbles: true, composed: true }), + ); + } else if (event === "pointer-return") { + for (const clientX of [pointerX + 100, pointerX]) { + content.dispatchEvent( + new PointerEvent("pointermove", { clientX, bubbles: true, composed: true }), + ); + } } else { content.dispatchEvent( new WheelEvent("wheel", { diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 58b6b974243fd..d371f71a994df 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -92,8 +92,17 @@ export function HistorySwipeNavigation() { useEffect(() => { let gesture: SwipeGesture | null = null; // Begin and end IPC race the wheels, so they cannot say which gesture a wheel belongs to. - // Trackpad scrolling never moves the pointer: a content claim holds until it does. + // A content claim holds while wheel events stay at the same pointer position. let claimedAt: { x: number; y: number } | null = null; + const onPointerMove = (event: PointerEvent) => { + if (claimedAt && (event.clientX !== claimedAt.x || event.clientY !== claimedAt.y)) { + claimedAt = null; + } + }; + // Keyboard shortcuts can replace content without moving the pointer. + const onKeyDown = (event: KeyboardEvent) => { + if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) claimedAt = null; + }; const hideIndicator = () => setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. @@ -153,10 +162,14 @@ export function HistorySwipeNavigation() { tracking: true, }); }; + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: true }); + window.addEventListener("keydown", onKeyDown, { capture: true }); window.addEventListener("wheel", onWheel, { capture: true, passive: true }); return () => { unsubscribeScrollBegin?.(); unsubscribeScrollEnd?.(); + window.removeEventListener("pointermove", onPointerMove, { capture: true }); + window.removeEventListener("keydown", onKeyDown, { capture: true }); window.removeEventListener("wheel", onWheel, { capture: true }); }; }, []); From e84ad644120b90c929308c7c8544a17df891de40 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 06:55:18 +0000 Subject: [PATCH 13/20] fix(web): a wheel at a new pointer position starts a fresh swipe context A trailing wheel after a history swipe's end could start tracking, and that gesture survived the pointer moving to a horizontal scroller, so the scroller's first wheel never claimed the gesture and the native end navigated. Wheels now share one context per pointer position. A wheel at a new position, a pointer move away, or a non-modifier keydown drops the whole context (pending or tracking gesture and any content claim) and hides the indicator. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 32 +++++++++++++++-- .../src/components/HistorySwipeNavigation.tsx | 35 ++++++++++++------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 78295b786f830..24b240de921c3 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -5,8 +5,16 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; type Step = readonly [ - // "move": the pointer moves to content without overflow elsewhere. - event: number | "begin" | "end" | "zoom" | "move" | "close-by-key" | "pointer-return", + // "move" and "move-to-scroller": the wheel position moves to new content elsewhere. + event: + | number + | "begin" + | "end" + | "zoom" + | "move" + | "move-to-scroller" + | "close-by-key" + | "pointer-return", after?: number | "remount" | "remove" | "close-dialog", ]; @@ -81,6 +89,23 @@ describe("HistorySwipeNavigation", () => { [[1, "remove"], ["end"], ["pointer-return"], ["begin"], [130]], "forward", ], + [ + "content scrolling after a trailing history wheel started tracking elsewhere", + "none", + 0, + [ + ["begin"], + [130], + ["end"], + [18], + ["move-to-scroller"], + [7, "remount"], + ["begin"], + [130], + ["end"], + ], + "forward", + ], [ "the next gesture's first wheel arriving before the previous end, then scroller removal", "shadow", @@ -147,6 +172,9 @@ describe("HistorySwipeNavigation", () => { } else if (event === "move") { ({ viewport, content } = mountPanel(screen.container, "none", 0)); pointerX += 100; + } else if (event === "move-to-scroller") { + ({ viewport, content } = mountPanel(screen.container, "shadow", 400)); + pointerX += 100; } else if (event === "close-by-key") { content.dispatchEvent( new KeyboardEvent("keydown", { key: "d", metaKey: true, bubbles: true, composed: true }), diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index d371f71a994df..5bfa0ffabbc4c 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -92,19 +92,28 @@ export function HistorySwipeNavigation() { useEffect(() => { let gesture: SwipeGesture | null = null; // Begin and end IPC race the wheels, so they cannot say which gesture a wheel belongs to. - // A content claim holds while wheel events stay at the same pointer position. - let claimedAt: { x: number; y: number } | null = null; + // Wheels at one pointer position share a context; moving or typing starts a new one. + let pointerAt: { x: number; y: number } | null = null; + let contentClaimed = false; + const hideIndicator = () => + setSwipe((current) => + current === null || !current.tracking ? current : { ...current, tracking: false }, + ); + const resetContext = () => { + gesture = null; + contentClaimed = false; + pointerAt = null; + hideIndicator(); + }; + const isAtPointer = (event: MouseEvent) => + event.clientX === pointerAt?.x && event.clientY === pointerAt.y; const onPointerMove = (event: PointerEvent) => { - if (claimedAt && (event.clientX !== claimedAt.x || event.clientY !== claimedAt.y)) { - claimedAt = null; - } + if (pointerAt && !isAtPointer(event)) resetContext(); }; // Keyboard shortcuts can replace content without moving the pointer. const onKeyDown = (event: KeyboardEvent) => { - if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) claimedAt = null; + if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) resetContext(); }; - const hideIndicator = () => - setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. const release = () => { if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { @@ -122,9 +131,11 @@ export function HistorySwipeNavigation() { gesture = null; }); const onWheel = (event: WheelEvent) => { - if (claimedAt !== null) { - if (event.clientX === claimedAt.x && event.clientY === claimedAt.y) return; - claimedAt = null; + if (!isAtPointer(event)) { + resetContext(); + pointerAt = { x: event.clientX, y: event.clientY }; + } else if (contentClaimed) { + return; } if (gesture === null) { gesture = @@ -138,7 +149,7 @@ export function HistorySwipeNavigation() { const path = event.composedPath(); // Claim content scrolling while its layout still exists, before a possible remount. if (isInHorizontalScroller(path)) { - claimedAt = { x: event.clientX, y: event.clientY }; + contentClaimed = true; gesture = null; return; } From 15160a0b57a3c0ae4ac1b50a5163c8a6d302b628 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 07:23:10 +0000 Subject: [PATCH 14/20] fix(desktop): a swipe reversed past its start can navigate the other way A trailing wheel after a scroll end opened a history swipe in its own direction. When the next swipe went the other way, its wheels only clamped that stale swipe at zero, so it never navigated, whether its begin arrived before or after its first wheel. A tracked swipe that reverses past its start now picks its direction again from the overflow, when history can go that way. Otherwise it clamps at zero as before. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 38 +++++++++++++++++-- .../src/components/HistorySwipeNavigation.tsx | 27 ++++++++++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 24b240de921c3..a38a93c5a0f11 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -35,7 +35,7 @@ function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: numb } describe("HistorySwipeNavigation", () => { - it.each<[string, Scroller, number, readonly Step[], "back" | "forward" | null]>([ + it.each<[string, Scroller, number, readonly Step[], "back" | "forward" | "both" | null]>([ ["scrolling left inside a shadow-root scroller", "shadow", 400, [[-130]], null], ["scrolling right inside a shadow-root scroller", "shadow", 400, [[130]], null], ["swiping left at a shadow-root scroller's left edge", "shadow", 0, [[-130]], null], @@ -120,6 +120,38 @@ describe("HistorySwipeNavigation", () => { [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], null, ], + [ + "a reversed next swipe whose first wheel beats its begin, after a trailing wheel", + "none", + 0, + [ + ["begin"], + [-130], + ["end"], + [-23.5560302734375], + [23.84126091003418], + ["begin"], + [130], + ["end"], + ], + "both", + ], + [ + "a reversed next swipe whose begin beats its first wheel, after a trailing wheel", + "none", + 0, + [ + ["begin"], + [130], + ["end"], + [23.5560302734375], + ["begin"], + [-23.84126091003418], + [-130], + ["end"], + ], + "both", + ], [ "a history gesture after zero-delta contact on a scroller", "shadow", @@ -214,7 +246,7 @@ describe("HistorySwipeNavigation", () => { } } - expect(back).toHaveBeenCalledTimes(navigates === "back" ? 1 : 0); - expect(forward).toHaveBeenCalledTimes(navigates === "forward" ? 1 : 0); + expect(back).toHaveBeenCalledTimes(navigates === "back" || navigates === "both" ? 1 : 0); + expect(forward).toHaveBeenCalledTimes(navigates === "forward" || navigates === "both" ? 1 : 0); }); }); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 5bfa0ffabbc4c..e3798ce6d5eca 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -144,6 +144,27 @@ export function HistorySwipeNavigation() { : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; + if (gesture.kind === "tracking") { + const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; + if (event.deltaX === 0 && event.deltaY === 0) return release(); + const { direction, distance, y } = gesture; + // Past its start the swipe picks a direction again: a stale trailing wheel may + // have opened it, and the next swipe's begin can arrive after its first wheels. + if (distance + delta < 0 && canNavigateHistory(direction === "back" ? "forward" : "back")) { + gesture = { + kind: "pending", + path: [], + y, + sumX: direction === "back" ? -distance : distance, + sumY: 0, + }; + hideIndicator(); + } else { + gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; + if (isMomentumTail(gesture.deltas)) return release(); + gesture.distance = Math.max(0, distance + delta); + } + } if (gesture.kind === "pending") { if (event.deltaX === 0 && event.deltaY === 0) return; const path = event.composedPath(); @@ -158,12 +179,6 @@ export function HistorySwipeNavigation() { if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...path] }); if (gesture.kind === "ignored") return; - } else { - 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); } setSwipe({ direction: gesture.direction, From fab0a7d4595a10dd2b3b9b42d49ab339732c7c37 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 07:32:02 +0000 Subject: [PATCH 15/20] test(desktop): preserve early swipe movement and history boundary recovery --- .../HistorySwipeNavigation.browser.test.tsx | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index a38a93c5a0f11..9486b12b0c3ca 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -35,7 +35,16 @@ function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: numb } describe("HistorySwipeNavigation", () => { - it.each<[string, Scroller, number, readonly Step[], "back" | "forward" | "both" | null]>([ + it.each< + [ + string, + Scroller, + number, + readonly Step[], + "back" | "forward" | "both" | null, + ("back" | "forward")?, + ] + >([ ["scrolling left inside a shadow-root scroller", "shadow", 400, [[-130]], null], ["scrolling right inside a shadow-root scroller", "shadow", 400, [[130]], null], ["swiping left at a shadow-root scroller's left edge", "shadow", 0, [[-130]], null], @@ -152,6 +161,21 @@ describe("HistorySwipeNavigation", () => { ], "both", ], + [ + "a reversed swipe needs its first wheel from before a late begin", + "none", + 0, + [["begin"], [-130], ["end"], [-23.5560302734375], [60], ["begin"], [100], ["end"]], + "both", + ], + [ + "a swipe resumes after reversing toward unavailable history", + "none", + 0, + [["begin"], [-80], [100], [-130], ["end"]], + "back", + "forward", + ], [ "a history gesture after zero-delta contact on a scroller", "shadow", @@ -176,7 +200,7 @@ describe("HistorySwipeNavigation", () => { ], ["swiping left over content without overflow", "none", 0, [[-130]], "back"], ["swiping right over content without overflow", "none", 0, [[130]], "forward"], - ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { + ])("%s", async (_name, scroller, scrollLeft, steps, navigates, unavailable) => { const listeners = { begin: new Set<() => void>(), end: new Set<() => void>() }; using _bridge = stubGlobal("desktopBridge", { onTrackpadScrollBegin: (listener: () => void) => { @@ -191,8 +215,12 @@ describe("HistorySwipeNavigation", () => { await using screen = await render(); 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); + using _canGoBack = vi + .spyOn(window.navigation, "canGoBack", "get") + .mockReturnValue(unavailable !== "back"); + using _canGoForward = vi + .spyOn(window.navigation, "canGoForward", "get") + .mockReturnValue(unavailable !== "forward"); let { viewport, content } = mountPanel(screen.container, scroller, scrollLeft); let pointerX = 0; From 9220955aa5fa4f31e2f36eaff0a8d2e6e78a11d4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 07:47:29 +0000 Subject: [PATCH 16/20] fix(desktop): content appearing under a still pointer owns the swipe A trailing wheel after a history swipe could start tracking, and new horizontally scrollable content opening under the same pointer then fed its gesture into that stale history swipe, navigating a second time. A tracking swipe now remembers its wheel target. When a wheel arrives from a different target, its composed path is classified, and a horizontal scroller claims the gesture for content. Wheels from the same target skip the layout reads. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 29 +++++++++++++++++-- .../src/components/HistorySwipeNavigation.tsx | 23 +++++++++++---- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 9486b12b0c3ca..ca45e80b6569f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -13,6 +13,7 @@ type Step = readonly [ | "zoom" | "move" | "move-to-scroller" + | "show-scroller" | "close-by-key" | "pointer-return", after?: number | "remount" | "remove" | "close-dialog", @@ -115,6 +116,30 @@ describe("HistorySwipeNavigation", () => { ], "forward", ], + [ + "content appearing at the same position after a trailing history wheel", + "none", + 0, + [ + ["begin"], + [130], + ["end"], + [18], + ["show-scroller"], + ["begin"], + [7, "remove"], + [130], + ["end"], + ], + "forward", + ], + [ + "a history swipe over content replaced without overflow", + "none", + 0, + [[60, "remove"], [70]], + "forward", + ], [ "the next gesture's first wheel arriving before the previous end, then scroller removal", "shadow", @@ -232,9 +257,9 @@ describe("HistorySwipeNavigation", () => { } else if (event === "move") { ({ viewport, content } = mountPanel(screen.container, "none", 0)); pointerX += 100; - } else if (event === "move-to-scroller") { + } else if (event === "move-to-scroller" || event === "show-scroller") { ({ viewport, content } = mountPanel(screen.container, "shadow", 400)); - pointerX += 100; + if (event === "move-to-scroller") pointerX += 100; } else if (event === "close-by-key") { content.dispatchEvent( new KeyboardEvent("keydown", { key: "d", metaKey: true, bubbles: true, composed: true }), diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index e3798ce6d5eca..d9295fd0e1abb 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -19,6 +19,7 @@ type SwipeGesture = deltas: number[]; y: number; bounds: SwipeBounds; + origin: EventTarget | undefined; } // Not a swipe, or one already decided: the rest of its events are ignored. | { kind: "ignored" }; @@ -51,6 +52,7 @@ function isInHorizontalScroller(path: readonly EventTarget[]): boolean { function lockSwipeAxis( pending: Extract, + origin: EventTarget | undefined, ): Exclude { const { path, sumX, sumY } = pending; if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; @@ -66,6 +68,7 @@ function lockSwipeAxis( deltas: [], y: pending.y, bounds: readMainContentBounds(), + origin, }; } @@ -105,6 +108,11 @@ export function HistorySwipeNavigation() { pointerAt = null; hideIndicator(); }; + const claimContent = () => { + contentClaimed = true; + gesture = null; + hideIndicator(); + }; const isAtPointer = (event: MouseEvent) => event.clientX === pointerAt?.x && event.clientY === pointerAt.y; const onPointerMove = (event: PointerEvent) => { @@ -147,6 +155,13 @@ export function HistorySwipeNavigation() { if (gesture.kind === "tracking") { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; if (event.deltaX === 0 && event.deltaY === 0) return release(); + // Content can appear under a still pointer while a stale trailing wheel is tracking. + // Only a new wheel target is classified, so overflow gained mid-swipe stays history. + const path = event.composedPath(); + if (path[0] !== gesture.origin) { + if (isInHorizontalScroller(path)) return claimContent(); + gesture.origin = path[0]; + } const { direction, distance, y } = gesture; // Past its start the swipe picks a direction again: a stale trailing wheel may // have opened it, and the next swipe's begin can arrive after its first wheels. @@ -169,15 +184,11 @@ export function HistorySwipeNavigation() { if (event.deltaX === 0 && event.deltaY === 0) return; const path = event.composedPath(); // Claim content scrolling while its layout still exists, before a possible remount. - if (isInHorizontalScroller(path)) { - contentClaimed = true; - gesture = null; - return; - } + if (isInHorizontalScroller(path)) return claimContent(); 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, ...path] }); + gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...path] }, path[0]); if (gesture.kind === "ignored") return; } setSwipe({ From 21015dd7074eeb019f03da6fd0a9181fe389acf6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 07:57:38 +0000 Subject: [PATCH 17/20] fix(desktop): content appearing after a history release can still claim the swipe A history swipe released by a zero wheel became a bare ignored gesture, so a wheel on content that appeared under the still pointer before the native end was never classified, and the next swipe at that position navigated again. A released swipe now keeps its wheel target, and tracking and released gestures share the new-target check. Zero-delta wheels still never claim, and gestures that were never a swipe (zoom, line-mode, vertical, dialog) stay unclassified. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 24 +++++++++++++ .../src/components/HistorySwipeNavigation.tsx | 36 +++++++++++-------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index ca45e80b6569f..6c058e813fb26 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -140,6 +140,30 @@ describe("HistorySwipeNavigation", () => { [[60, "remove"], [70]], "forward", ], + [ + "content appearing after history release before its native end", + "none", + 0, + [["begin"], [130], [0], ["show-scroller"], [7, "remove"], ["end"], ["begin"], [130], ["end"]], + "forward", + ], + [ + "zero-delta contact on content appearing after history release", + "none", + 0, + [ + ["begin"], + [130], + [0], + ["show-scroller"], + [0, "remove"], + ["end"], + ["begin"], + [-130], + ["end"], + ], + "both", + ], [ "the next gesture's first wheel arriving before the previous end, then scroller removal", "shadow", diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index d9295fd0e1abb..87f76b6b74c7e 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -21,8 +21,10 @@ type SwipeGesture = bounds: SwipeBounds; origin: EventTarget | undefined; } - // Not a swipe, or one already decided: the rest of its events are ignored. - | { kind: "ignored" }; + // Not a swipe: the rest of its events are ignored. + | { kind: "ignored" } + // A decided history swipe. Its momentum is ignored, but content can still claim it. + | { kind: "released"; origin: EventTarget | undefined }; type SwipeIndicatorState = { direction: HistoryDirection; progress: number; @@ -53,7 +55,7 @@ function isInHorizontalScroller(path: readonly EventTarget[]): boolean { function lockSwipeAxis( pending: Extract, origin: EventTarget | undefined, -): Exclude { +): Extract { 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"]'))) { @@ -123,19 +125,17 @@ export function HistorySwipeNavigation() { if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) resetContext(); }; // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. - const release = () => { - if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { - navigateHistory(gesture.direction); - } - gesture = { kind: "ignored" }; + const release = (swipe: Extract) => { + if (swipe.distance >= SWIPE_TRIGGER_DISTANCE) navigateHistory(swipe.direction); + gesture = { kind: "released", origin: swipe.origin }; hideIndicator(); }; // Chromium brackets each trackpad scroll, momentum included, with these. const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { - if (gesture?.kind === "ignored") gesture = null; + if (gesture?.kind === "ignored" || gesture?.kind === "released") gesture = null; }); const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { - if (gesture?.kind === "tracking") release(); + if (gesture?.kind === "tracking") release(gesture); gesture = null; }); const onWheel = (event: WheelEvent) => { @@ -152,16 +152,22 @@ export function HistorySwipeNavigation() { : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; - if (gesture.kind === "tracking") { - const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; - if (event.deltaX === 0 && event.deltaY === 0) return release(); - // Content can appear under a still pointer while a stale trailing wheel is tracking. + if (gesture.kind !== "pending") { + if (event.deltaX === 0 && event.deltaY === 0) { + if (gesture.kind === "tracking") release(gesture); + return; + } + // Content can appear under a still pointer before a history swipe's native end. // Only a new wheel target is classified, so overflow gained mid-swipe stays history. const path = event.composedPath(); if (path[0] !== gesture.origin) { if (isInHorizontalScroller(path)) return claimContent(); gesture.origin = path[0]; } + } + if (gesture.kind === "released") return; + if (gesture.kind === "tracking") { + const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; const { direction, distance, y } = gesture; // Past its start the swipe picks a direction again: a stale trailing wheel may // have opened it, and the next swipe's begin can arrive after its first wheels. @@ -176,7 +182,7 @@ export function HistorySwipeNavigation() { hideIndicator(); } else { gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; - if (isMomentumTail(gesture.deltas)) return release(); + if (isMomentumTail(gesture.deltas)) return release(gesture); gesture.distance = Math.max(0, distance + delta); } } From e1d13d5c0b56e4609d25b7f73de851a46488cff2 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:51:04 +0000 Subject: [PATCH 18/20] fix(desktop): reduce the swipe guard to the edge rule and early content checks Drop the pointer and keyboard context, the released state and swipe reversal. Content ownership lasts one native gesture again, and the tests keep only cases that fail on main or guard distinct behavior. Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 269 ++---------------- .../src/components/HistorySwipeNavigation.tsx | 122 +++----- 2 files changed, 55 insertions(+), 336 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 6c058e813fb26..cfe9266af0d6f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -3,20 +3,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; -type Scroller = "shadow" | "light" | "none" | "shadow-dialog"; +type Scroller = "shadow" | "none" | "shadow-dialog"; type Step = readonly [ - // "move" and "move-to-scroller": the wheel position moves to new content elsewhere. - event: - | number - | "begin" - | "end" - | "zoom" - | "move" - | "move-to-scroller" - | "show-scroller" - | "close-by-key" - | "pointer-return", - after?: number | "remount" | "remove" | "close-dialog", + event: number | "begin" | "end" | "show-scroller", + after?: "remove" | "close-dialog", ]; function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: number) { @@ -26,7 +16,7 @@ function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: numb scroller === "shadow-dialog" ? root.appendChild(Object.assign(document.createElement("div"), { role: "dialog" })) : root; - const overflows = scroller === "shadow" || scroller === "light"; + 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")); @@ -36,220 +26,39 @@ function mountPanel(container: HTMLElement, scroller: Scroller, scrollLeft: numb } describe("HistorySwipeNavigation", () => { - it.each< - [ - string, - Scroller, - number, - readonly Step[], - "back" | "forward" | "both" | null, - ("back" | "forward")?, - ] - >([ + it.each<[string, Scroller, number, readonly Step[], "back" | "forward" | null]>([ ["scrolling left inside a shadow-root scroller", "shadow", 400, [[-130]], null], - ["scrolling right inside a shadow-root scroller", "shadow", 400, [[130]], null], ["swiping left at a shadow-root scroller's left edge", "shadow", 0, [[-130]], null], - ["swiping right at a shadow-root scroller's right edge", "shadow", 800, [[130]], null], - ["swiping left at a scroller's left edge", "light", 0, [[-130]], null], - ["swiping right at a scroller's right edge", "light", 800, [[130]], null], - ["scrolling left inside a scroller", "light", 400, [[-130]], null], - ["a small first event that reaches the left edge", "shadow", 3, [[-3, 0], [-130]], null], - ["a small first event that reaches the right edge", "light", 797, [[3, 800], [130]], null], - ["a scroll that reaches the edge mid-gesture", "light", 100, [[-100, 0], [-130]], null], - [ - "a scroller remounted after a small first event", - "shadow", - 400, - [[1, "remount"], [130]], - null, - ], ["a scroller removed after a small first event", "shadow", 400, [[1, "remove"], [130]], null], [ - "a scroll begin arriving after the first wheel and scroller removal", + "a history gesture after a content scroll ends", "shadow", 400, - [[1, "remove"], ["begin"], [130]], - null, - ], - [ - "a history gesture elsewhere after a content scroll ends", - "shadow", - 400, - [[1, "remove"], ["end"], ["move"], ["begin"], [130]], + [[1, "remove"], ["end"], ["begin"], [130]], "forward", ], [ - "a history gesture elsewhere after a trailing wheel reaches a scroller", - "shadow", - 400, - [["begin"], [130], ["end"], [1, "remount"], ["move"], ["begin"], [-130]], - "back", - ], - [ - "a history gesture at the same position after closing content by keyboard", - "shadow", - 400, - [[1], ["end"], ["close-by-key", "remove"], ["begin"], [-130]], - "back", - ], - [ - "a history gesture after moving the pointer away and back to removed content", - "shadow", - 400, - [[1, "remove"], ["end"], ["pointer-return"], ["begin"], [130]], - "forward", - ], - [ - "content scrolling after a trailing history wheel started tracking elsewhere", - "none", - 0, - [ - ["begin"], - [130], - ["end"], - [18], - ["move-to-scroller"], - [7, "remount"], - ["begin"], - [130], - ["end"], - ], - "forward", - ], - [ - "content appearing at the same position after a trailing history wheel", - "none", - 0, - [ - ["begin"], - [130], - ["end"], - [18], - ["show-scroller"], - ["begin"], - [7, "remove"], - [130], - ["end"], - ], - "forward", - ], - [ - "a history swipe over content replaced without overflow", + "content appearing under tracking that a trailing history wheel started", "none", 0, - [[60, "remove"], [70]], + [["begin"], [130], ["end"], [18], ["show-scroller"], ["begin"], [7], [130], ["end"]], "forward", ], [ - "content appearing after history release before its native end", - "none", - 0, - [["begin"], [130], [0], ["show-scroller"], [7, "remove"], ["end"], ["begin"], [130], ["end"]], - "forward", - ], - [ - "zero-delta contact on content appearing after history release", - "none", + "a gesture that starts in a dialog before it closes", + "shadow-dialog", 0, - [ - ["begin"], - [130], - [0], - ["show-scroller"], - [0, "remove"], - ["end"], - ["begin"], - [-130], - ["end"], - ], - "both", - ], - [ - "the next gesture's first wheel arriving before the previous end, then scroller removal", - "shadow", - 400, - [["begin"], [130], [1, "remove"], ["end"], ["begin"], [130]], - null, - ], - [ - "a scroll begin arriving after the next gesture's first wheel and scroller removal", - "shadow", - 400, - [["begin"], [130], ["end"], [1, "remove"], ["begin"], [130]], + [[-1, "close-dialog"], [-130]], null, ], [ - "a reversed next swipe whose first wheel beats its begin, after a trailing wheel", - "none", - 0, - [ - ["begin"], - [-130], - ["end"], - [-23.5560302734375], - [23.84126091003418], - ["begin"], - [130], - ["end"], - ], - "both", - ], - [ - "a reversed next swipe whose begin beats its first wheel, after a trailing wheel", - "none", - 0, - [ - ["begin"], - [130], - ["end"], - [23.5560302734375], - ["begin"], - [-23.84126091003418], - [-130], - ["end"], - ], - "both", - ], - [ - "a reversed swipe needs its first wheel from before a late begin", - "none", - 0, - [["begin"], [-130], ["end"], [-23.5560302734375], [60], ["begin"], [100], ["end"]], - "both", - ], - [ - "a swipe resumes after reversing toward unavailable history", + "a history swipe over content replaced without overflow", "none", 0, - [["begin"], [-80], [100], [-130], ["end"]], + [[-60, "remove"], [-70]], "back", - "forward", - ], - [ - "a history gesture after zero-delta contact on a scroller", - "shadow", - 400, - [[0, "remove"], ["begin"], [130]], - "forward", - ], - [ - "a scroll begin after an ignored zoom wheel", - "none", - 0, - [["zoom"], ["begin"], [130]], - "forward", ], - ["swiping inside a dialog in a shadow root", "shadow-dialog", 0, [[-130]], null], - [ - "a gesture that starts in a dialog before it closes", - "shadow-dialog", - 0, - [[-1, "close-dialog"], [-130]], - null, - ], - ["swiping left over content without overflow", "none", 0, [[-130]], "back"], - ["swiping right over content without overflow", "none", 0, [[130]], "forward"], - ])("%s", async (_name, scroller, scrollLeft, steps, navigates, unavailable) => { + ])("%s", async (_name, scroller, scrollLeft, steps, navigates) => { const listeners = { begin: new Set<() => void>(), end: new Set<() => void>() }; using _bridge = stubGlobal("desktopBridge", { onTrackpadScrollBegin: (listener: () => void) => { @@ -264,66 +73,32 @@ describe("HistorySwipeNavigation", () => { await using screen = await render(); 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(unavailable !== "back"); - using _canGoForward = vi - .spyOn(window.navigation, "canGoForward", "get") - .mockReturnValue(unavailable !== "forward"); + 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); - let pointerX = 0; 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 === "move") { - ({ viewport, content } = mountPanel(screen.container, "none", 0)); - pointerX += 100; - } else if (event === "move-to-scroller" || event === "show-scroller") { + } else if (event === "show-scroller") { ({ viewport, content } = mountPanel(screen.container, "shadow", 400)); - if (event === "move-to-scroller") pointerX += 100; - } else if (event === "close-by-key") { - content.dispatchEvent( - new KeyboardEvent("keydown", { key: "d", metaKey: true, bubbles: true, composed: true }), - ); - } else if (event === "pointer-return") { - for (const clientX of [pointerX + 100, pointerX]) { - content.dispatchEvent( - new PointerEvent("pointermove", { clientX, bubbles: true, composed: true }), - ); - } } else { content.dispatchEvent( - new WheelEvent("wheel", { - deltaX: event === "zoom" ? 0 : event, - ctrlKey: event === "zoom", - clientX: pointerX, - bubbles: true, - composed: true, - }), + new WheelEvent("wheel", { deltaX: event, bubbles: true, composed: true }), ); } - if (after === "remount") { - const replacement = viewport.cloneNode(true) as HTMLDivElement; - const { scrollLeft: previous } = viewport; - viewport.replaceWith(replacement); - replacement.scrollLeft = previous; - viewport = replacement; - content = replacement.firstElementChild as HTMLDivElement; - } else if (after === "remove") { + if (after === "remove") { viewport.remove(); ({ viewport, content } = mountPanel(screen.container, "none", 0)); } else if (after === "close-dialog") { viewport.parentElement!.remove(); screen.container.appendChild(viewport); - } else if (after !== undefined) { - viewport.scrollLeft = after; } } - expect(back).toHaveBeenCalledTimes(navigates === "back" || navigates === "both" ? 1 : 0); - expect(forward).toHaveBeenCalledTimes(navigates === "forward" || navigates === "both" ? 1 : 0); + expect(back).toHaveBeenCalledTimes(navigates === "back" ? 1 : 0); + expect(forward).toHaveBeenCalledTimes(navigates === "forward" ? 1 : 0); }); }); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index 87f76b6b74c7e..32e5d2c28d5a6 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -19,12 +19,10 @@ type SwipeGesture = deltas: number[]; y: number; bounds: SwipeBounds; - origin: EventTarget | undefined; + target: EventTarget | undefined; } - // Not a swipe: the rest of its events are ignored. - | { kind: "ignored" } - // A decided history swipe. Its momentum is ignored, but content can still claim it. - | { kind: "released"; origin: EventTarget | undefined }; + // Not a swipe, or one already decided: the rest of its events are ignored. + | { kind: "ignored" }; type SwipeIndicatorState = { direction: HistoryDirection; progress: number; @@ -44,9 +42,7 @@ function readMainContentBounds(): SwipeBounds { // 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 <= 1) { - return false; - } + if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) return false; const { overflowX } = getComputedStyle(element); return overflowX === "auto" || overflowX === "scroll"; }); @@ -54,8 +50,8 @@ function isInHorizontalScroller(path: readonly EventTarget[]): boolean { function lockSwipeAxis( pending: Extract, - origin: EventTarget | undefined, -): Extract { + target: EventTarget | undefined, +): Exclude { 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"]'))) { @@ -70,7 +66,7 @@ function lockSwipeAxis( deltas: [], y: pending.y, bounds: readMainContentBounds(), - origin, + target, }; } @@ -96,55 +92,25 @@ export function HistorySwipeNavigation() { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes useEffect(() => { let gesture: SwipeGesture | null = null; - // Begin and end IPC race the wheels, so they cannot say which gesture a wheel belongs to. - // Wheels at one pointer position share a context; moving or typing starts a new one. - let pointerAt: { x: number; y: number } | null = null; - let contentClaimed = false; const hideIndicator = () => - setSwipe((current) => - current === null || !current.tracking ? current : { ...current, tracking: false }, - ); - const resetContext = () => { - gesture = null; - contentClaimed = false; - pointerAt = null; - hideIndicator(); - }; - const claimContent = () => { - contentClaimed = true; - gesture = null; - hideIndicator(); - }; - const isAtPointer = (event: MouseEvent) => - event.clientX === pointerAt?.x && event.clientY === pointerAt.y; - const onPointerMove = (event: PointerEvent) => { - if (pointerAt && !isAtPointer(event)) resetContext(); - }; - // Keyboard shortcuts can replace content without moving the pointer. - const onKeyDown = (event: KeyboardEvent) => { - if (!["Alt", "Control", "Meta", "Shift"].includes(event.key)) resetContext(); - }; + setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. - const release = (swipe: Extract) => { - if (swipe.distance >= SWIPE_TRIGGER_DISTANCE) navigateHistory(swipe.direction); - gesture = { kind: "released", origin: swipe.origin }; + const release = () => { + if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { + navigateHistory(gesture.direction); + } + gesture = { kind: "ignored" }; hideIndicator(); }; // Chromium brackets each trackpad scroll, momentum included, with these. const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { - if (gesture?.kind === "ignored" || gesture?.kind === "released") gesture = null; + if (gesture?.kind === "ignored") gesture = null; }); const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { - if (gesture?.kind === "tracking") release(gesture); + if (gesture?.kind === "tracking") release(); gesture = null; }); const onWheel = (event: WheelEvent) => { - if (!isAtPointer(event)) { - resetContext(); - pointerAt = { x: event.clientX, y: event.clientY }; - } else if (contentClaimed) { - return; - } if (gesture === null) { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL @@ -152,50 +118,32 @@ export function HistorySwipeNavigation() { : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; - if (gesture.kind !== "pending") { - if (event.deltaX === 0 && event.deltaY === 0) { - if (gesture.kind === "tracking") release(gesture); - return; - } - // Content can appear under a still pointer before a history swipe's native end. - // Only a new wheel target is classified, so overflow gained mid-swipe stays history. - const path = event.composedPath(); - if (path[0] !== gesture.origin) { - if (isInHorizontalScroller(path)) return claimContent(); - gesture.origin = path[0]; - } + if (event.deltaX === 0 && event.deltaY === 0) { + if (gesture.kind === "tracking") release(); + return; } - if (gesture.kind === "released") return; - if (gesture.kind === "tracking") { - const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; - const { direction, distance, y } = gesture; - // Past its start the swipe picks a direction again: a stale trailing wheel may - // have opened it, and the next swipe's begin can arrive after its first wheels. - if (distance + delta < 0 && canNavigateHistory(direction === "back" ? "forward" : "back")) { - gesture = { - kind: "pending", - path: [], - y, - sumX: direction === "back" ? -distance : distance, - sumY: 0, - }; - hideIndicator(); - } else { - gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; - if (isMomentumTail(gesture.deltas)) return release(gesture); - gesture.distance = Math.max(0, distance + delta); - } + 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") { - if (event.deltaX === 0 && event.deltaY === 0) return; - const path = event.composedPath(); - // Claim content scrolling while its layout still exists, before a possible remount. - if (isInHorizontalScroller(path)) return claimContent(); 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, ...path] }, path[0]); if (gesture.kind === "ignored") return; + } else { + gesture.target = path[0]; + const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; + gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; + if (isMomentumTail(gesture.deltas)) return release(); + gesture.distance = Math.max(0, gesture.distance + delta); } setSwipe({ direction: gesture.direction, @@ -205,14 +153,10 @@ export function HistorySwipeNavigation() { tracking: true, }); }; - window.addEventListener("pointermove", onPointerMove, { capture: true, passive: true }); - window.addEventListener("keydown", onKeyDown, { capture: true }); window.addEventListener("wheel", onWheel, { capture: true, passive: true }); return () => { unsubscribeScrollBegin?.(); unsubscribeScrollEnd?.(); - window.removeEventListener("pointermove", onPointerMove, { capture: true }); - window.removeEventListener("keydown", onKeyDown, { capture: true }); window.removeEventListener("wheel", onWheel, { capture: true }); }; }, []); From 33bf0c4925c40f646029025e0dd17cd127a51ef0 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:07:05 +0000 Subject: [PATCH 19/20] test(desktop): cover swipe targets that gain overflow and release over content Co-Authored-By: Claude Opus 5.5 --- .../HistorySwipeNavigation.browser.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index cfe9266af0d6f..55a4900bb0cf5 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -5,7 +5,7 @@ import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; type Scroller = "shadow" | "none" | "shadow-dialog"; type Step = readonly [ - event: number | "begin" | "end" | "show-scroller", + event: number | "begin" | "end" | "show-scroller" | "gain-overflow", after?: "remove" | "close-dialog", ]; @@ -52,10 +52,17 @@ describe("HistorySwipeNavigation", () => { null, ], [ - "a history swipe over content replaced without overflow", + "a history swipe over content replaced without overflow, released over a scroller", "none", 0, - [[-60, "remove"], [-70]], + [[-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) => { @@ -84,6 +91,9 @@ describe("HistorySwipeNavigation", () => { 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 }), From 4d13dd0c946ae4811f860300893debb94dfc294b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 08:27:38 +0000 Subject: [PATCH 20/20] test(web): a swipe from the tab strip's edge stays with the tab strip --- apps/web/src/components/RightPanelTabs.browser.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx index 4a7617bb1abd8..dea1626142de4 100644 --- a/apps/web/src/components/RightPanelTabs.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx @@ -60,7 +60,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); @@ -131,6 +131,6 @@ describe("RightPanelTabs", () => { viewport.scrollLeft = 0; scroll(viewport.querySelector("[data-active-tab]")!, [-40, -40, -40, -40, -40]); - expect(navigateHistory).toHaveBeenCalledExactlyOnceWith("back"); + expect(navigateHistory).not.toHaveBeenCalled(); }); });