Skip to main content

next_core/
app_structure.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use bincode::{Decode, Encode};
6use indexmap::map::{Entry, OccupiedEntry};
7use rustc_hash::FxHashMap;
8use tracing::Instrument;
9use turbo_rcstr::{RcStr, rcstr};
10use turbo_tasks::{
11    FxIndexMap, FxIndexSet, NonLocalValue, ResolvedVc, TryJoinIterExt, ValueDefault,
12    ValueToStringRef, Vc, debug::ValueDebugFormat, fxindexmap, turbobail,
13};
14use turbo_tasks_fs::{DirectoryContent, DirectoryEntry, FileSystemEntryType, FileSystemPath};
15use turbopack_core::issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString};
16
17use crate::{
18    mode::NextMode,
19    next_app::{
20        AppPage, AppPath, PageSegment, PageType,
21        metadata::{
22            GlobalMetadataFileMatch, MetadataFileMatch, match_global_metadata_file,
23            match_local_metadata_file, normalize_metadata_route,
24        },
25    },
26    next_import_map::get_next_package,
27};
28
29// Next.js ignores underscores for routes but you can use %5f to still serve an underscored
30// route.
31fn normalize_underscore(string: &str) -> String {
32    string.replace("%5F", "_")
33}
34
35/// A final route in the app directory.
36#[turbo_tasks::value]
37#[derive(Default, Debug, Clone)]
38pub struct AppDirModules {
39    pub page: Option<FileSystemPath>,
40    pub layout: Option<FileSystemPath>,
41    pub error: Option<FileSystemPath>,
42    pub global_error: Option<FileSystemPath>,
43    pub global_not_found: Option<FileSystemPath>,
44    pub loading: Option<FileSystemPath>,
45    pub template: Option<FileSystemPath>,
46    pub forbidden: Option<FileSystemPath>,
47    pub unauthorized: Option<FileSystemPath>,
48    pub not_found: Option<FileSystemPath>,
49    pub default: Option<FileSystemPath>,
50    pub route: Option<FileSystemPath>,
51    pub metadata: Metadata,
52}
53
54impl AppDirModules {
55    fn without_leaves(&self) -> Self {
56        Self {
57            page: None,
58            layout: self.layout.clone(),
59            error: self.error.clone(),
60            global_error: self.global_error.clone(),
61            global_not_found: self.global_not_found.clone(),
62            loading: self.loading.clone(),
63            template: self.template.clone(),
64            not_found: self.not_found.clone(),
65            forbidden: self.forbidden.clone(),
66            unauthorized: self.unauthorized.clone(),
67            default: None,
68            route: None,
69            metadata: self.metadata.clone(),
70        }
71    }
72}
73
74/// A single metadata file plus an optional "alt" text file.
75#[derive(Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]
76pub enum MetadataWithAltItem {
77    Static {
78        path: FileSystemPath,
79        alt_path: Option<FileSystemPath>,
80    },
81    Dynamic {
82        path: FileSystemPath,
83    },
84}
85
86/// A single metadata file.
87#[turbo_tasks::task_input]
88#[derive(Clone, Debug, Hash, PartialEq, Eq, Encode, Decode)]
89pub enum MetadataItem {
90    Static { path: FileSystemPath },
91    Dynamic { path: FileSystemPath },
92}
93
94#[turbo_tasks::function]
95pub async fn get_metadata_route_name(meta: MetadataItem) -> Result<Vc<RcStr>> {
96    Ok(match meta {
97        MetadataItem::Static { path } => Vc::cell(path.file_name().into()),
98        MetadataItem::Dynamic { path } => {
99            let Some(stem) = path.file_stem() else {
100                turbobail!("unable to resolve file stem for metadata item at {path}");
101            };
102
103            match stem {
104                "manifest" => Vc::cell(rcstr!("manifest.webmanifest")),
105                _ => Vc::cell(RcStr::from(stem)),
106            }
107        }
108    })
109}
110
111impl MetadataItem {
112    pub fn into_path(self) -> FileSystemPath {
113        match self {
114            MetadataItem::Static { path } => path,
115            MetadataItem::Dynamic { path } => path,
116        }
117    }
118}
119
120impl From<MetadataWithAltItem> for MetadataItem {
121    fn from(value: MetadataWithAltItem) -> Self {
122        match value {
123            MetadataWithAltItem::Static { path, .. } => MetadataItem::Static { path },
124            MetadataWithAltItem::Dynamic { path } => MetadataItem::Dynamic { path },
125        }
126    }
127}
128
129/// Metadata file that can be placed in any segment of the app directory.
130#[derive(Default, Clone, Debug, PartialEq, Eq, NonLocalValue, Encode, Decode)]
131pub struct Metadata {
132    pub icon: Vec<MetadataWithAltItem>,
133    pub apple: Vec<MetadataWithAltItem>,
134    pub twitter: Vec<MetadataWithAltItem>,
135    pub open_graph: Vec<MetadataWithAltItem>,
136    pub sitemap: Option<MetadataItem>,
137    // The page indicates where the metadata is defined and captured.
138    // The steps for capturing metadata (get_directory_tree) and constructing
139    // LoaderTree (directory_tree_to_entrypoints) is separated,
140    // and child loader tree can trickle down metadata when clone / merge components calculates
141    // the actual path incorrectly with fillMetadataSegment.
142    //
143    // This is only being used for the static metadata files.
144    pub base_page: Option<AppPage>,
145}
146
147impl Metadata {
148    pub fn is_empty(&self) -> bool {
149        let Metadata {
150            icon,
151            apple,
152            twitter,
153            open_graph,
154            sitemap,
155            base_page: _,
156        } = self;
157        icon.is_empty()
158            && apple.is_empty()
159            && twitter.is_empty()
160            && open_graph.is_empty()
161            && sitemap.is_none()
162    }
163}
164
165/// Metadata files that can be placed in the root of the app directory.
166#[turbo_tasks::value]
167#[derive(Default, Clone, Debug)]
168pub struct GlobalMetadata {
169    pub favicon: Option<MetadataItem>,
170    pub robots: Option<MetadataItem>,
171    pub manifest: Option<MetadataItem>,
172}
173
174impl GlobalMetadata {
175    pub fn is_empty(&self) -> bool {
176        let GlobalMetadata {
177            favicon,
178            robots,
179            manifest,
180        } = self;
181        favicon.is_none() && robots.is_none() && manifest.is_none()
182    }
183}
184
185#[turbo_tasks::value]
186#[derive(Debug)]
187pub struct DirectoryTree {
188    /// key is e.g. "dashboard", "(dashboard)", "@slot"
189    pub subdirectories: BTreeMap<RcStr, ResolvedVc<DirectoryTree>>,
190    pub modules: AppDirModules,
191}
192
193#[turbo_tasks::value]
194#[derive(Clone, Debug)]
195struct PlainDirectoryTree {
196    /// key is e.g. "dashboard", "(dashboard)", "@slot"
197    pub subdirectories: BTreeMap<RcStr, PlainDirectoryTree>,
198    pub modules: AppDirModules,
199    /// Whether this subtree contains a page or default, including inside parallel routes.
200    pub contains_page_or_default: bool,
201    /// Flattened URL tree with route groups and parallel routes transparent.
202    pub url_tree: UrlSegmentTree,
203}
204
205/// A tree representing the URL segment structure, with route groups and parallel
206/// routes flattened out. This provides a unified view of all segments at each URL
207/// level, regardless of which route group they're defined in.
208///
209/// For example, given this directory structure:
210///
211/// ```text
212/// app/
213/// ├── (group1)/
214/// │   └── products/
215/// │       └── sale/
216/// └── (group2)/
217///     └── products/
218///         └── [id]/
219/// ```
220///
221/// The UrlSegmentTree would be:
222///
223/// ```text
224/// (root)
225/// └── products/
226///     ├── sale/
227///     └── [id]/
228/// ```
229///
230/// This makes it easy to find all siblings at a given URL level.
231#[derive(Clone, Debug, Default, PartialEq, Eq, NonLocalValue, Encode, Decode)]
232struct UrlSegmentTree {
233    pub children: BTreeMap<RcStr, UrlSegmentTree>,
234}
235
236impl UrlSegmentTree {
237    fn static_children(&self) -> Vec<RcStr> {
238        self.children
239            .keys()
240            .filter(|name| !is_dynamic_segment(name))
241            .cloned()
242            .collect()
243    }
244
245    fn get_child(&self, segment: &str) -> Option<&UrlSegmentTree> {
246        self.children.get(segment)
247    }
248}
249
250fn build_url_segment_tree_from_subdirs(
251    subdirs: &BTreeMap<RcStr, PlainDirectoryTree>,
252) -> UrlSegmentTree {
253    let mut result = UrlSegmentTree::default();
254    build_url_segment_tree_recursive(subdirs, &mut result);
255    result
256}
257
258/// Recursively builds the URL segment tree by accumulating children at each
259/// URL level. Segments from different route groups that share the same URL path
260/// are merged together.
261///
262/// Example: `(group1)/products/sale/` and `(group2)/products/[id]/` both
263/// contribute to a single `products/` node containing both `sale/` and `[id]/`.
264fn build_url_segment_tree_recursive(
265    subdirs: &BTreeMap<RcStr, PlainDirectoryTree>,
266    result: &mut UrlSegmentTree,
267) {
268    for (name, subtree) in subdirs {
269        if is_url_transparent_segment(name) {
270            // Transparent segments (route groups, parallel routes) don't create
271            // a new URL level. Recurse with the same `result` so their children
272            // are accumulated at the current level.
273            build_url_segment_tree_recursive(&subtree.subdirectories, result);
274        } else {
275            // Non-transparent segments create a new URL level. Get or create a
276            // child node for this segment, then recurse to accumulate its children.
277            // Using `or_default()` ensures that if this segment was already added
278            // from a different route group, we merge into it rather than replace.
279            let child = result.children.entry(name.clone()).or_default();
280            build_url_segment_tree_recursive(&subtree.subdirectories, child);
281        }
282    }
283}
284
285#[turbo_tasks::value_impl]
286impl DirectoryTree {
287    #[turbo_tasks::function]
288    pub async fn into_plain(&self) -> Result<Vc<PlainDirectoryTree>> {
289        let mut subdirectories = BTreeMap::new();
290
291        for (name, subdirectory) in &self.subdirectories {
292            subdirectories.insert(name.clone(), subdirectory.into_plain().owned().await?);
293        }
294
295        let contains_page_or_default = self.modules.page.is_some()
296            || self.modules.default.is_some()
297            || subdirectories
298                .values()
299                .any(|subdirectory| subdirectory.contains_page_or_default);
300        let url_tree = build_url_segment_tree_from_subdirs(&subdirectories);
301
302        Ok(PlainDirectoryTree {
303            subdirectories,
304            modules: self.modules.clone(),
305            contains_page_or_default,
306            url_tree,
307        }
308        .cell())
309    }
310}
311
312#[turbo_tasks::value(transparent)]
313pub struct OptionAppDir(Option<FileSystemPath>);
314
315/// Finds and returns the [DirectoryTree] of the app directory if existing.
316#[turbo_tasks::function]
317pub async fn find_app_dir(project_path: FileSystemPath) -> Result<Vc<OptionAppDir>> {
318    let app = project_path.join("app")?;
319    let src_app = project_path.join("src/app")?;
320    let app_dir = if *app.get_type().await? == FileSystemEntryType::Directory {
321        app
322    } else if *src_app.get_type().await? == FileSystemEntryType::Directory {
323        src_app
324    } else {
325        return Ok(Vc::cell(None));
326    };
327
328    Ok(Vc::cell(Some(app_dir)))
329}
330
331#[turbo_tasks::function]
332async fn get_directory_tree(
333    dir: FileSystemPath,
334    page_extensions: Vc<Vec<RcStr>>,
335) -> Result<Vc<DirectoryTree>> {
336    let span = tracing::info_span!(
337        "read app directory tree",
338        name = display(dir.to_string_ref().await?)
339    );
340    get_directory_tree_internal(dir, page_extensions)
341        .instrument(span)
342        .await
343}
344
345async fn get_directory_tree_internal(
346    dir: FileSystemPath,
347    page_extensions: Vc<Vec<RcStr>>,
348) -> Result<Vc<DirectoryTree>> {
349    let DirectoryContent::Entries(entries) = &*dir.read_dir().await? else {
350        // the file watcher might invalidate things in the wrong order,
351        // and we have to account for the eventual consistency of turbo-tasks
352        // so we just return an empty tree here.
353        return Ok(DirectoryTree {
354            subdirectories: Default::default(),
355            modules: AppDirModules::default(),
356        }
357        .cell());
358    };
359    let page_extensions_value = page_extensions.await?;
360
361    let mut subdirectories = BTreeMap::new();
362    let mut modules = AppDirModules::default();
363
364    let mut metadata_icon = Vec::new();
365    let mut metadata_apple = Vec::new();
366    let mut metadata_open_graph = Vec::new();
367    let mut metadata_twitter = Vec::new();
368
369    for (basename, entry) in entries {
370        let entry = entry.clone().resolve_symlink().await?;
371        match entry {
372            DirectoryEntry::File(file) => {
373                // Do not process .d.ts files as routes
374                if basename.ends_with(".d.ts") {
375                    continue;
376                }
377                if let Some((stem, ext)) = basename.split_once('.')
378                    && page_extensions_value.iter().any(|e| e == ext)
379                {
380                    match stem {
381                        "page" => modules.page = Some(file.clone()),
382                        "layout" => modules.layout = Some(file.clone()),
383                        "error" => modules.error = Some(file.clone()),
384                        "global-error" => modules.global_error = Some(file.clone()),
385                        "global-not-found" => modules.global_not_found = Some(file.clone()),
386                        "loading" => modules.loading = Some(file.clone()),
387                        "template" => modules.template = Some(file.clone()),
388                        "forbidden" => modules.forbidden = Some(file.clone()),
389                        "unauthorized" => modules.unauthorized = Some(file.clone()),
390                        "not-found" => modules.not_found = Some(file.clone()),
391                        "default" => modules.default = Some(file.clone()),
392                        "route" => modules.route = Some(file.clone()),
393                        _ => {}
394                    }
395                }
396
397                let Some(MetadataFileMatch {
398                    metadata_type,
399                    number,
400                    dynamic,
401                }) = match_local_metadata_file(basename.as_str(), &page_extensions_value)
402                else {
403                    continue;
404                };
405
406                let entry = match metadata_type {
407                    "icon" => &mut metadata_icon,
408                    "apple-icon" => &mut metadata_apple,
409                    "twitter-image" => &mut metadata_twitter,
410                    "opengraph-image" => &mut metadata_open_graph,
411                    "sitemap" => {
412                        if dynamic {
413                            modules.metadata.sitemap = Some(MetadataItem::Dynamic { path: file });
414                        } else {
415                            modules.metadata.sitemap = Some(MetadataItem::Static { path: file });
416                        }
417                        continue;
418                    }
419                    _ => continue,
420                };
421
422                if dynamic {
423                    entry.push((number, MetadataWithAltItem::Dynamic { path: file }));
424                    continue;
425                }
426
427                let file_name = file.file_name();
428                let basename = file_name
429                    .rsplit_once('.')
430                    .map_or(file_name, |(basename, _)| basename);
431                let alt_path = file.parent().join(&format!("{basename}.alt.txt"))?;
432                let alt_path = matches!(&*alt_path.get_type().await?, FileSystemEntryType::File)
433                    .then_some(alt_path);
434
435                entry.push((
436                    number,
437                    MetadataWithAltItem::Static {
438                        path: file,
439                        alt_path,
440                    },
441                ));
442            }
443            DirectoryEntry::Directory(dir)
444                // appDir ignores paths starting with an underscore
445                if !basename.starts_with('_') => {
446                    let result = get_directory_tree(dir.clone(), page_extensions)
447                        .to_resolved()
448                        .await?;
449                    subdirectories.insert(basename.clone(), result);
450                }
451            // TODO(WEB-952) handle symlinks in app dir
452            _ => {}
453        }
454    }
455
456    fn sort<T>(mut list: Vec<(Option<u32>, T)>) -> Vec<T> {
457        list.sort_by_key(|(num, _)| *num);
458        list.into_iter().map(|(_, item)| item).collect()
459    }
460
461    modules.metadata.icon = sort(metadata_icon);
462    modules.metadata.apple = sort(metadata_apple);
463    modules.metadata.twitter = sort(metadata_twitter);
464    modules.metadata.open_graph = sort(metadata_open_graph);
465
466    Ok(DirectoryTree {
467        subdirectories,
468        modules,
469    }
470    .cell())
471}
472
473#[turbo_tasks::value]
474#[derive(Debug, Clone)]
475pub struct AppPageLoaderTree {
476    pub page: AppPage,
477    pub segment: RcStr,
478    #[bincode(with = "turbo_bincode::indexmap")]
479    pub parallel_routes: FxIndexMap<RcStr, AppPageLoaderTree>,
480    pub modules: AppDirModules,
481    pub global_metadata: ResolvedVc<GlobalMetadata>,
482    /// For dynamic segments, contains the list of static sibling segments that
483    /// exist at the same URL path level. Used by the client router to determine
484    /// if a prefetch can be reused.
485    pub static_siblings: Vec<RcStr>,
486}
487
488impl 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
499    /// Returns true if there's a page match in this loader tree.
500    pub fn has_page(&self) -> bool {
501        if &*self.segment == "__PAGE__" {
502            return true;
503        }
504
505        for (_, tree) in &self.parallel_routes {
506            if tree.has_page() {
507                return true;
508            }
509        }
510
511        false
512    }
513
514    /// Returns whether the only match in this tree is for a catch-all
515    /// route.
516    pub fn has_only_catchall(&self) -> bool {
517        if &*self.segment == "__PAGE__" && !self.page.is_catchall() {
518            return false;
519        }
520
521        for (_, tree) in &self.parallel_routes {
522            if !tree.has_only_catchall() {
523                return false;
524            }
525        }
526
527        true
528    }
529
530    fn contains_catchall_page(&self) -> bool {
531        (&*self.segment == "__PAGE__" && self.page.is_catchall())
532            || self
533                .parallel_routes
534                .values()
535                .any(AppPageLoaderTree::contains_catchall_page)
536    }
537
538    fn is_builtin_not_found_default(&self, builtin_default: &FileSystemPath) -> bool {
539        &*self.segment == "__DEFAULT__"
540            && self.modules.default.as_ref().is_some_and(|default| {
541                default.fs == builtin_default.fs && default.path == builtin_default.path
542            })
543    }
544
545    /// Returns true when a slot declared by its owning layout can only use Next.js' built-in
546    /// not-found default. Structural router-state branches can contain the same default without
547    /// being renderable slots, so they do not make the matcher incomplete.
548    fn contains_declared_builtin_not_found_default(
549        &self,
550        builtin_default: &FileSystemPath,
551        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
552        parent_layout: Option<&FileSystemPath>,
553    ) -> bool {
554        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
555
556        self.parallel_routes.iter().any(|(slot, tree)| {
557            let is_declared_default = tree.is_builtin_not_found_default(builtin_default)
558                && owner_layout.is_some_and(|layout| {
559                    declared_slots
560                        .get(layout)
561                        .is_some_and(|slots| slots.contains(slot))
562                });
563
564            is_declared_default
565                || tree.contains_declared_builtin_not_found_default(
566                    builtin_default,
567                    declared_slots,
568                    owner_layout,
569                )
570        })
571    }
572
573    fn collect_builtin_not_found_defaults(
574        &self,
575        builtin_default: &FileSystemPath,
576        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
577        parent_layout: Option<&FileSystemPath>,
578        missing_slots: &mut FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
579    ) {
580        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
581
582        for (slot, tree) in &self.parallel_routes {
583            if tree.is_builtin_not_found_default(builtin_default) {
584                if let Some(owner_layout) = owner_layout
585                    && declared_slots
586                        .get(owner_layout)
587                        .is_some_and(|slots| slots.contains(slot))
588                {
589                    missing_slots
590                        .entry(owner_layout.clone())
591                        .or_default()
592                        .insert(slot.clone());
593                }
594            } else {
595                tree.collect_builtin_not_found_defaults(
596                    builtin_default,
597                    declared_slots,
598                    owner_layout,
599                    missing_slots,
600                );
601            }
602        }
603    }
604
605    /// Returns true when one slot matches through a catch-all while a different slot at the same
606    /// level can only render Next.js' built-in not-found default.
607    fn has_unmatched_parallel_route(
608        &self,
609        builtin_default: &FileSystemPath,
610        declared_slots: &FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
611        parent_layout: Option<&FileSystemPath>,
612    ) -> bool {
613        let owner_layout = self.modules.layout.as_ref().or(parent_layout);
614        let slots_at_level = owner_layout.and_then(|layout| declared_slots.get(layout));
615
616        self.parallel_routes.iter().any(|(catchall_key, tree)| {
617            slots_at_level.is_some_and(|slots| slots.contains(catchall_key))
618                && tree.contains_catchall_page()
619                && self.parallel_routes.iter().any(|(default_key, tree)| {
620                    default_key != catchall_key
621                        && slots_at_level.is_some_and(|slots| slots.contains(default_key))
622                        && tree.is_builtin_not_found_default(builtin_default)
623                })
624        }) || self.parallel_routes.values().any(|tree| {
625            tree.has_unmatched_parallel_route(builtin_default, declared_slots, owner_layout)
626        })
627    }
628
629    /// Returns true if this loader tree contains an intercepting route match.
630    pub fn is_intercepting(&self) -> bool {
631        if self.page.is_intercepting() && self.has_page() {
632            return true;
633        }
634
635        for (_, tree) in &self.parallel_routes {
636            if tree.is_intercepting() {
637                return true;
638            }
639        }
640
641        false
642    }
643
644    /// Returns the specificity of the page (i.e. the number of segments
645    /// affecting the path)
646    pub fn get_specificity(&self) -> usize {
647        if &*self.segment == "__PAGE__" {
648            return AppPath::from(self.page.clone()).len();
649        }
650
651        let mut specificity = 0;
652
653        for (_, tree) in &self.parallel_routes {
654            specificity = specificity.max(tree.get_specificity());
655        }
656
657        specificity
658    }
659}
660
661#[turbo_tasks::value(transparent)]
662#[derive(Default)]
663pub struct RootParamVecOption(Option<Vec<RcStr>>);
664
665#[turbo_tasks::value_impl]
666impl ValueDefault for RootParamVecOption {
667    #[turbo_tasks::function]
668    fn value_default() -> Vc<Self> {
669        Vc::cell(Default::default())
670    }
671}
672
673#[turbo_tasks::value(transparent)]
674pub struct FileSystemPathVec(Vec<FileSystemPath>);
675
676#[turbo_tasks::value_impl]
677impl ValueDefault for FileSystemPathVec {
678    #[turbo_tasks::function]
679    fn value_default() -> Vc<Self> {
680        Vc::cell(Vec::new())
681    }
682}
683
684#[turbo_tasks::task_input]
685#[derive(Clone, PartialEq, Eq, Hash, ValueDebugFormat, Debug, Encode, Decode)]
686pub enum Entrypoint {
687    AppPage {
688        pages: Vec<AppPage>,
689        loader_tree: ResolvedVc<AppPageLoaderTree>,
690        participating_page_files: Vec<FileSystemPath>,
691        root_params: ResolvedVc<RootParamVecOption>,
692    },
693    AppRoute {
694        page: AppPage,
695        path: FileSystemPath,
696        root_params: ResolvedVc<RootParamVecOption>,
697    },
698    AppMetadata {
699        page: AppPage,
700        metadata: MetadataItem,
701        root_params: ResolvedVc<RootParamVecOption>,
702    },
703}
704
705impl Entrypoint {
706    pub fn page(&self) -> &AppPage {
707        match self {
708            Entrypoint::AppPage { pages, .. } => pages.first().unwrap(),
709            Entrypoint::AppRoute { page, .. } => page,
710            Entrypoint::AppMetadata { page, .. } => page,
711        }
712    }
713    pub fn root_params(&self) -> ResolvedVc<RootParamVecOption> {
714        match self {
715            Entrypoint::AppPage { root_params, .. } => *root_params,
716            Entrypoint::AppRoute { root_params, .. } => *root_params,
717            Entrypoint::AppMetadata { root_params, .. } => *root_params,
718        }
719    }
720}
721
722#[turbo_tasks::value(transparent)]
723pub struct Entrypoints(
724    #[bincode(with = "turbo_bincode::indexmap")] FxIndexMap<AppPath, Entrypoint>,
725);
726
727fn is_parallel_route(name: &str) -> bool {
728    name.starts_with('@')
729}
730
731fn is_group_route(name: &str) -> bool {
732    name.starts_with('(') && name.ends_with(')')
733}
734
735/// Returns true if this segment is "transparent" from a URL perspective.
736/// Route groups like `(marketing)` and parallel routes like `@modal` exist in
737/// the file system but don't contribute to the URL path.
738fn is_url_transparent_segment(name: &str) -> bool {
739    is_group_route(name) || is_parallel_route(name)
740}
741
742fn is_dynamic_segment(name: &str) -> bool {
743    name.starts_with('[') && name.ends_with(']')
744}
745
746fn match_parallel_route(name: &str) -> Option<&str> {
747    name.strip_prefix('@')
748}
749
750fn conflict_issue(
751    app_dir: FileSystemPath,
752    e: &'_ OccupiedEntry<'_, AppPath, Entrypoint>,
753    a: &str,
754    b: &str,
755    value_a: &AppPage,
756    value_b: &AppPage,
757) {
758    let item_names = if a == b {
759        format!("{a}s")
760    } else {
761        format!("{a} and {b}")
762    };
763
764    DirectoryTreeIssue {
765        app_dir,
766        message: StyledString::Text(
767            format!(
768                "Conflicting {} at {}: {a} at {value_a} and {b} at {value_b}",
769                item_names,
770                e.key(),
771            )
772            .into(),
773        )
774        .resolved_cell(),
775        severity: IssueSeverity::Error,
776    }
777    .resolved_cell()
778    .emit();
779}
780
781fn add_app_page(
782    app_dir: FileSystemPath,
783    result: &mut FxIndexMap<AppPath, Entrypoint>,
784    page: AppPage,
785    loader_tree: ResolvedVc<AppPageLoaderTree>,
786    participating_page_files: Vec<FileSystemPath>,
787    root_params: ResolvedVc<RootParamVecOption>,
788) {
789    let mut e = match result.entry(page.clone().into()) {
790        Entry::Occupied(e) => e,
791        Entry::Vacant(e) => {
792            e.insert(Entrypoint::AppPage {
793                pages: vec![page],
794                loader_tree,
795                participating_page_files,
796                root_params,
797            });
798            return;
799        }
800    };
801
802    let conflict = |existing_name: &str, existing_page: &AppPage| {
803        conflict_issue(app_dir, &e, "page", existing_name, &page, existing_page);
804    };
805
806    let value = e.get();
807    match value {
808        Entrypoint::AppPage {
809            pages: existing_pages,
810            loader_tree: existing_loader_tree,
811            ..
812        } => {
813            // loader trees should always match for the same path as they are generated by a
814            // turbo tasks function
815            if *existing_loader_tree != loader_tree {
816                conflict("page", existing_pages.first().unwrap());
817            }
818
819            let Entrypoint::AppPage {
820                pages: stored_pages,
821                participating_page_files: stored_page_files,
822                ..
823            } = e.get_mut()
824            else {
825                unreachable!("Entrypoint::AppPage was already matched");
826            };
827
828            stored_pages.push(page);
829            stored_pages.sort();
830            for page_file in participating_page_files {
831                if !stored_page_files.contains(&page_file) {
832                    stored_page_files.push(page_file);
833                }
834            }
835        }
836        Entrypoint::AppRoute {
837            page: existing_page,
838            ..
839        } => {
840            conflict("route", existing_page);
841        }
842        Entrypoint::AppMetadata {
843            page: existing_page,
844            ..
845        } => {
846            conflict("metadata", existing_page);
847        }
848    }
849}
850
851fn add_app_route(
852    app_dir: FileSystemPath,
853    result: &mut FxIndexMap<AppPath, Entrypoint>,
854    page: AppPage,
855    path: FileSystemPath,
856    root_params: ResolvedVc<RootParamVecOption>,
857) {
858    let e = match result.entry(page.clone().into()) {
859        Entry::Occupied(e) => e,
860        Entry::Vacant(e) => {
861            e.insert(Entrypoint::AppRoute {
862                page,
863                path,
864                root_params,
865            });
866            return;
867        }
868    };
869
870    let conflict = |existing_name: &str, existing_page: &AppPage| {
871        conflict_issue(app_dir, &e, "route", existing_name, &page, existing_page);
872    };
873
874    let value = e.get();
875    match value {
876        Entrypoint::AppPage { pages, .. } => {
877            conflict("page", pages.first().unwrap());
878        }
879        Entrypoint::AppRoute {
880            page: existing_page,
881            ..
882        } => {
883            conflict("route", existing_page);
884        }
885        Entrypoint::AppMetadata {
886            page: existing_page,
887            ..
888        } => {
889            conflict("metadata", existing_page);
890        }
891    }
892}
893
894fn add_app_metadata_route(
895    app_dir: FileSystemPath,
896    result: &mut FxIndexMap<AppPath, Entrypoint>,
897    page: AppPage,
898    metadata: MetadataItem,
899    root_params: ResolvedVc<RootParamVecOption>,
900) {
901    let e = match result.entry(page.clone().into()) {
902        Entry::Occupied(e) => e,
903        Entry::Vacant(e) => {
904            e.insert(Entrypoint::AppMetadata {
905                page,
906                metadata,
907                root_params,
908            });
909            return;
910        }
911    };
912
913    let conflict = |existing_name: &str, existing_page: &AppPage| {
914        conflict_issue(app_dir, &e, "metadata", existing_name, &page, existing_page);
915    };
916
917    let value = e.get();
918    match value {
919        Entrypoint::AppPage { pages, .. } => {
920            conflict("page", pages.first().unwrap());
921        }
922        Entrypoint::AppRoute {
923            page: existing_page,
924            ..
925        } => {
926            conflict("route", existing_page);
927        }
928        Entrypoint::AppMetadata {
929            page: existing_page,
930            ..
931        } => {
932            conflict("metadata", existing_page);
933        }
934    }
935}
936
937#[turbo_tasks::function]
938pub fn get_entrypoints(
939    app_dir: FileSystemPath,
940    page_extensions: Vc<Vec<RcStr>>,
941    is_global_not_found_enabled: Vc<bool>,
942    explicit_parallel_route_children: Vc<bool>,
943    strict_route_matching: Vc<bool>,
944    next_mode: Vc<NextMode>,
945) -> Vc<Entrypoints> {
946    directory_tree_to_entrypoints(
947        app_dir.clone(),
948        get_directory_tree(app_dir.clone(), page_extensions),
949        get_global_metadata(app_dir, page_extensions),
950        is_global_not_found_enabled,
951        explicit_parallel_route_children,
952        strict_route_matching,
953        next_mode,
954        Default::default(),
955        Default::default(),
956    )
957}
958
959#[turbo_tasks::value(transparent)]
960pub struct CollectedRootParams(#[bincode(with = "turbo_bincode::indexset")] FxIndexSet<RcStr>);
961
962#[turbo_tasks::function]
963pub async fn collect_root_params(
964    entrypoints: ResolvedVc<Entrypoints>,
965) -> Result<Vc<CollectedRootParams>> {
966    let mut collected_root_params = FxIndexSet::<RcStr>::default();
967    for (_, entrypoint) in entrypoints.await?.iter() {
968        if let Some(ref root_params) = *entrypoint.root_params().await? {
969            collected_root_params.extend(root_params.iter().cloned());
970        }
971    }
972    Ok(Vc::cell(collected_root_params))
973}
974
975#[turbo_tasks::function]
976async fn directory_tree_to_entrypoints(
977    app_dir: FileSystemPath,
978    directory_tree: Vc<DirectoryTree>,
979    global_metadata: Vc<GlobalMetadata>,
980    is_global_not_found_enabled: Vc<bool>,
981    explicit_parallel_route_children: Vc<bool>,
982    strict_route_matching: Vc<bool>,
983    next_mode: Vc<NextMode>,
984    root_layouts: Vc<FileSystemPathVec>,
985    root_params: Vc<RootParamVecOption>,
986) -> Result<Vc<Entrypoints>> {
987    let entrypoints = directory_tree_to_entrypoints_internal(
988        app_dir.clone(),
989        global_metadata,
990        is_global_not_found_enabled,
991        explicit_parallel_route_children,
992        strict_route_matching,
993        next_mode,
994        rcstr!(""),
995        directory_tree,
996        AppPage::new(),
997        root_layouts,
998        root_params,
999    );
1000
1001    if !*strict_route_matching.await? {
1002        return Ok(entrypoints);
1003    }
1004
1005    let builtin_default = get_next_package(app_dir.clone())
1006        .await?
1007        .join("dist/client/components/builtin/default.js")?;
1008    let entrypoints_ref = entrypoints.await?;
1009    let plain_tree = directory_tree.into_plain().await?;
1010    let mut declared_slots = FxIndexMap::default();
1011    collect_declared_parallel_route_slots(&plain_tree, &mut declared_slots);
1012    let mut candidate_entrypoints = FxIndexMap::default();
1013
1014    // Loader trees built while walking a subtree may still contain temporary synthesized
1015    // defaults that disappear when sibling pages are combined. Inspect only the finalized root
1016    // entrypoints so complete routes are never discarded based on an intermediate tree. This
1017    // layer retains incomplete static matchers long enough to report their exact slot topology;
1018    // incomplete catch-all matchers preserve the pruning behavior from the lower layer.
1019    for (app_path, entrypoint) in entrypoints_ref.iter() {
1020        let is_incomplete = match entrypoint {
1021            Entrypoint::AppPage { loader_tree, .. } => loader_tree
1022                .await?
1023                .has_unmatched_parallel_route(&builtin_default, &declared_slots, None),
1024            _ => false,
1025        };
1026
1027        if !is_incomplete {
1028            candidate_entrypoints.insert(app_path.clone(), entrypoint.clone());
1029        }
1030    }
1031
1032    let mut incompatible_parallel_route_slots = Vec::new();
1033    let mut retained_entrypoints = FxIndexMap::default();
1034
1035    // Report incomplete static matchers from their finalized loader trees, but still prune them
1036    // from the matcher set. Interception routes intentionally use synthetic retain markers and
1037    // therefore follow different reporting rules.
1038    for (app_path, entrypoint) in &candidate_entrypoints {
1039        let Entrypoint::AppPage { loader_tree, .. } = entrypoint else {
1040            retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1041            continue;
1042        };
1043        let loader_tree = loader_tree.await?;
1044        if !loader_tree.contains_declared_builtin_not_found_default(
1045            &builtin_default,
1046            &declared_slots,
1047            None,
1048        ) {
1049            retained_entrypoints.insert(app_path.clone(), entrypoint.clone());
1050            continue;
1051        }
1052        if app_path.intercepted_path().is_some() {
1053            continue;
1054        }
1055
1056        let mut missing_slots = FxIndexMap::default();
1057        loader_tree.collect_builtin_not_found_defaults(
1058            &builtin_default,
1059            &declared_slots,
1060            None,
1061            &mut missing_slots,
1062        );
1063        if missing_slots.is_empty() {
1064            bail!(
1065                "Invariant: strict route matching retained the incomplete route matcher \
1066                 `{app_path}`"
1067            );
1068        }
1069        incompatible_parallel_route_slots.extend(
1070            missing_slots
1071                .into_iter()
1072                .map(|(layout, slots)| (layout, app_path.clone(), slots.into_iter().collect())),
1073        );
1074    }
1075
1076    if !incompatible_parallel_route_slots.is_empty() {
1077        IncompatibleParallelRouteSlotsIssue {
1078            app_dir: app_dir.clone(),
1079            routes: incompatible_parallel_route_slots,
1080        }
1081        .resolved_cell()
1082        .emit();
1083    }
1084
1085    // This assertion is intentionally separate from the filtering condition above. It guards
1086    // future changes to entrypoint construction or pruning that might retain an incomplete tree.
1087    for (app_path, entrypoint) in &retained_entrypoints {
1088        let Entrypoint::AppPage { loader_tree, .. } = entrypoint else {
1089            continue;
1090        };
1091        if !app_path.contains_interception()
1092            && loader_tree
1093                .await?
1094                .contains_declared_builtin_not_found_default(
1095                    &builtin_default,
1096                    &declared_slots,
1097                    None,
1098                )
1099        {
1100            bail!(
1101                "Invariant: strict route matching retained the incomplete route matcher \
1102                 `{app_path}`"
1103            );
1104        }
1105    }
1106
1107    let ordinary_routes = retained_entrypoints
1108        .iter()
1109        .filter_map(|(route, entrypoint)| {
1110            (!route.contains_interception() && matches!(entrypoint, Entrypoint::AppPage { .. }))
1111                .then_some(route)
1112        })
1113        .collect::<Vec<_>>();
1114    let missing_canonical_interception_routes = retained_entrypoints
1115        .iter()
1116        .filter_map(|(interception_route, entrypoint)| {
1117            let Entrypoint::AppPage {
1118                participating_page_files,
1119                ..
1120            } = entrypoint
1121            else {
1122                return None;
1123            };
1124            let canonical_route = interception_route.intercepted_path()?;
1125            if canonical_route.is_route_pattern_covered_by(ordinary_routes.iter().copied()) {
1126                return None;
1127            }
1128
1129            Some((
1130                interception_route.clone(),
1131                canonical_route,
1132                participating_page_files.first()?.clone(),
1133            ))
1134        })
1135        .collect::<Vec<_>>();
1136    if !missing_canonical_interception_routes.is_empty() {
1137        MissingCanonicalInterceptionRoutesIssue {
1138            routes: missing_canonical_interception_routes,
1139        }
1140        .resolved_cell()
1141        .emit();
1142    }
1143
1144    let mut authored_pages = FxIndexSet::default();
1145    collect_authored_page_files(&plain_tree, &mut authored_pages);
1146
1147    let mut matched_pages = FxIndexSet::default();
1148    for entrypoint in retained_entrypoints.values() {
1149        if let Entrypoint::AppPage {
1150            participating_page_files,
1151            ..
1152        } = entrypoint
1153        {
1154            matched_pages.extend(participating_page_files.iter().cloned());
1155        }
1156    }
1157
1158    let unmatched_pages = authored_pages
1159        .into_iter()
1160        .filter(|page| !matched_pages.contains(page))
1161        .collect::<Vec<_>>();
1162    if !unmatched_pages.is_empty() {
1163        UnmatchedAppPagesIssue {
1164            app_dir,
1165            pages: unmatched_pages,
1166        }
1167        .resolved_cell()
1168        .emit();
1169    }
1170    Ok(Vc::cell(retained_entrypoints))
1171}
1172
1173#[turbo_tasks::value]
1174struct MissingCanonicalInterceptionRoutesIssue {
1175    routes: Vec<(AppPath, AppPath, FileSystemPath)>,
1176}
1177
1178#[turbo_tasks::value]
1179struct IncompatibleParallelRouteSlotsIssue {
1180    app_dir: FileSystemPath,
1181    routes: Vec<(FileSystemPath, AppPath, Vec<RcStr>)>,
1182}
1183
1184#[async_trait]
1185#[turbo_tasks::value_impl]
1186impl Issue for IncompatibleParallelRouteSlotsIssue {
1187    async fn file_path(&self) -> Result<FileSystemPath> {
1188        Ok(self.routes[0].0.clone())
1189    }
1190
1191    fn stage(&self) -> IssueStage {
1192        IssueStage::AppStructure
1193    }
1194
1195    fn severity(&self) -> IssueSeverity {
1196        IssueSeverity::Error
1197    }
1198
1199    async fn title(&self) -> Result<StyledString> {
1200        Ok(StyledString::Text(rcstr!(
1201            "Parallel route slots cannot render the same URLs"
1202        )))
1203    }
1204
1205    async fn description(&self) -> Result<Option<StyledString>> {
1206        let mut routes_by_layout = FxIndexMap::<FileSystemPath, Vec<_>>::default();
1207        for (layout, route, missing_slots) in &self.routes {
1208            routes_by_layout
1209                .entry(layout.clone())
1210                .or_default()
1211                .push((route.clone(), missing_slots.clone()));
1212        }
1213
1214        let mut layouts = routes_by_layout
1215            .into_iter()
1216            .map(|(layout, mut routes)| {
1217                routes.sort_by_cached_key(|(route, _)| route.to_string());
1218                let layout_path = self
1219                    .app_dir
1220                    .get_path_to(&layout)
1221                    .expect("parallel route layout should be within the app directory")
1222                    .to_string();
1223                let routes = routes
1224                    .into_iter()
1225                    .map(|(route, missing_slots)| {
1226                        let missing_slots = missing_slots
1227                            .iter()
1228                            .map(|slot| {
1229                                if &**slot == "children" {
1230                                    slot.to_string()
1231                                } else {
1232                                    format!("@{slot}")
1233                                }
1234                            })
1235                            .collect::<Vec<_>>()
1236                            .join(", ");
1237                        format!(
1238                            "- {route} is missing a matching page or default.tsx in \
1239                             {missing_slots}"
1240                        )
1241                    })
1242                    .collect::<Vec<_>>()
1243                    .join("\n");
1244                (layout_path, routes)
1245            })
1246            .collect::<Vec<_>>();
1247        layouts.sort_by(|a, b| a.0.cmp(&b.0));
1248        let layouts = layouts
1249            .into_iter()
1250            .map(|(layout_path, routes)| format!("app/{layout_path}\n{routes}"))
1251            .collect::<Vec<_>>()
1252            .join("\n\n");
1253
1254        Ok(Some(StyledString::Text(
1255            format!(
1256                "The following layouts have parallel route slots that cannot render the same \
1257                 URLs:\n{layouts}\n\nEvery URL matched by one slot must have a matching page or \
1258                 default.tsx in every sibling slot."
1259            )
1260            .into(),
1261        )))
1262    }
1263}
1264
1265#[async_trait]
1266#[turbo_tasks::value_impl]
1267impl Issue for MissingCanonicalInterceptionRoutesIssue {
1268    async fn file_path(&self) -> Result<FileSystemPath> {
1269        Ok(self.routes[0].2.clone())
1270    }
1271
1272    fn stage(&self) -> IssueStage {
1273        IssueStage::AppStructure
1274    }
1275
1276    fn severity(&self) -> IssueSeverity {
1277        IssueSeverity::Error
1278    }
1279
1280    async fn title(&self) -> Result<StyledString> {
1281        Ok(StyledString::Text(rcstr!(
1282            "Interception routes must have a canonical route"
1283        )))
1284    }
1285
1286    async fn description(&self) -> Result<Option<StyledString>> {
1287        let routes = self
1288            .routes
1289            .iter()
1290            .map(|(interception_route, canonical_route, _)| {
1291                format!("- {interception_route} (expected {canonical_route})")
1292            })
1293            .collect::<Vec<_>>()
1294            .join("\n");
1295        Ok(Some(StyledString::Text(
1296            format!(
1297                "The following interception routes do not have a canonical \
1298                 route:\n{routes}\n\nEvery interception route must have a matching \
1299                 non-interception route so the URL can be loaded directly or refreshed."
1300            )
1301            .into(),
1302        )))
1303    }
1304}
1305
1306fn collect_authored_page_files(
1307    directory_tree: &PlainDirectoryTree,
1308    pages: &mut FxIndexSet<FileSystemPath>,
1309) {
1310    if let Some(page) = &directory_tree.modules.page {
1311        pages.insert(page.clone());
1312    }
1313
1314    for subdirectory in directory_tree.subdirectories.values() {
1315        collect_authored_page_files(subdirectory, pages);
1316    }
1317}
1318
1319#[turbo_tasks::value]
1320struct UnmatchedAppPagesIssue {
1321    app_dir: FileSystemPath,
1322    pages: Vec<FileSystemPath>,
1323}
1324
1325#[async_trait]
1326#[turbo_tasks::value_impl]
1327impl Issue for UnmatchedAppPagesIssue {
1328    async fn file_path(&self) -> Result<FileSystemPath> {
1329        Ok(self.pages[0].clone())
1330    }
1331
1332    fn stage(&self) -> IssueStage {
1333        IssueStage::AppStructure
1334    }
1335
1336    fn severity(&self) -> IssueSeverity {
1337        IssueSeverity::Error
1338    }
1339
1340    async fn title(&self) -> Result<StyledString> {
1341        Ok(StyledString::Text(rcstr!("Unmatched app pages")))
1342    }
1343
1344    async fn description(&self) -> Result<Option<StyledString>> {
1345        let page_paths = self
1346            .pages
1347            .iter()
1348            .map(|page| {
1349                let relative_path = self
1350                    .app_dir
1351                    .get_path_to(page)
1352                    .expect("authored page should be within the app directory");
1353                format!("- app/{}", relative_path)
1354            })
1355            .collect::<Vec<_>>()
1356            .join("\n");
1357        Ok(Some(StyledString::Text(
1358            format!(
1359                "The following page files do not match any complete route:\n{page_paths}\n\nEvery \
1360                 page must be part of at least one complete route. Add matching pages or default \
1361                 files for the sibling parallel route slots, or remove the unreachable pages."
1362            )
1363            .into(),
1364        )))
1365    }
1366}
1367
1368#[turbo_tasks::value]
1369struct DuplicateParallelRouteIssue {
1370    app_dir: FileSystemPath,
1371    previously_inserted_page: AppPage,
1372    page: AppPage,
1373}
1374
1375#[async_trait]
1376#[turbo_tasks::value_impl]
1377impl Issue for DuplicateParallelRouteIssue {
1378    async fn file_path(&self) -> Result<FileSystemPath> {
1379        self.app_dir.join(&self.page.to_string())
1380    }
1381
1382    fn stage(&self) -> IssueStage {
1383        IssueStage::ProcessModule
1384    }
1385
1386    async fn title(&self) -> Result<StyledString> {
1387        Ok(StyledString::Text(
1388            format!(
1389                "You cannot have two parallel pages that resolve to the same path. Please check \
1390                 {} and {}.",
1391                self.previously_inserted_page, self.page
1392            )
1393            .into(),
1394        ))
1395    }
1396}
1397
1398#[turbo_tasks::value]
1399struct MissingRootLayoutIssue {
1400    app_dir: FileSystemPath,
1401    page_path: FileSystemPath,
1402}
1403
1404#[async_trait]
1405#[turbo_tasks::value_impl]
1406impl Issue for MissingRootLayoutIssue {
1407    async fn file_path(&self) -> Result<FileSystemPath> {
1408        Ok(self.page_path.clone())
1409    }
1410
1411    fn stage(&self) -> IssueStage {
1412        IssueStage::AppStructure
1413    }
1414
1415    fn severity(&self) -> IssueSeverity {
1416        IssueSeverity::Error
1417    }
1418
1419    async fn title(&self) -> Result<StyledString> {
1420        let page_path = self
1421            .app_dir
1422            .get_path_to(&self.page_path)
1423            .context("page should be within the app directory")?;
1424
1425        Ok(StyledString::Text(
1426            format!(
1427                "{page_path} doesn't have a root layout. To fix this error, make sure every page \
1428                 has a root layout."
1429            )
1430            .into(),
1431        ))
1432    }
1433}
1434
1435#[turbo_tasks::value]
1436struct MissingDefaultParallelRouteIssue {
1437    app_dir: FileSystemPath,
1438    app_page: AppPage,
1439    slot_name: RcStr,
1440}
1441
1442#[turbo_tasks::function]
1443fn missing_default_parallel_route_issue(
1444    app_dir: FileSystemPath,
1445    app_page: AppPage,
1446    slot_name: RcStr,
1447) -> Vc<MissingDefaultParallelRouteIssue> {
1448    MissingDefaultParallelRouteIssue {
1449        app_dir,
1450        app_page,
1451        slot_name,
1452    }
1453    .cell()
1454}
1455
1456#[async_trait]
1457#[turbo_tasks::value_impl]
1458impl Issue for MissingDefaultParallelRouteIssue {
1459    async fn file_path(&self) -> Result<FileSystemPath> {
1460        self.app_dir
1461            .join(&self.app_page.to_string())?
1462            .join(&format!("@{}", self.slot_name))
1463    }
1464
1465    fn stage(&self) -> IssueStage {
1466        IssueStage::AppStructure
1467    }
1468
1469    fn severity(&self) -> IssueSeverity {
1470        IssueSeverity::Error
1471    }
1472
1473    async fn title(&self) -> Result<StyledString> {
1474        Ok(StyledString::Text(
1475            format!(
1476                "Missing required default.js file for parallel route at {}/@{}",
1477                self.app_page, self.slot_name
1478            )
1479            .into(),
1480        ))
1481    }
1482
1483    async fn description(&self) -> Result<Option<StyledString>> {
1484        Ok(Some(StyledString::Stack(vec![
1485            StyledString::Text(
1486                format!(
1487                    "The parallel route slot \"@{}\" is missing a default.js file. When using \
1488                     parallel routes, each slot must have a default.js file to serve as a \
1489                     fallback.",
1490                    self.slot_name
1491                )
1492                .into(),
1493            ),
1494            StyledString::Text(
1495                format!(
1496                    "Create a default.js file at: {}/@{}/default.js",
1497                    self.app_page, self.slot_name
1498                )
1499                .into(),
1500            ),
1501        ])))
1502    }
1503
1504    fn documentation_link(&self) -> RcStr {
1505        rcstr!("https://nextjs.org/docs/messages/slot-missing-default")
1506    }
1507}
1508
1509fn page_path_except_parallel(loader_tree: &AppPageLoaderTree) -> Option<AppPage> {
1510    if loader_tree.page.iter().any(|v| {
1511        matches!(
1512            v,
1513            PageSegment::CatchAll(..)
1514                | PageSegment::OptionalCatchAll(..)
1515                | PageSegment::Parallel(..)
1516        )
1517    }) {
1518        return None;
1519    }
1520
1521    if loader_tree.modules.page.is_some() {
1522        return Some(loader_tree.page.clone());
1523    }
1524
1525    if let Some(children) = loader_tree.parallel_routes.get("children") {
1526        return page_path_except_parallel(children);
1527    }
1528
1529    None
1530}
1531
1532/// Checks if a directory tree has child routes (non-parallel, non-group routes).
1533/// Leaf segments don't need default.js because there are no child routes
1534/// that could cause the parallel slot to unmatch.
1535fn has_child_routes(directory_tree: &PlainDirectoryTree) -> bool {
1536    for (name, subdirectory) in &directory_tree.subdirectories {
1537        // Skip parallel routes (start with '@')
1538        if is_parallel_route(name) {
1539            continue;
1540        }
1541
1542        // Skip route groups, but check if they have pages inside
1543        if is_group_route(name) {
1544            // Recursively check if the group has child routes
1545            if has_child_routes(subdirectory) {
1546                return true;
1547            }
1548            continue;
1549        }
1550
1551        // If we get here, it's a regular route segment (child route)
1552        return true;
1553    }
1554
1555    false
1556}
1557
1558/// Returns whether the filesystem declares a children slot at this level. Route groups are
1559/// transparent, while a named slot does not declare children for its parent layout. Once an
1560/// ordinary branch is entered, route targets inside deeper named slots still make it renderable.
1561fn has_declared_children_slot(directory_tree: &PlainDirectoryTree) -> bool {
1562    directory_tree.modules.page.is_some()
1563        || directory_tree.modules.default.is_some()
1564        || directory_tree
1565            .subdirectories
1566            .iter()
1567            .filter(|(name, _)| !is_parallel_route(name))
1568            .any(|(_, subdirectory)| subdirectory.contains_page_or_default)
1569}
1570
1571/// Collects named slots at the current URL level. Route groups are transparent, while ordinary
1572/// segments and parallel routes establish nested levels with their own layout ownership.
1573fn collect_named_slots_at_level(
1574    directory_tree: &PlainDirectoryTree,
1575    slots: &mut FxIndexSet<RcStr>,
1576) {
1577    for (name, subdirectory) in &directory_tree.subdirectories {
1578        if let Some(slot) = match_parallel_route(name) {
1579            if subdirectory.contains_page_or_default {
1580                slots.insert(slot.into());
1581            }
1582        } else if is_group_route(name) {
1583            collect_named_slots_at_level(subdirectory, slots);
1584        }
1585    }
1586}
1587
1588/// Records the filesystem slots owned by each layout. Loader trees can also contain structural
1589/// branches used to carry parallel-route state; those branches must not participate in matcher
1590/// completeness unless the owning layout actually declares the slot.
1591fn collect_declared_parallel_route_slots(
1592    directory_tree: &PlainDirectoryTree,
1593    slots_by_layout: &mut FxIndexMap<FileSystemPath, FxIndexSet<RcStr>>,
1594) {
1595    if let Some(layout) = &directory_tree.modules.layout {
1596        let mut slots = FxIndexSet::default();
1597        if has_declared_children_slot(directory_tree) {
1598            slots.insert(rcstr!("children"));
1599        }
1600        collect_named_slots_at_level(directory_tree, &mut slots);
1601        slots_by_layout.insert(layout.clone(), slots);
1602    }
1603
1604    for subdirectory in directory_tree.subdirectories.values() {
1605        collect_declared_parallel_route_slots(subdirectory, slots_by_layout);
1606    }
1607}
1608
1609async fn check_duplicate(
1610    duplicate: &mut FxHashMap<AppPath, AppPage>,
1611    loader_tree: &AppPageLoaderTree,
1612    app_dir: FileSystemPath,
1613) -> Result<()> {
1614    let page_path = page_path_except_parallel(loader_tree);
1615
1616    if let Some(page_path) = page_path
1617        && let Some(prev) = duplicate.insert(AppPath::from(page_path.clone()), page_path.clone())
1618        && prev != page_path
1619    {
1620        DuplicateParallelRouteIssue {
1621            app_dir: app_dir.clone(),
1622            previously_inserted_page: prev.clone(),
1623            page: loader_tree.page.clone(),
1624        }
1625        .resolved_cell()
1626        .emit();
1627    }
1628
1629    Ok(())
1630}
1631
1632#[turbo_tasks::value(transparent)]
1633struct AppPageLoaderTreeOption(Option<ResolvedVc<AppPageLoaderTree>>);
1634
1635/// Creates the loader tree for a specific route (pathname / [AppPath]).
1636#[turbo_tasks::function]
1637async fn directory_tree_to_loader_tree(
1638    app_dir: FileSystemPath,
1639    global_metadata: Vc<GlobalMetadata>,
1640    directory_name: RcStr,
1641    directory_tree: Vc<DirectoryTree>,
1642    app_page: AppPage,
1643    // the page this loader tree is constructed for
1644    for_app_path: AppPath,
1645    explicit_parallel_route_children: Vc<bool>,
1646    strict_route_matching: Vc<bool>,
1647) -> Result<Vc<AppPageLoaderTreeOption>> {
1648    let plain_tree_vc = directory_tree.into_plain();
1649    let plain_tree = &*plain_tree_vc.await?;
1650    let strict_route_matching = *strict_route_matching.await?;
1651    let mut missing_defaults = Vec::new();
1652    let tree = directory_tree_to_loader_tree_internal(
1653        app_dir.clone(),
1654        global_metadata,
1655        directory_name,
1656        plain_tree,
1657        app_page.clone(),
1658        for_app_path,
1659        *explicit_parallel_route_children.await?,
1660        AppDirModules::default(),
1661        Some(&plain_tree.url_tree),
1662        &mut missing_defaults,
1663    )
1664    .await?;
1665
1666    // Strict matching handles incomplete routes after the finalized entrypoint is assembled.
1667    // Preserve the legacy per-tree missing-default diagnostics when strict matching is disabled.
1668    if !strict_route_matching {
1669        for (page, slot) in missing_defaults {
1670            missing_default_parallel_route_issue(app_dir.clone(), page, slot)
1671                .to_resolved()
1672                .await?
1673                .emit();
1674        }
1675    }
1676
1677    Ok(Vc::cell(tree.map(AppPageLoaderTree::resolved_cell)))
1678}
1679
1680/// Checks the current module if it needs to be updated with the default page.
1681/// If the module is already set, update the parent module to the same value.
1682/// If the parent module is set and module is not set, set the module to the parent module.
1683/// If the module and the parent module are not set, set them to the default value.
1684///
1685/// # Arguments
1686/// * `app_dir` - The application directory.
1687/// * `module` - The current module to check and update if it is not set.
1688/// * `parent_module` - The parent module to update if the current module is set or both are not
1689///   set.
1690/// * `file_path` - The file path to the default page if neither the current module nor the parent
1691///   module is set.
1692/// * `is_first_layer_group_route` - If true, the module will be overridden with the parent module
1693///   if it is not set.
1694async fn check_and_update_module_references(
1695    app_dir: FileSystemPath,
1696    module: &mut Option<FileSystemPath>,
1697    parent_module: &mut Option<FileSystemPath>,
1698    file_path: &str,
1699    is_first_layer_group_route: bool,
1700) -> Result<()> {
1701    match (module.as_mut(), parent_module.as_mut()) {
1702        // If the module is set, update the parent module to the same value
1703        (Some(module), _) => *parent_module = Some(module.clone()),
1704        // If we are in a first layer group route and we have a parent module, we want to override
1705        // a nonexistent module with the parent module
1706        (None, Some(parent_module)) if is_first_layer_group_route => {
1707            *module = Some(parent_module.clone())
1708        }
1709        // If we are not in a first layer group route, and the module is not set, and the parent
1710        // module is set, we do nothing
1711        (None, Some(_)) => {}
1712        // If the module is not set, and the parent module is not set, we override with the default
1713        // page. This can only happen in the root directory because after this the parent module
1714        // will always be set.
1715        (None, None) => {
1716            let default_page = get_next_package(app_dir).await?.join(file_path)?;
1717            *module = Some(default_page.clone());
1718            *parent_module = Some(default_page);
1719        }
1720    }
1721
1722    Ok(())
1723}
1724
1725/// Checks if the current directory is the root directory and if the module is not set.
1726/// If the module is not set, it will be set to the default page.
1727///
1728/// # Arguments
1729/// * `app_dir` - The application directory.
1730/// * `module` - The module to check and update if it is not set.
1731/// * `file_path` - The file path to the default page if the module is not set.
1732async fn check_and_update_global_module_references(
1733    app_dir: FileSystemPath,
1734    module: &mut Option<FileSystemPath>,
1735    file_path: &str,
1736) -> Result<()> {
1737    if module.is_none() {
1738        *module = Some(get_next_package(app_dir).await?.join(file_path)?);
1739    }
1740
1741    Ok(())
1742}
1743
1744async fn directory_tree_to_loader_tree_internal(
1745    app_dir: FileSystemPath,
1746    global_metadata: Vc<GlobalMetadata>,
1747    directory_name: RcStr,
1748    directory_tree: &PlainDirectoryTree,
1749    app_page: AppPage,
1750    // the page this loader tree is constructed for
1751    for_app_path: AppPath,
1752    explicit_parallel_route_children: bool,
1753    mut parent_modules: AppDirModules,
1754    url_tree: Option<&UrlSegmentTree>,
1755    missing_defaults: &mut Vec<(AppPage, RcStr)>,
1756) -> Result<Option<AppPageLoaderTree>> {
1757    let app_path = AppPath::from(app_page.clone());
1758
1759    if !for_app_path.contains(&app_path) {
1760        return Ok(None);
1761    }
1762
1763    let mut modules = directory_tree.modules.clone();
1764
1765    // Capture the current page for the metadata to calculate segment relative to
1766    // the corresponding page for the static metadata files.
1767    modules.metadata.base_page = Some(app_page.clone());
1768
1769    // the root directory in the app dir.
1770    let is_root_directory = app_page.is_root();
1771
1772    // If the first layer is a group route, we treat it as root layer
1773    let is_first_layer_group_route = app_page.is_first_layer_group_route();
1774
1775    // Handle the non-global modules that should always be overridden for top level groups or set to
1776    // the default page if they are not set.
1777    if is_root_directory || is_first_layer_group_route {
1778        check_and_update_module_references(
1779            app_dir.clone(),
1780            &mut modules.not_found,
1781            &mut parent_modules.not_found,
1782            "dist/client/components/builtin/not-found.js",
1783            is_first_layer_group_route,
1784        )
1785        .await?;
1786
1787        check_and_update_module_references(
1788            app_dir.clone(),
1789            &mut modules.forbidden,
1790            &mut parent_modules.forbidden,
1791            "dist/client/components/builtin/forbidden.js",
1792            is_first_layer_group_route,
1793        )
1794        .await?;
1795
1796        check_and_update_module_references(
1797            app_dir.clone(),
1798            &mut modules.unauthorized,
1799            &mut parent_modules.unauthorized,
1800            "dist/client/components/builtin/unauthorized.js",
1801            is_first_layer_group_route,
1802        )
1803        .await?;
1804    }
1805
1806    if is_root_directory {
1807        check_and_update_global_module_references(
1808            app_dir.clone(),
1809            &mut modules.global_error,
1810            "dist/client/components/builtin/global-error.js",
1811        )
1812        .await?;
1813    }
1814
1815    // For dynamic segments like [id], find all static siblings at the same URL level.
1816    // This is used by the client to determine if a prefetch can be reused when
1817    // navigating between routes that share the same parent layout.
1818    let static_siblings: Vec<RcStr> = if is_dynamic_segment(&directory_name) {
1819        url_tree
1820            .map(|t| {
1821                t.static_children()
1822                    .into_iter()
1823                    .filter(|s| s != &directory_name)
1824                    .collect()
1825            })
1826            .unwrap_or_default()
1827    } else {
1828        // Static segments don't need sibling info - only dynamic segments use it
1829        Vec::new()
1830    };
1831
1832    let mut tree = AppPageLoaderTree {
1833        page: app_page.clone(),
1834        segment: directory_name.clone(),
1835        parallel_routes: FxIndexMap::default(),
1836        modules: modules.without_leaves(),
1837        global_metadata: global_metadata.to_resolved().await?,
1838        static_siblings,
1839    };
1840
1841    let current_level_is_parallel_route = is_parallel_route(&directory_name);
1842
1843    if current_level_is_parallel_route {
1844        tree.segment = rcstr!("(__SLOT__)");
1845    }
1846
1847    if let Some(page) = (app_path == for_app_path || app_path.is_catchall())
1848        .then_some(modules.page)
1849        .flatten()
1850    {
1851        tree.parallel_routes.insert(
1852            rcstr!("children"),
1853            AppPageLoaderTree {
1854                page: app_page.clone(),
1855                segment: rcstr!("__PAGE__"),
1856                parallel_routes: FxIndexMap::default(),
1857                modules: AppDirModules {
1858                    page: Some(page),
1859                    metadata: modules.metadata,
1860                    ..Default::default()
1861                },
1862                global_metadata: global_metadata.to_resolved().await?,
1863                static_siblings: Vec::new(),
1864            },
1865        );
1866    }
1867
1868    let mut duplicate = FxHashMap::default();
1869
1870    for (subdir_name, subdirectory) in &directory_tree.subdirectories {
1871        let parallel_route_key = match_parallel_route(subdir_name);
1872
1873        let mut child_app_page = app_page.clone();
1874        let mut illegal_path_error = None;
1875
1876        // When constructing the app_page fails (e. g. due to limitations of the order),
1877        // we only want to emit the error when there are actual pages below that
1878        // directory.
1879        if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
1880            illegal_path_error = Some(e);
1881        }
1882
1883        // Root/transparent segments don't consume a URL level; others descend.
1884        let child_url_tree: Option<&UrlSegmentTree> =
1885            if directory_name.is_empty() || is_url_transparent_segment(&directory_name) {
1886                url_tree
1887            } else {
1888                url_tree.and_then(|t| t.get_child(&directory_name))
1889            };
1890
1891        let subtree = Box::pin(directory_tree_to_loader_tree_internal(
1892            app_dir.clone(),
1893            global_metadata,
1894            subdir_name.clone(),
1895            subdirectory,
1896            child_app_page.clone(),
1897            for_app_path.clone(),
1898            explicit_parallel_route_children,
1899            parent_modules.clone(),
1900            child_url_tree,
1901            missing_defaults,
1902        ))
1903        .await?;
1904
1905        if let Some(illegal_path) = subtree.as_ref().and(illegal_path_error) {
1906            return Err(illegal_path);
1907        }
1908
1909        if let Some(subtree) = subtree {
1910            if let Some(key) = parallel_route_key {
1911                // Validate that parallel routes (except "children") have a default.js file.
1912                // This validation matches the webpack loader's logic but is implemented
1913                // differently due to Turbopack's single-pass recursive processing.
1914
1915                // Check if we're inside a catch-all route (i.e., the parallel route is a child
1916                // of a catch-all segment). Only skip validation if the slot is UNDER a catch-all.
1917                // For example:
1918                //   /[...catchAll]/@slot - is_inside_catchall = true (skip validation) ✓
1919                //   /@slot/[...catchAll] - is_inside_catchall = false (require default) ✓
1920                // The catch-all provides fallback behavior, so default.js is not required.
1921                let is_inside_catchall = app_page.is_catchall();
1922
1923                // Check if this is a leaf segment (no child routes).
1924                // Leaf segments don't need default.js because there are no child routes
1925                // that could cause the parallel slot to unmatch. For example:
1926                //   /repo-overview/@slot/page with no child routes - is_leaf_segment = true (skip
1927                // validation) ✓   /repo-overview/@slot/page with
1928                // /repo-overview/child/page - is_leaf_segment = false (require default) ✓
1929                // This also handles route groups correctly by filtering them out.
1930                let is_leaf_segment = !has_child_routes(directory_tree);
1931
1932                // Turbopack-specific: Check if the parallel slot has matching child routes.
1933                // In webpack, this is checked implicitly via the two-phase processing:
1934                // slots with content are processed first and skip validation in the second phase.
1935                // In Turbopack's single-pass approach, we check directly if the slot has child
1936                // routes. If the slot has child routes that match the parent's
1937                // child routes, it can render content for those routes and doesn't
1938                // need a default. For example:
1939                //   /parent/@slot/page + /parent/@slot/child + /parent/child - slot_has_children =
1940                // true (skip validation) ✓   /parent/@slot/page + /parent/child (no
1941                // @slot/child) - slot_has_children = false (require default) ✓
1942                let slot_has_children = has_child_routes(subdirectory);
1943
1944                if key != "children"
1945                    && subdirectory.modules.default.is_none()
1946                    && !is_inside_catchall
1947                    && !is_leaf_segment
1948                    && !slot_has_children
1949                {
1950                    missing_defaults.push((app_page.clone(), key.into()));
1951                }
1952
1953                tree.parallel_routes.insert(key.into(), subtree);
1954                continue;
1955            }
1956
1957            // skip groups which don't have a page match.
1958            if is_group_route(subdir_name) && !subtree.has_page() {
1959                continue;
1960            }
1961
1962            if subtree.has_page() {
1963                check_duplicate(&mut duplicate, &subtree, app_dir.clone()).await?;
1964            }
1965
1966            if let Some(current_tree) = tree.parallel_routes.get("children") {
1967                if current_tree.has_only_catchall()
1968                    && (!subtree.has_only_catchall()
1969                        || current_tree.get_specificity() < subtree.get_specificity())
1970                {
1971                    tree.parallel_routes
1972                        .insert(rcstr!("children"), subtree.clone());
1973                }
1974            } else {
1975                tree.parallel_routes.insert(rcstr!("children"), subtree);
1976            }
1977        } else if let Some(key) = parallel_route_key {
1978            bail!(
1979                "missing page or default for parallel route `{}` (page: {})",
1980                key,
1981                app_page
1982            );
1983        }
1984    }
1985
1986    // An interception match is a partial update of its host's slots. Retain
1987    // every non-intercepting sibling above the interception marker, but keep
1988    // normal matching semantics inside the newly selected subtree.
1989    let is_interception_host = !app_path.contains_interception()
1990        && tree
1991            .parallel_routes
1992            .iter()
1993            .any(|(_, parallel_tree)| parallel_tree.is_intercepting());
1994
1995    if is_interception_host {
1996        let keys_to_replace = tree
1997            .parallel_routes
1998            .iter()
1999            .filter(|(_, parallel_tree)| !parallel_tree.is_intercepting())
2000            .map(|(key, _)| key.clone())
2001            .collect::<Vec<_>>();
2002        let is_inside_catchall = app_page.is_catchall();
2003        let is_leaf_segment = !has_child_routes(directory_tree);
2004
2005        for key in keys_to_replace {
2006            let subdir_name: RcStr = format!("@{key}").into();
2007
2008            let default = if key == "children" {
2009                modules.default.clone()
2010            } else if let Some(subdirectory) = directory_tree.subdirectories.get(&subdir_name) {
2011                subdirectory.modules.default.clone()
2012            } else {
2013                None
2014            };
2015            let slot_has_children = directory_tree
2016                .subdirectories
2017                .get(&subdir_name)
2018                .is_some_and(has_child_routes);
2019
2020            // Only emit the issue if this is not the children slot and there's no default
2021            // component. The ordinary children route does not require a default.js file.
2022            // Also skip validation if the slot is UNDER a catch-all route or if this is a
2023            // leaf segment (no child routes), or if the slot has matching child routes.
2024            if default.is_none()
2025                && key != "children"
2026                && !is_inside_catchall
2027                && !is_leaf_segment
2028                && !slot_has_children
2029            {
2030                missing_defaults.push((app_page.clone(), key.clone()));
2031            }
2032
2033            tree.parallel_routes.insert(
2034                key.clone(),
2035                retained_route_tree(app_dir.clone(), global_metadata, app_page.clone()).await?,
2036            );
2037        }
2038    }
2039
2040    if tree.parallel_routes.is_empty() {
2041        if modules.default.is_some() || current_level_is_parallel_route {
2042            tree = default_route_tree(
2043                app_dir.clone(),
2044                global_metadata,
2045                app_page.clone(),
2046                modules.default.clone(),
2047                rcstr!("children"),
2048                for_app_path.clone(),
2049            )
2050            .await?;
2051        } else {
2052            return Ok(None);
2053        }
2054    } else if tree.parallel_routes.get("children").is_none()
2055        && (!explicit_parallel_route_children || has_declared_children_slot(directory_tree))
2056    {
2057        // `children` is only a slot when this level has ordinary route
2058        // content. Named-only layouts can carry their parallel route state
2059        // directly without a synthetic default child.
2060        let children = if is_interception_host {
2061            retained_route_tree(app_dir.clone(), global_metadata, app_page.clone()).await?
2062        } else {
2063            default_route_tree(
2064                app_dir.clone(),
2065                global_metadata,
2066                app_page.clone(),
2067                modules.default.clone(),
2068                rcstr!("children"),
2069                for_app_path.clone(),
2070            )
2071            .await?
2072        };
2073        tree.parallel_routes.insert(rcstr!("children"), children);
2074    }
2075
2076    Ok(Some(tree))
2077}
2078
2079async fn default_route_tree(
2080    app_dir: FileSystemPath,
2081    global_metadata: Vc<GlobalMetadata>,
2082    app_page: AppPage,
2083    default_component: Option<FileSystemPath>,
2084    slot_name: RcStr,
2085    for_app_path: AppPath,
2086) -> Result<AppPageLoaderTree> {
2087    let default = if let Some(default) = default_component {
2088        default
2089    } else {
2090        let contains_interception = for_app_path.contains_interception();
2091
2092        // Legacy slot discovery can synthesize a children slot inside an
2093        // interception subtree even when no ordinary route declares it.
2094        // Explicit children detection omits that structural child; this
2095        // fallback remains for applications that disable the flag.
2096        let default_file = if contains_interception && slot_name == "children" {
2097            "dist/client/components/builtin/default-null.js"
2098        } else {
2099            "dist/client/components/builtin/default.js"
2100        };
2101
2102        get_next_package(app_dir).await?.join(default_file)?
2103    };
2104
2105    synthetic_default_route_tree(global_metadata, app_page, default).await
2106}
2107
2108async fn retained_route_tree(
2109    app_dir: FileSystemPath,
2110    global_metadata: Vc<GlobalMetadata>,
2111    app_page: AppPage,
2112) -> Result<AppPageLoaderTree> {
2113    let default_null = get_next_package(app_dir)
2114        .await?
2115        .join("dist/client/components/builtin/default-null.js")?;
2116    synthetic_default_route_tree(global_metadata, app_page, default_null).await
2117}
2118
2119async fn synthetic_default_route_tree(
2120    global_metadata: Vc<GlobalMetadata>,
2121    app_page: AppPage,
2122    default: FileSystemPath,
2123) -> Result<AppPageLoaderTree> {
2124    Ok(AppPageLoaderTree {
2125        page: app_page,
2126        segment: rcstr!("__DEFAULT__"),
2127        parallel_routes: FxIndexMap::default(),
2128        modules: AppDirModules {
2129            default: Some(default),
2130            ..Default::default()
2131        },
2132        global_metadata: global_metadata.to_resolved().await?,
2133        static_siblings: Vec::new(),
2134    })
2135}
2136
2137#[turbo_tasks::function]
2138async fn directory_tree_to_entrypoints_internal(
2139    app_dir: FileSystemPath,
2140    global_metadata: ResolvedVc<GlobalMetadata>,
2141    is_global_not_found_enabled: Vc<bool>,
2142    explicit_parallel_route_children: Vc<bool>,
2143    strict_route_matching: Vc<bool>,
2144    next_mode: Vc<NextMode>,
2145    directory_name: RcStr,
2146    directory_tree: Vc<DirectoryTree>,
2147    app_page: AppPage,
2148    root_layouts: ResolvedVc<FileSystemPathVec>,
2149    root_params: ResolvedVc<RootParamVecOption>,
2150) -> Result<Vc<Entrypoints>> {
2151    let span = tracing::info_span!("build layout trees", name = display(&app_page));
2152    directory_tree_to_entrypoints_internal_untraced(
2153        app_dir,
2154        global_metadata,
2155        is_global_not_found_enabled,
2156        explicit_parallel_route_children,
2157        strict_route_matching,
2158        next_mode,
2159        directory_name,
2160        directory_tree,
2161        app_page,
2162        root_layouts,
2163        root_params,
2164    )
2165    .instrument(span)
2166    .await
2167}
2168
2169async fn directory_tree_to_entrypoints_internal_untraced(
2170    app_dir: FileSystemPath,
2171    global_metadata: ResolvedVc<GlobalMetadata>,
2172    is_global_not_found_enabled: Vc<bool>,
2173    explicit_parallel_route_children: Vc<bool>,
2174    strict_route_matching: Vc<bool>,
2175    next_mode: Vc<NextMode>,
2176    directory_name: RcStr,
2177    directory_tree: Vc<DirectoryTree>,
2178    app_page: AppPage,
2179    root_layouts: ResolvedVc<FileSystemPathVec>,
2180    root_params: ResolvedVc<RootParamVecOption>,
2181) -> Result<Vc<Entrypoints>> {
2182    let mut result = FxIndexMap::default();
2183
2184    let directory_tree_vc = directory_tree;
2185    let directory_tree = &*directory_tree.await?;
2186
2187    let subdirectories = &directory_tree.subdirectories;
2188    let modules = &directory_tree.modules;
2189    // Route can have its own segment config, also can inherit from the layout root
2190    // segment config. https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes#segment-runtime-option
2191    // Pass down layouts from each tree to apply segment config when adding route.
2192    let root_layouts = if let Some(layout) = &modules.layout {
2193        let mut layouts = root_layouts.owned().await?;
2194        layouts.push(layout.clone());
2195        ResolvedVc::cell(layouts)
2196    } else {
2197        root_layouts
2198    };
2199
2200    // TODO: `root_layouts` is a misnomer, they're just parent layouts
2201    let root_params = if root_params.await?.is_none() && (*root_layouts.await?).len() == 1 {
2202        // found a root layout. the params up-to-and-including this point are the root params
2203        // for all child segments
2204        ResolvedVc::cell(Some(
2205            app_page
2206                .0
2207                .iter()
2208                .filter_map(|segment| match segment {
2209                    PageSegment::Dynamic(param)
2210                    | PageSegment::CatchAll(param)
2211                    | PageSegment::OptionalCatchAll(param) => Some(param.clone()),
2212                    _ => None,
2213                })
2214                .collect::<Vec<RcStr>>(),
2215        ))
2216    } else {
2217        root_params
2218    };
2219
2220    if let Some(page_path) = &modules.page {
2221        if root_layouts.await?.is_empty() {
2222            MissingRootLayoutIssue {
2223                app_dir: app_dir.clone(),
2224                page_path: page_path.clone(),
2225            }
2226            .resolved_cell()
2227            .emit();
2228        }
2229
2230        let app_path = AppPath::from(app_page.clone());
2231
2232        let loader_tree = *directory_tree_to_loader_tree(
2233            app_dir.clone(),
2234            *global_metadata,
2235            directory_name.clone(),
2236            directory_tree_vc,
2237            app_page.clone(),
2238            app_path,
2239            explicit_parallel_route_children,
2240            strict_route_matching,
2241        )
2242        .await?;
2243
2244        let loader_tree =
2245            loader_tree.context("loader tree should be created for a page/default")?;
2246        let mut participating_page_files = FxIndexSet::default();
2247        loader_tree
2248            .await?
2249            .collect_page_files(&mut participating_page_files);
2250        add_app_page(
2251            app_dir.clone(),
2252            &mut result,
2253            app_page.complete(PageType::Page)?,
2254            loader_tree,
2255            participating_page_files.into_iter().collect(),
2256            root_params,
2257        );
2258    }
2259
2260    if let Some(route) = &modules.route {
2261        add_app_route(
2262            app_dir.clone(),
2263            &mut result,
2264            app_page.complete(PageType::Route)?,
2265            route.clone(),
2266            root_params,
2267        );
2268    }
2269
2270    let Metadata {
2271        icon,
2272        apple,
2273        twitter,
2274        open_graph,
2275        sitemap,
2276        base_page: _,
2277    } = &modules.metadata;
2278
2279    for meta in sitemap
2280        .iter()
2281        .cloned()
2282        .chain(icon.iter().cloned().map(MetadataItem::from))
2283        .chain(apple.iter().cloned().map(MetadataItem::from))
2284        .chain(twitter.iter().cloned().map(MetadataItem::from))
2285        .chain(open_graph.iter().cloned().map(MetadataItem::from))
2286    {
2287        let app_page = app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
2288
2289        add_app_metadata_route(
2290            app_dir.clone(),
2291            &mut result,
2292            normalize_metadata_route(app_page)?,
2293            meta,
2294            root_params,
2295        );
2296    }
2297
2298    // root path: /
2299    if app_page.is_root() {
2300        let GlobalMetadata {
2301            favicon,
2302            robots,
2303            manifest,
2304        } = &*global_metadata.await?;
2305
2306        for meta in favicon.iter().chain(robots.iter()).chain(manifest.iter()) {
2307            let app_page =
2308                app_page.clone_push_str(&get_metadata_route_name(meta.clone()).await?)?;
2309
2310            add_app_metadata_route(
2311                app_dir.clone(),
2312                &mut result,
2313                normalize_metadata_route(app_page)?,
2314                meta.clone(),
2315                root_params,
2316            );
2317        }
2318
2319        let mut modules = directory_tree.modules.clone();
2320
2321        // fill in the default modules for the not-found entrypoint
2322        if modules.layout.is_none() {
2323            modules.layout = Some(
2324                get_next_package(app_dir.clone())
2325                    .await?
2326                    .join("dist/client/components/builtin/layout.js")?,
2327            );
2328        }
2329
2330        if modules.not_found.is_none() {
2331            modules.not_found = Some(
2332                get_next_package(app_dir.clone())
2333                    .await?
2334                    .join("dist/client/components/builtin/not-found.js")?,
2335            );
2336        }
2337        if modules.forbidden.is_none() {
2338            modules.forbidden = Some(
2339                get_next_package(app_dir.clone())
2340                    .await?
2341                    .join("dist/client/components/builtin/forbidden.js")?,
2342            );
2343        }
2344        if modules.unauthorized.is_none() {
2345            modules.unauthorized = Some(
2346                get_next_package(app_dir.clone())
2347                    .await?
2348                    .join("dist/client/components/builtin/unauthorized.js")?,
2349            );
2350        }
2351        if modules.global_error.is_none() {
2352            modules.global_error = Some(
2353                get_next_package(app_dir.clone())
2354                    .await?
2355                    .join("dist/client/components/builtin/global-error.js")?,
2356            );
2357        }
2358
2359        // Next.js has this logic in "collect-app-paths", where the root not-found page
2360        // is considered as its own entry point.
2361
2362        // Determine if we enable the global not-found feature.
2363        let is_global_not_found_enabled = *is_global_not_found_enabled.await?;
2364        let use_global_not_found =
2365            is_global_not_found_enabled || modules.global_not_found.is_some();
2366
2367        let not_found_root_modules = modules.without_leaves();
2368        let not_found_tree = AppPageLoaderTree {
2369            page: app_page.clone(),
2370            segment: directory_name.clone(),
2371            parallel_routes: fxindexmap! {
2372                rcstr!("children") => AppPageLoaderTree {
2373                    page: app_page.clone(),
2374                    segment: rcstr!("/_not-found"),
2375                    parallel_routes: fxindexmap! {
2376                        rcstr!("children") => AppPageLoaderTree {
2377                            // Keep the root identity so the synthetic leaf does not contribute
2378                            // runtime or deployment config, matching webpack's layout inheritance.
2379                            page: app_page.clone(),
2380                            segment: rcstr!("__PAGE__"),
2381                            parallel_routes: FxIndexMap::default(),
2382                            modules: if use_global_not_found {
2383                                // if global-not-found.js is present:
2384                                // leaf module only keeps page pointing to empty-stub
2385                                AppDirModules {
2386                                    // page is built-in/empty-stub
2387                                    page: Some(get_next_package(app_dir.clone())
2388                                        .await?
2389                                        .join("dist/client/components/builtin/empty-stub.js")?,
2390                                    ),
2391                                    ..Default::default()
2392                                }
2393                            } else {
2394                                // if global-not-found.js is not present:
2395                                // we search if we can compose root layout with the root not-found.js;
2396                                AppDirModules {
2397                                    page: match modules.not_found {
2398                                        Some(v) => Some(v),
2399                                        None => Some(get_next_package(app_dir.clone())
2400                                            .await?
2401                                            .join("dist/client/components/builtin/not-found.js")?,
2402                                        ),
2403                                    },
2404                                    ..Default::default()
2405                                }
2406                            },
2407                            global_metadata,
2408                            static_siblings: Vec::new(),
2409                        }
2410                    },
2411                    modules: AppDirModules {
2412                        ..Default::default()
2413                    },
2414                    global_metadata,
2415                    static_siblings: Vec::new(),
2416                },
2417            },
2418            modules: AppDirModules {
2419                // `global-not-found.js` does not need a layout since it's included.
2420                // Skip it if it's present.
2421                // Otherwise, we need to compose it with the root layout to compose with
2422                // not-found.js boundary.
2423                layout: if use_global_not_found {
2424                    match modules.global_not_found {
2425                        Some(v) => Some(v),
2426                        None => Some(
2427                            get_next_package(app_dir.clone())
2428                                .await?
2429                                .join("dist/client/components/builtin/global-not-found.js")?,
2430                        ),
2431                    }
2432                } else {
2433                    modules.layout
2434                },
2435                ..not_found_root_modules
2436            },
2437            global_metadata,
2438            static_siblings: Vec::new(),
2439        }
2440        .resolved_cell();
2441
2442        {
2443            let app_page = app_page
2444                .clone_push_str("_not-found")?
2445                .complete(PageType::Page)?;
2446
2447            add_app_page(
2448                app_dir.clone(),
2449                &mut result,
2450                app_page,
2451                not_found_tree,
2452                Vec::new(),
2453                root_params,
2454            );
2455        }
2456
2457        // Create production global error page only in build mode
2458        // This aligns with webpack: default Pages entries (including /_error) are only added when
2459        // the build isn't app-only. If the build is app-only (no user pages/api), we should still
2460        // expose the app global error so runtime errors render, but we shouldn't emit it otherwise.
2461        if matches!(*next_mode.await?, NextMode::Build) {
2462            // Create a `_global-error/page` route using user's global-error.js or built-in
2463            // fallback.
2464            let next_package = get_next_package(app_dir.clone()).await?;
2465            let global_error_tree = AppPageLoaderTree {
2466                page: app_page.clone(),
2467                segment: directory_name.clone(),
2468                parallel_routes: fxindexmap! {
2469                    rcstr!("children") => AppPageLoaderTree {
2470                        page: app_page.clone(),
2471                        segment: rcstr!("__PAGE__"),
2472                        parallel_routes: FxIndexMap::default(),
2473                        modules: AppDirModules {
2474                            page: Some(next_package
2475                                .join("dist/client/components/builtin/app-error.js")?),
2476                            ..Default::default()
2477                        },
2478                        global_metadata,
2479                        static_siblings: Vec::new(),
2480                    }
2481                },
2482                // global-error is needed for getGlobalErrorStyles to work during rendering.
2483                // Use user's custom global-error if defined, otherwise builtin fallback.
2484                modules: AppDirModules {
2485                    global_error: modules.global_error.clone(),
2486                    ..Default::default()
2487                },
2488                global_metadata,
2489                static_siblings: Vec::new(),
2490            }
2491            .resolved_cell();
2492
2493            let app_global_error_page = app_page
2494                .clone_push_str("_global-error")?
2495                .complete(PageType::Page)?;
2496            add_app_page(
2497                app_dir.clone(),
2498                &mut result,
2499                app_global_error_page,
2500                global_error_tree,
2501                Vec::new(),
2502                root_params,
2503            );
2504        }
2505    }
2506
2507    let app_page = &app_page;
2508    let directory_name = &directory_name;
2509    let subdirectories = subdirectories
2510        .iter()
2511        .map(|(subdir_name, &subdirectory)| {
2512            let app_dir = app_dir.clone();
2513
2514            async move {
2515                let mut child_app_page = app_page.clone();
2516                let mut illegal_path = None;
2517
2518                // When constructing the app_page fails (e. g. due to limitations of the order),
2519                // we only want to emit the error when there are actual pages below that
2520                // directory.
2521                if let Err(e) = child_app_page.push_str(&normalize_underscore(subdir_name)) {
2522                    illegal_path = Some(e);
2523                }
2524
2525                let map = directory_tree_to_entrypoints_internal(
2526                    app_dir.clone(),
2527                    *global_metadata,
2528                    is_global_not_found_enabled,
2529                    explicit_parallel_route_children,
2530                    strict_route_matching,
2531                    next_mode,
2532                    subdir_name.clone(),
2533                    *subdirectory,
2534                    child_app_page.clone(),
2535                    *root_layouts,
2536                    *root_params,
2537                )
2538                .await?;
2539
2540                if let Some(illegal_path) = illegal_path
2541                    && !map.is_empty()
2542                {
2543                    return Err(illegal_path);
2544                }
2545
2546                let mut loader_trees = Vec::new();
2547
2548                for (_, entrypoint) in map.iter() {
2549                    if let Entrypoint::AppPage { ref pages, .. } = *entrypoint {
2550                        for page in pages {
2551                            let app_path = AppPath::from(page.clone());
2552
2553                            let loader_tree = directory_tree_to_loader_tree(
2554                                app_dir.clone(),
2555                                *global_metadata,
2556                                directory_name.clone(),
2557                                directory_tree_vc,
2558                                app_page.clone(),
2559                                app_path,
2560                                explicit_parallel_route_children,
2561                                strict_route_matching,
2562                            );
2563                            loader_trees.push(loader_tree);
2564                        }
2565                    }
2566                }
2567                Ok((map, loader_trees))
2568            }
2569        })
2570        .try_join()
2571        .await?;
2572
2573    for (map, loader_trees) in subdirectories.iter() {
2574        let mut i = 0;
2575        for (_, entrypoint) in map.iter() {
2576            match entrypoint {
2577                Entrypoint::AppPage {
2578                    pages,
2579                    loader_tree: _,
2580                    participating_page_files: child_participating_page_files,
2581                    root_params,
2582                } => {
2583                    for page in pages {
2584                        let loader_tree = *loader_trees[i].await?;
2585                        i += 1;
2586
2587                        let loader_tree = loader_tree
2588                            .context("loader tree should be created for a page/default")?;
2589                        let mut participating_page_files = FxIndexSet::default();
2590                        loader_tree
2591                            .await?
2592                            .collect_page_files(&mut participating_page_files);
2593                        participating_page_files
2594                            .extend(child_participating_page_files.iter().cloned());
2595                        add_app_page(
2596                            app_dir.clone(),
2597                            &mut result,
2598                            page.clone(),
2599                            loader_tree,
2600                            participating_page_files.into_iter().collect(),
2601                            *root_params,
2602                        );
2603                    }
2604                }
2605                Entrypoint::AppRoute {
2606                    page,
2607                    path,
2608                    root_params,
2609                } => {
2610                    add_app_route(
2611                        app_dir.clone(),
2612                        &mut result,
2613                        page.clone(),
2614                        path.clone(),
2615                        *root_params,
2616                    );
2617                }
2618                Entrypoint::AppMetadata {
2619                    page,
2620                    metadata,
2621                    root_params,
2622                } => {
2623                    add_app_metadata_route(
2624                        app_dir.clone(),
2625                        &mut result,
2626                        page.clone(),
2627                        metadata.clone(),
2628                        *root_params,
2629                    );
2630                }
2631            }
2632        }
2633    }
2634    Ok(Vc::cell(result))
2635}
2636
2637/// Returns the global metadata for an app directory.
2638#[turbo_tasks::function]
2639pub async fn get_global_metadata(
2640    app_dir: FileSystemPath,
2641    page_extensions: Vc<Vec<RcStr>>,
2642) -> Result<Vc<GlobalMetadata>> {
2643    let DirectoryContent::Entries(entries) = &*app_dir.read_dir().await? else {
2644        bail!("app_dir must be a directory")
2645    };
2646    let mut metadata = GlobalMetadata::default();
2647
2648    for (basename, entry) in entries {
2649        let DirectoryEntry::File(file) = entry else {
2650            continue;
2651        };
2652
2653        let Some(GlobalMetadataFileMatch {
2654            metadata_type,
2655            dynamic,
2656        }) = match_global_metadata_file(basename, &page_extensions.await?)
2657        else {
2658            continue;
2659        };
2660
2661        let entry = match metadata_type {
2662            "favicon" => &mut metadata.favicon,
2663            "manifest" => &mut metadata.manifest,
2664            "robots" => &mut metadata.robots,
2665            _ => continue,
2666        };
2667
2668        if dynamic {
2669            *entry = Some(MetadataItem::Dynamic { path: file.clone() });
2670        } else {
2671            *entry = Some(MetadataItem::Static { path: file.clone() });
2672        }
2673        // TODO(WEB-952) handle symlinks in app dir
2674    }
2675
2676    Ok(metadata.cell())
2677}
2678
2679#[turbo_tasks::value(shared)]
2680struct DirectoryTreeIssue {
2681    pub severity: IssueSeverity,
2682    pub app_dir: FileSystemPath,
2683    pub message: ResolvedVc<StyledString>,
2684}
2685
2686#[async_trait]
2687#[turbo_tasks::value_impl]
2688impl Issue for DirectoryTreeIssue {
2689    fn severity(&self) -> IssueSeverity {
2690        self.severity
2691    }
2692
2693    async fn title(&self) -> Result<StyledString> {
2694        Ok(StyledString::Text(rcstr!(
2695            "An issue occurred while preparing your Next.js app"
2696        )))
2697    }
2698
2699    fn stage(&self) -> IssueStage {
2700        IssueStage::AppStructure
2701    }
2702
2703    async fn file_path(&self) -> Result<FileSystemPath> {
2704        Ok(self.app_dir.clone())
2705    }
2706
2707    async fn description(&self) -> Result<Option<StyledString>> {
2708        Ok(Some((*self.message.await?).clone()))
2709    }
2710}