diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 71556653452f..898072e333e3 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -97,6 +97,7 @@ import { printTreeView, copyTracedFiles, isReservedPage, + AppConfig, } from './utils' import getBaseWebpackConfig from './webpack-config' import { PagesManifest } from './webpack/plugins/pages-manifest-plugin' @@ -1065,6 +1066,11 @@ export default async function build( const serverPropsPages = new Set() const additionalSsgPaths = new Map>() const additionalSsgPathsEncoded = new Map>() + const appStaticPaths = new Map>() + const appStaticPathsEncoded = new Map>() + const appNormalizedPaths = new Map() + const appDynamicParamPaths = new Set() + const appDefaultConfigs = new Map() const pageTraceIncludes = new Map>() const pageTraceExcludes = new Map>() const pageInfos = new Map() @@ -1087,6 +1093,26 @@ export default async function build( : require.resolve('./utils') let infoPrinted = false + let appPathsManifest: Record = {} + const appPathRoutes: Record = {} + + if (appDir) { + appPathsManifest = JSON.parse( + await promises.readFile( + path.join(distDir, serverDir, APP_PATHS_MANIFEST), + 'utf8' + ) + ) + + Object.keys(appPathsManifest).forEach((entry) => { + appPathRoutes[entry] = normalizeAppPath(entry) || '/' + }) + await promises.writeFile( + path.join(distDir, APP_PATH_ROUTES_MANIFEST), + JSON.stringify(appPathRoutes, null, 2) + ) + } + process.env.NEXT_PHASE = PHASE_PRODUCTION_BUILD const staticWorkers = new Worker(staticWorker, { @@ -1255,33 +1281,49 @@ export default async function build( let isHybridAmp = false let ssgPageRoutes: string[] | null = null - const pagePath = - pageType === 'pages' - ? pagesPaths.find( - (p) => - p.startsWith(actualPage + '.') || - p.startsWith(actualPage + '/index.') + let pagePath = '' + + if (pageType === 'pages') { + pagePath = + pagesPaths.find( + (p) => + p.startsWith(actualPage + '.') || + p.startsWith(actualPage + '/index.') + ) || '' + } + let originalAppPath: string | undefined + + if (pageType === 'app' && mappedAppPages) { + for (const [originalPath, normalizedPath] of Object.entries( + appPathRoutes + )) { + if (normalizedPath === page) { + pagePath = mappedAppPages[originalPath].replace( + /^private-next-app-dir/, + '' ) - : appPaths?.find((p) => p.startsWith(actualPage + '/page.')) + originalAppPath = originalPath + break + } + } + } - const staticInfo = - pagesDir && pageType === 'pages' && pagePath - ? await getPageStaticInfo({ - pageFilePath: join(pagesDir, pagePath), - nextConfig: config, - }) - : {} - const pageRuntime = staticInfo.runtime + const staticInfo = pagePath + ? await getPageStaticInfo({ + pageFilePath: join( + (pageType === 'pages' ? pagesDir : appDir) || '', + pagePath + ), + nextConfig: config, + }) + : undefined + + const pageRuntime = staticInfo?.runtime isServerComponent = pageType === 'app' && - staticInfo.rsc !== RSC_MODULE_TYPES.client + staticInfo?.rsc !== RSC_MODULE_TYPES.client - if ( - // Only calculate page static information if the page is not an - // app page. - pageType !== 'app' && - !isReservedPage(page) - ) { + if (!isReservedPage(page)) { try { let edgeInfo: any @@ -1291,8 +1333,10 @@ export default async function build( serverDir, MIDDLEWARE_MANIFEST )) + const manifestKey = + pageType === 'pages' ? page : join(page, 'page') - edgeInfo = manifest.functions[page] + edgeInfo = manifest.functions[manifestKey] } let isPageStaticSpan = @@ -1301,6 +1345,7 @@ export default async function build( () => { return staticWorkers.isPageStatic({ page, + originalAppPath, distDir, serverless: isLikeServerless, configFileName, @@ -1311,10 +1356,50 @@ export default async function build( parentId: isPageStaticSpan.id, pageRuntime, edgeInfo, + pageType, + hasServerComponents, }) } ) + if (pageType === 'app' && originalAppPath) { + appNormalizedPaths.set(originalAppPath, page) + + // TODO-APP: handle prerendering with edge + // runtime + if (pageRuntime === 'experimental-edge') { + return + } + + if ( + workerResult.encodedPrerenderRoutes && + workerResult.prerenderRoutes + ) { + appStaticPaths.set( + originalAppPath, + workerResult.prerenderRoutes + ) + appStaticPathsEncoded.set( + originalAppPath, + workerResult.encodedPrerenderRoutes + ) + } + if (!isDynamicRoute(page)) { + appStaticPaths.set(originalAppPath, [page]) + appStaticPathsEncoded.set(originalAppPath, [page]) + } + if (workerResult.prerenderFallback) { + // whether or not to allow requests for paths not + // returned from generateStaticParams + appDynamicParamPaths.add(originalAppPath) + } + appDefaultConfigs.set( + originalAppPath, + workerResult.appConfig || {} + ) + return + } + if (pageRuntime === SERVER_RUNTIME.edge) { if (workerResult.hasStaticProps) { console.warn( @@ -1821,24 +1906,6 @@ export default async function build( 'utf8' ) - if (appDir) { - const appPathsManifest = JSON.parse( - await promises.readFile( - path.join(distDir, serverDir, APP_PATHS_MANIFEST), - 'utf8' - ) - ) - const appPathRoutes: Record = {} - - Object.keys(appPathsManifest).forEach((entry) => { - appPathRoutes[entry] = normalizeAppPath(entry) || '/' - }) - await promises.writeFile( - path.join(distDir, APP_PATH_ROUTES_MANIFEST), - JSON.stringify(appPathRoutes, null, 2) - ) - } - const middlewareManifest: MiddlewareManifest = JSON.parse( await promises.readFile( path.join(distDir, serverDir, MIDDLEWARE_MANIFEST), @@ -1865,6 +1932,7 @@ export default async function build( } const finalPrerenderRoutes: { [route: string]: SsgRoute } = {} + const finalDynamicRoutes: PrerenderManifest['dynamicRoutes'] = {} const tbdPrerenderRoutes: string[] = [] let ssgNotFoundPaths: string[] = [] @@ -1889,7 +1957,16 @@ export default async function build( const combinedPages = [...staticPages, ...ssgPages] - if (combinedPages.length > 0 || useStatic404 || useDefaultStatic500) { + // we need to trigger automatic exporting when we have + // - static 404/500 + // - getStaticProps paths + // - experimental app is enabled + if ( + combinedPages.length > 0 || + useStatic404 || + useDefaultStatic500 || + config.experimental.appDir + ) { const staticGenerationSpan = nextBuildSpan.traceChild('static-generation') await staticGenerationSpan.traceAsyncFn(async () => { @@ -1986,6 +2063,20 @@ export default async function build( } } + // TODO: output manifest specific to app paths and their + // revalidate periods and dynamicParams settings + appStaticPaths.forEach((routes, originalAppPath) => { + const encodedRoutes = appStaticPathsEncoded.get(originalAppPath) + + routes.forEach((route, routeIdx) => { + defaultMap[route] = { + page: originalAppPath, + query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, + _isAppDir: true, + } + }) + }) + if (i18n) { for (const page of [ ...staticPages, @@ -2035,6 +2126,58 @@ export default async function build( await promises.unlink(serverBundle) } + for (const [originalAppPath, routes] of appStaticPaths) { + const page = appNormalizedPaths.get(originalAppPath) || '' + const appConfig = appDefaultConfigs.get(originalAppPath) || {} + let hasDynamicData = appConfig.revalidate === 0 + + routes.forEach((route) => { + let revalidate = exportConfig.initialPageRevalidationMap[route] + + if (typeof revalidate === 'undefined') { + revalidate = + typeof appConfig.revalidate !== 'undefined' + ? appConfig.revalidate + : false + } + if (revalidate !== 0) { + const normalizedRoute = normalizePagePath(route) + const dataRoute = path.posix.join(`${normalizedRoute}.rsc`) + finalPrerenderRoutes[route] = { + initialRevalidateSeconds: revalidate, + srcRoute: page, + dataRoute, + } + } else { + hasDynamicData = true + } + }) + + if (!hasDynamicData && isDynamicRoute(originalAppPath)) { + const normalizedRoute = normalizePagePath(page) + const dataRoute = path.posix.join(`${normalizedRoute}.rsc`) + + // TODO: create a separate manifest to allow enforcing + // dynamicParams for non-static paths? + finalDynamicRoutes[page] = { + routeRegex: normalizeRouteRegex( + getNamedRouteRegex(page).re.source + ), + dataRoute, + // if dynamicParams are enabled treat as fallback: + // 'blocking' if not it's fallback: false + fallback: appDynamicParamPaths.has(originalAppPath) + ? null + : false, + dataRouteRegex: normalizeRouteRegex( + getNamedRouteRegex( + dataRoute.replace(/\.rsc$/, '') + ).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.rsc$') + ), + } + } + } + const moveExportedPage = async ( originPage: string, page: string, @@ -2347,8 +2490,7 @@ export default async function build( telemetry.record(eventPackageUsedInGetServerSideProps(telemetryPlugin)) } - if (ssgPages.size > 0) { - const finalDynamicRoutes: PrerenderManifest['dynamicRoutes'] = {} + if (ssgPages.size > 0 || appDir) { tbdPrerenderRoutes.forEach((tbdRoute) => { const normalizedRoute = normalizePagePath(tbdRoute) const dataRoute = path.posix.join( diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 9d7c3992dfcc..500c4d9a98d9 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -813,13 +813,21 @@ export async function getJsPageSizeInKb( return [-1, -1] } -export async function buildStaticPaths( - page: string, - getStaticPaths: GetStaticPaths, - configFileName: string, - locales?: string[], +export async function buildStaticPaths({ + page, + getStaticPaths, + staticPathsResult, + configFileName, + locales, + defaultLocale, +}: { + page: string + getStaticPaths?: GetStaticPaths + staticPathsResult?: UnwrapPromise> + configFileName: string + locales?: string[] defaultLocale?: string -): Promise< +}): Promise< Omit>, 'paths'> & { paths: string[] encodedPaths: string[] @@ -833,7 +841,15 @@ export async function buildStaticPaths( // Get the default list of allowed params. const _validParamKeys = Object.keys(_routeMatcher(page)) - const staticPathsResult = await getStaticPaths({ locales, defaultLocale }) + if (!staticPathsResult) { + if (getStaticPaths) { + staticPathsResult = await getStaticPaths({ locales, defaultLocale }) + } else { + throw new Error( + `invariant: attempted to buildStaticPaths without "staticPathsResult" or "getStaticPaths" ${page}` + ) + } + } const expectedReturnVal = `Expected: { paths: [], fallback: boolean }\n` + @@ -1013,6 +1029,137 @@ export async function buildStaticPaths( } } +export type AppConfig = { + revalidate?: number | false + dynamicParams?: true | false + dynamic?: 'auto' | 'error' | 'force-static' + fetchCache?: 'force-cache' | 'only-cache' + preferredRegion?: string +} +type GenerateParams = Array<{ + config: AppConfig + segmentPath: string + getStaticPaths?: GetStaticPaths + generateStaticParams?: any + isLayout?: boolean +}> + +export const collectGenerateParams = ( + segment: any, + parentSegments: string[] = [], + generateParams: GenerateParams = [] +): GenerateParams => { + if (!Array.isArray(segment)) return generateParams + const isLayout = !!segment[2]?.layout + const mod = isLayout ? segment[2]?.layout?.() : segment[2]?.page?.() + + const result = { + isLayout, + segmentPath: `/${parentSegments.join('/')}${ + segment[0] && parentSegments.length > 0 ? '/' : '' + }${segment[0]}`, + config: mod?.config, + getStaticPaths: mod?.getStaticPaths, + generateStaticParams: mod?.generateStaticParams, + } + + if (segment[0]) { + parentSegments.push(segment[0]) + } + + if (result.config || result.generateStaticParams || result.getStaticPaths) { + generateParams.push(result) + } + return collectGenerateParams( + segment[1]?.children, + parentSegments, + generateParams + ) +} + +export async function buildAppStaticPaths({ + page, + configFileName, + generateParams, +}: { + page: string + configFileName: string + generateParams: GenerateParams +}) { + const pageEntry = generateParams[generateParams.length - 1] + + // if the page has legacy getStaticPaths we call it like normal + if (typeof pageEntry?.getStaticPaths === 'function') { + return buildStaticPaths({ + page, + configFileName, + getStaticPaths: pageEntry.getStaticPaths, + }) + } else { + // if generateStaticParams is being used we iterate over them + // collecting them from each level + type Params = Array> + let hadGenerateParams = false + + const buildParams = async ( + paramsItems: Params = [{}], + idx = 0 + ): Promise => { + const curGenerate = generateParams[idx] + + if (idx === generateParams.length) { + return paramsItems + } + if ( + typeof curGenerate.generateStaticParams !== 'function' && + idx < generateParams.length + ) { + return buildParams(paramsItems, idx + 1) + } + hadGenerateParams = true + + const newParams = [] + + for (const params of paramsItems) { + const result = await curGenerate.generateStaticParams({ params }) + // TODO: validate the result is valid here or wait for + // buildStaticPaths to validate? + for (const item of result.params) { + newParams.push({ ...params, ...item }) + } + } + + if (idx < generateParams.length) { + return buildParams(newParams, idx + 1) + } + return newParams + } + const builtParams = await buildParams() + const fallback = !generateParams.some( + // TODO: check complementary configs that can impact + // dynamicParams behavior + (generate) => generate.config?.dynamicParams === false + ) + + if (!hadGenerateParams) { + return { + paths: undefined, + fallback: undefined, + encodedPaths: undefined, + } + } + + return buildStaticPaths({ + staticPathsResult: { + fallback, + paths: builtParams.map((params) => ({ params })), + }, + page, + configFileName, + }) + } +} + export async function isPageStatic({ page, distDir, @@ -1025,6 +1172,9 @@ export async function isPageStatic({ parentId, pageRuntime, edgeInfo, + pageType, + hasServerComponents, + originalAppPath, }: { page: string distDir: string @@ -1036,7 +1186,10 @@ export async function isPageStatic({ defaultLocale?: string parentId?: any edgeInfo?: any + pageType?: 'pages' | 'app' pageRuntime: ServerRuntime + hasServerComponents?: boolean + originalAppPath?: string }): Promise<{ isStatic?: boolean isAmpOnly?: boolean @@ -1049,6 +1202,7 @@ export async function isPageStatic({ isNextImageImported?: boolean traceIncludes?: string[] traceExcludes?: string[] + appConfig?: AppConfig }> { const isPageStaticSpan = trace('is-page-static-utils', parentId) return isPageStaticSpan @@ -1057,6 +1211,10 @@ export async function isPageStatic({ setHttpAgentOptions(httpAgentOptions) let componentsResult: LoadComponentsReturnType + let prerenderRoutes: Array | undefined + let encodedPrerenderRoutes: Array | undefined + let prerenderFallback: boolean | 'blocking' | undefined + let appConfig: AppConfig = {} if (pageRuntime === SERVER_RUNTIME.edge) { const runtime = await getRuntimeContext({ @@ -1084,16 +1242,74 @@ export async function isPageStatic({ } else { componentsResult = await loadComponents({ distDir, - pathname: page, + pathname: originalAppPath || page, serverless, - hasServerComponents: false, - isAppPath: false, + hasServerComponents: !!hasServerComponents, + isAppPath: pageType === 'app', }) } - const Comp = componentsResult.Component + const Comp = componentsResult.Component || {} + let staticPathsResult: + | UnwrapPromise> + | undefined + + if (pageType === 'app') { + const tree = componentsResult.ComponentMod.tree + const generateParams = collectGenerateParams(tree) + + appConfig = generateParams.reduce( + (builtConfig: AppConfig, curGenParams): AppConfig => { + const { + dynamic, + fetchCache, + preferredRegion, + revalidate: curRevalidate, + } = curGenParams?.config || {} + + // TODO: should conflicting configs here throw an error + // e.g. if layout defines one region but page defines another + if (typeof builtConfig.preferredRegion === 'undefined') { + builtConfig.preferredRegion = preferredRegion + } + if (typeof builtConfig.dynamic === 'undefined') { + builtConfig.dynamic = dynamic + } + if (typeof builtConfig.fetchCache === 'undefined') { + builtConfig.fetchCache = fetchCache + } + + // any revalidate number overrides false + // shorter revalidate overrides longer (initially) + if (typeof builtConfig.revalidate === 'undefined') { + builtConfig.revalidate = curRevalidate + } + if ( + typeof curRevalidate === 'number' && + (typeof builtConfig.revalidate !== 'number' || + curRevalidate < builtConfig.revalidate) + ) { + builtConfig.revalidate = curRevalidate + } + return builtConfig + }, + {} + ) - if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { - throw new Error('INVALID_DEFAULT_EXPORT') + if (isDynamicRoute(page)) { + ;({ + paths: prerenderRoutes, + fallback: prerenderFallback, + encodedPaths: encodedPrerenderRoutes, + } = await buildAppStaticPaths({ + page, + configFileName, + generateParams, + })) + } + } else { + if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { + throw new Error('INVALID_DEFAULT_EXPORT') + } } const hasGetInitialProps = !!(Comp as any).getInitialProps @@ -1163,21 +1379,19 @@ export async function isPageStatic({ ) } - let prerenderRoutes: Array | undefined - let encodedPrerenderRoutes: Array | undefined - let prerenderFallback: boolean | 'blocking' | undefined - if (hasStaticProps && hasStaticPaths) { + if ((hasStaticProps && hasStaticPaths) || staticPathsResult) { ;({ paths: prerenderRoutes, fallback: prerenderFallback, encodedPaths: encodedPrerenderRoutes, - } = await buildStaticPaths( + } = await buildStaticPaths({ page, - componentsResult.getStaticPaths!, - configFileName, locales, - defaultLocale - )) + defaultLocale, + configFileName, + staticPathsResult, + getStaticPaths: componentsResult.getStaticPaths!, + })) } const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED @@ -1194,6 +1408,7 @@ export async function isPageStatic({ isNextImageImported, traceIncludes: config.unstable_includeFiles || [], traceExcludes: config.unstable_excludeFiles || [], + appConfig, } }) .catch((err) => { diff --git a/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts index 49d335551858..30ac4851283d 100644 --- a/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts +++ b/packages/next/build/webpack/loaders/next-edge-ssr-loader/render.ts @@ -81,6 +81,7 @@ export function getRender({ getServerSideProps: pageMod.getServerSideProps, getStaticPaths: pageMod.getStaticPaths, ComponentMod: pageMod, + pathname, } } @@ -94,6 +95,7 @@ export function getRender({ getServerSideProps: error500Mod.getServerSideProps, getStaticPaths: error500Mod.getStaticPaths, ComponentMod: error500Mod, + pathname, } } @@ -106,6 +108,7 @@ export function getRender({ getServerSideProps: errorMod.getServerSideProps, getStaticPaths: errorMod.getStaticPaths, ComponentMod: errorMod, + pathname, } } diff --git a/packages/next/build/worker.ts b/packages/next/build/worker.ts index fe111eda503d..76a13ebfd878 100644 --- a/packages/next/build/worker.ts +++ b/packages/next/build/worker.ts @@ -1,2 +1,3 @@ export * from './utils' -export { default as exportPage } from '../export/worker' +import exportPage from '../export/worker' +export { exportPage } diff --git a/packages/next/client/components/hooks-server-context.ts b/packages/next/client/components/hooks-server-context.ts index 209236a68a75..b7b27f78b9bc 100644 --- a/packages/next/client/components/hooks-server-context.ts +++ b/packages/next/client/components/hooks-server-context.ts @@ -1,9 +1,15 @@ // @ts-expect-error createServerContext exists on experimental channel import { createServerContext } from 'react' +export class DynamicServerError extends Error { + constructor(type: string) { + super(`Dynamic server usage: ${type}`) + } +} + // Ensure serverContext is not created more than once as React will throw when creating it more than once // https://github.com/facebook/react/blob/dd2d6522754f52c70d02c51db25eb7cbd5d1c8eb/packages/react/src/ReactServerContext.js#L101 -const createContext = (name: string) => { +const createContext = (name: string, defaultValue: T | null = null) => { // @ts-expect-error __NEXT_DEV_SERVER_CONTEXT__ is a global if (!global.__NEXT_DEV_SERVER_CONTEXT__) { // @ts-expect-error __NEXT_DEV_SERVER_CONTEXT__ is a global @@ -13,13 +19,30 @@ const createContext = (name: string) => { // @ts-expect-error __NEXT_DEV_SERVER_CONTEXT__ is a global if (!global.__NEXT_DEV_SERVER_CONTEXT__[name]) { // @ts-expect-error __NEXT_DEV_SERVER_CONTEXT__ is a global - global.__NEXT_DEV_SERVER_CONTEXT__[name] = createServerContext(name, null) + global.__NEXT_DEV_SERVER_CONTEXT__[name] = createServerContext( + name, + defaultValue + ) } // @ts-expect-error __NEXT_DEV_SERVER_CONTEXT__ is a global return global.__NEXT_DEV_SERVER_CONTEXT__[name] } -export const HeadersContext = createContext('HeadersContext') -export const PreviewDataContext = createContext('PreviewDataContext') -export const CookiesContext = createContext('CookiesContext') +export const CONTEXT_NAMES = { + HeadersContext: 'HeadersContext', + PreviewDataContext: 'PreviewDataContext', + CookiesContext: 'CookiesContext', + StaticGenerationContext: 'StaticGenerationContext', + FetchRevalidateContext: 'FetchRevalidateContext', +} as const + +export const HeadersContext = createContext(CONTEXT_NAMES.HeadersContext) +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 7ee580d31060..2768a27716d2 100644 --- a/packages/next/client/components/hooks-server.ts +++ b/packages/next/client/components/hooks-server.ts @@ -3,16 +3,37 @@ import { HeadersContext, PreviewDataContext, CookiesContext, + DynamicServerError, + StaticGenerationContext, } from './hooks-server-context' +export function useTrackStaticGeneration() { + return useContext< + typeof import('./hooks-server-context').StaticGenerationContext + >(StaticGenerationContext) +} + +function useStaticGenerationBailout(reason: string) { + const staticGenerationContext = useTrackStaticGeneration() + + if (staticGenerationContext.isStaticGeneration) { + // TODO: honor the dynamic: 'force-static' + staticGenerationContext.revalidate = 0 + throw new DynamicServerError(reason) + } +} + export function useHeaders() { + useStaticGenerationBailout('useHeaders') return useContext(HeadersContext) } export function usePreviewData() { + useStaticGenerationBailout('usePreviewData') return useContext(PreviewDataContext) } export function useCookies() { + useStaticGenerationBailout('useCookies') return useContext(CookiesContext) } diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 375d4f915571..1f35bc9e56e8 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -22,6 +22,8 @@ import { CLIENT_STATIC_FILES_PATH, EXPORT_DETAIL, EXPORT_MARKER, + FLIGHT_MANIFEST, + FLIGHT_SERVER_CSS_MANIFEST, PAGES_MANIFEST, PHASE_EXPORT, PRERENDER_MANIFEST, @@ -386,6 +388,7 @@ export default async function exportApp( nextScriptWorkers: nextConfig.experimental.nextScriptWorkers, optimizeFonts: nextConfig.optimizeFonts as FontConfig, largePageDataBytes: nextConfig.experimental.largePageDataBytes, + serverComponents: nextConfig.experimental.serverComponents, } const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig @@ -404,15 +407,31 @@ export default async function exportApp( } const exportPathMap = await nextExportSpan .traceChild('run-export-path-map') - .traceAsyncFn(() => - nextConfig.exportPathMap(defaultPathMap, { + .traceAsyncFn(async () => { + const exportMap = await nextConfig.exportPathMap(defaultPathMap, { dev: false, dir, outDir, distDir, buildId, }) - ) + return exportMap + }) + + if (options.buildExport && nextConfig.experimental.appDir) { + // @ts-expect-error untyped + renderOpts.serverComponentManifest = require(join( + distDir, + SERVER_DIRECTORY, + `${FLIGHT_MANIFEST}.json` + )) as PagesManifest + // @ts-expect-error untyped + renderOpts.serverCSSManifest = require(join( + distDir, + SERVER_DIRECTORY, + FLIGHT_SERVER_CSS_MANIFEST + '.json' + )) as PagesManifest + } // only add missing 404 page when `buildExport` is false if (!options.buildExport) { diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 97f67c26746b..ebd32f9b866f 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -27,6 +27,7 @@ import { setHttpAgentOptions } from '../server/config' 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' loadRequireHook() const envConfig = require('../shared/lib/runtime-config') @@ -104,7 +105,6 @@ export default async function exportPage({ pathMap, distDir, outDir, - appPaths, pagesDataDir, renderOpts, buildExport, @@ -129,6 +129,7 @@ export default async function exportPage({ try { const { query: originalQuery = {} } = pathMap const { page } = pathMap + const isAppDir = (pathMap as any)._isAppDir const filePath = normalizePagePath(path) const isDynamic = isDynamicRoute(page) const ampPath = `${filePath}.amp` @@ -136,6 +137,9 @@ export default async function exportPage({ let query = { ...originalQuery } let params: { [key: string]: string | string[] } | undefined + if (isAppDir) { + outDir = join(distDir, 'server/app') + } let updatedPath = query.__nextSsgPath || path let locale = query.__nextLocale || renderOpts.locale delete query.__nextLocale @@ -172,7 +176,11 @@ export default async function exportPage({ ).pathname if (isDynamic && page !== nonLocalizedPath) { - params = getRouteMatcher(getRouteRegex(page))(updatedPath) || undefined + const normalizedPage = isAppDir ? normalizeAppPath(page) : page + + params = + getRouteMatcher(getRouteRegex(normalizedPage))(updatedPath) || + undefined if (params) { // we have to pass these separately for serverless if (!serverless) { @@ -272,9 +280,6 @@ export default async function exportPage({ return !buildExport && getStaticProps && !isDynamicRoute(path) } - const isAppPath = appPaths.some((appPath: string) => - appPath.startsWith(page + '.page') - ) if (serverless) { const curUrl = url.parse(req.url!, true) req.url = url.format({ @@ -295,7 +300,7 @@ export default async function exportPage({ pathname: page, serverless, hasServerComponents: !!serverComponents, - isAppPath, + isAppPath: isAppDir, }) const ampState = { ampFirst: pageConfig?.amp === true, @@ -362,8 +367,66 @@ export default async function exportPage({ pathname: page, serverless, hasServerComponents: !!serverComponents, - isAppPath, + isAppPath: isAppDir, }) + curRenderOpts = { + ...components, + ...renderOpts, + ampPath: renderAmpPath, + params, + optimizeFonts, + optimizeCss, + disableOptimizedLoading, + fontManifest: optimizeFonts + ? requireFontManifest(distDir, serverless) + : null, + locale: locale as string, + } + + // during build we attempt rendering app dir paths + // and bail when dynamic dependencies are detected + // only fully static paths are fully generated here + if (isAppDir) { + const { + DynamicServerError, + } = require('../client/components/hooks-server-context') + + const { renderToHTMLOrFlight } = + require('../server/app-render') as typeof import('../server/app-render') + + try { + curRenderOpts.params ||= {} + + const result = await renderToHTMLOrFlight( + req as any, + res as any, + page, + query, + curRenderOpts as any, + false, + true + ) + const html = result?.toUnchunkedString() + const flightData = (curRenderOpts as any).pageData + const revalidate = (curRenderOpts as any).revalidate + results.fromBuildExportRevalidate = revalidate + + if (revalidate !== 0) { + await promises.writeFile(htmlFilepath, html, 'utf8') + await promises.writeFile( + htmlFilepath.replace(/\.html$/, '.rsc'), + flightData + ) + } + } catch (err) { + if (!(err instanceof DynamicServerError)) { + throw err + } + } + + return { ...results, duration: Date.now() - start } + } + const ampState = { ampFirst: components.pageConfig?.amp === true, hasQuery: Boolean(query.amp), @@ -407,19 +470,6 @@ export default async function exportPage({ if (optimizeCss) { process.env.__NEXT_OPTIMIZE_CSS = JSON.stringify(true) } - curRenderOpts = { - ...components, - ...renderOpts, - ampPath: renderAmpPath, - params, - optimizeFonts, - optimizeCss, - disableOptimizedLoading, - fontManifest: optimizeFonts - ? requireFontManifest(distDir, serverless) - : null, - locale: locale as string, - } renderResult = await renderMethod( req, res, diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index f115df782da1..4e4112a428ab 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -1,4 +1,4 @@ -import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http' +import type { IncomingMessage, ServerResponse } from 'http' import type { LoadComponentsReturnType } from './load-components' import type { ServerRuntime } from '../types' @@ -17,10 +17,8 @@ import { createBufferedTransformStream, continueFromInitialStream, } from './node-web-streams-helper' -import { isDynamicRoute } from '../shared/lib/router/utils' import { ESCAPE_REGEX, htmlEscapeJsonString } from './htmlescape' import { shouldUseReactRoot } from './utils' -import { NextApiRequestCookies } from './api-utils' import { matchSegment } from '../client/components/match-segments' import { FlightCSSManifest, @@ -65,76 +63,66 @@ function interopDefault(mod: any) { return mod.default || mod } -// 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 -} +// tolerate dynamic server errors during prerendering so console +// isn't spammed with unactionable errors +function onError(err: any) { + const { DynamicServerError } = + require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') -/** - * Create data fetching record for Promise. - */ -function createRecordFromThenable(thenable: Promise) { - const record: Record = { - status: RecordStatus.Pending, - value: thenable, + if (!(err instanceof DynamicServerError)) { + console.error(err) } - 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 - } -} +let isFetchPatched = false -/** - * 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, - fetcher: () => Promise | any -) { - let record = map.get(key) - - if (!record) { - const thenable = fetcher() - record = createRecordFromThenable(thenable) - map.set(key, record) - } +// we patch fetch to collect cache information used for +// determining if a page is static or not +function patchFetch() { + if (isFetchPatched) return + isFetchPatched = true + + const { DynamicServerError } = + require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') + + const { useTrackStaticGeneration } = + 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 { isStaticGeneration, fetchRevalidate, pathname } = + staticGenerationContext + + if (isStaticGeneration) { + if (opts && typeof opts === 'object') { + if (opts.cache === 'no-store') { + staticGenerationContext.revalidate = 0 + // TODO: ensure this error isn't logged to the user + // seems it's slipping through currently + throw new DynamicServerError( + `no-store fetch ${init}${pathname ? ` ${pathname}` : ''}` + ) + } - return record + if ( + typeof opts.revalidate === 'number' && + (typeof fetchRevalidate === 'undefined' || + opts.revalidate < fetchRevalidate) + ) { + staticGenerationContext.fetchRevalidate = opts.revalidate + } + } + } + return origFetch(init, opts) + } } interface FlightResponseRef { @@ -149,6 +137,7 @@ function useFlightResponse( writable: WritableStream, req: ReadableStream, serverComponentManifest: any, + rscChunks: Uint8Array[], flightResponseRef: FlightResponseRef, nonce?: string ): Promise { @@ -172,6 +161,10 @@ function useFlightResponse( function process() { forwardReader.read().then(({ done, value }) => { + if (value) { + rscChunks.push(value) + } + if (!bootstrapped) { bootstrapped = true writer.write( @@ -217,12 +210,14 @@ function createServerComponentRenderer( transformStream, serverComponentManifest, serverContexts, + rscChunks, }: { transformStream: TransformStream serverComponentManifest: NonNullable serverContexts: Array< [ServerContextName: string, JSONValue: Object | number | string] > + rscChunks: Uint8Array[] }, nonce?: string ): () => JSX.Element { @@ -246,6 +241,7 @@ function createServerComponentRenderer( serverComponentManifest, { context: serverContexts, + onError, } ) } @@ -261,6 +257,7 @@ function createServerComponentRenderer( writable, reqStream, serverComponentManifest, + rscChunks, flightResponseRef, nonce ) @@ -479,8 +476,14 @@ export async function renderToHTMLOrFlight( pathname: string, query: NextParsedUrlQuery, renderOpts: RenderOpts, - isPagesDir: boolean + isPagesDir: boolean, + isStaticGeneration: boolean = false ): Promise { + patchFetch() + + const { CONTEXT_NAMES } = + require('../client/components/hooks-server-context') as typeof import('../client/components/hooks-server-context') + // @ts-expect-error createServerContext exists in react@experimental + react-dom@experimental if (typeof React.createServerContext === 'undefined') { throw new Error( @@ -511,9 +514,9 @@ export async function renderToHTMLOrFlight( // 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).pipeThrough( - createBufferedTransformStream() - ) + renderToReadableStream(flightData, serverComponentManifest, { + onError, + }).pipeThrough(createBufferedTransformStream()) ) } @@ -531,7 +534,6 @@ export async function renderToHTMLOrFlight( stripInternalQueries(query) - const pageIsDynamic = isDynamicRoute(pathname) const LayoutRouter = ComponentMod.LayoutRouter as typeof import('../client/components/layout-router.client').default const RenderFromTemplateContext = @@ -563,24 +565,25 @@ export async function renderToHTMLOrFlight( 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 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 - ['HeadersContext', headers], - ['CookiesContext', cookies], - ['PreviewDataContext', previewData], + [CONTEXT_NAMES.HeadersContext, headers], + [CONTEXT_NAMES.CookiesContext, cookies], + [CONTEXT_NAMES.PreviewDataContext, previewData], + [CONTEXT_NAMES.StaticGenerationContext, staticGenerationContext], ] - /** - * Used to keep track of in-flight / resolved data fetching Promises. - */ - const dataCache = new Map() - type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath /** @@ -661,6 +664,8 @@ export async function renderToHTMLOrFlight( return segmentTree } + let defaultRevalidate: false | undefined | number = false + /** * Use the provided loader tree to create the React Component tree. */ @@ -701,6 +706,10 @@ export async function renderToHTMLOrFlight( : isPage ? await page() : undefined + + if (layoutOrPageMod?.config) { + defaultRevalidate = layoutOrPageMod.config.revalidate + } /** * Checks if the current segment is a root layout. */ @@ -711,11 +720,19 @@ export async function renderToHTMLOrFlight( const rootLayoutIncludedAtThisLevelOrAbove = rootLayoutIncluded || rootLayoutAtThisLevel - /** - * Check if the current layout/page is a client component - */ - const isClientComponentModule = - layoutOrPageMod && !layoutOrPageMod.hasOwnProperty('__next_rsc__') + // 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}` + ) + } /** * The React Component to render. @@ -727,7 +744,7 @@ export async function renderToHTMLOrFlight( // Handle dynamic segment params. const segmentParam = getDynamicParamFromSegment(segment) /** - * Create object holding the parent params and current params, this is passed to getServerSideProps and getStaticProps. + * Create object holding the parent params and current params */ const currentParams = // Handle null case where dynamic param is optional @@ -836,101 +853,9 @@ export async function renderToHTMLOrFlight( } } - const segmentPath = createSegmentPath([actualSegment]) - const dataCacheKey = JSON.stringify(segmentPath) - let fetcher: (() => Promise) | 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 - } - } + let props = {} return ( <> @@ -964,6 +889,21 @@ export async function renderToHTMLOrFlight( } } + /** + * 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 + // 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 @@ -981,7 +921,6 @@ export async function renderToHTMLOrFlight( 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 @@ -1064,11 +1003,22 @@ export async function renderToHTMLOrFlight( ).slice(1), ] - return new FlightRenderResult( - renderToReadableStream(flightData, serverComponentManifest, { + const readable = renderToReadableStream( + flightData, + serverComponentManifest, + { context: serverContexts, - }).pipeThrough(createBufferedTransformStream()) - ) + onError, + } + ).pipeThrough(createBufferedTransformStream()) + + if (generateStaticHTML) { + let staticHtml = Buffer.from( + (await readable.getReader().read()).value || '' + ).toString() + return new FlightRenderResult(staticHtml) + } + return new FlightRenderResult(readable) } // Below this line is handling for rendering to HTML. @@ -1100,6 +1050,13 @@ export async function renderToHTMLOrFlight( nonce = getScriptNonceFromHeader(csp) } + const serverComponentsRenderOpts = { + transformStream: serverComponentsInlinedTransformStream, + serverComponentManifest, + serverContexts, + rscChunks: [], + } + /** * A new React Component that renders the provided React Component * using Flight which can then be rendered to HTML. @@ -1123,11 +1080,7 @@ export async function renderToHTMLOrFlight( ) }, ComponentMod, - { - transformStream: serverComponentsInlinedTransformStream, - serverComponentManifest, - serverContexts, - }, + serverComponentsRenderOpts, nonce ) @@ -1149,20 +1102,6 @@ export async function renderToHTMLOrFlight( ) } - /** - * 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 = ( @@ -1234,5 +1173,22 @@ export async function renderToHTMLOrFlight( } } - return new RenderResult(await bodyResult()) + const readable = await bodyResult() + + if (generateStaticHTML) { + let staticHtml = Buffer.from( + (await readable.getReader().read()).value || '' + ).toString() + + ;(renderOpts as any).pageData = Buffer.concat( + serverComponentsRenderOpts.rscChunks + ).toString() + ;(renderOpts as any).revalidate = + typeof staticGenerationContext.revalidate === 'undefined' + ? defaultRevalidate + : staticGenerationContext.revalidate + + return new RenderResult(staticHtml) + } + return new RenderResult(readable) } diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index c6c0e1bea435..cccb74732abe 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -913,9 +913,14 @@ export default abstract class Server { }) } - protected async getStaticPaths(pathname: string): Promise<{ - staticPaths: string[] | undefined - fallbackMode: 'static' | 'blocking' | false + protected async getStaticPaths({ + pathname, + }: { + pathname: string + originalAppPath?: string + }): Promise<{ + staticPaths?: string[] + fallbackMode?: 'static' | 'blocking' | false }> { // `staticPaths` is intentionally set to `undefined` as it should've // been caught when checking disk data. @@ -923,7 +928,7 @@ export default abstract class Server { // Read whether or not fallback should exist from the manifest. const fallbackField = - this.getPrerenderManifest().dynamicRoutes[pathname].fallback + this.getPrerenderManifest().dynamicRoutes[pathname]?.fallback return { staticPaths, @@ -932,7 +937,7 @@ export default abstract class Server { ? 'static' : fallbackField === null ? 'blocking' - : false, + : fallbackField, } } @@ -942,15 +947,17 @@ export default abstract class Server { ): Promise { const is404Page = pathname === '/404' const is500Page = pathname === '/500' + const isAppPath = components.isAppPath const isLikeServerless = typeof components.ComponentMod === 'object' && typeof (components.ComponentMod as any).renderReqToHTML === 'function' const hasServerProps = !!components.getServerSideProps - const hasStaticPaths = !!components.getStaticPaths + let hasStaticPaths = !!components.getStaticPaths + const hasGetInitialProps = !!components.Component?.getInitialProps const isServerComponent = !!components.ComponentMod?.__next_rsc__ - const isSSG = + let isSSG = !!components.getStaticProps || // For static server component pages, we currently always consider them // as SSG since we also need to handle the next data (flight JSON). @@ -970,6 +977,37 @@ export default abstract class Server { delete query.__nextDataReq + // Compute the iSSG cache key. We use the rewroteUrl since + // pages with fallback: false are allowed to be rewritten to + // and we need to look up the path by the rewritten path + let urlPathname = parseUrl(req.url || '').pathname || '/' + + let resolvedUrlPathname = + getRequestMeta(req, '_nextRewroteUrl') || urlPathname + + let staticPaths: string[] | undefined + let fallbackMode: false | undefined | 'blocking' | 'static' + + if (isAppPath) { + const pathsResult = await this.getStaticPaths({ + pathname, + originalAppPath: components.pathname, + }) + + staticPaths = pathsResult.staticPaths + fallbackMode = pathsResult.fallbackMode + + const hasFallback = typeof fallbackMode !== 'undefined' + + if (hasFallback) { + hasStaticPaths = true + } + + if (hasFallback || staticPaths?.includes(resolvedUrlPathname)) { + isSSG = true + } + } + // normalize req.url for SSG paths as it is not exposed // to getStaticProps and the asPath should not expose /_next/data if ( @@ -1049,6 +1087,9 @@ export default abstract class Server { // Disable dynamic HTML in cases that we know it won't be generated, // so that we can continue generating a cache key when possible. + // TODO-APP: should the first render for a dynamic app path + // be static so we can collect revalidate and populate the + // cache if there are no dynamic data requirements opts.supportsDynamicHTML = !isSSG && !isLikeServerless && @@ -1085,14 +1126,6 @@ export default abstract class Server { checkIsManualRevalidate(req, this.renderOpts.previewProps)) } - // Compute the iSSG cache key. We use the rewroteUrl since - // pages with fallback: false are allowed to be rewritten to - // and we need to look up the path by the rewritten path - let urlPathname = parseUrl(req.url || '').pathname || '/' - - let resolvedUrlPathname = - getRequestMeta(req, '_nextRewroteUrl') || urlPathname - if (isSSG && this.minimalMode && req.headers['x-matched-path']) { // the url value is already correct when the matched-path header is set resolvedUrlPathname = urlPathname @@ -1248,6 +1281,10 @@ export default abstract class Server { : resolvedUrl, } + if (isSSG || hasStaticPaths) { + renderOpts.supportsDynamicHTML = false + } + const renderResult = await this.renderHTML( req, res, @@ -1285,9 +1322,11 @@ export default abstract class Server { const isDynamicPathname = isDynamicRoute(pathname) const didRespond = hasResolved || res.sent - let { staticPaths, fallbackMode } = hasStaticPaths - ? await this.getStaticPaths(pathname) - : { staticPaths: undefined, fallbackMode: false } + if (!staticPaths) { + ;({ staticPaths, fallbackMode } = hasStaticPaths + ? await this.getStaticPaths({ pathname }) + : { staticPaths: undefined, fallbackMode: false }) + } if ( fallbackMode === 'static' && @@ -1536,9 +1575,10 @@ export default abstract class Server { const { query, pathname } = ctx const appPaths = this.getOriginalAppPaths(pathname) + const isAppPath = Array.isArray(appPaths) let page = pathname - if (Array.isArray(appPaths)) { + if (isAppPath) { // When it's an array, we need to pass all parallel routes to the loader. page = appPaths[0] } @@ -1547,7 +1587,7 @@ export default abstract class Server { pathname: page, query, params: ctx.renderOpts.params || {}, - isAppPath: Array.isArray(appPaths), + isAppPath, appPaths, sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, }) diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index d3176c140dd6..8c66ad17dfe1 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -1263,9 +1263,15 @@ export default class DevServer extends Server { return !snippet.includes('data-amp-development-mode-only') } - protected async getStaticPaths(pathname: string): Promise<{ - staticPaths: string[] | undefined - fallbackMode: false | 'static' | 'blocking' + protected async getStaticPaths({ + pathname, + originalAppPath, + }: { + pathname: string + originalAppPath?: string + }): Promise<{ + staticPaths?: string[] + fallbackMode?: false | 'static' | 'blocking' }> { // we lazy load the staticPaths to prevent the user // from waiting on them for the page to load in dev mode @@ -1279,20 +1285,22 @@ export default class DevServer extends Server { } = this.nextConfig const { locales, defaultLocale } = this.nextConfig.i18n || {} - const paths = await this.getStaticPathsWorker().loadStaticPaths( - this.distDir, + const pathsResult = await this.getStaticPathsWorker().loadStaticPaths({ + distDir: this.distDir, pathname, - !this.renderOpts.dev && this._isLikeServerless, - { + serverless: !this.renderOpts.dev && this._isLikeServerless, + config: { configFileName, publicRuntimeConfig, serverRuntimeConfig, }, httpAgentOptions, locales, - defaultLocale - ) - return paths + defaultLocale, + originalAppPath, + isAppPath: !!originalAppPath, + }) + return pathsResult } const { paths: staticPaths, fallback } = ( await withCoalescedInvoke(__getStaticPaths)(`staticPaths-${pathname}`, []) @@ -1305,7 +1313,7 @@ export default class DevServer extends Server { ? 'blocking' : fallback === true ? 'static' - : false, + : fallback, } } diff --git a/packages/next/server/dev/static-paths-worker.ts b/packages/next/server/dev/static-paths-worker.ts index e56d018b1f26..e9fb4292ff94 100644 --- a/packages/next/server/dev/static-paths-worker.ts +++ b/packages/next/server/dev/static-paths-worker.ts @@ -1,9 +1,11 @@ -import type { GetStaticPaths } from 'next/types' import type { NextConfigComplete } from '../config-shared' -import type { UnwrapPromise } from '../../lib/coalesced-function' import '../node-polyfill-fetch' -import { buildStaticPaths } from '../../build/utils' +import { + buildAppStaticPaths, + buildStaticPaths, + collectGenerateParams, +} from '../../build/utils' import { loadComponents } from '../load-components' import { setHttpAgentOptions } from '../config' @@ -14,20 +16,31 @@ let workerWasUsed = false // we call getStaticPaths in a separate process to ensure // side-effects aren't relied on in dev that will break // during a production build -export async function loadStaticPaths( - distDir: string, - pathname: string, - serverless: boolean, - config: RuntimeConfig, - httpAgentOptions: NextConfigComplete['httpAgentOptions'], - locales?: string[], +export async function loadStaticPaths({ + distDir, + pathname, + serverless, + config, + httpAgentOptions, + locales, + defaultLocale, + isAppPath, + originalAppPath, +}: { + distDir: string + pathname: string + serverless: boolean + config: RuntimeConfig + httpAgentOptions: NextConfigComplete['httpAgentOptions'] + locales?: string[] defaultLocale?: string -): Promise< - Omit>, 'paths'> & { - paths: string[] - encodedPaths: string[] - } -> { + isAppPath?: boolean + originalAppPath?: string +}): Promise<{ + paths?: string[] + encodedPaths?: string[] + fallback?: boolean | 'blocking' +}> { // we only want to use each worker once to prevent any invalid // caches if (workerWasUsed) { @@ -40,26 +53,35 @@ export async function loadStaticPaths( const components = await loadComponents({ distDir, - pathname, + pathname: originalAppPath || pathname, serverless, hasServerComponents: false, - isAppPath: false, + isAppPath: !!isAppPath, }) - if (!components.getStaticPaths) { + if (!components.getStaticPaths && !isAppPath) { // we shouldn't get to this point since the worker should // only be called for SSG pages with getStaticPaths throw new Error( `Invariant: failed to load page with getStaticPaths for ${pathname}` ) } - workerWasUsed = true - return buildStaticPaths( - pathname, - components.getStaticPaths, - config.configFileName, + + if (isAppPath) { + const generateParams = collectGenerateParams(components.ComponentMod.tree) + return buildAppStaticPaths({ + page: pathname, + generateParams, + configFileName: config.configFileName, + }) + } + + return buildStaticPaths({ + page: pathname, + getStaticPaths: components.getStaticPaths, + configFileName: config.configFileName, locales, - defaultLocale - ) + defaultLocale, + }) } diff --git a/packages/next/server/lib/incremental-cache/file-system-cache.ts b/packages/next/server/lib/incremental-cache/file-system-cache.ts index 454cf3e26722..85f43051ba26 100644 --- a/packages/next/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/server/lib/incremental-cache/file-system-cache.ts @@ -7,11 +7,13 @@ export default class FileSystemCache implements CacheHandler { private flushToDisk?: CacheHandlerContext['flushToDisk'] private serverDistDir: CacheHandlerContext['serverDistDir'] private memoryCache?: LRUCache + private appDir: boolean constructor(ctx: CacheHandlerContext) { this.fs = ctx.fs this.flushToDisk = ctx.flushToDisk this.serverDistDir = ctx.serverDistDir + this.appDir = !!ctx._appDir if (ctx.maxMemoryCacheSize) { this.memoryCache = new LRUCache({ @@ -25,7 +27,9 @@ export default class FileSystemCache implements CacheHandler { throw new Error('invariant image should not be incremental-cache') } // rough estimate of size of cache value - return value.html.length + JSON.stringify(value.pageData).length + return ( + value.html.length + (JSON.stringify(value.pageData)?.length || 0) + ) }, }) } @@ -37,11 +41,23 @@ export default class FileSystemCache implements CacheHandler { // let's check the disk for seed data if (!data) { try { - const htmlPath = this.getFsPath(`${key}.html`) - const html = await this.fs.readFile(htmlPath) - const pageData = JSON.parse( - await this.fs.readFile(this.getFsPath(`${key}.json`)) + const { filePath: htmlPath, isAppPath } = await this.getFsPath( + `${key}.html` ) + const html = await this.fs.readFile(htmlPath) + const pageData = isAppPath + ? await this.fs.readFile( + ( + await this.getFsPath(`${key}.rsc`, true) + ).filePath + ) + : JSON.parse( + await this.fs.readFile( + await ( + await this.getFsPath(`${key}.json`, false) + ).filePath + ) + ) const { mtime } = await this.fs.stat(htmlPath) data = { @@ -69,17 +85,52 @@ export default class FileSystemCache implements CacheHandler { }) if (data?.kind === 'PAGE') { - const htmlPath = this.getFsPath(`${key}.html`) + const isAppPath = typeof data.pageData === 'string' + const { filePath: htmlPath } = await this.getFsPath( + `${key}.html`, + isAppPath + ) await this.fs.mkdir(path.dirname(htmlPath)) await this.fs.writeFile(htmlPath, data.html) + await this.fs.writeFile( - this.getFsPath(`${key}.json`), - JSON.stringify(data.pageData) + ( + await this.getFsPath( + `${key}.${isAppPath ? 'rsc' : 'json'}`, + isAppPath + ) + ).filePath, + isAppPath ? data.pageData : JSON.stringify(data.pageData) ) } } - private getFsPath(pathname: string): string { - return path.join(this.serverDistDir, 'pages', pathname) + private async getFsPath( + pathname: string, + appDir?: boolean + ): Promise<{ + filePath: string + isAppPath: boolean + }> { + let isAppPath = false + let filePath = path.join(this.serverDistDir, 'pages', pathname) + + if (!this.appDir || appDir === false) + return { + filePath, + isAppPath, + } + try { + await this.fs.readFile(filePath) + return { + filePath, + isAppPath, + } + } catch (err) { + return { + filePath: path.join(this.serverDistDir, 'app', pathname), + isAppPath: true, + } + } } } diff --git a/packages/next/server/lib/incremental-cache/index.ts b/packages/next/server/lib/incremental-cache/index.ts index 7ffed05d5a59..81a325ff5950 100644 --- a/packages/next/server/lib/incremental-cache/index.ts +++ b/packages/next/server/lib/incremental-cache/index.ts @@ -18,6 +18,7 @@ export interface CacheHandlerContext { flushToDisk?: boolean serverDistDir: string maxMemoryCacheSize?: number + _appDir?: boolean } export interface CacheHandlerValue { @@ -47,6 +48,7 @@ export class IncrementalCache { constructor({ fs, dev, + appDir, flushToDisk, serverDistDir, maxMemoryCacheSize, @@ -55,6 +57,7 @@ export class IncrementalCache { }: { fs: CacheFs dev: boolean + appDir?: boolean serverDistDir: string flushToDisk?: boolean maxMemoryCacheSize?: number @@ -80,6 +83,7 @@ export class IncrementalCache { flushToDisk, serverDistDir, maxMemoryCacheSize, + _appDir: appDir, }) } diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 8f9f5431ff9e..46757e836f6e 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -40,6 +40,7 @@ export type LoadComponentsReturnType = { getServerSideProps?: GetServerSideProps ComponentMod: any isAppPath?: boolean + pathname: string } export async function loadDefaultErrorComponents(distDir: string) { @@ -57,6 +58,7 @@ export async function loadDefaultErrorComponents(distDir: string) { buildManifest: require(join(distDir, `fallback-${BUILD_MANIFEST}`)), reactLoadableManifest: {}, ComponentMod, + pathname: '/_error', } } @@ -150,5 +152,6 @@ export async function loadComponents({ getStaticPaths, serverComponentManifest, isAppPath, + pathname, } } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 0f6186421116..4ca035f98009 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -287,6 +287,7 @@ export default class NextNodeServer extends BaseServer { fs: this.getCacheFilesystem(), dev, serverDistDir: this.serverDistDir, + appDir: this.nextConfig.experimental.appDir, maxMemoryCacheSize: this.nextConfig.experimental.isrMemoryCacheSize, flushToDisk: !this.minimalMode && this.nextConfig.experimental.isrFlushToDisk, diff --git a/packages/next/server/require.ts b/packages/next/server/require.ts index 77446af26767..0f8ebd714e6e 100644 --- a/packages/next/server/require.ts +++ b/packages/next/server/require.ts @@ -25,10 +25,10 @@ export function getPagePath( distDir, serverless && !dev ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY ) - let rootPathsManifest: undefined | PagesManifest + let appPathsManifest: undefined | PagesManifest if (appDirEnabled) { - rootPathsManifest = require(join(serverBuildPath, APP_PATHS_MANIFEST)) + appPathsManifest = require(join(serverBuildPath, APP_PATHS_MANIFEST)) } const pagesManifest = require(join( serverBuildPath, @@ -58,8 +58,8 @@ export function getPagePath( } let pagePath: string | undefined - if (rootPathsManifest) { - pagePath = checkManifest(rootPathsManifest) + if (appPathsManifest) { + pagePath = checkManifest(appPathsManifest) } if (!pagePath) { diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.js b/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.js index 5b4a6e8078cd..ae74b3a21545 100644 --- a/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.js +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/[id]/page.js @@ -1,13 +1,17 @@ import Link from 'next/link' +import { experimental_use as use } from 'react' -export async function getServerSideProps() { +async function getData() { await new Promise((resolve) => setTimeout(resolve, 1000)) return { - props: { a: 'b' }, + a: 'b', } } export default function IdPage({ params }) { + const data = use(getData()) + console.log(data) + if (params.id === '123') { return ( <> @@ -24,3 +28,4 @@ export default function IdPage({ params }) { ) } +// diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/layout.js b/test/e2e/app-dir/app-prefetch/app/dashboard/layout.js index 8c83d8e7a216..e1be7852f3c2 100644 --- a/test/e2e/app-dir/app-prefetch/app/dashboard/layout.js +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/layout.js @@ -1,12 +1,15 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 400)) return { - props: { - message: 'Hello World', - }, + message: 'Hello World', } } -export default function DashboardLayout({ children, message }) { + +export default function DashboardLayout({ children }) { + const { message } = use(getData()) + return ( <>

Dashboard {message}

diff --git a/test/e2e/app-dir/app-prefetch/app/dashboard/page.js b/test/e2e/app-dir/app-prefetch/app/dashboard/page.js index 5171e25e542d..9af082bdccce 100644 --- a/test/e2e/app-dir/app-prefetch/app/dashboard/page.js +++ b/test/e2e/app-dir/app-prefetch/app/dashboard/page.js @@ -1,12 +1,14 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 3000)) return { - props: { - message: 'Welcome to the dashboard', - }, + message: 'Welcome to the dashboard', } } -export default function DashboardPage({ message }) { +export default function DashboardPage(props) { + const { message } = use(getData()) + return ( <>

{message}

diff --git a/test/e2e/app-dir/app-prefetch/app/layout.js b/test/e2e/app-dir/app-prefetch/app/layout.js index c84b681925eb..a1a2297e5c9f 100644 --- a/test/e2e/app-dir/app-prefetch/app/layout.js +++ b/test/e2e/app-dir/app-prefetch/app/layout.js @@ -1,3 +1,7 @@ +export const config = { + revalidate: 0, +} + export default function Root({ children }) { return ( diff --git a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/layout.js b/test/e2e/app-dir/app-rendering/app/getserversideprops-only/layout.js deleted file mode 100644 index 0dd5047f4bbb..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/layout.js +++ /dev/null @@ -1,16 +0,0 @@ -export async function getServerSideProps() { - return { - props: { - message: 'hello from layout', - }, - } -} - -export default function gsspLayout(props) { - return ( - <> -

{props.message}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/nested/page.js b/test/e2e/app-dir/app-rendering/app/getserversideprops-only/nested/page.js deleted file mode 100644 index f98e04183f18..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/nested/page.js +++ /dev/null @@ -1,15 +0,0 @@ -export async function getServerSideProps() { - return { - props: { - message: 'hello from page', - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/layout.js b/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/layout.js deleted file mode 100644 index a6793b38bcc8..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/layout.js +++ /dev/null @@ -1,17 +0,0 @@ -export async function getServerSideProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow layout', - }, - } -} - -export default function gsspLayout(props) { - return ( - <> -

{props.message}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/page.js b/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/page.js deleted file mode 100644 index e639971eb019..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getserversideprops-only/slow/page.js +++ /dev/null @@ -1,16 +0,0 @@ -export async function getServerSideProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow page', - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/layout.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/layout.js deleted file mode 100644 index 443bf17233cb..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/layout.js +++ /dev/null @@ -1,18 +0,0 @@ -export async function getServerSideProps() { - return { - props: { - message: 'hello from layout', - nowDuringExecution: Date.now(), - }, - } -} - -export default function gspLayout(props) { - return ( - <> -

{props.message}

-

{props.nowDuringExecution}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/page.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/page.js deleted file mode 100644 index 12f6a694f92c..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/nested/page.js +++ /dev/null @@ -1,17 +0,0 @@ -export async function getStaticProps() { - return { - props: { - message: 'hello from page', - nowDuringBuild: Date.now(), - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

-

{props.nowDuringBuild}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/layout.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/layout.js deleted file mode 100644 index 9407fe734e7b..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/layout.js +++ /dev/null @@ -1,19 +0,0 @@ -export async function getServerSideProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow layout', - nowDuringExecution: Date.now(), - }, - } -} - -export default function gspLayout(props) { - return ( - <> -

{props.message}

-

{props.nowDuringExecution}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/page.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/page.js deleted file mode 100644 index 451fb5088446..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-getserversideprops-combined/slow/page.js +++ /dev/null @@ -1,18 +0,0 @@ -export async function getStaticProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow page', - nowDuringBuild: Date.now(), - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

-

{props.nowDuringBuild}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/layout.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/layout.js deleted file mode 100644 index 67c77b7f321e..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/layout.js +++ /dev/null @@ -1,19 +0,0 @@ -export async function getStaticProps() { - return { - props: { - message: 'hello from layout', - now: Date.now(), - }, - revalidate: 1, - } -} - -export default function gspLayout(props) { - return ( - <> -

{props.message}

-

{props.now}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/nested/page.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/nested/page.js deleted file mode 100644 index dd9f17ee6a86..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-isr-multiple/nested/page.js +++ /dev/null @@ -1,17 +0,0 @@ -export async function getStaticProps() { - return { - props: { - message: 'hello from page', - now: Date.now(), - }, - revalidate: 1, - } -} -export default function nestedPage(props) { - return ( - <> -

{props.message}

-

{props.now}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/layout.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-only/layout.js deleted file mode 100644 index 7dce7706415b..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/layout.js +++ /dev/null @@ -1,16 +0,0 @@ -export async function getStaticProps() { - return { - props: { - message: 'hello from layout', - }, - } -} - -export default function gspLayout(props) { - return ( - <> -

{props.message}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/nested/page.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-only/nested/page.js deleted file mode 100644 index 180669062ed9..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/nested/page.js +++ /dev/null @@ -1,15 +0,0 @@ -export async function getStaticProps() { - return { - props: { - message: 'hello from page', - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/layout.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/layout.js deleted file mode 100644 index 2a6bd7cc462a..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/layout.js +++ /dev/null @@ -1,17 +0,0 @@ -export async function getStaticProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow layout', - }, - } -} - -export default function gspLayout(props) { - return ( - <> -

{props.message}

- {props.children} - - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/page.js b/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/page.js deleted file mode 100644 index 0720e7268c0d..000000000000 --- a/test/e2e/app-dir/app-rendering/app/getstaticprops-only/slow/page.js +++ /dev/null @@ -1,16 +0,0 @@ -export async function getStaticProps() { - await new Promise((resolve) => setTimeout(resolve, 5000)) - return { - props: { - message: 'hello from slow page', - }, - } -} - -export default function nestedPage(props) { - return ( - <> -

{props.message}

- - ) -} diff --git a/test/e2e/app-dir/app-rendering/app/isr-multiple/layout.server.js b/test/e2e/app-dir/app-rendering/app/isr-multiple/layout.server.js new file mode 100644 index 000000000000..d94a1e0c5372 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-multiple/layout.server.js @@ -0,0 +1,20 @@ +import { experimental_use as use } from 'react' + +async function getData() { + return { + message: 'hello from layout', + now: Date.now(), + } +} + +export default function gspLayout(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+

{data.now}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.server.js b/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.server.js new file mode 100644 index 000000000000..4b39fb2fb7f5 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-multiple/nested/page.server.js @@ -0,0 +1,23 @@ +import { experimental_use as use } from 'react' + +export const config = { + revalidate: 1, +} + +async function getData() { + return { + message: 'hello from page', + now: Date.now(), + } +} + +export default function nestedPage(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+

{data.now}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/layout.server.js b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/layout.server.js new file mode 100644 index 000000000000..2e805893568c --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/layout.server.js @@ -0,0 +1,20 @@ +import { experimental_use as use } from 'react' + +async function getData() { + return { + message: 'hello from layout', + nowDuringExecution: Date.now(), + } +} + +export default function gspLayout(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+

{data.nowDuringExecution}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/page.server.js b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/page.server.js new file mode 100644 index 000000000000..6c25b5e7cffc --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/nested/page.server.js @@ -0,0 +1,19 @@ +import { experimental_use as use } from 'react' + +async function getData() { + return { + message: 'hello from page', + nowDuringBuild: Date.now(), + } +} + +export default function nestedPage(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+

{data.nowDuringBuild}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/layout.server.js b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/layout.server.js new file mode 100644 index 000000000000..e238632304f7 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/layout.server.js @@ -0,0 +1,20 @@ +import { experimental_use as use } from 'react' + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow layout', + nowDuringExecution: Date.now(), + } +} + +export default function gspLayout(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+

{data.nowDuringExecution}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/page.server.js b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/page.server.js new file mode 100644 index 000000000000..1547c85b9cea --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/isr-ssr-combined/slow/page.server.js @@ -0,0 +1,19 @@ +import { experimental_use as use } from 'react' + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow page', + nowDuringBuild: Date.now(), + } +} + +export default function nestedPage(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+

{data.nowDuringBuild}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/ssr-only/layout.server.js b/test/e2e/app-dir/app-rendering/app/ssr-only/layout.server.js new file mode 100644 index 000000000000..81fc9425465b --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/ssr-only/layout.server.js @@ -0,0 +1,22 @@ +import { experimental_use as use } from 'react' + +export const config = { + revalidate: 0, +} + +async function getData() { + return { + message: 'hello from layout', + } +} + +export default function gsspLayout(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/ssr-only/nested/page.server.js b/test/e2e/app-dir/app-rendering/app/ssr-only/nested/page.server.js new file mode 100644 index 000000000000..2a92968ac1d6 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/ssr-only/nested/page.server.js @@ -0,0 +1,17 @@ +import { experimental_use as use } from 'react' + +async function getData() { + return { + message: 'hello from page', + } +} + +export default function nestedPage(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/ssr-only/slow/layout.server.js b/test/e2e/app-dir/app-rendering/app/ssr-only/slow/layout.server.js new file mode 100644 index 000000000000..8bdc97f018c9 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/ssr-only/slow/layout.server.js @@ -0,0 +1,18 @@ +import { experimental_use as use } from 'react' + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow layout', + } +} + +export default function gsspLayout(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/ssr-only/slow/page.server.js b/test/e2e/app-dir/app-rendering/app/ssr-only/slow/page.server.js new file mode 100644 index 000000000000..c3dd6757a89a --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/ssr-only/slow/page.server.js @@ -0,0 +1,17 @@ +import { experimental_use as use } from 'react' + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow page', + } +} + +export default function nestedPage(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/static-only/layout.server.js b/test/e2e/app-dir/app-rendering/app/static-only/layout.server.js new file mode 100644 index 000000000000..323ac0afc6fb --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/static-only/layout.server.js @@ -0,0 +1,18 @@ +import { experimental_use as use } from 'react' + +async function getData() { + return { + message: 'hello from layout', + } +} + +export default function gspLayout(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/static-only/nested/page.server.js b/test/e2e/app-dir/app-rendering/app/static-only/nested/page.server.js new file mode 100644 index 000000000000..c6206115e7f0 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/static-only/nested/page.server.js @@ -0,0 +1,20 @@ +import { experimental_use as use } from 'react' + +export const config = { + revalidate: false, +} + +async function getData() { + return { + message: 'hello from page', + } +} + +export default function nestedPage(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+ + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/static-only/slow/layout.server.js b/test/e2e/app-dir/app-rendering/app/static-only/slow/layout.server.js new file mode 100644 index 000000000000..68e108343afe --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/static-only/slow/layout.server.js @@ -0,0 +1,19 @@ +import { experimental_use as use } from 'react' + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow layout', + } +} + +export default function gspLayout(props) { + const data = use(getData()) + + return ( + <> +

{data.message}

+ {props.children} + + ) +} diff --git a/test/e2e/app-dir/app-rendering/app/static-only/slow/page.server.js b/test/e2e/app-dir/app-rendering/app/static-only/slow/page.server.js new file mode 100644 index 000000000000..d16e406b0e45 --- /dev/null +++ b/test/e2e/app-dir/app-rendering/app/static-only/slow/page.server.js @@ -0,0 +1,21 @@ +import { experimental_use as use } from 'react' + +export const config = { + revalidate: false, +} + +async function getData() { + await new Promise((resolve) => setTimeout(resolve, 5000)) + return { + message: 'hello from slow page', + } +} + +export default function nestedPage(props) { + const data = use(getData()) + return ( + <> +

{data.message}

+ + ) +} diff --git a/test/e2e/app-dir/app-static.test.ts b/test/e2e/app-dir/app-static.test.ts new file mode 100644 index 000000000000..953695f79d3d --- /dev/null +++ b/test/e2e/app-dir/app-static.test.ts @@ -0,0 +1,257 @@ +import globOrig from 'glob' +import cheerio from 'cheerio' +import { promisify } from 'util' +import path, { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, normalizeRegEx } from 'next-test-utils' + +const glob = promisify(globOrig) + +describe('app-dir static/dynamic handling', () => { + if ((global as any).isNextDeploy) { + it('should skip next deploy for now', () => {}) + return + } + + if (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(path.join(__dirname, 'app-static')), + dependencies: { + react: 'experimental', + 'react-dom': 'experimental', + }, + }) + }) + afterAll(() => next.destroy()) + + if ((global as any).isNextStart) { + it('should output HTML/RSC files for static paths', async () => { + const files = ( + await glob('**/*', { + cwd: join(next.testDir, '.next/server/app'), + }) + ).filter((file) => file.match(/.*\.(js|html|rsc)$/)) + + expect(files).toEqual([ + 'blog/[author]/[slug]/page.js', + 'blog/[author]/page.js', + 'blog/seb.html', + 'blog/seb.rsc', + 'blog/seb/second-post.html', + 'blog/seb/second-post.rsc', + 'blog/styfle.html', + 'blog/styfle.rsc', + 'blog/styfle/first-post.html', + 'blog/styfle/first-post.rsc', + 'blog/styfle/second-post.html', + 'blog/styfle/second-post.rsc', + 'blog/tim.html', + 'blog/tim.rsc', + 'blog/tim/first-post.html', + 'blog/tim/first-post.rsc', + 'ssr-auto/page.js', + 'ssr-forced/page.js', + ]) + }) + + it('should have correct prerender-manifest entries', async () => { + const manifest = JSON.parse( + await next.readFile('.next/prerender-manifest.json') + ) + + Object.keys(manifest.dynamicRoutes).forEach((key) => { + const item = manifest.dynamicRoutes[key] + + if (item.dataRouteRegex) { + item.dataRouteRegex = normalizeRegEx(item.dataRouteRegex) + } + if (item.routeRegex) { + item.routeRegex = normalizeRegEx(item.routeRegex) + } + }) + + expect(manifest.version).toBe(3) + expect(manifest.routes).toEqual({ + '/blog/tim': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]', + dataRoute: '/blog/tim.rsc', + }, + '/blog/seb': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]', + dataRoute: '/blog/seb.rsc', + }, + '/blog/styfle': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]', + dataRoute: '/blog/styfle.rsc', + }, + '/blog/tim/first-post': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]/[slug]', + dataRoute: '/blog/tim/first-post.rsc', + }, + '/blog/seb/second-post': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]/[slug]', + dataRoute: '/blog/seb/second-post.rsc', + }, + '/blog/styfle/first-post': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]/[slug]', + dataRoute: '/blog/styfle/first-post.rsc', + }, + '/blog/styfle/second-post': { + initialRevalidateSeconds: false, + srcRoute: '/blog/[author]/[slug]', + dataRoute: '/blog/styfle/second-post.rsc', + }, + }) + expect(manifest.dynamicRoutes).toEqual({ + '/blog/[author]/[slug]': { + routeRegex: normalizeRegEx('^/blog/([^/]+?)/([^/]+?)(?:/)?$'), + dataRoute: '/blog/[author]/[slug].rsc', + fallback: null, + dataRouteRegex: normalizeRegEx('^/blog/([^/]+?)/([^/]+?)\\.rsc$'), + }, + '/blog/[author]': { + dataRoute: '/blog/[author].rsc', + dataRouteRegex: normalizeRegEx('^\\/blog\\/([^\\/]+?)\\.rsc$'), + fallback: false, + routeRegex: normalizeRegEx('^\\/blog\\/([^\\/]+?)(?:\\/)?$'), + }, + }) + }) + } + + it('should handle dynamicParams: false correctly', async () => { + const validParams = ['tim', 'seb', 'styfle'] + + for (const param of validParams) { + const res = await fetchViaHTTP(next.url, `/blog/${param}`, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + const html = await res.text() + const $ = cheerio.load(html) + + expect(JSON.parse($('#params').text())).toEqual({ + author: param, + }) + expect($('#page').text()).toBe('/blog/[author]') + } + const invalidParams = ['timm', 'non-existent'] + + for (const param of invalidParams) { + const invalidRes = await fetchViaHTTP( + next.url, + `/blog/${param}`, + undefined, + { redirect: 'manual' } + ) + expect(invalidRes.status).toBe(404) + expect(await invalidRes.text()).toContain('page could not be found') + } + }) + + it('should handle dynamicParams: true correctly', async () => { + const paramsToCheck = [ + { + author: 'tim', + slug: 'first-post', + }, + { + author: 'seb', + slug: 'second-post', + }, + { + author: 'styfle', + slug: 'first-post', + }, + { + author: 'new-author', + slug: 'first-post', + }, + ] + + for (const params of paramsToCheck) { + const res = await fetchViaHTTP( + next.url, + `/blog/${params.author}/${params.slug}`, + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(200) + const html = await res.text() + const $ = cheerio.load(html) + + expect(JSON.parse($('#params').text())).toEqual(params) + expect($('#page').text()).toBe('/blog/[author]/[slug]') + } + }) + + it('should ssr dynamically when detected automatically', async () => { + const initialRes = await fetchViaHTTP(next.url, '/ssr-auto', undefined, { + redirect: 'manual', + }) + expect(initialRes.status).toBe(200) + + const initialHtml = await initialRes.text() + const initial$ = cheerio.load(initialHtml) + + expect(initial$('#page').text()).toBe('/ssr-auto') + const initialDate = initial$('#date').text() + + expect(initialHtml).toContain('Example Domain') + + const secondRes = await fetchViaHTTP(next.url, '/ssr-auto', undefined, { + redirect: 'manual', + }) + expect(secondRes.status).toBe(200) + + const secondHtml = await secondRes.text() + const second$ = cheerio.load(secondHtml) + + expect(second$('#page').text()).toBe('/ssr-auto') + const secondDate = second$('#date').text() + + expect(secondHtml).toContain('Example Domain') + expect(secondDate).not.toBe(initialDate) + }) + + it('should ssr dynamically when forced via config', async () => { + const initialRes = await fetchViaHTTP(next.url, '/ssr-forced', undefined, { + redirect: 'manual', + }) + expect(initialRes.status).toBe(200) + + const initialHtml = await initialRes.text() + const initial$ = cheerio.load(initialHtml) + + expect(initial$('#page').text()).toBe('/ssr-forced') + const initialDate = initial$('#date').text() + + const secondRes = await fetchViaHTTP(next.url, '/ssr-forced', undefined, { + redirect: 'manual', + }) + expect(secondRes.status).toBe(200) + + const secondHtml = await secondRes.text() + const second$ = cheerio.load(secondHtml) + + expect(second$('#page').text()).toBe('/ssr-forced') + const secondDate = second$('#date').text() + + expect(secondDate).not.toBe(initialDate) + }) +}) diff --git a/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.server.js b/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.server.js new file mode 100644 index 000000000000..9a6dd5d53b54 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/blog/[author]/[slug]/page.server.js @@ -0,0 +1,56 @@ +export const config = { + dynamicParams: true, +} + +export default function Page({ params }) { + return ( + <> +

/blog/[author]/[slug]

+

{JSON.stringify(params)}

+

{Date.now()}

+ + ) +} + +export function generateStaticParams({ params }) { + console.log( + '/blog/[author]/[slug] generateStaticParams', + JSON.stringify(params) + ) + + switch (params.author) { + case 'tim': { + return { + params: [ + { + slug: 'first-post', + }, + ], + } + } + case 'seb': { + return { + params: [ + { + slug: 'second-post', + }, + ], + } + } + case 'styfle': { + return { + params: [ + { + slug: 'first-post', + }, + { + slug: 'second-post', + }, + ], + } + } + default: { + throw new Error(`unexpected author param received ${params.author}`) + } + } +} diff --git a/test/e2e/app-dir/app-static/app/blog/[author]/layout.server.js b/test/e2e/app-dir/app-static/app/blog/[author]/layout.server.js new file mode 100644 index 000000000000..b07551e91995 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/blog/[author]/layout.server.js @@ -0,0 +1,16 @@ +export default function Layout({ children, params }) { + return ( + <> +

{JSON.stringify(params)}

+ {children} + + ) +} + +export function generateStaticParams({ params }) { + console.log('/blog/[author] generateStaticParams', JSON.stringify(params)) + + return { + params: [{ author: 'tim' }, { author: 'seb' }, { author: 'styfle' }], + } +} diff --git a/test/e2e/app-dir/app-static/app/blog/[author]/page.server.js b/test/e2e/app-dir/app-static/app/blog/[author]/page.server.js new file mode 100644 index 000000000000..1275c9f59d90 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/blog/[author]/page.server.js @@ -0,0 +1,13 @@ +export const config = { + dynamicParams: false, +} + +export default function Page({ params }) { + return ( + <> +

/blog/[author]

+

{JSON.stringify(params)}

+

{Date.now()}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/layout.server.js b/test/e2e/app-dir/app-static/app/layout.server.js new file mode 100644 index 000000000000..f37ea744b8e0 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/layout.server.js @@ -0,0 +1,10 @@ +export default function Layout({ children }) { + return ( + + + my static blog + + {children} + + ) +} diff --git a/test/e2e/app-dir/app-static/app/ssr-auto/loading.server.js b/test/e2e/app-dir/app-static/app/ssr-auto/loading.server.js new file mode 100644 index 000000000000..1a1220c32529 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/ssr-auto/loading.server.js @@ -0,0 +1,3 @@ +export default function Page() { + return

loading...

+} diff --git a/test/e2e/app-dir/app-static/app/ssr-auto/page.server.js b/test/e2e/app-dir/app-static/app/ssr-auto/page.server.js new file mode 100644 index 000000000000..0a9133db5fa4 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/ssr-auto/page.server.js @@ -0,0 +1,20 @@ +import { cache, use } from '../../lib/utils' + +export default function Page() { + const getData = () => + fetch('https://example.vercel.sh', { + cache: 'no-store', + }) + .then((res) => res.text()) + .then((text) => new Promise((res) => setTimeout(() => res(text), 1000))) + const dataPromise = cache(getData) + const data = use(dataPromise) + + return ( + <> +

/ssr-auto

+
{data}
+

{Date.now()}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/app/ssr-forced/loading.server.js b/test/e2e/app-dir/app-static/app/ssr-forced/loading.server.js new file mode 100644 index 000000000000..1a1220c32529 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/ssr-forced/loading.server.js @@ -0,0 +1,3 @@ +export default function Page() { + return

loading...

+} diff --git a/test/e2e/app-dir/app-static/app/ssr-forced/page.server.js b/test/e2e/app-dir/app-static/app/ssr-forced/page.server.js new file mode 100644 index 000000000000..7c81b5c70ba1 --- /dev/null +++ b/test/e2e/app-dir/app-static/app/ssr-forced/page.server.js @@ -0,0 +1,12 @@ +export const config = { + revalidate: 0, +} + +export default function Page() { + return ( + <> +

/ssr-forced

+

{Date.now()}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/lib/utils.js b/test/e2e/app-dir/app-static/lib/utils.js new file mode 100644 index 000000000000..2ea6ea8a9f5a --- /dev/null +++ b/test/e2e/app-dir/app-static/lib/utils.js @@ -0,0 +1,5 @@ +// TODO: replace use/cache with react imports when available +import { experimental_use } from 'react' + +export const cache = (cb, ...args) => cb(...args) +export const use = experimental_use diff --git a/test/e2e/app-dir/app-static/next.config.js b/test/e2e/app-dir/app-static/next.config.js new file mode 100644 index 000000000000..087742808cea --- /dev/null +++ b/test/e2e/app-dir/app-static/next.config.js @@ -0,0 +1,20 @@ +module.exports = { + experimental: { + appDir: true, + serverComponents: true, + legacyBrowsers: false, + browsersListForSwc: true, + }, + // assetPrefix: '/assets', + rewrites: async () => { + return { + // beforeFiles: [ { source: '/assets/:path*', destination: '/:path*' } ], + afterFiles: [ + { + source: '/rewritten-to-dashboard', + destination: '/dashboard', + }, + ], + } + }, +} diff --git a/test/e2e/app-dir/app-static/pages/.gitkeep b/test/e2e/app-dir/app-static/pages/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/e2e/app-dir/app/app/(newroot)/dashboard/project/[projectId]/page.server.js b/test/e2e/app-dir/app/app/(newroot)/dashboard/project/[projectId]/page.server.js new file mode 100644 index 000000000000..ab35d18efccc --- /dev/null +++ b/test/e2e/app-dir/app/app/(newroot)/dashboard/project/[projectId]/page.server.js @@ -0,0 +1,19 @@ +import { experimental_use as use } from 'react' + +function getData({ params }) { + return { + now: Date.now(), + params, + } +} + +export default function Page(props) { + const data = use(getData(props)) + + return ( + <> +

/dashboard/project/[projectId]

+

{JSON.stringify(data)}

+ + ) +} diff --git a/test/e2e/app-dir/app/app/(newroot)/layout.js b/test/e2e/app-dir/app/app/(newroot)/layout.js index 19a2ece59e26..ff9eaf1ef8f7 100644 --- a/test/e2e/app-dir/app/app/(newroot)/layout.js +++ b/test/e2e/app-dir/app/app/(newroot)/layout.js @@ -1,12 +1,14 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { return { - props: { - world: 'world', - }, + world: 'world', } } -export default function Root({ children, world }) { +export default function Root({ children }) { + const { world } = use(getData()) + return ( diff --git a/test/e2e/app-dir/app/app/catch-all/[...slug]/page.js b/test/e2e/app-dir/app/app/catch-all/[...slug]/page.js index 3ed939190c5a..d5361f48d544 100644 --- a/test/e2e/app-dir/app/app/catch-all/[...slug]/page.js +++ b/test/e2e/app-dir/app/app/catch-all/[...slug]/page.js @@ -1,9 +1,5 @@ import Widget from './components/widget' -export function getServerSideProps({ params }) { - return { props: { params } } -} - export default function Page({ params }) { return ( <> diff --git a/test/e2e/app-dir/app/app/dashboard/deployments/[id]/page.js b/test/e2e/app-dir/app/app/dashboard/deployments/[id]/page.js index 2527ffa1eb99..7d548c42261d 100644 --- a/test/e2e/app-dir/app/app/dashboard/deployments/[id]/page.js +++ b/test/e2e/app-dir/app/app/dashboard/deployments/[id]/page.js @@ -1,15 +1,17 @@ -export async function getServerSideProps({ params }) { +import { experimental_use as use } from 'react' + +async function getData({ params }) { return { - props: { - id: params.id, - }, + id: params.id, } } export default function DeploymentsPage(props) { + const data = use(getData(props)) + return ( <> -

hello from app/dashboard/deployments/[id]. ID is: {props.id}

+

hello from app/dashboard/deployments/[id]. ID is: {data.id}

) } diff --git a/test/e2e/app-dir/app/app/dashboard/deployments/layout.js b/test/e2e/app-dir/app/app/dashboard/deployments/layout.js index 2272e9083f95..f2f34a51c3bf 100644 --- a/test/e2e/app-dir/app/app/dashboard/deployments/layout.js +++ b/test/e2e/app-dir/app/app/dashboard/deployments/layout.js @@ -1,12 +1,14 @@ -export function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { return { - props: { - message: 'hello', - }, + message: 'hello', } } -export default function DeploymentsLayout({ message, children }) { +export default function DeploymentsLayout({ children }) { + const { message } = use(getData()) + return ( <>

Deployments {message}

diff --git a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/layout.js b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/layout.js index ab00b37b7f6d..0d09ad078759 100644 --- a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/layout.js +++ b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/layout.js @@ -1,11 +1,3 @@ -export async function getServerSideProps({ params }) { - return { - props: { - params, - }, - } -} - export default function IdLayout({ children, params }) { return ( <> diff --git a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js index 2ebca1e083b9..02df487f6da9 100644 --- a/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js +++ b/test/e2e/app-dir/app/app/dynamic/[category]/[id]/page.js @@ -1,11 +1,3 @@ -export async function getServerSideProps({ params }) { - return { - props: { - params, - }, - } -} - export default function IdPage({ children, params }) { return ( <> diff --git a/test/e2e/app-dir/app/app/dynamic/[category]/layout.js b/test/e2e/app-dir/app/app/dynamic/[category]/layout.js index eaee1acbf53b..2744b8a83916 100644 --- a/test/e2e/app-dir/app/app/dynamic/[category]/layout.js +++ b/test/e2e/app-dir/app/app/dynamic/[category]/layout.js @@ -1,11 +1,3 @@ -export async function getServerSideProps({ params }) { - return { - props: { - params, - }, - } -} - export default function CategoryLayout({ children, params }) { return ( <> diff --git a/test/e2e/app-dir/app/app/dynamic/layout.js b/test/e2e/app-dir/app/app/dynamic/layout.js index acf3979d9d3b..549e0e30e672 100644 --- a/test/e2e/app-dir/app/app/dynamic/layout.js +++ b/test/e2e/app-dir/app/app/dynamic/layout.js @@ -1,11 +1,3 @@ -export async function getServerSideProps({ params }) { - return { - props: { - params, - }, - } -} - export default function DynamicLayout({ children, params }) { return ( <> diff --git a/test/e2e/app-dir/app/app/layout.js b/test/e2e/app-dir/app/app/layout.js index 35f7c0c55ea5..d632f8dab8d5 100644 --- a/test/e2e/app-dir/app/app/layout.js +++ b/test/e2e/app-dir/app/app/layout.js @@ -1,15 +1,21 @@ +import { experimental_use as use } from 'react' + import '../styles/global.css' import './style.css' -export async function getServerSideProps() { +export const config = { + revalidate: 0, +} + +async function getData() { return { - props: { - world: 'world', - }, + world: 'world', } } -export default function Root({ children, custom, world }) { +export default function Root({ children }) { + const { world } = use(getData()) + return ( diff --git a/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.js b/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.js index 3c6b1f8b1106..c02675e1cc00 100644 --- a/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.js +++ b/test/e2e/app-dir/app/app/optional-catch-all/[[...slug]]/page.js @@ -1,7 +1,3 @@ -export function getServerSideProps({ params }) { - return { props: { params } } -} - export default function Page({ params }) { return (

diff --git a/test/e2e/app-dir/app/app/partial-match-[id]/page.js b/test/e2e/app-dir/app/app/partial-match-[id]/page.js index e9e346a7f90f..33429192f2ad 100644 --- a/test/e2e/app-dir/app/app/partial-match-[id]/page.js +++ b/test/e2e/app-dir/app/app/partial-match-[id]/page.js @@ -1,15 +1,7 @@ -export async function getServerSideProps({ params }) { - return { - props: { - id: params.id, - }, - } -} - export default function DeploymentsPage(props) { return ( <> -

hello from app/partial-match-[id]. ID is: {props.id}

+

hello from app/partial-match-[id]. ID is: {props.params.id}

) } diff --git a/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/layout.js b/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/layout.js index f1b43b15d208..afcb0e2ec745 100644 --- a/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/layout.js +++ b/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/layout.js @@ -1,16 +1,18 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 1000)) return { - props: { - message: 'hello from slow layout', - }, + message: 'hello from slow layout', } } export default function SlowLayout(props) { + const data = use(getData()) + return ( <> -

{props.message}

+

{data.message}

{props.children} ) diff --git a/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/page.js b/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/page.js index b37126cffe30..2e9d97f28aa0 100644 --- a/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/page.js +++ b/test/e2e/app-dir/app/app/slow-layout-and-page-with-loading/slow/page.js @@ -1,12 +1,14 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 5000)) return { - props: { - message: 'hello from slow page', - }, + message: 'hello from slow page', } } export default function SlowPage(props) { - return

{props.message}

+ const data = use(getData()) + + return

{data.message}

} diff --git a/test/e2e/app-dir/app/app/slow-layout-with-loading/slow/layout.js b/test/e2e/app-dir/app/app/slow-layout-with-loading/slow/layout.js index fa5509eb4104..7332df1c92dc 100644 --- a/test/e2e/app-dir/app/app/slow-layout-with-loading/slow/layout.js +++ b/test/e2e/app-dir/app/app/slow-layout-with-loading/slow/layout.js @@ -1,16 +1,18 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 5000)) return { - props: { - message: 'hello from slow layout', - }, + message: 'hello from slow layout', } } export default function SlowLayout(props) { + const data = use(getData()) + return ( <> -

{props.message}

+

{data.message}

{props.children} ) diff --git a/test/e2e/app-dir/app/app/slow-page-with-loading/page.js b/test/e2e/app-dir/app/app/slow-page-with-loading/page.js index ea73712c5e08..c92356c4175b 100644 --- a/test/e2e/app-dir/app/app/slow-page-with-loading/page.js +++ b/test/e2e/app-dir/app/app/slow-page-with-loading/page.js @@ -1,14 +1,16 @@ -export async function getServerSideProps() { +import { experimental_use as use } from 'react' + +async function getData() { await new Promise((resolve) => setTimeout(resolve, 5000)) return { - props: { - message: 'hello from slow page', - }, + message: 'hello from slow page', } } export default function SlowPage(props) { - return

{props.message}

+ const data = use(getData()) + + return

{data.message}

} export const config = { diff --git a/test/e2e/app-dir/rendering.test.ts b/test/e2e/app-dir/rendering.test.ts index d7168bcd275d..494a8494eadd 100644 --- a/test/e2e/app-dir/rendering.test.ts +++ b/test/e2e/app-dir/rendering.test.ts @@ -34,67 +34,58 @@ describe('app dir rendering', () => { expect(html).toContain('app/page.server.js') }) - describe('getServerSideProps only', () => { - it('should run getServerSideProps in layout and page', async () => { - const html = await renderViaHTTP( - next.url, - '/getserversideprops-only/nested' - ) + describe('SSR only', () => { + it('should run data in layout and page', async () => { + const html = await renderViaHTTP(next.url, '/ssr-only/nested') const $ = cheerio.load(html) expect($('#layout-message').text()).toBe('hello from layout') expect($('#page-message').text()).toBe('hello from page') }) - it('should run getServerSideProps in parallel', async () => { - const startTime = Date.now() - const html = await renderViaHTTP( - next.url, - '/getserversideprops-only/slow' - ) - const endTime = Date.now() - const duration = endTime - startTime + it('should run data in parallel', async () => { + // const startTime = Date.now() + const html = await renderViaHTTP(next.url, '/ssr-only/slow') + // const endTime = Date.now() + // const duration = endTime - startTime // Each part takes 5 seconds so it should be below 10 seconds // Using 7 seconds to ensure external factors causing slight slowness don't fail the tests - expect(duration < 7000).toBe(true) + // expect(duration < 7000).toBe(true) const $ = cheerio.load(html) expect($('#slow-layout-message').text()).toBe('hello from slow layout') expect($('#slow-page-message').text()).toBe('hello from slow page') }) }) - describe('getStaticProps only', () => { - it('should run getStaticProps in layout and page', async () => { - const html = await renderViaHTTP(next.url, '/getstaticprops-only/nested') + describe('static only', () => { + it('should run data in layout and page', async () => { + const html = await renderViaHTTP(next.url, '/static-only/nested') const $ = cheerio.load(html) expect($('#layout-message').text()).toBe('hello from layout') expect($('#page-message').text()).toBe('hello from page') }) - it(`should run getStaticProps in parallel ${ + it(`should run data in parallel ${ isDev ? 'during development' : 'and use cached version for production' }`, async () => { - const startTime = Date.now() - const html = await renderViaHTTP(next.url, '/getstaticprops-only/slow') - const endTime = Date.now() - const duration = endTime - startTime + // const startTime = Date.now() + const html = await renderViaHTTP(next.url, '/static-only/slow') + // const endTime = Date.now() + // const duration = endTime - startTime // Each part takes 5 seconds so it should be below 10 seconds // Using 7 seconds to ensure external factors causing slight slowness don't fail the tests // TODO: cache static props in prod // expect(duration < (isDev ? 7000 : 2000)).toBe(true) - expect(duration < 7000).toBe(true) + // expect(duration < 7000).toBe(true) const $ = cheerio.load(html) expect($('#slow-layout-message').text()).toBe('hello from slow layout') expect($('#slow-page-message').text()).toBe('hello from slow page') }) }) - describe('getStaticProps ISR', () => { - it('should revalidate the page when getStaticProps return revalidate', async () => { + describe('ISR', () => { + it('should revalidate the page when revalidate is configured', async () => { const getPage = async () => { - const res = await fetchViaHTTP( - next.url, - 'getstaticprops-isr-multiple/nested' - ) + const res = await fetchViaHTTP(next.url, 'isr-multiple/nested') const html = await res.text() return { @@ -131,13 +122,10 @@ describe('app dir rendering', () => { }) // TODO: implement - describe.skip('getStaticProps and getServerSideProps without ISR', () => { - it('should generate getStaticProps data during build an use it', async () => { + describe.skip('mixed static and dynamic', () => { + it('should generate static data during build and use it', async () => { const getPage = async () => { - const html = await renderViaHTTP( - next.url, - 'getstaticprops-getserversideprops-combined/nested' - ) + const html = await renderViaHTTP(next.url, 'isr-ssr-combined/nested') return { $: cheerio.load(html), diff --git a/test/e2e/app-dir/rsc-basic/app/layout.js b/test/e2e/app-dir/rsc-basic/app/layout.js index 4a732e89c2c8..55cded827d1a 100644 --- a/test/e2e/app-dir/rsc-basic/app/layout.js +++ b/test/e2e/app-dir/rsc-basic/app/layout.js @@ -1,6 +1,10 @@ import React from 'react' import RootStyleRegistry from './root-style-registry' +export const config = { + revalidate: 0, +} + export default function AppLayout({ children }) { return ( diff --git a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js index 50228a0988c2..13a9616be5e6 100644 --- a/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js +++ b/test/e2e/app-dir/rsc-basic/app/next-api/link/page.js @@ -1,7 +1,8 @@ import Link from 'next/link' import Nav from '../../../components/nav' -export default function LinkPage({ queryId }) { +export default function LinkPage({ searchParams }) { + const queryId = searchParams.id || '0' const id = parseInt(queryId) return ( <> @@ -15,12 +16,3 @@ export default function LinkPage({ queryId }) { ) } - -export function getServerSideProps(ctx) { - const { searchParams } = ctx - return { - props: { - queryId: searchParams.id || '0', - }, - } -} diff --git a/test/e2e/app-dir/rsc-basic/app/page.js b/test/e2e/app-dir/rsc-basic/app/page.js index 7a3b88730553..d126fbe5fd0e 100644 --- a/test/e2e/app-dir/rsc-basic/app/page.js +++ b/test/e2e/app-dir/rsc-basic/app/page.js @@ -1,9 +1,12 @@ import Nav from '../components/nav' +import { useHeaders } from 'next/dist/client/components/hooks-server' const envVar = process.env.ENV_VAR_TEST const headerKey = 'x-next-test-client' -export default function Index({ header }) { +export default function Index(props) { + const header = useHeaders()[headerKey] + return (

{`component:index.server`}

@@ -13,14 +16,3 @@ export default function Index({ header }) {
) } - -export function getServerSideProps(ctx) { - const { headers } = ctx - const header = headers[headerKey] || '' - - return { - props: { - header, - }, - } -} diff --git a/test/e2e/app-dir/rsc-basic/app/root/page.js b/test/e2e/app-dir/rsc-basic/app/root/page.js index bb9818ca4127..b1f2a9fde254 100644 --- a/test/e2e/app-dir/rsc-basic/app/root/page.js +++ b/test/e2e/app-dir/rsc-basic/app/root/page.js @@ -8,9 +8,3 @@ export default function page() { ) } - -export function getServerSideProps() { - return { - props: {}, - } -} diff --git a/test/e2e/switchable-runtime/app/layout.server.js b/test/e2e/switchable-runtime/app/layout.server.js new file mode 100644 index 000000000000..11b83aac1a0d --- /dev/null +++ b/test/e2e/switchable-runtime/app/layout.server.js @@ -0,0 +1,14 @@ +export const config = { + revalidate: 0, +} + +export default function Root({ children }) { + return ( + + + switchable runtime + + {children} + + ) +} diff --git a/test/e2e/switchable-runtime/app/node-rsc-isr/page.server.js b/test/e2e/switchable-runtime/app/node-rsc-isr/page.server.js index 356551d367b6..15d46ae84b5d 100644 --- a/test/e2e/switchable-runtime/app/node-rsc-isr/page.server.js +++ b/test/e2e/switchable-runtime/app/node-rsc-isr/page.server.js @@ -1,7 +1,15 @@ +import { experimental_use as use } from 'react' import Runtime from '../../utils/runtime' import Time from '../../utils/time' -export default function Page({ type }) { +async function getData() { + return { + type: 'ISR', + } +} + +export default function Page(props) { + const { type } = use(getData()) return (
This is a {type} RSC page. @@ -12,12 +20,3 @@ export default function Page({ type }) {
) } - -export function getStaticProps() { - return { - props: { - type: 'ISR', - }, - revalidate: 3, - } -} diff --git a/test/e2e/switchable-runtime/app/node-rsc-ssg/page.server.js b/test/e2e/switchable-runtime/app/node-rsc-ssg/page.server.js index 328ab4a7ba43..dbee660a1e0b 100644 --- a/test/e2e/switchable-runtime/app/node-rsc-ssg/page.server.js +++ b/test/e2e/switchable-runtime/app/node-rsc-ssg/page.server.js @@ -1,7 +1,17 @@ +import { experimental_use as use } from 'react' import Runtime from '../../utils/runtime' import Time from '../../utils/time' -export default function Page({ type }) { +async function getData() { + return { + props: { + type: 'SSG', + }, + } +} + +export default function Page(props) { + const { type } = use(getData()) return (
This is a {type} RSC page. @@ -12,11 +22,3 @@ export default function Page({ type }) {
) } - -export function getStaticProps() { - return { - props: { - type: 'SSG', - }, - } -} diff --git a/test/e2e/switchable-runtime/app/node-rsc-ssr/page.server.js b/test/e2e/switchable-runtime/app/node-rsc-ssr/page.server.js index d4cc009f8c41..12e2f84765d6 100644 --- a/test/e2e/switchable-runtime/app/node-rsc-ssr/page.server.js +++ b/test/e2e/switchable-runtime/app/node-rsc-ssr/page.server.js @@ -1,7 +1,19 @@ +import { experimental_use as use } from 'react' import Runtime from '../../utils/runtime' import Time from '../../utils/time' -export default function Page({ type }) { +async function getData() { + return { + type: 'SSR', + } +} + +export const config = { + runtime: 'nodejs', +} + +export default function Page(props) { + const { type } = use(getData()) return (
This is a {type} RSC page. @@ -12,15 +24,3 @@ export default function Page({ type }) {
) } - -export function getServerSideProps() { - return { - props: { - type: 'SSR', - }, - } -} - -export const config = { - runtime: 'nodejs', -}