import { render, screen, act } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useIsMobile, VIEWPORT_COOKIE_NAME } from "../use-mobile"; // The provider lives in `contexts/` (not `utils/`) because the server layouts // render it directly and only `dist/contexts/*` gets tsup's "use client" banner. import { ViewportProvider } from "../../contexts/ViewportContext"; function Probe() { const isMobile = useIsMobile(); return {isMobile ? "mobile" : "desktop"}; } function setWidth(width: number) { Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: width }); } beforeEach(() => { setWidth(1280); document.cookie = `${VIEWPORT_COOKIE_NAME}=; path=/; max-age=0`; vi.mocked(window.matchMedia).mockClear(); }); describe("server-graph safety", () => { /** * Regression guard. This module is reachable from the RSC/Node graph: * apps/web instrumentation.ts -> jsonapi.config -> Bootstrapper -> * @carlonicora/nextjs-jsonapi/core -> utils barrel -> use-mobile. * Creating the context at module scope crashed the dev server on boot * ("createContext is not a function"), because React resolves to the * vendored RSC build there. Importing must stay side-effect free. */ it("does not create the context merely by being imported", async () => { const key = Symbol.for("nextjs-jsonapi:viewportContext"); const store = globalThis as unknown as Record; const previous = store[key]; delete store[key]; try { vi.resetModules(); await import("../use-mobile"); expect(store[key]).toBeUndefined(); } finally { store[key] = previous; vi.resetModules(); } }); it("creates the context on first call and returns a stable identity after", async () => { const key = Symbol.for("nextjs-jsonapi:viewportContext"); const store = globalThis as unknown as Record; const previous = store[key]; delete store[key]; try { vi.resetModules(); const { getViewportContext } = await import("../use-mobile"); const first = getViewportContext(); expect(first).toBeDefined(); expect(getViewportContext()).toBe(first); } finally { store[key] = previous; vi.resetModules(); } }); }); describe("useIsMobile with a ViewportProvider", () => { it("returns the server seed on the first render, before any measurement", () => { setWidth(1280); const seen: string[] = []; function Recorder() { const isMobile = useIsMobile(); seen.push(isMobile ? "mobile" : "desktop"); return null; } render( , ); expect(seen[0]).toBe("mobile"); }); it("corrects a wrong seed after mounting and persists the measurement", () => { setWidth(1280); render( , ); expect(screen.getByTestId("probe").textContent).toBe("desktop"); expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=0`); }); it("keeps a correct seed and persists it", () => { setWidth(500); render( , ); expect(screen.getByTestId("probe").textContent).toBe("mobile"); expect(document.cookie).toContain(`${VIEWPORT_COOKIE_NAME}=1`); }); it("follows a viewport change through the matchMedia listener", () => { setWidth(1280); render( , ); expect(screen.getByTestId("probe").textContent).toBe("desktop"); const mql = vi.mocked(window.matchMedia).mock.results[0].value; const handler = mql.addEventListener.mock.calls[0][1] as () => void; setWidth(500); act(() => handler()); expect(screen.getByTestId("probe").textContent).toBe("mobile"); }); }); describe("useIsMobile without a provider (legacy fallback)", () => { it("measures the viewport itself and does not write a cookie", () => { setWidth(500); render(); expect(screen.getByTestId("probe").textContent).toBe("mobile"); expect(document.cookie).not.toContain(`${VIEWPORT_COOKIE_NAME}=`); }); it("reports desktop for a wide viewport", () => { setWidth(1280); render(); expect(screen.getByTestId("probe").textContent).toBe("desktop"); }); });