Skip to content

Commit 2609805

Browse files
author
root
committed
refactor: centralize web viewport hook
1 parent 5990e92 commit 2609805

5 files changed

Lines changed: 171 additions & 127 deletions

File tree

packages/web/src/app.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe("App shell selection", () => {
6969
expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument();
7070
});
7171

72-
it("renders DesktopShell on wide coarse-pointer devices", () => {
72+
it("renders MobileShell on wide coarse-pointer devices", () => {
7373
setMatchMediaMock((query) => query.includes("pointer: coarse"));
7474
const store = createStore();
7575
store.set(connectionStatusAtom, "connected");
@@ -82,7 +82,7 @@ describe("App shell selection", () => {
8282
</Provider>
8383
);
8484

85-
expect(screen.getByTestId("desktop-shell")).toBeInTheDocument();
86-
expect(screen.queryByTestId("mobile-shell")).not.toBeInTheDocument();
85+
expect(screen.getByTestId("mobile-shell")).toBeInTheDocument();
86+
expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument();
8787
});
8888
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { useViewport } from "./use-viewport";
4+
5+
type MediaListener = () => void;
6+
7+
const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)";
8+
9+
const createMatchMediaHarness = (initialMatches: boolean) => {
10+
let matches = initialMatches;
11+
const listeners = new Set<MediaListener>();
12+
13+
const matchMedia = vi.fn((query: string) => ({
14+
matches,
15+
media: query,
16+
addEventListener: (_event: string, listener: MediaListener) => {
17+
listeners.add(listener);
18+
},
19+
removeEventListener: (_event: string, listener: MediaListener) => {
20+
listeners.delete(listener);
21+
},
22+
addListener: (listener: MediaListener) => {
23+
listeners.add(listener);
24+
},
25+
removeListener: (listener: MediaListener) => {
26+
listeners.delete(listener);
27+
},
28+
dispatchEvent: () => true,
29+
}));
30+
31+
return {
32+
matchMedia,
33+
setMatches(nextMatches: boolean) {
34+
matches = nextMatches;
35+
for (const listener of listeners) {
36+
listener();
37+
}
38+
},
39+
};
40+
};
41+
42+
const Probe = () => {
43+
return <div data-testid="viewport">{useViewport()}</div>;
44+
};
45+
46+
describe("useViewport", () => {
47+
let originalMatchMedia: typeof window.matchMedia;
48+
49+
beforeEach(() => {
50+
originalMatchMedia = window.matchMedia;
51+
});
52+
53+
afterEach(() => {
54+
window.matchMedia = originalMatchMedia;
55+
});
56+
57+
it("returns desktop when the combined viewport query does not match", () => {
58+
const harness = createMatchMediaHarness(false);
59+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
60+
61+
render(<Probe />);
62+
63+
expect(window.matchMedia).toHaveBeenCalledWith(VIEWPORT_QUERY);
64+
expect(screen.getByTestId("viewport")).toHaveTextContent("desktop");
65+
});
66+
67+
it("returns mobile when the combined viewport query matches", () => {
68+
const harness = createMatchMediaHarness(true);
69+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
70+
71+
render(<Probe />);
72+
73+
expect(screen.getByTestId("viewport")).toHaveTextContent("mobile");
74+
});
75+
76+
it("updates when the media query match changes", () => {
77+
const harness = createMatchMediaHarness(false);
78+
window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia;
79+
80+
render(<Probe />);
81+
expect(screen.getByTestId("viewport")).toHaveTextContent("desktop");
82+
83+
act(() => {
84+
harness.setMatches(true);
85+
});
86+
87+
expect(screen.getByTestId("viewport")).toHaveTextContent("mobile");
88+
});
89+
90+
it("cleans up listeners on unmount", () => {
91+
const removeEventListener = vi.fn();
92+
const listeners = new Set<MediaListener>();
93+
94+
window.matchMedia = vi.fn((query: string) => ({
95+
matches: false,
96+
media: query,
97+
addEventListener: (_event: string, listener: MediaListener) => {
98+
listeners.add(listener);
99+
},
100+
removeEventListener,
101+
addListener: (listener: MediaListener) => {
102+
listeners.add(listener);
103+
},
104+
removeListener: vi.fn(),
105+
dispatchEvent: () => true,
106+
})) as unknown as typeof window.matchMedia;
107+
108+
const view = render(<Probe />);
109+
view.unmount();
110+
111+
expect(removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
112+
});
113+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useSyncExternalStore } from "react";
2+
3+
export type Viewport = "mobile" | "desktop";
4+
5+
const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)";
6+
7+
const subscribe = (onStoreChange: () => void) => {
8+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
9+
return () => undefined;
10+
}
11+
12+
const mediaQueryList = window.matchMedia(VIEWPORT_QUERY);
13+
const supportsEventListener = typeof mediaQueryList.addEventListener === "function";
14+
15+
if (supportsEventListener) {
16+
mediaQueryList.addEventListener("change", onStoreChange);
17+
return () => {
18+
mediaQueryList.removeEventListener("change", onStoreChange);
19+
};
20+
}
21+
22+
mediaQueryList.addListener(onStoreChange);
23+
return () => {
24+
mediaQueryList.removeListener(onStoreChange);
25+
};
26+
};
27+
28+
const getSnapshot = (): Viewport => {
29+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
30+
return "desktop";
31+
}
32+
33+
return window.matchMedia(VIEWPORT_QUERY).matches ? "mobile" : "desktop";
34+
};
35+
36+
const getServerSnapshot = (): Viewport => "desktop";
37+
38+
export function useViewport(): Viewport {
39+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
40+
}
Lines changed: 13 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,10 @@
1-
import { act, renderHook } from "@testing-library/react";
1+
import { renderHook } from "@testing-library/react";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { useViewport } from "./use-viewport";
44

5-
type MQListener = (event: { matches: boolean }) => void;
5+
const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)";
66

