Skip to content

Commit 7b7cee3

Browse files
authored
Error when app pages do not match any route (#97401)
## What? This reports an error under `experimental.strictRouteMatching` when a `page.tsx` is left out of every retained matcher. A page can lose its own broad catchall matcher and still be valid if it participates in a more specific matcher, so this checks the final matcher set instead of treating every pruned candidate as an error. Both Webpack and Turbopack report every unmatched page in development and production builds. ## Why? Pages that are never selected by any complete loader tree are unreachable by construction. This usually means parallel slots define incompatible paths without matching pages or defaults, and silently ignoring those files makes the project look like it has routes that cannot actually exist. For example: ```text app/disagreeing-slots/ ├── layout.tsx ├── @first/foo/page.tsx ├── @second/bar/page.tsx └── [...slug]/page.tsx ``` Every candidate is incomplete: `/foo` has no match in `@second`, `/bar` has no match in `@first`, and URLs handled only by the catchall have no matching page or applicable default in either named slot. Strict route matching prunes all three matchers, and this change reports all three page files instead of silently leaving them out of the build. The same validation catches a real `children` branch that only becomes incomplete below its page: ```text app/declared-children/ ├── layout.tsx ├── page.tsx └── @panel/ ├── default.tsx └── details/page.tsx ``` `/declared-children` is valid, but `/declared-children/details` only has a page in `@panel`. Because the declared `children` slot has no matching page or default at `/details`, that matcher is pruned and `@panel/details/page.tsx` is reported as unreachable. The root `page.tsx` is still part of the valid `/declared-children` matcher and is not reported. A pruned matcher is not automatically an error. If `@slot/[...parts]/page.tsx` is combined into retained `/foo` and `/bar` matchers, that page still participates in routes and remains valid. ## How? Webpack snapshots the authored app page paths before pruning and compares them with the pages used by the retained app paths. Turbopack collects authored page source files from the directory tree and compares them with the files referenced by the final accepted loader trees. The tests cover disagreeing named slots, declared `children`, optional catchalls, route groups, nested slots, and the valid case where a pruned catchall is still used by more specific matchers. ## Verification - `pnpm test-start-turbo test/e2e/app-dir/unmatched-app-pages/unmatched-app-pages.test.ts` - `pnpm test-start-webpack test/e2e/app-dir/unmatched-app-pages/unmatched-app-pages.test.ts` - `pnpm test-dev-turbo test/e2e/app-dir/unmatched-app-pages/unmatched-app-pages.test.ts` - `pnpm test-dev-webpack test/e2e/app-dir/unmatched-app-pages/unmatched-app-pages.test.ts` - `pnpm test-start-turbo test/e2e/app-dir/parallel-routes-pruned-matchers/parallel-routes-pruned-matchers.test.ts` - `pnpm test-start-webpack test/e2e/app-dir/parallel-routes-pruned-matchers/parallel-routes-pruned-matchers.test.ts` <!-- NEXT_JS_LLM -->
1 parent ec62d6a commit 7b7cee3

62 files changed

Lines changed: 621 additions & 106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/next-core/src/app_structure.rs‎

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,16 @@ pub struct AppPageLoaderTree {
486486
}
487487

488488
impl AppPageLoaderTree {
489+
fn collect_page_files(&self, pages: &mut FxIndexSet<FileSystemPath>) {
490+
if let Some(page) = &self.modules.page {
491+
pages.insert(page.clone());
492+
}
493+
494+
for tree in self.parallel_routes.values() {
495+
tree.collect_page_files(pages);
496+
}
497+
}
498+
489499
/// Returns true if there's a page match in this loader tree.
490500
pub fn has_page(&self) -> bool {
491501
if &*self.segment == "__PAGE__" {
@@ -613,6 +623,7 @@ pub enum Entrypoint {
613623
AppPage {
614624
pages: Vec<AppPage>,
615625
loader_tree: ResolvedVc<AppPageLoaderTree>,
626+
participating_page_files: Vec<FileSystemPath>,
616627
root_params: ResolvedVc<RootParamVecOption>,
617628
},
618629
AppRoute {
@@ -709,6 +720,7 @@ fn add_app_page(
709720
result: &mut FxIndexMap<AppPath, Entrypoint>,
710721
page: AppPage,
711722
loader_tree: ResolvedVc<AppPageLoaderTree>,
723+
participating_page_files: Vec<FileSystemPath>,
712724
root_params: ResolvedVc<RootParamVecOption>,
713725
) {
714726
let mut e = match result.entry(page.clone().into()) {
@@ -717,6 +729,7 @@ fn add_app_page(
717729
e.insert(Entrypoint::AppPage {
718730
pages: vec![page],
719731
loader_tree,
732+
participating_page_files,
720733
root_params,
721734
});
722735
return;
@@ -742,6 +755,7 @@ fn add_app_page(
742755

743756
let Entrypoint::AppPage {
744757
pages: stored_pages,
758+
participating_page_files: stored_page_files,
745759
..
746760
} = e.get_mut()
747761
else {
@@ -750,6 +764,11 @@ fn add_app_page(
750764

751765
stored_pages.push(page);
752766
stored_pages.sort();
767+
for page_file in participating_page_files {
768+
if !stored_page_files.contains(&page_file) {
769+
stored_page_files.push(page_file);
770+
}
771+
}
753772
}
754773
Entrypoint::AppRoute {
755774
page: existing_page,
@@ -972,9 +991,98 @@ async fn directory_tree_to_entrypoints(
972991
);
973992
}
974993
}
994+
995+
let mut authored_pages = FxIndexSet::default();
996+
collect_authored_page_files(&plain_tree, &mut authored_pages);
997+
998+
let mut matched_pages = FxIndexSet::default();
999+
for entrypoint in retained_entrypoints.values() {
1000+
if let Entrypoint::AppPage {
1001+
participating_page_files,
1002+
..
1003+
} = entrypoint
1004+
{
1005+
matched_pages.extend(participating_page_files.iter().cloned());
1006+
}
1007+
}
1008+
1009+
let unmatched_pages = authored_pages
1010+
.into_iter()
1011+
.filter(|page| !matched_pages.contains(page))
1012+
.collect::<Vec<_>>();
1013+
if !unmatched_pages.is_empty() {
1014+
UnmatchedAppPagesIssue {
1015+
app_dir,
1016+
pages: unmatched_pages,
1017+
}
1018+
.resolved_cell()
1019+
.emit();
1020+
}
9751021
Ok(Vc::cell(retained_entrypoints))
9761022
}
9771023

1024+
fn collect_authored_page_files(
1025+
directory_tree: &PlainDirectoryTree,
1026+
pages: &mut FxIndexSet<FileSystemPath>,
1027+
) {
1028+
if let Some(page) = &directory_tree.modules.page {
1029+
pages.insert(page.clone());
1030+
}
1031+
1032+
for subdirectory in directory_tree.subdirectories.values() {
1033+
collect_authored_page_files(subdirectory, pages);
1034+
}
1035+
}
1036+
1037+
#[turbo_tasks::value]
1038+
struct UnmatchedAppPagesIssue {
1039+
app_dir: FileSystemPath,
1040+
pages: Vec<FileSystemPath>,
1041+
}
1042+
1043+
#[async_trait]
1044+
#[turbo_tasks::value_impl]
1045+
impl Issue for UnmatchedAppPagesIssue {
1046+
async fn file_path(&self) -> Result<FileSystemPath> {
1047+
Ok(self.pages[0].clone())
1048+
}
1049+
1050+
fn stage(&self) -> IssueStage {
1051+
IssueStage::AppStructure
1052+
}
1053+
1054+
fn severity(&self) -> IssueSeverity {
1055+
IssueSeverity::Error
1056+
}
1057+
1058+
async fn title(&self) -> Result<StyledString> {
1059+
Ok(StyledString::Text(rcstr!("Unmatched app pages")))
1060+
}
1061+
1062+
async fn description(&self) -> Result<Option<StyledString>> {
1063+
let page_paths = self
1064+
.pages
1065+
.iter()
1066+
.map(|page| {
1067+
let relative_path = self
1068+
.app_dir
1069+
.get_path_to(page)
1070+
.expect("authored page should be within the app directory");
1071+
format!("- app/{}", relative_path)
1072+
})
1073+
.collect::<Vec<_>>()
1074+
.join("\n");
1075+
Ok(Some(StyledString::Text(
1076+
format!(
1077+
"The following page files do not match any complete route:\n{page_paths}\n\nEvery \
1078+
page must be part of at least one complete route. Add matching pages or default \
1079+
files for the sibling parallel route slots, or remove the unreachable pages."
1080+
)
1081+
.into(),
1082+
)))
1083+
}
1084+
}
1085+
9781086
#[turbo_tasks::value]
9791087
struct DuplicateParallelRouteIssue {
9801088
app_dir: FileSystemPath,
@@ -1852,11 +1960,18 @@ async fn directory_tree_to_entrypoints_internal_untraced(
18521960
)
18531961
.await?;
18541962

1963+
let loader_tree =
1964+
loader_tree.context("loader tree should be created for a page/default")?;
1965+
let mut participating_page_files = FxIndexSet::default();
1966+
loader_tree
1967+
.await?
1968+
.collect_page_files(&mut participating_page_files);
18551969
add_app_page(
18561970
app_dir.clone(),
18571971
&mut result,
18581972
app_page.complete(PageType::Page)?,
1859-
loader_tree.context("loader tree should be created for a page/default")?,
1973+
loader_tree,
1974+
participating_page_files.into_iter().collect(),
18601975
root_params,
18611976
);
18621977
}
@@ -2052,6 +2167,7 @@ async fn directory_tree_to_entrypoints_internal_untraced(
20522167
&mut result,
20532168
app_page,
20542169
not_found_tree,
2170+
Vec::new(),
20552171
root_params,
20562172
);
20572173
}
@@ -2100,6 +2216,7 @@ async fn directory_tree_to_entrypoints_internal_untraced(
21002216
&mut result,
21012217
app_global_error_page,
21022218
global_error_tree,
2219+
Vec::new(),
21032220
root_params,
21042221
);
21052222
}
@@ -2178,18 +2295,27 @@ async fn directory_tree_to_entrypoints_internal_untraced(
21782295
Entrypoint::AppPage {
21792296
pages,
21802297
loader_tree: _,
2298+
participating_page_files: child_participating_page_files,
21812299
root_params,
21822300
} => {
21832301
for page in pages {
21842302
let loader_tree = *loader_trees[i].await?;
21852303
i += 1;
21862304

2305+
let loader_tree = loader_tree
2306+
.context("loader tree should be created for a page/default")?;
2307+
let mut participating_page_files = FxIndexSet::default();
2308+
loader_tree
2309+
.await?
2310+
.collect_page_files(&mut participating_page_files);
2311+
participating_page_files
2312+
.extend(child_participating_page_files.iter().cloned());
21872313
add_app_page(
21882314
app_dir.clone(),
21892315
&mut result,
21902316
page.clone(),
2191-
loader_tree
2192-
.context("loader tree should be created for a page/default")?,
2317+
loader_tree,
2318+
participating_page_files.into_iter().collect(),
21932319
*root_params,
21942320
);
21952321
}

‎packages/next/src/build/entries.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { ProxyConfig, ProxyMatcher } from './analysis/get-page-static-info'
88
import type { LoadedEnvFiles } from '@next/env'
99
import type { AppLoaderOptions } from './webpack/loaders/next-app-loader'
1010

11-
import { dirname, posix, join, normalize } from 'path'
11+
import { dirname, posix, join, normalize, relative } from 'path'
1212
import { stringify } from 'querystring'
1313
import {
1414
PAGES_DIR_ALIAS,
@@ -58,6 +58,7 @@ import { normalizeCatchAllRoutes } from './normalize-catchall-routes'
5858
import type { PageExtensions } from './page-extensions-type'
5959
import type { MappedPages } from './build-context'
6060
import { PAGE_TYPES } from '../lib/page-types'
61+
import { UnmatchedAppPagesError } from '../shared/lib/errors/unmatched-app-pages-error'
6162

6263
type ObjectValue<T> = T extends { [key: string]: infer V } ? V : never
6364
import { getStaticInfoIncludingLayouts } from './get-static-info-including-layouts'
@@ -409,23 +410,45 @@ export async function createEntrypoints(
409410

410411
let appPathsPerRoute: Record<string, string[]> = {}
411412
if (appDir && appPaths) {
413+
const appPageFiles = new Map<string, string>()
412414
for (const pathname in appPaths) {
413415
const normalizedPath = normalizeAppPath(pathname)
414416
const actualPath = appPaths[pathname]
415417
if (!appPathsPerRoute[normalizedPath]) {
416418
appPathsPerRoute[normalizedPath] = []
417419
}
418-
appPathsPerRoute[normalizedPath].push(
419-
// TODO-APP: refactor to pass the page path from createPagesMapping instead.
420-
getPageFromPath(actualPath, pageExtensions).replace(APP_DIR_ALIAS, '')
420+
// TODO-APP: refactor to pass the page path from createPagesMapping instead.
421+
const appPath = getPageFromPath(actualPath, pageExtensions).replace(
422+
APP_DIR_ALIAS,
423+
''
421424
)
425+
appPathsPerRoute[normalizedPath].push(appPath)
426+
appPageFiles.set(appPath, actualPath)
422427
}
423428

424429
// TODO: find a better place to do this
425-
normalizeCatchAllRoutes(appPathsPerRoute, {
430+
const unmatchedAppPages = normalizeCatchAllRoutes(appPathsPerRoute, {
426431
strictRouteMatching: config.experimental.strictRouteMatching,
427432
defaultAppPaths: Object.keys(appDefaultPaths ?? {}),
428433
})
434+
if (unmatchedAppPages.length > 0) {
435+
throw new UnmatchedAppPagesError(
436+
unmatchedAppPages.map((appPath) => {
437+
const absolutePagePath = appPageFiles.get(appPath)
438+
if (!absolutePagePath) return appPath
439+
440+
return relative(
441+
rootDir,
442+
getPageFilePath({
443+
absolutePagePath,
444+
pagesDir,
445+
appDir,
446+
rootDir,
447+
})
448+
)
449+
})
450+
)
451+
}
429452

430453
// Make sure to sort parallel routes to make the result deterministic.
431454
appPathsPerRoute = Object.fromEntries(

‎packages/next/src/build/normalize-catchall-routes.test.ts‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ describe('normalizeCatchallRoutes', () => {
221221
})
222222

223223
describe('strictRouteMatching pruning', () => {
224+
it('does not report pages when strict route matching is disabled', () => {
225+
const appPaths = {
226+
'/[...slug]': ['/[...slug]/page'],
227+
'/foo': ['/@first/foo/page'],
228+
'/bar': ['/@second/bar/page'],
229+
}
230+
231+
const unmatchedAppPages = normalizeCatchAllRoutes(appPaths)
232+
233+
expect(unmatchedAppPages).toEqual([])
234+
})
235+
224236
it('does not require an implicit children slot with no routes', () => {
225237
const appPaths = {
226238
'/[...slug]': ['/@catchall/[...slug]/page'],
@@ -299,9 +311,16 @@ describe('normalizeCatchallRoutes', () => {
299311
'/bar': ['/@second/bar/page'],
300312
}
301313

302-
normalizeCatchAllRoutes(appPaths, { strictRouteMatching: true })
314+
const unmatchedAppPages = normalizeCatchAllRoutes(appPaths, {
315+
strictRouteMatching: true,
316+
})
303317

304318
expect(appPaths).toEqual({})
319+
expect(unmatchedAppPages).toEqual([
320+
'/@first/foo/page',
321+
'/@second/bar/page',
322+
'/[...slug]/page',
323+
])
305324
})
306325

307326
it('prunes an optional children catch-all when a named slot is missing', () => {
@@ -310,11 +329,14 @@ describe('normalizeCatchallRoutes', () => {
310329
'/specific': ['/specific/page', '/@slot/specific/page'],
311330
}
312331

313-
normalizeCatchAllRoutes(appPaths, { strictRouteMatching: true })
332+
const unmatchedAppPages = normalizeCatchAllRoutes(appPaths, {
333+
strictRouteMatching: true,
334+
})
314335

315336
expect(appPaths).toEqual({
316337
'/specific': ['/specific/page', '/@slot/specific/page'],
317338
})
339+
expect(unmatchedAppPages).toEqual(['/[[...slug]]/page'])
318340
})
319341

320342
it('prunes a catch-all with an incomplete nested parallel route', () => {
@@ -502,9 +524,29 @@ describe('normalizeCatchallRoutes', () => {
502524
'/photo/specific': ['/@modal/(.)photo/@specific/specific/page'],
503525
}
504526

505-
normalizeCatchAllRoutes(appPaths, { strictRouteMatching: true })
527+
const unmatchedAppPages = normalizeCatchAllRoutes(appPaths, {
528+
strictRouteMatching: true,
529+
})
506530

507531
expect(appPaths).toEqual({})
532+
expect(unmatchedAppPages).toEqual([
533+
'/@modal/(.)photo/@catchall/[...slug]/page',
534+
'/@modal/(.)photo/@specific/specific/page',
535+
])
536+
})
537+
538+
it('does not report a catch-all page used by more specific matchers', () => {
539+
const appPaths = {
540+
'/foo': ['/foo/page'],
541+
'/bar': ['/bar/page'],
542+
'/[...parts]': ['/@slot/[...parts]/page'],
543+
}
544+
545+
const unmatchedAppPages = normalizeCatchAllRoutes(appPaths, {
546+
strictRouteMatching: true,
547+
})
548+
549+
expect(unmatchedAppPages).toEqual([])
508550
})
509551
})
510552
})

‎packages/next/src/build/normalize-catchall-routes.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ import {
66
export function normalizeCatchAllRoutes(
77
appPaths: Record<string, string[]>,
88
options: NormalizeCatchAllRoutesOptions = {}
9-
) {
9+
): string[] {
1010
return normalizeCatchAllRoutesInternal(appPaths, undefined, options)
1111
}

0 commit comments

Comments
 (0)