Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1472,5 +1472,6 @@
"1471": "Cannot resolve a dynamic segment that has no param value: the response provides no rendered pathname to parse it from.",
"1472": "Cannot convert a server response with no transport data and no base tree.",
"1473": "Invariant: image cache entry \"%s\" is empty",
"1474": "Invariant: cannot write an empty buffer to the image cache"
"1474": "Invariant: cannot write an empty buffer to the image cache",
"1475": "Invariant: no direct app page entry found for %s"
}
38 changes: 36 additions & 2 deletions packages/next/src/build/adapter/build-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { recursiveReadDir } from '../../lib/recursive-readdir'
import { isDynamicRoute } from '../../shared/lib/router/utils'
import type { Revalidate } from '../../server/lib/cache-control'
import type { NextConfigComplete } from '../../server/config-shared'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import {
normalizeAppPath,
selectAppPageEntry,
} from '../../shared/lib/router/utils/app-paths'
import { AdapterOutputType, type PHASE_TYPE } from '../../shared/lib/constants'
import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path'
import {
Expand Down Expand Up @@ -194,6 +197,37 @@ type PrerenderClassification =
htmlSize?: never
}

// App paths sharing a pathname collapse into one Adapter output. Put the
// canonical entry first because later paths only merge assets into that output.
function orderAppPageKeysByEntry(appPageKeys: readonly string[]): string[] {
const appPathsByPathname = new Map<string, string[]>()

for (const page of appPageKeys) {
const pathname = normalizeAppPath(page)
const appPaths = appPathsByPathname.get(pathname)

if (appPaths) {
appPaths.push(page)
} else {
appPathsByPathname.set(pathname, [page])
}
}

const orderedAppPageKeys: string[] = []
for (const [pathname, appPaths] of appPathsByPathname) {
const entryPage = selectAppPageEntry(pathname, appPaths)
orderedAppPageKeys.push(entryPage)

for (const appPath of appPaths) {
if (appPath !== entryPage) {
orderedAppPageKeys.push(appPath)
}
}
}

return orderedAppPageKeys
}

export interface AdapterOutput {
/**
* `PAGES` represents all the React pages that are under `pages/`.
Expand Down Expand Up @@ -1092,7 +1126,7 @@ export async function handleBuildComplete({
const appDistDir = path.join(distDir, 'server', 'app')

if (appPageKeys) {
for (const page of appPageKeys) {
for (const page of orderAppPageKeysByEntry(appPageKeys)) {
if (middlewareManifest.functions.hasOwnProperty(page)) {
continue
}
Expand Down
40 changes: 32 additions & 8 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ import { installBindings } from './swc/install-bindings'
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
import { getFilesInDir } from '../lib/get-files-in-dir'
import { eventSwcPlugins } from '../telemetry/events/swc-plugins'
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
import {
compareAppPaths,
normalizeAppPath,
selectAppPageEntry,
} from '../shared/lib/router/utils/app-paths'
import {
ACTION_HEADER,
type NEXT_ROUTER_PREFETCH_HEADER,
Expand Down Expand Up @@ -2186,8 +2190,24 @@ export default async function build(
emittedAppPageKeySet.has(appPageKey)
)

const appPathsByPathname = new Map<string, string[]>()
for (const key in appPathsManifest) {
appPathRoutes[key] = normalizeAppPath(key)
const pathname = normalizeAppPath(key)
const routeAppPaths = appPathsByPathname.get(pathname)
if (routeAppPaths) {
routeAppPaths.push(key)
} else {
appPathsByPathname.set(pathname, [key])
}
}

// Legacy deployment builders collapse this manifest by pathname using
// the final entry. Keep that entry aligned with selectAppPageEntry so
// the traced module and the module loaded at runtime cannot diverge.
for (const [pathname, routeAppPaths] of appPathsByPathname) {
for (const key of routeAppPaths.sort(compareAppPaths)) {
appPathRoutes[key] = pathname
}
}

await writeManifest(
Expand Down Expand Up @@ -2373,21 +2393,25 @@ export default async function build(
let originalAppPath: string | undefined

if (pageType === 'app' && mappedAppPages) {
const originalAppPaths: string[] = []
for (const [originalPath, normalizedPath] of Object.entries(
appPathRoutes
)) {
if (
normalizedPath === page &&
mappedAppPages[originalPath]
) {
pagePath = mappedAppPages[originalPath].replace(
/^private-next-app-dir/,
''
)
originalAppPath = originalPath
break
originalAppPaths.push(originalPath)
}
}

if (originalAppPaths.length > 0) {
originalAppPath = selectAppPageEntry(page, originalAppPaths)
pagePath = mappedAppPages[originalAppPath].replace(
/^private-next-app-dir/,
''
)
}
}

const pageFilePath = isAppBuiltinPage(pagePath)
Expand Down
102 changes: 1 addition & 101 deletions packages/next/src/build/normalize-catchall-routes.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1 @@
import { isInterceptionRouteAppPath } from '../shared/lib/router/utils/interception-routes'
import { AppPathnameNormalizer } from '../server/normalizers/built/app/app-pathname-normalizer'

/**
* This function will transform the appPaths in order to support catch-all routes and parallel routes.
* It will traverse the appPaths, looking for catch-all routes and try to find parallel routes that could match
* the catch-all. If it finds a match, it will add the catch-all to the parallel route's list of possible routes.
*
* @param appPaths The appPaths to transform
*/
export function normalizeCatchAllRoutes(
appPaths: Record<string, string[]>,
normalizer = new AppPathnameNormalizer()
) {
const catchAllRoutes = [
...new Set(
Object.values(appPaths)
.flat()
.filter(isCatchAllRoute)
// Sorting is important because we want to match the most specific path.
.sort((a, b) => b.split('/').length - a.split('/').length)
),
]

// interception routes should only be matched by a single entrypoint
// we don't want to push a catch-all route to an interception route
// because it would mean the interception would be handled by the wrong page component
const filteredAppPaths = Object.keys(appPaths).filter(
(route) => !isInterceptionRouteAppPath(route)
)

for (const appPath of filteredAppPaths) {
for (const catchAllRoute of catchAllRoutes) {
const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute)
const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice(
0,
normalizedCatchAllRoute.search(catchAllRouteRegex)
)

if (
// check if the appPath could match the catch-all
appPath.startsWith(normalizedCatchAllRouteBasePath) &&
// check if there's not already a slot value that could match the catch-all
!appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute))
) {
// optional catch-all routes are not currently supported, but leaving this logic in place
// for when they are eventually supported.
if (isOptionalCatchAll(catchAllRoute)) {
// optional catch-all routes should match both the root segment and any segment after it
// for example, `/[[...slug]]` should match `/` and `/foo` and `/foo/bar`
appPaths[appPath].push(catchAllRoute)
} else if (isCatchAll(catchAllRoute)) {
// regular catch-all (single bracket) should only match segments after it
// for example, `/[...slug]` should match `/foo` and `/foo/bar` but not `/`
if (normalizedCatchAllRouteBasePath !== appPath) {
appPaths[appPath].push(catchAllRoute)
}
}
}
}
}
}

