Remove the erroring on force-dynamic in static generation for app route (#63526)

### What
* Remove the erroring of force-dynamic is not able to use during static
generation
* Export the route segments properly in sitemap conventions

### Why

We discovered this error is showing up when users are using
force-dynamic with generating multi sitemaps.

When you have a dynamic `route /[id]/route.js` , and you have
generateSitemaps defined which is actually using `generateSitemaps`
under the hood , then you set the route to dynamic with `export dynamic
= 'force-dynamic'`.

We should keep the route still as dynamic. `generateStaticParams` is
only for generating the paths, which is static in build time. And the
`force-dynamic` is going to be applied to each generated path.

Closes NEXT-2881
This commit is contained in:
Jiachi Liu 2024-03-22 00:15:20 +01:00 committed by GitHub
parent 4c467a2638
commit 053db6af8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 149 additions and 46 deletions

View file

@ -15,6 +15,7 @@ import { imageExtMimeTypeMap } from '../../../lib/mime-type'
import { WEBPACK_RESOURCE_QUERIES } from '../../../lib/constants'
import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep'
import type { PageExtensions } from '../../page-extensions-type'
import { getLoaderModuleNamedExports } from './utils'
interface Options {
segment: string
@ -23,40 +24,6 @@ interface Options {
basePath: string
}
export async function getNamedExports(
resourcePath: string,
context: webpack.LoaderContext<any>
): Promise<string[]> {
const mod = await new Promise<webpack.NormalModule>((res, rej) => {
context.loadModule(
resourcePath,
(err: null | Error, _source: any, _sourceMap: any, module: any) => {
if (err) {
return rej(err)
}
res(module)
}
)
})
const exportNames =
mod.dependencies
?.filter((dep) => {
return (
[
'HarmonyExportImportedSpecifierDependency',
'HarmonyExportSpecifierDependency',
].includes(dep.constructor.name) &&
'name' in dep &&
dep.name !== 'default'
)
})
.map((dep: any) => {
return dep.name
}) || []
return exportNames
}
// [NOTE] For turbopack
// refer loader_tree's write_static|dynamic_metadata for corresponding features
async function nextMetadataImageLoader(
@ -95,7 +62,7 @@ async function nextMetadataImageLoader(
if (isDynamicResource) {
const exportedFieldsExcludingDefault = (
await getNamedExports(resourcePath, this)
await getLoaderModuleNamedExports(resourcePath, this)
).filter((name) => name !== 'default')
// re-export and spread as `exportedImageData` to avoid non-exported error

View file

@ -2,7 +2,7 @@ import type webpack from 'webpack'
import fs from 'fs'
import path from 'path'
import { imageExtMimeTypeMap } from '../../../lib/mime-type'
import { getNamedExports } from './next-metadata-image-loader'
import { getLoaderModuleNamedExports } from './utils'
function errorOnBadHandler(resourcePath: string) {
return `
@ -154,7 +154,15 @@ async function getDynamicSiteMapRouteCode(
) {
let staticGenerationCode = ''
const exportNames = await getNamedExports(resourcePath, loaderContext)
const exportNames = await getLoaderModuleNamedExports(
resourcePath,
loaderContext
)
// Re-export configs but avoid conflicted exports
const reExportNames = exportNames.filter(
(name) => name !== 'default' && name !== 'generateSitemaps'
)
const hasGenerateSiteMaps = exportNames.includes('generateSitemaps')
if (
process.env.NODE_ENV === 'production' &&
@ -189,8 +197,13 @@ const fileType = ${JSON.stringify(getFilenameAndExtension(resourcePath).name)}
${errorOnBadHandler(resourcePath)}
${'' /* re-export the userland route configs */}
export * from ${JSON.stringify(resourcePath)}
${
reExportNames.length > 0
? `export { ${reExportNames.join(', ')} } from ${JSON.stringify(
resourcePath
)}\n`
: ''
}
export async function GET(_, ctx) {
const { __metadata_id__, ...params } = ctx.params || {}

View file

@ -1,3 +1,4 @@
import type webpack from 'webpack'
import { createHash } from 'crypto'
import { RSC_MODULE_TYPES } from '../../../shared/lib/constants'
@ -58,3 +59,37 @@ export function encodeToBase64<T extends {}>(obj: T): string {
export function decodeFromBase64<T extends {}>(str: string): T {
return JSON.parse(Buffer.from(str, 'base64').toString('utf8'))
}
export async function getLoaderModuleNamedExports(
resourcePath: string,
context: webpack.LoaderContext<any>
): Promise<string[]> {
const mod = await new Promise<webpack.NormalModule>((res, rej) => {
context.loadModule(
resourcePath,
(err: null | Error, _source: any, _sourceMap: any, module: any) => {
if (err) {
return rej(err)
}
res(module)
}
)
})
const exportNames =
mod.dependencies
?.filter((dep) => {
return (
[
'HarmonyExportImportedSpecifierDependency',
'HarmonyExportSpecifierDependency',
].includes(dep.constructor.name) &&
'name' in dep &&
dep.name !== 'default'
)
})
.map((dep: any) => {
return dep.name
}) || []
return exportNames
}

View file

@ -304,19 +304,17 @@ export class AppRouteRouteModule extends RouteModule<
}
// We assume we can pass the original request through however we may end up
// proxying it in certain circumstances based on execution type and configuraiton
// proxying it in certain circumstances based on execution type and configuration
let request = rawRequest
// Update the static generation store based on the dynamic property.
if (isStaticGeneration) {
switch (this.dynamic) {
case 'force-dynamic':
// We should never be in this case but since it can happen based on the way our build/execution is structured
// We defend against it for the time being
throw new Error(
'Invariant: `force-dynamic` during static generation not expected for app routes. This is a bug in Next.js'
)
case 'force-dynamic': {
// Routes of generated paths should be dynamic
staticGenerationStore.forceDynamic = true
break
}
case 'force-static':
// The dynamic property is set to force-static, so we should
// force the page to be static.

View file

@ -0,0 +1,9 @@
export function GET() {
return new Response('force-dynamic')
}
export const dynamic = 'force-dynamic'
export async function generateStaticParams() {
return [{ id: '0' }, { id: '1' }]
}

View file

@ -0,0 +1,9 @@
export default function Page() {
return 'page'
}
export const dynamic = 'force-dynamic'
export async function generateStaticParams() {
return [{ id: '0' }, { id: '1' }]
}

View file

@ -0,0 +1,7 @@
export default function Layout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,22 @@
export default function sitemap() {
return [
{
url: 'https://acme.com',
lastModified: new Date(),
changeFrequency: 'secondly',
priority: 1,
},
{
url: 'https://acme.com/about',
lastModified: new Date(),
changeFrequency: 'secondly',
priority: 0.8,
},
]
}
export const dynamic = 'force-dynamic'
export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }]
}

View file

@ -0,0 +1,43 @@
import { type NextInstance, createNextDescribe } from 'e2e-utils'
async function getLastModifiedTime(next: NextInstance, pathname: string) {
const content = await (await next.fetch(pathname)).text()
return content.match(/<lastmod>([^<]+)<\/lastmod>/)[1]
}
createNextDescribe(
'app-dir - dynamic in generate params',
{
files: __dirname,
},
({ next, isNextDev }) => {
it('should render sitemap with generateSitemaps in force-dynamic config dynamically', async () => {
const firstTime = await getLastModifiedTime(
next,
isNextDev ? 'sitemap.xml/0' : '/sitemap/0.xml'
)
const secondTime = await getLastModifiedTime(
next,
isNextDev ? 'sitemap.xml/0' : '/sitemap/0.xml'
)
expect(firstTime).not.toEqual(secondTime)
})
it('should be able to call while generating multiple dynamic sitemaps', async () => {
expect(
(await next.fetch(isNextDev ? 'sitemap.xml/0' : '/sitemap/0.xml'))
.status
).toBe(200)
expect(
(await next.fetch(isNextDev ? 'sitemap.xml/1' : '/sitemap/1.xml'))
.status
).toBe(200)
})
it('should be able to call fetch while generating multiple dynamic pages', async () => {
expect((await next.fetch('/dynamic/0')).status).toBe(200)
expect((await next.fetch('/dynamic/1')).status).toBe(200)
})
}
)