diff --git a/.changeset/neat-melons-worry.md b/.changeset/neat-melons-worry.md new file mode 100644 index 0000000000..d9148ae10d --- /dev/null +++ b/.changeset/neat-melons-worry.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-router': patch +--- + +Fix React Server Component imports from `@tanstack/react-router` by adding a `react-server` root export that preserves the normal API surface while resolving `notFound` and `redirect` from a server-safe entry. + +This fixes RSC routes that throw `notFound()` or `redirect()` from server functions so they behave correctly during SSR and client navigation. diff --git a/e2e/react-start/rsc/src/routeTree.gen.ts b/e2e/react-start/rsc/src/routeTree.gen.ts index d5074d36e0..c06c950ea2 100644 --- a/e2e/react-start/rsc/src/routeTree.gen.ts +++ b/e2e/react-start/rsc/src/routeTree.gen.ts @@ -19,6 +19,8 @@ import { Route as RscSsrFalseRouteImport } from './routes/rsc-ssr-false' import { Route as RscSsrDataOnlyRouteImport } from './routes/rsc-ssr-data-only' import { Route as RscSlotsRouteImport } from './routes/rsc-slots' import { Route as RscSlotJsxArgsRouteImport } from './routes/rsc-slot-jsx-args' +import { Route as RscServerRedirectRouteImport } from './routes/rsc-server-redirect' +import { Route as RscServerNotFoundRouteImport } from './routes/rsc-server-not-found' import { Route as RscRequestHeadersRouteImport } from './routes/rsc-request-headers' import { Route as RscReactCacheRouteImport } from './routes/rsc-react-cache' import { Route as RscParallelRouteImport } from './routes/rsc-parallel' @@ -103,6 +105,16 @@ const RscSlotJsxArgsRoute = RscSlotJsxArgsRouteImport.update({ path: '/rsc-slot-jsx-args', getParentRoute: () => rootRouteImport, } as any) +const RscServerRedirectRoute = RscServerRedirectRouteImport.update({ + id: '/rsc-server-redirect', + path: '/rsc-server-redirect', + getParentRoute: () => rootRouteImport, +} as any) +const RscServerNotFoundRoute = RscServerNotFoundRouteImport.update({ + id: '/rsc-server-not-found', + path: '/rsc-server-not-found', + getParentRoute: () => rootRouteImport, +} as any) const RscRequestHeadersRoute = RscRequestHeadersRouteImport.update({ id: '/rsc-request-headers', path: '/rsc-request-headers', @@ -298,6 +310,8 @@ export interface FileRoutesByFullPath { '/rsc-parallel': typeof RscParallelRoute '/rsc-react-cache': typeof RscReactCacheRoute '/rsc-request-headers': typeof RscRequestHeadersRoute + '/rsc-server-not-found': typeof RscServerNotFoundRoute + '/rsc-server-redirect': typeof RscServerRedirectRoute '/rsc-slot-jsx-args': typeof RscSlotJsxArgsRoute '/rsc-slots': typeof RscSlotsRoute '/rsc-ssr-data-only': typeof RscSsrDataOnlyRoute @@ -343,6 +357,8 @@ export interface FileRoutesByTo { '/rsc-parallel': typeof RscParallelRoute '/rsc-react-cache': typeof RscReactCacheRoute '/rsc-request-headers': typeof RscRequestHeadersRoute + '/rsc-server-not-found': typeof RscServerNotFoundRoute + '/rsc-server-redirect': typeof RscServerRedirectRoute '/rsc-slot-jsx-args': typeof RscSlotJsxArgsRoute '/rsc-slots': typeof RscSlotsRoute '/rsc-ssr-data-only': typeof RscSsrDataOnlyRoute @@ -389,6 +405,8 @@ export interface FileRoutesById { '/rsc-parallel': typeof RscParallelRoute '/rsc-react-cache': typeof RscReactCacheRoute '/rsc-request-headers': typeof RscRequestHeadersRoute + '/rsc-server-not-found': typeof RscServerNotFoundRoute + '/rsc-server-redirect': typeof RscServerRedirectRoute '/rsc-slot-jsx-args': typeof RscSlotJsxArgsRoute '/rsc-slots': typeof RscSlotsRoute '/rsc-ssr-data-only': typeof RscSsrDataOnlyRoute @@ -436,6 +454,8 @@ export interface FileRouteTypes { | '/rsc-parallel' | '/rsc-react-cache' | '/rsc-request-headers' + | '/rsc-server-not-found' + | '/rsc-server-redirect' | '/rsc-slot-jsx-args' | '/rsc-slots' | '/rsc-ssr-data-only' @@ -481,6 +501,8 @@ export interface FileRouteTypes { | '/rsc-parallel' | '/rsc-react-cache' | '/rsc-request-headers' + | '/rsc-server-not-found' + | '/rsc-server-redirect' | '/rsc-slot-jsx-args' | '/rsc-slots' | '/rsc-ssr-data-only' @@ -526,6 +548,8 @@ export interface FileRouteTypes { | '/rsc-parallel' | '/rsc-react-cache' | '/rsc-request-headers' + | '/rsc-server-not-found' + | '/rsc-server-redirect' | '/rsc-slot-jsx-args' | '/rsc-slots' | '/rsc-ssr-data-only' @@ -572,6 +596,8 @@ export interface RootRouteChildren { RscParallelRoute: typeof RscParallelRoute RscReactCacheRoute: typeof RscReactCacheRoute RscRequestHeadersRoute: typeof RscRequestHeadersRoute + RscServerNotFoundRoute: typeof RscServerNotFoundRoute + RscServerRedirectRoute: typeof RscServerRedirectRoute RscSlotJsxArgsRoute: typeof RscSlotJsxArgsRoute RscSlotsRoute: typeof RscSlotsRoute RscSsrDataOnlyRoute: typeof RscSsrDataOnlyRoute @@ -661,6 +687,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RscSlotJsxArgsRouteImport parentRoute: typeof rootRouteImport } + '/rsc-server-redirect': { + id: '/rsc-server-redirect' + path: '/rsc-server-redirect' + fullPath: '/rsc-server-redirect' + preLoaderRoute: typeof RscServerRedirectRouteImport + parentRoute: typeof rootRouteImport + } + '/rsc-server-not-found': { + id: '/rsc-server-not-found' + path: '/rsc-server-not-found' + fullPath: '/rsc-server-not-found' + preLoaderRoute: typeof RscServerNotFoundRouteImport + parentRoute: typeof rootRouteImport + } '/rsc-request-headers': { id: '/rsc-request-headers' path: '/rsc-request-headers' @@ -924,6 +964,8 @@ const rootRouteChildren: RootRouteChildren = { RscParallelRoute: RscParallelRoute, RscReactCacheRoute: RscReactCacheRoute, RscRequestHeadersRoute: RscRequestHeadersRoute, + RscServerNotFoundRoute: RscServerNotFoundRoute, + RscServerRedirectRoute: RscServerRedirectRoute, RscSlotJsxArgsRoute: RscSlotJsxArgsRoute, RscSlotsRoute: RscSlotsRoute, RscSsrDataOnlyRoute: RscSsrDataOnlyRoute, diff --git a/e2e/react-start/rsc/src/routes/__root.tsx b/e2e/react-start/rsc/src/routes/__root.tsx index ac169e7e94..9ebb559527 100644 --- a/e2e/react-start/rsc/src/routes/__root.tsx +++ b/e2e/react-start/rsc/src/routes/__root.tsx @@ -312,6 +312,22 @@ function RootComponent() { > Request Headers + + Server Not Found + + + Server Redirect + { + throw notFound() +}) + +export const Route = createFileRoute('/rsc-server-not-found')({ + loader: async () => { + await getMissingResource() + }, + component: RscServerNotFoundComponent, + notFoundComponent: RscServerNotFoundBoundary, +}) + +function RscServerNotFoundComponent() { + return ( +
+

