Skip to content

fix(shims): add withRouter HOC to next/router#1203

Merged
james-elicx merged 2 commits into
mainfrom
fix/shim-router-withrouter
May 14, 2026
Merged

fix(shims): add withRouter HOC to next/router#1203
james-elicx merged 2 commits into
mainfrom
fix/shim-router-withrouter

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

Summary

  • Add withRouter higher-order component to packages/vinext/src/shims/router.ts, ported from packages/next/src/client/with-router.tsx in Next.js.
  • Also export WithRouterProps and ExcludeRouterProps types (commonly imported by typed Pages Router apps) and add matching declare module "next/router" entries in next-shims.d.ts.

Why

The Next.js deploy test suite currently has 9 build failures of the form:

[MISSING_EXPORT] "withRouter" is not exported by "node_modules/.../vinext/dist/shims/router.js"

withRouter is primarily used by class components (which cannot call hooks) to receive the Pages Router router instance as a prop. Real-world Next.js apps and the e2e fixtures (e.g. test/e2e/with-router/pages/a.js) import it directly from next/router. Without this export, those bundles fail to build.

Implementation

The implementation mirrors Next.js's HOC behaviour:

  1. Calls useRouter() and forwards the result as a router prop.
  2. Forwards getInitialProps and origGetInitialProps from the composed component (parity with _app Pages Router behaviour for class components that define getInitialProps).
  3. Sets a withRouter(<Name>) displayName outside production.

Source: packages/next/src/client/with-router.tsx

Type signature differences vs Next.js

Next.js types the composed component as NextComponentType<C, any, P>. vinext does not currently expose NextComponentType from this shim, so the port uses ComponentType<P> instead. The runtime shape is identical; the only difference is that vinext's HOC cannot recover the legacy NextComponentType.context type. No existing call sites in the Next.js test fixtures depend on that — they all use plain class components that read this.props.router.

Tests

Added a next/router withRouter HOC describe block in tests/shims.test.ts (4 tests):

  1. withRouter is exported as a function.
  2. Wrapping a component returns a function and sets a withRouter(...) displayName in dev.
  3. The wrapped component renders correctly and receives router as a prop with the expected NextRouter surface (push, replace, back, reload, prefetch, events).
  4. getInitialProps and origGetInitialProps are forwarded from the composed component.

Local results:

  • vp test run tests/shims.test.ts870 passed (4 new).
  • vp check — pass (format, lint, type checks).

Uncertainty / follow-ups

  • The simplified type signature (ComponentType<P> instead of NextComponentType<C, any, P>) is a deliberate narrowing because vinext does not expose NextComponentType. If a user's TypeScript code depends on the richer Next.js signature, they may see a type-only regression. The runtime behaviour is identical and matches Next.js exactly.
  • WithRouterProps.router in next-shims.d.ts is typed as any rather than the full NextRouter interface; the ambient declaration file already uses any for useRouter's return value, so this keeps the d.ts file consistent. The actual implementation (in router.ts) uses the full NextRouter type.

Add the `withRouter` higher-order component export to
`packages/vinext/src/shims/router.ts`, ported from
`packages/next/src/client/with-router.tsx` in Next.js.

`withRouter` is used primarily by class components (which cannot call
hooks) to receive the Pages Router `router` instance as a prop. Real
Next.js apps and the Next.js deploy test suite import it from
`next/router`; without this export, bundlers fail with
`[MISSING_EXPORT] "withRouter" is not exported by ".../shims/router.js"`,
which is currently the cause of 9 build failures in the deploy suite.

The implementation mirrors Next.js's HOC: it calls `useRouter()` and
forwards the router as a `router` prop, forwards `getInitialProps` and
`origGetInitialProps` from the composed component, and sets a
`withRouter(<Name>)` displayName in development.

Also exports the `WithRouterProps` and `ExcludeRouterProps` types
(commonly imported by typed Pages Router apps) and adds matching
`declare module "next/router"` entries.
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1203

commit: b6875b8

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean port — the runtime behavior matches Next.js and the tests cover the key surface. One correctness issue with prop spreading order that could cause a subtle behavioral divergence, plus a few minor nits.

CI note: The failing Vitest check (tests/link-navigation.test.ts > clicking an RSC Link starts app-router navigation inside a React transition) is unrelated to this PR — it's a pre-existing flake in the link navigation test.

Comment thread packages/vinext/src/shims/router.ts Outdated
return createElement(ComposedComponent, {
...(props as unknown as P),
router,
});
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.

Bug: Prop spreading order differs from Next.js — user router prop silently wins over the HOC-injected one.

In Next.js's with-router.tsx, the order is:

<ComposedComponent router={useRouter()} {...props} />

This means if a caller accidentally passes a router prop to the wrapped component, the caller's value overrides the HOC-injected one (props spread last). This is the documented Next.js behavior — the last-spread wins.

But here the order is reversed:

createElement(ComposedComponent, {
  ...(props as unknown as P),
  router,   // ← always wins
});

The HOC-injected router always wins, which means a user-passed router prop is silently swallowed.

In practice this rarely matters (nobody should be passing router to a withRouter-wrapped component), but for Next.js behavioral parity and to match the source you're porting from, the order should be:

createElement(ComposedComponent, {
  router,
  ...(props as unknown as P),
});

This also matches the ExcludeRouterProps<P> type constraint — the wrapper's props type already excludes router, so in well-typed code the caller can't pass it. But in JavaScript (no types) or with as any casts, the spreading order is the behavioral contract.

