Skip to content
294 changes: 294 additions & 0 deletions e2e/react-router/basic-file-based/src/routeTree.gen.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/bar')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/bar"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/bar/_barLayout')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/bar/_barLayout"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/baz/_bazLayout')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/baz/_bazLayout"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/baz/')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/baz/"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/foo/_fooLayout')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/foo/_fooLayout"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/quux/_quuxLayout/hello')(
{
component: RouteComponent,
},
)

function RouteComponent() {
return <div>Hello "/(virtualLayout)/quux/_quuxLayout/hello"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/quux/_quuxLayout')({
component: RouteComponent,
})

function RouteComponent() {
return (
<div>
<Outlet />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/qux/_quxLayout')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/qux/_quxLayout"!</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/(virtualLayout)/qux')({
component: RouteComponent,
})

function RouteComponent() {
return <div>Hello "/(virtualLayout)/qux"!</div>
}
34 changes: 34 additions & 0 deletions e2e/react-router/basic-file-based/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,37 @@ test('Should remount deps when remountDeps does change ', async ({ page }) => {
'Page component mounts: 2',
)
})

test('Should match notFoundComponent when navigating to a route with a virtualLayout', async ({
page,
}) => {
await page.goto('/foo')
await expect(page.getByRole('paragraph')).toContainText(
'This is the notFoundComponent configured on root route',
)

await page.goto('/bar')
await expect(page.locator('#app')).toContainText(
'Hello "/(virtualLayout)/bar"!',
)

await page.goto('/baz/')
await expect(page.locator('#app')).toContainText(
'Hello "/(virtualLayout)/baz/"!',
)

await page.goto('/qux')
await expect(page.locator('#app')).toContainText(
'Hello "/(virtualLayout)/qux"!',
)

await page.goto('/quux')
await expect(page.getByRole('paragraph')).toContainText(
'This is the notFoundComponent configured on root route',
)

await page.goto('/quux/hello')
await expect(page.locator('#app')).toContainText(
'Hello "/(virtualLayout)/quux/_quuxLayout/hello"!',
)
})
3 changes: 3 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3197,6 +3197,7 @@ interface RouteLike {
children?: Array<RouteLike>
options?: {
caseSensitive?: boolean
isVirtualLayout?: boolean
}
}

Expand Down Expand Up @@ -3457,6 +3458,8 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
| { foundRoute: TRouteLike; routeParams: Record<string, string> }
| undefined = undefined
for (const route of flatRoutes) {
if (route.options?.isVirtualLayout) continue

const matchedParams = getMatchedParams(route)

if (matchedParams) {
Expand Down
18 changes: 18 additions & 0 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,23 @@ export class Generator {
(d) => d,
])

// After all nodes have been processed we can safely (re)-evaluate
// whether a virtual `layout` route should be treated as a virtual-layout wrapper
sortedRouteNodes.forEach((node) => {
if (
node.isVirtual &&
node._fsRouteType === 'layout' &&
node.children &&
!node.children.some((c) => c.cleanedPath === '/')
) {
node.isVirtualLayout = true
} else {
// If the conditions are not met, ensure the flag is falsy so we
// don't rely on an outdated value that may have been set earlier.
delete node.isVirtualLayout
}
})

const pluginConfig = plugin.config({
generator: this,
rootRouteNode,
Expand Down Expand Up @@ -592,6 +609,7 @@ export class Generator {
`id: '${node.path}'`,
!node.isNonPath ? `path: '${node.cleanedPath}'` : undefined,
`getParentRoute: () => ${findParent(node, exportName)}`,
node.isVirtualLayout ? `isVirtualLayout: true` : undefined,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A value is set via update() in the router core to determine whether a route should be matched.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@schiller-manuel
I wanted to implement this in a way that keeps the logic hidden from the user’s code, but I couldn’t come up with a better solution. 😕
I’d appreciate any further suggestions you might have.
If you need a test case, I’d be happy to write one.

]
.filter(Boolean)
.join(',')}
Expand Down
1 change: 1 addition & 0 deletions packages/router-generator/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type RouteNode = {
children?: Array<RouteNode>
parent?: RouteNode
exports?: Array<string>
isVirtualLayout?: boolean
}

export interface GetRouteNodesResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ const NestedRoute = NestedRouteImport.update({
id: '/nested',
path: '/nested',
getParentRoute: () => rootRouteImport,
isVirtualLayout: true,
} as any)
const FooRoute = FooRouteImport.update({
id: '/foo',
path: '/foo',
getParentRoute: () => rootRouteImport,
isVirtualLayout: true,
} as any)
const LayoutA2Route = LayoutA2RouteImport.update({
id: '/_layout-a2',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const FooRoute = FooRouteImport.update({
id: '/foo',
path: '/foo',
getParentRoute: () => rootRouteImport,
isVirtualLayout: true,
} as any)
const FooLayoutRouteRoute = FooLayoutRouteRouteImport.update({
id: '/_layout',
Expand Down