diff --git a/packages/next/client/components/hooks-server-context.ts b/packages/next/client/components/hooks-server-context.ts index 05c084577ba06..81d288381be0e 100644 --- a/packages/next/client/components/hooks-server-context.ts +++ b/packages/next/client/components/hooks-server-context.ts @@ -33,7 +33,6 @@ export const CONTEXT_NAMES = { HeadersContext: 'HeadersContext', PreviewDataContext: 'PreviewDataContext', CookiesContext: 'CookiesContext', - StaticGenerationContext: 'StaticGenerationContext', FetchRevalidateContext: 'FetchRevalidateContext', } as const @@ -42,7 +41,3 @@ export const PreviewDataContext = createContext( CONTEXT_NAMES.PreviewDataContext ) export const CookiesContext = createContext(CONTEXT_NAMES.CookiesContext) -export const StaticGenerationContext = createContext( - CONTEXT_NAMES.StaticGenerationContext, - { isStaticGeneration: false } -) diff --git a/packages/next/client/components/hooks-server.ts b/packages/next/client/components/hooks-server.ts index 2768a27716d27..7a47daa19e54c 100644 --- a/packages/next/client/components/hooks-server.ts +++ b/packages/next/client/components/hooks-server.ts @@ -1,24 +1,40 @@ +import type { AsyncLocalStorage } from 'async_hooks' import { useContext } from 'react' import { HeadersContext, PreviewDataContext, CookiesContext, DynamicServerError, - StaticGenerationContext, } from './hooks-server-context' -export function useTrackStaticGeneration() { - return useContext< - typeof import('./hooks-server-context').StaticGenerationContext - >(StaticGenerationContext) +export interface StaticGenerationStore { + inUse?: boolean + pathname?: string + revalidate?: number + fetchRevalidate?: number + isStaticGeneration?: boolean +} + +export let staticGenerationAsyncStorage: + | AsyncLocalStorage + | StaticGenerationStore = {} + +if (process.env.NEXT_RUNTIME !== 'edge' && typeof window === 'undefined') { + staticGenerationAsyncStorage = + new (require('async_hooks').AsyncLocalStorage)() } function useStaticGenerationBailout(reason: string) { - const staticGenerationContext = useTrackStaticGeneration() + const staticGenerationStore = + staticGenerationAsyncStorage && 'getStore' in staticGenerationAsyncStorage + ? staticGenerationAsyncStorage?.getStore() + : staticGenerationAsyncStorage - if (staticGenerationContext.isStaticGeneration) { + if (staticGenerationStore?.isStaticGeneration) { // TODO: honor the dynamic: 'force-static' - staticGenerationContext.revalidate = 0 + if (staticGenerationStore) { + staticGenerationStore.revalidate = 0 + } throw new DynamicServerError(reason) } } diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index ebd32f9b866fb..f7d1cb230cea2 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -28,6 +28,7 @@ import RenderResult from '../server/render-result' import isError from '../lib/is-error' import { addRequestMeta } from '../server/request-meta' import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' +import { REDIRECT_ERROR_CODE } from '../client/components/redirect' loadRequireHook() const envConfig = require('../shared/lib/runtime-config') @@ -419,7 +420,10 @@ export default async function exportPage({ ) } } catch (err) { - if (!(err instanceof DynamicServerError)) { + if ( + !(err instanceof DynamicServerError) && + (err as any).code !== REDIRECT_ERROR_CODE + ) { throw err } } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index e16ef64d024ec..8d47e3411cbab 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -27,6 +27,7 @@ import { import { FlushEffectsContext } from '../shared/lib/flush-effects' import { stripInternalQueries } from './internal-utils' import type { ComponentsType } from '../build/webpack/loaders/next-app-loader' +import type { UnwrapPromise } from '../lib/coalesced-function' import { REDIRECT_ERROR_CODE } from '../client/components/redirect' // this needs to be required lazily so that `next-server` can set @@ -109,26 +110,24 @@ function patchFetch() { const { DynamicServerError } = require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') - const { useTrackStaticGeneration } = + const { staticGenerationAsyncStorage } = require('../client/components/hooks-server') as typeof import('../client/components/hooks-server') const origFetch = (global as any).fetch ;(global as any).fetch = async (init: any, opts: any) => { - let staticGenerationContext: ReturnType = - {} - try { - // eslint-disable-next-line react-hooks/rules-of-hooks - staticGenerationContext = useTrackStaticGeneration() || {} - } catch (_) {} + const staticGenerationStore = + 'getStore' in staticGenerationAsyncStorage + ? staticGenerationAsyncStorage.getStore() + : staticGenerationAsyncStorage const { isStaticGeneration, fetchRevalidate, pathname } = - staticGenerationContext + staticGenerationStore || {} - if (isStaticGeneration) { + if (staticGenerationStore && isStaticGeneration) { if (opts && typeof opts === 'object') { if (opts.cache === 'no-store') { - staticGenerationContext.revalidate = 0 + staticGenerationStore.revalidate = 0 // TODO: ensure this error isn't logged to the user // seems it's slipping through currently throw new DynamicServerError( @@ -141,7 +140,7 @@ function patchFetch() { (typeof fetchRevalidate === 'undefined' || opts.revalidate < fetchRevalidate) ) { - staticGenerationContext.fetchRevalidate = opts.revalidate + staticGenerationStore.fetchRevalidate = opts.revalidate } } } @@ -505,311 +504,366 @@ export async function renderToHTMLOrFlight( ): Promise { patchFetch() - const { CONTEXT_NAMES } = - require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') + const { staticGenerationAsyncStorage } = + require('../client/components/hooks-server') as typeof import('../client/components/hooks-server') - // @ts-expect-error createServerContext exists in react@experimental + react-dom@experimental - if (typeof React.createServerContext === 'undefined') { + if ( + !('getStore' in staticGenerationAsyncStorage) && + staticGenerationAsyncStorage.inUse + ) { 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.' + `Invariant: A separate worker must be used for each render when AsyncLocalStorage is not available` ) } - // don't modify original query object - query = Object.assign({}, query) + // we wrap the render in an AsyncLocalStorage context + const wrappedRender = async () => { + const staticGenerationStore = + 'getStore' in staticGenerationAsyncStorage + ? staticGenerationAsyncStorage.getStore() + : staticGenerationAsyncStorage - const { - buildManifest, - subresourceIntegrityManifest, - serverComponentManifest, - serverCSSManifest = {}, - supportsDynamicHTML, - ComponentMod, - } = renderOpts + const { CONTEXT_NAMES } = + require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') - const isFlight = query.__flight__ !== undefined - const isPrefetch = query.__flight_prefetch__ !== undefined + // @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.' + ) + } - // 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 FlightRenderResult( - renderToReadableStream(flightData, serverComponentManifest, { - onError: flightDataRendererErrorHandler, - }).pipeThrough(createBufferedTransformStream()) - ) - } + // don't modify original query object + query = Object.assign({}, query) - // 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 LayoutRouter = - ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default - const RenderFromTemplateContext = - ComponentMod.RenderFromTemplateContext as typeof import('../client/components/render-from-template-context.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 + const { + buildManifest, + subresourceIntegrityManifest, + 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 FlightRenderResult( + renderToReadableStream(flightData, serverComponentManifest, { + onError: flightDataRendererErrorHandler, + }).pipeThrough(createBufferedTransformStream()) + ) + } - /** - * 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 - ) - /** - * 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 staticGenerationContext: { - revalidate?: undefined | number - isStaticGeneration: boolean - pathname: string - } = { isStaticGeneration, pathname } - - 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 - [CONTEXT_NAMES.HeadersContext, headers], - [CONTEXT_NAMES.CookiesContext, cookies], - [CONTEXT_NAMES.PreviewDataContext, previewData], - [CONTEXT_NAMES.StaticGenerationContext, staticGenerationContext], - ] - - type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath + // 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 - /** - * 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 + stripInternalQueries(query) - /** - * 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 LayoutRouter = + ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default + const RenderFromTemplateContext = + ComponentMod.RenderFromTemplateContext as typeof import('../client/components/render-from-template-context.client').default + const HotReloader = ComponentMod.HotReloader as + | typeof import('../client/components/hot-reloader.client').default + | null - const key = segmentParam.param - const value = pathParams[key] + const headers = req.headers + // TODO-APP: fix type of req + // @ts-expect-error + const cookies = req.cookies - 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 - } + /** + * 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 + ) + /** + * 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 type = getShortDynamicParamType(segmentParam.type) + 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 + [CONTEXT_NAMES.HeadersContext, headers], + [CONTEXT_NAMES.CookiesContext, cookies], + [CONTEXT_NAMES.PreviewDataContext, previewData], + ] - 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, - } - } + type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath - const createFlightRouterStateFromLoaderTree = ([ - segment, - parallelRoutes, - ]: LoaderTree): FlightRouterState => { - const dynamicParam = getDynamicParamFromSegment(segment) + /** + * 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 - const segmentTree: FlightRouterState = [ - dynamicParam ? dynamicParam.treeSegment : segment, - {}, - ] + /** + * 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 + } - if (parallelRoutes) { - segmentTree[1] = Object.keys(parallelRoutes).reduce( - (existingValue, currentValue) => { - existingValue[currentValue] = createFlightRouterStateFromLoaderTree( - parallelRoutes[currentValue] - ) - return existingValue - }, - {} as FlightRouterState[1] - ) - } + 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 + } - return segmentTree - } + const type = getShortDynamicParamType(segmentParam.type) - let defaultRevalidate: false | undefined | number = false + 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, + } + } - /** - * Use the provided loader tree to create the React Component tree. - */ - const createComponentTree = async ({ - createSegmentPath, - loaderTree: [ + const createFlightRouterStateFromLoaderTree = ([ segment, parallelRoutes, - { layoutOrPagePath, layout, template, error, 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: string[] = layoutOrPagePath - ? getCssInlinedLinkTags( - serverComponentManifest, - serverCSSManifest!, - layoutOrPagePath + ]: 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] ) - : [] - const Template = template - ? await interopDefault(template()) - : React.Fragment - const ErrorComponent = error ? await interopDefault(error()) : undefined - 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 + } - if (layoutOrPageMod?.config) { - defaultRevalidate = layoutOrPageMod.config.revalidate + return segmentTree } + + let defaultRevalidate: false | undefined | number = false + /** - * 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. + * Use the provided loader tree to create the React Component tree. */ - const rootLayoutIncludedAtThisLevelOrAbove = - rootLayoutIncluded || rootLayoutAtThisLevel + const createComponentTree = async ({ + createSegmentPath, + loaderTree: [ + segment, + parallelRoutes, + { layoutOrPagePath, layout, template, error, 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: string[] = layoutOrPagePath + ? getCssInlinedLinkTags( + serverComponentManifest, + serverCSSManifest!, + layoutOrPagePath + ) + : [] + const Template = template + ? await interopDefault(template()) + : React.Fragment + const ErrorComponent = error ? await interopDefault(error()) : undefined + 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 + + if (layoutOrPageMod?.config) { + defaultRevalidate = layoutOrPageMod.config.revalidate + } + /** + * 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 + + // TODO-APP: move these errors to the loader instead? + // we will also need a migration doc here to link to + if (typeof layoutOrPageMod?.getServerSideProps === 'function') { + throw new Error( + `getServerSideProps is not supported in app/, detected in ${segment}` + ) + } - // TODO-APP: move these errors to the loader instead? - // we will also need a migration doc here to link to - if (typeof layoutOrPageMod?.getServerSideProps === 'function') { - throw new Error( - `getServerSideProps is not supported in app/, detected in ${segment}` - ) - } + if (typeof layoutOrPageMod?.getStaticProps === 'function') { + throw new Error( + `getStaticProps is not supported in app/, detected in ${segment}` + ) + } - if (typeof layoutOrPageMod?.getStaticProps === 'function') { - throw new Error( - `getStaticProps is not supported in app/, detected in ${segment}` - ) - } + /** + * The React Component to render. + */ + const Component = layoutOrPageMod + ? interopDefault(layoutOrPageMod) + : undefined - /** - * 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 + */ + 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, + : undefined} + error={ErrorComponent} + template={ + + } + 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, + }) - // Handle dynamic segment params. - const segmentParam = getDynamicParamFromSegment(segment) - /** - * Create object holding the parent params and current params - */ - 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, + current: , segment: childSegmentParam ? childSegmentParam.treeSegment : childSegment, } + const segmentPath = createSegmentPath(currentSegmentPath) + // This is turned back into an object below. return [ parallelRouteKey, : undefined} + segmentPath={segmentPath} error={ErrorComponent} + loading={Loading ? : undefined} template={