FN-6243: keep mobile auto-merge toggles visible

Keep the mobile dashboard pinned while offscreen auto-merge controls are toggled.

- Reset document horizontal scroll during mobile board stabilization and immediately after auto-merge toggles.
- Cover portrait and landscape mobile scroll realignment in the auto-merge integration test.
- Document the real-browser blank-dashboard root cause and add a patch changeset.

Files changed:
 .changeset/FN-6243-mobile-auto-merge-blank.md      |  5 ++
 ...bile-auto-merge-toggle-document-scroll-blank.md | 55 ++++++++++++++++++++++
 packages/dashboard/app/components/Board.tsx        | 47 ++++++++++++++++--
 ...-merge-toggle-blank.mobile-integration.test.tsx | 49 +++++++++++++++++++
 4 files changed, 152 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6243

Fusion-Task-Lineage: 91f74de2-667f-4732-a840-47b792288bec
This commit is contained in:
gsxdsm
2026-06-11 21:26:41 -07:00
parent 143a724a99
commit c0ff360737
4 changed files with 152 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile dashboard blanking after toggling the in-review auto-merge switch by keeping the board visible when real browsers horizontally pan the document to the offscreen column control.

View File

@@ -0,0 +1,55 @@
---
title: "Mobile auto-merge toggle blanks dashboard via document horizontal scroll"
date: 2026-06-11
category: ui-bugs
module: packages/dashboard/app/components/Board
problem_type: ui_bug
component: dashboard-board
symptoms:
- "Toggling the in-review Auto-merge switch on a mobile viewport leaves the dashboard blank/white until refresh"
- "React board subtree remains mounted; no PageErrorBoundary fallback or pageerror is emitted"
- "Existing jsdom board/task-card/worktree tests pass because jsdom has no real viewport pan/paint"
root_cause: mobile_document_horizontal_scroll
resolution_type: code_fix
severity: high
related_components:
- packages/dashboard/app/components/Column
- packages/dashboard/app/hooks/useAppSettings
- packages/dashboard/app/styles.css
tags:
- mobile
- real-browser
- auto-merge
- horizontal-scroll
- blank-screen
- fn-6243
---
# Mobile auto-merge toggle blanks dashboard via document horizontal scroll
## Problem
The recurring mobile blank-screen regression for the in-review **Auto-merge** toggle was not a React unmount or thrown exception. A real mobile browser can pan the **document** horizontally while bringing the offscreen in-review toggle into view/focus. Once `window.scrollX` is non-zero, the entire dashboard shell is shifted left and the viewport can look blank even though `main.board` and all columns remain mounted.
## Real-browser evidence
FN-6243 reproduced this with the existing Playwright CLI against a real dashboard process (`node packages/cli/dist/bin.js dashboard --port 0 --no-auth --dev --paused`) at a 375×812 mobile/touch viewport.
Pre-fix evidence:
- Before toggle: `main.board` box `{ x: 0, width: 375, height: 454.828125 }`; in-review column box `{ x: 948, width: 300, height: 430.828125 }`.
- After toggle: `main.board` still existed but box shifted to `{ x: -911, width: 375, height: 454.828125 }`; in-review column shifted to `{ x: -874, width: 300, height: 430.828125 }`.
- `pageErrors: []`.
Post-fix evidence:
- After toggle round-trip: `window.scrollX === 0`, `main.board` remained at `{ x: 0, width: 375, height: 454.828125 }`, in-review column was visible with non-zero size, and `pageErrors: []`.
## Solution
Keep the document/root horizontal scroll pinned to zero on mobile board stabilization and immediately after the auto-merge toggle fires. The board's own internal horizontal scroll remains the only horizontal scroller; do not reintroduce mandatory scroll snap.
Regression coverage should include both:
1. The existing jsdom integration surface for `useAppSettings.toggleAutoMerge` success and rollback paths.
2. A real-browser/manual or smoke run when the bug class involves viewport pan, paint, layout, visual viewport, or fixed mobile chrome. jsdom cannot reproduce this class.

