Skip to content

Commit f5198b4

Browse files
authored
[metadata] fix the metadata route like pages and refactor utils (#77264)
### What - Fix the metadata route like pages rendering, e.g. `/sitemap/page.js` should still work properly rather than being treated as route - Refactor metadata utils, should prefer using the file matcher rather than just checking the route or page path if possible to determine if it's metadata route Previously the metadata regex didn't handle the route well. For example: When it's not checking the end, `/opengraph-image-abc` can still be matched, but actually it's not a valid metadata route. This PR refines the route/file path regex matching for metadata entries and add more tests. The underlayer helper - `isMetadataRouteFile`, you can use it to check if a file path is an metadata entry. The helpers built on top of it: - `isMetadataPage`: determine if a page path is metadata route, the input is more like a pathname. - `isMetadataRoute`: determine if a route is metadata route, the input contains nextjs route suffix like `/route`. We change the most of places to use `isMetadataRouteFile` if possible, since it's more accurate, especially when we check the static metadata routes, e.g. `/opengraph-image.png`, `/opengraph-image.js` will require the extension check and strict matching on file name convention. Closes #77250 Fixes #76747
1 parent 122c7d0 commit f5198b4

File tree

15 files changed

+292
-93
lines changed

15 files changed

+292
-93
lines changed

packages/next/src/build/webpack/loaders/next-app-loader/create-app-route-code.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import path from 'path'
22
import { stringify } from 'querystring'
33
import { WEBPACK_RESOURCE_QUERIES } from '../../../../lib/constants'
4-
import { isMetadataRoute } from '../../../../lib/metadata/is-metadata-route'
4+
import {
5+
DEFAULT_METADATA_ROUTE_EXTENSIONS,
6+
isMetadataRouteFile,
7+
} from '../../../../lib/metadata/is-metadata-route'
58
import type { NextConfig } from '../../../../server/config-shared'
69
import { AppBundlePathNormalizer } from '../../../../server/normalizers/built/app/app-bundle-path-normalizer'
710
import { AppPathnameNormalizer } from '../../../../server/normalizers/built/app/app-pathname-normalizer'
@@ -10,13 +13,15 @@ import type { PageExtensions } from '../../../page-extensions-type'
1013
import { getFilenameAndExtension } from '../next-metadata-route-loader'
1114

1215
export async function createAppRouteCode({
16+
appDir,
1317
name,
1418
page,
1519
pagePath,
1620
resolveAppRoute,
1721
pageExtensions,
1822
nextConfigOutput,
1923
}: {
24+
appDir: string
2025
name: string
2126
page: string
2227
pagePath: string
@@ -39,10 +44,16 @@ export async function createAppRouteCode({
3944
)
4045
}
4146

42-
// If this is a metadata route, then we need to use the metadata loader for
43-
// the route to ensure that the route is generated.
47+
// If this is a metadata route file, then we need to use the metadata-loader
48+
// for the route to ensure that the route is generated.
4449
const fileBaseName = path.parse(resolvedPagePath).name
45-
if (isMetadataRoute(name) && fileBaseName !== 'route') {
50+
const appDirRelativePath = resolvedPagePath.slice(appDir.length)
51+
const isMetadataEntryFile = isMetadataRouteFile(
52+
appDirRelativePath,
53+
DEFAULT_METADATA_ROUTE_EXTENSIONS,
54+
true
55+
)
56+
if (isMetadataEntryFile) {
4657
const { ext } = getFilenameAndExtension(resolvedPagePath)
4758
const isDynamicRouteExtension = pageExtensions.includes(ext)
4859

packages/next/src/build/webpack/loaders/next-app-loader/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
666666

667667
if (isAppRouteRoute(name)) {
668668
return createAppRouteCode({
669+
appDir,
669670
// TODO: investigate if the local `page` is the same as the loaderOptions.page
670671
page: loaderOptions.page,
671672
name,

packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ import { PAGE_TYPES } from '../../../lib/page-types'
4343
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
4444
import { getAssumedSourceType } from '../loaders/next-flight-loader'
4545
import { isAppRouteRoute } from '../../../lib/is-app-route-route'
46-
import { isMetadataRoute } from '../../../lib/metadata/is-metadata-route'
46+
import {
47+
DEFAULT_METADATA_ROUTE_EXTENSIONS,
48+
isMetadataRouteFile,
49+
} from '../../../lib/metadata/is-metadata-route'
4750
import type { MetadataRouteLoaderOptions } from '../loaders/next-metadata-route-loader'
4851
import type { FlightActionEntryLoaderActions } from '../loaders/next-flight-action-entry-loader'
4952

@@ -346,13 +349,27 @@ export class FlightClientEntryPlugin {
346349
: entryRequest
347350

348351
// Replace file suffix as `.js` will be added.
352+
// bundlePath will have app/ prefix but not src/.
353+
// e.g. src/app/foo/page.js -> app/foo/page
349354
let bundlePath = normalizePathSep(
350355
relativeRequest.replace(/\.[^.\\/]+$/, '').replace(/^src[\\/]/, '')
351356
)
352357

353358
// For metadata routes, the entry name can be used as the bundle path,
354359
// as it has been normalized already.
355-
if (isMetadataRoute(bundlePath)) {
360+
// e.g.
361+
// When `relativeRequest` is 'src/app/sitemap.js',
362+
// `appDirRelativeRequest` will be '/sitemap.js'
363+
// then `isMetadataEntryFile` will be `true`
364+
const appDirRelativeRequest = relativeRequest
365+
.replace(/^src[\\/]/, '')
366+
.replace(/^app[\\/]/, '/')
367+
const isMetadataEntryFile = isMetadataRouteFile(
368+
appDirRelativeRequest,
369+
DEFAULT_METADATA_ROUTE_EXTENSIONS,
370+
true
371+
)
372+
if (isMetadataEntryFile) {
356373
bundlePath = name
357374
}
358375

packages/next/src/build/webpack/plugins/next-trace-entrypoints-plugin.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import picomatch from 'next/dist/compiled/picomatch'
1818
import { getModuleBuildInfo } from '../loaders/get-module-build-info'
1919
import { getPageFilePath } from '../../entries'
2020
import { resolveExternal } from '../../handle-externals'
21-
import { isStaticMetadataRoutePage } from '../../../lib/metadata/is-metadata-route'
21+
import { isMetadataRouteFile } from '../../../lib/metadata/is-metadata-route'
2222
import { getCompilationSpan } from '../utils'
2323

2424
const PLUGIN_NAME = 'TraceEntryPointsPlugin'
@@ -243,7 +243,7 @@ export class TraceEntryPointsPlugin implements webpack.WebpackPluginInstance {
243243

244244
const entryIsStaticMetadataRoute =
245245
appDirRelativeEntryPath &&
246-
isStaticMetadataRoutePage(appDirRelativeEntryPath)
246+
isMetadataRouteFile(appDirRelativeEntryPath, [], true)
247247

248248
// Include the client reference manifest in the trace, but not for
249249
// static metadata routes, for which we don't generate those.

packages/next/src/export/routes/app-route.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error'
2323
import { hasNextSupport } from '../../server/ci-info'
2424
import { isStaticGenEnabled } from '../../server/route-modules/app-route/helpers/is-static-gen-enabled'
2525
import type { ExperimentalConfig } from '../../server/config-shared'
26-
import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route'
26+
import { isMetadataRoute } from '../../lib/metadata/is-metadata-route'
2727
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
2828
import type { Params } from '../../server/request/params'
2929
import { AfterRunner } from '../../server/after/run-with-after'
@@ -101,13 +101,13 @@ export async function exportAppRoute(
101101
try {
102102
const userland = module.userland
103103
// we don't bail from the static optimization for
104-
// metadata routes
105-
const normalizedPage = normalizeAppPath(page)
106-
const isMetadataRoute = isMetadataRouteFile(normalizedPage, [], false)
104+
// metadata routes, since it's app-route we can always append /route suffix.
105+
const routePath = normalizeAppPath(page) + '/route'
106+
const isPageMetadataRoute = isMetadataRoute(routePath)
107107

108108
if (
109109
!isStaticGenEnabled(userland) &&
110-
!isMetadataRoute &&
110+
!isPageMetadataRoute &&
111111
// We don't disable static gen when dynamicIO is enabled because we
112112
// expect that anything dynamic in the GET handler will make it dynamic
113113
// and thus avoid the cache surprises that led to us removing static gen

packages/next/src/lib/metadata/get-metadata-route.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isMetadataRoute } from './is-metadata-route'
1+
import { isMetadataPage } from './is-metadata-route'
22
import path from '../../shared/lib/isomorphic/path'
33
import { interpolateDynamicPath } from '../../server/server-utils'
44
import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
@@ -83,7 +83,7 @@ export function fillMetadataSegment(
8383
* @returns
8484
*/
8585
export function normalizeMetadataRoute(page: string) {
86-
if (!isMetadataRoute(page)) {
86+
if (!isMetadataPage(page)) {
8787
return page
8888
}
8989
let route = page

packages/next/src/lib/metadata/is-metadata-route.test.ts

+102-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { getExtensionRegexString } from './is-metadata-route'
1+
import {
2+
getExtensionRegexString,
3+
isMetadataRouteFile,
4+
isMetadataRoute,
5+
isMetadataPage,
6+
} from './is-metadata-route'
27

38
describe('getExtensionRegexString', () => {
49
function createExtensionMatchRegex(
@@ -54,3 +59,99 @@ describe('getExtensionRegexString', () => {
5459
})
5560
})
5661
})
62+
63+
describe('isMetadataRouteFile', () => {
64+
describe('match route - without extension', () => {
65+
it('should match metadata route page paths', () => {
66+
expect(isMetadataRouteFile('/icons/descriptor/page', [], false)).toBe(
67+
false
68+
)
69+
expect(isMetadataRouteFile('/foo/icon', [], false)).toBe(true)
70+
expect(isMetadataRouteFile('/foo/opengraph-image', [], false)).toBe(true)
71+
expect(isMetadataRouteFile('/foo/sitemap.xml', [], false)).toBe(true)
72+
// group routes
73+
expect(
74+
isMetadataRouteFile('/foo/opengraph-image-abc123', [], false)
75+
).toBe(true)
76+
// These pages are not normalized from actual entry files
77+
expect(isMetadataRouteFile('/foo/sitemap/0.xml', [], false)).toBe(false)
78+
expect(
79+
isMetadataRouteFile('/foo/opengraph-image-abc12313333', [], false)
80+
).toBe(false)
81+
})
82+
})
83+
84+
describe('match file - with extension', () => {
85+
it('should match static metadata route files', () => {
86+
expect(isMetadataRouteFile('/icons/descriptor/page', [], true)).toBe(
87+
false
88+
)
89+
expect(isMetadataRouteFile('/foo/icon.png', [], true)).toBe(true)
90+
expect(isMetadataRouteFile('/bar/opengraph-image.jpg', [], true)).toBe(
91+
true
92+
)
93+
expect(isMetadataRouteFile('/favicon.ico', [], true)).toBe(true)
94+
expect(isMetadataRouteFile('/robots.txt', [], true)).toBe(true)
95+
expect(isMetadataRouteFile('/manifest.json', [], true)).toBe(true)
96+
expect(isMetadataRouteFile('/sitemap.xml', [], true)).toBe(true)
97+
})
98+
99+
it('should match dynamic metadata routes', () => {
100+
// with dynamic extensions, passing the 2nd arg: such as ['tsx', 'ts']
101+
expect(isMetadataRouteFile('/foo/icon.js', ['tsx', 'ts'], true)).toBe(
102+
false
103+
)
104+
expect(isMetadataRouteFile('/foo/icon.ts', ['tsx', 'ts'], true)).toBe(
105+
true
106+
)
107+
expect(
108+
isMetadataRouteFile('/foo/icon.tsx', ['js', 'jsx', 'tsx', 'ts'], true)
109+
).toBe(true)
110+
})
111+
})
112+
})
113+
114+
describe('isMetadataRoute', () => {
115+
it('should require suffix for metadata routes', () => {
116+
expect(isMetadataRoute('/icon')).toBe(false)
117+
expect(isMetadataRoute('/icon/route')).toBe(true)
118+
expect(isMetadataRoute('/opengraph-image')).toBe(false)
119+
expect(isMetadataRoute('/opengraph-image/route')).toBe(true)
120+
})
121+
122+
it('should match metadata routes', () => {
123+
expect(isMetadataRoute('/app/robots/route')).toBe(true)
124+
expect(isMetadataRoute('/robots/route')).toBe(true)
125+
expect(isMetadataRoute('/sitemap/[__metadata_id__]/route')).toBe(true)
126+
expect(isMetadataRoute('/app/sitemap/page')).toBe(false)
127+
expect(isMetadataRoute('/icon-a102f4/route')).toBe(true)
128+
})
129+
130+
it('should match grouped metadata routes', () => {
131+
expect(isMetadataRoute('/opengraph-image-1ow20b/route')).toBe(true)
132+
expect(isMetadataRoute('/foo/icon2-1ow20b/route')).toBe(true)
133+
})
134+
135+
it('should support metadata variant numeric suffix', () => {
136+
expect(isMetadataRoute('/icon0/route')).toBe(true)
137+
expect(isMetadataRoute('/opengraph-image1/route')).toBe(true)
138+
expect(isMetadataRoute('/foo/icon0-a120ff/route')).toBe(true)
139+
expect(isMetadataRoute('/foo/icon0-a120ff3/route')).toBe(false)
140+
})
141+
})
142+
143+
describe('isMetadataPage', () => {
144+
it('should match metadata page path', () => {
145+
expect(isMetadataPage('/sitemap.xml')).toBe(true)
146+
expect(isMetadataPage('/favicon.ico')).toBe(true)
147+
expect(isMetadataPage('/manifest.json')).toBe(true)
148+
expect(isMetadataPage('/robots.txt')).toBe(true)
149+
})
150+
151+
it('should not match app router page path or error boundary path', () => {
152+
expect(isMetadataPage('/icon/page')).toBe(false)
153+
expect(isMetadataPage('/icon/route')).toBe(false)
154+
expect(isMetadataPage('/icon/error')).toBe(false)
155+
expect(isMetadataPage('/icon/not-found')).toBe(false)
156+
})
157+
})

0 commit comments

Comments
 (0)