diff --git a/packages/next/client/components/app-router.client.tsx b/packages/next/client/components/app-router.client.tsx index 93a7b4167565..c2dd1fa8d3fc 100644 --- a/packages/next/client/components/app-router.client.tsx +++ b/packages/next/client/components/app-router.client.tsx @@ -41,6 +41,7 @@ function fetchFlight( JSON.stringify(flightRouterState) ) + // TODO-APP: Verify that TransformStream is supported. const { readable, writable } = new TransformStream() fetch(flightUrl.toString()).then((res) => { diff --git a/packages/next/client/components/hooks-client.ts b/packages/next/client/components/hooks-client.ts index 461b013f99d4..8606c757dc1c 100644 --- a/packages/next/client/components/hooks-client.ts +++ b/packages/next/client/components/hooks-client.ts @@ -12,16 +12,25 @@ import { AppTreeContext, } from '../../shared/lib/app-router-context' +/** + * Get the current search params. For example useSearchParams() would return {"foo": "bar"} when ?foo=bar + */ export function useSearchParams() { return useContext(SearchParamsContext) } +/** + * Get an individual search param. For example useSearchParam("foo") would return "bar" when ?foo=bar + */ export function useSearchParam(key: string): string | string[] { const params = useContext(SearchParamsContext) return params[key] } // TODO-APP: Move the other router context over to this one +/** + * Get the router methods. For example router.push('/dashboard') + */ export function useRouter(): import('../../shared/lib/app-router-context').AppRouterInstance { return useContext(AppRouterContext) } @@ -31,6 +40,9 @@ export function useRouter(): import('../../shared/lib/app-router-context').AppRo // return useContext(ParamsContext) // } +/** + * Get the current pathname. For example usePathname() on /dashboard?foo=bar would return "/dashboard" + */ export function usePathname(): string { return useContext(PathnameContext) } @@ -40,6 +52,10 @@ export function usePathname(): string { // return useContext(LayoutSegmentsContext) // } +// TODO-APP: Expand description when the docs are written for it. +/** + * Get the current segment one level down from the layout. + */ export function useSelectedLayoutSegment( parallelRouteKey: string = 'children' ): string { diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 343a6f3ba391..e62212076656 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -41,6 +41,9 @@ export type RenderOptsPartial = { export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial +/** + * Interop between "export default" and "module.exports". + */ function interopDefault(mod: any) { return mod.default || mod } @@ -60,6 +63,9 @@ type Record = { value: any } +/** + * Create data fetching record for Promise. + */ function createRecordFromThenable(thenable: Promise) { const record: Record = { status: RecordStatus.Pending, @@ -84,6 +90,9 @@ function createRecordFromThenable(thenable: Promise) { 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 @@ -92,6 +101,10 @@ function readRecordValue(record: Record) { } } +/** + * 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, key: string, @@ -108,6 +121,10 @@ function preloadDataFetchingRecord( return record } +/** + * Render Flight stream. + * This is only used for renderToHTML, the Flight response does not need additional wrappers. + */ function useFlightResponse( writable: WritableStream, cachePrefix: string, @@ -158,10 +175,18 @@ function useFlightResponse( return entry } -// Create the wrapper component for a Flight stream. +/** + * 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: any, + ComponentMod: { + __next_app_webpack_require__?: any + __next_rsc__?: { + __webpack_require__?: any + } + }, { cachePrefix, transformStream, @@ -171,7 +196,9 @@ function createServerComponentRenderer( cachePrefix: string transformStream: TransformStream serverComponentManifest: NonNullable - serverContexts: Array<[ServerContextName: string, JSONValue: any]> + serverContexts: Array< + [ServerContextName: string, JSONValue: Object | number | string] + > } ) { // We need to expose the `__webpack_require__` API globally for @@ -180,7 +207,7 @@ function createServerComponentRenderer( // @ts-ignore globalThis.__next_require__ = ComponentMod.__next_app_webpack_require__ || - ComponentMod.__next_rsc__.__webpack_require__ + ComponentMod.__next_rsc__?.__webpack_require__ // @ts-ignore globalThis.__next_chunk_load__ = () => Promise.resolve() @@ -278,6 +305,7 @@ export type FlightDataPath = parallelRoute: string, segment: Segment, parallelRoute: string, + currentSegment: Segment, tree: FlightRouterState, subTreeData: React.ReactNode ] @@ -288,6 +316,9 @@ export type ChildProp = { segment: Segment } +/** + * Parse dynamic route segment to type of parameter + */ function getSegmentParam(segment: string): { param: string type: DynamicParamTypes @@ -316,6 +347,9 @@ function getSegmentParam(segment: string): { return null } +/** + * Get inline tags based on __next_rsc_css__ manifest. Only used when rendering to HTML. + */ function getCssInlinedLinkTags( ComponentMod: any, serverComponentManifest: any @@ -336,7 +370,7 @@ function getCssInlinedLinkTags( ) } -export async function renderToHTML( +export async function renderToHTMLOrFlight( req: IncomingMessage, res: ServerResponse, pathname: string, @@ -364,6 +398,7 @@ export async function renderToHTML( const isFlight = query.__flight__ !== undefined + // Handle client-side navigation to pages directory if (isFlight && isPagesDir) { stripInternalQueries(query) const search = stringifyQuery(query) @@ -380,6 +415,9 @@ export async function renderToHTML( // 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) @@ -388,9 +426,7 @@ export async function renderToHTML( stripInternalQueries(query) - const hasConcurrentFeatures = !!runtime const pageIsDynamic = isDynamicRoute(pathname) - const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default const HotReloader = ComponentMod.HotReloader as @@ -398,10 +434,14 @@ export async function renderToHTML( | null const headers = req.headers - // @ts-expect-error TODO-APP: fix type of req + // TODO-APP: fix type of req + // @ts-expect-error const cookies = req.cookies - const tree: LoaderTree = ComponentMod.tree + /** + * The tree created in next-app-loader that holds component segments and modules + */ + const loaderTree: LoaderTree = ComponentMod.tree // 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 @@ -412,6 +452,11 @@ export async function renderToHTML( (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], @@ -419,19 +464,28 @@ export async function renderToHTML( ['PreviewDataContext', previewData], ] + /** + * Used to keep track of in-flight / resolved data fetching Promises. + */ const dataCache = new Map() 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 = ( - // [id] or [slug] + // [slug] / [[slug]] / [...slug] segment: string ): { param: string value: string | string[] | null - treeValue: string + treeSegment: Segment type: DynamicParamTypesShort } | null => { const segmentParam = getSegmentParam(segment) @@ -443,22 +497,29 @@ export async function renderToHTML( 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: getShortDynamicParamType(segmentParam.type), - treeValue: '', + 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, - treeValue: Array.isArray(value) ? value.join('/') : value, - type: getShortDynamicParamType(segmentParam.type), + // The value that is rendered in the router tree. + treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type], + type: type, } } @@ -471,9 +532,7 @@ export async function renderToHTML( const dynamicParam = getDynamicParamFromSegment(segment) const segmentTree: FlightRouterState = [ - dynamicParam - ? [dynamicParam.param, dynamicParam.treeValue, dynamicParam.type] - : segment, + dynamicParam ? dynamicParam.treeSegment : segment, {}, ] @@ -495,15 +554,18 @@ export async function renderToHTML( return segmentTree } + /** + * Use the provided loader tree to create the React Component tree. + */ const createComponentTree = async ({ createSegmentPath, - tree: [segment, parallelRoutes, { layout, loading, page }], + loaderTree: [segment, parallelRoutes, { layout, loading, page }], parentParams, firstItem, rootLayoutIncluded, }: { createSegmentPath: CreateSegmentPath - tree: LoaderTree + loaderTree: LoaderTree parentParams: { [key: string]: any } rootLayoutIncluded?: boolean firstItem?: boolean @@ -516,10 +578,19 @@ export async function renderToHTML( : 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__') @@ -537,11 +608,18 @@ export async function renderToHTML( } } + /** + * 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 @@ -549,42 +627,39 @@ export async function renderToHTML( ...parentParams, [segmentParam.param]: segmentParam.value, } - : parentParams - const actualSegment = segmentParam - ? [segmentParam.param, segmentParam.treeValue] - : segment + : // 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 = firstItem + const currentSegmentPath: FlightSegmentPath = firstItem ? [parallelRouteKey] : [actualSegment, parallelRouteKey] + // Create the child component const { Component: ChildComponent } = await createComponentTree({ createSegmentPath: (child) => { return createSegmentPath([...currentSegmentPath, ...child]) }, - tree: parallelRoutes[parallelRouteKey], + loaderTree: parallelRoutes[parallelRouteKey], parentParams: currentParams, rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, }) - const childSegmentParam = getDynamicParamFromSegment( - parallelRoutes[parallelRouteKey][0] - ) + const childSegment = parallelRoutes[parallelRouteKey][0] + const childSegmentParam = getDynamicParamFromSegment(childSegment) const childProp: ChildProp = { current: , segment: childSegmentParam - ? [ - childSegmentParam.param, - childSegmentParam.treeValue, - childSegmentParam.type, - ] - : parallelRoutes[parallelRouteKey][0], + ? childSegmentParam.treeSegment + : childSegment, } + // This is turned back into an object below. return [ parallelRouteKey, { list[parallelRouteKey] = Comp @@ -607,7 +683,7 @@ export async function renderToHTML( {} as { [key: string]: React.ReactNode } ) - // When the segment does not have a layout/page we still have to add the layout router to ensure the path holds the loading component + // 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}, @@ -692,9 +768,9 @@ export async function renderToHTML( 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) { - // The data fetching was kicked off before rendering (see above) - // if the data was not resolved yet the layout rendering will be suspended const record = preloadDataFetchingRecord( dataCache, dataCacheKey, @@ -726,17 +802,24 @@ export async function renderToHTML( } } + // 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 ( - treeToFilter: LoaderTree, + loaderTreeToFilter: LoaderTree, parentParams: { [key: string]: string | string[] }, flightRouterState?: FlightRouterState, parentRendered?: boolean ): Promise => { - const [segment, parallelRoutes] = treeToFilter + 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 @@ -747,11 +830,16 @@ export async function renderToHTML( } : parentParams const actualSegment: Segment = segmentParam - ? [segmentParam.param, segmentParam.treeValue, segmentParam.type] + ? 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 || @@ -761,14 +849,16 @@ export async function renderToHTML( if (!parentRendered && renderComponentsOnThisLevel) { return [ actualSegment, - createFlightRouterStateFromLoaderTree(treeToFilter), + // Create router state using the slice of the loaderTree + createFlightRouterStateFromLoaderTree(loaderTreeToFilter), + // 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, - tree: treeToFilter, + loaderTree: loaderTreeToFilter, parentParams: currentParams, firstItem: true, } @@ -778,6 +868,7 @@ export async function renderToHTML( ] } + // Walk through all parallel routes. for (const parallelRouteKey of parallelRoutesKeys) { const parallelRoute = parallelRoutes[parallelRouteKey] const path = await walkTreeWithFlightRouterState( @@ -795,10 +886,16 @@ export async function renderToHTML( 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(tree, {}, providedFlightRouterState) + await walkTreeWithFlightRouterState( + loaderTree, + {}, + providedFlightRouterState + ) ).slice(1), ] @@ -809,75 +906,74 @@ export async function renderToHTML( ) } - const search = stringifyQuery(query) - - // TODO-APP: validate req.url as it gets passed to render. - const initialCanonicalUrl = req.url! - - const initialTree = createFlightRouterStateFromLoaderTree(tree) - - const initialStylesheets: string[] = getCssInlinedLinkTags( - ComponentMod, - serverComponentManifest - ) + // 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, - tree, + 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 - const WrappedComponentTreeWithRouter = () => { - return ( - } - initialCanonicalUrl={initialCanonicalUrl} - initialTree={initialTree} - initialStylesheets={initialStylesheets} - > - - - ) - } - - const bootstrapScripts = buildManifest.rootMainFiles.map( - (src) => '/_next/' + src - ) - let serverComponentsInlinedTransformStream: TransformStream< Uint8Array, Uint8Array - > | null = null + > = new TransformStream() - serverComponentsInlinedTransformStream = new TransformStream() + // TODO-APP: validate req.url as it gets passed to render. + const initialCanonicalUrl = req.url! + const initialStylesheets: string[] = getCssInlinedLinkTags( + ComponentMod, + serverComponentManifest + ) - const Component = createServerComponentRenderer( - WrappedComponentTreeWithRouter, + /** + * 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 ( + } + initialCanonicalUrl={initialCanonicalUrl} + initialTree={initialTree} + initialStylesheets={initialStylesheets} + > + + + ) + }, ComponentMod, { - cachePrefix: pathname + (search ? `?${search}` : ''), + cachePrefix: initialCanonicalUrl, transformStream: serverComponentsInlinedTransformStream, serverComponentManifest, serverContexts, } ) + /** + * Style registry for styled-jsx + */ const jsxStyleRegistry = createStyleRegistry() - const styledJsxFlushEffect = () => { + /** + * styled-jsx styles as React Component + */ + const styledJsxFlushEffect = (): React.ReactNode => { const styles = jsxStyleRegistry.styles() jsxStyleRegistry.flush() return <>{styles} } - const AppContainer = ({ children }: { children: JSX.Element }) => ( - {children} - ) - /** * Rules of Static & Dynamic HTML: * @@ -894,16 +990,19 @@ export async function renderToHTML( const generateStaticHTML = supportsDynamicHTML !== true const bodyResult = async () => { const content = ( - - - + + + ) const renderStream = await renderToInitialStream({ ReactDOMServer, element: content, streamOptions: { - bootstrapScripts, + // Include hydration scripts in the HTML + bootstrapScripts: buildManifest.rootMainFiles.map( + (src) => '/_next/' + src + ), }, }) @@ -912,6 +1011,8 @@ export async function renderToHTML( return flushed } + const hasConcurrentFeatures = !!runtime + return await continueFromInitialStream(renderStream, { dataStream: serverComponentsInlinedTransformStream?.readable, generateStaticHTML: generateStaticHTML || !hasConcurrentFeatures, diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 275415eada62..a7c8d80876df 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -53,7 +53,7 @@ import { getExtension, serveStatic } from './serve-static' import { ParsedUrlQuery } from 'querystring' import { apiResolver } from './api-utils/node' import { RenderOpts, renderToHTML } from './render' -import { renderToHTML as appRenderToHTML } from './app-render' +import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { ParsedUrl, parseUrl } from '../shared/lib/router/utils/parse-url' import * as Log from '../build/output/log' @@ -615,7 +615,7 @@ export default class NextNodeServer extends BaseServer { (renderOpts.isAppPath || query.__flight__) ) { const isPagesDir = !renderOpts.isAppPath - return appRenderToHTML( + return appRenderToHTMLOrFlight( req.originalRequest, res.originalResponse, pathname, diff --git a/packages/next/shared/lib/app-router-context.ts b/packages/next/shared/lib/app-router-context.ts index c889f66858f8..34edab97d88d 100644 --- a/packages/next/shared/lib/app-router-context.ts +++ b/packages/next/shared/lib/app-router-context.ts @@ -3,14 +3,13 @@ import type { FocusRef } from '../../client/components/reducer' import type { FlightRouterState, FlightData } from '../../server/app-render' export type ChildSegmentMap = Map -type ParallelRoutesCacheNodes = Map export type CacheNode = { data: ReturnType< typeof import('../../client/components/app-router.client').fetchServerResponse > | null subTreeData: null | React.ReactNode - parallelRoutes: ParallelRoutesCacheNodes + parallelRoutes: Map } export type AppRouterInstance = {