import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** * Regression tests for the `next/navigation` shim fallback (unregistered host). * * The guarantee under test: an unregistered `useRouter().push/replace` must * perform a same-origin SPA navigation via the History API and NEVER hard-load * the document — a `?search=` write from `useApiParams` used to hard-reload via * `window.location.replace`. The registered path must delegate to the host * router untouched. * * `impl` is module-level singleton state, so each test re-imports the module * fresh via `vi.resetModules()` to isolate registration state. We also stub * `window.location` with a spy object so any hard navigation (assign/replace/ * reload) is observable — jsdom leaves those methods unimplemented anyway. */ const ORIGIN = 'http://localhost:3000' async function freshModule() { vi.resetModules() return import('../next-navigation') } let assignSpy: ReturnType let replaceSpy: ReturnType let reloadSpy: ReturnType let pushStateSpy: ReturnType let replaceStateSpy: ReturnType let originalLocation: Location beforeEach(() => { originalLocation = window.location assignSpy = vi.fn() replaceSpy = vi.fn() reloadSpy = vi.fn() Object.defineProperty(window, 'location', { configurable: true, value: { href: `${ORIGIN}/`, origin: ORIGIN, pathname: '/', search: '', assign: assignSpy, replace: replaceSpy, reload: reloadSpy, }, }) // Real jsdom history — spy without changing behavior so we can assert which // History API the fallback used (push vs replace encodes the nav semantics). pushStateSpy = vi.spyOn(window.history, 'pushState') replaceStateSpy = vi.spyOn(window.history, 'replaceState') }) afterEach(() => { Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }) vi.restoreAllMocks() }) describe('fallback useRouter (unregistered host)', () => { it('replace() writes the URL via history.replaceState — never a hard navigation', async () => { const { useRouter } = await freshModule() const popstate = vi.fn() window.addEventListener('popstate', popstate) useRouter().replace('?search=laptop') expect(replaceStateSpy).toHaveBeenCalledWith(null, '', `${ORIGIN}/?search=laptop`) expect(pushStateSpy).not.toHaveBeenCalled() // No full-document navigation of any kind — this is the reported bug. expect(assignSpy).not.toHaveBeenCalled() expect(replaceSpy).not.toHaveBeenCalled() expect(reloadSpy).not.toHaveBeenCalled() // Subscribers (usePathname/useSearchParams) get a re-render signal. expect(popstate).toHaveBeenCalledTimes(1) window.removeEventListener('popstate', popstate) }) it('push() uses history.pushState (new entry) — never a hard navigation', async () => { const { useRouter } = await freshModule() useRouter().push('/scripts?tab=list') expect(pushStateSpy).toHaveBeenCalledWith(null, '', `${ORIGIN}/scripts?tab=list`) expect(replaceStateSpy).not.toHaveBeenCalled() expect(assignSpy).not.toHaveBeenCalled() expect(reloadSpy).not.toHaveBeenCalled() }) it('falls back to a real navigation for cross-origin targets, honoring replace semantics', async () => { const { useRouter } = await freshModule() useRouter().replace('https://example.com/evil') // replace() must use location.replace (no back-button entry), not assign. expect(replaceSpy).toHaveBeenCalledWith('https://example.com/evil') expect(assignSpy).not.toHaveBeenCalled() expect(replaceStateSpy).not.toHaveBeenCalled() expect(pushStateSpy).not.toHaveBeenCalled() }) it('uses location.assign for a cross-origin push (new history entry)', async () => { const { useRouter } = await freshModule() useRouter().push('https://example.com/evil') expect(assignSpy).toHaveBeenCalledWith('https://example.com/evil') expect(replaceSpy).not.toHaveBeenCalled() expect(pushStateSpy).not.toHaveBeenCalled() }) it('falls back to a real navigation for a malformed href (push → assign)', async () => { const { useRouter } = await freshModule() useRouter().push('http://[invalid') expect(assignSpy).toHaveBeenCalledWith('http://[invalid') expect(replaceSpy).not.toHaveBeenCalled() expect(pushStateSpy).not.toHaveBeenCalled() }) it('falls back to a real navigation for a malformed href (replace → replace)', async () => { const { useRouter } = await freshModule() useRouter().replace('http://[invalid') expect(replaceSpy).toHaveBeenCalledWith('http://[invalid') expect(assignSpy).not.toHaveBeenCalled() expect(replaceStateSpy).not.toHaveBeenCalled() }) it('back/forward remain history-based (unchanged)', async () => { const { useRouter } = await freshModule() const backSpy = vi.spyOn(window.history, 'back').mockImplementation(() => {}) const forwardSpy = vi.spyOn(window.history, 'forward').mockImplementation(() => {}) useRouter().back() useRouter().forward() expect(backSpy).toHaveBeenCalledTimes(1) expect(forwardSpy).toHaveBeenCalledTimes(1) }) }) describe('registered useRouter (Next host)', () => { it('delegates to the host router and never touches window.location or history', async () => { const { useRouter, registerNavigation } = await freshModule() const hostRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn(), } registerNavigation({ useRouter: () => hostRouter }) const r = useRouter() r.replace('?search=laptop', { scroll: false }) r.push('/scripts') expect(hostRouter.replace).toHaveBeenCalledWith('?search=laptop', { scroll: false }) expect(hostRouter.push).toHaveBeenCalledWith('/scripts') // The fallback path is completely bypassed — no navigation side effects. expect(assignSpy).not.toHaveBeenCalled() expect(replaceSpy).not.toHaveBeenCalled() expect(reloadSpy).not.toHaveBeenCalled() expect(pushStateSpy).not.toHaveBeenCalled() expect(replaceStateSpy).not.toHaveBeenCalled() }) })