Skip to content

Commit 71ad0dd

Browse files
authored
Add prefetch to new router (vercel#39866)
Follow-up to vercel#37551 Implements prefetching for the new router. There are multiple behaviors related to prefetching so I've split them out for each case. The list below each case is what's prefetched: Reference: - Checkmark checked → it's implemented. - RSC Payload → Rendered server components. - Router state → Patch for the router history state. - Preloads for client component entry → This will be handled in a follow-up PR. - No `loading.js` static case → Will be handled in a follow-up PR. --- - `prefetch={true}` (default, same as current router, links in viewport are prefetched) - [x] Static all the way down the component tree - [x] RSC payload - [x] Router state - [ ] preloads for the client component entry - [x] Not static all the way down the component tree - [x] With `loading.js` - [x] RSC payload up until the loading below the common layout - [x] router state - [ ] preloads for the client component entry - [x] No `loading.js` (This case can be static files to make sure it’s fast) - [x] router state - [ ] preloads for the client component entry - `prefetch={false}` - [x] always do an optimistic navigation. We already have this implemented where it tries to figure out the router state based on the provided url. That result might be wrong but the router will automatically figure out that --- In the first implementation there is a distinction between `hard` and `soft` navigation. With the addition of prefetching you no longer have to add a `soft` prop to `next/link` in order to leverage the `soft` case. A heuristic has been added that automatically prefers `soft` navigation except when navigating between mismatching dynamic parameters. An example: - `app/[userOrTeam]/dashboard/page.js` and `app/[userOrTeam]/dashboard/settings/page.js` - `/tim/dashboard` → `/tim/dashboard/settings` = Soft navigation - `/tim/dashboard` → `/vercel/dashboard` = Hard navigation - `/vercel/dashboard` → `/vercel/dashboard/settings` = Soft navigation - `/vercel/dashboard/settings` -> `/tim/dashboard` = Hard navigation --- While adding these new heuristics some of the tests started failing and I found some state bugs in `router.reload()` which have been fixed. An example being when you push to `/dashboard` while on `/` in the same transition it would navigate to `/`, it also wouldn't push a new history entry. Both of these cases are now fixed: ``` React.startTransition(() => { router.push('/dashboard') router.reload() }) ``` --- While debugging the various changes I ended up debugging and manually diffing the cache and router state quite often and was looking at a way to automate this. `useReducer` is quite similar to Redux so I was wondering if Redux Devtools could be used in order to debug the various actions as it has diffing built-in. It took a bit of time to figure out the connection mechanism but in the end I figured out how to connect `useReducer`, a new hook `useReducerWithReduxDevtools` has been added, we'll probably want to put this behind a compile-time flag when the new router is marked stable but until then it's useful to have it enabled by default (only when you have Redux Devtools installed ofcourse). > ⚠️ Redux Devtools is only connected to take incoming actions / state. Time travel and other features are not supported because the state sent to the devtools is normalized to allow diffing the maps, you can't move backward based on that state so applying the state is not connected. Example of the integration: <img width="1912" alt="Screen Shot 2022-09-02 at 10 00 40" src="https://user-images.githubusercontent.com/6324199/188637303-ad8d6a81-15e5-4b65-875b-1c4f93df4e44.png">
1 parent 5f95b6b commit 71ad0dd

File tree

28 files changed

+1068
-482
lines changed

28 files changed

+1068
-482
lines changed

packages/next/client/components/app-router.client.tsx

Lines changed: 99 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useEffect } from 'react'
1+
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
2+
import React, { useEffect, useMemo, useCallback } from 'react'
23
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'
34
import {
45
AppRouterContext,
@@ -12,6 +13,7 @@ import type {
1213
import type { FlightRouterState, FlightData } from '../../server/app-render'
1314
import {
1415
ACTION_NAVIGATE,
16+
ACTION_PREFETCH,
1517
ACTION_RELOAD,
1618
ACTION_RESTORE,
1719
ACTION_SERVER_PATCH,
@@ -23,13 +25,15 @@ import {
2325
PathnameContext,
2426
// LayoutSegmentsContext,
2527
} from './hooks-client-context'
28+
import { useReducerWithReduxDevtools } from './use-reducer-with-devtools'
2629

2730
/**
2831
* Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side.
2932
*/
3033
function fetchFlight(
3134
url: URL,
32-
flightRouterState: FlightRouterState
35+
flightRouterState: FlightRouterState,
36+
prefetch?: true
3337
): ReadableStream {
3438
const flightUrl = new URL(url)
3539
const searchParams = flightUrl.searchParams
@@ -40,6 +44,9 @@ function fetchFlight(
4044
'__flight_router_state_tree__',
4145
JSON.stringify(flightRouterState)
4246
)
47+
if (prefetch) {
48+
searchParams.append('__flight_prefetch__', '1')
49+
}
4350

4451
// TODO-APP: Verify that TransformStream is supported.
4552
const { readable, writable } = new TransformStream()
@@ -56,18 +63,17 @@ function fetchFlight(
5663
*/
5764
export function fetchServerResponse(
5865
url: URL,
59-
flightRouterState: FlightRouterState
66+
flightRouterState: FlightRouterState,
67+
prefetch?: true
6068
): { readRoot: () => FlightData } {
6169
// Handle the `fetch` readable stream that can be read using `readRoot`.
62-
return createFromReadableStream(fetchFlight(url, flightRouterState))
70+
return createFromReadableStream(fetchFlight(url, flightRouterState, prefetch))
6371
}
6472

6573
/**
6674
* Renders development error overlay when NODE_ENV is development.
6775
*/
68-
function ErrorOverlay({
69-
children,
70-
}: React.PropsWithChildren<{}>): React.ReactElement {
76+
function ErrorOverlay({ children }: PropsWithChildren<{}>): ReactElement {
7177
if (process.env.NODE_ENV === 'production') {
7278
return <>{children}</>
7379
} else {
@@ -83,6 +89,8 @@ function ErrorOverlay({
8389
let initialParallelRoutes: CacheNode['parallelRoutes'] =
8490
typeof window === 'undefined' ? null! : new Map()
8591

92+
const prefetched = new Set<string>()
93+
8694
/**
8795
* The global router that wraps the application components.
8896
*/
@@ -94,34 +102,41 @@ export default function AppRouter({
94102
}: {
95103
initialTree: FlightRouterState
96104
initialCanonicalUrl: string
97-
children: React.ReactNode
98-
hotReloader?: React.ReactNode
105+
children: ReactNode
106+
hotReloader?: ReactNode
99107
}) {
100-
const [{ tree, cache, pushRef, focusAndScrollRef, canonicalUrl }, dispatch] =
101-
React.useReducer(reducer, {
108+
const initialState = useMemo(() => {
109+
return {
102110
tree: initialTree,
103111
cache: {
104112
data: null,
105113
subTreeData: children,
106114
parallelRoutes:
107115
typeof window === 'undefined' ? new Map() : initialParallelRoutes,
108116
},
117+
prefetchCache: new Map(),
109118
pushRef: { pendingPush: false, mpaNavigation: false },
110119
focusAndScrollRef: { apply: false },
111120
canonicalUrl:
112121
initialCanonicalUrl +
113122
// Hash is read as the initial value for canonicalUrl in the browser
114123
// This is safe to do as canonicalUrl can't be rendered, it's only used to control the history updates the useEffect further down.
115124
(typeof window !== 'undefined' ? window.location.hash : ''),
116-
})
125+
}
126+
}, [children, initialCanonicalUrl, initialTree])
127+
const [
128+
{ tree, cache, prefetchCache, pushRef, focusAndScrollRef, canonicalUrl },
129+
dispatch,
130+
sync,
131+
] = useReducerWithReduxDevtools(reducer, initialState)
117132

118133
useEffect(() => {
119134
// Ensure initialParallelRoutes is cleaned up from memory once it's used.
120135
initialParallelRoutes = null!
121136
}, [])
122137

123138
// Add memoized pathname/query for useSearchParams and usePathname.
124-
const { searchParams, pathname } = React.useMemo(() => {
139+
const { searchParams, pathname } = useMemo(() => {
125140
const url = new URL(
126141
canonicalUrl,
127142
typeof window === 'undefined' ? 'http://n' : window.location.href
@@ -138,7 +153,7 @@ export default function AppRouter({
138153
/**
139154
* Server response that only patches the cache and tree.
140155
*/
141-
const changeByServerResponse = React.useCallback(
156+
const changeByServerResponse = useCallback(
142157
(previousTree: FlightRouterState, flightData: FlightData) => {
143158
dispatch({
144159
type: ACTION_SERVER_PATCH,
@@ -149,24 +164,25 @@ export default function AppRouter({
149164
subTreeData: null,
150165
parallelRoutes: new Map(),
151166
},
167+
mutable: {},
152168
})
153169
},
154-
[]
170+
[dispatch]
155171
)
156172

157173
/**
158174
* The app router that is exposed through `useRouter`. It's only concerned with dispatching actions to the reducer, does not hold state.
159175
*/
160-
const appRouter = React.useMemo<AppRouterInstance>(() => {
176+
const appRouter = useMemo<AppRouterInstance>(() => {
161177
const navigate = (
162178
href: string,
163-
cacheType: 'hard' | 'soft',
164-
navigateType: 'push' | 'replace'
179+
navigateType: 'push' | 'replace',
180+
forceOptimisticNavigation: boolean
165181
) => {
166182
return dispatch({
167183
type: ACTION_NAVIGATE,
168184
url: new URL(href, location.origin),
169-
cacheType,
185+
forceOptimisticNavigation,
170186
navigateType,
171187
cache: {
172188
data: null,
@@ -179,29 +195,47 @@ export default function AppRouter({
179195

180196
const routerInstance: AppRouterInstance = {
181197
// TODO-APP: implement prefetching of flight
182-
prefetch: (_href) => Promise.resolve(),
183-
replace: (href) => {
184-
// @ts-ignore startTransition exists
185-
React.startTransition(() => {
186-
navigate(href, 'hard', 'replace')
187-
})
188-
},
189-
softReplace: (href) => {
190-
// @ts-ignore startTransition exists
191-
React.startTransition(() => {
192-
navigate(href, 'soft', 'replace')
193-
})
198+
prefetch: async (href) => {
199+
// If prefetch has already been triggered, don't trigger it again.
200+
if (prefetched.has(href)) {
201+
return
202+
}
203+
204+
prefetched.add(href)
205+
206+
const url = new URL(href, location.origin)
207+
// TODO-APP: handle case where history.state is not the new router history entry
208+
const r = fetchServerResponse(
209+
url,
210+
// initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case.
211+
window.history.state?.tree || initialTree,
212+
true
213+
)
214+
try {
215+
r.readRoot()
216+
} catch (e) {
217+
await e
218+
const flightData = r.readRoot()
219+
// @ts-ignore startTransition exists
220+
React.startTransition(() => {
221+
dispatch({
222+
type: ACTION_PREFETCH,
223+
url,
224+
flightData,
225+
})
226+
})
227+
}
194228
},
195-
softPush: (href) => {
229+
replace: (href, options = {}) => {
196230
// @ts-ignore startTransition exists
197231
React.startTransition(() => {
198-
navigate(href, 'soft', 'push')
232+
navigate(href, 'replace', Boolean(options.forceOptimisticNavigation))
199233
})
200234
},
201-
push: (href) => {
235+
push: (href, options = {}) => {
202236
// @ts-ignore startTransition exists
203237
React.startTransition(() => {
204-
navigate(href, 'hard', 'push')
238+
navigate(href, 'push', Boolean(options.forceOptimisticNavigation))
205239
})
206240
},
207241
reload: () => {
@@ -211,7 +245,6 @@ export default function AppRouter({
211245
type: ACTION_RELOAD,
212246

213247
// TODO-APP: revisit if this needs to be passed.
214-
url: new URL(window.location.href),
215248
cache: {
216249
data: null,
217250
subTreeData: null,
@@ -224,7 +257,7 @@ export default function AppRouter({
224257
}
225258

226259
return routerInstance
227-
}, [])
260+
}, [dispatch, initialTree])
228261

229262
useEffect(() => {
230263
// When mpaNavigation flag is set do a hard navigation to the new url.
@@ -245,47 +278,52 @@ export default function AppRouter({
245278
} else {
246279
window.history.replaceState(historyState, '', canonicalUrl)
247280
}
248-
}, [tree, pushRef, canonicalUrl])
281+
282+
sync()
283+
}, [tree, pushRef, canonicalUrl, sync])
249284

250285
// Add `window.nd` for debugging purposes.
251286
// This is not meant for use in applications as concurrent rendering will affect the cache/tree/router.
252287
if (typeof window !== 'undefined') {
253288
// @ts-ignore this is for debugging
254-
window.nd = { router: appRouter, cache, tree }
289+
window.nd = { router: appRouter, cache, prefetchCache, tree }
255290
}
256291

257292
/**
258293
* Handle popstate event, this is used to handle back/forward in the browser.
259294
* By default dispatches ACTION_RESTORE, however if the history entry was not pushed/replaced by app-router it will reload the page.
260295
* That case can happen when the old router injected the history entry.
261296
*/
262-
const onPopState = React.useCallback(({ state }: PopStateEvent) => {
263-
if (!state) {
264-
// TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
265-
return
266-
}
297+
const onPopState = useCallback(
298+
({ state }: PopStateEvent) => {
299+
if (!state) {
300+
// TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
301+
return
302+
}
267303

268-
// TODO-APP: this case happens when pushState/replaceState was called outside of Next.js or when the history entry was pushed by the old router.
269-
// It reloads the page in this case but we might have to revisit this as the old router ignores it.
270-
if (!state.__NA) {
271-
window.location.reload()
272-
return
273-
}
304+
// TODO-APP: this case happens when pushState/replaceState was called outside of Next.js or when the history entry was pushed by the old router.
305+
// It reloads the page in this case but we might have to revisit this as the old router ignores it.
306+
if (!state.__NA) {
307+
window.location.reload()
308+
return
309+
}
274310

275-
// @ts-ignore useTransition exists
276-
// TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously
277-
// Without startTransition works if the cache is there for this path
278-
React.startTransition(() => {
279-
dispatch({
280-
type: ACTION_RESTORE,
281-
url: new URL(window.location.href),
282-
tree: state.tree,
311+
// @ts-ignore useTransition exists
312+
// TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously
313+
// Without startTransition works if the cache is there for this path
314+
React.startTransition(() => {
315+
dispatch({
316+
type: ACTION_RESTORE,
317+
url: new URL(window.location.href),
318+
tree: state.tree,
319+
})
283320
})
284-
})
285-
}, [])
321+
},
322+
[dispatch]
323+
)
286324

287325
// Register popstate event to call onPopstate.
288-
React.useEffect(() => {
326+
useEffect(() => {
289327
window.addEventListener('popstate', onPopState)
290328
return () => {
291329
window.removeEventListener('popstate', onPopState)

0 commit comments

Comments
 (0)