From 1f55ba357fe6f51ba7e4e5c26c9cc1942ea97e31 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 3 Nov 2022 14:34:50 -0600 Subject: [PATCH] Change `usePathname` to return `string | null` (#42380) This changes the API of `usePathname` to return `string | null` to support hybrid use-cases where the pathname is unknown at build time (during automatic static optimization and when fallback is set true with dynamic parameters in the pathname). This supports a cleaner DX experience for those moving from `pages/` to `app/` so they can begin to use `usePathname` in components that are shared across them. --- packages/next/client/components/navigation.ts | 10 +- packages/next/client/index.tsx | 14 +- packages/next/server/render.tsx | 14 +- packages/next/shared/lib/router/adapters.ts | 81 ----------- packages/next/shared/lib/router/adapters.tsx | 129 ++++++++++++++++++ 5 files changed, 145 insertions(+), 103 deletions(-) delete mode 100644 packages/next/shared/lib/router/adapters.ts create mode 100644 packages/next/shared/lib/router/adapters.tsx diff --git a/packages/next/client/components/navigation.ts b/packages/next/client/components/navigation.ts index 0d5f4dde633feb4..37423db9f080e58 100644 --- a/packages/next/client/components/navigation.ts +++ b/packages/next/client/components/navigation.ts @@ -76,7 +76,6 @@ export function useSearchParams() { throw new Error('invariant expected search params to be mounted') } - // eslint-disable-next-line react-hooks/rules-of-hooks const readonlySearchParams = useMemo(() => { return new ReadonlyURLSearchParams(searchParams) }, [searchParams]) @@ -87,14 +86,9 @@ export function useSearchParams() { /** * Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard" */ -export function usePathname(): string { +export function usePathname(): string | null { staticGenerationBailout('usePathname') - const pathname = useContext(PathnameContext) - if (pathname === null) { - throw new Error('invariant expected pathname to be mounted') - } - - return pathname + return useContext(PathnameContext) } // TODO-APP: getting all params when client-side navigating is non-trivial as it does not have route matchers so this might have to be a server context instead. diff --git a/packages/next/client/index.tsx b/packages/next/client/index.tsx index a63a5ca9b05c911..a5baeae1648c634 100644 --- a/packages/next/client/index.tsx +++ b/packages/next/client/index.tsx @@ -39,13 +39,10 @@ import { hasBasePath } from './has-base-path' import { AppRouterContext } from '../shared/lib/app-router-context' import { adaptForAppRouterInstance, - adaptForPathname, adaptForSearchParams, + PathnameContextProviderAdapter, } from '../shared/lib/router/adapters' -import { - PathnameContext, - SearchParamsContext, -} from '../shared/lib/hooks-client-context' +import { SearchParamsContext } from '../shared/lib/hooks-client-context' /// @@ -316,7 +313,10 @@ function AppContainer({ > - + - + diff --git a/packages/next/server/render.tsx b/packages/next/server/render.tsx index b379c3d9a8a70fa..c5b2686a6e2b5f9 100644 --- a/packages/next/server/render.tsx +++ b/packages/next/server/render.tsx @@ -84,14 +84,11 @@ import stripAnsi from 'next/dist/compiled/strip-ansi' import { stripInternalQueries } from './internal-utils' import { adaptForAppRouterInstance, - adaptForPathname, adaptForSearchParams, + PathnameContextProviderAdapter, } from '../shared/lib/router/adapters' import { AppRouterContext } from '../shared/lib/app-router-context' -import { - PathnameContext, - SearchParamsContext, -} from '../shared/lib/hooks-client-context' +import { SearchParamsContext } from '../shared/lib/hooks-client-context' let tryGetPreviewData: typeof import('./api-utils/node').tryGetPreviewData let warn: typeof import('../build/output/log').warn @@ -621,7 +618,10 @@ export async function renderToHTML( const AppContainer = ({ children }: { children: JSX.Element }) => ( - + - + ) diff --git a/packages/next/shared/lib/router/adapters.ts b/packages/next/shared/lib/router/adapters.ts deleted file mode 100644 index facd64c2a7effed..000000000000000 --- a/packages/next/shared/lib/router/adapters.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ParsedUrlQuery } from 'node:querystring' -import { AppRouterInstance } from '../app-router-context' -import { NextRouter } from './router' - -/** - * adaptForAppRouterInstance implements the AppRouterInstance with a NextRouter. - * - * @param router the NextRouter to adapt - * @returns an AppRouterInstance - */ -export function adaptForAppRouterInstance( - router: NextRouter -): AppRouterInstance { - return { - back(): void { - router.back() - }, - forward(): void { - router.forward() - }, - refresh(): void { - router.reload() - }, - push(href: string): void { - void router.push(href) - }, - replace(href: string): void { - void router.replace(href) - }, - prefetch(href: string): void { - void router.prefetch(href) - }, - } -} - -/** - * transforms the ParsedUrlQuery into a URLSearchParams. - * - * @param query the query to transform - * @returns URLSearchParams - */ -function transformQuery(query: ParsedUrlQuery): URLSearchParams { - const params = new URLSearchParams() - - for (const [name, value] of Object.entries(query)) { - if (Array.isArray(value)) { - for (const val of value) { - params.append(name, val) - } - } else if (typeof value !== 'undefined') { - params.append(name, value) - } - } - - return params -} - -/** - * adaptForSearchParams transforms the ParsedURLQuery into URLSearchParams. - * - * @param router the router that contains the query. - * @returns the search params in the URLSearchParams format - */ -export function adaptForSearchParams(router: NextRouter): URLSearchParams { - if (!router.isReady || !router.query) { - return new URLSearchParams() - } - - return transformQuery(router.query) -} - -/** - * adaptForPathname adapts the `asPath` parameter from the router to a pathname. - * - * @param asPath the asPath parameter to transform that comes from the router - * @returns pathname part of `asPath` - */ -export function adaptForPathname(asPath: string): string { - const url = new URL(asPath, 'http://f') - return url.pathname -} diff --git a/packages/next/shared/lib/router/adapters.tsx b/packages/next/shared/lib/router/adapters.tsx new file mode 100644 index 000000000000000..9bf0096abad2be7 --- /dev/null +++ b/packages/next/shared/lib/router/adapters.tsx @@ -0,0 +1,129 @@ +import type { ParsedUrlQuery } from 'node:querystring' +import React, { useMemo, useRef } from 'react' +import type { AppRouterInstance } from '../app-router-context' +import { PathnameContext } from '../hooks-client-context' +import type { NextRouter } from './router' +import { isDynamicRoute } from './utils' + +/** + * adaptForAppRouterInstance implements the AppRouterInstance with a NextRouter. + * + * @param router the NextRouter to adapt + * @returns an AppRouterInstance + */ +export function adaptForAppRouterInstance( + router: NextRouter +): AppRouterInstance { + return { + back(): void { + router.back() + }, + forward(): void { + router.forward() + }, + refresh(): void { + router.reload() + }, + push(href: string): void { + void router.push(href) + }, + replace(href: string): void { + void router.replace(href) + }, + prefetch(href: string): void { + void router.prefetch(href) + }, + } +} + +/** + * transforms the ParsedUrlQuery into a URLSearchParams. + * + * @param query the query to transform + * @returns URLSearchParams + */ +function transformQuery(query: ParsedUrlQuery): URLSearchParams { + const params = new URLSearchParams() + + for (const [name, value] of Object.entries(query)) { + if (Array.isArray(value)) { + for (const val of value) { + params.append(name, val) + } + } else if (typeof value !== 'undefined') { + params.append(name, value) + } + } + + return params +} + +/** + * adaptForSearchParams transforms the ParsedURLQuery into URLSearchParams. + * + * @param router the router that contains the query. + * @returns the search params in the URLSearchParams format + */ +export function adaptForSearchParams( + router: Pick +): URLSearchParams { + if (!router.isReady || !router.query) { + return new URLSearchParams() + } + + return transformQuery(router.query) +} + +export function PathnameContextProviderAdapter({ + children, + router, + ...props +}: React.PropsWithChildren<{ + router: Pick + isAutoExport: boolean +}>) { + const ref = useRef(props.isAutoExport) + const value = useMemo(() => { + // isAutoExport is only ever `true` on the first render from the server, + // so reset it to `false` after we read it for the first time as `true`. If + // we don't use the value, then we don't need it. + const isAutoExport = ref.current + if (isAutoExport) { + ref.current = false + } + + // When the route is a dynamic route, we need to do more processing to + // determine if we need to stop showing the pathname. + if (isDynamicRoute(router.pathname)) { + // When the router is rendering the fallback page, it can't possibly know + // the path, so return `null` here. Read more about fallback pages over + // at: + // https://nextjs.org/docs/api-reference/data-fetching/get-static-paths#fallback-pages + if (router.isFallback) { + return null + } + + // When `isAutoExport` is true, meaning this is a page page has been + // automatically statically optimized, and the router is not ready, then + // we can't know the pathname yet. Read more about automatic static + // optimization at: + // https://nextjs.org/docs/advanced-features/automatic-static-optimization + if (isAutoExport && !router.isReady) { + return null + } + } + + // The `router.asPath` contains the pathname seen by the browser (including + // any query strings), so it should have that stripped. Read more about the + // `asPath` option over at: + // https://nextjs.org/docs/api-reference/next/router#router-object + const url = new URL(router.asPath, 'http://f') + return url.pathname + }, [router.asPath, router.isFallback, router.isReady, router.pathname]) + + return ( + + {children} + + ) +}