From a566f498d8008c4f01813bb2c771a2ee8f9a7054 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 02:00:29 -0700 Subject: [PATCH] FN-6047: fix mobile-to-tablet viewport reflow Keep dashboard mobile/tablet viewport transitions from blanking or leaving stale mobile chrome. - update viewport-mode detection to resync on resize, orientation changes, and visualViewport resize events - stabilize board scroll handling across mobile viewport changes without forcing tablet layouts back to the mobile scroll position - expand dashboard regression coverage for mobile/tablet/desktop auto-merge toggles, app-shell reflow, and viewport listener wiring Files changed: packages/dashboard/app/components/Board.tsx | 33 ++++- .../app/components/__tests__/ChatView.test.tsx | 4 +- ...-merge-toggle-blank.mobile-integration.test.tsx | 145 ++++++++++++++++++--- .../app/hooks/__tests__/useViewportMode.test.ts | 126 +++++++++++++++++- packages/dashboard/app/hooks/useViewportMode.ts | 21 ++- 5 files changed, 295 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-6047 Fusion-Task-Lineage: 5d5dcedf-6265-4672-b63e-414f3339e417 --- packages/dashboard/app/components/Board.tsx | 33 +++- .../components/__tests__/ChatView.test.tsx | 4 +- ...e-toggle-blank.mobile-integration.test.tsx | 145 +++++++++++++++--- .../hooks/__tests__/useViewportMode.test.ts | 126 ++++++++++++++- .../dashboard/app/hooks/useViewportMode.ts | 21 ++- 5 files changed, 295 insertions(+), 34 deletions(-) diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index a43b9ecbb5..cf7848455b 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -203,10 +203,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask // `overflow-anchor: none`) and only stabilize via reflow + scroll offset // normalization; do NOT reintroduce `scroll-snap-type: x mandatory`. useEffect(() => { - if (!window.matchMedia(MOBILE_MEDIA_QUERY).matches) { - return; - } - + const mobileQuery = window.matchMedia(MOBILE_MEDIA_QUERY); let rafId: number | null = null; let timeoutId: ReturnType | null = null; @@ -214,7 +211,9 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask const boardEl = boardRef.current; if (!boardEl) return; void boardEl.offsetWidth; - boardEl.scrollLeft = 0; + if (mobileQuery.matches) { + boardEl.scrollLeft = 0; + } }; const scheduleStabilization = () => { @@ -250,14 +249,38 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask scheduleStabilization(); }; + const addChangeListener = (query: MediaQueryList, listener: () => void) => { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return; + } + if (typeof query.addListener === "function") { + query.addListener(listener); + } + }; + + const removeChangeListener = (query: MediaQueryList, listener: () => void) => { + if (typeof query.removeEventListener === "function") { + query.removeEventListener("change", listener); + return; + } + if (typeof query.removeListener === "function") { + query.removeListener(listener); + } + }; + scheduleStabilization(); window.addEventListener("pageshow", handlePageShow); + window.addEventListener("resize", handleViewportResize); + addChangeListener(mobileQuery, handleViewportResize); if (typeof visualViewport?.addEventListener === "function") { visualViewport.addEventListener("resize", handleViewportResize); } return () => { window.removeEventListener("pageshow", handlePageShow); + window.removeEventListener("resize", handleViewportResize); + removeChangeListener(mobileQuery, handleViewportResize); if (typeof visualViewport?.removeEventListener === "function") { visualViewport.removeEventListener("resize", handleViewportResize); } diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 3af7e50631..aed6ed1f41 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -4315,7 +4315,9 @@ describe("ChatView mobile behavior", () => { await renderWithAct(); await waitFor(() => { - expect(mockVV.addEventListener).not.toHaveBeenCalled(); + expect(mockVV.addEventListener).toHaveBeenCalledTimes(1); + expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function)); + expect(mockVV.addEventListener).not.toHaveBeenCalledWith("scroll", expect.any(Function)); }); } finally { restoreMatchMedia.mockRestore(); 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 bf0fef3546..3c5d84583a 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 @@ -92,33 +92,73 @@ function ensureMatchMedia() { } } -function mockViewport(width: number, height = 812) { +const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; + +type ViewportSpy = ReturnType & { + setViewport: (width: number, height?: number) => void; + dispatchChange: (query: string) => void; +}; + +function mockViewport(width: number, height = 812): ViewportSpy { ensureMatchMedia(); - Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); - Object.defineProperty(window, "innerHeight", { value: height, configurable: true }); - return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ - matches: - query === MOBILE_MEDIA_QUERY - ? width <= 768 || height <= 480 - : query === "(min-width: 769px) and (max-width: 1024px)" - ? width >= 769 && width <= 1024 - : false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + let viewportWidth = width; + let viewportHeight = height; + const listeners = new Map void>>(); + + const matchesQuery = (query: string) => { + if (query === MOBILE_MEDIA_QUERY) return viewportWidth <= 768 || viewportHeight <= 480; + if (query === TABLET_MEDIA_QUERY) return viewportWidth >= 769 && viewportWidth <= 1024; + return false; + }; + + const setWindowSize = () => { + Object.defineProperty(window, "innerWidth", { value: viewportWidth, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: viewportHeight, configurable: true }); + }; + + setWindowSize(); + + const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => { + const queryListeners = listeners.get(query) ?? new Set<() => void>(); + listeners.set(query, queryListeners); + return { + get matches() { + return matchesQuery(query); + }, + media: query, + onchange: null, + addListener: vi.fn((listener: () => void) => queryListeners.add(listener)), + removeListener: vi.fn((listener: () => void) => queryListeners.delete(listener)), + addEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "change") queryListeners.add(listener); + }), + removeEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "change") queryListeners.delete(listener); + }), + dispatchEvent: vi.fn(() => true), + }; + }) as ViewportSpy; + + spy.setViewport = (nextWidth: number, nextHeight = viewportHeight) => { + viewportWidth = nextWidth; + viewportHeight = nextHeight; + setWindowSize(); + }; + spy.dispatchChange = (query: string) => { + for (const listener of [...(listeners.get(query) ?? [])]) listener(); + }; + + return spy; } -function createVisualViewport(scale = 1) { +function createVisualViewport(scale = 1, width = window.innerWidth, height = window.innerHeight) { const resizeListeners = new Set<() => void>(); return { scale, offsetTop: 0, - height: 812, + offsetLeft: 0, + width, + height, addEventListener: vi.fn((event: string, listener: () => void) => { if (event === "resize") { resizeListeners.add(listener); @@ -129,6 +169,9 @@ function createVisualViewport(scale = 1) { resizeListeners.delete(listener); } }), + setSize: (nextWidth: number, nextHeight: number) => { + Object.assign(window.visualViewport ?? {}, { width: nextWidth, height: nextHeight }); + }, dispatchResize: () => { for (const listener of [...resizeListeners]) { listener(); @@ -525,6 +568,68 @@ describe("auto-merge toggle mobile integration regression", () => { viewportSpy.mockRestore(); }); + it("reflows the App shell from mobile to tablet without leaving mobile chrome or a blank strip", async () => { + const { viewportSpy, visualViewport } = renderAppShellHarness({ + width: 375, + height: 812, + tasks: createInReviewAndWorktreeTasks(), + autoMerge: false, + }); + + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(1); + }); + + expectBoardVisible(["FN-5972", "Worktree child task"]); + expect(document.querySelector(".project-content.project-content--with-mobile-nav")).not.toBeNull(); + expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeInTheDocument(); + + act(() => { + viewportSpy.setViewport(834, 1112); + visualViewport.setSize(834, 1112); + viewportSpy.dispatchChange(MOBILE_MEDIA_QUERY); + visualViewport.dispatchResize(); + window.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(1); + }); + + expectBoardVisible(["FN-5972", "Worktree child task"]); + const projectContent = document.querySelector(".project-content"); + expect(projectContent).not.toBeNull(); + expect(projectContent).not.toHaveClass("project-content--with-mobile-nav"); + expect(projectContent).not.toHaveStyle({ width: "375px" }); + expect(screen.queryByTestId("mobile-nav-tab-tasks")).toBeNull(); + expect(screen.queryByText("Something went wrong")).toBeNull(); + + viewportSpy.mockRestore(); + }); + + it("keeps short landscape phones in mobile mode with mobile chrome", async () => { + const { viewportSpy, visualViewport } = renderAppShellHarness({ + width: 844, + height: 390, + tasks: [createTask("FN-LANDSCAPE", "in-review")], + autoMerge: false, + }); + + await act(async () => { + await Promise.resolve(); + }); + act(() => { + visualViewport.dispatchResize(); + vi.advanceTimersByTime(1); + }); + + expectBoardVisible(["FN-LANDSCAPE"]); + expect(document.querySelector(".project-content.project-content--with-mobile-nav")).not.toBeNull(); + expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeInTheDocument(); + + viewportSpy.mockRestore(); + }); + it("keeps the App-level mobile shell visible while round-tripping auto-merge", async () => { const { viewportSpy, visualViewport } = renderAppShellHarness({ width: 375, diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index 81c00718fe..157799e6fd 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -1,7 +1,68 @@ -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getViewportMode, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode"; +const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; + +type TestMediaQueryList = MediaQueryList & { + setMatches: (matches: boolean) => void; + dispatchChange: () => void; +}; + +function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) { + const listeners = new Map void>>(); + const matches = new Map([ + [MOBILE_MEDIA_QUERY, initial.mobile], + [TABLET_MEDIA_QUERY, initial.tablet], + ]); + const queries = new Map(); + + const getQuery = (query: string): TestMediaQueryList => { + const existing = queries.get(query); + if (existing) return existing; + + const queryListeners = new Set<() => void>(); + listeners.set(query, queryListeners); + const mediaQueryList = { + get matches() { + return matches.get(query) ?? false; + }, + media: query, + onchange: null, + addEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "change") queryListeners.add(listener); + }), + removeEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "change") queryListeners.delete(listener); + }), + addListener: vi.fn((listener: () => void) => queryListeners.add(listener)), + removeListener: vi.fn((listener: () => void) => queryListeners.delete(listener)), + dispatchEvent: vi.fn(() => true), + setMatches: (nextMatches: boolean) => { + matches.set(query, nextMatches); + }, + dispatchChange: () => { + for (const listener of [...queryListeners]) listener(); + }, + } as TestMediaQueryList; + queries.set(query, mediaQueryList); + return mediaQueryList; + }; + + vi.stubGlobal("matchMedia", vi.fn((query: string) => getQuery(query))); + + return { + mobileQuery: getQuery(MOBILE_MEDIA_QUERY), + tabletQuery: getQuery(TABLET_MEDIA_QUERY), + transition(next: { mobile: boolean; tablet: boolean }, dispatch: "mobile" | "tablet" | "both" = "both") { + getQuery(MOBILE_MEDIA_QUERY).setMatches(next.mobile); + getQuery(TABLET_MEDIA_QUERY).setMatches(next.tablet); + if (dispatch === "mobile" || dispatch === "both") getQuery(MOBILE_MEDIA_QUERY).dispatchChange(); + if (dispatch === "tablet" || dispatch === "both") getQuery(TABLET_MEDIA_QUERY).dispatchChange(); + }, + }; +} + describe("useViewportMode", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -28,6 +89,69 @@ describe("useViewportMode", () => { expect(renderHook(() => useViewportMode()).result.current).toBe("mobile"); }); + it("updates from mobile to tablet when the mobile media query changes", () => { + const viewport = createViewportMediaMock({ mobile: true, tablet: false }); + const { result } = renderHook(() => useViewportMode()); + + expect(result.current).toBe("mobile"); + + act(() => { + viewport.transition({ mobile: false, tablet: true }, "mobile"); + }); + + expect(result.current).toBe("tablet"); + }); + + it("updates from tablet to mobile when the tablet media query changes", () => { + const viewport = createViewportMediaMock({ mobile: false, tablet: true }); + const { result } = renderHook(() => useViewportMode()); + + expect(result.current).toBe("tablet"); + + act(() => { + viewport.transition({ mobile: true, tablet: false }, "tablet"); + }); + + expect(result.current).toBe("mobile"); + }); + + it("updates from mobile to tablet on window resize when media-query change events are missed", () => { + const viewport = createViewportMediaMock({ mobile: true, tablet: false }); + const { result } = renderHook(() => useViewportMode()); + + expect(result.current).toBe("mobile"); + + act(() => { + viewport.mobileQuery.setMatches(false); + viewport.tabletQuery.setMatches(true); + window.dispatchEvent(new Event("resize")); + }); + + expect(result.current).toBe("tablet"); + }); + + it("tracks a mobile to tablet to desktop to mobile viewport cycle", () => { + const viewport = createViewportMediaMock({ mobile: true, tablet: false }); + const { result } = renderHook(() => useViewportMode()); + + expect(result.current).toBe("mobile"); + + act(() => { + viewport.transition({ mobile: false, tablet: true }, "tablet"); + }); + expect(result.current).toBe("tablet"); + + act(() => { + viewport.transition({ mobile: false, tablet: false }, "tablet"); + }); + expect(result.current).toBe("desktop"); + + act(() => { + viewport.transition({ mobile: true, tablet: false }, "mobile"); + }); + expect(result.current).toBe("mobile"); + }); + it("supports legacy MediaQueryList listeners without runtime errors", () => { const listeners: Array<() => void> = []; const removeListener = vi.fn((listener: () => void) => { diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 593e2a7bec..ed2f81b4a4 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -25,13 +25,7 @@ export function useViewportMode(): ViewportMode { const tabletQuery = window.matchMedia("(min-width: 769px) and (max-width: 1024px)"); const updateMode = () => { - if (mobileQuery.matches) { - setMode("mobile"); - } else if (tabletQuery.matches) { - setMode("tablet"); - } else { - setMode("desktop"); - } + setMode(getViewportMode()); }; const addChangeListener = (query: MediaQueryList, listener: () => void) => { @@ -56,9 +50,22 @@ export function useViewportMode(): ViewportMode { addChangeListener(mobileQuery, updateMode); addChangeListener(tabletQuery, updateMode); + window.addEventListener("resize", updateMode); + window.addEventListener("orientationchange", updateMode); + const visualViewport = window.visualViewport; + if (typeof visualViewport?.addEventListener === "function") { + visualViewport.addEventListener("resize", updateMode); + } + updateMode(); + return () => { removeChangeListener(mobileQuery, updateMode); removeChangeListener(tabletQuery, updateMode); + window.removeEventListener("resize", updateMode); + window.removeEventListener("orientationchange", updateMode); + if (typeof visualViewport?.removeEventListener === "function") { + visualViewport.removeEventListener("resize", updateMode); + } }; }, []);