diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index e23dd59f7..38684dae5 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -95,7 +95,7 @@ async function hydrate() { ` } - // Wrap with RouterContext.Provider so next/compat/router works during hydration + // Wrap with RouterContext.Provider so next/router and next/compat/router work during hydration. const { wrapWithRouterContext } = await import("next/router"); element = wrapWithRouterContext(element); diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 33c16f638..832ce0ac0 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -341,7 +341,7 @@ export function createSSRHandler( try { await _alsRegistration; - // Set SSR context for the router shim so useRouter() returns + // Set SSR context for the Pages Router provider so useRouter() returns // the correct URL and params during server-side rendering. const routerShim = await importModule(runner, "next/router"); if (typeof routerShim.setSSRContext === "function") { @@ -799,7 +799,7 @@ export function createSSRHandler( let element: React.ReactElement; // wrapWithRouterContext wraps the element in RouterContext.Provider so that - // next/compat/router's useRouter() returns the real router. + // next/router and next/compat/router return the real Pages Router. const wrapWithRouterContext = routerShim.wrapWithRouterContext; if (AppComponent) { diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 937035313..ef7f82826 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -8,10 +8,11 @@ import { useState, useEffect, - useCallback, useMemo, + useContext, createElement, type ReactElement, + type ReactNode, type ComponentType, } from "react"; import { RouterContext } from "./internal/router-context.js"; @@ -529,7 +530,7 @@ async function navigateClient(url: string): Promise { element = React.createElement(PageComponent, pageProps); } - // Wrap with RouterContext.Provider so next/compat/router works + // Wrap with RouterContext.Provider so next/router and next/compat/router work. element = wrapWithRouterContext(element); // Commit __NEXT_DATA__ only after all assertStillCurrent() checks have passed, @@ -585,9 +586,8 @@ async function runNavigateClient( /** * Build the full router value object from the current pathname, query, asPath, - * and a set of navigation methods. Shared by useRouter() (which passes - * hook-derived callbacks) and wrapWithRouterContext() (which passes the Router - * singleton methods) so the shape stays in sync. + * and a set of navigation methods. Shared by the Pages Router context provider + * and tests so the public router shape stays in sync. */ function buildRouterValue( pathname: string, @@ -736,12 +736,28 @@ async function prefetchUrl(url: string): Promise { /** * useRouter hook - Pages Router compatible. + * + * Ported from Next.js: packages/next/src/client/router.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/router.ts */ export function useRouter(): NextRouter { + const router = useContext(RouterContext); + if (!router) { + throw new Error( + "NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted", + ); + } + + return router; +} + +function PagesRouterProvider({ children }: { children: ReactNode }): ReactElement { const [{ pathname, query, asPath }, setState] = useState(getPathnameAndQuery); // Popstate is handled by the module-level listener below so beforePopState() - // is consistently enforced even when multiple components mount useRouter(). + // is consistently enforced regardless of hook consumers. Keep URL snapshot + // subscriptions at the provider boundary so many useRouter() calls share one + // router state and one vinext:navigate listener. useEffect(() => { const onNavigate = ((_e: CustomEvent) => { setState(getPathnameAndQuery()); @@ -750,44 +766,20 @@ export function useRouter(): NextRouter { return () => window.removeEventListener("vinext:navigate", onNavigate); }, []); - const push = useCallback( - (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise => - performNavigation(url, as, options, "push", () => setState(getPathnameAndQuery())), - [], - ); - - const replace = useCallback( - (url: string | UrlObject, as?: string, options?: TransitionOptions): Promise => - performNavigation(url, as, options, "replace", () => setState(getPathnameAndQuery())), - [], - ); - - const back = useCallback(() => { - window.history.back(); - }, []); - - const reload = useCallback(() => { - window.location.reload(); - }, []); - - const prefetch = useCallback(prefetchUrl, []); - const router = useMemo( (): NextRouter => buildRouterValue(pathname, query, asPath, { - push, - replace, - back, - reload, - prefetch, - beforePopState: (cb: BeforePopStateCallback) => { - _beforePopStateCb = cb; - }, + push: Router.push, + replace: Router.replace, + back: Router.back, + reload: Router.reload, + prefetch: Router.prefetch, + beforePopState: Router.beforePopState, }), - [pathname, query, asPath, push, replace, back, reload, prefetch], + [pathname, query, asPath], ); - return router; + return createElement(RouterContext.Provider, { value: router }, children); } // beforePopState callback: called before handling browser back/forward. @@ -861,24 +853,12 @@ if (typeof window !== "undefined") { * Wrap a React element in a RouterContext.Provider so that * next/compat/router's useRouter() returns the real Pages Router value. * - * This is a plain function, NOT a React component — it builds the router - * value object directly from the current SSR context (server) or - * window.location + Router singleton (client), avoiding duplicate state - * that a hook-based component would create. + * The provider owns the reactive Pages Router snapshot so next/router and + * next/compat/router consumers share one context value instead of each hook + * installing its own global URL-change listener. */ export function wrapWithRouterContext(element: ReactElement): ReactElement { - const { pathname, query, asPath } = getPathnameAndQuery(); - - const routerValue = buildRouterValue(pathname, query, asPath, { - push: Router.push, - replace: Router.replace, - back: Router.back, - reload: Router.reload, - prefetch: Router.prefetch, - beforePopState: Router.beforePopState, - }); - - return createElement(RouterContext.Provider, { value: routerValue }, element) as ReactElement; + return createElement(PagesRouterProvider, null, element); } /** diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 87eeadcc9..1f5f8786f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -6,6 +6,7 @@ import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/ro import { isValidModulePath } from "../packages/vinext/src/client/validate-module-path.js"; import vinext from "../packages/vinext/src/index.js"; import type { Plugin } from "vite-plus"; +import type { NextRouter } from "../packages/vinext/src/shims/router.js"; import type { CacheHandler, CacheHandlerValue, @@ -1518,6 +1519,31 @@ describe("window.next debug global", () => { describe("next/router withRouter HOC", () => { let previousWindow: unknown; + function createTestRouter(overrides: Partial = {}): NextRouter { + const router: NextRouter = { + pathname: "/provided", + route: "/provided", + query: {}, + asPath: "/provided", + basePath: "", + isReady: true, + isPreview: false, + isFallback: false, + push: vi.fn(async () => true), + replace: vi.fn(async () => true), + back: vi.fn(), + reload: vi.fn(), + prefetch: vi.fn(async () => {}), + beforePopState: vi.fn(), + events: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, + }; + return { ...router, ...overrides }; + } + beforeEach(() => { previousWindow = (globalThis as any).window; (globalThis as any).window = { @@ -1538,6 +1564,107 @@ describe("next/router withRouter HOC", () => { expect(typeof withRouter).toBe("function"); }); + it("next/router useRouter reads the mounted RouterContext value", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const { useRouter } = await import("../packages/vinext/src/shims/router.js"); + const { RouterContext } = + await import("../packages/vinext/src/shims/internal/router-context.js"); + + const providedRouter = createTestRouter({ pathname: "/from-context" }); + let captured: NextRouter | null = null; + + function Probe() { + captured = useRouter(); + return React.createElement("span", null, "ok"); + } + + renderToStaticMarkup( + React.createElement( + RouterContext.Provider, + { value: providedRouter }, + React.createElement(Probe), + ), + ); + + expect(captured).toBe(providedRouter); + }); + + it("next/router useRouter throws when the Pages Router context is not mounted", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const { useRouter } = await import("../packages/vinext/src/shims/router.js"); + + function Probe() { + useRouter(); + return React.createElement("span", null, "ok"); + } + + expect(() => renderToStaticMarkup(React.createElement(Probe))).toThrow( + "NextRouter was not mounted", + ); + }); + + it("next/router useRouter does not subscribe once per hook call", async () => { + const previousWindowForMock = (globalThis as any).window; + const addEventListener = vi.fn(); + const providedRouter = createTestRouter(); + + (globalThis as any).window = { + location: { pathname: "/", search: "", hash: "", href: "http://localhost/" }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener, + removeEventListener: vi.fn(), + __NEXT_DATA__: { page: "/", query: {}, isFallback: false }, + }; + + vi.resetModules(); + vi.doMock("react", () => { + const react = { + createContext(defaultValue: unknown) { + return { Provider: "Provider", Consumer: "Consumer", defaultValue }; + }, + createElement(type: unknown, props: unknown, ...children: unknown[]) { + return { type, props, children }; + }, + useContext() { + return providedRouter; + }, + useState(initialValue: unknown) { + return [typeof initialValue === "function" ? initialValue() : initialValue, vi.fn()]; + }, + useEffect(effect: () => void | (() => void)) { + effect(); + }, + useMemo(factory: () => unknown) { + return factory(); + }, + }; + return { ...react, default: react }; + }); + + try { + const { useRouter } = await import("../packages/vinext/src/shims/router.js"); + + expect(useRouter()).toBe(providedRouter); + expect(useRouter()).toBe(providedRouter); + expect(useRouter()).toBe(providedRouter); + + const navigateListenerCalls = addEventListener.mock.calls.filter( + (call) => call[0] === "vinext:navigate", + ); + expect(navigateListenerCalls).toHaveLength(0); + } finally { + vi.doUnmock("react"); + if (previousWindowForMock === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindowForMock; + } + vi.resetModules(); + } + }); + it("withRouter wraps a component and forwards static props", async () => { const { withRouter } = await import("../packages/vinext/src/shims/router.js"); const React = await import("react"); @@ -1554,7 +1681,8 @@ describe("next/router withRouter HOC", () => { }); it("withRouter injects a router prop into the wrapped component", async () => { - const { withRouter } = await import("../packages/vinext/src/shims/router.js"); + const { withRouter, wrapWithRouterContext } = + await import("../packages/vinext/src/shims/router.js"); const React = await import("react"); const { renderToStaticMarkup } = await import("react-dom/server"); @@ -1567,7 +1695,9 @@ describe("next/router withRouter HOC", () => { }; const Wrapped = withRouter(Inner); - const html = renderToStaticMarkup(React.createElement(Wrapped as any, { label: "hi" })); + const html = renderToStaticMarkup( + wrapWithRouterContext(React.createElement(Wrapped, { label: "hi" })), + ); expect(html).toBe("ok"); expect(receivedLabel).toBe("hi"); // router must be the NextRouter shape (push/replace/back/...). @@ -1587,7 +1717,8 @@ describe("next/router withRouter HOC", () => { // so a user-passed `router` prop overrides the HOC-injected one. If the // spread order is ever inverted in the shim, this test fails. it("user-passed router prop overrides the HOC-injected router (Next.js spread order)", async () => { - const { withRouter } = await import("../packages/vinext/src/shims/router.js"); + const { withRouter, wrapWithRouterContext } = + await import("../packages/vinext/src/shims/router.js"); const React = await import("react"); const { renderToStaticMarkup } = await import("react-dom/server"); @@ -1599,7 +1730,10 @@ describe("next/router withRouter HOC", () => { const Wrapped = withRouter(Inner); const userRouter = { sentinel: "user-provided" }; - renderToStaticMarkup(React.createElement(Wrapped as any, { router: userRouter })); + const WrappedWithOverride = Wrapped as React.ComponentType<{ router: unknown }>; + renderToStaticMarkup( + wrapWithRouterContext(React.createElement(WrappedWithOverride, { router: userRouter })), + ); // Last spread wins: the user-passed router survives. expect(receivedRouter).toBe(userRouter); });