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:
7
.changeset/fn-7342-board-scroll-reset.md
Normal file
7
.changeset/fn-7342-board-scroll-reset.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" })] });
|
||||
|
||||
Reference in New Issue
Block a user