Skip to content

Commit

Permalink
Introduce useOptimisticPathname and useOptimisticSearchParams
Browse files Browse the repository at this point in the history
  • Loading branch information
gaojude committed Mar 4, 2025
1 parent c66cedf commit d93cbdb
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 47 deletions.
92 changes: 67 additions & 25 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
startTransition,
useInsertionEffect,
useDeferredValue,
useOptimistic,
} from 'react'
import {
AppRouterContext,
Expand Down Expand Up @@ -38,6 +39,8 @@ import {
SearchParamsContext,
PathnameContext,
PathParamsContext,
OptimisticPathnameContext,
OptimisticSearchParamsContext,
} from '../../shared/lib/hooks-client-context.shared-runtime'
import { useReducer, useUnwrapState } from './use-reducer'
import {
Expand Down Expand Up @@ -249,6 +252,19 @@ function Head({
return useDeferredValue(head, resolvedPrefetchRsc)
}

const parsePathnameAndSearchParamsFromCanonicalUrl = (canonicalUrl: string) => {
const url = new URL(
canonicalUrl,
typeof window === 'undefined' ? 'http://n' : window.location.href
)
return {
pathname: hasBasePath(url.pathname)
? removeBasePath(url.pathname)
: url.pathname,
searchParams: url.searchParams,
}
}

/**
* The global router that wraps the application components.
*/
Expand All @@ -263,21 +279,34 @@ function Router({
}) {
const [state, dispatch] = useReducer(actionQueue)
const { canonicalUrl } = useUnwrapState(state)
// Add memoized pathname/query for useSearchParams and usePathname.
const { searchParams, pathname } = useMemo(() => {
const url = new URL(
canonicalUrl,
typeof window === 'undefined' ? 'http://n' : window.location.href
)

return {
// This is turned into a readonly class in `useSearchParams`
searchParams: url.searchParams,
pathname: hasBasePath(url.pathname)
? removeBasePath(url.pathname)
: url.pathname,
const { searchParams, pathname } = useMemo(
() => parsePathnameAndSearchParamsFromCanonicalUrl(canonicalUrl),
[canonicalUrl]
)

const [
{ optimisticPathname, optimisticSearchParams },
setOptimisticPathnameAndSearchParamsFromHref,
] = useOptimistic<
{
optimisticPathname: string
optimisticSearchParams: URLSearchParams
},
string
>(
{
optimisticPathname: pathname,
optimisticSearchParams: searchParams,
},
(_, url) => {
const parsed = parsePathnameAndSearchParamsFromCanonicalUrl(url)
return {
optimisticPathname: parsed.pathname,
optimisticSearchParams: parsed.searchParams,
}
}
}, [canonicalUrl])
)

const changeByServerResponse = useChangeByServerResponse(dispatch)
const navigate = useNavigate(dispatch)
Expand Down Expand Up @@ -320,11 +349,13 @@ function Router({
},
replace: (href, options = {}) => {
startTransition(() => {
setOptimisticPathnameAndSearchParamsFromHref(href)
navigate(href, 'replace', options.scroll ?? true)
})
},
push: (href, options = {}) => {
startTransition(() => {
setOptimisticPathnameAndSearchParamsFromHref(href)
navigate(href, 'push', options.scroll ?? true)
})
},
Expand Down Expand Up @@ -353,7 +384,12 @@ function Router({
}

return routerInstance
}, [actionQueue, dispatch, navigate])
}, [
actionQueue,
dispatch,
navigate,
setOptimisticPathnameAndSearchParamsFromHref,
])

useEffect(() => {
// Exists for debugging purposes. Don't use in application code.
Expand Down Expand Up @@ -670,17 +706,23 @@ function Router({
<RuntimeStyles />
<PathParamsContext.Provider value={pathParams}>
<PathnameContext.Provider value={pathname}>
<SearchParamsContext.Provider value={searchParams}>
<GlobalLayoutRouterContext.Provider
value={globalLayoutRouterContext}
>
<AppRouterContext.Provider value={appRouter}>
<LayoutRouterContext.Provider value={layoutRouterContext}>
{content}
</LayoutRouterContext.Provider>
</AppRouterContext.Provider>
</GlobalLayoutRouterContext.Provider>
</SearchParamsContext.Provider>
<OptimisticPathnameContext.Provider value={optimisticPathname}>
<SearchParamsContext.Provider value={searchParams}>
<OptimisticSearchParamsContext.Provider
value={optimisticSearchParams}
>
<GlobalLayoutRouterContext.Provider
value={globalLayoutRouterContext}
>
<AppRouterContext.Provider value={appRouter}>
<LayoutRouterContext.Provider value={layoutRouterContext}>
{content}
</LayoutRouterContext.Provider>
</AppRouterContext.Provider>
</GlobalLayoutRouterContext.Provider>
</OptimisticSearchParamsContext.Provider>
</SearchParamsContext.Provider>
</OptimisticPathnameContext.Provider>
</PathnameContext.Provider>
</PathParamsContext.Provider>
</>
Expand Down
76 changes: 54 additions & 22 deletions packages/next/src/client/components/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
SearchParamsContext,
PathnameContext,
PathParamsContext,
OptimisticPathnameContext,
OptimisticSearchParamsContext,
} from '../../shared/lib/hooks-client-context.shared-runtime'
import { getSegmentValue } from './router-reducer/reducers/get-segment-value'
import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment'
Expand All @@ -23,6 +25,34 @@ const useDynamicRouteParams =
).useDynamicRouteParams
: undefined

const useReadonlyURLSearchParams = (
searchParams: URLSearchParams | null,
parentHookName: string
) => {
// In the case where this is `null`, the compat types added in
// `next-env.d.ts` will add a new overload that changes the return type to
// include `null`.
const readonlySearchParams = useMemo(() => {
if (!searchParams) {
// When the router is not ready in pages, we won't have the search params
// available.
return null
}

return new ReadonlyURLSearchParams(searchParams)
}, [searchParams]) as ReadonlyURLSearchParams

if (typeof window === 'undefined') {
// AsyncLocalStorage should not be included in the client bundle.
const { bailoutToClientRendering } =
require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering')
// TODO-APP: handle dynamic = 'force-static' here and on the client
bailoutToClientRendering(`${parentHookName}()`)
}

return readonlySearchParams
}

/**
* A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook
* that lets you *read* the current URL's search parameters.
Expand All @@ -46,29 +76,20 @@ const useDynamicRouteParams =
// Client components API
export function useSearchParams(): ReadonlyURLSearchParams {
const searchParams = useContext(SearchParamsContext)
return useReadonlyURLSearchParams(searchParams, 'useSearchParams()')
}

// In the case where this is `null`, the compat types added in
// `next-env.d.ts` will add a new overload that changes the return type to
// include `null`.
const readonlySearchParams = useMemo(() => {
if (!searchParams) {
// When the router is not ready in pages, we won't have the search params
// available.
return null
}

return new ReadonlyURLSearchParams(searchParams)
}, [searchParams]) as ReadonlyURLSearchParams

if (typeof window === 'undefined') {
// AsyncLocalStorage should not be included in the client bundle.
const { bailoutToClientRendering } =
require('./bailout-to-client-rendering') as typeof import('./bailout-to-client-rendering')
// TODO-APP: handle dynamic = 'force-static' here and on the client
bailoutToClientRendering('useSearchParams()')
}

return readonlySearchParams
/**
* Similar to `useSearchParams` but lets you read the search parameters optimistically during navigation.
* Useful for showing an optimistic UI state while navigation is in progress.
*/
// Client components API
export function useOptimisticSearchParams(): URLSearchParams {
const optimisticSearchParams = useContext(OptimisticSearchParamsContext)
return useReadonlyURLSearchParams(
optimisticSearchParams,
'useOptimisticSearchParams()'
)
}

/**
Expand Down Expand Up @@ -97,6 +118,17 @@ export function usePathname(): string {
return useContext(PathnameContext) as string
}

/**
* Similar to `usePathname` but lets you read the pathname optimistically during navigation.
* Useful for showing an optimistic UI state while navigation is in progress.
*/
// Client components API
export function useOptimisticPathname(): string {
useDynamicRouteParams?.('useOptimisticPathname()')

return useContext(OptimisticPathnameContext) as string
}

// Client components API
export {
ServerInsertedHTMLContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import type { Params } from '../../server/request/params'
export const SearchParamsContext = createContext<URLSearchParams | null>(null)
export const PathnameContext = createContext<string | null>(null)
export const PathParamsContext = createContext<Params | null>(null)
export const OptimisticPathnameContext = createContext<string | null>(null)
export const OptimisticSearchParamsContext =
createContext<URLSearchParams | null>(null)

if (process.env.NODE_ENV !== 'production') {
SearchParamsContext.displayName = 'SearchParamsContext'
PathnameContext.displayName = 'PathnameContext'
PathParamsContext.displayName = 'PathParamsContext'
OptimisticPathnameContext.displayName = 'OptimisticPathnameContext'
OptimisticSearchParamsContext.displayName = 'OptimisticSearchParamsContext'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default async function Page({ params }) {
const { id } = await params
await new Promise((resolve) => setTimeout(resolve, 1000))
return <div id={`target-page`}>ID={id}</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

import {
useOptimisticPathname,
useOptimisticSearchParams,
usePathname,
useSearchParams,
} from 'next/navigation'

import Link from 'next/link'

export default function Layout({ children }) {
const optimisticPathname = useOptimisticPathname()
const optimisticSearchParams = useOptimisticSearchParams()
const pathname = usePathname()
const searchParams = useSearchParams()
return (
<>
<p>
optimisticPathname=
<span id="optimistic-pathname">{optimisticPathname}</span>
</p>
<p>
pathname=
<span id="pathname">{pathname}</span>
</p>
<p>
optimisticSearchParams=
<span id="optimistic-search-params">
{optimisticSearchParams.toString()}
</span>
</p>
<p>
searchParams=
<span id="search-params">{searchParams.toString()}</span>
</p>
<Link
id="navigation-link"
href="/hooks/use-optimistic-pathname-and-search-params/dynamic/1?id=1"
>
Navigate to Dynamic Page
</Link>
<div>{children}</div>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>Home</div>
}
55 changes: 55 additions & 0 deletions test/e2e/app-dir/hooks/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,59 @@ describe('app dir - hooks', () => {
expect(JSON.parse($('#page-layout-segment').text())).toEqual(null)
})
})

describe('useOptimisticPathnameAndSearchParams', () => {
it('should update optimistic values during navigation', async () => {
// Test flow:
// 1. Initial state: Both optimistic and actual values match current URL
// 2. During navigation: Optimistic values update to target URL while actual values remain unchanged
// 3. After navigation: Both optimistic and actual values match new target URL

const browser = await next.browser(
'/hooks/use-optimistic-pathname-and-search-params?foo=bar'
)

// Check initial values
expect(await browser.elementById('pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params'
)
expect(await browser.elementById('optimistic-pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params'
)
expect(await browser.elementById('search-params').text()).toBe('foo=bar')
expect(await browser.elementById('optimistic-search-params').text()).toBe(
'foo=bar'
)

// Start navigation by clicking a link
await browser.elementById('navigation-link').click()

// During navigation, optimistic values should update immediately
expect(await browser.elementById('pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params'
)
expect(await browser.elementById('optimistic-pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params/dynamic/1'
)
expect(await browser.elementById('search-params').text()).toBe('foo=bar')
expect(await browser.elementById('optimistic-search-params').text()).toBe(
'id=1'
)

// Wait for navigation to complete
await browser.waitForElementByCss('#target-page')

// After navigation completes, all values should match the new URL
expect(await browser.elementById('pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params/dynamic/1'
)
expect(await browser.elementById('optimistic-pathname').text()).toBe(
'/hooks/use-optimistic-pathname-and-search-params/dynamic/1'
)
expect(await browser.elementById('search-params').text()).toBe('id=1')
expect(await browser.elementById('optimistic-search-params').text()).toBe(
'id=1'
)
})
})
})

0 comments on commit d93cbdb

Please sign in to comment.