diff --git a/.changeset/FN-6243-mobile-auto-merge-blank.md b/.changeset/FN-6243-mobile-auto-merge-blank.md new file mode 100644 index 0000000000..b2b9b94fef --- /dev/null +++ b/.changeset/FN-6243-mobile-auto-merge-blank.md @@ -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. diff --git a/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md b/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md new file mode 100644 index 0000000000..11cea018e5 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md @@ -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. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 4e4557a2d0..5ce373ad8d 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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 = 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, next: ReadonlyMap): 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 } : {})} /> diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx index 3c5d84583a..e5df510c48 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx @@ -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,