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
This commit is contained in:
gsxdsm
2026-06-09 02:00:29 -07:00
parent 13c6d96fe1
commit a566f498d8
5 changed files with 295 additions and 34 deletions

View File

@@ -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<typeof setTimeout> | 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);
}

View File

@@ -4315,7 +4315,9 @@ describe("ChatView mobile behavior", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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();

View File

@@ -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<typeof vi.spyOn> & {
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<string, Set<() => 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,

View File

@@ -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<string, Set<() => void>>();
const matches = new Map<string, boolean>([
[MOBILE_MEDIA_QUERY, initial.mobile],
[TABLET_MEDIA_QUERY, initial.tablet],
]);
const queries = new Map<string, TestMediaQueryList>();
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) => {

View File

@@ -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);
}
};
}, []);