Skip to content

Commit

Permalink
Change usePathname to return string | null (#42380)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wyattjoh authored Nov 3, 2022
1 parent e74de1a commit 1f55ba3
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 103 deletions.
10 changes: 2 additions & 8 deletions packages/next/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions packages/next/client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -316,7 +313,10 @@ function AppContainer({
>
<AppRouterContext.Provider value={adaptForAppRouterInstance(router)}>
<SearchParamsContext.Provider value={adaptForSearchParams(router)}>
<PathnameContext.Provider value={adaptForPathname(asPath)}>
<PathnameContextProviderAdapter
router={router}
isAutoExport={self.__NEXT_DATA__.autoExport ?? false}
>
<RouterContext.Provider value={makePublicRouterInstance(router)}>
<HeadManagerContext.Provider value={headManager}>
<ImageConfigContext.Provider
Expand All @@ -328,7 +328,7 @@ function AppContainer({
</ImageConfigContext.Provider>
</HeadManagerContext.Provider>
</RouterContext.Provider>
</PathnameContext.Provider>
</PathnameContextProviderAdapter>
</SearchParamsContext.Provider>
</AppRouterContext.Provider>
</Container>
Expand Down
14 changes: 7 additions & 7 deletions packages/next/server/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -621,7 +618,10 @@ export async function renderToHTML(
const AppContainer = ({ children }: { children: JSX.Element }) => (
<AppRouterContext.Provider value={appRouter}>
<SearchParamsContext.Provider value={adaptForSearchParams(router)}>
<PathnameContext.Provider value={adaptForPathname(asPath)}>
<PathnameContextProviderAdapter
router={router}
isAutoExport={isAutoExport}
>
<RouterContext.Provider value={router}>
<AmpStateContext.Provider value={ampState}>
<HeadManagerContext.Provider
Expand All @@ -648,7 +648,7 @@ export async function renderToHTML(
</HeadManagerContext.Provider>
</AmpStateContext.Provider>
</RouterContext.Provider>
</PathnameContext.Provider>
</PathnameContextProviderAdapter>
</SearchParamsContext.Provider>
</AppRouterContext.Provider>
)
Expand Down
81 changes: 0 additions & 81 deletions packages/next/shared/lib/router/adapters.ts

This file was deleted.

129 changes: 129 additions & 0 deletions packages/next/shared/lib/router/adapters.tsx
Original file line number Diff line number Diff line change
@@ -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<NextRouter, 'isReady' | 'query'>
): URLSearchParams {
if (!router.isReady || !router.query) {
return new URLSearchParams()
}

return transformQuery(router.query)
}

export function PathnameContextProviderAdapter({
children,
router,
...props
}: React.PropsWithChildren<{
router: Pick<NextRouter, 'pathname' | 'asPath' | 'isReady' | 'isFallback'>
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 (
<PathnameContext.Provider value={value}>
{children}
</PathnameContext.Provider>
)
}

0 comments on commit 1f55ba3

Please sign in to comment.