71ad0dd0b0
Follow-up to #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">
1099 lines
34 KiB
TypeScript
1099 lines
34 KiB
TypeScript
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'
|
|
import type { LoadComponentsReturnType } from './load-components'
|
|
import type { ServerRuntime } from '../types'
|
|
|
|
import React from 'react'
|
|
import { ParsedUrlQuery, stringify as stringifyQuery } from 'querystring'
|
|
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack'
|
|
import { renderToReadableStream } from 'next/dist/compiled/react-server-dom-webpack/writer.browser.server'
|
|
import { NextParsedUrlQuery } from './request-meta'
|
|
import RenderResult from './render-result'
|
|
import {
|
|
readableStreamTee,
|
|
encodeText,
|
|
decodeText,
|
|
renderToInitialStream,
|
|
createBufferedTransformStream,
|
|
continueFromInitialStream,
|
|
} from './node-web-streams-helper'
|
|
import { isDynamicRoute } from '../shared/lib/router/utils'
|
|
import { htmlEscapeJsonString } from './htmlescape'
|
|
import { shouldUseReactRoot, stripInternalQueries } from './utils'
|
|
import { NextApiRequestCookies } from './api-utils'
|
|
import { matchSegment } from '../client/components/match-segments'
|
|
import {
|
|
FlightCSSManifest,
|
|
FlightManifest,
|
|
} from '../build/webpack/plugins/flight-manifest-plugin'
|
|
import { FlushEffectsContext } from '../client/components/hooks-client'
|
|
|
|
// this needs to be required lazily so that `next-server` can set
|
|
// the env before we require
|
|
const ReactDOMServer = shouldUseReactRoot
|
|
? require('react-dom/server.browser')
|
|
: require('react-dom/server')
|
|
|
|
export type RenderOptsPartial = {
|
|
err?: Error | null
|
|
dev?: boolean
|
|
serverComponentManifest?: FlightManifest
|
|
serverCSSManifest?: FlightCSSManifest
|
|
supportsDynamicHTML?: boolean
|
|
runtime?: ServerRuntime
|
|
serverComponents?: boolean
|
|
assetPrefix?: string
|
|
}
|
|
|
|
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
|
|
|
|
/**
|
|
* Interop between "export default" and "module.exports".
|
|
*/
|
|
function interopDefault(mod: any) {
|
|
return mod.default || mod
|
|
}
|
|
|
|
const rscCache = new Map()
|
|
|
|
// Shadowing check does not work with TypeScript enums
|
|
// eslint-disable-next-line no-shadow
|
|
const enum RecordStatus {
|
|
Pending,
|
|
Resolved,
|
|
Rejected,
|
|
}
|
|
|
|
type Record = {
|
|
status: RecordStatus
|
|
// Could hold the existing promise or the resolved Promise
|
|
value: any
|
|
}
|
|
|
|
/**
|
|
* Create data fetching record for Promise.
|
|
*/
|
|
function createRecordFromThenable(thenable: Promise<any>) {
|
|
const record: Record = {
|
|
status: RecordStatus.Pending,
|
|
value: thenable,
|
|
}
|
|
thenable.then(
|
|
function (value) {
|
|
if (record.status === RecordStatus.Pending) {
|
|
const resolvedRecord = record
|
|
resolvedRecord.status = RecordStatus.Resolved
|
|
resolvedRecord.value = value
|
|
}
|
|
},
|
|
function (err) {
|
|
if (record.status === RecordStatus.Pending) {
|
|
const rejectedRecord = record
|
|
rejectedRecord.status = RecordStatus.Rejected
|
|
rejectedRecord.value = err
|
|
}
|
|
}
|
|
)
|
|
return record
|
|
}
|
|
|
|
/**
|
|
* Read record value or throw Promise if it's not resolved yet.
|
|
*/
|
|
function readRecordValue(record: Record) {
|
|
if (record.status === RecordStatus.Resolved) {
|
|
return record.value
|
|
} else {
|
|
throw record.value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preload data fetching record before it is called during React rendering.
|
|
* If the record is already in the cache returns that record.
|
|
*/
|
|
function preloadDataFetchingRecord(
|
|
map: Map<string, Record>,
|
|
key: string,
|
|
fetcher: () => Promise<any> | any
|
|
) {
|
|
let record = map.get(key)
|
|
|
|
if (!record) {
|
|
const thenable = fetcher()
|
|
record = createRecordFromThenable(thenable)
|
|
map.set(key, record)
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
/**
|
|
* Render Flight stream.
|
|
* This is only used for renderToHTML, the Flight response does not need additional wrappers.
|
|
*/
|
|
function useFlightResponse(
|
|
writable: WritableStream<Uint8Array>,
|
|
cachePrefix: string,
|
|
req: ReadableStream<Uint8Array>,
|
|
serverComponentManifest: any
|
|
) {
|
|
const id = cachePrefix + ',' + (React as any).useId()
|
|
let entry = rscCache.get(id)
|
|
if (!entry) {
|
|
const [renderStream, forwardStream] = readableStreamTee(req)
|
|
entry = createFromReadableStream(renderStream, {
|
|
moduleMap: serverComponentManifest.__ssr_module_mapping__,
|
|
})
|
|
rscCache.set(id, entry)
|
|
|
|
let bootstrapped = false
|
|
// We only attach CSS chunks to the inlined data.
|
|
const forwardReader = forwardStream.getReader()
|
|
const writer = writable.getWriter()
|
|
function process() {
|
|
forwardReader.read().then(({ done, value }) => {
|
|
if (!bootstrapped) {
|
|
bootstrapped = true
|
|
writer.write(
|
|
encodeText(
|
|
`<script>(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
|
|
JSON.stringify([0, id])
|
|
)})</script>`
|
|
)
|
|
)
|
|
}
|
|
if (done) {
|
|
rscCache.delete(id)
|
|
writer.close()
|
|
} else {
|
|
const responsePartial = decodeText(value)
|
|
const scripts = `<script>(self.__next_s=self.__next_s||[]).push(${htmlEscapeJsonString(
|
|
JSON.stringify([1, id, responsePartial])
|
|
)})</script>`
|
|
|
|
writer.write(encodeText(scripts))
|
|
process()
|
|
}
|
|
})
|
|
}
|
|
process()
|
|
}
|
|
return entry
|
|
}
|
|
|
|
/**
|
|
* Create a component that renders the Flight stream.
|
|
* This is only used for renderToHTML, the Flight response does not need additional wrappers.
|
|
*/
|
|
function createServerComponentRenderer(
|
|
ComponentToRender: React.ComponentType,
|
|
ComponentMod: {
|
|
__next_app_webpack_require__?: any
|
|
__next_rsc__?: {
|
|
__webpack_require__?: any
|
|
}
|
|
},
|
|
{
|
|
cachePrefix,
|
|
transformStream,
|
|
serverComponentManifest,
|
|
serverContexts,
|
|
}: {
|
|
cachePrefix: string
|
|
transformStream: TransformStream<Uint8Array, Uint8Array>
|
|
serverComponentManifest: NonNullable<RenderOpts['serverComponentManifest']>
|
|
serverContexts: Array<
|
|
[ServerContextName: string, JSONValue: Object | number | string]
|
|
>
|
|
}
|
|
) {
|
|
// We need to expose the `__webpack_require__` API globally for
|
|
// react-server-dom-webpack. This is a hack until we find a better way.
|
|
if (ComponentMod.__next_app_webpack_require__ || ComponentMod.__next_rsc__) {
|
|
// @ts-ignore
|
|
globalThis.__next_require__ =
|
|
ComponentMod.__next_app_webpack_require__ ||
|
|
ComponentMod.__next_rsc__?.__webpack_require__
|
|
|
|
// @ts-ignore
|
|
globalThis.__next_chunk_load__ = () => Promise.resolve()
|
|
}
|
|
|
|
let RSCStream: ReadableStream<Uint8Array>
|
|
const createRSCStream = () => {
|
|
if (!RSCStream) {
|
|
RSCStream = renderToReadableStream(
|
|
<ComponentToRender />,
|
|
serverComponentManifest,
|
|
{
|
|
context: serverContexts,
|
|
}
|
|
)
|
|
}
|
|
return RSCStream
|
|
}
|
|
|
|
const writable = transformStream.writable
|
|
return function ServerComponentWrapper() {
|
|
const reqStream = createRSCStream()
|
|
const response = useFlightResponse(
|
|
writable,
|
|
cachePrefix,
|
|
reqStream,
|
|
serverComponentManifest
|
|
)
|
|
return response.readRoot()
|
|
}
|
|
}
|
|
|
|
type DynamicParamTypes = 'catchall' | 'optional-catchall' | 'dynamic'
|
|
// c = catchall
|
|
// oc = optional catchall
|
|
// d = dynamic
|
|
export type DynamicParamTypesShort = 'c' | 'oc' | 'd'
|
|
|
|
/**
|
|
* Shorten the dynamic param in order to make it smaller when transmitted to the browser.
|
|
*/
|
|
function getShortDynamicParamType(
|
|
type: DynamicParamTypes
|
|
): DynamicParamTypesShort {
|
|
switch (type) {
|
|
case 'catchall':
|
|
return 'c'
|
|
case 'optional-catchall':
|
|
return 'oc'
|
|
case 'dynamic':
|
|
return 'd'
|
|
default:
|
|
throw new Error('Unknown dynamic param type')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Segment in the router state.
|
|
*/
|
|
export type Segment =
|
|
| string
|
|
| [param: string, value: string, type: DynamicParamTypesShort]
|
|
|
|
/**
|
|
* LoaderTree is generated in next-app-loader.
|
|
*/
|
|
type LoaderTree = [
|
|
segment: string,
|
|
parallelRoutes: { [parallelRouterKey: string]: LoaderTree },
|
|
components: {
|
|
filePath: string
|
|
layout?: () => any
|
|
loading?: () => any
|
|
page?: () => any
|
|
}
|
|
]
|
|
|
|
/**
|
|
* Router state
|
|
*/
|
|
export type FlightRouterState = [
|
|
segment: Segment,
|
|
parallelRoutes: { [parallelRouterKey: string]: FlightRouterState },
|
|
url?: string,
|
|
refresh?: 'refetch'
|
|
]
|
|
|
|
/**
|
|
* Individual Flight response path
|
|
*/
|
|
export type FlightSegmentPath =
|
|
// Uses `any` as repeating pattern can't be typed.
|
|
| any[]
|
|
// Looks somewhat like this
|
|
| [
|
|
segment: Segment,
|
|
parallelRouterKey: string,
|
|
segment: Segment,
|
|
parallelRouterKey: string,
|
|
segment: Segment,
|
|
parallelRouterKey: string
|
|
]
|
|
|
|
export type FlightDataPath =
|
|
// Uses `any` as repeating pattern can't be typed.
|
|
| any[]
|
|
// Looks somewhat like this
|
|
| [
|
|
// Holds full path to the segment.
|
|
...FlightSegmentPath,
|
|
/* segment of the rendered slice: */ Segment,
|
|
/* treePatch */ FlightRouterState,
|
|
/* subTreeData: */ React.ReactNode | null // Can be null during prefetch if there's no loading component
|
|
]
|
|
|
|
/**
|
|
* The Flight response data
|
|
*/
|
|
export type FlightData = Array<FlightDataPath> | string
|
|
|
|
/**
|
|
* Property holding the current subTreeData.
|
|
*/
|
|
export type ChildProp = {
|
|
/**
|
|
* Null indicates that the tree is partial
|
|
*/
|
|
current: React.ReactNode | null
|
|
segment: Segment
|
|
}
|
|
|
|
/**
|
|
* Parse dynamic route segment to type of parameter
|
|
*/
|
|
function getSegmentParam(segment: string): {
|
|
param: string
|
|
type: DynamicParamTypes
|
|
} | null {
|
|
if (segment.startsWith('[[...') && segment.endsWith(']]')) {
|
|
return {
|
|
type: 'optional-catchall',
|
|
param: segment.slice(5, -2),
|
|
}
|
|
}
|
|
|
|
if (segment.startsWith('[...') && segment.endsWith(']')) {
|
|
return {
|
|
type: 'catchall',
|
|
param: segment.slice(4, -1),
|
|
}
|
|
}
|
|
|
|
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
return {
|
|
type: 'dynamic',
|
|
param: segment.slice(1, -1),
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get inline <link> tags based on server CSS manifest. Only used when rendering to HTML.
|
|
*/
|
|
function getCssInlinedLinkTags(
|
|
serverComponentManifest: FlightManifest,
|
|
serverCSSManifest: FlightCSSManifest,
|
|
filePath: string
|
|
): string[] {
|
|
const layoutOrPageCss =
|
|
serverCSSManifest[filePath] ||
|
|
serverComponentManifest.__client_css_manifest__?.[filePath]
|
|
|
|
if (!layoutOrPageCss) {
|
|
return []
|
|
}
|
|
|
|
const chunks = new Set<string>()
|
|
|
|
for (const css of layoutOrPageCss) {
|
|
const mod = serverComponentManifest[css]
|
|
if (mod) {
|
|
for (const chunk of mod.default.chunks) {
|
|
chunks.add(chunk)
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...chunks]
|
|
}
|
|
|
|
export async function renderToHTMLOrFlight(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
pathname: string,
|
|
query: NextParsedUrlQuery,
|
|
renderOpts: RenderOpts,
|
|
isPagesDir: boolean
|
|
): Promise<RenderResult | null> {
|
|
// @ts-expect-error createServerContext exists in react@experimental + react-dom@experimental
|
|
if (typeof React.createServerContext === 'undefined') {
|
|
throw new Error(
|
|
'"app" directory requires React.createServerContext which is not available in the version of React you are using. Please update to react@experimental and react-dom@experimental.'
|
|
)
|
|
}
|
|
|
|
// don't modify original query object
|
|
query = Object.assign({}, query)
|
|
|
|
const {
|
|
buildManifest,
|
|
serverComponentManifest,
|
|
serverCSSManifest = {},
|
|
supportsDynamicHTML,
|
|
ComponentMod,
|
|
} = renderOpts
|
|
|
|
const isFlight = query.__flight__ !== undefined
|
|
const isPrefetch = query.__flight_prefetch__ !== undefined
|
|
|
|
// Handle client-side navigation to pages directory
|
|
if (isFlight && isPagesDir) {
|
|
stripInternalQueries(query)
|
|
const search = stringifyQuery(query)
|
|
|
|
// Empty so that the client-side router will do a full page navigation.
|
|
const flightData: FlightData = pathname + (search ? `?${search}` : '')
|
|
return new RenderResult(
|
|
renderToReadableStream(flightData, serverComponentManifest).pipeThrough(
|
|
createBufferedTransformStream()
|
|
)
|
|
)
|
|
}
|
|
|
|
// TODO-APP: verify the tree is valid
|
|
// TODO-APP: verify query param is single value (not an array)
|
|
// TODO-APP: verify tree can't grow out of control
|
|
/**
|
|
* Router state provided from the client-side router. Used to handle rendering from the common layout down.
|
|
*/
|
|
const providedFlightRouterState: FlightRouterState = isFlight
|
|
? query.__flight_router_state_tree__
|
|
? JSON.parse(query.__flight_router_state_tree__ as string)
|
|
: {}
|
|
: undefined
|
|
|
|
stripInternalQueries(query)
|
|
|
|
const pageIsDynamic = isDynamicRoute(pathname)
|
|
const LayoutRouter =
|
|
ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default
|
|
const HotReloader = ComponentMod.HotReloader as
|
|
| typeof import('../client/components/hot-reloader.client').default
|
|
| null
|
|
|
|
const headers = req.headers
|
|
// TODO-APP: fix type of req
|
|
// @ts-expect-error
|
|
const cookies = req.cookies
|
|
|
|
/**
|
|
* The tree created in next-app-loader that holds component segments and modules
|
|
*/
|
|
const loaderTree: LoaderTree = ComponentMod.tree
|
|
|
|
const tryGetPreviewData =
|
|
process.env.NEXT_RUNTIME === 'edge'
|
|
? () => false
|
|
: require('./api-utils/node').tryGetPreviewData
|
|
|
|
// Reads of this are cached on the `req` object, so this should resolve
|
|
// instantly. There's no need to pass this data down from a previous
|
|
// invoke, where we'd have to consider server & serverless.
|
|
const previewData = tryGetPreviewData(
|
|
req,
|
|
res,
|
|
(renderOpts as any).previewProps
|
|
)
|
|
const isPreview = previewData !== false
|
|
/**
|
|
* Server Context is specifically only available in Server Components.
|
|
* It has to hold values that can't change while rendering from the common layout down.
|
|
* An example of this would be that `headers` are available but `searchParams` are not because that'd mean we have to render from the root layout down on all requests.
|
|
*/
|
|
const serverContexts: Array<[string, any]> = [
|
|
['WORKAROUND', null], // TODO-APP: First value has a bug currently where the value is not set on the second request: https://github.com/facebook/react/issues/24849
|
|
['HeadersContext', headers],
|
|
['CookiesContext', cookies],
|
|
['PreviewDataContext', previewData],
|
|
]
|
|
|
|
/**
|
|
* Used to keep track of in-flight / resolved data fetching Promises.
|
|
*/
|
|
const dataCache = new Map<string, Record>()
|
|
|
|
type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath
|
|
|
|
/**
|
|
* Dynamic parameters. E.g. when you visit `/dashboard/vercel` which is rendered by `/dashboard/[slug]` the value will be {"slug": "vercel"}.
|
|
*/
|
|
const pathParams = (renderOpts as any).params as ParsedUrlQuery
|
|
|
|
/**
|
|
* Parse the dynamic segment and return the associated value.
|
|
*/
|
|
const getDynamicParamFromSegment = (
|
|
// [slug] / [[slug]] / [...slug]
|
|
segment: string
|
|
): {
|
|
param: string
|
|
value: string | string[] | null
|
|
treeSegment: Segment
|
|
type: DynamicParamTypesShort
|
|
} | null => {
|
|
const segmentParam = getSegmentParam(segment)
|
|
if (!segmentParam) {
|
|
return null
|
|
}
|
|
|
|
const key = segmentParam.param
|
|
const value = pathParams[key]
|
|
|
|
if (!value) {
|
|
// Handle case where optional catchall does not have a value, e.g. `/dashboard/[...slug]` when requesting `/dashboard`
|
|
if (segmentParam.type === 'optional-catchall') {
|
|
const type = getShortDynamicParamType(segmentParam.type)
|
|
return {
|
|
param: key,
|
|
value: null,
|
|
type: type,
|
|
// This value always has to be a string.
|
|
treeSegment: [key, '', type],
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const type = getShortDynamicParamType(segmentParam.type)
|
|
|
|
return {
|
|
param: key,
|
|
// The value that is passed to user code.
|
|
value: value,
|
|
// The value that is rendered in the router tree.
|
|
treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type],
|
|
type: type,
|
|
}
|
|
}
|
|
|
|
const createFlightRouterStateFromLoaderTree = ([
|
|
segment,
|
|
parallelRoutes,
|
|
]: LoaderTree): FlightRouterState => {
|
|
const dynamicParam = getDynamicParamFromSegment(segment)
|
|
|
|
const segmentTree: FlightRouterState = [
|
|
dynamicParam ? dynamicParam.treeSegment : segment,
|
|
{},
|
|
]
|
|
|
|
if (parallelRoutes) {
|
|
segmentTree[1] = Object.keys(parallelRoutes).reduce(
|
|
(existingValue, currentValue) => {
|
|
existingValue[currentValue] = createFlightRouterStateFromLoaderTree(
|
|
parallelRoutes[currentValue]
|
|
)
|
|
return existingValue
|
|
},
|
|
{} as FlightRouterState[1]
|
|
)
|
|
}
|
|
|
|
return segmentTree
|
|
}
|
|
|
|
/**
|
|
* Use the provided loader tree to create the React Component tree.
|
|
*/
|
|
const createComponentTree = async ({
|
|
createSegmentPath,
|
|
loaderTree: [segment, parallelRoutes, { filePath, layout, loading, page }],
|
|
parentParams,
|
|
firstItem,
|
|
rootLayoutIncluded,
|
|
}: {
|
|
createSegmentPath: CreateSegmentPath
|
|
loaderTree: LoaderTree
|
|
parentParams: { [key: string]: any }
|
|
rootLayoutIncluded?: boolean
|
|
firstItem?: boolean
|
|
}): Promise<{ Component: React.ComponentType }> => {
|
|
// TODO-APP: enable stylesheet per layout/page
|
|
const stylesheets = getCssInlinedLinkTags(
|
|
serverComponentManifest,
|
|
serverCSSManifest!,
|
|
filePath
|
|
)
|
|
const Loading = loading ? await interopDefault(loading()) : undefined
|
|
const isLayout = typeof layout !== 'undefined'
|
|
const isPage = typeof page !== 'undefined'
|
|
const layoutOrPageMod = isLayout
|
|
? await layout()
|
|
: isPage
|
|
? await page()
|
|
: undefined
|
|
/**
|
|
* Checks if the current segment is a root layout.
|
|
*/
|
|
const rootLayoutAtThisLevel = isLayout && !rootLayoutIncluded
|
|
/**
|
|
* Checks if the current segment or any level above it has a root layout.
|
|
*/
|
|
const rootLayoutIncludedAtThisLevelOrAbove =
|
|
rootLayoutIncluded || rootLayoutAtThisLevel
|
|
|
|
/**
|
|
* Check if the current layout/page is a client component
|
|
*/
|
|
const isClientComponentModule =
|
|
layoutOrPageMod && !layoutOrPageMod.hasOwnProperty('__next_rsc__')
|
|
|
|
/**
|
|
* The React Component to render.
|
|
*/
|
|
const Component = layoutOrPageMod
|
|
? interopDefault(layoutOrPageMod)
|
|
: undefined
|
|
|
|
// Handle dynamic segment params.
|
|
const segmentParam = getDynamicParamFromSegment(segment)
|
|
/**
|
|
* Create object holding the parent params and current params, this is passed to getServerSideProps and getStaticProps.
|
|
*/
|
|
const currentParams =
|
|
// Handle null case where dynamic param is optional
|
|
segmentParam && segmentParam.value !== null
|
|
? {
|
|
...parentParams,
|
|
[segmentParam.param]: segmentParam.value,
|
|
}
|
|
: // Pass through parent params to children
|
|
parentParams
|
|
// Resolve the segment param
|
|
const actualSegment = segmentParam ? segmentParam.treeSegment : segment
|
|
|
|
// This happens outside of rendering in order to eagerly kick off data fetching for layouts / the page further down
|
|
const parallelRouteMap = await Promise.all(
|
|
Object.keys(parallelRoutes).map(
|
|
async (parallelRouteKey): Promise<[string, React.ReactNode]> => {
|
|
const currentSegmentPath: FlightSegmentPath = firstItem
|
|
? [parallelRouteKey]
|
|
: [actualSegment, parallelRouteKey]
|
|
|
|
const childSegment = parallelRoutes[parallelRouteKey][0]
|
|
const childSegmentParam = getDynamicParamFromSegment(childSegment)
|
|
|
|
if (isPrefetch && Loading) {
|
|
const childProp: ChildProp = {
|
|
// Null indicates the tree is not fully rendered
|
|
current: null,
|
|
segment: childSegmentParam
|
|
? childSegmentParam.treeSegment
|
|
: childSegment,
|
|
}
|
|
|
|
// This is turned back into an object below.
|
|
return [
|
|
parallelRouteKey,
|
|
<LayoutRouter
|
|
parallelRouterKey={parallelRouteKey}
|
|
segmentPath={createSegmentPath(currentSegmentPath)}
|
|
loading={Loading ? <Loading /> : undefined}
|
|
childProp={childProp}
|
|
rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove}
|
|
/>,
|
|
]
|
|
}
|
|
|
|
// Create the child component
|
|
const { Component: ChildComponent } = await createComponentTree({
|
|
createSegmentPath: (child) => {
|
|
return createSegmentPath([...currentSegmentPath, ...child])
|
|
},
|
|
loaderTree: parallelRoutes[parallelRouteKey],
|
|
parentParams: currentParams,
|
|
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
|
|
})
|
|
|
|
const childProp: ChildProp = {
|
|
current: <ChildComponent />,
|
|
segment: childSegmentParam
|
|
? childSegmentParam.treeSegment
|
|
: childSegment,
|
|
}
|
|
|
|
// This is turned back into an object below.
|
|
return [
|
|
parallelRouteKey,
|
|
<LayoutRouter
|
|
parallelRouterKey={parallelRouteKey}
|
|
segmentPath={createSegmentPath(currentSegmentPath)}
|
|
loading={Loading ? <Loading /> : undefined}
|
|
childProp={childProp}
|
|
rootLayoutIncluded={rootLayoutIncludedAtThisLevelOrAbove}
|
|
/>,
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
// Convert the parallel route map into an object after all promises have been resolved.
|
|
const parallelRouteComponents = parallelRouteMap.reduce(
|
|
(list, [parallelRouteKey, Comp]) => {
|
|
list[parallelRouteKey] = Comp
|
|
return list
|
|
},
|
|
{} as { [key: string]: React.ReactNode }
|
|
)
|
|
|
|
// When the segment does not have a layout or page we still have to add the layout router to ensure the path holds the loading component
|
|
if (!Component) {
|
|
return {
|
|
Component: () => <>{parallelRouteComponents.children}</>,
|
|
}
|
|
}
|
|
|
|
const segmentPath = createSegmentPath([actualSegment])
|
|
const dataCacheKey = JSON.stringify(segmentPath)
|
|
let fetcher: (() => Promise<any>) | null = null
|
|
|
|
type GetServerSidePropsContext = {
|
|
headers: IncomingHttpHeaders
|
|
cookies: NextApiRequestCookies
|
|
layoutSegments: FlightSegmentPath
|
|
params?: { [key: string]: string | string[] }
|
|
preview?: boolean
|
|
previewData?: string | object | undefined
|
|
}
|
|
|
|
type getServerSidePropsContextPage = GetServerSidePropsContext & {
|
|
searchParams: URLSearchParams
|
|
pathname: string
|
|
}
|
|
|
|
type GetStaticPropsContext = {
|
|
layoutSegments: FlightSegmentPath
|
|
params?: { [key: string]: string | string[] }
|
|
preview?: boolean
|
|
previewData?: string | object | undefined
|
|
}
|
|
|
|
type GetStaticPropContextPage = GetStaticPropsContext & {
|
|
pathname: string
|
|
}
|
|
|
|
// TODO-APP: pass a shared cache from previous getStaticProps/getServerSideProps calls?
|
|
if (!isClientComponentModule && layoutOrPageMod.getServerSideProps) {
|
|
// TODO-APP: recommendation for i18n
|
|
// locales: (renderOpts as any).locales, // always the same
|
|
// locale: (renderOpts as any).locale, // /nl/something -> nl
|
|
// defaultLocale: (renderOpts as any).defaultLocale, // changes based on domain
|
|
const getServerSidePropsContext:
|
|
| GetServerSidePropsContext
|
|
| getServerSidePropsContextPage = {
|
|
headers,
|
|
cookies,
|
|
layoutSegments: segmentPath,
|
|
// TODO-APP: change pathname to actual pathname, it holds the dynamic parameter currently
|
|
...(isPage ? { searchParams: query, pathname } : {}),
|
|
...(pageIsDynamic ? { params: currentParams } : undefined),
|
|
...(isPreview
|
|
? { preview: true, previewData: previewData }
|
|
: undefined),
|
|
}
|
|
fetcher = () =>
|
|
Promise.resolve(
|
|
layoutOrPageMod.getServerSideProps(getServerSidePropsContext)
|
|
)
|
|
}
|
|
// TODO-APP: implement layout specific caching for getStaticProps
|
|
if (!isClientComponentModule && layoutOrPageMod.getStaticProps) {
|
|
const getStaticPropsContext:
|
|
| GetStaticPropsContext
|
|
| GetStaticPropContextPage = {
|
|
layoutSegments: segmentPath,
|
|
...(isPage ? { pathname } : {}),
|
|
...(pageIsDynamic ? { params: currentParams } : undefined),
|
|
...(isPreview
|
|
? { preview: true, previewData: previewData }
|
|
: undefined),
|
|
}
|
|
fetcher = () =>
|
|
Promise.resolve(layoutOrPageMod.getStaticProps(getStaticPropsContext))
|
|
}
|
|
|
|
if (fetcher) {
|
|
// Kick off data fetching before rendering, this ensures there is no waterfall for layouts as
|
|
// all data fetching required to render the page is kicked off simultaneously
|
|
preloadDataFetchingRecord(dataCache, dataCacheKey, fetcher)
|
|
}
|
|
|
|
return {
|
|
Component: () => {
|
|
let props
|
|
// The data fetching was kicked off before rendering (see above)
|
|
// if the data was not resolved yet the layout rendering will be suspended
|
|
if (fetcher) {
|
|
const record = preloadDataFetchingRecord(
|
|
dataCache,
|
|
dataCacheKey,
|
|
fetcher
|
|
)
|
|
// Result of calling getStaticProps or getServerSideProps. If promise is not resolve yet it will suspend.
|
|
const recordValue = readRecordValue(record)
|
|
|
|
if (props) {
|
|
props = Object.assign({}, props, recordValue.props)
|
|
} else {
|
|
props = recordValue.props
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{stylesheets
|
|
? stylesheets.map((href) => (
|
|
<link
|
|
rel="stylesheet"
|
|
href={`/_next/${href}?ts=${Date.now()}`}
|
|
// `Precedence` is an opt-in signal for React to handle
|
|
// resource loading and deduplication, etc:
|
|
// https://github.com/facebook/react/pull/25060
|
|
// @ts-ignore
|
|
precedence="high"
|
|
key={href}
|
|
/>
|
|
))
|
|
: null}
|
|
<Component
|
|
{...props}
|
|
{...parallelRouteComponents}
|
|
// TODO-APP: params and query have to be blocked parallel route names. Might have to add a reserved name list.
|
|
// Params are always the current params that apply to the layout
|
|
// If you have a `/dashboard/[team]/layout.js` it will provide `team` as a param but not anything further down.
|
|
params={currentParams}
|
|
// Query is only provided to page
|
|
{...(isPage ? { searchParams: query } : {})}
|
|
/>
|
|
</>
|
|
)
|
|
},
|
|
}
|
|
}
|
|
|
|
// Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`.
|
|
if (isFlight) {
|
|
// TODO-APP: throw on invalid flightRouterState
|
|
/**
|
|
* Use router state to decide at what common layout to render the page.
|
|
* This can either be the common layout between two pages or a specific place to start rendering from using the "refetch" marker in the tree.
|
|
*/
|
|
const walkTreeWithFlightRouterState = async (
|
|
loaderTreeToFilter: LoaderTree,
|
|
parentParams: { [key: string]: string | string[] },
|
|
flightRouterState?: FlightRouterState,
|
|
parentRendered?: boolean
|
|
): Promise<FlightDataPath> => {
|
|
const [segment, parallelRoutes] = loaderTreeToFilter
|
|
const parallelRoutesKeys = Object.keys(parallelRoutes)
|
|
|
|
// Because this function walks to a deeper point in the tree to start rendering we have to track the dynamic parameters up to the point where rendering starts
|
|
// That way even when rendering the subtree getServerSideProps/getStaticProps get the right parameters.
|
|
const segmentParam = getDynamicParamFromSegment(segment)
|
|
const currentParams =
|
|
// Handle null case where dynamic param is optional
|
|
segmentParam && segmentParam.value !== null
|
|
? {
|
|
...parentParams,
|
|
[segmentParam.param]: segmentParam.value,
|
|
}
|
|
: parentParams
|
|
const actualSegment: Segment = segmentParam
|
|
? segmentParam.treeSegment
|
|
: segment
|
|
|
|
/**
|
|
* Decide if the current segment is where rendering has to start.
|
|
*/
|
|
const renderComponentsOnThisLevel =
|
|
// No further router state available
|
|
!flightRouterState ||
|
|
// Segment in router state does not match current segment
|
|
!matchSegment(actualSegment, flightRouterState[0]) ||
|
|
// Last item in the tree
|
|
parallelRoutesKeys.length === 0 ||
|
|
// Explicit refresh
|
|
flightRouterState[3] === 'refetch'
|
|
|
|
if (!parentRendered && renderComponentsOnThisLevel) {
|
|
return [
|
|
actualSegment,
|
|
// Create router state using the slice of the loaderTree
|
|
createFlightRouterStateFromLoaderTree(loaderTreeToFilter),
|
|
// Check if one level down from the common layout has a loading component. If it doesn't only provide the router state as part of the Flight data.
|
|
isPrefetch && !Boolean(loaderTreeToFilter[2].loading)
|
|
? null
|
|
: // Create component tree using the slice of the loaderTree
|
|
React.createElement(
|
|
(
|
|
await createComponentTree(
|
|
// This ensures flightRouterPath is valid and filters down the tree
|
|
{
|
|
createSegmentPath: (child) => child,
|
|
loaderTree: loaderTreeToFilter,
|
|
parentParams: currentParams,
|
|
firstItem: true,
|
|
}
|
|
)
|
|
).Component
|
|
),
|
|
]
|
|
}
|
|
|
|
// Walk through all parallel routes.
|
|
for (const parallelRouteKey of parallelRoutesKeys) {
|
|
const parallelRoute = parallelRoutes[parallelRouteKey]
|
|
const path = await walkTreeWithFlightRouterState(
|
|
parallelRoute,
|
|
currentParams,
|
|
flightRouterState && flightRouterState[1][parallelRouteKey],
|
|
parentRendered || renderComponentsOnThisLevel
|
|
)
|
|
|
|
if (typeof path[path.length - 1] !== 'string') {
|
|
return [actualSegment, parallelRouteKey, ...path]
|
|
}
|
|
}
|
|
|
|
return [actualSegment]
|
|
}
|
|
|
|
// Flight data that is going to be passed to the browser.
|
|
// Currently a single item array but in the future multiple patches might be combined in a single request.
|
|
const flightData: FlightData = [
|
|
// TODO-APP: change walk to output without ''
|
|
(
|
|
await walkTreeWithFlightRouterState(
|
|
loaderTree,
|
|
{},
|
|
providedFlightRouterState
|
|
)
|
|
).slice(1),
|
|
]
|
|
|
|
return new RenderResult(
|
|
renderToReadableStream(flightData, serverComponentManifest, {
|
|
context: serverContexts,
|
|
}).pipeThrough(createBufferedTransformStream())
|
|
)
|
|
}
|
|
|
|
// Below this line is handling for rendering to HTML.
|
|
|
|
// Create full component tree from root to leaf.
|
|
const { Component: ComponentTree } = await createComponentTree({
|
|
createSegmentPath: (child) => child,
|
|
loaderTree: loaderTree,
|
|
parentParams: {},
|
|
firstItem: true,
|
|
})
|
|
|
|
// AppRouter is provided by next-app-loader
|
|
const AppRouter =
|
|
ComponentMod.AppRouter as typeof import('../client/components/app-router.client').default
|
|
|
|
let serverComponentsInlinedTransformStream: TransformStream<
|
|
Uint8Array,
|
|
Uint8Array
|
|
> = new TransformStream()
|
|
|
|
// TODO-APP: validate req.url as it gets passed to render.
|
|
const initialCanonicalUrl = req.url!
|
|
|
|
/**
|
|
* A new React Component that renders the provided React Component
|
|
* using Flight which can then be rendered to HTML.
|
|
*/
|
|
const ServerComponentsRenderer = createServerComponentRenderer(
|
|
() => {
|
|
const initialTree = createFlightRouterStateFromLoaderTree(loaderTree)
|
|
|
|
return (
|
|
<AppRouter
|
|
hotReloader={
|
|
HotReloader && (
|
|
<HotReloader assetPrefix={renderOpts.assetPrefix || ''} />
|
|
)
|
|
}
|
|
initialCanonicalUrl={initialCanonicalUrl}
|
|
initialTree={initialTree}
|
|
>
|
|
<ComponentTree />
|
|
</AppRouter>
|
|
)
|
|
},
|
|
ComponentMod,
|
|
{
|
|
cachePrefix: initialCanonicalUrl,
|
|
transformStream: serverComponentsInlinedTransformStream,
|
|
serverComponentManifest,
|
|
serverContexts,
|
|
}
|
|
)
|
|
|
|
const flushEffectsCallbacks: Set<() => React.ReactNode> = new Set()
|
|
function FlushEffects({ children }: { children: JSX.Element }) {
|
|
// Reset flushEffectsHandler on each render
|
|
flushEffectsCallbacks.clear()
|
|
const addFlushEffects = React.useCallback(
|
|
(handler: () => React.ReactNode) => {
|
|
flushEffectsCallbacks.add(handler)
|
|
},
|
|
[]
|
|
)
|
|
|
|
return (
|
|
<FlushEffectsContext.Provider value={addFlushEffects}>
|
|
{children}
|
|
</FlushEffectsContext.Provider>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Rules of Static & Dynamic HTML:
|
|
*
|
|
* 1.) We must generate static HTML unless the caller explicitly opts
|
|
* in to dynamic HTML support.
|
|
*
|
|
* 2.) If dynamic HTML support is requested, we must honor that request
|
|
* or throw an error. It is the sole responsibility of the caller to
|
|
* ensure they aren't e.g. requesting dynamic HTML for an AMP page.
|
|
*
|
|
* These rules help ensure that other existing features like request caching,
|
|
* coalescing, and ISR continue working as intended.
|
|
*/
|
|
const generateStaticHTML = supportsDynamicHTML !== true
|
|
const bodyResult = async () => {
|
|
const content = (
|
|
<FlushEffects>
|
|
<ServerComponentsRenderer />
|
|
</FlushEffects>
|
|
)
|
|
|
|
const flushEffectHandler = (): string => {
|
|
const flushed = ReactDOMServer.renderToString(
|
|
<>{Array.from(flushEffectsCallbacks).map((callback) => callback())}</>
|
|
)
|
|
return flushed
|
|
}
|
|
|
|
const renderStream = await renderToInitialStream({
|
|
ReactDOMServer,
|
|
element: content,
|
|
streamOptions: {
|
|
// Include hydration scripts in the HTML
|
|
bootstrapScripts: buildManifest.rootMainFiles.map(
|
|
(src) => `${renderOpts.assetPrefix || ''}/_next/` + src
|
|
),
|
|
},
|
|
})
|
|
|
|
return await continueFromInitialStream(renderStream, {
|
|
dataStream: serverComponentsInlinedTransformStream?.readable,
|
|
generateStaticHTML: generateStaticHTML,
|
|
flushEffectHandler,
|
|
flushEffectsToHead: true,
|
|
})
|
|
}
|
|
|
|
return new RenderResult(await bodyResult())
|
|
}
|