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:
5
.changeset/FN-6243-mobile-auto-merge-blank.md
Normal file
5
.changeset/FN-6243-mobile-auto-merge-blank.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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 } : {})}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user