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..b2e6fcaaaf402
--- /dev/null
+++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { render } from "../../test/render";
+import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
+
+const navigateHistory = vi.hoisted(() => vi.fn());
+vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory }));
+
+function swipe(target: Element, deltaX: number) {
+ for (const delta of [deltaX, 0]) {
+ target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true }));
+ }
+}
+
+describe("HistorySwipeNavigation", () => {
+ it.each([
+ ["scrolling left inside a shadow-root scroller keeps the thread", "shadow", 100, -130, false],
+ ["scrolling right inside a shadow-root scroller keeps the thread", "shadow", 100, 130, false],
+ [
+ "scrolling after a shadow scroller remount keeps the thread",
+ "shadow-remount",
+ 100,
+ 130,
+ false,
+ ],
+ ["scrolling left inside a scroller keeps the thread", "light", 100, -130, false],
+ ["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true],
+ ["swiping back over a panel without overflow navigates", "none", 0, -130, true],
+ ["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false],
+ ] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => {
+ navigateHistory.mockReset();
+ await using screen = await render();
+ const panel = screen.container.appendChild(document.createElement("div"));
+ const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel;
+ const viewport = root.appendChild(document.createElement("div"));
+ const noOverflow = scroller === "none" || scroller === "closed-dialog";
+ viewport.style.cssText = `width: 200px; overflow-x: ${noOverflow ? "visible" : "auto"}`;
+ const content = viewport.appendChild(document.createElement("div"));
+ content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`;
+ viewport.scrollLeft = scrollLeft;
+
+ if (scroller === "closed-dialog") {
+ panel.setAttribute("role", "dialog");
+ content.dispatchEvent(new WheelEvent("wheel", { deltaX: -1, bubbles: true, composed: true }));
+ panel.remove();
+ screen.container.appendChild(viewport);
+ }
+
+ if (scroller === "shadow-remount") {
+ content.dispatchEvent(new WheelEvent("wheel", { deltaX: 1, bubbles: true, composed: true }));
+ const replacement = viewport.cloneNode(true) as HTMLDivElement;
+ viewport.replaceWith(replacement);
+ replacement.scrollLeft = scrollLeft;
+ swipe(replacement, deltaX);
+ } else {
+ swipe(content, deltaX);
+ }
+
+ expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0);
+ });
+});
diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx
index c020878b25363..767b4285c696f 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,13 +37,10 @@ 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;
+// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it.
+function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean {
+ for (const element of path) {
+ if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue;
const { overflowX } = getComputedStyle(element);
if (overflowX !== "auto" && overflowX !== "scroll") continue;
const canScroll =
@@ -58,10 +55,12 @@ function canScrollHorizontally(target: EventTarget | null, deltaX: number): bool
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 (canScrollHorizontally(path, sumX)) return { kind: "ignored" };
const direction = sumX < 0 ? "back" : "forward";
if (!canNavigateHistory(direction)) return { kind: "ignored" };
return {
@@ -119,14 +118,14 @@ 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") {
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);
+ gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] });
if (gesture.kind === "ignored") return;
} else {
const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX;