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;
|
return stableGrouped;
|
||||||
}, [tasks, doneSortMode]);
|
}, [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
|
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
|
||||||
// snap against stale layout/visualViewport metrics before flex columns resolve,
|
// snap against stale layout/visualViewport metrics before flex columns resolve,
|
||||||
// both on initial mount and on pageshow/bfcache restore after backgrounding.
|
// both on initial mount and on pageshow/bfcache restore after backgrounding.
|
||||||
// We keep the FN-001 baseline (`scroll-snap-type: x proximity` +
|
// 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`.
|
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
|
const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||||
@@ -278,7 +282,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
void boardEl.offsetWidth;
|
void boardEl.offsetWidth;
|
||||||
if (mobileQuery.matches) {
|
if (mobileQuery.matches) {
|
||||||
resetDocumentHorizontalScroll();
|
resetDocumentHorizontalScroll();
|
||||||
boardEl.scrollLeft = 0;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,49 @@ function renderBoard(props = {}) {
|
|||||||
return render(<Board {...createBoardProps(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() {
|
async function openWorkflowSwitcher() {
|
||||||
const trigger = await screen.findByTestId("workflow-switcher");
|
const trigger = await screen.findByTestId("workflow-switcher");
|
||||||
fireEvent.click(trigger);
|
fireEvent.click(trigger);
|
||||||
@@ -224,6 +267,46 @@ describe("Board", () => {
|
|||||||
expect(main.id).toBe("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", () => {
|
it("FN-4380: does not eagerly fetch GitHub badge status on board mount", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
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 () => {
|
it("archived column is collapsible in workflow mode", async () => {
|
||||||
enableFlag({ "FN-9": "builtin:coding" });
|
enableFlag({ "FN-9": "builtin:coding" });
|
||||||
renderBoard({ tasks: [mkTask({ id: "FN-9", column: "archived" })] });
|
renderBoard({ tasks: [mkTask({ id: "FN-9", column: "archived" })] });
|
||||||
|
|||||||
Reference in New Issue
Block a user