Skip to content

Commit f88de0d

Browse files
authored
feat: type guard file generation (#81400)
## Overview Generates `.next/types/validator.ts` - a single TypeScript file that validates all route exports using the `satisfies` operator. Replaces the old `next-types-plugin` webpack plugin. ## Why needed - **Turbopack compatibility**: Old webpack plugin didn't work with Turbopack - **Performance**: Single large file vs hundreds of small validation files - **Type safety**: Catches invalid exports like `dynamic = 'invalid-value'` ## Validation Structure ```typescript // Type definitions type PageConfig = { default: React.ComponentType<any> config?: {} generateMetadata?: (props: any, parent: any) => Promise<any> | any dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static' // ... other Next.js exports } // Validation checks // Validate app/page.tsx { const handler = {} as typeof import("../../app/page") handler satisfies PageConfig } ... ``` ## Example Usage ```typescript // app/blog/[slug]/page.tsx export const dynamic = 'some-random-string' // ← Type error caught! export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) { const params = await props.params return <div>Blog Post: {params.slug}</div> } ``` ## Key Benefits - **Build-time validation**: Catches invalid route exports during TypeScript compilation - **Turbopack support**: Works with both webpack and Turbopack - **Better performance**: One file instead of hundreds of small type guard files
1 parent 7a6ee79 commit f88de0d

File tree

16 files changed

+845
-109
lines changed

16 files changed

+845
-109
lines changed

packages/next/src/build/entries.ts

Lines changed: 124 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -76,37 +76,36 @@ import type { MappedPages } from './build-context'
7676
import { PAGE_TYPES } from '../lib/page-types'
7777
import { isAppPageRoute } from '../lib/is-app-page-route'
7878
import { recursiveReadDir } from '../lib/recursive-readdir'
79-
import { createValidFileMatcher } from '../server/lib/find-page-file'
79+
import type { createValidFileMatcher } from '../server/lib/find-page-file'
8080
import { isReservedPage } from './utils'
8181
import { isParallelRouteSegment } from '../shared/lib/segment'
8282
import { ensureLeadingSlash } from '../shared/lib/page-path/ensure-leading-slash'
8383

8484
/**
85-
* Collect app pages and layouts from the app directory
85+
* Collect app pages, layouts, and default files from the app directory
8686
* @param appDir - The app directory path
87-
* @param pageExtensions - The configured page extensions
88-
* @param options - Optional configuration
89-
* @returns Object containing appPaths and layoutPaths arrays
87+
* @param validFileMatcher - File matcher object
88+
* @returns Object containing appPaths, layoutPaths, and defaultPaths arrays
9089
*/
9190
export async function collectAppFiles(
9291
appDir: string,
93-
pageExtensions: PageExtensions
92+
validFileMatcher: ReturnType<typeof createValidFileMatcher>
9493
): Promise<{
9594
appPaths: string[]
9695
layoutPaths: string[]
96+
defaultPaths: string[]
9797
}> {
98-
const validFileMatcher = createValidFileMatcher(pageExtensions, appDir)
99-
100-
// Collect both app pages and layouts in a single directory traversal
98+
// Collect app pages, layouts, and default files in a single directory traversal
10199
const allAppFiles = await recursiveReadDir(appDir, {
102100
pathnameFilter: (absolutePath) =>
103101
validFileMatcher.isAppRouterPage(absolutePath) ||
104102
validFileMatcher.isRootNotFound(absolutePath) ||
105-
validFileMatcher.isAppLayoutPage(absolutePath),
103+
validFileMatcher.isAppLayoutPage(absolutePath) ||
104+
validFileMatcher.isAppDefaultPage(absolutePath),
106105
ignorePartFilter: (part) => part.startsWith('_'),
107106
})
108107

109-
// Separate app pages from layouts
108+
// Separate app pages, layouts, and defaults
110109
const appPaths = allAppFiles.filter(
111110
(absolutePath) =>
112111
validFileMatcher.isAppRouterPage(absolutePath) ||
@@ -115,26 +114,25 @@ export async function collectAppFiles(
115114
const layoutPaths = allAppFiles.filter((absolutePath) =>
116115
validFileMatcher.isAppLayoutPage(absolutePath)
117116
)
117+
const defaultPaths = allAppFiles.filter((absolutePath) =>
118+
validFileMatcher.isAppDefaultPage(absolutePath)
119+
)
118120

119-
return { appPaths, layoutPaths }
121+
return { appPaths, layoutPaths, defaultPaths }
120122
}
121123

122124
/**
123125
* Collect pages from the pages directory
124126
* @param pagesDir - The pages directory path
125-
* @param pageExtensions - The configured page extensions
127+
* @param validFileMatcher - File matcher object
126128
* @returns Array of page file paths
127129
*/
128130
export async function collectPagesFiles(
129131
pagesDir: string,
130-
pageExtensions: PageExtensions
132+
validFileMatcher: ReturnType<typeof createValidFileMatcher>
131133
): Promise<string[]> {
132134
return recursiveReadDir(pagesDir, {
133-
pathnameFilter: (absolutePath) => {
134-
const relativePath = absolutePath.replace(pagesDir + '/', '')
135-
return pageExtensions.some((ext) => relativePath.endsWith(`.${ext}`))
136-
},
137-
ignorePartFilter: (part) => part.startsWith('_'),
135+
pathnameFilter: validFileMatcher.isPageFile,
138136
})
139137
}
140138

@@ -154,30 +152,35 @@ export type SlotInfo = {
154152
* @param baseDir - The base directory path
155153
* @param filePath - The mapped file path (with private prefix)
156154
* @param prefix - The directory prefix ('pages' or 'app')
155+
* @param isSrcDir - Whether the project uses src directory structure
157156
* @returns The relative file path
158157
*/
159158
export function createRelativeFilePath(
160159
baseDir: string,
161160
filePath: string,
162-
prefix: 'pages' | 'app'
161+
prefix: 'pages' | 'app',
162+
isSrcDir: boolean
163163
): string {
164164
const privatePrefix =
165165
prefix === 'pages' ? 'private-next-pages' : 'private-next-app-dir'
166+
const srcPrefix = isSrcDir ? 'src/' : ''
166167
return join(
167168
baseDir,
168-
filePath.replace(new RegExp(`^${privatePrefix}/`), `${prefix}/`)
169+
filePath.replace(new RegExp(`^${privatePrefix}/`), `${srcPrefix}${prefix}/`)
169170
)
170171
}
171172

172173
/**
173174
* Process pages routes from mapped pages
174175
* @param mappedPages - The mapped pages object
175176
* @param baseDir - The base directory path
177+
* @param isSrcDir - Whether the project uses src directory structure
176178
* @returns Object containing pageRoutes and pageApiRoutes
177179
*/
178180
export function processPageRoutes(
179181
mappedPages: { [page: string]: string },
180-
baseDir: string
182+
baseDir: string,
183+
isSrcDir: boolean
181184
): {
182185
pageRoutes: RouteInfo[]
183186
pageApiRoutes: RouteInfo[]
@@ -186,7 +189,12 @@ export function processPageRoutes(
186189
const pageApiRoutes: RouteInfo[] = []
187190

188191
for (const [route, filePath] of Object.entries(mappedPages)) {
189-
const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'pages')
192+
const relativeFilePath = createRelativeFilePath(
193+
baseDir,
194+
filePath,
195+
'pages',
196+
isSrcDir
197+
)
190198

191199
if (route.startsWith('/api/')) {
192200
pageApiRoutes.push({
@@ -243,46 +251,129 @@ export function extractSlotsFromAppRoutes(mappedAppPages: {
243251
return slots
244252
}
245253

254+
/**
255+
* Extract slots from default files
256+
* @param mappedDefaultFiles - The mapped default files object
257+
* @returns Array of slot information
258+
*/
259+
export function extractSlotsFromDefaultFiles(mappedDefaultFiles: {
260+
[page: string]: string
261+
}): SlotInfo[] {
262+
const slots: SlotInfo[] = []
263+
264+
for (const [route] of Object.entries(mappedDefaultFiles)) {
265+
const segments = route.split('/')
266+
for (let i = segments.length - 1; i >= 0; i--) {
267+
const segment = segments[i]
268+
if (isParallelRouteSegment(segment)) {
269+
const parentPath = normalizeAppPath(segments.slice(0, i).join('/'))
270+
const slotName = segment.slice(1)
271+
272+
// Check if the slot already exists
273+
if (slots.some((s) => s.name === slotName && s.parent === parentPath))
274+
continue
275+
276+
slots.push({
277+
name: slotName,
278+
parent: parentPath,
279+
})
280+
break
281+
}
282+
}
283+
}
284+
285+
return slots
286+
}
287+
288+
/**
289+
* Combine and deduplicate slot arrays using a Set
290+
* @param slotArrays - Arrays of slot information to combine
291+
* @returns Deduplicated array of slots
292+
*/
293+
export function combineSlots(...slotArrays: SlotInfo[][]): SlotInfo[] {
294+
const slotSet = new Set<string>()
295+
const result: SlotInfo[] = []
296+
297+
for (const slots of slotArrays) {
298+
for (const slot of slots) {
299+
const key = `${slot.name}:${slot.parent}`
300+
if (!slotSet.has(key)) {
301+
slotSet.add(key)
302+
result.push(slot)
303+
}
304+
}
305+
}
306+
307+
return result
308+
}
309+
246310
/**
247311
* Process app routes from mapped app pages
248312
* @param mappedAppPages - The mapped app pages object
313+
* @param validFileMatcher - File matcher object
249314
* @param baseDir - The base directory path
315+
* @param isSrcDir - Whether the project uses src directory structure
250316
* @returns Array of route information
251317
*/
252318
export function processAppRoutes(
253319
mappedAppPages: { [page: string]: string },
254-
baseDir: string
255-
): RouteInfo[] {
320+
validFileMatcher: ReturnType<typeof createValidFileMatcher>,
321+
baseDir: string,
322+
isSrcDir: boolean
323+
): {
324+
appRoutes: RouteInfo[]
325+
appRouteHandlers: RouteInfo[]
326+
} {
256327
const appRoutes: RouteInfo[] = []
328+
const appRouteHandlers: RouteInfo[] = []
257329

258330
for (const [route, filePath] of Object.entries(mappedAppPages)) {
259331
if (route === '/_not-found/page') continue
260332

261-
const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'app')
333+
const relativeFilePath = createRelativeFilePath(
334+
baseDir,
335+
filePath,
336+
'app',
337+
isSrcDir
338+
)
262339

263-
appRoutes.push({
264-
route: normalizeAppPath(normalizePathSep(route)),
265-
filePath: relativeFilePath,
266-
})
340+
if (validFileMatcher.isAppRouterRoute(filePath)) {
341+
appRouteHandlers.push({
342+
route: normalizeAppPath(normalizePathSep(route)),
343+
filePath: relativeFilePath,
344+
})
345+
} else {
346+
appRoutes.push({
347+
route: normalizeAppPath(normalizePathSep(route)),
348+
filePath: relativeFilePath,
349+
})
350+
}
267351
}
268352

269-
return appRoutes
353+
return { appRoutes, appRouteHandlers }
270354
}
271355

272356
/**
273357
* Process layout routes from mapped app layouts
274358
* @param mappedAppLayouts - The mapped app layouts object
275359
* @param baseDir - The base directory path
360+
* @param isSrcDir - Whether the project uses src directory structure
276361
* @returns Array of layout route information
277362
*/
278363
export function processLayoutRoutes(
279364
mappedAppLayouts: { [page: string]: string },
280-
baseDir: string
365+
baseDir: string,
366+
isSrcDir: boolean
281367
): RouteInfo[] {
282368
const layoutRoutes: RouteInfo[] = []
283369

284370
for (const [route, filePath] of Object.entries(mappedAppLayouts)) {
285-
const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'app')
371+
const relativeFilePath = createRelativeFilePath(
372+
baseDir,
373+
filePath,
374+
'app',
375+
isSrcDir
376+
)
286377
layoutRoutes.push({
287378
route: ensureLeadingSlash(
288379
normalizeAppPath(normalizePathSep(route)).replace(/\/layout$/, '')

0 commit comments

Comments
 (0)