Files
fusion/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts
gsxdsm a566f498d8 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
2026-06-09 02:00:29 -07:00

180 lines
5.9 KiB
TypeScript

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();
});
it("treats short landscape phones as mobile", () => {
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches:
query === MOBILE_MEDIA_QUERY
? true
: query === "(min-width: 769px) and (max-width: 1024px)"
? false
: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
expect(getViewportMode()).toBe("mobile");
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) => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === MOBILE_MEDIA_QUERY,
media: query,
onchange: null,
addListener: (listener: () => void) => listeners.push(listener),
removeListener,
})),
);
renderHook(() => useViewportMode());
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});