From fa19c4410fdc340a11984de71e2846a99d20e7ac Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 7 Nov 2022 10:16:28 -0800 Subject: [PATCH] `next/compat/router` (#42502) After speaking with @timneutkens, this PR provides a smoother experience to users trying to migrate over to app without affecting users in pages. This PR adds a new export available from `next/compat/router` that exposes a `useRouter()` hook that can be used in both `app/` and `pages/`. It differs from `next/router` in that it does not throw an error when the pages router is not mounted, and instead has a return type of `NextRouter | null`. This allows developers to convert components to support running in both `app/` and `pages/` as they are transitioning over to `app/`. A component that before looked like this: ```tsx import { useRouter } from 'next/router'; const MyComponent = () => { const { isReady, query } = useRouter(); // ... }; ``` Will error when converted over to `next/compat/router`, as `null` cannot be destructured. Instead, developers will be able to take advantage of new hooks: ```tsx import { useEffect } from 'react'; import { useRouter } from 'next/compat/router'; import { useSearchParams } from 'next/navigation'; const MyComponent = () => { const router = useRouter() // may be null or a NextRouter instance const searchParams = useSearchParams() useEffect(() => { if (router && !router.isReady) { return } // In `app/`, searchParams will be ready immediately with the values, in // `pages/` it will be available after the router is ready. const search = searchParams.get('search') // ... }, [router, searchParams]) // ... } ``` This component will now work in both `pages/` and `app/`. When the component is no longer used in `pages/`, you can remove the references to the compat router: ```tsx import { useSearchParams } from 'next/navigation'; const MyComponent = () => { const searchParams = useSearchParams() // As this component is only used in `app/`, the compat router can be removed. const search = searchParams.get('search') // ... } ``` Note that as of Next.js 13, calling `useRouter` from `next/router` will throw an error when not mounted. This now includes an error page that can be used to assist developers. We hope to introduce a codemod that can convert instances of your `useRouter` from `next/router` to `next/compat/router` in the future. Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com> --- errors/manifest.json | 4 ++++ errors/next-router-not-mounted.md | 13 +++++++++++++ packages/next/client/compat/router.ts | 17 +++++++++++++++++ packages/next/client/route-announcer.tsx | 2 +- packages/next/client/router.ts | 10 +++++----- packages/next/compat/router.d.ts | 1 + packages/next/compat/router.js | 1 + packages/next/tsconfig.json | 1 + test/integration/typescript/pages/hello.tsx | 2 +- 9 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 errors/next-router-not-mounted.md create mode 100644 packages/next/client/compat/router.ts create mode 100644 packages/next/compat/router.d.ts create mode 100644 packages/next/compat/router.js diff --git a/errors/manifest.json b/errors/manifest.json index 5e48d7fa9d44e0f..9be351338750b2d 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -757,6 +757,10 @@ { "title": "invalid-segment-export", "path": "/errors/invalid-segment-export.md" + }, + { + "title": "next-router-not-mounted", + "path": "/errors/next-router-not-mounted.md" } ] } diff --git a/errors/next-router-not-mounted.md b/errors/next-router-not-mounted.md new file mode 100644 index 000000000000000..0bdb3f7a9ce737b --- /dev/null +++ b/errors/next-router-not-mounted.md @@ -0,0 +1,13 @@ +# NextRouter was not mounted + +#### Why This Error Occurred + +A component used `useRouter` outside a Next.js application, or was rendered outside a Next.js application. This can happen when doing unit testing on components that use the `useRouter` hook as they are not configured with Next.js' contexts. + +#### Possible Ways to Fix It + +If used in a test, mock out the router by mocking the `next/router`'s `useRouter()` hook. + +### Useful Links + +- [next-router-mock](https://www.npmjs.com/package/next-router-mock) diff --git a/packages/next/client/compat/router.ts b/packages/next/client/compat/router.ts new file mode 100644 index 000000000000000..58b1b9f02ed0550 --- /dev/null +++ b/packages/next/client/compat/router.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react' +import { RouterContext } from '../../shared/lib/router-context' +import { NextRouter } from '../router' + +/** + * useRouter from `next/compat/router` is designed to assist developers + * migrating from `pages/` to `app/`. Unlike `next/router`, this hook does not + * throw when the `NextRouter` is not mounted, and instead returns `null`. The + * more concrete return type here lets developers use this hook within + * components that could be shared between both `app/` and `pages/` and handle + * to the case where the router is not mounted. + * + * @returns The `NextRouter` instance if it's available, otherwise `null`. + */ +export function useRouter(): NextRouter | null { + return useContext(RouterContext) +} diff --git a/packages/next/client/route-announcer.tsx b/packages/next/client/route-announcer.tsx index cbd26f09ea7bda4..3b59fd84d96bcbd 100644 --- a/packages/next/client/route-announcer.tsx +++ b/packages/next/client/route-announcer.tsx @@ -17,7 +17,7 @@ const nextjsRouteAnnouncerStyles: React.CSSProperties = { } export const RouteAnnouncer = () => { - const { asPath } = useRouter(true) + const { asPath } = useRouter() const [routeAnnouncement, setRouteAnnouncement] = React.useState('') // Only announce the path change, but not for the first load because screen diff --git a/packages/next/client/router.ts b/packages/next/client/router.ts index dbcd11a9b2d6209..7f4acbd51a60c8c 100644 --- a/packages/next/client/router.ts +++ b/packages/next/client/router.ts @@ -129,12 +129,12 @@ export default singletonRouter as SingletonRouter // Reexport the withRoute HOC export { default as withRouter } from './with-router' -export function useRouter(throwOnMissing: true): NextRouter -export function useRouter(): NextRouter -export function useRouter(throwOnMissing?: boolean) { +export function useRouter(): NextRouter { const router = React.useContext(RouterContext) - if (!router && throwOnMissing) { - throw new Error('invariant expected pages router to be mounted') + if (!router) { + throw new Error( + 'Error: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted' + ) } return router diff --git a/packages/next/compat/router.d.ts b/packages/next/compat/router.d.ts new file mode 100644 index 000000000000000..c458473721738cf --- /dev/null +++ b/packages/next/compat/router.d.ts @@ -0,0 +1 @@ +export * from '../dist/client/compat/router' diff --git a/packages/next/compat/router.js b/packages/next/compat/router.js new file mode 100644 index 000000000000000..1b46d46053276b8 --- /dev/null +++ b/packages/next/compat/router.js @@ -0,0 +1 @@ +module.exports = require('../dist/client/compat/router') diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index abbb87416c5f28f..0cbb6fd2199e9af 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -14,6 +14,7 @@ "./*.d.ts", "future/*.d.ts", "image-types/global.d.ts", + "compat/*.d.ts", "legacy/*.d.ts", "types/compiled.d.ts" ] diff --git a/test/integration/typescript/pages/hello.tsx b/test/integration/typescript/pages/hello.tsx index 1e5c82d05ec5cf9..acfd061ee410906 100644 --- a/test/integration/typescript/pages/hello.tsx +++ b/test/integration/typescript/pages/hello.tsx @@ -31,7 +31,7 @@ class Test2 extends Test { new Test2().show() export default function HelloPage(): JSX.Element { - const router = useRouter(true) + const router = useRouter() console.log(process.browser) console.log(router.pathname) console.log(router.isReady)