FN-7342: preserve board scroll during refresh stabilization

Board stabilization now avoids resetting the user's horizontal column position during refresh and resize handling.

- Keep mobile stabilization focused on document-level drift instead of forcing #board.scrollLeft back to triage.
- Add regression coverage for board and all-workflows scroll preservation across task refresh, window resize, and visualViewport resize.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7342-board-scroll-reset.md           |   7 ++
 packages/dashboard/app/components/Board.tsx        |   7 +-
 .../app/components/__tests__/Board.test.tsx        | 109 +++++++++++++++++++++
 3 files changed, 121 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7342

Fusion-Task-Lineage: 0aa8b152-5cc0-4f77-93d5-f20faf0a5e19

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 22:19:40 -07:00
parent c54b231290
commit f840cbc14a
3 changed files with 121 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve board column scroll during dashboard refresh and viewport stabilization.
category: fix
dev: Narrows mobile board stabilization so task/workflow refresh and resize events pin document drift without resetting #board.scrollLeft.

View File

@@ -261,11 +261,15 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return stableGrouped;
}, [tasks, doneSortMode]);
/*
FNXC:BoardNavigation 2026-06-30-17:42:
Periodic task/workflow refreshes, rerenders, window resize, and visualViewport resize must not override intentional board-column scroll while the Board is already visible. Keep FN-001/FN-4574 stabilization focused on page-level horizontal drift and layout reflow; #board is the user's horizontal scroller, so it must not be forced back to triage.
*/
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
// snap against stale layout/visualViewport metrics before flex columns resolve,
// both on initial mount and on pageshow/bfcache restore after backgrounding.
// We keep the FN-001 baseline (`scroll-snap-type: x proximity` +
// `overflow-anchor: none`) and only stabilize via reflow + scroll offset
// `overflow-anchor: none`) and only stabilize via reflow + document scroll
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
useEffect(() => {
const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
@@ -278,7 +282,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
void boardEl.offsetWidth;
if (mobileQuery.matches) {
resetDocumentHorizontalScroll();
boardEl.scrollLeft = 0;
}
};

View File

@@ -199,6 +199,49 @@ function renderBoard(props = {}) {
return render(<Board {...createBoardProps(props)} />);
}
function installMobileBoardStabilizationHarness() {
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const visualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
const visualViewportTarget = new EventTarget() as EventTarget & { scale: number };
visualViewportTarget.scale = 1;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768px"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
callback(performance.now());
return 1;
});
window.cancelAnimationFrame = vi.fn();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: visualViewportTarget,
});
return {
visualViewport: visualViewportTarget,
restore() {
window.matchMedia = originalMatchMedia;
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
if (visualViewportDescriptor) {
Object.defineProperty(window, "visualViewport", visualViewportDescriptor);
} else {
delete (window as typeof window & { visualViewport?: VisualViewport }).visualViewport;
}
},
};
}
async function openWorkflowSwitcher() {
const trigger = await screen.findByTestId("workflow-switcher");
fireEvent.click(trigger);
@@ -224,6 +267,46 @@ describe("Board", () => {
expect(main.id).toBe("board");
});
it("preserves intentional board column scroll during mobile resize stabilization", () => {
const harness = installMobileBoardStabilizationHarness();
try {
const { rerender } = renderBoard({
tasks: [
{ id: "FN-SCROLL-1", description: "Later lane", column: "in-review", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z" } as Task,
],
});
const board = screen.getByRole("main") as HTMLElement;
board.scrollLeft = 360;
rerender(<Board {...createBoardProps({ tasks: [] })} />);
act(() => {
window.dispatchEvent(new Event("resize"));
});
expect(board.scrollLeft).toBe(360);
expect(document.documentElement.scrollLeft).toBe(0);
} finally {
harness.restore();
}
});
it("preserves intentional board column scroll during mobile visualViewport stabilization", () => {
const harness = installMobileBoardStabilizationHarness();
try {
renderBoard();
const board = screen.getByRole("main") as HTMLElement;
board.scrollLeft = 480;
act(() => {
harness.visualViewport.dispatchEvent(new Event("resize"));
});
expect(board.scrollLeft).toBe(480);
} finally {
harness.restore();
}
});
it("FN-4380: does not eagerly fetch GitHub badge status on board mount", () => {
vi.useFakeTimers();
try {
@@ -1666,6 +1749,32 @@ describe("Board", () => {
]);
});
it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => {
const harness = installMobileBoardStabilizationHarness();
try {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "todo" }), mkTask({ id: "FN-2", column: "intake" })] });
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
const board = screen.getByRole("main") as HTMLElement;
expect(board.className).toContain("board-workflow-columns");
board.scrollLeft = 520;
act(() => {
harness.visualViewport.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("resize"));
});
expect(board.scrollLeft).toBe(520);
expect(screen.getByTestId("column-intake")).toHaveAttribute("data-tasks", expect.stringContaining("FN-2"));
} finally {
harness.restore();
}
});
it("archived column is collapsible in workflow mode", async () => {
enableFlag({ "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-9", column: "archived" })] });