getInitialProps?: unknown;
origGetInitialProps?: unknown;
};
(WithRouterWrapper as unknown as { getInitialProps?: unknown }).getInitialProps =
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: In Next.js, getInitialProps is assigned directly without the intermediate cast:

WithRouterWrapper.getInitialProps = ComposedComponent.getInitialProps

Because NextComponentType has getInitialProps on its type, there's no cast needed. Here, since ComposedComponent is typed as ComponentType<P> (which lacks getInitialProps), the casts are necessary. That's fine — just noting this is a consequence of the type narrowing documented in the PR description. The runtime behavior is correct.

One thing to consider: if ComposedComponent.getInitialProps is undefined (which it will be for most function components), you're assigning undefined to WithRouterWrapper.getInitialProps. Next.js does the same, so this is correct — but it means the property exists (as undefined) on the wrapper. That's fine for _app checks that use in operator or hasOwnProperty, since they'd find the key. Just flagging for visibility — no change needed.

import { ComponentType } from "react";
export function useRouter(): any;
export function setSSRContext(ctx: any): void;
export type WithRouterProps = { router: any };
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: This is typed as { router: any } while the runtime export uses { router: NextRouter }. The PR description explains this is intentional (keeping the d.ts consistent with the existing any pattern for useRouter). That's a reasonable consistency choice — just noting it for anyone reading later who wonders why the types diverge.

Comment thread tests/shims.test.ts Outdated
// ---------------------------------------------------------------------------
describe("next/router withRouter HOC", () => {
it("exports withRouter as a named function", async () => {
const previousWindow = (globalThis as any).window;
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: Repeated window mock setup across all 4 tests.

Each test in this describe block creates the same win object and does the same globalThis.window swap + vi.resetModules() + finally restore. Consider extracting this into a beforeEach/afterEach pair within the describe block:

describe("next/router withRouter HOC", () => {
  let previousWindow: any;
  beforeEach(() => {
    previousWindow = (globalThis as any).window;
    (globalThis as any).window = {
      location: { pathname: "/", search: "", hash: "", href: "http://localhost/" },
      history: { state: null, pushState() {}, replaceState() {} },
      addEventListener() {},
    };
    vi.resetModules();
  });
  afterEach(() => {
    (globalThis as any).window = previousWindow;
    vi.resetModules();
  });
  // ...tests without the boilerplate
});

This would cut ~60 lines of boilerplate. Not a blocker — the existing pattern is fine and matches the style in the existing window.next tests above.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted successfully. Here's a summary of what I found:

Review Summary for PR #1203

Overall: Clean port of withRouter from Next.js. The implementation correctly mirrors the Next.js HOC behavior, the tests are solid, and the shim mapping ensures the export is properly exposed through next/router.

Key finding — prop spreading order bug

The main issue is a prop spreading order difference from Next.js at router.ts:949-955. In Next.js:

<ComposedComponent router={useRouter()} {...props} />

Props spread after router, so a caller-passed router would override the HOC-injected one. In the vinext port, it's reversed — router is spread last, so it always wins. While unlikely to matter in practice (the ExcludeRouterProps type prevents passing router in TypeScript), it's a behavioral divergence from the source being ported. The fix is a one-line reorder.

Other notes:

  • CI failure is pre-existing (link-navigation.test.ts flake), unrelated to this PR
  • Type declarations in next-shims.d.ts are consistent with the existing any pattern
  • Test boilerplate — suggested extracting the repeated window mock into beforeEach/afterEach (non-blocking)
  • getInitialProps forwarding is correctly ported, including the origGetInitialProps parity for _app

github run

Address bonk review on PR #1203:

1. (Correctness) Match the Next.js prop-spread order in `withRouter`.
   Next.js's with-router.tsx renders
     <ComposedComponent router={useRouter()} {...props} />
   so the injected `router` is placed FIRST and `{...props}` is
   spread after — a user-passed `router` prop wins by last-spread.
   The previous implementation spread `props` first and then
   re-assigned `router`, which silently swallowed any user-passed
   `router` prop. Now matches Next.js exactly.

   Added a regression test that passes a sentinel `router` prop and
   asserts the user-passed value reaches the composed component.

2. (Cleanup) Extracted the repeated globalThis.window mock and
   vi.resetModules() teardown in the withRouter describe block into
   beforeEach/afterEach. Cuts ~60 lines from tests/shims.test.ts.
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Both review items from the first pass are addressed in the second commit:

  1. Prop spreading order — fixed. { router, ...(props as Record<string, unknown>) } now matches Next.js's <ComposedComponent router={useRouter()} {...props} /> exactly (HOC router first, user props spread after). The new regression test at tests/shims.test.ts:907 validates this with a sentinel object.

  2. Test boilerplate — cleaned up. The window mock is now in beforeEach/afterEach within the describe block.

CI: Check, Vitest (unit), all E2E projects that have completed, all deploys, and smoke tests are green. A few integration shards are still pending but unrelated to this change.

Implementation is clean and correct. LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted. Both items from the previous review — the prop spreading order bug and the test boilerplate — are addressed in the second commit. CI is green across all completed checks. Approved.

github run

@james-elicx james-elicx marked this pull request as ready for review May 14, 2026 15:13
@james-elicx james-elicx merged commit 36d2ec5 into main May 14, 2026
35 checks passed
@james-elicx james-elicx deleted the fix/shim-router-withrouter branch May 14, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant