Skip to content

Commit f113141

Browse files
timneutkenshuozhiijjk
authoredJul 6, 2022
Implement new client-side router (#37551)
## Client-side router for `app` directory This PR implements the new router that leverages React 18 concurrent features like Suspense and startTransition. It also integrates with React Server Components and builds on top of it to allow server-centric routing that only renders the part of the page that has to change. It's one of the pieces of the implementation of https://nextjs.org/blog/layouts-rfc. ## Details I'm going to document the differences with the current router here (will be reworked for the upgrade guide) ### Client-side cache In the current router we have an in-memory cache for getStaticProps data so that if you prefetch and then navigate to a route that has been prefetched it'll be near-instant. For getServerSideProps the behavior is different, any navigation to a page with getServerSideProps fetches the data again. In the new model the cache is a fundamental piece, it's more granular than at the page level and is set up to ensure consistency across concurrent renders. It can also be invalidated at any level. #### Push/Replace (also applies to next/link) The new router still has a `router.push` / `router.replace` method. There are a few differences in how it works though: - It only takes `href` as an argument, historically you had to provide `href` (the page path) and `as` (the actual url path) to do dynamic routing. In later versions of Next.js this is no longer required and in the majority of cases `as` was no longer needed. In the new router there's no way to reason about `href` vs `as` because there is no notion of "pages" in the browser. - Both methods now use `startTransition`, you can wrap these in your own `startTransition` to get `isPending` - The push/replace support concurrent rendering. When a render is bailed by clicking a different link to navigate to a completely different page that still works and doesn't cause race conditions. - Support for optimistic loading states when navigating ##### Hard/Soft push/replace Because of the client-side cache being reworked this now allows us to cover two cases: hard push and soft push. The main difference between the two is if the cache is reused while navigating. The default for `next/link` is a `hard` push which means that the part of the cache affected by the navigation will be invalidated, e.g. if you already navigated to `/dashboard` and you `router.push('/dashboard')` again it'll get the latest version. This is similar to the existing `getServerSideProps` handling. In case of a soft push (API to be defined but for testing added `router.softPush('/')`) it'll reuse the existing cache and not invalidate parts that are already filled in. In practice this means it's more like the `getStaticProps` client-side navigation because it does not fetch on navigation except if a part of the page is missing. #### Back/Forward navigation Back and Forward navigation ([popstate](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event)) are always handled as a soft navigation, meaning that the cache is reused, this ensures back/forward navigation is near-instant when it's in the client-side cache. This will also allow back/forward navigation to be a high priority update instead of a transition as it is based on user interaction. Note: in this PR it still uses `startTransition` as there's no way to handle the high priority update suspending which happens in case of missing data in the cache. We're working with the React team on a solution for this particular case. ### Layouts Note: this section assumes you've read [The layouts RFC](https://nextjs.org/blog/layouts-rfc) and [React Server Components RFC](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html) React Server Components rendering leverages the Flight streaming mechanism in React 18, this allows sending a serializable representation of the rendered React tree on the server to the browser, the client-side React can use this serialized representation to render components client-side without the JavaScript being sent to the browser. This is one of the building blocks of Server Components. This allows a bunch of interesting features but for now I'll keep it to how it affects layouts. When you have a `app/dashboard/layout.js` and `app/dashboard/page.js` the page will render as children of the layout, when you add another page like `app/dashboard/integrations/page.js` that page falls under the dashboard layout as well. When client-side navigating the new router automatically figures out if the page you're navigating to can be a smaller render than the whole page, in this case `app/dashboard/page.js` and `app/dashboard/integrations/page.js` share the `app/dashboard/layout.js` so instead of rendering the whole page we render below the layout component, this means the layout itself does not get re-rendered, the layout's `getServerSideProps` would not be called, and the Flight response would only hold the result of `app/dashboard/integrations/page.js`, effectively giving you the smallest patch for the UI. --- Note: the commits in this PR were mostly work in progress to ensure it wasn't lost along the way. The implementation was reworked a bunch of times to where it is now. Co-authored-by: Jiachi Liu <4800338+huozhi@users.noreply.github.com> Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
1 parent 6e2c382 commit f113141

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1931
-437
lines changed
 

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
"eslint-plugin-import": "2.22.1",
129129
"eslint-plugin-jest": "24.3.5",
130130
"eslint-plugin-react": "7.23.2",
131-
"eslint-plugin-react-hooks": "4.2.0",
131+
"eslint-plugin-react-hooks": "4.5.0",
132132
"event-stream": "4.0.1",
133133
"execa": "2.0.3",
134134
"express": "4.17.0",

‎packages/next/build/webpack/loaders/next-app-loader.ts

+86-105
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,93 @@
1-
import path from 'path'
21
import type webpack from 'webpack5'
32
import { NODE_RESOLVE_OPTIONS } from '../../webpack-config'
43
import { getModuleBuildInfo } from './get-module-build-info'
54

6-
function pathToUrlPath(pathname: string) {
7-
let urlPath = pathname.replace(/^private-next-app-dir/, '')
8-
9-
// For `app/layout.js`
10-
if (urlPath === '') {
11-
urlPath = '/'
12-
}
13-
14-
return urlPath
15-
}
16-
17-
async function resolvePathsByPage({
18-
name,
5+
async function createTreeCodeFromPath({
196
pagePath,
207
resolve,
8+
removeExt,
219
}: {
22-
name: 'layout' | 'loading'
2310
pagePath: string
2411
resolve: (pathname: string) => Promise<string | undefined>
12+
removeExt: (pathToRemoveExtensions: string) => string
2513
}) {
26-
const paths = new Map<string, string | undefined>()
27-
const parts = pagePath.split('/')
14+
let tree: undefined | string
15+
const splittedPath = pagePath.split('/')
16+
const appDirPrefix = splittedPath[0]
17+
18+
const segments = ['', ...splittedPath.slice(1)]
2819
const isNewRootLayout =
29-
parts[1]?.length > 2 && parts[1]?.startsWith('(') && parts[1]?.endsWith(')')
20+
segments[0]?.length > 2 &&
21+
segments[0]?.startsWith('(') &&
22+
segments[0]?.endsWith(')')
23+
24+
let isCustomRootLayout = false
3025

31-
for (let i = parts.length; i >= 0; i--) {
32-
const pathWithoutSlashLayout = parts.slice(0, i).join('/')
26+
// segment.length - 1 because arrays start at 0 and we're decrementing
27+
for (let i = segments.length - 1; i >= 0; i--) {
28+
const segment = removeExt(segments[i])
29+
const segmentPath = segments.slice(0, i + 1).join('/')
3330

34-
if (!pathWithoutSlashLayout) {
31+
// First item in the list is the page which can't have layouts by itself
32+
if (i === segments.length - 1) {
33+
// Use '' for segment as it's the page. There can't be a segment called '' so this is the safest way to add it.
34+
tree = `['', {}, {page: () => require('${pagePath}')}]`
3535
continue
3636
}
37-
const layoutPath = `${pathWithoutSlashLayout}/${name}`
38-
let resolvedLayoutPath = await resolve(layoutPath)
39-
let urlPath = pathToUrlPath(pathWithoutSlashLayout)
37+
38+
// For segmentPath === '' avoid double `/`
39+
const layoutPath = `${appDirPrefix}${segmentPath}/layout`
40+
// For segmentPath === '' avoid double `/`
41+
const loadingPath = `${appDirPrefix}${segmentPath}/loading`
42+
43+
const resolvedLayoutPath = await resolve(layoutPath)
44+
const resolvedLoadingPath = await resolve(loadingPath)
4045

4146
// if we are in a new root app/(root) and a custom root layout was
4247
// not provided or a root layout app/layout is not present, we use
4348
// a default root layout to provide the html/body tags
44-
const isCustomRootLayout = name === 'layout' && isNewRootLayout && i === 2
45-
46-
if (
47-
name === 'layout' &&
48-
(isCustomRootLayout || i === 1) &&
49-
!resolvedLayoutPath
50-
) {
51-
resolvedLayoutPath = await resolve('next/dist/lib/app-layout')
52-
}
53-
paths.set(urlPath, resolvedLayoutPath)
49+
isCustomRootLayout = isNewRootLayout && i === 1
50+
51+
// Existing tree are the children of the current segment
52+
const children = tree
53+
54+
tree = `['${segment}', {
55+
${
56+
// When there are no children the current index is the page component
57+
children ? `children: ${children},` : ''
58+
}
59+
}, {
60+
${
61+
resolvedLayoutPath
62+
? `layout: () => require('${resolvedLayoutPath}'),`
63+
: ''
64+
}
65+
${
66+
resolvedLoadingPath
67+
? `loading: () => require('${resolvedLoadingPath}'),`
68+
: ''
69+
}
70+
}]`
5471

5572
// if we're in a new root layout don't add the top-level app/layout
5673
if (isCustomRootLayout) {
5774
break
5875
}
5976
}
60-
return paths
77+
78+
return `const tree = ${tree};`
79+
}
80+
81+
function createAbsolutePath(appDir: string, pathToTurnAbsolute: string) {
82+
return pathToTurnAbsolute.replace(/^private-next-app-dir/, appDir)
83+
}
84+
85+
function removeExtensions(
86+
extensions: string[],
87+
pathToRemoveExtensions: string
88+
) {
89+
const regex = new RegExp(`(${extensions.join('|')})$`.replace(/\./g, '\\.'))
90+
return pathToRemoveExtensions.replace(regex, '')
6191
}
6292

6393
const nextAppLoader: webpack.LoaderDefinitionFunction<{
@@ -71,7 +101,7 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
71101
const buildInfo = getModuleBuildInfo((this as any)._module)
72102
buildInfo.route = {
73103
page: name.replace(/^app/, ''),
74-
absolutePagePath: appDir + pagePath.replace(/^private-next-app-dir/, ''),
104+
absolutePagePath: createAbsolutePath(appDir, pagePath),
75105
}
76106

77107
const extensions = pageExtensions.map((extension) => `.${extension}`)
@@ -81,89 +111,40 @@ const nextAppLoader: webpack.LoaderDefinitionFunction<{
81111
}
82112
const resolve = this.getResolve(resolveOptions)
83113

84-
const loadingPaths = await resolvePathsByPage({
85-
name: 'loading',
86-
pagePath: pagePath,
87-
resolve: async (pathname) => {
88-
try {
89-
return await resolve(this.rootContext, pathname)
90-
} catch (err: any) {
91-
if (err.message.includes("Can't resolve")) {
92-
return undefined
93-
}
94-
throw err
95-
}
96-
},
97-
})
98-
99-
const loadingComponentsCode = []
100-
for (const [loadingPath, resolvedLoadingPath] of loadingPaths) {
101-
if (resolvedLoadingPath) {
102-
this.addDependency(resolvedLoadingPath)
103-
// use require so that we can bust the require cache
104-
const codeLine = `'${loadingPath}': () => require('${resolvedLoadingPath}')`
105-
loadingComponentsCode.push(codeLine)
106-
} else {
114+
const resolver = async (pathname: string) => {
115+
try {
116+
const resolved = await resolve(this.rootContext, pathname)
117+
this.addDependency(resolved)
118+
return resolved
119+
} catch (err: any) {
120+
const absolutePath = createAbsolutePath(appDir, pathname)
107121
for (const ext of extensions) {
108-
this.addMissingDependency(
109-
path.join(appDir, loadingPath, `layout${ext}`)
110-
)
111-
}
112-
}
113-
}
114-
115-
const layoutPaths = await resolvePathsByPage({
116-
name: 'layout',
117-
pagePath: pagePath,
118-
resolve: async (pathname) => {
119-
try {
120-
return await resolve(this.rootContext, pathname)
121-
} catch (err: any) {
122-
if (err.message.includes("Can't resolve")) {
123-
return undefined
124-
}
125-
throw err
122+
const absolutePathWithExtension = `${absolutePath}${ext}`
123+
this.addMissingDependency(absolutePathWithExtension)
126124
}
127-
},
128-
})
129-
130-
const componentsCode = []
131-
for (const [layoutPath, resolvedLayoutPath] of layoutPaths) {
132-
if (resolvedLayoutPath) {
133-
this.addDependency(resolvedLayoutPath)
134-
// use require so that we can bust the require cache
135-
const codeLine = `'${layoutPath}': () => require('${resolvedLayoutPath}')`
136-
componentsCode.push(codeLine)
137-
} else {
138-
for (const ext of extensions) {
139-
this.addMissingDependency(path.join(appDir, layoutPath, `layout${ext}`))
125+
if (err.message.includes("Can't resolve")) {
126+
return undefined
140127
}
128+
throw err
141129
}
142130
}
143131

144-
// Add page itself to the list of components
145-
componentsCode.push(
146-
`'${pathToUrlPath(pagePath).replace(
147-
new RegExp(`(${extensions.join('|')})$`),
148-
''
149-
// use require so that we can bust the require cache
150-
)}': () => require('${pagePath}')`
151-
)
132+
const treeCode = await createTreeCodeFromPath({
133+
pagePath,
134+
resolve: resolver,
135+
removeExt: (p) => removeExtensions(extensions, p),
136+
})
152137

153138
const result = `
154-
export const components = {
155-
${componentsCode.join(',\n')}
156-
};
157-
158-
export const loadingComponents = {
159-
${loadingComponentsCode.join(',\n')}
160-
};
139+
export ${treeCode}
161140
162141
export const AppRouter = require('next/dist/client/components/app-router.client.js').default
163142
export const LayoutRouter = require('next/dist/client/components/layout-router.client.js').default
143+
export const hooksClientContext = require('next/dist/client/components/hooks-client-context.js')
164144
165145
export const __next_app_webpack_require__ = __webpack_require__
166146
`
147+
167148
return result
168149
}
169150

0 commit comments

Comments
 (0)
Please sign in to comment.