diff --git a/packages/next/errors.json b/packages/next/errors.json index 60e496855aa61..b0c5257a03fc4 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -969,5 +969,6 @@ "968": "Invariant: getNextConfigEdge must only be called in edge runtime", "969": "Invariant: nextConfig couldn't be loaded", "970": "process.env.NEXT_DEPLOYMENT_ID is missing but runtimeServerDeploymentId is enabled", - "971": "The NEXT_DEPLOYMENT_ID environment variable value \"%s\" does not match the provided deploymentId \"%s\" in the config." + "971": "The NEXT_DEPLOYMENT_ID environment variable value \"%s\" does not match the provided deploymentId \"%s\" in the config.", + "972": "Failed to resolve pattern \"%s\": %s" } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 50ba7d8e16202..211117216e888 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1132,6 +1132,10 @@ export default async function build( nextBuildSpan, config, cacheDir, + debugBuildPaths: + debugBuildAppPaths !== undefined || debugBuildPagePaths !== undefined + ? { app: debugBuildAppPaths, pages: debugBuildPagePaths } + : undefined, } if (appDir && 'exportPathMap' in config) { diff --git a/packages/next/src/build/type-check.ts b/packages/next/src/build/type-check.ts index 6fcd75e339ab2..95fad78cedf3b 100644 --- a/packages/next/src/build/type-check.ts +++ b/packages/next/src/build/type-check.ts @@ -20,7 +20,6 @@ import { hrtimeDurationToString } from './duration-to-string' function verifyTypeScriptSetup( dir: string, distDir: string, - intentDirs: string[], typeCheckPreflight: boolean, tsconfigPath: string | undefined, disableStaticImages: boolean, @@ -28,7 +27,10 @@ function verifyTypeScriptSetup( enableWorkerThreads: boolean | undefined, hasAppDir: boolean, hasPagesDir: boolean, - isolatedDevBuild: boolean | undefined + isolatedDevBuild: boolean | undefined, + appDir: string | undefined, + pagesDir: string | undefined, + debugBuildPaths: { app?: string[]; pages?: string[] } | undefined ) { const typeCheckWorker = new Worker( require.resolve('../lib/verify-typescript-setup'), @@ -48,7 +50,6 @@ function verifyTypeScriptSetup( .verifyTypeScriptSetup({ dir, distDir, - intentDirs, typeCheckPreflight, tsconfigPath, disableStaticImages, @@ -56,6 +57,9 @@ function verifyTypeScriptSetup( hasAppDir, hasPagesDir, isolatedDevBuild, + appDir, + pagesDir, + debugBuildPaths, }) .then((result) => { typeCheckWorker.end() @@ -76,6 +80,7 @@ export async function startTypeChecking({ pagesDir, telemetry, appDir, + debugBuildPaths, }: { cacheDir: string config: NextConfigComplete @@ -84,6 +89,7 @@ export async function startTypeChecking({ pagesDir?: string telemetry: Telemetry appDir?: string + debugBuildPaths?: { app?: string[]; pages?: string[] } }) { const ignoreTypeScriptErrors = Boolean(config.typescript.ignoreBuildErrors) @@ -111,7 +117,6 @@ export async function startTypeChecking({ verifyTypeScriptSetup( dir, config.distDir, - [pagesDir, appDir].filter(Boolean) as string[], !ignoreTypeScriptErrors, config.typescript.tsconfigPath, config.images.disableStaticImages, @@ -119,7 +124,10 @@ export async function startTypeChecking({ config.experimental.workerThreads, !!appDir, !!pagesDir, - config.experimental.isolatedDevBuild + config.experimental.isolatedDevBuild, + appDir, + pagesDir, + debugBuildPaths ).then((resolved) => { const checkEnd = process.hrtime(typeCheckAndLintStart) return [resolved, checkEnd] as const diff --git a/packages/next/src/cli/next-test.ts b/packages/next/src/cli/next-test.ts index c6c4813d898ae..91a76a7e4a924 100644 --- a/packages/next/src/cli/next-test.ts +++ b/packages/next/src/cli/next-test.ts @@ -140,13 +140,14 @@ async function runPlaywright( const { version: typeScriptVersion } = await verifyTypeScriptSetup({ dir: baseDir, distDir: nextConfig.distDir, - intentDirs: [pagesDir, appDir].filter(Boolean) as string[], typeCheckPreflight: false, tsconfigPath: nextConfig.typescript.tsconfigPath, disableStaticImages: nextConfig.images.disableStaticImages, hasAppDir: !!appDir, hasPagesDir: !!pagesDir, isolatedDevBuild: nextConfig.experimental.isolatedDevBuild, + appDir: appDir || undefined, + pagesDir: pagesDir || undefined, }) const isUsingTypeScript = !!typeScriptVersion diff --git a/packages/next/src/cli/next-typegen.ts b/packages/next/src/cli/next-typegen.ts index 632c6a185ed62..796a5d0e7332d 100644 --- a/packages/next/src/cli/next-typegen.ts +++ b/packages/next/src/cli/next-typegen.ts @@ -57,13 +57,14 @@ const nextTypegen = async ( await verifyTypeScriptSetup({ dir: baseDir, distDir: nextConfig.distDir, - intentDirs: [pagesDir, appDir].filter(Boolean) as string[], typeCheckPreflight: false, tsconfigPath: nextConfig.typescript.tsconfigPath, disableStaticImages: nextConfig.images.disableStaticImages, hasAppDir: !!appDir, hasPagesDir: !!pagesDir, isolatedDevBuild: nextConfig.experimental.isolatedDevBuild, + appDir: appDir || undefined, + pagesDir: pagesDir || undefined, }) console.log('Generating route types...') diff --git a/packages/next/src/lib/resolve-build-paths.ts b/packages/next/src/lib/resolve-build-paths.ts index e526f147d3412..ee328129b1920 100644 --- a/packages/next/src/lib/resolve-build-paths.ts +++ b/packages/next/src/lib/resolve-build-paths.ts @@ -13,12 +13,36 @@ interface ResolvedBuildPaths { } /** - * Resolves glob patterns and explicit paths to actual file paths - * Categorizes them into App Router and Pages Router paths + * Escapes bracket expressions that correspond to existing directories. + * This allows Next.js dynamic routes like [slug] to work with glob patterns. * - * @param patterns - Array of glob patterns or explicit paths - * @param projectDir - Root project directory - * @returns Object with categorized app and page paths + * e.g., "app/blog/[slug]/** /page.tsx" → "app/blog/\[slug\]/** /page.tsx" + * (if app/blog/[slug] directory exists) + */ +function escapeExistingBrackets(pattern: string, projectDir: string): string { + // Match bracket expressions: [name], [...name], [[...name]] + const bracketRegex = /\[\[?\.\.\.[^\]]+\]?\]|\[[^\]]+\]/g + let lastIndex = 0 + let result = '' + let match: RegExpExecArray | null + + while ((match = bracketRegex.exec(pattern)) !== null) { + const pathPrefix = pattern.slice(0, match.index + match[0].length) + const exists = fs.existsSync(path.join(projectDir, pathPrefix)) + + result += pattern.slice(lastIndex, match.index) + result += exists + ? match[0].replace(/\[/g, '\\[').replace(/\]/g, '\\]') + : match[0] + lastIndex = match.index + match[0].length + } + + return result + pattern.slice(lastIndex) +} + +/** + * Resolves glob patterns and explicit paths to actual file paths. + * Categorizes them into App Router and Pages Router paths. */ export async function resolveBuildPaths( patterns: string[], @@ -29,41 +53,30 @@ export async function resolveBuildPaths( for (const pattern of patterns) { const trimmed = pattern.trim() + if (!trimmed) continue - if (!trimmed) { - continue - } - - // Detect if pattern is glob pattern (contains glob special chars) - const isGlobPattern = /[*?[\]{}!]/.test(trimmed) + try { + // Escape brackets that correspond to existing Next.js dynamic route directories + const escapedPattern = escapeExistingBrackets(trimmed, projectDir) + const matches = (await glob(escapedPattern, { + cwd: projectDir, + })) as string[] - if (isGlobPattern) { - try { - // Resolve glob pattern - const matches = (await glob(trimmed, { - cwd: projectDir, - })) as string[] - - if (matches.length === 0) { - Log.warn(`Glob pattern "${trimmed}" did not match any files`) - } + if (matches.length === 0) { + Log.warn(`Pattern "${trimmed}" did not match any files`) + } - for (const file of matches) { - // Skip directories, only process files - if (!fs.statSync(path.join(projectDir, file)).isDirectory()) { - categorizeAndAddPath(file, appPaths, pagePaths) - } + for (const file of matches) { + if (!fs.statSync(path.join(projectDir, file)).isDirectory()) { + categorizeAndAddPath(file, appPaths, pagePaths) } - } catch (error) { - throw new Error( - `Failed to resolve glob pattern "${trimmed}": ${ - isError(error) ? error.message : String(error) - }` - ) } - } else { - // Explicit path - categorize based on prefix - categorizeAndAddPath(trimmed, appPaths, pagePaths, projectDir) + } catch (error) { + throw new Error( + `Failed to resolve pattern "${trimmed}": ${ + isError(error) ? error.message : String(error) + }` + ) } } @@ -74,68 +87,23 @@ export async function resolveBuildPaths( } /** - * Categorizes a file path to either app or pages router based on its prefix, - * and normalizes it to the format expected by Next.js internal build system. - * - * The internal build system expects: - * - App router: paths with leading slash (e.g., "/page.tsx", "/dashboard/page.tsx") - * - Pages router: paths with leading slash (e.g., "/index.tsx", "/about.tsx") + * Categorizes a file path to either app or pages router based on its prefix. * * Examples: * - "app/page.tsx" → appPaths.add("/page.tsx") - * - "app/dashboard/page.tsx" → appPaths.add("/dashboard/page.tsx") * - "pages/index.tsx" → pagePaths.add("/index.tsx") - * - "pages/about.tsx" → pagePaths.add("/about.tsx") - * - "/page.tsx" → appPaths.add("/page.tsx") (already in app router format) */ function categorizeAndAddPath( filePath: string, appPaths: Set, - pagePaths: Set, - projectDir?: string + pagePaths: Set ): void { - // Normalize path separators to forward slashes (Windows compatibility) const normalized = filePath.replace(/\\/g, '/') - // Skip non-file entries (like directories without extensions) - if (normalized.endsWith('/')) { - return - } - if (normalized.startsWith('app/')) { - // App router path: remove 'app/' prefix and ensure leading slash - // "app/page.tsx" → "/page.tsx" - // "app/dashboard/page.tsx" → "/dashboard/page.tsx" - const withoutPrefix = normalized.slice(4) // Remove "app/" - appPaths.add('/' + withoutPrefix) + appPaths.add('/' + normalized.slice(4)) } else if (normalized.startsWith('pages/')) { - // Pages router path: remove 'pages/' prefix and add leading slash - // "pages/index.tsx" → "/index.tsx" - // "pages/about.tsx" → "/about.tsx" - const withoutPrefix = normalized.slice(6) // Remove "pages/" - pagePaths.add('/' + withoutPrefix) - } else if (normalized.startsWith('/')) { - // Leading slash suggests app router format (already in correct format) - // "/page.tsx" → "/page.tsx" (no change needed) - appPaths.add(normalized) - } else { - // No obvious prefix - try to detect based on file existence - if (projectDir) { - const appPath = path.join(projectDir, 'app', normalized) - const pagesPath = path.join(projectDir, 'pages', normalized) - - if (fs.existsSync(appPath)) { - appPaths.add('/' + normalized) - } else if (fs.existsSync(pagesPath)) { - pagePaths.add('/' + normalized) - } else { - // Default to pages router for paths without clear indicator - pagePaths.add('/' + normalized) - } - } else { - // Without projectDir context, default to pages router - pagePaths.add('/' + normalized) - } + pagePaths.add('/' + normalized.slice(6)) } } diff --git a/packages/next/src/lib/typescript/runTypeCheck.ts b/packages/next/src/lib/typescript/runTypeCheck.ts index 2dc8dd74eab6f..e930ac0bef0f2 100644 --- a/packages/next/src/lib/typescript/runTypeCheck.ts +++ b/packages/next/src/lib/typescript/runTypeCheck.ts @@ -16,6 +16,27 @@ export interface TypeCheckResult { incremental: boolean } +export interface TypeCheckDirs { + app?: string + pages?: string +} + +export interface DebugBuildPaths { + app?: string[] + pages?: string[] +} + +/** + * Check if a file path matches any of the debug build paths. + * Both filePath and debugPaths are resolved file paths from glob. + */ +function fileMatchesDebugPaths( + filePath: string, + debugPaths: string[] +): boolean { + return debugPaths.includes(filePath) +} + export async function runTypeCheck( typescript: typeof import('typescript'), baseDir: string, @@ -23,7 +44,9 @@ export async function runTypeCheck( tsConfigPath: string, cacheDir?: string, isAppDirEnabled?: boolean, - isolatedDevBuild?: boolean + isolatedDevBuild?: boolean, + dirs?: TypeCheckDirs, + debugBuildPaths?: DebugBuildPaths ): Promise { const effectiveConfiguration = await getTypeScriptConfiguration( typescript, @@ -52,6 +75,47 @@ export async function runTypeCheck( ) } + // Apply debug build paths filter if specified + if (dirs && debugBuildPaths) { + const { app: appDir, pages: pagesDir } = dirs + const { app: debugAppPaths, pages: debugPagePaths } = debugBuildPaths + + fileNames = fileNames.filter((fileName) => { + // Check if file is in app directory + if (appDir && fileName.startsWith(appDir + path.sep)) { + // If debugAppPaths is undefined, include all app files + if (debugAppPaths === undefined) { + return true + } + // If debugAppPaths is empty array, exclude all app files + if (debugAppPaths.length === 0) { + return false + } + // Check if file matches any of the debug paths + const relativeToApp = fileName.slice(appDir.length) + return fileMatchesDebugPaths(relativeToApp, debugAppPaths) + } + + // Check if file is in pages directory + if (pagesDir && fileName.startsWith(pagesDir + path.sep)) { + // If debugPagePaths is undefined, include all pages files + if (debugPagePaths === undefined) { + return true + } + // If debugPagePaths is empty array, exclude all pages files + if (debugPagePaths.length === 0) { + return false + } + // Check if file matches any of the debug paths + const relativeToPages = fileName.slice(pagesDir.length) + return fileMatchesDebugPaths(relativeToPages, debugPagePaths) + } + + // Keep files outside app/pages directories (shared code, etc.) + return true + }) + } + if (fileNames.length < 1) { return { hasWarnings: false, diff --git a/packages/next/src/lib/verify-typescript-setup.ts b/packages/next/src/lib/verify-typescript-setup.ts index bcd55423cd091..bec4cedd8a50d 100644 --- a/packages/next/src/lib/verify-typescript-setup.ts +++ b/packages/next/src/lib/verify-typescript-setup.ts @@ -37,28 +37,35 @@ export async function verifyTypeScriptSetup({ dir, distDir, cacheDir, - intentDirs, tsconfigPath, typeCheckPreflight, disableStaticImages, hasAppDir, hasPagesDir, isolatedDevBuild, + appDir, + pagesDir, + debugBuildPaths, }: { dir: string distDir: string cacheDir?: string tsconfigPath: string | undefined - intentDirs: string[] typeCheckPreflight: boolean disableStaticImages: boolean hasAppDir: boolean hasPagesDir: boolean isolatedDevBuild: boolean | undefined + appDir?: string + pagesDir?: string + debugBuildPaths?: { app?: string[]; pages?: string[] } }): Promise<{ result?: TypeCheckResult; version: string | null }> { const tsConfigFileName = tsconfigPath || 'tsconfig.json' const resolvedTsConfigPath = path.join(dir, tsConfigFileName) + // Construct intentDirs from appDir and pagesDir for getTypeScriptIntent + const intentDirs = [pagesDir, appDir].filter(Boolean) as string[] + try { // Check if the project uses TypeScript: const intent = await getTypeScriptIntent(dir, intentDirs, tsConfigFileName) @@ -159,7 +166,9 @@ export async function verifyTypeScriptSetup({ resolvedTsConfigPath, cacheDir, hasAppDir, - isolatedDevBuild + isolatedDevBuild, + { app: appDir, pages: pagesDir }, + debugBuildPaths ) } return { result, version: typescriptVersion } diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index af3a3a9edcd63..def090ff6d769 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -143,13 +143,14 @@ async function verifyTypeScript(opts: SetupOpts) { const verifyResult = await verifyTypeScriptSetup({ dir: opts.dir, distDir: opts.nextConfig.distDir, - intentDirs: [opts.pagesDir, opts.appDir].filter(Boolean) as string[], typeCheckPreflight: false, tsconfigPath: opts.nextConfig.typescript.tsconfigPath, disableStaticImages: opts.nextConfig.images.disableStaticImages, hasAppDir: !!opts.appDir, hasPagesDir: !!opts.pagesDir, isolatedDevBuild: opts.nextConfig.experimental.isolatedDevBuild, + appDir: opts.appDir, + pagesDir: opts.pagesDir, }) if (verifyResult.version) { diff --git a/test/production/debug-build-path/app/blog/[slug]/comments/page.tsx b/test/production/debug-build-path/app/blog/[slug]/comments/page.tsx new file mode 100644 index 0000000000000..f8695f565e1b8 --- /dev/null +++ b/test/production/debug-build-path/app/blog/[slug]/comments/page.tsx @@ -0,0 +1,8 @@ +export default async function Comments({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + return

Comments for: {slug}

+} diff --git a/test/production/debug-build-path/app/blog/[slug]/page.tsx b/test/production/debug-build-path/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000000..43ed8bc3bfb42 --- /dev/null +++ b/test/production/debug-build-path/app/blog/[slug]/page.tsx @@ -0,0 +1,8 @@ +export default async function BlogPost({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + return

Blog Post: {slug}

+} diff --git a/test/production/debug-build-path/app/with-type-error/page.tsx b/test/production/debug-build-path/app/with-type-error/page.tsx new file mode 100644 index 0000000000000..4c2bd20f52cfd --- /dev/null +++ b/test/production/debug-build-path/app/with-type-error/page.tsx @@ -0,0 +1,6 @@ +// This file has an intentional type error +const invalidValue: string = 123 + +export default function WithTypeError() { + return
WithTypeError: {invalidValue}
+} diff --git a/test/production/debug-build-path/debug-build-paths.test.ts b/test/production/debug-build-path/debug-build-paths.test.ts index 80dc15154fea4..783633711e5f1 100644 --- a/test/production/debug-build-path/debug-build-paths.test.ts +++ b/test/production/debug-build-path/debug-build-paths.test.ts @@ -40,6 +40,26 @@ describe('debug-build-paths', () => { // Should not build app routes expect(buildResult.cliOutput).not.toContain('Route (app)') }) + + it('should build dynamic route with literal [slug] path', async () => { + // Test that literal paths with brackets work without escaping + // The path is checked for file existence before being treated as glob + const buildResult = await next.build({ + args: ['--debug-build-paths', 'app/blog/[slug]/page.tsx'], + }) + expect(buildResult.exitCode).toBe(0) + expect(buildResult.cliOutput).toBeDefined() + + // Should build only the blog/[slug] route + expect(buildResult.cliOutput).toContain('Route (app)') + expect(buildResult.cliOutput).toContain('/blog/[slug]') + // Should not build other app routes + expect(buildResult.cliOutput).not.toMatch(/○ \/\n/) + expect(buildResult.cliOutput).not.toContain('○ /about') + expect(buildResult.cliOutput).not.toContain('○ /dashboard') + // Should not build pages routes + expect(buildResult.cliOutput).not.toContain('Route (pages)') + }) }) describe('glob pattern matching', () => { @@ -62,5 +82,93 @@ describe('debug-build-paths', () => { expect(buildResult.cliOutput).not.toContain('○ /about') expect(buildResult.cliOutput).not.toContain('○ /dashboard') }) + + it('should match nested routes with app/blog/**/page.tsx pattern', async () => { + const buildResult = await next.build({ + args: ['--debug-build-paths', 'app/blog/**/page.tsx'], + }) + expect(buildResult.exitCode).toBe(0) + expect(buildResult.cliOutput).toBeDefined() + + // Should build the blog route + expect(buildResult.cliOutput).toContain('Route (app)') + expect(buildResult.cliOutput).toContain('/blog/[slug]') + // Should not build other app routes (check for exact route, not substring) + expect(buildResult.cliOutput).not.toMatch(/○ \/\n/) + expect(buildResult.cliOutput).not.toContain('○ /about') + expect(buildResult.cliOutput).not.toContain('○ /dashboard') + // Should not build pages routes + expect(buildResult.cliOutput).not.toContain('Route (pages)') + }) + + it('should match hybrid pattern with literal [slug] and glob **', async () => { + // Test pattern: app/blog/[slug]/**/page.tsx + // [slug] should be treated as literal directory (exists on disk) + // ** should be treated as glob (match any depth) + const buildResult = await next.build({ + args: ['--debug-build-paths', 'app/blog/[slug]/**/page.tsx'], + }) + expect(buildResult.exitCode).toBe(0) + expect(buildResult.cliOutput).toBeDefined() + + // Should build both blog/[slug] and blog/[slug]/comments routes + expect(buildResult.cliOutput).toContain('Route (app)') + expect(buildResult.cliOutput).toContain('/blog/[slug]') + expect(buildResult.cliOutput).toContain('/blog/[slug]/comments') + // Should not build other app routes + expect(buildResult.cliOutput).not.toMatch(/○ \/\n/) + expect(buildResult.cliOutput).not.toContain('○ /about') + expect(buildResult.cliOutput).not.toContain('○ /dashboard') + // Should not build pages routes + expect(buildResult.cliOutput).not.toContain('Route (pages)') + }) + + it('should match multiple app routes with explicit patterns', async () => { + const buildResult = await next.build({ + args: [ + '--debug-build-paths', + 'app/page.tsx,app/about/page.tsx,app/dashboard/page.tsx,app/blog/**/page.tsx', + ], + }) + expect(buildResult.exitCode).toBe(0) + expect(buildResult.cliOutput).toBeDefined() + + // Should build specified app routes + expect(buildResult.cliOutput).toContain('Route (app)') + expect(buildResult.cliOutput).toContain('○ /') + expect(buildResult.cliOutput).toContain('○ /about') + expect(buildResult.cliOutput).toContain('○ /dashboard') + expect(buildResult.cliOutput).toContain('/blog/[slug]') + // Should not build routes not specified + expect(buildResult.cliOutput).not.toContain('/with-type-error') + // Should not build pages routes + expect(buildResult.cliOutput).not.toContain('Route (pages)') + }) + }) + + describe('typechecking with debug-build-paths', () => { + it('should skip typechecking for excluded app routes', async () => { + // Build only pages routes, excluding app routes with type error + const buildResult = await next.build({ + args: ['--debug-build-paths', 'pages/foo.tsx'], + }) + // Build should succeed because the file with type error is not checked + expect(buildResult.exitCode).toBe(0) + expect(buildResult.cliOutput).toContain('Route (pages)') + expect(buildResult.cliOutput).toContain('○ /foo') + // Should not include app routes + expect(buildResult.cliOutput).not.toContain('Route (app)') + }) + + it('should fail typechecking when route with type error is included', async () => { + // Build all app routes including the one with type error + const buildResult = await next.build({ + args: ['--debug-build-paths', 'app/**/page.tsx'], + }) + // Build should fail due to type error in with-type-error/page.tsx + expect(buildResult.exitCode).toBe(1) + expect(buildResult.cliOutput).toContain('Type error') + expect(buildResult.cliOutput).toContain('with-type-error/page.tsx') + }) }) })