Repository: TanStack/router (ts)

Issue:
Duplicate virtual route generation with nested layout routes
### Which project does this relate to?

Router

### Describe the bug

I have simplified file structure like this:
![Image](https://github.com/user-attachments/assets/f19deb38-8151-4c16-9ea5-6be836033c77)
In a real application each directory contains additional routes.

As you can see I don't have duplicated file routes. But after generation I get duplicated routes in `routeTree.gen.ts`:
```
// Create Virtual Routes

const ProtectedSystemsSysIdImport = createFileRoute(
  '/_protected/systems/$sysId',
)()
const ProtectedSystemsSysIdSysLayoutEditImport = createFileRoute(
  '/_protected/systems/$sysId/_sys-layout/edit',
)()
```
Why is `ProtectedSystemsSysIdImport` being created when I already have `_public/systems.$sysId.tsx`?
Is it possible to restructure my code to fix this issue or is this a problem in generator?

### Your Example Website or App

https://stackblitz.com/edit/vitejs-vite-6ambir

### Steps to Reproduce the Bug or Issue

1. `npm run dev`

### Expected behavior

I expect to get valid route tree when there are no conflicting route files in the application.

### Screenshots or Videos

_No response_

### Platform

- OS: Linux
- Browser: Firefox
- Version: 129.0


### Additional context

_No response_
Duplicate route segment at runtime breaks route types and from parameter
### Which project does this relate to?

Router

### Describe the bug

With a specific configuration of pathless route, layout route, and index, the runtime of tanstack/router can end up with an incorrect `routeId` for a route.

For example:
```
routes/
  foo/
    _layout/
      route.tsx <- empty createFileRoute(), so basically just an <Outlet/>
      index.tsx
```
when we log `useMatches().at(-1).routeId`  from `index.tsx` we get 
```
/foo/foo/_layout/
```
instead of the expected
```
/foo/_layout/
```
Notice the `/foo` appearing twice

This example mentions `useMatches().at(-1).routeId`, but the same is true for `Route.id` in the same file.

If it was all "under the hood" it wouldn't necessarily be a huge deal, but route ids are used in the `from` parameters of many hooks (`useSearch({ from: '<some-route-id>' })`), and because of this bug, 
- if you use the "expected" route id as the `from` param, you get the classic "invariant violation" error
- if you use the "real" route id (the one with the duplicate segment), then you don't get any type inferences

### Your Example Website or App

https://stackblitz.com/edit/tanstack-router-tjgs9sry

### Steps to Reproduce the Bug or Issue

1. go to the stackblitz
2. click on the "reproduction" link

### Expected behavior

The `/foo` segment should not be duplicated

### Screenshots or Videos

_No response_

### Platform

- OS: macOS
- Browser: Chrome
- Version: 1.99.0


### Additional context

Interestingly, the `routeTree.gen.ts` file seems to be correct, no "segment duplication" (but maybe I just don't really know how this all gets assembled at runtime, and it's possible the duplication is already present, just not obvious).

Another interesting thing to note is that adding a `foo/route.tsx` file (even if it's just an empty `createFileRoute`) removes the issue
```
routes/
  foo/
    route.tsx   <- empty createFileRoute()
    _layout/
      route.tsx <- empty createFileRoute()
      index.tsx
```

For anyone stumbling onto this issue, this is probably your best bet at a temporary fix until this gets resolved.

Pull request:
fix(router-generator): correctly generate id for layouts
fixes #2360
fixes #3326

Patch:
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index fae51bce3..e7cb7b218 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -2042,6 +2042,13 @@ export class Router<
     ) {
       await this.latestLoadPromise
     }
+
+    if (this.hasNotFoundMatch()) {
+      this.__store.setState((s) => ({
+        ...s,
+        statusCode: 404,
+      }))
+    }
   }
 
   startViewTransition = (fn: () => Promise<void>) => {
diff --git a/packages/router-generator/package.json b/packages/router-generator/package.json
index 34f0bbf56..bd6ec2904 100644
--- a/packages/router-generator/package.json
+++ b/packages/router-generator/package.json
@@ -35,6 +35,7 @@
     "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js -p tsconfig.legacy.json",
     "test:types:ts57": "tsc -p tsconfig.legacy.json",
     "test:unit": "vitest --typecheck",
+    "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests",
     "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .",
     "build": "vite build"
   },