+ RSC Server Not Found +

+
+ ) +} + +function RscServerNotFoundBoundary() { + return ( +
+

+ RSC Server Function Not Found +

+

+ The server function threw `notFound()` and the route-level not found + boundary rendered instead of crashing. +

+ + Back home + +
+ ) +} diff --git a/e2e/react-start/rsc/src/routes/rsc-server-redirect.tsx b/e2e/react-start/rsc/src/routes/rsc-server-redirect.tsx new file mode 100644 index 0000000000..a8e14f6c93 --- /dev/null +++ b/e2e/react-start/rsc/src/routes/rsc-server-redirect.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { pageStyles } from '~/utils/styles' + +const redirectToBasic = createServerFn({ + method: 'GET', +}).handler(async () => { + throw redirect({ to: '/rsc-basic' }) +}) + +export const Route = createFileRoute('/rsc-server-redirect')({ + loader: async () => { + await redirectToBasic() + }, + component: RscServerRedirectComponent, +}) + +function RscServerRedirectComponent() { + return ( +
+

+ RSC Server Redirect +

+
+ ) +} diff --git a/e2e/react-start/rsc/tests/rsc-server-not-found.spec.ts b/e2e/react-start/rsc/tests/rsc-server-not-found.spec.ts new file mode 100644 index 0000000000..c760b40140 --- /dev/null +++ b/e2e/react-start/rsc/tests/rsc-server-not-found.spec.ts @@ -0,0 +1,47 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.describe('RSC Server Not Found Tests', () => { + test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + ], + }) + + test('SSR renders the route notFoundComponent when a server function throws notFound', async ({ + page, + }) => { + const response = await page.goto('/rsc-server-not-found') + await page.waitForURL('/rsc-server-not-found') + + expect(response?.status()).toBe(404) + + await expect( + page.getByTestId('rsc-server-not-found-boundary'), + ).toBeVisible() + await expect(page.getByTestId('rsc-server-not-found-heading')).toHaveText( + 'RSC Server Function Not Found', + ) + await expect( + page.getByTestId('rsc-server-not-found-message'), + ).toContainText('server function threw `notFound()`') + }) + + test('client navigation renders the route notFoundComponent when a server function throws notFound', async ({ + page, + }) => { + await page.goto('/') + await page.waitForURL('/') + await expect(page.getByTestId('app-hydrated')).toHaveText('hydrated') + + await page.getByTestId('nav-server-not-found').click() + await page.waitForURL('/rsc-server-not-found') + + await expect( + page.getByTestId('rsc-server-not-found-boundary'), + ).toBeVisible() + await expect(page.getByTestId('rsc-server-not-found-heading')).toHaveText( + 'RSC Server Function Not Found', + ) + }) +}) diff --git a/e2e/react-start/rsc/tests/rsc-server-redirect.spec.ts b/e2e/react-start/rsc/tests/rsc-server-redirect.spec.ts new file mode 100644 index 0000000000..da0a84fb94 --- /dev/null +++ b/e2e/react-start/rsc/tests/rsc-server-redirect.spec.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test.describe('RSC Server Redirect Tests', () => { + test('SSR follows route redirect when a server function throws redirect', async ({ + page, + }) => { + const response = await page.goto('/rsc-server-redirect') + await page.waitForURL('/rsc-basic') + + expect(response?.status()).toBe(200) + await expect(page.getByTestId('rsc-basic-content')).toBeVisible() + }) + + test('client navigation follows route redirect when a server function throws redirect', async ({ + page, + }) => { + await page.goto('/') + await page.waitForURL('/') + await expect(page.getByTestId('app-hydrated')).toHaveText('hydrated') + + await page.getByTestId('nav-server-redirect').click() + await page.waitForURL('/rsc-basic') + + await expect(page.getByTestId('rsc-basic-content')).toBeVisible() + }) +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 20eaf383bb..63e72a4944 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -48,6 +48,10 @@ "module": "dist/esm/index.js", "exports": { ".": { + "react-server": { + "types": "./dist/esm/index.rsc.d.ts", + "default": "./dist/esm/index.rsc.js" + }, "import": { "types": "./dist/esm/index.d.ts", "development": "./dist/esm/index.dev.js", diff --git a/packages/react-router/src/index.rsc.ts b/packages/react-router/src/index.rsc.ts new file mode 100644 index 0000000000..d5baf7e952 --- /dev/null +++ b/packages/react-router/src/index.rsc.ts @@ -0,0 +1,11 @@ +export * from './index' + +export { + notFound, + isNotFound, + redirect, + isRedirect, + rootRouteId, +} from '@tanstack/router-core' + +export type { NotFoundError } from '@tanstack/router-core' diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts index f21400df28..0eb9e3c77e 100644 --- a/packages/react-router/vite.config.ts +++ b/packages/react-router/vite.config.ts @@ -28,6 +28,7 @@ export default mergeConfig( tsconfigPath: './tsconfig.build.json', entry: [ './src/index.tsx', + './src/index.rsc.ts', './src/index.dev.tsx', './src/ssr/client.ts', './src/ssr/server.ts',