Skip to content

Commit 8ec9ca9

Browse files
authored
fix(router-core): avoid false notFound matches for proxied loader data (#7156)
* fix(router-core): avoid false notFound matches for proxied loader data * test(react-start): cover proxied loader data notFound regression * test(react-start): move notFound regression to rsc direct loader * chore: add changeset
1 parent bdbb331 commit 8ec9ca9

7 files changed

Lines changed: 141 additions & 3 deletions

File tree

.changeset/spotty-geckos-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/router-core': patch
3+
---
4+
5+
avoid false notFound matches for proxied loader data

e2e/react-start/rsc/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Route as RscFormsRouteImport } from './routes/rsc-forms'
3333
import { Route as RscFlightApiRouteImport } from './routes/rsc-flight-api'
3434
import { Route as RscExternalRouteImport } from './routes/rsc-external'
3535
import { Route as RscErrorRouteImport } from './routes/rsc-error'
36+
import { Route as RscDirectLoaderRouteImport } from './routes/rsc-direct-loader'
3637
import { Route as RscDeferredComponentRouteImport } from './routes/rsc-deferred-component'
3738
import { Route as RscDeferredRouteImport } from './routes/rsc-deferred'
3839
import { Route as RscCssPreloadComplexRouteImport } from './routes/rsc-css-preload-complex'
@@ -171,6 +172,11 @@ const RscErrorRoute = RscErrorRouteImport.update({
171172
path: '/rsc-error',
172173
getParentRoute: () => rootRouteImport,
173174
} as any)
175+
const RscDirectLoaderRoute = RscDirectLoaderRouteImport.update({
176+
id: '/rsc-direct-loader',
177+
path: '/rsc-direct-loader',
178+
getParentRoute: () => rootRouteImport,
179+
} as any)
174180
const RscDeferredComponentRoute = RscDeferredComponentRouteImport.update({
175181
id: '/rsc-deferred-component',
176182
path: '/rsc-deferred-component',
@@ -270,6 +276,7 @@ export interface FileRoutesByFullPath {
270276
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
271277
'/rsc-deferred': typeof RscDeferredRoute
272278
'/rsc-deferred-component': typeof RscDeferredComponentRoute
279+
'/rsc-direct-loader': typeof RscDirectLoaderRoute
273280
'/rsc-error': typeof RscErrorRoute
274281
'/rsc-external': typeof RscExternalRoute
275282
'/rsc-flight-api': typeof RscFlightApiRoute
@@ -313,6 +320,7 @@ export interface FileRoutesByTo {
313320
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
314321
'/rsc-deferred': typeof RscDeferredRoute
315322
'/rsc-deferred-component': typeof RscDeferredComponentRoute
323+
'/rsc-direct-loader': typeof RscDirectLoaderRoute
316324
'/rsc-error': typeof RscErrorRoute
317325
'/rsc-external': typeof RscExternalRoute
318326
'/rsc-flight-api': typeof RscFlightApiRoute
@@ -357,6 +365,7 @@ export interface FileRoutesById {
357365
'/rsc-css-preload-complex': typeof RscCssPreloadComplexRoute
358366
'/rsc-deferred': typeof RscDeferredRoute
359367
'/rsc-deferred-component': typeof RscDeferredComponentRoute
368+
'/rsc-direct-loader': typeof RscDirectLoaderRoute
360369
'/rsc-error': typeof RscErrorRoute
361370
'/rsc-external': typeof RscExternalRoute
362371
'/rsc-flight-api': typeof RscFlightApiRoute
@@ -402,6 +411,7 @@ export interface FileRouteTypes {
402411
| '/rsc-css-preload-complex'
403412
| '/rsc-deferred'
404413
| '/rsc-deferred-component'
414+
| '/rsc-direct-loader'
405415
| '/rsc-error'
406416
| '/rsc-external'
407417
| '/rsc-flight-api'
@@ -445,6 +455,7 @@ export interface FileRouteTypes {
445455
| '/rsc-css-preload-complex'
446456
| '/rsc-deferred'
447457
| '/rsc-deferred-component'
458+
| '/rsc-direct-loader'
448459
| '/rsc-error'
449460
| '/rsc-external'
450461
| '/rsc-flight-api'
@@ -488,6 +499,7 @@ export interface FileRouteTypes {
488499
| '/rsc-css-preload-complex'
489500
| '/rsc-deferred'
490501
| '/rsc-deferred-component'
502+
| '/rsc-direct-loader'
491503
| '/rsc-error'
492504
| '/rsc-external'
493505
| '/rsc-flight-api'
@@ -532,6 +544,7 @@ export interface RootRouteChildren {
532544
RscCssPreloadComplexRoute: typeof RscCssPreloadComplexRoute
533545
RscDeferredRoute: typeof RscDeferredRoute
534546
RscDeferredComponentRoute: typeof RscDeferredComponentRoute
547+
RscDirectLoaderRoute: typeof RscDirectLoaderRoute
535548
RscErrorRoute: typeof RscErrorRoute
536549
RscExternalRoute: typeof RscExternalRoute
537550
RscFlightApiRoute: typeof RscFlightApiRoute
@@ -733,6 +746,13 @@ declare module '@tanstack/react-router' {
733746
preLoaderRoute: typeof RscErrorRouteImport
734747
parentRoute: typeof rootRouteImport
735748
}
749+
'/rsc-direct-loader': {
750+
id: '/rsc-direct-loader'
751+
path: '/rsc-direct-loader'
752+
fullPath: '/rsc-direct-loader'
753+
preLoaderRoute: typeof RscDirectLoaderRouteImport
754+
parentRoute: typeof rootRouteImport
755+
}
736756
'/rsc-deferred-component': {
737757
id: '/rsc-deferred-component'
738758
path: '/rsc-deferred-component'
@@ -868,6 +888,7 @@ const rootRouteChildren: RootRouteChildren = {
868888
RscCssPreloadComplexRoute: RscCssPreloadComplexRoute,
869889
RscDeferredRoute: RscDeferredRoute,
870890
RscDeferredComponentRoute: RscDeferredComponentRoute,
891+
RscDirectLoaderRoute: RscDirectLoaderRoute,
871892
RscErrorRoute: RscErrorRoute,
872893
RscExternalRoute: RscExternalRoute,
873894
RscFlightApiRoute: RscFlightApiRoute,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { createServerFn } from '@tanstack/react-start'
3+
import { renderServerComponent } from '@tanstack/react-start/rsc'
4+
import { pageStyles } from '~/utils/styles'
5+
import {
6+
serverBadge,
7+
serverBox,
8+
serverHeader,
9+
timestamp,
10+
} from '~/utils/serverStyles'
11+
12+
const getDirectLoaderServerComponent = createServerFn({
13+
method: 'GET',
14+
}).handler(async () => {
15+
const serverTimestamp = Date.now()
16+
17+
return renderServerComponent(
18+
<div style={serverBox} data-testid="rsc-direct-loader-content">
19+
<div style={serverHeader}>
20+
<span style={serverBadge}>SERVER RENDERED</span>
21+
<span style={timestamp} data-testid="rsc-direct-loader-timestamp">
22+
Fetched: {new Date(serverTimestamp).toLocaleTimeString()}
23+
</span>
24+
</div>
25+
26+
<h2 data-testid="rsc-direct-loader-heading">Direct loader RSC</h2>
27+
<p>
28+
This route returns a server component directly from its loader without
29+
wrapping it in an object.
30+
</p>
31+
</div>,
32+
)
33+
})
34+
35+
export const Route = createFileRoute('/rsc-direct-loader')({
36+
loader: () => getDirectLoaderServerComponent(),
37+
component: RscDirectLoaderComponent,
38+
})
39+
40+
function RscDirectLoaderComponent() {
41+
const ServerComponent = Route.useLoaderData()
42+
43+
return (
44+
<div style={pageStyles.container}>
45+
<h1 data-testid="rsc-direct-loader-title" style={pageStyles.title}>
46+
Direct Loader Return
47+
</h1>
48+
<p style={pageStyles.description}>
49+
The loader returns the server component itself. This guards against
50+
mistaking proxied loader data for a notFound result.
51+
</p>
52+
{ServerComponent}
53+
</div>
54+
)
55+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect } from '@playwright/test'
2+
import { test } from '@tanstack/router-e2e-utils'
3+
4+
test.describe('RSC Direct Loader Tests', () => {
5+
test('direct loader return does not trigger notFound handling', async ({
6+
page,
7+
}) => {
8+
const response = await page.goto('/rsc-direct-loader')
9+
await page.waitForURL('/rsc-direct-loader')
10+
11+
expect(response?.status()).toBe(200)
12+
13+
await expect(page.getByTestId('rsc-direct-loader-title')).toHaveText(
14+
'Direct Loader Return',
15+
)
16+
await expect(page.getByTestId('rsc-direct-loader-content')).toBeVisible()
17+
await expect(page.getByTestId('rsc-direct-loader-heading')).toHaveText(
18+
'Direct loader RSC',
19+
)
20+
})
21+
})

examples/react/start-rscs/src/routes/pokemon-rsc.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { getPokemonList } from '~/pokemon/server-functions'
44
export const Route = createFileRoute('/pokemon-rsc')({
55
loader: async () => {
66
const { Renderable } = await getPokemonList()
7-
return { PokemonList: Renderable }
7+
return Renderable
88
},
99
component: PokemonPage,
1010
})
1111

1212
function PokemonPage() {
13-
const { PokemonList } = Route.useLoaderData()
13+
const PokemonList = Route.useLoaderData()
1414

1515
return (
1616
<div className="py-10">

packages/router-core/src/not-found.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,5 @@ export function notFound(options: NotFoundError = {}) {
3737

3838
/** Determine if a value is a TanStack Router not-found error. */
3939
export function isNotFound(obj: any): obj is NotFoundError {
40-
return !!obj?.isNotFound
40+
return obj?.isNotFound === true
4141
}

packages/router-core/tests/load.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,42 @@ describe('redirect resolution', () => {
8282
)
8383
})
8484

85+
describe('notFound detection', () => {
86+
test('does not treat arbitrary proxy property access as notFound', async () => {
87+
const rootRoute = new BaseRootRoute({})
88+
const fooRoute = new BaseRoute({
89+
getParentRoute: () => rootRoute,
90+
path: '/foo',
91+
loader: () =>
92+
new Proxy(
93+
{},
94+
{
95+
get(_target, prop) {
96+
if (prop === 'isNotFound') return 'truthy-but-not-true'
97+
return undefined
98+
},
99+
has() {
100+
return true
101+
},
102+
},
103+
),
104+
})
105+
106+
const routeTree = rootRoute.addChildren([fooRoute])
107+
108+
const router = createTestRouter({
109+
routeTree,
110+
history: createMemoryHistory({ initialEntries: ['/foo'] }),
111+
isServer: true,
112+
})
113+
114+
await router.load()
115+
116+
expect(router.state.matches.at(-1)?.status).toBe('success')
117+
expect(router.state.matches.at(-1)?.error).toBeUndefined()
118+
})
119+
})
120+
85121
describe('beforeLoad skip or exec', () => {
86122
const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => {
87123
const rootRoute = new BaseRootRoute({})

0 commit comments

Comments
 (0)