diff --git a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts
index 08e669f33..574bd4c21 100644
--- a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts
+++ b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts
@@ -137,12 +137,7 @@ export async function getRouteNodes(
           )
 
           const segments = routePath.split('/')
-          const lastRouteSegment = segments[segments.length - 1]
-          const isLayout =
-            (lastRouteSegment !== config.indexToken &&
-              lastRouteSegment !== config.routeToken &&
-              lastRouteSegment?.startsWith('_')) ||
-            false
+          const isLayout = determineIsLayout({ segments, config })
 
           ;(
             [
@@ -199,3 +194,35 @@ export async function getRouteNodes(
   const rootRouteNode = routeNodes.find((d) => d.routePath === `/${rootPathId}`)
   return { rootRouteNode, routeNodes }
 }
+
+function determineIsLayout({
+  segments,
+  config,
+}: {
+  segments: Array<string>
+  config: Config
+}) {
+  if (segments.length === 0) {
+    return false
+  }
+
+  const lastRouteSegment = segments[segments.length - 1]!
+  const secondToLastRouteSegment = segments[segments.length - 2]
+
+  if (lastRouteSegment === rootPathId) {
+    return false
+  }
+
+  if (
+    lastRouteSegment === config.routeToken &&
+    typeof secondToLastRouteSegment === 'string'
+  ) {
+    return secondToLastRouteSegment.startsWith('_')
+  }
+
+  return (
+    lastRouteSegment !== config.indexToken &&
+    lastRouteSegment !== config.routeToken &&
+    lastRouteSegment.startsWith('_')
+  )
+}


Test patch:
diff --git a/e2e/start/basic/app/components/NotFound.tsx b/e2e/start/basic/app/components/NotFound.tsx
index 7b54fa568..af4e0e749 100644
--- a/e2e/start/basic/app/components/NotFound.tsx
+++ b/e2e/start/basic/app/components/NotFound.tsx
@@ -2,7 +2,7 @@ import { Link } from '@tanstack/react-router'
 
 export function NotFound({ children }: { children?: any }) {
   return (
-    <div className="space-y-2 p-2">
+    <div className="space-y-2 p-2" data-testid="default-not-found-component">
       <div className="text-gray-600 dark:text-gray-400">
         {children || <p>The page you are looking for does not exist.</p>}
       </div>
diff --git a/e2e/start/basic/tests/not-found.spec.ts b/e2e/start/basic/tests/not-found.spec.ts
index 00d81b7fb..0d83ab528 100644
--- a/e2e/start/basic/tests/not-found.spec.ts
+++ b/e2e/start/basic/tests/not-found.spec.ts
@@ -11,50 +11,64 @@ test.use({
   ],
 })
 test.describe('not-found', () => {
-  const navigationTestMatrix = combinate({
-    // TODO beforeLoad!
-    thrower: [/* 'beforeLoad',*/ 'loader'] as const,
-    preload: [false, true] as const,
+  test(`global not found`, async ({ page }) => {
+    const response = await page.goto(`/this-page-does-not-exist/foo/bar`)
+
+    expect(response?.status()).toBe(404)
+
+    await expect(
+      page.getByTestId('default-not-found-component'),
+    ).toBeInViewport()
   })
 
-  navigationTestMatrix.forEach(({ thrower, preload }) => {
-    test(`navigation: thrower: ${thrower}, preload: ${preload}`, async ({
-      page,
-    }) => {
-      await page.goto(`/not-found/${preload === false ? '?preload=false' : ''}`)
-      const link = page.getByTestId(`via-${thrower}`)
-
-      if (preload) {
-        await link.focus()
-        await new Promise((r) => setTimeout(r, 250))
-      }
-
-      await link.click()
-
-      await expect(
-        page.getByTestId(`via-${thrower}-notFound-component`),
-      ).toBeInViewport()
-      await expect(
-        page.getByTestId(`via-${thrower}-route-component`),
-      ).not.toBeInViewport()
+  test.describe('throw notFound()', () => {
+    const navigationTestMatrix = combinate({
+      // TODO beforeLoad!
+      thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+      preload: [false, true] as const,
     })
-  })
-  const directVisitTestMatrix = combinate({
-    // TODO beforeLoad!
 
-    thrower: [/* 'beforeLoad',*/ 'loader'] as const,
-  })
+    navigationTestMatrix.forEach(({ thrower, preload }) => {
+      test(`navigation: thrower: ${thrower}, preload: ${preload}`, async ({
+        page,
+      }) => {
+        await page.goto(
+          `/not-found/${preload === false ? '?preload=false' : ''}`,
+        )
+        const link = page.getByTestId(`via-${thrower}`)
+
+        if (preload) {
+          await link.focus()
+          await new Promise((r) => setTimeout(r, 250))
+        }
+
+        await link.click()
+
+        await expect(
+          page.getByTestId(`via-${thrower}-notFound-component`),
+        ).toBeInViewport()
+        await expect(
+          page.getByTestId(`via-${thrower}-route-component`),
+        ).not.toBeInViewport()
+      })
+    })
+    const directVisitTestMatrix = combinate({
+      // TODO beforeLoad!
+
+      thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+    })
 
-  directVisitTestMatrix.forEach(({ thrower }) => {
-    test(`direct visit: thrower: ${thrower}`, async ({ page }) => {
-      await page.goto(`/not-found/via-${thrower}`)
-      await page.waitForLoadState('networkidle')
-      await expect(
-        page.getByTestId(`via-${thrower}-notFound-component`),
-      ).toBeInViewport()
-      await expect(
-        page.getByTestId(`via-${thrower}-route-component`),
-      ).not.toBeInViewport()
+    directVisitTestMatrix.forEach(({ thrower }) => {
+      test(`direct visit: thrower: ${thrower}`, async ({ page }) => {
+        await page.goto(`/not-found/via-${thrower}`)
+        await page.waitForLoadState('networkidle')
+        await expect(
+          page.getByTestId(`via-${thrower}-notFound-component`),
+        ).toBeInViewport()
+        await expect(
+          page.getByTestId(`via-${thrower}-route-component`),
+        ).not.toBeInViewport()
+      })
     })
   })
 })
diff --git a/packages/router-generator/tests/generator/nested-layouts/routeTree.snapshot.ts b/packages/router-generator/tests/generator/nested-layouts/routeTree.snapshot.ts
index 9137e8291..51095a9fe 100644
--- a/packages/router-generator/tests/generator/nested-layouts/routeTree.snapshot.ts
+++ b/packages/router-generator/tests/generator/nested-layouts/routeTree.snapshot.ts
@@ -123,7 +123,7 @@ const folderInFolderRoute = folderInFolderImport.update({
 } as any)
 
 const FooLayoutB5RouteRoute = FooLayoutB5RouteImport.update({
-  id: '/foo/_layout-b5',
+  id: '/_layout-b5',
   getParentRoute: () => FooRoute,
 } as any)
 
diff --git a/packages/router-generator/tests/generator/no-duplicate-route-segment/routeTree.snapshot.ts b/packages/router-generator/tests/generator/no-duplicate-route-segment/routeTree.snapshot.ts
new file mode 100644
index 000000000..ed2a80251
--- /dev/null
+++ b/packages/router-generator/tests/generator/no-duplicate-route-segment/routeTree.snapshot.ts
@@ -0,0 +1,159 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { createFileRoute } from '@tanstack/react-router'
+
+// Import Routes
+
+import { Route as rootRoute } from './routes/__root'
+import { Route as FooLayoutRouteImport } from './routes/foo/_layout/route'
+import { Route as FooLayoutIndexImport } from './routes/foo/_layout/index'
+
+// Create Virtual Routes
+
+const FooImport = createFileRoute('/foo')()
+
+// Create/Update Routes
+
+const FooRoute = FooImport.update({
+  id: '/foo',
+  path: '/foo',
+  getParentRoute: () => rootRoute,
+} as any)
+
+const FooLayoutRouteRoute = FooLayoutRouteImport.update({
+  id: '/_layout',
+  getParentRoute: () => FooRoute,
+} as any)
+
+const FooLayoutIndexRoute = FooLayoutIndexImport.update({
+  id: '/',
+  path: '/',
+  getParentRoute: () => FooLayoutRouteRoute,
+} as any)
+
+// Populate the FileRoutesByPath interface
+
+declare module '@tanstack/react-router' {
+  interface FileRoutesByPath {
+    '/foo': {
+      id: '/foo'
+      path: '/foo'
+      fullPath: '/foo'
+      preLoaderRoute: typeof FooImport
+      parentRoute: typeof rootRoute
+    }
+    '/foo/_layout': {
+      id: '/foo/_layout'
+      path: '/foo'
+      fullPath: '/foo'
+      preLoaderRoute: typeof FooLayoutRouteImport
+      parentRoute: typeof FooRoute
+    }
+    '/foo/_layout/': {
+      id: '/foo/_layout/'
+      path: '/'
+      fullPath: '/foo/'
+      preLoaderRoute: typeof FooLayoutIndexImport
+      parentRoute: typeof FooLayoutRouteImport
+    }
+  }
+}
+
+// Create and export the route tree
+
+interface FooLayoutRouteRouteChildren {
+  FooLayoutIndexRoute: typeof FooLayoutIndexRoute
+}
+
+const FooLayoutRouteRouteChildren: FooLayoutRouteRouteChildren = {
+  FooLayoutIndexRoute: FooLayoutIndexRoute,
+}
+
+const FooLayoutRouteRouteWithChildren = FooLayoutRouteRoute._addFileChildren(
+  FooLayoutRouteRouteChildren,
+)
+
+interface FooRouteChildren {
+  FooLayoutRouteRoute: typeof FooLayoutRouteRouteWithChildren
+}
+
+const FooRouteChildren: FooRouteChildren = {
+  FooLayoutRouteRoute: FooLayoutRouteRouteWithChildren,
+}
+
+const FooRouteWithChildren = FooRoute._addFileChildren(FooRouteChildren)
+
+export interface FileRoutesByFullPath {
+  '/foo': typeof FooLayoutRouteRouteWithChildren
+  '/foo/': typeof FooLayoutIndexRoute
+}
+
+export interface FileRoutesByTo {
+  '/foo': typeof FooLayoutIndexRoute
+}
+
+export interface FileRoutesById {
+  __root__: typeof rootRoute
+  '/foo': typeof FooRouteWithChildren
+  '/foo/_layout': typeof FooLayoutRouteRouteWithChildren
+  '/foo/_layout/': typeof FooLayoutIndexRoute
+}
+
+export interface FileRouteTypes {
+  fileRoutesByFullPath: FileRoutesByFullPath
+  fullPaths: '/foo' | '/foo/'
+  fileRoutesByTo: FileRoutesByTo
+  to: '/foo'
+  id: '__root__' | '/foo' | '/foo/_layout' | '/foo/_layout/'
+  fileRoutesById: FileRoutesById
+}
+
+export interface RootRouteChildren {
+  FooRoute: typeof FooRouteWithChildren
+}
+
+const rootRouteChildren: RootRouteChildren = {
+  FooRoute: FooRouteWithChildren,
+}
+
+export const routeTree = rootRoute
+  ._addFileChildren(rootRouteChildren)
+  ._addFileTypes<FileRouteTypes>()
+
+/* ROUTE_MANIFEST_START
+{
+  "routes": {
+    "__root__": {
+      "filePath": "__root.tsx",
+      "children": [
+        "/foo"
+      ]
+    },
+    "/foo": {
+      "filePath": "foo/_layout",
+      "children": [
+        "/foo/_layout"
+      ]
+    },
+    "/foo/_layout": {
+      "filePath": "foo/_layout/route.tsx",
+      "parent": "/foo",
+      "children": [
+        "/foo/_layout/"
+      ]
+    },
+    "/foo/_layout/": {
+      "filePath": "foo/_layout/index.tsx",
+      "parent": "/foo/_layout"
+    }
+  }
+}
+ROUTE_MANIFEST_END */
diff --git a/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/__root.tsx b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/__root.tsx
new file mode 100644
index 000000000..ab504e42e
--- /dev/null
+++ b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/__root.tsx
@@ -0,0 +1,1 @@
+/** */
diff --git a/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/index.tsx b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/index.tsx
new file mode 100644
index 000000000..ab504e42e
--- /dev/null
+++ b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/index.tsx
@@ -0,0 +1,1 @@
+/** */
diff --git a/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/route.tsx b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/route.tsx
new file mode 100644
index 000000000..ab504e42e
--- /dev/null
+++ b/packages/router-generator/tests/generator/no-duplicate-route-segment/routes/foo/_layout/route.tsx
@@ -0,0 +1,1 @@
+/** */