fix(issues): wire swimlane's outer scroller into tab scroll restoration

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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-15 16:18:50 +08:00
parent fa2ad2b8ca
commit cb0625d245
2 changed files with 55 additions and 6 deletions

View File

@@ -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(
<QueryClientProvider client={qc}>
<I18nProvider resources={TEST_RESOURCES} locale="en">
<IssueContextMenuProvider>
<ScrollRestorationProvider adapter={adapter}>
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />
</ScrollRestorationProvider>
</IssueContextMenuProvider>
</I18nProvider>
</QueryClientProvider>,
);
const scroller = container.querySelector<HTMLElement>(
'[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);
});
});

View File

@@ -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<Issue | null>(null);
// The outer scroll box is the customScrollParent for the lane Virtuoso.
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(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}
>
<div ref={setScrollEl} className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
<div ref={attachScroller} data-tab-scroll-root="swimlane" className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
<div className="flex shrink-0 flex-col" style={{ width: `${trackWidth}px` }}>
{/* Sticky status header row — visually matches the top of a BoardColumn */}
<div className="sticky top-0 z-10 mb-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/75">
@@ -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}