diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index cfe6909597..d4c5072b4c 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -76,6 +76,7 @@ const RSC_SEGMENT_SUFFIX = '.segment.rsc' const buildAppCacheValue = async ( path: string, shouldUseAppPageKind: boolean, + rscIsRequired = true, ): Promise => { const meta = JSON.parse(await readFile(`${path}.meta`, 'utf-8')) as RouteMetadata const html = await readFile(`${path}.html`, 'utf-8') @@ -104,9 +105,16 @@ const buildAppCacheValue = async ( return { kind: 'APP_PAGE', html, - rscData: await readFile(`${path}.rsc`, 'base64').catch(() => - readFile(`${path}.prefetch.rsc`, 'base64'), - ), + rscData: await readFile(`${path}.rsc`, 'base64') + .catch(() => readFile(`${path}.prefetch.rsc`, 'base64')) + .catch((error) => { + if (rscIsRequired) { + throw error + } + // disabling unicorn/no-useless-undefined because we need to return undefined explicitly to satisfy types + // eslint-disable-next-line unicorn/no-useless-undefined + return undefined + }), segmentData, ...meta, } @@ -186,6 +194,8 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise }) : false + let appRouterNotFoundDefinedInPrerenderManifest = false + await Promise.all([ ...Object.entries(manifest.routes).map( ([route, meta]): Promise => @@ -215,7 +225,11 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise value = await buildAppCacheValue( join(ctx.publishDir, 'server/app', key), shouldUseAppPageKind, + meta.renderingMode !== 'PARTIALLY_STATIC', ) + if (route === '/_not-found') { + appRouterNotFoundDefinedInPrerenderManifest = true + } break case meta.dataRoute === null: value = await buildRouteCacheValue( @@ -262,9 +276,12 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise ), ]) - // app router 404 pages are not in the prerender manifest - // so we need to check for them manually - if (existsSync(join(ctx.publishDir, `server/app/_not-found.html`))) { + // app router 404 pages are not in the prerender manifest for some next.js versions + // so we need to check for them manually if prerender manifest does not include it + if ( + !appRouterNotFoundDefinedInPrerenderManifest && + existsSync(join(ctx.publishDir, `server/app/_not-found.html`)) + ) { const lastModified = Date.now() const key = '/404' const value = await buildAppCacheValue( diff --git a/tests/fixtures/ppr/package.json b/tests/fixtures/ppr/package.json index 276dd9325b..12f0c58069 100644 --- a/tests/fixtures/ppr/package.json +++ b/tests/fixtures/ppr/package.json @@ -15,7 +15,7 @@ }, "test": { "dependencies": { - "next": "canary" + "next": "canary || >=16.0.0" } } } diff --git a/tests/integration/cache-handler.test.ts b/tests/integration/cache-handler.test.ts index 23e27101ca..77596c203b 100644 --- a/tests/integration/cache-handler.test.ts +++ b/tests/integration/cache-handler.test.ts @@ -218,7 +218,7 @@ describe('app router', () => { const blobEntries = await getBlobEntries(ctx) expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ - '/404', + shouldHaveAppRouterNotFoundInPrerenderManifest() ? undefined : '/404', shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/index', @@ -372,7 +372,7 @@ describe('plugin', () => { const blobEntries = await getBlobEntries(ctx) expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ - '/404', + shouldHaveAppRouterNotFoundInPrerenderManifest() ? undefined : '/404', shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/api/revalidate-handler', diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 3339dbce34..8e77b3d2a8 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -104,7 +104,7 @@ test('Test that the simple next app is working', async (ctx) const blobEntries = await getBlobEntries(ctx) expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ - '/404', + shouldHaveAppRouterNotFoundInPrerenderManifest() ? undefined : '/404', shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/api/cached-permanent', '/api/cached-revalidate', @@ -392,41 +392,40 @@ test('rewrites to external addresses dont use compression', expect(gunzipSync(page.bodyBuffer).toString('utf-8')).toContain('Example Domain') }) -test.skipIf(process.env.NEXT_VERSION !== 'canary')( - 'Test that a simple next app with PPR is working', - async (ctx) => { - await createFixture('ppr', ctx) - await runPlugin(ctx) - // check if the blob entries where successful set on the build plugin - const blobEntries = await getBlobEntries(ctx) - expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( - [ - '/1', - '/2', - '/404', - isExperimentalPPRHardDeprecated() ? undefined : '/[dynamic]', - shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, - shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, - '/index', - '404.html', - '500.html', - ].filter(Boolean), - ) +test.skipIf( + process.env.NEXT_VERSION !== 'canary' && nextVersionSatisfies('<16.0.0'), +)('Test that a simple next app with PPR is working', async (ctx) => { + await createFixture('ppr', ctx) + await runPlugin(ctx) + // check if the blob entries where successful set on the build plugin + const blobEntries = await getBlobEntries(ctx) + expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( + [ + '/1', + '/2', + shouldHaveAppRouterNotFoundInPrerenderManifest() ? undefined : '/404', + isExperimentalPPRHardDeprecated() ? undefined : '/[dynamic]', + shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, + shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, + '/index', + '404.html', + '500.html', + ].filter(Boolean), + ) - // test the function call - const home = await invokeFunction(ctx) - expect(home.statusCode).toBe(200) - expect(load(home.body)('h1').text()).toBe('Home') + // test the function call + const home = await invokeFunction(ctx) + expect(home.statusCode).toBe(200) + expect(load(home.body)('h1').text()).toBe('Home') - const dynamicPrerendered = await invokeFunction(ctx, { url: '/1' }) - expect(dynamicPrerendered.statusCode).toBe(200) - expect(load(dynamicPrerendered.body)('h1').text()).toBe('Dynamic Page: 1') + const dynamicPrerendered = await invokeFunction(ctx, { url: '/1' }) + expect(dynamicPrerendered.statusCode).toBe(200) + expect(load(dynamicPrerendered.body)('h1').text()).toBe('Dynamic Page: 1') - const dynamicNotPrerendered = await invokeFunction(ctx, { url: '/3' }) - expect(dynamicNotPrerendered.statusCode).toBe(200) - expect(load(dynamicNotPrerendered.body)('h1').text()).toBe('Dynamic Page: 3') - }, -) + const dynamicNotPrerendered = await invokeFunction(ctx, { url: '/3' }) + expect(dynamicNotPrerendered.statusCode).toBe(200) + expect(load(dynamicNotPrerendered.body)('h1').text()).toBe('Dynamic Page: 3') +}) // setup for this test only works with webpack builds due to usage of ` __non_webpack_require__` to avoid bundling a file test.skipIf(hasDefaultTurbopackBuilds())( diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index 51d0a58c11..46caf3bd85 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -6,7 +6,7 @@ import fg from 'fast-glob' import { coerce, gt, gte, satisfies, valid } from 'semver' import { execaCommand } from 'execa' -const FUTURE_NEXT_PATCH_VERSION = '15.999.0' +const FUTURE_NEXT_PATCH_VERSION = '16.999.0' const NEXT_VERSION_REQUIRES_REACT_19 = '14.3.0-canary.45' const REACT_18_VERSION = '18.2.0' @@ -114,23 +114,44 @@ export async function setNextVersionInFixture( packageJsons.map(async (packageJsonPath) => { const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) if (packageJson.dependencies?.next) { - const versionConstraint = packageJson.test?.dependencies?.next - // We can't use semver to check "canary" or "latest", so we use a fake future minor version - const checkVersion = isSemverVersion ? resolvedVersion : FUTURE_NEXT_PATCH_VERSION - if ( - operation === 'update' && - versionConstraint && - !(versionConstraint === 'canary' - ? isNextCanary() - : satisfies(checkVersion, versionConstraint, { includePrerelease: true })) && - version !== versionConstraint - ) { - if (!silent) { - console.log( - `${logPrefix}⏩ Skipping '${packageJson.name}' because it requires next@${versionConstraint}`, + /** @type {string | undefined} */ + const versionConstraints = packageJson.test?.dependencies?.next + + if (versionConstraints) { + // We need to be able to define constraint such as "canary or >=16.0.0" for testing features that might have + // canary-only support (you can only even try them on canary releases) that later on get promoted to be usable in stable + // releases. + // There is no proper semver range to express "canary" portion of it, so we have to implement custom logic here + + if (versionConstraints.includes('(') || versionConstraints.includes(')')) { + // We currently don't have a use case for complex semver ranges, so to simplify the implementation we literally + // just split on '||' and check each part separately. This might not work work with '()' groups in semver ranges + // so we throw here for clarity that it's not supported + throw new Error( + `${logPrefix}Complex semver ranges with '()' groups are not supported in test.dependencies.next: ${versionConstraints}`, ) } - return { packageJsonPath, needUpdate: false } + + // We can't use semver to check "canary" or "latest", so we use a fake future minor version + const checkVersion = isSemverVersion ? resolvedVersion : FUTURE_NEXT_PATCH_VERSION + + if ( + operation === 'update' && + version !== versionConstraints && + !versionConstraints.split('||').some((versionConstraintUntrimmedPart) => { + const versionConstraintPart = versionConstraintUntrimmedPart.trim() + return versionConstraintPart === 'canary' + ? isNextCanary() + : satisfies(checkVersion, versionConstraintPart, { includePrerelease: true }) + }) + ) { + if (!silent) { + console.log( + `${logPrefix}⏩ Skipping '${packageJson.name}' because it requires next@${versionConstraints}`, + ) + } + return { packageJsonPath, needUpdate: false } + } } } return { packageJsonPath, needUpdate: true }