View File

@@ -8,7 +8,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { Pencil, Plus } from "lucide-react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
@@ -82,6 +82,37 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
const EMPTY_WORKFLOW_STEP_NAME_LOOKUP: ReadonlyMap<string, string> = new Map();
let boardWasPreviouslyInactive = false;
// Real mobile browsers can pan the document horizontally while focusing/clicking
// an offscreen in-review auto-merge control. Keep that scroll container pinned;
// the board itself remains the only horizontal scroller.
function resetDocumentHorizontalScroll() {
const scrollingElement = document.scrollingElement as HTMLElement | null;
if (window.scrollX !== 0) {
window.scrollTo(0, window.scrollY);
}
if (scrollingElement) {
scrollingElement.scrollLeft = 0;
}
document.documentElement.scrollLeft = 0;
if (document.body) {
document.body.scrollLeft = 0;
}
}
function scheduleDocumentHorizontalScrollReset() {
const run = () => {
resetDocumentHorizontalScroll();
setTimeout(resetDocumentHorizontalScroll, 0);
};
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(run);
return;
}
setTimeout(run, 0);
}
function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next: ReadonlyMap<string, string>): boolean {
if (previous.size !== next.size) return false;
for (const [key, value] of previous) {
@@ -211,7 +242,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const boardEl = boardRef.current;
if (!boardEl) return;
void boardEl.offsetWidth;
if (isMobileViewport()) {
if (mobileQuery.matches) {
resetDocumentHorizontalScroll();
boardEl.scrollLeft = 0;
}
};
@@ -348,6 +380,13 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
await promoteTask(taskId, projectId);
}, [projectId]);
const handleToggleAutoMerge = useCallback(() => {
onToggleAutoMerge();
if (window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
scheduleDocumentHorizontalScrollReset();
}
}, [onToggleAutoMerge]);
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const flagOn = boardWorkflows?.flagEnabled === true;
@@ -563,7 +602,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge } : {})}
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
/>
);
@@ -656,7 +695,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { onToggleAutoMerge } : {})}
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/>

View File

@@ -427,6 +427,55 @@ describe("auto-merge toggle mobile integration regression", () => {
vi.unstubAllGlobals();
});
it.each([
{ name: "mobile portrait", width: 375, height: 812 },
{ name: "mobile landscape", width: 844, height: 390 },
])("realigns mobile document horizontal scroll after toggling an offscreen auto-merge control on $name", async ({ width, height }) => {
const { viewportSpy, visualViewport } = renderBoardHarness({
width,
height,
tasks: createInReviewAndWorktreeTasks(),
autoMerge: true,
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(1);
});
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation((xOrOptions?: number | ScrollToOptions, y?: number) => {
const left = typeof xOrOptions === "object" ? (xOrOptions.left ?? window.scrollX) : (xOrOptions ?? window.scrollX);
const top = typeof xOrOptions === "object" ? (xOrOptions.top ?? window.scrollY) : (y ?? window.scrollY);
Object.defineProperty(window, "scrollX", { configurable: true, value: left });
Object.defineProperty(window, "scrollY", { configurable: true, value: top });
});
Object.defineProperty(window, "scrollX", { configurable: true, value: 911 });
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
document.documentElement.scrollLeft = 911;
document.body.scrollLeft = 911;
await act(async () => {
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(updateSettings).toHaveBeenCalledWith({ autoMerge: false }, "proj_123");
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
expect(window.scrollX).toBe(0);
expect(document.documentElement.scrollLeft).toBe(0);
expect(document.body.scrollLeft).toBe(0);
expectBoardVisible(["FN-5972", "Worktree child task"]);
scrollToSpy.mockRestore();
viewportSpy.mockRestore();
});
it("keeps the real board/task-card and worktree-group composition visible on mobile portrait after toggling auto-merge on and back off", async () => {
const { viewportSpy, visualViewport } = renderBoardHarness({
width: 375,