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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user