fix(router): share pages useRouter state through context#1271
fix(router): share pages useRouter state through context#1271NathanDrake2406 wants to merge 1 commit into
Conversation
Pages Router useRouter previously owned a hook-local router snapshot and subscribed each hook call to vinext:navigate. That diverged from Next.js RouterContext semantics and made mounted hook count scale global listeners. Move the reactive snapshot into the Pages Router provider, make next/router consume RouterContext like Next.js, and keep singleton navigation methods as the shared command surface. Add regression coverage for context reads, missing context errors, and per-hook listener scaling.
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Clean refactor that aligns next/router with Next.js context semantics. The core change — making useRouter() consume RouterContext instead of managing hook-local state — is correct and well-motivated. The PagesRouterProvider lifts the reactive URL subscription to the provider boundary, eliminating O(n) listeners.
SSR path is safe: useState initializes from getPathnameAndQuery() which reads SSR context on the server, useEffect is a no-op during SSR (React skips effects during renderToString/renderToStaticMarkup), and useMemo computes the router value synchronously. All wrapWithRouterContext call sites (dev-server, prod-server entry, pages-client-entry, and the navigateClient function) continue to work because wrapWithRouterContext still wraps in the same context provider, just via a component now.
The deletion of the duplicated push/replace/back/reload/prefetch useCallback wrappers is a big win — these were exact copies of the Router singleton methods, and the hook-local setState calls they contained were redundant with the vinext:navigate event that the singleton methods already dispatch. Good cleanup.
Two minor observations below, neither blocking.
| return router; | ||
| } | ||
|
|
||
| function PagesRouterProvider({ children }: { children: ReactNode }): ReactElement { |
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| vi.resetModules(); | ||
| vi.doMock("react", () => { |
There was a problem hiding this comment.
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.
|
Review posted and approved. Here's a summary of the review: Approved with two non-blocking observations. Key findings:
|
Overview
useRouter()with Next.js context semantics and avoid per-hook URL subscriptions.wrapWithRouterContext()'s provider and makenext/routerconsumeRouterContext.packages/vinext/src/shims/router.ts,tests/shims.test.tsnext/routerandnext/compat/routershare one provider-backed router value during hydration, SSR, and client navigation.Why
Pages Router correctness depends on a single mounted router context being the source of truth for route fields and navigation methods. Next.js implements
next/routerby readingRouterContext; vinext already mounted that context, butnext/routerbypassed it and created hook-local state plus avinext:navigatelistener per hook call.next/routeruseRouter()reads the mounted Pages Router context and throws when it is absent.useRouter()now consumesRouterContextand uses the Next.js not-mounted error.wrapWithRouterContext()now returns a provider component that tracks the URL once.vinext:navigateis subscribed at the provider boundary, not in each hook invocation.What changed
next/routeruseRouter()vinext:navigatelistener.useRouter()outside Pages Router contextNextRouter was not mounted, matching Next.js.next/compat/routerRouterContext, butnext/routerused a separate path.Maintainer review path
packages/vinext/src/shims/router.tsuseRouter()for Next.js parity.PagesRouterProviderownership ofvinext:navigatesubscription and router value construction.Routersingleton methods to confirm command behavior remains unchanged.tests/shims.test.tspackages/vinext/src/entries/pages-client-entry.tsandpackages/vinext/src/server/dev-server.tsnext/routerandnext/compat/routerare covered by the wrapper.Validation
vp checkvp test run tests/shims.test.ts tests/pages-router.test.ts -t "next/router withRouter HOC|Pages Router router helpers|next/compat/router"PLAYWRIGHT_PROJECT=pages-router pnpm run test:e2e -- tests/e2e/pages-router/navigation.spec.ts tests/e2e/pages-router/shallow-routing.spec.tstests/entry-templates.test.tsand repository checks.Risk / compatibility
next/routernow throws outside a mounted Pages Router context, matching Next.js. This may surface invalid unit-test setup that vinext previously tolerated.next/compat/routerbehavior remains nullable outside context and provider-backed inside Pages Router.Non-goals
Routerclass implementation.References
next/routeruseRouter()useRouter()readingRouterContextand throwing when it is not mounted.RouterContext.ProviderRouterContext.Provider.RouterContextnext/routerandnext/compat/router.next/compat/routerRouterContext, returning null outside context.NextRouter was not mounteddocsnext/routeris used outside a mounted Pages Router context.useRouterdocsuseRouter()andwithRouter().