function hasMatchedSlots(path1: string, path2: string): boolean {
const slots1 = path1.split('/').filter(isMatchableSlot)
const slots2 = path2.split('/').filter(isMatchableSlot)

// if the catch-all route does not have the same number of slots as the app path, it can't match
if (slots1.length !== slots2.length) return false

// compare the slots in both paths. For there to be a match, each slot must be the same
for (let i = 0; i < slots1.length; i++) {
if (slots1[i] !== slots2[i]) return false
}

return true
}

/**
* Returns true for slots that should be considered when checking for match compatibility.
* Excludes children slots because these are similar to having a segment-level `page`
* which would cause a slot length mismatch when comparing it to a catch-all route.
*/
function isMatchableSlot(segment: string): boolean {
return segment.startsWith('@') && segment !== '@children'
}

const catchAllRouteRegex = /\[?\[\.\.\./

function isCatchAllRoute(pathname: string): boolean {
// Optional catch-all slots are not currently supported, and as such they are not considered when checking for match compatibility.
return !isOptionalCatchAll(pathname) && isCatchAll(pathname)
}

function isOptionalCatchAll(pathname: string): boolean {
return pathname.includes('[[...')
}

function isCatchAll(pathname: string): boolean {
return pathname.includes('[...')
}
export { normalizeCatchAllRoutes } from '../server/lib/router-utils/normalize-catchall-routes'
Original file line number Diff line number Diff line change
Expand Up @@ -271,36 +271,16 @@ describe('request insights trace viewer', () => {
durationMs: 5,
attributes: { 'next.span_type': 'NextNodeServer.matchRoute' },
},
{
name: 'prepare route',
spanId: 'ensure',
parentSpanId: 'match',
startTime: 107,
durationMs: 2,
attributes: {
'next.span_type': 'DevRouteMatcherManager.ensureRoute',
},
},
{
name: 'compile route',
spanId: 'compile-route',
parentSpanId: 'ensure',
parentSpanId: 'match',
startTime: 107.1,
durationMs: 1.5,
attributes: {
'next.span_type': 'DevBundlerService.ensurePage',
},
},
{
name: 'reload route matchers',
spanId: 'reload-matchers',
parentSpanId: 'match',
startTime: 109,
durationMs: 1,
attributes: {
'next.span_type': 'DevRouteMatcherManager.reloadMatchers',
},
},
{
name: 'render',
spanId: 'base-render',
Expand Down Expand Up @@ -402,9 +382,7 @@ describe('request insights trace viewer', () => {
).toEqual([
{ label: 'GET', depth: 0 },
{ label: 'match route', depth: 1 },
{ label: 'prepare route', depth: 2 },
{ label: 'compile route', depth: 3 },
{ label: 'reload route matchers', depth: 2 },
{ label: 'compile route', depth: 2 },
{ label: 'render', depth: 2 },
{ label: 'load components', depth: 3 },
{ label: 'prepare app page response', depth: 3 },
Expand All @@ -419,9 +397,7 @@ describe('request insights trace viewer', () => {
'GET',
'prepare request',
'match route',
'prepare route',
'compile route',
'reload route matchers',
'render',
'resolve page components',
'load components',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ const DEFAULT_VISIBLE_SPAN_TYPES = new Set([
'BaseServer.handleRequest',
'Middleware.execute',
'NextNodeServer.matchRoute',
'DevRouteMatcherManager.ensureRoute',
'DevRouteMatcherManager.reloadMatchers',
'DevBundlerService.ensurePage',
'BaseServer.render',
'LoadComponents.loadComponents',
Expand Down
Loading
Loading