Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down
88 changes: 34 additions & 54 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -529,7 +530,7 @@ async function navigateClient(url: string): Promise<void> {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -736,12 +736,28 @@ async function prefetchUrl(url: string): Promise<void> {

/**
* 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the useEffect on line 670 unconditionally references window, which would throw if this component somehow rendered on the server outside React's renderer (e.g., if someone called the effect directly in a test). In practice this is fine because React skips useEffect during SSR, so the callback never runs. But a typeof window !== "undefined" guard inside the effect would make it defensive.

Not blocking — useEffect has been a server no-op since React 16 and that contract is stable.

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());
Expand All @@ -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<boolean> =>
performNavigation(url, as, options, "push", () => setState(getPathnameAndQuery())),
[],
);

const replace = useCallback(
(url: string | UrlObject, as?: string, options?: TransitionOptions): Promise<boolean> =>
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.
Expand Down Expand Up @@ -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);
}

/**
Expand Down
142 changes: 138 additions & 4 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1518,6 +1519,31 @@ describe("window.next debug global", () => {
describe("next/router withRouter HOC", () => {
let previousWindow: unknown;

function createTestRouter(overrides: Partial<NextRouter> = {}): 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 = {
Expand All @@ -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", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this React mock covers exactly the hooks PagesRouterProvider and useRouter use today (createContext, createElement, useContext, useState, useEffect, useMemo). If a future change adds a hook the mock doesn't cover, this test will fail with an opaque undefined is not a function error. Consider adding a brief comment listing the mocked hooks and why, so maintainers know the surface is intentionally minimal.

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");
Expand All @@ -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");

Expand All @@ -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("<span>ok</span>");
expect(receivedLabel).toBe("hi");
// router must be the NextRouter shape (push/replace/back/...).
Expand All @@ -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");

Expand All @@ -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);
});
Expand Down
Loading