From cb0625d245e129cd5eeafb7bfc8de9744622ff3c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:18:50 +0800 Subject: [PATCH] fix(issues): wire swimlane's outer scroller into tab scroll restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review blocker on #5403: board/list/issue-detail register their scroll containers with the tab session memento protocol, but the swimlane outer scroller did not — under the single-router architecture an inactive tab unmounts, so a deep-scrolled swimlane returned at top after a tab switch or reload. Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for capture, useRestoredScrollRef in the scroller's attach callback for the pre-paint assignment, and the saved offset into the lane Virtuoso's initialScrollTop. Regression test asserts both the capture marker and the restored offset. Co-Authored-By: Claude Fable 5 --- .../issues/components/swimlane-view.test.tsx | 34 +++++++++++++++++++ .../views/issues/components/swimlane-view.tsx | 27 +++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/views/issues/components/swimlane-view.test.tsx b/packages/views/issues/components/swimlane-view.test.tsx index aa3ed51bf1..099ac5ff23 100644 --- a/packages/views/issues/components/swimlane-view.test.tsx +++ b/packages/views/issues/components/swimlane-view.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, act, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SwimLaneView } from "./swimlane-view"; import { IssueContextMenuProvider } from "../actions"; +import { ScrollRestorationProvider } from "../../platform"; import type { Issue } from "@multica/core/types"; import { I18nProvider } from "@multica/core/i18n/react"; import enCommon from "../../locales/en/common.json"; @@ -1809,3 +1810,36 @@ describe("SwimLaneView", () => { expect(screen.queryByText("Batch Sub-issue")).toBeNull(); }); }); + +describe("SwimLaneView tab-session scroll restoration (MUL-4741)", () => { + it("registers the outer scroller for memento capture and restores the saved offset at attach", () => { + const adapter = { + get: (key: string) => + key === "swimlane" ? { top: 240, height: 2000 } : undefined, + }; + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const { container } = render( + + + + + + + + + , + ); + + const scroller = container.querySelector( + '[data-tab-scroll-root="swimlane"]', + ); + // Capture side: the coordinator scans [data-tab-scroll-root] — without + // the marker, leaving the tab never saves the swimlane offset. + expect(scroller).not.toBeNull(); + // Restore side: the ref-attach assignment applies the saved offset + // before first paint (jsdom has no layout, so no clamping applies). + expect(scroller!.scrollTop).toBe(240); + }); +}); diff --git a/packages/views/issues/components/swimlane-view.tsx b/packages/views/issues/components/swimlane-view.tsx index bd579caf94..e25db44241 100644 --- a/packages/views/issues/components/swimlane-view.tsx +++ b/packages/views/issues/components/swimlane-view.tsx @@ -55,12 +55,8 @@ import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; import { VirtuosoSeed } from "../../common/virtuoso-seed"; -// A swimlane row (header + one row of card cells) is ~300px+ tall — a -// viewport fits ~3. The generic VIRTUOSO_SEED_COUNT (30, sized for 36px list -// rows) made every surface remount synchronously mount up to 30 full lanes -// (each lane = statuses x cells x cards); 6 covers the viewport with margin. -const SWIMLANE_LANE_SEED_COUNT = 6; import { DeferredPopup } from "../../common/deferred-popup"; +import { useRestoredScrollOffset, useRestoredScrollRef } from "../../platform"; import { DeferredTooltip } from "../../common/deferred-tooltip"; import type { ChildProgress } from "./list-row"; import { useT } from "../../i18n"; @@ -70,6 +66,12 @@ import type { IssueCreateDefaults } from "../surface/types"; const COLUMN_WIDTH = 280; const COLUMN_GAP = 16; +// A swimlane row (header + one row of card cells) is ~300px+ tall — a +// viewport fits ~3. The generic VIRTUOSO_SEED_COUNT (30, sized for 36px list +// rows) made every surface remount synchronously mount up to 30 full lanes +// (each lane = statuses x cells x cards); 6 covers the viewport with margin. +const SWIMLANE_LANE_SEED_COUNT = 6; + // Hoisted out of SwimLaneView so its reference is stable across renders — // useQueries' combine option uses it through replaceEqualDeep, but keeping // the function stable saves the per-render reference check. @@ -822,6 +824,18 @@ function SwimLaneViewImpl({ const [activeIssue, setActiveIssue] = useState(null); // The outer scroll box is the customScrollParent for the lane Virtuoso. const [scrollEl, setScrollEl] = useState(null); + // Pull-based scroll restoration (MUL-4741): same wiring as board/list/ + // issue-detail — ref-attach assigns the saved offset pre-paint, and the + // lane Virtuoso is born at it via initialScrollTop. + const restoredScrollTop = useRestoredScrollOffset("swimlane"); + const restoreScrollRef = useRestoredScrollRef("swimlane"); + const attachScroller = useCallback( + (el: HTMLDivElement | null) => { + setScrollEl(el); + restoreScrollRef(el); + }, + [restoreScrollRef], + ); const isDraggingRef = useRef(false); // Settle lock: held from drop until the move mutation settles, so a cache // change that lands mid-flight (e.g. a membership refetch) does not rebuild @@ -1203,7 +1217,7 @@ function SwimLaneViewImpl({ onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
+
{/* Sticky status header row — visually matches the top of a BoardColumn */}
@@ -1283,6 +1297,7 @@ function SwimLaneViewImpl({ customScrollParent={scrollEl} data={orderedLanes} computeItemKey={computeLaneKey} + initialScrollTop={restoredScrollTop} initialItemCount={Math.min(orderedLanes.length, SWIMLANE_LANE_SEED_COUNT)} increaseViewportBy={{ top: 600, bottom: 600 }} components={laneComponents}