Files
fusion/packages/dashboard/app/hooks/useBoardScrollRestore.ts
gsxdsm f157bf7460 fix(dashboard): harden visibility suspension, log caps, and mobile board UX
Suspend poll/SSE work when the tab is hidden, cap log buffers, restore board scroll more reliably, and improve list windowing/live tickers with related tests and a mobile-tab retention changeset.
2026-07-26 09:50:44 -07:00

176 lines
7.5 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.
FNXC:BoardNavigation 2026-07-26-10:20:
Mobile browsers discard a backgrounded dashboard tab (iOS Safari tab, iOS installed PWA, Chrome Android alike) and reload it from scratch when the user returns — the "white splash reload". That is an involuntary, OS-driven event, so the app must be able to put the user back where they were rather than at the top of the board.
Two additions carry the snapshot across that reload:
1. Persist on hide. `pagehide` / `visibilitychange:hidden` is the LAST moment we are guaranteed to run before a discard, and the common case is a user who was sitting on the board and never opened task detail — so the existing capture-on-open-detail path alone would have nothing to restore. Snapshot-on-hide is cheap (a handful of scrollTop reads plus one sessionStorage write) and does no background work while hidden, so it does not itself make the tab a discard candidate.
2. Replay on mount. A reloaded board has no rows until its first fetch resolves, so the restore is retried on a bounded timer (not a busy rAF chain — a discarded-tab restore can take far longer than a remount, and the timer stays cheap) until the board actually has columns, then stops. Any real user scroll/keyboard input aborts the replay immediately; never fight the user for control of the scroll position.
The mount replay defers to the in-memory back-navigation path: it only seeds the ref when nothing is captured and no restore is pending, and it re-checks that pending flag on every attempt, so the two paths can never race for the same board.
*/
import { useCallback, useEffect, useRef } from "react";
import {
captureBoardScrollSnapshot,
persistBoardScrollSnapshot,
readPersistedBoardScrollSnapshot,
restoreBoardScrollSnapshot,
type BoardScrollSnapshot,
} from "../utils/boardScrollSnapshot";
import type { TaskView } from "./useViewState";
const MAX_RESTORE_ATTEMPTS = 6;
/*
Bounded reload-replay budget: ~4s of 100ms polls. Long enough to outlast a cold board fetch on a
mobile connection, short enough that a board which never renders stops costing anything.
*/
const RELOAD_RESTORE_INTERVAL_MS = 100;
const RELOAD_RESTORE_MAX_ATTEMPTS = 40;
export interface UseBoardScrollRestoreResult {
capture: () => void;
requestRestore: () => void;
}
export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestoreResult {
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
const pendingBoardScrollRestoreRef = useRef(false);
const taskViewRef = useRef(taskView);
useEffect(() => {
taskViewRef.current = taskView;
}, [taskView]);
const capture = useCallback(() => {
const snapshot = captureBoardScrollSnapshot();
boardScrollSnapshotRef.current = snapshot;
// Mirror to sessionStorage so a discard/reload between now and the return still restores.
persistBoardScrollSnapshot(snapshot);
}, []);
const requestRestore = useCallback(() => {
pendingBoardScrollRestoreRef.current = true;
}, []);
// Snapshot-on-hide: the last guaranteed callback before an OS tab discard.
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") return;
const persistNow = () => {
if (taskViewRef.current !== "board") return;
const snapshot = captureBoardScrollSnapshot();
if (!snapshot) return;
boardScrollSnapshotRef.current = snapshot;
persistBoardScrollSnapshot(snapshot);
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") persistNow();
};
window.addEventListener("pagehide", persistNow);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pagehide", persistNow);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// Reload/discard replay: restore the persisted snapshot once the board actually has content.
useEffect(() => {
if (typeof window === "undefined") return;
// An in-flight in-memory restore owns the board; do not seed a competing one.
if (boardScrollSnapshotRef.current || pendingBoardScrollRestoreRef.current) return;
const persisted = readPersistedBoardScrollSnapshot();
if (!persisted) return;
boardScrollSnapshotRef.current = persisted;
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const cancel = () => {
cancelled = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
window.removeEventListener("wheel", cancel);
window.removeEventListener("touchstart", cancel);
window.removeEventListener("keydown", cancel);
};
const attempt = () => {
timer = null;
if (cancelled) return;
attempts += 1;
// Only replay while the board is the visible surface, and never on top of a pending
// back-navigation restore — that path has a fresher snapshot.
if (taskViewRef.current === "board" && !pendingBoardScrollRestoreRef.current) {
if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
cancel();
return;
}
}
if (attempts >= RELOAD_RESTORE_MAX_ATTEMPTS) {
cancel();
return;
}
timer = setTimeout(attempt, RELOAD_RESTORE_INTERVAL_MS);
};
window.addEventListener("wheel", cancel, { passive: true });
window.addEventListener("touchstart", cancel, { passive: true });
window.addEventListener("keydown", cancel);
timer = setTimeout(attempt, 0);
return cancel;
}, []);
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 };
}