7-
interface MockMediaQueryList {
8-
matches: boolean;
9-
media: string;
10-
addEventListener: (type: "change", listener: MQListener) => void;
11-
removeEventListener: (type: "change", listener: MQListener) => void;
12-
trigger: (matches: boolean) => void;
13-
}
14-
15-
function createMatchMediaMock(initialMatches: (query: string) => boolean) {
16-
const lists = new Map<string, MockMediaQueryList>();
17-
18-
const matchMedia = vi.fn((query: string) => {
19-
if (lists.has(query)) {
20-
return lists.get(query)!;
21-
}
22-
23-
const listeners = new Set<MQListener>();
24-
const list: MockMediaQueryList = {
25-
matches: initialMatches(query),
26-
media: query,
27-
addEventListener: (_type, listener) => listeners.add(listener),
28-
removeEventListener: (_type, listener) => listeners.delete(listener),
29-
trigger: (matches: boolean) => {
30-
list.matches = matches;
31-
for (const listener of listeners) {
32-
listener({ matches });
33-
}
34-
},
35-
};
36-
37-
lists.set(query, list);
38-
return list;
39-
});
40-
41-
return { lists, matchMedia };
42-
}
43-
44-
describe("useViewport", () => {
7+
describe("useViewport compatibility re-export", () => {
458
let originalMatchMedia: typeof window.matchMedia;
469

4710
beforeEach(() => {
@@ -52,58 +15,21 @@ describe("useViewport", () => {
5215
window.matchMedia = originalMatchMedia;
5316
});
5417

55-
it('returns "desktop" when viewport is wide and pointer is fine', () => {
56-
const { matchMedia } = createMatchMediaMock(() => false);
57-
window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
58-
59-
const { result } = renderHook(() => useViewport());
60-
61-
expect(result.current).toBe("desktop");
62-
});
63-
64-
it('returns "mobile" when viewport is narrow', () => {
65-
const { matchMedia } = createMatchMediaMock((query) => query.includes("max-width: 899px"));
66-
window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
67-
68-
const { result } = renderHook(() => useViewport());
69-
70-
expect(result.current).toBe("mobile");
71-
});
72-
73-
it('returns "desktop" when pointer is coarse but viewport is wide', () => {
74-
const { matchMedia } = createMatchMediaMock((query) => query.includes("pointer: coarse"));
75-
window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
76-
77-
const { result } = renderHook(() => useViewport());
78-
79-
expect(result.current).toBe("desktop");
80-
});
18+
it("keeps wide coarse-pointer devices on the mobile branch through the legacy import path", () => {
19+
const matchMedia = vi.fn((query: string) => ({
20+
matches: query === VIEWPORT_QUERY,
21+
media: query,
22+
addEventListener: vi.fn(),
23+
removeEventListener: vi.fn(),
24+
addListener: vi.fn(),
25+
removeListener: vi.fn(),
26+
}));
8127

82-
it("updates reactively when the viewport query changes", () => {
83-
const { lists, matchMedia } = createMatchMediaMock(() => false);
8428
window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
8529

8630
const { result } = renderHook(() => useViewport());
8731

88-
expect(result.current).toBe("desktop");
89-
90-
act(() => {
91-
lists.get("(max-width: 899px)")!.trigger(true);
92-
});
93-
32+
expect(matchMedia).toHaveBeenCalledWith(VIEWPORT_QUERY);
9433
expect(result.current).toBe("mobile");
9534
});
96-
97-
it("cleans up listeners on unmount", () => {
98-
const { lists, matchMedia } = createMatchMediaMock(() => false);
99-
window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
100-
101-
const { unmount } = renderHook(() => useViewport());
102-
const widthList = lists.get("(max-width: 899px)")!;
103-
const widthRemove = vi.spyOn(widthList, "removeEventListener");
104-
105-
unmount();
106-
107-
expect(widthRemove).toHaveBeenCalledWith("change", expect.any(Function));
108-
});
10935
});
Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,2 @@
1-
import { useEffect, useState } from "react";
2-
3-
export type Viewport = "mobile" | "desktop";
4-
5-
const WIDTH_QUERY = "(max-width: 899px)";
6-
7-
function computeViewport(): Viewport {
8-
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
9-
return "desktop";
10-
}
11-
12-
return window.matchMedia(WIDTH_QUERY).matches ? "mobile" : "desktop";
13-
}
14-
15-
export function useViewport(): Viewport {
16-
const [viewport, setViewport] = useState<Viewport>(computeViewport);
17-
18-
useEffect(() => {
19-
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
20-
return;
21-
}
22-
23-
const widthList = window.matchMedia(WIDTH_QUERY);
24-
const handleChange = () => {
25-
setViewport(computeViewport());
26-
};
27-
28-
widthList.addEventListener("change", handleChange);
29-
handleChange();
30-
31-
return () => {
32-
widthList.removeEventListener("change", handleChange);
33-
};
34-
}, []);
35-
36-
return viewport;
37-
}
1+
export { useViewport } from "../components/ui/_internal/use-viewport";
2+
export type { Viewport } from "../components/ui/_internal/use-viewport";

0 commit comments

Comments
 (0)