Files
fusion/packages/dashboard/app/hooks/useBoardScrollRestore.ts
gsxdsm d05ca2154f FN-7258: restore mobile board scroll on return
Preserve the board lane position when mobile users return from full-panel task detail.

- Capture and restore board, column, project-content, and document scroll offsets for board detail navigation.
- Retry board scroll restoration across bounded animation frames after remount and hydration.
- Cover mobile and desktop Back to board behavior with focused tests and document the mobile scroll contract.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7258-mobile-board-scroll.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +
 .../__tests__/navigation-history.test.tsx          | 111 ++++++++++++++++++---
 .../hooks/__tests__/useBoardScrollRestore.test.ts  |  43 +++++++-
 .../dashboard/app/hooks/useBoardScrollRestore.ts   |  45 ++++++---
 .../utils/__tests__/boardScrollSnapshot.test.ts    |  91 +++++++++++++++--
 .../dashboard/app/utils/boardScrollSnapshot.ts     |  41 +++++++-
 7 files changed, 305 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7258

Fusion-Task-Lineage: 97faf031-4244-4117-9948-45491c364134

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-29 21:02:34 -07:00

77 lines
2.8 KiB
TypeScript

/*
FNXC:BoardNavigation 2026-06-24-00:00:
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
FNXC:BoardNavigation 2026-06-29-20:45:
Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
*/
import { useCallback, useEffect, useRef } from "react";
import {
captureBoardScrollSnapshot,
restoreBoardScrollSnapshot,
type BoardScrollSnapshot,
} from "../utils/boardScrollSnapshot";
import type { TaskView } from "./useViewState";
const MAX_RESTORE_ATTEMPTS = 6;
export interface UseBoardScrollRestoreResult {
capture: () => void;
requestRestore: () => void;
}
export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestoreResult {
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
const pendingBoardScrollRestoreRef = useRef(false);
const capture = useCallback(() => {
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
}, []);
const requestRestore = useCallback(() => {
pendingBoardScrollRestoreRef.current = true;
}, []);
useEffect(() => {
if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return;
const scheduleFrame = typeof window.requestAnimationFrame === "function"
? window.requestAnimationFrame.bind(window)
: ((callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0));
const cancelFrame = typeof window.cancelAnimationFrame === "function"
? window.cancelAnimationFrame.bind(window)
: window.clearTimeout.bind(window);
const frameIds: number[] = [];
let attempts = 0;
let cancelled = false;
const schedule = (callback: FrameRequestCallback) => {
const id = scheduleFrame(callback);
frameIds.push(id);
};
const attemptRestore = () => {
if (cancelled || !pendingBoardScrollRestoreRef.current) return;
attempts += 1;
const restored = restoreBoardScrollSnapshot(boardScrollSnapshotRef.current);
if (restored) {
pendingBoardScrollRestoreRef.current = false;
return;
}
if (attempts < MAX_RESTORE_ATTEMPTS) {
schedule(attemptRestore);
}
};
schedule(() => {
schedule(attemptRestore);
});
return () => {
cancelled = true;
frameIds.forEach(cancelFrame);
};
}, [taskView]);
return { capture, requestRestore };
}