rsnext/packages/next/client/app-index.tsx
Tim Neutkens f113141389
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>
2022-07-06 21:16:47 +00:00

159 lines
4.6 KiB
TypeScript

/* global location */
import '../build/polyfills/polyfill-module'
// @ts-ignore react-dom/client exists when using React 18
import ReactDOMClient from 'react-dom/client'
// @ts-ignore startTransition exists when using React 18
import React from 'react'
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'
/// <reference types="react-dom/experimental" />
export const version = process.env.__NEXT_VERSION
const appElement: HTMLElement | Document | null = document
let reactRoot: any = null
function renderReactElement(
domEl: HTMLElement | Document,
fn: () => JSX.Element
): void {
const reactEl = fn()
if (!reactRoot) {
// Unlike with createRoot, you don't need a separate root.render() call here
reactRoot = (ReactDOMClient as any).hydrateRoot(domEl, reactEl)
} else {
reactRoot.render(reactEl)
}
}
const getCacheKey = () => {
const { pathname, search } = location
return pathname + search
}
const encoder = new TextEncoder()
let initialServerDataBuffer: string[] | undefined = undefined
let initialServerDataWriter: ReadableStreamDefaultController | undefined =
undefined
let initialServerDataLoaded = false
let initialServerDataFlushed = false
function nextServerDataCallback(seg: [number, string, string]) {
if (seg[0] === 0) {
initialServerDataBuffer = []
} else {
if (!initialServerDataBuffer)
throw new Error('Unexpected server data: missing bootstrap script.')
if (initialServerDataWriter) {
initialServerDataWriter.enqueue(encoder.encode(seg[2]))
} else {
initialServerDataBuffer.push(seg[2])
}
}
}
// There might be race conditions between `nextServerDataRegisterWriter` and
// `DOMContentLoaded`. The former will be called when React starts to hydrate
// the root, the latter will be called when the DOM is fully loaded.
// For streaming, the former is called first due to partial hydration.
// For non-streaming, the latter can be called first.
// Hence, we use two variables `initialServerDataLoaded` and
// `initialServerDataFlushed` to make sure the writer will be closed and
// `initialServerDataBuffer` will be cleared in the right time.
function nextServerDataRegisterWriter(ctr: ReadableStreamDefaultController) {
if (initialServerDataBuffer) {
initialServerDataBuffer.forEach((val) => {
ctr.enqueue(encoder.encode(val))
})
if (initialServerDataLoaded && !initialServerDataFlushed) {
ctr.close()
initialServerDataFlushed = true
initialServerDataBuffer = undefined
}
}
initialServerDataWriter = ctr
}
// When `DOMContentLoaded`, we can close all pending writers to finish hydration.
const DOMContentLoaded = function () {
if (initialServerDataWriter && !initialServerDataFlushed) {
initialServerDataWriter.close()
initialServerDataFlushed = true
initialServerDataBuffer = undefined
}
initialServerDataLoaded = true
}
// It's possible that the DOM is already loaded.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false)
} else {
DOMContentLoaded()
}
const nextServerDataLoadingGlobal = ((self as any).__next_s =
(self as any).__next_s || [])
nextServerDataLoadingGlobal.forEach(nextServerDataCallback)
nextServerDataLoadingGlobal.push = nextServerDataCallback
function createResponseCache() {
return new Map<string, any>()
}
const rscCache = createResponseCache()
function useInitialServerResponse(cacheKey: string) {
const response = rscCache.get(cacheKey)
if (response) return response
const readable = new ReadableStream({
start(controller) {
nextServerDataRegisterWriter(controller)
},
})
const newResponse = createFromReadableStream(readable)
rscCache.set(cacheKey, newResponse)
return newResponse
}
const ServerRoot = ({ cacheKey }: { cacheKey: string }) => {
React.useEffect(() => {
rscCache.delete(cacheKey)
})
const response = useInitialServerResponse(cacheKey)
const root = response.readRoot()
return root
}
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
if (process.env.__NEXT_TEST_MODE) {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
window.__NEXT_HYDRATED = true
if (window.__NEXT_HYDRATED_CB) {
window.__NEXT_HYDRATED_CB()
}
}, [])
}
return children as React.ReactElement
}
const RSCComponent = () => {
const cacheKey = getCacheKey()
return <ServerRoot cacheKey={cacheKey} />
}
export function hydrate() {
renderReactElement(appElement!, () => (
<React.StrictMode>
<Root>
<RSCComponent />
</Root>
</React.StrictMode>
))
}