diff --git a/docs/start/config.json b/docs/start/config.json index 7d00579c24a..9bf039c5d13 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -113,6 +113,10 @@ "label": "Selective SSR", "to": "framework/react/guide/selective-ssr" }, + { + "label": "Selective Hydration", + "to": "framework/react/guide/selective-hydration" + }, { "label": "SPA Mode", "to": "framework/react/guide/spa-mode" diff --git a/docs/start/framework/react/guide/selective-hydration.md b/docs/start/framework/react/guide/selective-hydration.md new file mode 100644 index 00000000000..c3a18ff93e5 --- /dev/null +++ b/docs/start/framework/react/guide/selective-hydration.md @@ -0,0 +1,475 @@ +--- +id: selective-hydration +title: Selective Client-Side Hydration +--- + +## What is Selective Hydration? + +In TanStack Start, routes are server-side rendered by default and then "hydrated" on the client - meaning React attaches event handlers and makes the page interactive. The `hydrate` option gives you **page-level** control over which routes should include the React hydration bundle and become interactive on the client. + +**Note:** This is **page-level** selective hydration, meaning the entire page either hydrates or doesn't. For **component-level** selective hydration (Server Components), where individual components can opt in or out of hydration, stay tuned for upcoming releases from TanStack Router. + +When you set `hydrate: false` on a route: + +- ✅ The page is still server-side rendered (SSR) and SEO-friendly +- ✅ All content loads instantly with no JavaScript required +- ✅ External scripts from the `head()` option still work +- ❌ React is not loaded or hydrated (no interactivity) +- ❌ No `useState`, `useEffect`, or event handlers +- ❌ Navigation becomes traditional full-page reloads + +**Important:** `hydrate: false` should only be used when you want a truly static site with absolutely no React on the client. Most applications should keep the default `hydrate: true` behavior, even for primarily static content, as you typically need at least some client-side interactivity for navigation, analytics, or other features. + +## How does this compare to `ssr: false`? + +The `ssr` and `hydrate` options serve different purposes: + +| Option | Controls | Use Case | +|--------|----------|----------| +| **`ssr`** | Server-side rendering and data loading | Control when `beforeLoad`/`loader` run and when components render on the server | +| **`hydrate`** | Client-side React hydration | Control whether the page becomes interactive after being server-rendered | + +**Common Patterns:** + +```tsx +// Full SSR + Hydration (default) +ssr: true, hydrate: true +// ✅ Renders on server ✅ Data loads on server ✅ Interactive on client + +// Static server-rendered page (no JavaScript) +ssr: true, hydrate: false +// ✅ Renders on server ✅ Data loads on server ❌ NOT interactive + +// Client-only page +ssr: false, hydrate: true +// ❌ Renders on client ❌ Data loads on client ✅ Interactive on client + +// This combination doesn't make sense +ssr: false, hydrate: false +// ❌ Nothing renders or works (avoid this) +``` + +**When to use `hydrate: false`:** +- Truly static sites where you want zero React on the client +- Pages where you're willing to give up client-side navigation and all interactivity +- Print-only views or embedded content +- **Note:** This is a very rare use case - most sites should use `hydrate: true` (default) + +**When to use `ssr: false`:** +- Pages using browser-only APIs (localStorage, canvas) +- Client-only routes (user dashboards, admin panels) +- Pages with heavy client-side state + +## Configuration + +You can control whether a route includes the React hydration bundle using the `hydrate` property. This is an **opt-in/opt-out mechanism**: + +- **Not set (undefined)**: The default behavior is to hydrate +- **`hydrate: true`**: Explicitly ensures hydration (useful to override a parent's `hydrate: false`) +- **`hydrate: false`**: Explicitly disables hydration + +You can change the default behavior using the `defaultHydrate` option in `createStart`: + +```tsx +// src/start.ts +import { createStart } from '@tanstack/react-start' + +export const startInstance = createStart(() => ({ + // Disable hydration by default + defaultHydrate: false, +})) +``` + +### Omitting `hydrate` (default behavior) + +When you don't specify the `hydrate` option, the default behavior is to hydrate. The page is server-rendered and React hydrates it on the client, making it fully interactive: + +```tsx +// src/routes/posts/$postId.tsx +export const Route = createFileRoute('/posts/$postId')({ + // hydrate not specified - will use default behavior (hydrate) + loader: async ({ params }) => { + return { post: await fetchPost(params.postId) } + }, + component: PostPage, +}) + +function PostPage() { + const { post } = Route.useLoaderData() + const [likes, setLikes] = useState(0) + + return ( +
+

{post.title}

+

{post.content}

+ +
+ ) +} +``` + +**Result:** +- ✅ Server renders the HTML +- ✅ Loader data is sent to the client +- ✅ React hydrates and attaches event handlers +- ✅ The "Like" button works + +### Explicitly setting `hydrate: true` + +You can explicitly set `hydrate: true` to **ensure** a route is always hydrated, even if a parent or nested route has `hydrate: false`. This is useful for resolving conflicts in the route tree: + +```tsx +// Parent route disables hydration +export const Route = createFileRoute('/blog')({ + hydrate: false, + component: BlogLayout, +}) + +// Child route explicitly ensures hydration +export const Route = createFileRoute('/blog/interactive')({ + hydrate: true, // Explicitly opt-in to ensure hydration + component: InteractiveBlogPost, +}) +``` + +**When this creates a conflict:** +- If a route has `hydrate: false` and a child has explicit `hydrate: true`, this creates a conflict +- TanStack Router will **not hydrate** the page (safer default) and log a warning +- You should resolve the conflict by making the hydration settings consistent + +**When to explicitly use `hydrate: true`:** +- To document intent that a route must be hydrated +- To override a parent's `hydrate: false` (though this creates a conflict that needs resolution) +- To ensure hydration when `defaultHydrate: false` is set globally + +### `hydrate: false` + +This disables client-side hydration. The page is server-rendered but React is not loaded: + +```tsx +// src/routes/legal/privacy.tsx +export const Route = createFileRoute('/legal/privacy')({ + hydrate: false, + loader: async () => { + return { lastUpdated: '2024-01-15' } + }, + head: () => ({ + meta: [ + { title: 'Privacy Policy' }, + { name: 'description', content: 'Our privacy policy' }, + ], + // External scripts still work + scripts: [ + { src: 'https://analytics.example.com/script.js' }, + ], + }), + component: PrivacyPage, +}) + +function PrivacyPage() { + const { lastUpdated } = Route.useLoaderData() + + return ( +
+

Privacy Policy

+

Last updated: {lastUpdated}

+

This is a static page with no JavaScript...

+ {/* This button won't work (no event handlers attached) */} + +
+ ) +} +``` + +**Result:** +- ✅ Server renders the HTML with all content +- ✅ Loader data is used during SSR +- ✅ Meta tags and external scripts are included +- ❌ React is NOT loaded on the client +- ❌ No JavaScript bundle downloaded +- ❌ Event handlers don't work +- ❌ `useState`, `useEffect`, etc. don't run + +**What gets excluded when `hydrate: false`:** +- React runtime bundle +- React DOM bundle +- TanStack Router client bundle +- Your application code +- Hydration data script (`window.$_TSR`) +- Modulepreload links for JavaScript + +**What still works:** +- Server-side rendering +- Loader data (during SSR only) +- Meta tags from `head()` +- External scripts from `head()` +- CSS and stylesheets +- Images and static assets + +## Inheritance + +A child route inherits the `hydrate` configuration of its parent. If **any route** in the match has `hydrate: false`, the entire page will not be hydrated: + +```tsx +root { hydrate: true } + blog { hydrate: false } + $postId { hydrate: true } +``` + +**Result:** +- The `blog` route sets `hydrate: false` +- Even though `$postId` sets `hydrate: true`, it inherits `false` from its parent +- The entire page will NOT be hydrated + +This differs from the `ssr` option, which allows child routes to be "more restrictive" than their parents. With `hydrate`, if any route in the tree has `hydrate: false`, the entire match is treated as non-hydrated. + +**Why this design?** + +Hydration is an all-or-nothing operation for the entire page. You can't hydrate part of a React tree without hydrating its ancestors. This ensures: + +- ✅ Predictable behavior +- ✅ No partial hydration issues +- ✅ Clear mental model + +## Combining with `ssr` Options + +You can combine `ssr` and `hydrate` options for different behaviors: + +### Static Content Page (Server-Rendered, No JavaScript) + +Perfect for SEO-focused content that doesn't need interactivity: + +```tsx +export const Route = createFileRoute('/blog/$slug')({ + ssr: true, // Render on server + hydrate: false, // Don't load React on client + loader: async ({ params }) => { + return { post: await fetchPost(params.slug) } + }, + head: ({ loaderData }) => ({ + meta: [ + { title: loaderData.post.title }, + { name: 'description', content: loaderData.post.excerpt }, + ], + }), + component: BlogPost, +}) +``` + +**Benefits:** +- ⚡ Fastest possible page load (no JavaScript) +- 🔍 Perfect SEO (fully rendered HTML) +- 📦 Smallest possible bundle size + +### Client-Only Interactive Page + +For pages that need browser APIs: + +```tsx +export const Route = createFileRoute('/dashboard')({ + ssr: false, // Don't render on server (needs browser APIs) + hydrate: true, // Load React and make interactive + loader: () => { + // Runs only on client + return { user: getUserFromLocalStorage() } + }, + component: Dashboard, +}) +``` + +### Hybrid: Server Data, Client Rendering + +Load data on server but render on client (useful for heavy visualizations): + +```tsx +export const Route = createFileRoute('/reports/$id')({ + ssr: 'data-only', // Load data on server, but don't render + hydrate: true, // Hydrate and render on client + loader: async ({ params }) => { + // Runs on server during SSR + return { report: await fetchReport(params.id) } + }, + component: ReportVisualization, // Renders only on client +}) +``` + +## Conflict Detection + +The `hydrate` option is an **opt-in/opt-out mechanism**. Conflicts occur when: +- Some routes explicitly set `hydrate: false` (opt-out) +- Other routes explicitly set `hydrate: true` (opt-in to ensure hydration) + +**Note:** Routes that don't specify `hydrate` (using the default behavior) do not create conflicts. + +When TanStack Start detects conflicting explicit settings: + +1. **Does not hydrate the page** (safer default - respects the `false` setting) +2. **Logs a warning** to help you debug: + +``` +⚠️ [TanStack Router] Conflicting hydrate options detected in route matches. +Some routes have hydrate: false while others have hydrate: true. +The page will NOT be hydrated, but this may not be the intended behavior. +Please ensure all routes in the match have consistent hydrate settings. +``` + +**How to resolve conflicts:** +- **Option 1:** Remove explicit `hydrate: true` from child routes (let them use default behavior or inherit from parent) +- **Option 2:** Remove `hydrate: false` from parent routes if child routes need hydration +- **Option 3:** Restructure your routes so interactive and static pages are in separate branches + +## Use Cases + +### 📄 When to use `hydrate: false`: + +**Important:** This is a very rare use case. Most applications should keep the default hydration behavior. + +Use `hydrate: false` only when: +- You want a **truly static site** with zero React on the client +- You're willing to give up **all client-side navigation** and interactivity +- You want to avoid the overhead of loading React entirely +- Examples: Print-only views, embedded content, purely informational pages + +### ⚡ When to explicitly use `hydrate: true`: + +You typically don't need to explicitly set `hydrate: true` since it's the default behavior. However, explicitly setting it is useful when: +- **Documenting intent**: Making it clear that a route requires hydration +- **Overriding `defaultHydrate: false`**: When you've set a global default of `false` but need specific routes to hydrate +- **Attempting to override a parent**: Though this creates a conflict (see Conflict Detection above), you might use `hydrate: true` to signal that a child route needs hydration even if a parent has `hydrate: false` + +For general interactive features (forms, dashboards, real-time updates, user interactions), simply omit the `hydrate` option and use the default behavior. + +## Performance Impact + +When you use `hydrate: false`: + +**Bundle Size Savings:** +- React Runtime: ~130KB (gzipped: ~45KB) +- React DOM: ~130KB (gzipped: ~45KB) +- TanStack Router Client: ~40KB (gzipped: ~12KB) +- Your App Code: Varies + +**Total Savings:** ~300KB+ (gzipped: ~100KB+) per page + +**Load Time Improvements:** +- No JavaScript parsing/execution +- No hydration time +- Instant interactivity (no loading state) + +## Example: Mixed Application + +A typical application might use both options: + +```tsx +// Root route - enable hydration by default +export const Route = createRootRoute({ + component: RootComponent, +}) + +// Marketing pages - no hydration needed +export const Route = createFileRoute('/about')({ + hydrate: false, + component: AboutPage, +}) + +export const Route = createFileRoute('/blog/$slug')({ + hydrate: false, + loader: fetchBlogPost, + component: BlogPost, +}) + +// Legal pages - no hydration needed +export const Route = createFileRoute('/legal/privacy')({ + hydrate: false, + component: PrivacyPolicy, +}) + +// App pages - need hydration for interactivity +export const Route = createFileRoute('/dashboard')({ + hydrate: true, // explicit for clarity + loader: fetchDashboardData, + component: Dashboard, +}) + +export const Route = createFileRoute('/settings')({ + hydrate: true, + component: SettingsPage, +}) +``` + +## Development Mode + +In development mode, React Refresh (HMR) is kept even when `hydrate: false` is set. This allows you to: + +- ✅ See changes instantly during development +- ✅ Test the no-JavaScript experience in production builds + +To test the true `hydrate: false` experience: + +```bash +# Build for production +pnpm build + +# Preview the production build +pnpm preview +``` + +## Troubleshooting + +### My page has `hydrate: false` but JavaScript is still loading + +**Check:** +1. Are any parent routes setting `hydrate: true`? +2. Are you in development mode? (React Refresh is kept for HMR) +3. Did you rebuild after changing the option? + +```bash +pnpm build +``` + +### My interactive features stopped working + +If you set `hydrate: false`, all React features will stop working: +- Event handlers (`onClick`, `onChange`) +- Hooks (`useState`, `useEffect`, `useQuery`) +- Context providers +- Client-side routing + +**Solution:** Explicitly set `hydrate: true` or remove the option (which defaults to hydrating). + +### I'm seeing hydration errors + +Hydration errors occur when server-rendered HTML doesn't match the client. If you have these errors: + +1. Consider `ssr: 'data-only'` (skip server rendering, only load data) +2. Or use `hydrate: false` if the page doesn't need interactivity + +See the [Hydration Errors guide](./hydration-errors) for more details. + +## Summary + +The `hydrate` option gives you precise **page-level** control over client-side React hydration: + +- **Default (omitted)**: Pages hydrate by default - Full SSR + Hydration = Interactive pages +- **`hydrate: true`**: Explicitly ensures a page is hydrated (useful for conflict resolution or documenting intent) +- **`hydrate: false`**: Static server-rendered pages with no JavaScript +- **Opt-in/opt-out mechanism**: Conflicts occur only when explicit `true` and `false` values are both present +- **Inheritance**: If any route has `hydrate: false`, the page won't hydrate + +**Note:** This is **page-level** selective hydration. For **component-level** selective hydration (Server Components), stay tuned for upcoming releases from TanStack Router. + +Use `hydrate: false` for truly static pages to: +- ⚡ Reduce bundle size +- 🚀 Improve load times +- 📉 Minimize JavaScript overhead +- 🔍 Maintain perfect SEO + +For interactive pages, simply omit the `hydrate` option to use the default behavior: +- 🎯 User interactions +- 💾 Client-side state +- 🔄 Real-time updates +- ⚡ Dynamic behavior diff --git a/e2e/react-start/basic-hydrate-false/.gitignore b/e2e/react-start/basic-hydrate-false/.gitignore new file mode 100644 index 00000000000..a79d5cf1299 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/.gitignore @@ -0,0 +1,20 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output + +/build/ +/api/ +/server/build +/public/build +# Sentry Config File +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-start/basic-hydrate-false/.prettierignore b/e2e/react-start/basic-hydrate-false/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/e2e/react-start/basic-hydrate-false/README.md b/e2e/react-start/basic-hydrate-false/README.md new file mode 100644 index 00000000000..445a8066297 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/README.md @@ -0,0 +1,152 @@ +# TanStack Router - Hydrate Feature Demo + +This demo showcases the new `hydrate` option for TanStack Start routes, which allows you to create server-rendered pages without client-side React hydration. + +## What is `hydrate: false`? + +The `hydrate` option controls whether a route should include the React hydration bundle and become interactive on the client. When set to `false`: + +- ✅ Page is fully server-side rendered +- ✅ All content is SEO-friendly +- ✅ Fast initial page load (no JavaScript bundle) +- ❌ No React interactivity (no state, effects, or event handlers) +- ❌ Navigation uses traditional full-page reloads + +## Running the Demo + +### 1. Install Dependencies + +```bash +pnpm install +``` + +### 2. Build and Start the Server + +```bash +pnpm build +pnpm start +``` + +### 3. Open in Browser + +Navigate to `http://localhost:3000` + +## Demo Routes + +### Home Page (`/`) +- Overview of the feature +- Links to comparison pages + +### Hydrated Route (`/hydrated`) +- **Normal React route** with full interactivity +- Includes React bundle and hydration +- Features: + - Interactive counter with state + - Effect hooks that run on mount + - Full React functionality + +### Static Route (`/static`) +- **Server-rendered static page** with `hydrate: false` +- No React bundle loaded +- Features: + - Server-side rendered content + - Loader data still works + - No client-side interactivity + - Demonstrates what doesn't work without hydration + +## Code Example + +### Hydrated Route (Default) + +```typescript +export const Route = createFileRoute('/hydrated')({ + // hydrate: true is the default (can be omitted) + loader: () => ({ message: 'Server data' }), + component: MyComponent, +}) +``` + +### Static Route + +```typescript +export const Route = createFileRoute('/static')({ + hydrate: false, // 🔑 This is the key option + loader: () => ({ message: 'Server data' }), + component: MyStaticComponent, +}) +``` + +## Inspecting the Difference + +### Open DevTools → Network Tab + +1. Visit `/hydrated` and filter by "JS" + - You'll see: React, React DOM, Router bundles, and app code + - Look for hydration data in the HTML (`window.$_TSR`) + +2. Visit `/static` and filter by "JS" + - You'll see: NO application bundles + - NO hydration data in the HTML + - Only external scripts you explicitly added + +### Check Page Source + +**Hydrated Route:** +```html + + +``` + +**Static Route:** +```html + +``` + +## Use Cases + +Perfect for: +- 📄 Legal pages (Terms, Privacy Policy) +- 📝 Blog posts and articles +- 🎯 Marketing landing pages +- 📚 Documentation +- 🔍 SEO-focused content pages +- ⚡ When you want minimal JavaScript + +## Advanced Features + +### Inheritance +If a parent route has `hydrate: false`, all child routes inherit it unless explicitly overridden. + +### Dynamic Hydration +You can use a function to determine hydration dynamically: + +```typescript +export const Route = createFileRoute('/dynamic')({ + hydrate: ({ search, params }) => { + // Conditionally hydrate based on query params or other factors + return search.interactive === 'true' + }, + component: MyComponent, +}) +``` + +### Conflict Warning +If conflicting `hydrate` settings exist in the route tree (some true, some false), the page will hydrate and log a warning to help you debug. + +## Running Tests + +```bash +pnpm test:e2e +``` + +The E2E tests verify: +- Main bundle scripts are excluded when `hydrate: false` +- Serialized data is excluded +- External scripts still work +- Server-rendered content is correct +- Meta tags are properly rendered + +## Learn More + +- [TanStack Router Documentation](https://tanstack.com/router) +- [TanStack Start Documentation](https://tanstack.com/router/latest/docs/framework/react/start) diff --git a/e2e/react-start/basic-hydrate-false/package.json b/e2e/react-start/basic-hydrate-false/package.json new file mode 100644 index 00000000000..27cccea7a47 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/package.json @@ -0,0 +1,53 @@ +{ + "name": "tanstack-react-start-e2e-basic-hydrate-false", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "build:spa": "MODE=spa vite build && tsc --noEmit", + "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "start:spa": "node server.js", + "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", + "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", + "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", + "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", + "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:preview" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "js-cookie": "^3.0.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.1.15", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "combinate": "^1.1.11", + "postcss": "^8.5.1", + "srvx": "^0.8.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^3.24.2" + } +} diff --git a/e2e/react-start/basic-hydrate-false/playwright.config.ts b/e2e/react-start/basic-hydrate-false/playwright.config.ts new file mode 100644 index 00000000000..aa29067f463 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/playwright.config.ts @@ -0,0 +1,72 @@ +import { defineConfig, devices } from '@playwright/test' +import { + getDummyServerPort, + getTestServerPort, +} from '@tanstack/router-e2e-utils' +import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' +import { isPreview } from './tests/utils/isPreview' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort( + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, +) +const START_PORT = await getTestServerPort( + `${packageJson.name}${isSpaMode ? '_spa_start' : ''}`, +) +const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +const spaModeCommand = `pnpm build:spa && pnpm start:spa` +const ssrModeCommand = `pnpm build && pnpm start` +const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` + +const getCommand = () => { + if (isSpaMode) return spaModeCommand + if (isPrerender) return prerenderModeCommand + if (isPreview) return previewModeCommand + return ssrModeCommand +} +console.log('running in spa mode: ', isSpaMode.toString()) +console.log('running in prerender mode: ', isPrerender.toString()) +console.log('running in preview mode: ', isPreview.toString()) +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: getCommand(), + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + MODE: process.env.MODE || '', + VITE_NODE_ENV: 'test', + VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), + VITE_SERVER_PORT: String(PORT), + START_PORT: String(START_PORT), + PORT: String(PORT), + }, + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/e2e/react-start/basic-hydrate-false/postcss.config.mjs b/e2e/react-start/basic-hydrate-false/postcss.config.mjs new file mode 100644 index 00000000000..a7f73a2d1d7 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/e2e/react-start/basic-hydrate-false/public/android-chrome-192x192.png b/e2e/react-start/basic-hydrate-false/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/android-chrome-192x192.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/android-chrome-512x512.png b/e2e/react-start/basic-hydrate-false/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/android-chrome-512x512.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/apple-touch-icon.png b/e2e/react-start/basic-hydrate-false/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/apple-touch-icon.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/favicon-16x16.png b/e2e/react-start/basic-hydrate-false/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/favicon-16x16.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/favicon-32x32.png b/e2e/react-start/basic-hydrate-false/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/favicon-32x32.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/favicon.ico b/e2e/react-start/basic-hydrate-false/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/favicon.ico differ diff --git a/e2e/react-start/basic-hydrate-false/public/favicon.png b/e2e/react-start/basic-hydrate-false/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/react-start/basic-hydrate-false/public/favicon.png differ diff --git a/e2e/react-start/basic-hydrate-false/public/script.js b/e2e/react-start/basic-hydrate-false/public/script.js new file mode 100644 index 00000000000..897477e7d0a --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/public/script.js @@ -0,0 +1,2 @@ +console.log('SCRIPT_1 loaded') +window.SCRIPT_1 = true diff --git a/e2e/react-start/basic-hydrate-false/public/script2.js b/e2e/react-start/basic-hydrate-false/public/script2.js new file mode 100644 index 00000000000..819af30daf9 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/public/script2.js @@ -0,0 +1,2 @@ +console.log('SCRIPT_2 loaded') +window.SCRIPT_2 = true diff --git a/e2e/react-start/basic-hydrate-false/public/site.webmanifest b/e2e/react-start/basic-hydrate-false/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/e2e/react-start/basic-hydrate-false/server.js b/e2e/react-start/basic-hydrate-false/server.js new file mode 100644 index 00000000000..d618ab4bce3 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/server.js @@ -0,0 +1,67 @@ +import { toNodeHandler } from 'srvx/node' +import path from 'node:path' +import express from 'express' +import { createProxyMiddleware } from 'http-proxy-middleware' + +const port = process.env.PORT || 3000 + +const startPort = process.env.START_PORT || 3001 + +export async function createStartServer() { + const server = (await import('./dist/server/server.js')).default + const nodeHandler = toNodeHandler(server.fetch) + + const app = express() + + app.use(express.static('./dist/client')) + + app.use(async (req, res, next) => { + try { + await nodeHandler(req, res) + } catch (error) { + next(error) + } + }) + + return { app } +} + +export async function createSpaServer() { + const app = express() + + app.use( + '/api', + createProxyMiddleware({ + target: `http://localhost:${startPort}/api`, // Replace with your target server's URL + changeOrigin: false, // Needed for virtual hosted sites, + }), + ) + + app.use( + '/_serverFn', + createProxyMiddleware({ + target: `http://localhost:${startPort}/_serverFn`, // Replace with your target server's URL + changeOrigin: false, // Needed for virtual hosted sites, + }), + ) + + app.use(express.static('./dist/client')) + + app.get('/{*splat}', (req, res) => { + res.sendFile(path.resolve('./dist/client/index.html')) + }) + + return { app } +} + +createSpaServer().then(async ({ app }) => + app.listen(port, () => { + console.info(`Client Server: http://localhost:${port}`) + }), +) + +createStartServer().then(async ({ app }) => + app.listen(startPort, () => { + console.info(`Start Server: http://localhost:${startPort}`) + }), +) diff --git a/e2e/react-start/basic-hydrate-false/src/client.tsx b/e2e/react-start/basic-hydrate-false/src/client.tsx new file mode 100644 index 00000000000..fdfbde86770 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/client.tsx @@ -0,0 +1,16 @@ +// DO NOT DELETE THIS FILE!!! +// This file is a good smoke test to make sure the custom client entry is working +import { StrictMode, startTransition } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { StartClient } from '@tanstack/react-start/client' + +console.log("[client-entry]: using custom client entry in 'src/client.tsx'") + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/e2e/react-start/basic-hydrate-false/src/components/CustomMessage.tsx b/e2e/react-start/basic-hydrate-false/src/components/CustomMessage.tsx new file mode 100644 index 00000000000..d00e4eac60b --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/components/CustomMessage.tsx @@ -0,0 +1,10 @@ +import * as React from 'react' + +export function CustomMessage({ message }: { message: string }) { + return ( +
+
This is a custom message:
+

{message}

+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/basic-hydrate-false/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..ef2daa1ea1d --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/components/NotFound.tsx b/e2e/react-start/basic-hydrate-false/src/components/NotFound.tsx new file mode 100644 index 00000000000..4e84e3f8e00 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from '@tanstack/react-router' + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/components/RedirectOnClick.tsx b/e2e/react-start/basic-hydrate-false/src/components/RedirectOnClick.tsx new file mode 100644 index 00000000000..37b434c2e7d --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/components/RedirectOnClick.tsx @@ -0,0 +1,26 @@ +import { useServerFn } from '@tanstack/react-start' +import { throwRedirect } from './throwRedirect' + +interface RedirectOnClickProps { + target: 'internal' | 'external' + reloadDocument?: boolean + externalHost?: string +} + +export function RedirectOnClick({ + target, + reloadDocument, + externalHost, +}: RedirectOnClickProps) { + const execute = useServerFn(throwRedirect) + return ( + + ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/components/throwRedirect.ts b/e2e/react-start/basic-hydrate-false/src/components/throwRedirect.ts new file mode 100644 index 00000000000..0081a3c5602 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/components/throwRedirect.ts @@ -0,0 +1,23 @@ +import { redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +export const throwRedirect = createServerFn() + .inputValidator( + (opts: { + target: 'internal' | 'external' + reloadDocument?: boolean + externalHost?: string + }) => opts, + ) + .handler((ctx) => { + if (ctx.data.target === 'internal') { + throw redirect({ + to: '/posts', + reloadDocument: ctx.data.reloadDocument, + }) + } + const href = ctx.data.externalHost ?? 'http://example.com' + throw redirect({ + href, + }) + }) diff --git a/e2e/react-start/basic-hydrate-false/src/routeTree.gen.ts b/e2e/react-start/basic-hydrate-false/src/routeTree.gen.ts new file mode 100644 index 00000000000..98eaa38ffee --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routeTree.gen.ts @@ -0,0 +1,1078 @@ +/* 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 { Route as rootRouteImport } from './routes/__root' +import { Route as Char45824Char54620Char48124Char44397RouteImport } from './routes/대한민국' +import { Route as UsersRouteImport } from './routes/users' +import { Route as StreamRouteImport } from './routes/stream' +import { Route as StaticRouteImport } from './routes/static' +import { Route as ScriptsRouteImport } from './routes/scripts' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as LinksRouteImport } from './routes/links' +import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' +import { Route as HydratedRouteImport } from './routes/hydrated' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as LayoutRouteImport } from './routes/_layout' +import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' +import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' +import { Route as IndexRouteImport } from './routes/index' +import { Route as UsersIndexRouteImport } from './routes/users.index' +import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' +import { Route as RedirectIndexRouteImport } from './routes/redirect/index' +import { Route as PostsIndexRouteImport } from './routes/posts.index' +import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' +import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' +import { Route as UsersUserIdRouteImport } from './routes/users.$userId' +import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' +import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' +import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' +import { Route as NotFoundViaHeadRouteImport } from './routes/not-found/via-head' +import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad' +import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target' +import { Route as ApiUsersRouteImport } from './routes/api.users' +import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' +import { Route as RedirectTargetIndexRouteImport } from './routes/redirect/$target/index' +import { Route as RedirectTargetViaLoaderRouteImport } from './routes/redirect/$target/via-loader' +import { Route as RedirectTargetViaBeforeLoadRouteImport } from './routes/redirect/$target/via-beforeLoad' +import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep' +import { Route as ApiUsersIdRouteImport } from './routes/api/users.$id' +import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b' +import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a' +import { Route as RedirectTargetServerFnIndexRouteImport } from './routes/redirect/$target/serverFn/index' +import { Route as RedirectTargetServerFnViaUseServerFnRouteImport } from './routes/redirect/$target/serverFn/via-useServerFn' +import { Route as RedirectTargetServerFnViaLoaderRouteImport } from './routes/redirect/$target/serverFn/via-loader' +import { Route as RedirectTargetServerFnViaBeforeLoadRouteImport } from './routes/redirect/$target/serverFn/via-beforeLoad' +import { Route as FooBarQuxHereRouteImport } from './routes/foo/$bar/$qux/_here' +import { Route as FooBarQuxHereIndexRouteImport } from './routes/foo/$bar/$qux/_here/index' + +const FooBarQuxRouteImport = createFileRoute('/foo/$bar/$qux')() + +const Char45824Char54620Char48124Char44397Route = + Char45824Char54620Char48124Char44397RouteImport.update({ + id: '/대한민국', + path: '/대한민국', + getParentRoute: () => rootRouteImport, + } as any) +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) +const StaticRoute = StaticRouteImport.update({ + id: '/static', + path: '/static', + getParentRoute: () => rootRouteImport, +} as any) +const ScriptsRoute = ScriptsRouteImport.update({ + id: '/scripts', + path: '/scripts', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const LinksRoute = LinksRouteImport.update({ + id: '/links', + path: '/links', + getParentRoute: () => rootRouteImport, +} as any) +const InlineScriptsRoute = InlineScriptsRouteImport.update({ + id: '/inline-scripts', + path: '/inline-scripts', + getParentRoute: () => rootRouteImport, +} as any) +const HydratedRoute = HydratedRouteImport.update({ + id: '/hydrated', + path: '/hydrated', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', + getParentRoute: () => rootRouteImport, +} as any) +const SearchParamsRouteRoute = SearchParamsRouteRouteImport.update({ + id: '/search-params', + path: '/search-params', + getParentRoute: () => rootRouteImport, +} as any) +const NotFoundRouteRoute = NotFoundRouteRouteImport.update({ + id: '/not-found', + path: '/not-found', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersIndexRoute = UsersIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => UsersRoute, +} as any) +const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => SearchParamsRouteRoute, +} as any) +const RedirectIndexRoute = RedirectIndexRouteImport.update({ + id: '/redirect/', + path: '/redirect/', + getParentRoute: () => rootRouteImport, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRoute, +} as any) +const NotFoundIndexRoute = NotFoundIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => NotFoundRouteRoute, +} as any) +const MultiCookieRedirectIndexRoute = + MultiCookieRedirectIndexRouteImport.update({ + id: '/multi-cookie-redirect/', + path: '/multi-cookie-redirect/', + getParentRoute: () => rootRouteImport, + } as any) +const UsersUserIdRoute = UsersUserIdRouteImport.update({ + id: '/$userId', + path: '/$userId', + getParentRoute: () => UsersRoute, +} as any) +const SearchParamsLoaderThrowsRedirectRoute = + SearchParamsLoaderThrowsRedirectRouteImport.update({ + id: '/loader-throws-redirect', + path: '/loader-throws-redirect', + getParentRoute: () => SearchParamsRouteRoute, + } as any) +const SearchParamsDefaultRoute = SearchParamsDefaultRouteImport.update({ + id: '/default', + path: '/default', + getParentRoute: () => SearchParamsRouteRoute, +} as any) +const RedirectTargetRoute = RedirectTargetRouteImport.update({ + id: '/redirect/$target', + path: '/redirect/$target', + getParentRoute: () => rootRouteImport, +} as any) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRoute, +} as any) +const NotFoundViaLoaderRoute = NotFoundViaLoaderRouteImport.update({ + id: '/via-loader', + path: '/via-loader', + getParentRoute: () => NotFoundRouteRoute, +} as any) +const NotFoundViaHeadRoute = NotFoundViaHeadRouteImport.update({ + id: '/via-head', + path: '/via-head', + getParentRoute: () => NotFoundRouteRoute, +} as any) +const NotFoundViaBeforeLoadRoute = NotFoundViaBeforeLoadRouteImport.update({ + id: '/via-beforeLoad', + path: '/via-beforeLoad', + getParentRoute: () => NotFoundRouteRoute, +} as any) +const MultiCookieRedirectTargetRoute = + MultiCookieRedirectTargetRouteImport.update({ + id: '/multi-cookie-redirect/target', + path: '/multi-cookie-redirect/target', + getParentRoute: () => rootRouteImport, + } as any) +const ApiUsersRoute = ApiUsersRouteImport.update({ + id: '/api/users', + path: '/api/users', + getParentRoute: () => rootRouteImport, +} as any) +const LayoutLayout2Route = LayoutLayout2RouteImport.update({ + id: '/_layout-2', + getParentRoute: () => LayoutRoute, +} as any) +const FooBarQuxRoute = FooBarQuxRouteImport.update({ + id: '/foo/$bar/$qux', + path: '/foo/$bar/$qux', + getParentRoute: () => rootRouteImport, +} as any) +const RedirectTargetIndexRoute = RedirectTargetIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => RedirectTargetRoute, +} as any) +const RedirectTargetViaLoaderRoute = RedirectTargetViaLoaderRouteImport.update({ + id: '/via-loader', + path: '/via-loader', + getParentRoute: () => RedirectTargetRoute, +} as any) +const RedirectTargetViaBeforeLoadRoute = + RedirectTargetViaBeforeLoadRouteImport.update({ + id: '/via-beforeLoad', + path: '/via-beforeLoad', + getParentRoute: () => RedirectTargetRoute, + } as any) +const PostsPostIdDeepRoute = PostsPostIdDeepRouteImport.update({ + id: '/posts_/$postId/deep', + path: '/posts/$postId/deep', + getParentRoute: () => rootRouteImport, +} as any) +const ApiUsersIdRoute = ApiUsersIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ApiUsersRoute, +} as any) +const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({ + id: '/layout-b', + path: '/layout-b', + getParentRoute: () => LayoutLayout2Route, +} as any) +const LayoutLayout2LayoutARoute = LayoutLayout2LayoutARouteImport.update({ + id: '/layout-a', + path: '/layout-a', + getParentRoute: () => LayoutLayout2Route, +} as any) +const RedirectTargetServerFnIndexRoute = + RedirectTargetServerFnIndexRouteImport.update({ + id: '/serverFn/', + path: '/serverFn/', + getParentRoute: () => RedirectTargetRoute, + } as any) +const RedirectTargetServerFnViaUseServerFnRoute = + RedirectTargetServerFnViaUseServerFnRouteImport.update({ + id: '/serverFn/via-useServerFn', + path: '/serverFn/via-useServerFn', + getParentRoute: () => RedirectTargetRoute, + } as any) +const RedirectTargetServerFnViaLoaderRoute = + RedirectTargetServerFnViaLoaderRouteImport.update({ + id: '/serverFn/via-loader', + path: '/serverFn/via-loader', + getParentRoute: () => RedirectTargetRoute, + } as any) +const RedirectTargetServerFnViaBeforeLoadRoute = + RedirectTargetServerFnViaBeforeLoadRouteImport.update({ + id: '/serverFn/via-beforeLoad', + path: '/serverFn/via-beforeLoad', + getParentRoute: () => RedirectTargetRoute, + } as any) +const FooBarQuxHereRoute = FooBarQuxHereRouteImport.update({ + id: '/_here', + getParentRoute: () => FooBarQuxRoute, +} as any) +const FooBarQuxHereIndexRoute = FooBarQuxHereIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => FooBarQuxHereRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren + '/deferred': typeof DeferredRoute + '/hydrated': typeof HydratedRoute + '/inline-scripts': typeof InlineScriptsRoute + '/links': typeof LinksRoute + '/posts': typeof PostsRouteWithChildren + '/scripts': typeof ScriptsRoute + '/static': typeof StaticRoute + '/stream': typeof StreamRoute + '/users': typeof UsersRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/api/users': typeof ApiUsersRouteWithChildren + '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute + '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute + '/not-found/via-head': typeof NotFoundViaHeadRoute + '/not-found/via-loader': typeof NotFoundViaLoaderRoute + '/posts/$postId': typeof PostsPostIdRoute + '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/users/$userId': typeof UsersUserIdRoute + '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute + '/not-found/': typeof NotFoundIndexRoute + '/posts/': typeof PostsIndexRoute + '/redirect': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute + '/users/': typeof UsersIndexRoute + '/layout-a': typeof LayoutLayout2LayoutARoute + '/layout-b': typeof LayoutLayout2LayoutBRoute + '/api/users/$id': typeof ApiUsersIdRoute + '/posts/$postId/deep': typeof PostsPostIdDeepRoute + '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute + '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/redirect/$target/': typeof RedirectTargetIndexRoute + '/foo/$bar/$qux': typeof FooBarQuxHereRouteWithChildren + '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute + '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute + '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute + '/redirect/$target/serverFn': typeof RedirectTargetServerFnIndexRoute + '/foo/$bar/$qux/': typeof FooBarQuxHereIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/deferred': typeof DeferredRoute + '/hydrated': typeof HydratedRoute + '/inline-scripts': typeof InlineScriptsRoute + '/links': typeof LinksRoute + '/scripts': typeof ScriptsRoute + '/static': typeof StaticRoute + '/stream': typeof StreamRoute + '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/api/users': typeof ApiUsersRouteWithChildren + '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute + '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute + '/not-found/via-head': typeof NotFoundViaHeadRoute + '/not-found/via-loader': typeof NotFoundViaLoaderRoute + '/posts/$postId': typeof PostsPostIdRoute + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/users/$userId': typeof UsersUserIdRoute + '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute + '/not-found': typeof NotFoundIndexRoute + '/posts': typeof PostsIndexRoute + '/redirect': typeof RedirectIndexRoute + '/search-params': typeof SearchParamsIndexRoute + '/users': typeof UsersIndexRoute + '/layout-a': typeof LayoutLayout2LayoutARoute + '/layout-b': typeof LayoutLayout2LayoutBRoute + '/api/users/$id': typeof ApiUsersIdRoute + '/posts/$postId/deep': typeof PostsPostIdDeepRoute + '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute + '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/redirect/$target': typeof RedirectTargetIndexRoute + '/foo/$bar/$qux': typeof FooBarQuxHereIndexRoute + '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute + '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute + '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute + '/redirect/$target/serverFn': typeof RedirectTargetServerFnIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/not-found': typeof NotFoundRouteRouteWithChildren + '/search-params': typeof SearchParamsRouteRouteWithChildren + '/_layout': typeof LayoutRouteWithChildren + '/deferred': typeof DeferredRoute + '/hydrated': typeof HydratedRoute + '/inline-scripts': typeof InlineScriptsRoute + '/links': typeof LinksRoute + '/posts': typeof PostsRouteWithChildren + '/scripts': typeof ScriptsRoute + '/static': typeof StaticRoute + '/stream': typeof StreamRoute + '/users': typeof UsersRouteWithChildren + '/대한민국': typeof Char45824Char54620Char48124Char44397Route + '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren + '/api/users': typeof ApiUsersRouteWithChildren + '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute + '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute + '/not-found/via-head': typeof NotFoundViaHeadRoute + '/not-found/via-loader': typeof NotFoundViaLoaderRoute + '/posts/$postId': typeof PostsPostIdRoute + '/redirect/$target': typeof RedirectTargetRouteWithChildren + '/search-params/default': typeof SearchParamsDefaultRoute + '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute + '/users/$userId': typeof UsersUserIdRoute + '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute + '/not-found/': typeof NotFoundIndexRoute + '/posts/': typeof PostsIndexRoute + '/redirect/': typeof RedirectIndexRoute + '/search-params/': typeof SearchParamsIndexRoute + '/users/': typeof UsersIndexRoute + '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute + '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute + '/api/users/$id': typeof ApiUsersIdRoute + '/posts_/$postId/deep': typeof PostsPostIdDeepRoute + '/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute + '/redirect/$target/via-loader': typeof RedirectTargetViaLoaderRoute + '/redirect/$target/': typeof RedirectTargetIndexRoute + '/foo/$bar/$qux': typeof FooBarQuxRouteWithChildren + '/foo/$bar/$qux/_here': typeof FooBarQuxHereRouteWithChildren + '/redirect/$target/serverFn/via-beforeLoad': typeof RedirectTargetServerFnViaBeforeLoadRoute + '/redirect/$target/serverFn/via-loader': typeof RedirectTargetServerFnViaLoaderRoute + '/redirect/$target/serverFn/via-useServerFn': typeof RedirectTargetServerFnViaUseServerFnRoute + '/redirect/$target/serverFn/': typeof RedirectTargetServerFnIndexRoute + '/foo/$bar/$qux/_here/': typeof FooBarQuxHereIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/not-found' + | '/search-params' + | '/deferred' + | '/hydrated' + | '/inline-scripts' + | '/links' + | '/posts' + | '/scripts' + | '/static' + | '/stream' + | '/users' + | '/대한민국' + | '/api/users' + | '/multi-cookie-redirect/target' + | '/not-found/via-beforeLoad' + | '/not-found/via-head' + | '/not-found/via-loader' + | '/posts/$postId' + | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' + | '/users/$userId' + | '/multi-cookie-redirect' + | '/not-found/' + | '/posts/' + | '/redirect' + | '/search-params/' + | '/users/' + | '/layout-a' + | '/layout-b' + | '/api/users/$id' + | '/posts/$postId/deep' + | '/redirect/$target/via-beforeLoad' + | '/redirect/$target/via-loader' + | '/redirect/$target/' + | '/foo/$bar/$qux' + | '/redirect/$target/serverFn/via-beforeLoad' + | '/redirect/$target/serverFn/via-loader' + | '/redirect/$target/serverFn/via-useServerFn' + | '/redirect/$target/serverFn' + | '/foo/$bar/$qux/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/deferred' + | '/hydrated' + | '/inline-scripts' + | '/links' + | '/scripts' + | '/static' + | '/stream' + | '/대한민국' + | '/api/users' + | '/multi-cookie-redirect/target' + | '/not-found/via-beforeLoad' + | '/not-found/via-head' + | '/not-found/via-loader' + | '/posts/$postId' + | '/search-params/default' + | '/search-params/loader-throws-redirect' + | '/users/$userId' + | '/multi-cookie-redirect' + | '/not-found' + | '/posts' + | '/redirect' + | '/search-params' + | '/users' + | '/layout-a' + | '/layout-b' + | '/api/users/$id' + | '/posts/$postId/deep' + | '/redirect/$target/via-beforeLoad' + | '/redirect/$target/via-loader' + | '/redirect/$target' + | '/foo/$bar/$qux' + | '/redirect/$target/serverFn/via-beforeLoad' + | '/redirect/$target/serverFn/via-loader' + | '/redirect/$target/serverFn/via-useServerFn' + | '/redirect/$target/serverFn' + id: + | '__root__' + | '/' + | '/not-found' + | '/search-params' + | '/_layout' + | '/deferred' + | '/hydrated' + | '/inline-scripts' + | '/links' + | '/posts' + | '/scripts' + | '/static' + | '/stream' + | '/users' + | '/대한민국' + | '/_layout/_layout-2' + | '/api/users' + | '/multi-cookie-redirect/target' + | '/not-found/via-beforeLoad' + | '/not-found/via-head' + | '/not-found/via-loader' + | '/posts/$postId' + | '/redirect/$target' + | '/search-params/default' + | '/search-params/loader-throws-redirect' + | '/users/$userId' + | '/multi-cookie-redirect/' + | '/not-found/' + | '/posts/' + | '/redirect/' + | '/search-params/' + | '/users/' + | '/_layout/_layout-2/layout-a' + | '/_layout/_layout-2/layout-b' + | '/api/users/$id' + | '/posts_/$postId/deep' + | '/redirect/$target/via-beforeLoad' + | '/redirect/$target/via-loader' + | '/redirect/$target/' + | '/foo/$bar/$qux' + | '/foo/$bar/$qux/_here' + | '/redirect/$target/serverFn/via-beforeLoad' + | '/redirect/$target/serverFn/via-loader' + | '/redirect/$target/serverFn/via-useServerFn' + | '/redirect/$target/serverFn/' + | '/foo/$bar/$qux/_here/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren + SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren + LayoutRoute: typeof LayoutRouteWithChildren + DeferredRoute: typeof DeferredRoute + HydratedRoute: typeof HydratedRoute + InlineScriptsRoute: typeof InlineScriptsRoute + LinksRoute: typeof LinksRoute + PostsRoute: typeof PostsRouteWithChildren + ScriptsRoute: typeof ScriptsRoute + StaticRoute: typeof StaticRoute + StreamRoute: typeof StreamRoute + UsersRoute: typeof UsersRouteWithChildren + Char45824Char54620Char48124Char44397Route: typeof Char45824Char54620Char48124Char44397Route + ApiUsersRoute: typeof ApiUsersRouteWithChildren + MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute + RedirectTargetRoute: typeof RedirectTargetRouteWithChildren + MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute + RedirectIndexRoute: typeof RedirectIndexRoute + PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute + FooBarQuxRoute: typeof FooBarQuxRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/대한민국': { + id: '/대한민국' + path: '/대한민국' + fullPath: '/대한민국' + preLoaderRoute: typeof Char45824Char54620Char48124Char44397RouteImport + parentRoute: typeof rootRouteImport + } + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } + '/static': { + id: '/static' + path: '/static' + fullPath: '/static' + preLoaderRoute: typeof StaticRouteImport + parentRoute: typeof rootRouteImport + } + '/scripts': { + id: '/scripts' + path: '/scripts' + fullPath: '/scripts' + preLoaderRoute: typeof ScriptsRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/links': { + id: '/links' + path: '/links' + fullPath: '/links' + preLoaderRoute: typeof LinksRouteImport + parentRoute: typeof rootRouteImport + } + '/inline-scripts': { + id: '/inline-scripts' + path: '/inline-scripts' + fullPath: '/inline-scripts' + preLoaderRoute: typeof InlineScriptsRouteImport + parentRoute: typeof rootRouteImport + } + '/hydrated': { + id: '/hydrated' + path: '/hydrated' + fullPath: '/hydrated' + preLoaderRoute: typeof HydratedRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout': { + id: '/_layout' + path: '' + fullPath: '' + preLoaderRoute: typeof LayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/search-params': { + id: '/search-params' + path: '/search-params' + fullPath: '/search-params' + preLoaderRoute: typeof SearchParamsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/not-found': { + id: '/not-found' + path: '/not-found' + fullPath: '/not-found' + preLoaderRoute: typeof NotFoundRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/': { + id: '/users/' + path: '/' + fullPath: '/users/' + preLoaderRoute: typeof UsersIndexRouteImport + parentRoute: typeof UsersRoute + } + '/search-params/': { + id: '/search-params/' + path: '/' + fullPath: '/search-params/' + preLoaderRoute: typeof SearchParamsIndexRouteImport + parentRoute: typeof SearchParamsRouteRoute + } + '/redirect/': { + id: '/redirect/' + path: '/redirect' + fullPath: '/redirect' + preLoaderRoute: typeof RedirectIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRoute + } + '/not-found/': { + id: '/not-found/' + path: '/' + fullPath: '/not-found/' + preLoaderRoute: typeof NotFoundIndexRouteImport + parentRoute: typeof NotFoundRouteRoute + } + '/multi-cookie-redirect/': { + id: '/multi-cookie-redirect/' + path: '/multi-cookie-redirect' + fullPath: '/multi-cookie-redirect' + preLoaderRoute: typeof MultiCookieRedirectIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/$userId': { + id: '/users/$userId' + path: '/$userId' + fullPath: '/users/$userId' + preLoaderRoute: typeof UsersUserIdRouteImport + parentRoute: typeof UsersRoute + } + '/search-params/loader-throws-redirect': { + id: '/search-params/loader-throws-redirect' + path: '/loader-throws-redirect' + fullPath: '/search-params/loader-throws-redirect' + preLoaderRoute: typeof SearchParamsLoaderThrowsRedirectRouteImport + parentRoute: typeof SearchParamsRouteRoute + } + '/search-params/default': { + id: '/search-params/default' + path: '/default' + fullPath: '/search-params/default' + preLoaderRoute: typeof SearchParamsDefaultRouteImport + parentRoute: typeof SearchParamsRouteRoute + } + '/redirect/$target': { + id: '/redirect/$target' + path: '/redirect/$target' + fullPath: '/redirect/$target' + preLoaderRoute: typeof RedirectTargetRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRoute + } + '/not-found/via-loader': { + id: '/not-found/via-loader' + path: '/via-loader' + fullPath: '/not-found/via-loader' + preLoaderRoute: typeof NotFoundViaLoaderRouteImport + parentRoute: typeof NotFoundRouteRoute + } + '/not-found/via-head': { + id: '/not-found/via-head' + path: '/via-head' + fullPath: '/not-found/via-head' + preLoaderRoute: typeof NotFoundViaHeadRouteImport + parentRoute: typeof NotFoundRouteRoute + } + '/not-found/via-beforeLoad': { + id: '/not-found/via-beforeLoad' + path: '/via-beforeLoad' + fullPath: '/not-found/via-beforeLoad' + preLoaderRoute: typeof NotFoundViaBeforeLoadRouteImport + parentRoute: typeof NotFoundRouteRoute + } + '/multi-cookie-redirect/target': { + id: '/multi-cookie-redirect/target' + path: '/multi-cookie-redirect/target' + fullPath: '/multi-cookie-redirect/target' + preLoaderRoute: typeof MultiCookieRedirectTargetRouteImport + parentRoute: typeof rootRouteImport + } + '/api/users': { + id: '/api/users' + path: '/api/users' + fullPath: '/api/users' + preLoaderRoute: typeof ApiUsersRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout/_layout-2': { + id: '/_layout/_layout-2' + path: '' + fullPath: '' + preLoaderRoute: typeof LayoutLayout2RouteImport + parentRoute: typeof LayoutRoute + } + '/foo/$bar/$qux': { + id: '/foo/$bar/$qux' + path: '/foo/$bar/$qux' + fullPath: '/foo/$bar/$qux' + preLoaderRoute: typeof FooBarQuxRouteImport + parentRoute: typeof rootRouteImport + } + '/redirect/$target/': { + id: '/redirect/$target/' + path: '/' + fullPath: '/redirect/$target/' + preLoaderRoute: typeof RedirectTargetIndexRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/redirect/$target/via-loader': { + id: '/redirect/$target/via-loader' + path: '/via-loader' + fullPath: '/redirect/$target/via-loader' + preLoaderRoute: typeof RedirectTargetViaLoaderRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/redirect/$target/via-beforeLoad': { + id: '/redirect/$target/via-beforeLoad' + path: '/via-beforeLoad' + fullPath: '/redirect/$target/via-beforeLoad' + preLoaderRoute: typeof RedirectTargetViaBeforeLoadRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/posts_/$postId/deep': { + id: '/posts_/$postId/deep' + path: '/posts/$postId/deep' + fullPath: '/posts/$postId/deep' + preLoaderRoute: typeof PostsPostIdDeepRouteImport + parentRoute: typeof rootRouteImport + } + '/api/users/$id': { + id: '/api/users/$id' + path: '/$id' + fullPath: '/api/users/$id' + preLoaderRoute: typeof ApiUsersIdRouteImport + parentRoute: typeof ApiUsersRoute + } + '/_layout/_layout-2/layout-b': { + id: '/_layout/_layout-2/layout-b' + path: '/layout-b' + fullPath: '/layout-b' + preLoaderRoute: typeof LayoutLayout2LayoutBRouteImport + parentRoute: typeof LayoutLayout2Route + } + '/_layout/_layout-2/layout-a': { + id: '/_layout/_layout-2/layout-a' + path: '/layout-a' + fullPath: '/layout-a' + preLoaderRoute: typeof LayoutLayout2LayoutARouteImport + parentRoute: typeof LayoutLayout2Route + } + '/redirect/$target/serverFn/': { + id: '/redirect/$target/serverFn/' + path: '/serverFn' + fullPath: '/redirect/$target/serverFn' + preLoaderRoute: typeof RedirectTargetServerFnIndexRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/redirect/$target/serverFn/via-useServerFn': { + id: '/redirect/$target/serverFn/via-useServerFn' + path: '/serverFn/via-useServerFn' + fullPath: '/redirect/$target/serverFn/via-useServerFn' + preLoaderRoute: typeof RedirectTargetServerFnViaUseServerFnRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/redirect/$target/serverFn/via-loader': { + id: '/redirect/$target/serverFn/via-loader' + path: '/serverFn/via-loader' + fullPath: '/redirect/$target/serverFn/via-loader' + preLoaderRoute: typeof RedirectTargetServerFnViaLoaderRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/redirect/$target/serverFn/via-beforeLoad': { + id: '/redirect/$target/serverFn/via-beforeLoad' + path: '/serverFn/via-beforeLoad' + fullPath: '/redirect/$target/serverFn/via-beforeLoad' + preLoaderRoute: typeof RedirectTargetServerFnViaBeforeLoadRouteImport + parentRoute: typeof RedirectTargetRoute + } + '/foo/$bar/$qux/_here': { + id: '/foo/$bar/$qux/_here' + path: '/foo/$bar/$qux' + fullPath: '/foo/$bar/$qux' + preLoaderRoute: typeof FooBarQuxHereRouteImport + parentRoute: typeof FooBarQuxRoute + } + '/foo/$bar/$qux/_here/': { + id: '/foo/$bar/$qux/_here/' + path: '/' + fullPath: '/foo/$bar/$qux/' + preLoaderRoute: typeof FooBarQuxHereIndexRouteImport + parentRoute: typeof FooBarQuxHereRoute + } + } +} + +interface NotFoundRouteRouteChildren { + NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute + NotFoundViaHeadRoute: typeof NotFoundViaHeadRoute + NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute + NotFoundIndexRoute: typeof NotFoundIndexRoute +} + +const NotFoundRouteRouteChildren: NotFoundRouteRouteChildren = { + NotFoundViaBeforeLoadRoute: NotFoundViaBeforeLoadRoute, + NotFoundViaHeadRoute: NotFoundViaHeadRoute, + NotFoundViaLoaderRoute: NotFoundViaLoaderRoute, + NotFoundIndexRoute: NotFoundIndexRoute, +} + +const NotFoundRouteRouteWithChildren = NotFoundRouteRoute._addFileChildren( + NotFoundRouteRouteChildren, +) + +interface SearchParamsRouteRouteChildren { + SearchParamsDefaultRoute: typeof SearchParamsDefaultRoute + SearchParamsLoaderThrowsRedirectRoute: typeof SearchParamsLoaderThrowsRedirectRoute + SearchParamsIndexRoute: typeof SearchParamsIndexRoute +} + +const SearchParamsRouteRouteChildren: SearchParamsRouteRouteChildren = { + SearchParamsDefaultRoute: SearchParamsDefaultRoute, + SearchParamsLoaderThrowsRedirectRoute: SearchParamsLoaderThrowsRedirectRoute, + SearchParamsIndexRoute: SearchParamsIndexRoute, +} + +const SearchParamsRouteRouteWithChildren = + SearchParamsRouteRoute._addFileChildren(SearchParamsRouteRouteChildren) + +interface LayoutLayout2RouteChildren { + LayoutLayout2LayoutARoute: typeof LayoutLayout2LayoutARoute + LayoutLayout2LayoutBRoute: typeof LayoutLayout2LayoutBRoute +} + +const LayoutLayout2RouteChildren: LayoutLayout2RouteChildren = { + LayoutLayout2LayoutARoute: LayoutLayout2LayoutARoute, + LayoutLayout2LayoutBRoute: LayoutLayout2LayoutBRoute, +} + +const LayoutLayout2RouteWithChildren = LayoutLayout2Route._addFileChildren( + LayoutLayout2RouteChildren, +) + +interface LayoutRouteChildren { + LayoutLayout2Route: typeof LayoutLayout2RouteWithChildren +} + +const LayoutRouteChildren: LayoutRouteChildren = { + LayoutLayout2Route: LayoutLayout2RouteWithChildren, +} + +const LayoutRouteWithChildren = + LayoutRoute._addFileChildren(LayoutRouteChildren) + +interface PostsRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRoute + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsPostIdRoute: PostsPostIdRoute, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +interface UsersRouteChildren { + UsersUserIdRoute: typeof UsersUserIdRoute + UsersIndexRoute: typeof UsersIndexRoute +} + +const UsersRouteChildren: UsersRouteChildren = { + UsersUserIdRoute: UsersUserIdRoute, + UsersIndexRoute: UsersIndexRoute, +} + +const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren) + +interface ApiUsersRouteChildren { + ApiUsersIdRoute: typeof ApiUsersIdRoute +} + +const ApiUsersRouteChildren: ApiUsersRouteChildren = { + ApiUsersIdRoute: ApiUsersIdRoute, +} + +const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( + ApiUsersRouteChildren, +) + +interface RedirectTargetRouteChildren { + RedirectTargetViaBeforeLoadRoute: typeof RedirectTargetViaBeforeLoadRoute + RedirectTargetViaLoaderRoute: typeof RedirectTargetViaLoaderRoute + RedirectTargetIndexRoute: typeof RedirectTargetIndexRoute + RedirectTargetServerFnViaBeforeLoadRoute: typeof RedirectTargetServerFnViaBeforeLoadRoute + RedirectTargetServerFnViaLoaderRoute: typeof RedirectTargetServerFnViaLoaderRoute + RedirectTargetServerFnViaUseServerFnRoute: typeof RedirectTargetServerFnViaUseServerFnRoute + RedirectTargetServerFnIndexRoute: typeof RedirectTargetServerFnIndexRoute +} + +const RedirectTargetRouteChildren: RedirectTargetRouteChildren = { + RedirectTargetViaBeforeLoadRoute: RedirectTargetViaBeforeLoadRoute, + RedirectTargetViaLoaderRoute: RedirectTargetViaLoaderRoute, + RedirectTargetIndexRoute: RedirectTargetIndexRoute, + RedirectTargetServerFnViaBeforeLoadRoute: + RedirectTargetServerFnViaBeforeLoadRoute, + RedirectTargetServerFnViaLoaderRoute: RedirectTargetServerFnViaLoaderRoute, + RedirectTargetServerFnViaUseServerFnRoute: + RedirectTargetServerFnViaUseServerFnRoute, + RedirectTargetServerFnIndexRoute: RedirectTargetServerFnIndexRoute, +} + +const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren( + RedirectTargetRouteChildren, +) + +interface FooBarQuxHereRouteChildren { + FooBarQuxHereIndexRoute: typeof FooBarQuxHereIndexRoute +} + +const FooBarQuxHereRouteChildren: FooBarQuxHereRouteChildren = { + FooBarQuxHereIndexRoute: FooBarQuxHereIndexRoute, +} + +const FooBarQuxHereRouteWithChildren = FooBarQuxHereRoute._addFileChildren( + FooBarQuxHereRouteChildren, +) + +interface FooBarQuxRouteChildren { + FooBarQuxHereRoute: typeof FooBarQuxHereRouteWithChildren +} + +const FooBarQuxRouteChildren: FooBarQuxRouteChildren = { + FooBarQuxHereRoute: FooBarQuxHereRouteWithChildren, +} + +const FooBarQuxRouteWithChildren = FooBarQuxRoute._addFileChildren( + FooBarQuxRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + NotFoundRouteRoute: NotFoundRouteRouteWithChildren, + SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, + LayoutRoute: LayoutRouteWithChildren, + DeferredRoute: DeferredRoute, + HydratedRoute: HydratedRoute, + InlineScriptsRoute: InlineScriptsRoute, + LinksRoute: LinksRoute, + PostsRoute: PostsRouteWithChildren, + ScriptsRoute: ScriptsRoute, + StaticRoute: StaticRoute, + StreamRoute: StreamRoute, + UsersRoute: UsersRouteWithChildren, + Char45824Char54620Char48124Char44397Route: + Char45824Char54620Char48124Char44397Route, + ApiUsersRoute: ApiUsersRouteWithChildren, + MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, + RedirectTargetRoute: RedirectTargetRouteWithChildren, + MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute, + RedirectIndexRoute: RedirectIndexRoute, + PostsPostIdDeepRoute: PostsPostIdDeepRoute, + FooBarQuxRoute: FooBarQuxRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/basic-hydrate-false/src/router.tsx b/e2e/react-start/basic-hydrate-false/src/router.tsx new file mode 100644 index 00000000000..fef35c9e067 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + }) + + return router +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/__root.tsx b/e2e/react-start/basic-hydrate-false/src/routes/__root.tsx new file mode 100644 index 00000000000..c7d87cad188 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/__root.tsx @@ -0,0 +1,188 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' +import appCss from '~/styles/app.css?url' +import { seo } from '~/utils/seo' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ...seo({ + title: + 'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework', + description: `TanStack Start is a type-safe, client-first, full-stack React framework. `, + }), + ], + links: [ + { rel: 'stylesheet', href: appCss }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/favicon-16x16.png', + }, + { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, + { rel: 'icon', href: '/favicon.ico' }, + ], + styles: [ + { + media: 'all and (min-width: 500px)', + children: ` + .inline-div { + color: white; + background-color: gray; + max-width: 250px; + }`, + }, + ], + }), + errorComponent: (props) => { + return ( + + + + ) + }, + notFoundComponent: () => , + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +const RouterDevtools = + process.env.NODE_ENV === 'production' + ? () => null // Render nothing in production + : React.lazy(() => + // Lazy load in development + import('@tanstack/react-router-devtools').then((res) => ({ + default: res.TanStackRouterDevtools, + })), + ) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + +
+ + Home + {' '} + + Posts + {' '} + + Users + {' '} + + Layout + {' '} + + Scripts + {' '} + + Inline Scripts + {' '} + + Deferred + {' '} + + redirect + {' '} + + This Route Does Not Exist + +
+
+ {children} +
This is an inline styled div
+ + + + + + + ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/_layout.tsx b/e2e/react-start/basic-hydrate-false/src/routes/_layout.tsx new file mode 100644 index 00000000000..02ddbb1cd94 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/_layout.tsx @@ -0,0 +1,16 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
I'm a layout
+
+ +
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2.tsx b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2.tsx new file mode 100644 index 00000000000..3b7dbf29031 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2.tsx @@ -0,0 +1,34 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout/_layout-2')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
I'm a nested layout
+
+ + Layout A + + + Layout B + +
+
+ +
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-a.tsx b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-a.tsx new file mode 100644 index 00000000000..61e19b4d9f1 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-a.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout/_layout-2/layout-a')({ + component: LayoutAComponent, +}) + +function LayoutAComponent() { + return
I'm layout A!
+} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-b.tsx b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-b.tsx new file mode 100644 index 00000000000..cceed1fb9ad --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-b.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout/_layout-2/layout-b')({ + component: LayoutBComponent, +}) + +function LayoutBComponent() { + return
I'm layout B!
+} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/api.users.ts b/e2e/react-start/basic-hydrate-false/src/routes/api.users.ts new file mode 100644 index 00000000000..a03076490b8 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/api.users.ts @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import axios from 'redaxios' + +import type { User } from '~/utils/users' + +let queryURL = 'https://jsonplaceholder.typicode.com' + +if (import.meta.env.VITE_NODE_ENV === 'test') { + queryURL = `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}` +} + +export const Route = createFileRoute('/api/users')({ + server: { + handlers: { + GET: async ({ request }) => { + console.info('Fetching users... @', request.url) + const res = await axios.get>(`${queryURL}/users`) + + const list = res.data.slice(0, 10) + + return json( + list.map((u) => ({ id: u.id, name: u.name, email: u.email })), + ) + }, + }, + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/api/users.$id.ts b/e2e/react-start/basic-hydrate-false/src/routes/api/users.$id.ts new file mode 100644 index 00000000000..2d2c279e931 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/api/users.$id.ts @@ -0,0 +1,32 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import axios from 'redaxios' +import type { User } from '~/utils/users' + +let queryURL = 'https://jsonplaceholder.typicode.com' + +if (import.meta.env.VITE_NODE_ENV === 'test') { + queryURL = `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}` +} + +export const Route = createFileRoute('/api/users/$id')({ + server: { + handlers: { + GET: async ({ request, params }) => { + console.info(`Fetching users by id=${params.id}... @`, request.url) + try { + const res = await axios.get(`${queryURL}/users/` + params.id) + + return json({ + id: res.data.id, + name: res.data.name, + email: res.data.email, + }) + } catch (e) { + console.error(e) + return json({ error: 'User not found' }, { status: 404 }) + } + }, + }, + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/deferred.tsx b/e2e/react-start/basic-hydrate-false/src/routes/deferred.tsx new file mode 100644 index 00000000000..9c6e3064b88 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/deferred.tsx @@ -0,0 +1,62 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { Suspense, useState } from 'react' + +const personServerFn = createServerFn({ method: 'GET' }) + .inputValidator((data: { name: string }) => data) + .handler(({ data }) => { + return { name: data.name, randomNumber: Math.floor(Math.random() * 100) } + }) + +const slowServerFn = createServerFn({ method: 'GET' }) + .inputValidator((data: { name: string }) => data) + .handler(async ({ data }) => { + await new Promise((r) => setTimeout(r, 1000)) + return { name: data.name, randomNumber: Math.floor(Math.random() * 100) } + }) + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + deferredStuff: new Promise((r) => + setTimeout(() => r('Hello deferred!'), 2000), + ), + deferredPerson: slowServerFn({ data: { name: 'Tanner Linsley' } }), + person: await personServerFn({ data: { name: 'John Doe' } }), + } + }, + component: Deferred, +}) + +function Deferred() { + const [count, setCount] = useState(0) + const { deferredStuff, deferredPerson, person } = Route.useLoaderData() + + return ( +
+
+ {person.name} - {person.randomNumber} +
+ Loading person...
}> + ( +
+ {data.name} - {data.randomNumber} +
+ )} + /> + + Loading stuff...}> +

{data}

} + /> +
+
Count: {count}
+
+ +
+ + ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here.tsx b/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here.tsx new file mode 100644 index 00000000000..95a5599e237 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here.tsx @@ -0,0 +1,16 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar/$qux/_here')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
I'm a deeper layout with parameters
+
+ +
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here/index.tsx new file mode 100644 index 00000000000..924c8bb3c16 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/foo/$bar/$qux/_here/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar/$qux/_here/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
OK you got me
+} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/hydrated.tsx b/e2e/react-start/basic-hydrate-false/src/routes/hydrated.tsx new file mode 100644 index 00000000000..8d99d83ca48 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/hydrated.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/hydrated')({ + // Default is hydrate: true, so this is a normal React route + loader: () => { + return { + serverTime: new Date().toISOString(), + message: 'This data was loaded on the server', + } + }, + head: () => ({ + meta: [ + { + title: 'Hydrated Route - TanStack Router Demo', + }, + { + name: 'description', + content: 'A fully interactive React page with hydration', + }, + ], + }), + component: HydratedComponent, +}) + +function HydratedComponent() { + const data = Route.useLoaderData() + const [count, setCount] = React.useState(0) + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) + + return ( +
+ + ← Back to Home + + +

✅ Hydrated Route

+ +
+
+

✨ Interactive Features

+ +
+

Counter (React State):

+
+ + + {count} + + +
+
+ +
+

Hydration Status:

+
+ {mounted ? '✅ Hydrated and Interactive' : '⏳ Server-Rendered'} +
+
+
+ +
+

📦 Server Data

+
+

+ Server Time:{' '} + + {data.serverTime} + +

+

+ Message: {data.message} +

+
+
+ +
+

🔍 What's Included

+

+ Open DevTools → Network → JS to see the loaded bundles: +

+
    +
  • React runtime bundle
  • +
  • React DOM bundle
  • +
  • TanStack Router bundle
  • +
  • Your application code
  • +
  • Hydration data (window.$_TSR)
  • +
+
+
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/index.tsx new file mode 100644 index 00000000000..743b1c99afa --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/index.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

+ TanStack Router - Hydrate Feature Demo +

+ +
+

+ This demo showcases the new hydrate option + for TanStack Start routes. +

+

+ The hydrate: false option allows you to + render pages that are server-side rendered but do not include the React hydration bundle. +

+
+ +
+ +

+ ✅ Hydrated Route +

+

+ A normal React route with full interactivity. Includes the React bundle and hydration. +

+ + + +

+ 🚫 Static Route (hydrate: false) +

+

+ A server-rendered static page. No React bundle, no hydration, no interactivity. +

+ +
+ +
+

💡 Use Cases

+
    +
  • Marketing/landing pages that don't need interactivity
  • +
  • Legal pages (Terms, Privacy Policy)
  • +
  • Blog posts or documentation
  • +
  • SEO-focused content pages
  • +
  • Reducing JavaScript bundle size for static content
  • +
+
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/inline-scripts.tsx b/e2e/react-start/basic-hydrate-false/src/routes/inline-scripts.tsx new file mode 100644 index 00000000000..86255c63bd4 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/inline-scripts.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/inline-scripts')({ + head: () => ({ + scripts: [ + { + children: + 'window.INLINE_SCRIPT_1 = true; console.log("Inline script 1 executed");', + }, + { + children: + 'window.INLINE_SCRIPT_2 = "test"; console.log("Inline script 2 executed");', + type: 'text/javascript', + }, + ], + }), + component: InlineScriptsComponent, +}) + +function InlineScriptsComponent() { + return ( +
+

Inline Scripts Test

+

+ This route tests inline script duplication prevention. Two inline + scripts should be loaded. +

+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/links.tsx b/e2e/react-start/basic-hydrate-false/src/routes/links.tsx new file mode 100644 index 00000000000..adc8fe9c4d7 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/links.tsx @@ -0,0 +1,47 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/links')({ + component: () => { + const navigate = Route.useNavigate() + return ( +
+

+ link test +

+
+ + Link to /posts + +
+
+ + Link to /posts (reloadDocument=true) + +
+
+ +
+
+ +
+
+ ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/index.tsx new file mode 100644 index 00000000000..7ae4947aef1 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/index.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { setCookie } from '@tanstack/react-start/server' + +const setMultipleCookiesAndRedirect = createServerFn().handler(() => { + // Set multiple cookies before redirecting + // This tests that multiple Set-Cookie headers are preserved during redirect + setCookie('session', 'session-value', { path: '/' }) + setCookie('csrf', 'csrf-token-value', { path: '/' }) + setCookie('theme', 'dark', { path: '/' }) + + throw redirect({ to: '/multi-cookie-redirect/target' }) +}) + +export const Route = createFileRoute('/multi-cookie-redirect/')({ + loader: () => setMultipleCookiesAndRedirect(), + component: () => null, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/target.tsx b/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/target.tsx new file mode 100644 index 00000000000..3b0cb20bef1 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/target.tsx @@ -0,0 +1,39 @@ +import { createFileRoute } from '@tanstack/react-router' +import Cookies from 'js-cookie' +import React, { useEffect } from 'react' + +export const Route = createFileRoute('/multi-cookie-redirect/target')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [cookies, setCookies] = React.useState>({}) + + useEffect(() => { + setCookies({ + session: Cookies.get('session') || '', + csrf: Cookies.get('csrf') || '', + theme: Cookies.get('theme') || '', + }) + }, []) + + return ( +
+

+ Multi Cookie Redirect Target +

+
+

+ Session cookie:{' '} + {cookies.session} +

+

+ CSRF cookie: {cookies.csrf} +

+

+ Theme cookie: {cookies.theme} +

+
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/not-found/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/not-found/index.tsx new file mode 100644 index 00000000000..722813fca36 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/not-found/index.tsx @@ -0,0 +1,41 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/not-found/')({ + component: () => { + const preload = Route.useSearch({ select: (s) => s.preload }) + return ( +
+
+ + via-beforeLoad + +
+
+ + via-loader + +
+
+ + via-head + +
+
+ ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/not-found/route.tsx b/e2e/react-start/basic-hydrate-false/src/routes/not-found/route.tsx new file mode 100644 index 00000000000..e604e098fd0 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/not-found/route.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router' +import z from 'zod' + +export const Route = createFileRoute('/not-found')({ + validateSearch: z.object({ + preload: z.literal(false).optional(), + }), +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-beforeLoad.tsx b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-beforeLoad.tsx new file mode 100644 index 00000000000..85164dbab5b --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-beforeLoad.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' + +export const Route = createFileRoute('/not-found/via-beforeLoad')({ + beforeLoad: () => { + throw notFound() + }, + component: RouteComponent, + notFoundComponent: () => { + return ( +
+ Not Found "/not-found/via-beforeLoad"! +
+ ) + }, +}) + +function RouteComponent() { + return ( +
+ Hello "/not-found/via-beforeLoad"! +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-head.tsx b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-head.tsx new file mode 100644 index 00000000000..7cd09f9fa31 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-head.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' + +export const Route = createFileRoute('/not-found/via-head')({ + head: () => { + throw notFound() + }, + component: RouteComponent, + notFoundComponent: () => { + return ( +
+ Not Found "/not-found/via-head"! +
+ ) + }, +}) + +function RouteComponent() { + return ( +
+ Hello "/not-found/via-head"! +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-loader.tsx b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-loader.tsx new file mode 100644 index 00000000000..6174b27f775 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/not-found/via-loader.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' + +export const Route = createFileRoute('/not-found/via-loader')({ + loader: () => { + throw notFound() + }, + component: RouteComponent, + notFoundComponent: () => { + return ( +
+ Not Found "/not-found/via-loader"! +
+ ) + }, +}) + +function RouteComponent() { + return ( +
+ Hello "/not-found/via-loader"! +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/posts.$postId.tsx b/e2e/react-start/basic-hydrate-false/src/routes/posts.$postId.tsx new file mode 100644 index 00000000000..09d00685829 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/posts.$postId.tsx @@ -0,0 +1,39 @@ +import { ErrorComponent, Link, createFileRoute } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' + +import { fetchPost } from '~/utils/posts' +import { NotFound } from '~/components/NotFound' + +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params: { postId } }) => fetchPost({ data: postId }), + errorComponent: PostErrorComponent, + component: PostComponent, + notFoundComponent: () => { + return Post not found + }, +}) + +function PostErrorComponent({ error }: ErrorComponentProps) { + return +} + +function PostComponent() { + const post = Route.useLoaderData() + + return ( +
+

{post.title}

+
{post.body}
+ + Deep View + +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/posts.index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/posts.index.tsx new file mode 100644 index 00000000000..1bad79b0f7c --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/posts.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/')({ + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return
Select a post.
+} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/posts.tsx b/e2e/react-start/basic-hydrate-false/src/routes/posts.tsx new file mode 100644 index 00000000000..0f69c183419 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/posts.tsx @@ -0,0 +1,46 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' + +import { fetchPosts } from '~/utils/posts' + +export const Route = createFileRoute('/posts')({ + head: () => ({ + meta: [ + { + title: 'Posts page', + }, + ], + }), + loader: async () => fetchPosts(), + component: PostsComponent, +}) + +function PostsComponent() { + const posts = Route.useLoaderData() + + return ( +
+
    + {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( + (post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + }, + )} +
+
+ +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/posts_.$postId.deep.tsx b/e2e/react-start/basic-hydrate-false/src/routes/posts_.$postId.deep.tsx new file mode 100644 index 00000000000..1f785f5f7ff --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/posts_.$postId.deep.tsx @@ -0,0 +1,30 @@ +import { ErrorComponent, Link, createFileRoute } from '@tanstack/react-router' +import type { ErrorComponentProps } from '@tanstack/react-router' +import { fetchPost } from '~/utils/posts' + +export const Route = createFileRoute('/posts_/$postId/deep')({ + loader: async ({ params: { postId } }) => fetchPost({ data: postId }), + errorComponent: PostDeepErrorComponent, + component: PostDeepComponent, +}) + +function PostDeepErrorComponent({ error }: ErrorComponentProps) { + return +} + +function PostDeepComponent() { + const post = Route.useLoaderData() + + return ( +
+ + ← All Posts + +

{post.title}

+
{post.body}
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target.tsx new file mode 100644 index 00000000000..686f1c7056a --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, retainSearchParams } from '@tanstack/react-router' +import z from 'zod' + +export const Route = createFileRoute('/redirect/$target')({ + params: { + parse: (p) => + z + .object({ + target: z.union([z.literal('internal'), z.literal('external')]), + }) + .parse(p), + }, + validateSearch: z.object({ + reloadDocument: z.boolean().optional(), + preload: z.literal(false).optional(), + externalHost: z.string().optional(), + }), + search: { + middlewares: [retainSearchParams(['externalHost'])], + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/index.tsx new file mode 100644 index 00000000000..f399d965cf1 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/index.tsx @@ -0,0 +1,76 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/$target/')({ + component: () => { + const preload = Route.useSearch({ select: (s) => s.preload }) + return ( +
+
+ + via-beforeLoad + +
+
+ + via-beforeLoad (reloadDocument=true) + +
+
+ + via-loader + +
+
+ + via-loader (reloadDocument=true) + +
+
+ + serverFn + +
+
+ ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/index.tsx new file mode 100644 index 00000000000..ddb0d8e9964 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/index.tsx @@ -0,0 +1,86 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/$target/serverFn/')({ + component: () => ( +
+

+ redirect test with server functions (target {Route.useParams().target}) +

+
+ + via-beforeLoad + +
+
+ + via-beforeLoad (reloadDocument=true) + +
+
+ + via-loader + +
+
+ + via-loader (reloadDocument=true) + +
+
+ + via-useServerFn + +
+
+ + via-useServerFn (reloadDocument=true) + +
+
+ ), +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-beforeLoad.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-beforeLoad.tsx new file mode 100644 index 00000000000..eed26559f34 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-beforeLoad.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router' +import { throwRedirect } from '~/components/throwRedirect' + +export const Route = createFileRoute( + '/redirect/$target/serverFn/via-beforeLoad', +)({ + beforeLoad: ({ + params: { target }, + search: { reloadDocument, externalHost }, + }) => throwRedirect({ data: { target, reloadDocument, externalHost } }), + component: () =>
{Route.fullPath}
, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-loader.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-loader.tsx new file mode 100644 index 00000000000..1db205e3115 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-loader.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router' +import { throwRedirect } from '~/components/throwRedirect' + +export const Route = createFileRoute('/redirect/$target/serverFn/via-loader')({ + loaderDeps: ({ search: { reloadDocument, externalHost } }) => ({ + reloadDocument, + externalHost, + }), + loader: ({ params: { target }, deps: { reloadDocument, externalHost } }) => + throwRedirect({ data: { target, reloadDocument, externalHost } }), + component: () =>
{Route.fullPath}
, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-useServerFn.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-useServerFn.tsx new file mode 100644 index 00000000000..866bb19b10e --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/serverFn/via-useServerFn.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' +import { RedirectOnClick } from '~/components/RedirectOnClick' + +export const Route = createFileRoute( + '/redirect/$target/serverFn/via-useServerFn', +)({ + component: () => { + const { target } = Route.useParams() + const { reloadDocument, externalHost } = Route.useSearch() + return ( + + ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-beforeLoad.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-beforeLoad.tsx new file mode 100644 index 00000000000..3b30323869a --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-beforeLoad.tsx @@ -0,0 +1,16 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/$target/via-beforeLoad')({ + beforeLoad: ({ + params: { target }, + search: { reloadDocument, externalHost }, + }) => { + switch (target) { + case 'internal': + throw redirect({ to: '/posts', reloadDocument }) + case 'external': + throw redirect({ href: externalHost }) + } + }, + component: () =>
{Route.fullPath}
, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-loader.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-loader.tsx new file mode 100644 index 00000000000..c88a3e66f10 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-loader.tsx @@ -0,0 +1,17 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/$target/via-loader')({ + loaderDeps: ({ search: { reloadDocument, externalHost } }) => ({ + reloadDocument, + externalHost, + }), + loader: ({ params: { target }, deps: { externalHost, reloadDocument } }) => { + switch (target) { + case 'internal': + throw redirect({ to: '/posts', reloadDocument }) + case 'external': + throw redirect({ href: externalHost }) + } + }, + component: () =>
{Route.fullPath}
, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/redirect/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/redirect/index.tsx new file mode 100644 index 00000000000..c0b26a1df4f --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/redirect/index.tsx @@ -0,0 +1,28 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/redirect/')({ + component: () => ( +
+ + internal + {' '} + + external + +
+ ), +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/scripts.tsx b/e2e/react-start/basic-hydrate-false/src/routes/scripts.tsx new file mode 100644 index 00000000000..a2b613bc3c6 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/scripts.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/react-router' + +const isProd = import.meta.env.PROD + +export const Route = createFileRoute('/scripts')({ + head: () => ({ + scripts: [ + { + src: 'script.js', + }, + isProd + ? undefined + : { + src: 'script2.js', + }, + ], + }), + component: ScriptsComponent, +}) + +function ScriptsComponent() { + return ( +
+

Scripts Test

+

+ Both `script.js` and `script2.js` are included in development, but only + `script.js` is included in production. +

+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/search-params/default.tsx b/e2e/react-start/basic-hydrate-false/src/routes/search-params/default.tsx new file mode 100644 index 00000000000..19e1149b8d2 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/search-params/default.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' + +export const Route = createFileRoute('/search-params/default')({ + validateSearch: z.object({ + default: z.string().default('d1'), + }), + beforeLoad: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + loader: ({ context }) => { + if (context.hello !== 'world') { + throw new Error('Context hello is not "world"') + } + }, + component: () => { + const search = Route.useSearch() + const context = Route.useRouteContext() + return ( + <> +
{search.default}
+
{context.hello}
+ + ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/search-params/index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/search-params/index.tsx new file mode 100644 index 00000000000..c0d4a55ac85 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/search-params/index.tsx @@ -0,0 +1,26 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/search-params/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + go to /search-params/default + +
+ + go to /search-params/default?default=d2 + +
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/search-params/loader-throws-redirect.tsx b/e2e/react-start/basic-hydrate-false/src/routes/search-params/loader-throws-redirect.tsx new file mode 100644 index 00000000000..c55628ad331 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/search-params/loader-throws-redirect.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { z } from 'zod' + +export const Route = createFileRoute('/search-params/loader-throws-redirect')({ + validateSearch: z.object({ + step: z.enum(['a', 'b', 'c']).optional(), + }), + loaderDeps: ({ search: { step } }) => ({ step }), + loader: ({ deps: { step } }) => { + if (step === undefined) { + throw redirect({ + to: '/search-params/loader-throws-redirect', + search: { step: 'a' }, + }) + } + }, + component: () => { + const search = Route.useSearch() + return ( +
+

SearchParams

+
{search.step}
+
+ ) + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/search-params/route.tsx b/e2e/react-start/basic-hydrate-false/src/routes/search-params/route.tsx new file mode 100644 index 00000000000..5adb281c41b --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/search-params/route.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/search-params')({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return { hello: 'world' as string } + }, +}) diff --git a/e2e/react-start/basic-hydrate-false/src/routes/static.tsx b/e2e/react-start/basic-hydrate-false/src/routes/static.tsx new file mode 100644 index 00000000000..73c6651bc21 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/static.tsx @@ -0,0 +1,162 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/static')({ + // This is the key: hydrate: false means no React hydration + hydrate: false, + loader: () => { + return { + serverTime: new Date().toISOString(), + message: 'This data was loaded on the server', + pageViews: Math.floor(Math.random() * 10000), + } + }, + head: () => ({ + meta: [ + { + title: 'Static Route - TanStack Router Demo', + }, + { + name: 'description', + content: 'A static server-rendered page with no hydration', + }, + ], + // You can still include external scripts that don't require React + scripts: [ + { + children: ` + console.log('✅ External scripts still work!'); + console.log('This page has no React hydration'); + `, + }, + ], + }), + component: StaticComponent, +}) + +function StaticComponent() { + const data = Route.useLoaderData() + + return ( +
+ + ← Back to Home + + +

+ 🚫 Static Route (hydrate: false) +

+ +
+
+

🎯 What This Means

+
    +
  • + + Page is server-side rendered +
  • +
  • + + All content is SEO-friendly +
  • +
  • + + Fast initial page load (no JS bundle) +
  • +
  • + + No React interactivity +
  • +
  • + + No useState, useEffect, or event handlers +
  • +
  • + + Links are traditional navigation (full page reload) +
  • +
+
+ +
+

📦 Server Data

+
+

+ Server Time:{' '} + + {data.serverTime} + +

+

+ Message: {data.message} +

+

+ Page Views:{' '} + {data.pageViews.toLocaleString()} +

+
+
+ +
+

+ ⚠️ Try Clicking This Button +

+

+ This button has an onClick handler, but it won't work because React + is not hydrated: +

+ +

+ Nothing happens when you click because there's no JavaScript event + handler attached. +

+
+ +
+

🔍 What's NOT Included

+

Open DevTools → Network → JS. You'll see:

+
    +
  • + React runtime bundle (NOT loaded) +
  • +
  • + React DOM bundle (NOT loaded) +
  • +
  • + TanStack Router client bundle (NOT loaded) +
  • +
  • + Your application code (NOT loaded) +
  • +
  • + Hydration data (NOT included) +
  • +
+

+ ✅ Result: Significantly smaller page size and faster initial load! +

+
+ +
+

💡 Perfect For:

+
    +
  • Terms of Service / Privacy Policy pages
  • +
  • Blog posts and articles
  • +
  • Marketing landing pages
  • +
  • Documentation
  • +
  • Any content that doesn't need interactivity
  • +
+
+
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/stream.tsx b/e2e/react-start/basic-hydrate-false/src/routes/stream.tsx new file mode 100644 index 00000000000..691792ac214 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/stream.tsx @@ -0,0 +1,64 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { useEffect, useState } from 'react' + +export const Route = createFileRoute('/stream')({ + component: Home, + loader() { + return { + promise: new Promise((resolve) => + setTimeout(() => resolve('promise-data'), 150), + ), + stream: new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)) + controller.enqueue(`stream-data-${i} `) + } + controller.close() + }, + }), + } + }, +}) + +const decoder = new TextDecoder('utf-8') + +function Home() { + const { promise, stream } = Route.useLoaderData() + const [streamData, setStreamData] = useState>([]) + + useEffect(() => { + async function fetchStream() { + const reader = stream.getReader() + let chunk + + while (!(chunk = await reader.read()).done) { + let value = chunk.value + if (typeof value !== 'string') { + value = decoder.decode(value, { stream: !chunk.done }) + } + setStreamData((prev) => [...prev, value]) + } + } + + fetchStream() + }, []) + + return ( + <> + ( +
+ {promiseData} +
+ {streamData.map((d) => ( +
{d}
+ ))} +
+
+ )} + /> + + ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/users.$userId.tsx b/e2e/react-start/basic-hydrate-false/src/routes/users.$userId.tsx new file mode 100644 index 00000000000..59ddc3df873 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/users.$userId.tsx @@ -0,0 +1,39 @@ +import { ErrorComponent, createFileRoute } from '@tanstack/react-router' +import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/react-start' +import type { ErrorComponentProps } from '@tanstack/react-router' + +import type { User } from '~/utils/users' +import { NotFound } from '~/components/NotFound' + +export const Route = createFileRoute('/users/$userId')({ + loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() + return await axios + .get('/api/users/' + userId, { baseURL: router.options.origin }) + .then((r) => r.data) + .catch(() => { + throw new Error('Failed to fetch user') + }) + }, + errorComponent: UserErrorComponent, + component: UserComponent, + notFoundComponent: () => { + return User not found + }, +}) + +function UserErrorComponent({ error }: ErrorComponentProps) { + return +} + +function UserComponent() { + const user = Route.useLoaderData() + + return ( +
+

{user.name}

+
{user.email}
+
+ ) +} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/users.index.tsx b/e2e/react-start/basic-hydrate-false/src/routes/users.index.tsx new file mode 100644 index 00000000000..b6b0ee67fbf --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/users.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/')({ + component: UsersIndexComponent, +}) + +function UsersIndexComponent() { + return
Select a user.
+} diff --git a/e2e/react-start/basic-hydrate-false/src/routes/users.tsx b/e2e/react-start/basic-hydrate-false/src/routes/users.tsx new file mode 100644 index 00000000000..f7a57210971 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/routes/users.tsx @@ -0,0 +1,50 @@ +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { getRouterInstance } from '@tanstack/react-start' +import axios from 'redaxios' + +import type { User } from '~/utils/users' + +export const Route = createFileRoute('/users')({ + loader: async () => { + const router = await getRouterInstance() + return await axios + .get>('/api/users', { baseURL: router.options.origin }) + .then((r) => r.data) + .catch(() => { + throw new Error('Failed to fetch users') + }) + }, + component: UsersComponent, +}) + +function UsersComponent() { + const users = Route.useLoaderData() + + return ( +
+
    + {[ + ...users, + { id: 'i-do-not-exist', name: 'Non-existent User', email: '' }, + ].map((user) => { + return ( +
  • + +
    {user.name}
    + +
  • + ) + })} +
+
+ +
+ ) +} diff --git "a/e2e/react-start/basic-hydrate-false/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" "b/e2e/react-start/basic-hydrate-false/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" new file mode 100644 index 00000000000..c70cb5096a9 --- /dev/null +++ "b/e2e/react-start/basic-hydrate-false/src/routes/\353\214\200\355\225\234\353\257\274\352\265\255.tsx" @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/대한민국')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/대한민국"!
+} diff --git a/e2e/react-start/basic-hydrate-false/src/server.ts b/e2e/react-start/basic-hydrate-false/src/server.ts new file mode 100644 index 00000000000..00d13b4d178 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/server.ts @@ -0,0 +1,11 @@ +// DO NOT DELETE THIS FILE!!! +// This file is a good smoke test to make sure the custom server entry is working +import handler from '@tanstack/react-start/server-entry' + +console.log("[server-entry]: using custom server entry in 'src/server.ts'") + +export default { + fetch(request: Request) { + return handler.fetch(request) + }, +} diff --git a/e2e/react-start/basic-hydrate-false/src/styles/app.css b/e2e/react-start/basic-hydrate-false/src/styles/app.css new file mode 100644 index 00000000000..c36c737cd46 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/styles/app.css @@ -0,0 +1,30 @@ +@import 'tailwindcss'; + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} + +@layer base { + html { + color-scheme: light dark; + } + + * { + @apply border-gray-200 dark:border-gray-800; + } + + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } +} diff --git a/e2e/react-start/basic-hydrate-false/src/utils/posts.tsx b/e2e/react-start/basic-hydrate-false/src/utils/posts.tsx new file mode 100644 index 00000000000..b2d9f3edecf --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/utils/posts.tsx @@ -0,0 +1,42 @@ +import { notFound } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import axios from 'redaxios' + +export type PostType = { + id: string + title: string + body: string +} + +let queryURL = 'https://jsonplaceholder.typicode.com' + +if (import.meta.env.VITE_NODE_ENV === 'test') { + queryURL = `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}` +} + +export const fetchPost = createServerFn({ method: 'GET' }) + .inputValidator((postId: string) => postId) + .handler(async ({ data: postId }) => { + console.info(`Fetching post with id ${postId}...`) + const post = await axios + .get(`${queryURL}/posts/${postId}`) + .then((r) => r.data) + .catch((err) => { + console.error(err) + if (err.status === 404) { + throw notFound() + } + throw err + }) + + return post + }) + +export const fetchPosts = createServerFn({ method: 'GET' }).handler( + async () => { + console.info('Fetching posts...') + return axios + .get>(`${queryURL}/posts`) + .then((r) => r.data.slice(0, 10)) + }, +) diff --git a/e2e/react-start/basic-hydrate-false/src/utils/seo.ts b/e2e/react-start/basic-hydrate-false/src/utils/seo.ts new file mode 100644 index 00000000000..d18ad84b74e --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/e2e/react-start/basic-hydrate-false/src/utils/users.tsx b/e2e/react-start/basic-hydrate-false/src/utils/users.tsx new file mode 100644 index 00000000000..46be4b15804 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/src/utils/users.tsx @@ -0,0 +1,9 @@ +export type User = { + id: number + name: string + email: string +} + +const PORT = process.env.VITE_SERVER_PORT || 3000 + +export const DEPLOY_URL = `http://localhost:${PORT}` diff --git a/e2e/react-start/basic-hydrate-false/tests/hydrate.spec.ts b/e2e/react-start/basic-hydrate-false/tests/hydrate.spec.ts new file mode 100644 index 00000000000..1630ffb715f --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/hydrate.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from '@playwright/test' + +test.describe('Hydrate: false feature', () => { + test('should not include main bundle scripts when hydrate is false', async ({ + page, + }) => { + // Visit the route with hydrate: false + await page.goto('/static') + + // Wait for the page to load + await expect(page.getByTestId('static-heading')).toBeVisible() + + // Get the HTML content + const html = await page.content() + + // The main bundle scripts from the manifest should NOT be present + // These are the scripts that would hydrate the React app + const hasMainBundleScript = html.includes('type="module"') + expect(hasMainBundleScript).toBe(false) + + // The serialized router data script ($_TSR) should NOT be present + const hasRouterDataScript = html.includes('window.$_TSR') + expect(hasRouterDataScript).toBe(false) + + // Verify that inline scripts from head() option are still present + const hasInlineScript = html.includes('External scripts still work') + expect(hasInlineScript).toBe(true) + + // Verify that the page content is still rendered (SSR worked) + await expect(page.getByTestId('static-heading')).toContainText( + 'Static Route', + ) + await expect(page.getByTestId('message')).toContainText( + 'This data was loaded on the server', + ) + + // The page should not be interactive (no hydration) + // Button with onClick should not work + await expect(page.getByTestId('inactive-button')).toBeVisible() + }) + + test('should render correct meta tags when hydrate is false', async ({ + page, + }) => { + await page.goto('/static') + + // Verify meta tags from head() option are present + const title = await page.title() + expect(title).toContain('Static Route') + + const description = await page.getAttribute( + 'meta[name="description"]', + 'content', + ) + expect(description).toBe('A static server-rendered page with no hydration') + }) + + test('should not include modulepreload links when hydrate is false', async ({ + page, + }) => { + await page.goto('/static') + + const html = await page.content() + + // Modulepreload links should NOT be present + const hasModulePreload = html.includes('rel="modulepreload"') + expect(hasModulePreload).toBe(false) + }) + + test('should still serve static content correctly when hydrate is false', async ({ + page, + }) => { + await page.goto('/static') + + // Verify that loader data is rendered (SSR) + const message = await page.getByTestId('message').textContent() + expect(message).toContain('This data was loaded on the server') + + // Verify server time is present (loader ran on server) + const serverTime = await page.getByTestId('server-time').textContent() + expect(serverTime).toBeTruthy() + expect(serverTime).toContain('Server Time:') + + // Verify page views are rendered + const pageViews = await page.getByTestId('page-views').textContent() + expect(pageViews).toContain('Page Views:') + }) + + test('hydrated route should include all bundles and be interactive', async ({ + page, + }) => { + // Visit the hydrated route for comparison + await page.goto('/hydrated') + + // Wait for hydration to complete + await expect(page.getByTestId('hydration-status')).toContainText( + 'Hydrated and Interactive', + ) + + // Get the HTML content + const html = await page.content() + + // The main bundle scripts SHOULD be present + const hasMainBundleScript = html.includes('type="module"') + expect(hasMainBundleScript).toBe(true) + + // Verify interactivity works + const counter = page.getByTestId('counter') + await expect(counter).toContainText('0') + + // Click the increment button + await page.click('button:has-text("+")') + await expect(counter).toContainText('1') + + // Click the decrement button + await page.click('button:has-text("-")') + await expect(counter).toContainText('0') + }) + + test('navigation from home to static page should work', async ({ page }) => { + await page.goto('/') + + // Click the static route link + await page.click('a[href="/static"]') + + // Verify we're on the static page + await expect(page.getByTestId('static-heading')).toBeVisible() + await expect(page).toHaveURL('/static') + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/navigation.spec.ts b/e2e/react-start/basic-hydrate-false/tests/navigation.spec.ts new file mode 100644 index 00000000000..ea5bc50a072 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/navigation.spec.ts @@ -0,0 +1,77 @@ +import { expect } from '@playwright/test' + +import { test } from '@tanstack/router-e2e-utils' + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + ], +}) +test('Navigating to post', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + + await page.getByRole('link', { name: 'Posts' }).click() + await page.getByRole('link', { name: 'sunt aut facere repe' }).click() + await page.getByRole('link', { name: 'Deep View' }).click() + await expect(page.getByRole('heading')).toContainText('sunt aut facere') +}) + +test('Navigating to user', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + await page.getByRole('link', { name: 'Users' }).click() + await page.getByRole('link', { name: 'Leanne Graham' }).click() + await expect(page.getByRole('heading')).toContainText('Leanne Graham') +}) + +test('Navigating nested layouts', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + + await page.getByRole('link', { name: 'Layout', exact: true }).click() + + await expect(page.locator('body')).toContainText("I'm a layout") + await expect(page.locator('body')).toContainText("I'm a nested layout") + + await page.getByRole('link', { name: 'Layout A' }).click() + await expect(page.locator('body')).toContainText("I'm layout A!") + + await page.getByRole('link', { name: 'Layout B' }).click() + await expect(page.locator('body')).toContainText("I'm layout B!") +}) + +test('client side navigating to a route with scripts', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined) +}) + +test('directly going to a route with scripts', async ({ page }) => { + await page.goto('/scripts') + await page.waitForURL('/scripts') + await page.waitForLoadState('networkidle') + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined) +}) + +test('Navigating to a not-found route', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + + await page.getByRole('link', { name: 'This Route Does Not Exist' }).click() + await page.getByRole('link', { name: 'Start Over' }).click() + await expect(page.getByRole('heading')).toContainText('Welcome Home!') +}) + +test('Should change title on client side navigation', async ({ page }) => { + await page.goto('/') + await page.waitForURL('/') + + await page.getByRole('link', { name: 'Posts' }).click() + + await expect(page).toHaveTitle('Posts page') +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/not-found.spec.ts b/e2e/react-start/basic-hydrate-false/tests/not-found.spec.ts new file mode 100644 index 00000000000..0b5acc3b782 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/not-found.spec.ts @@ -0,0 +1,77 @@ +import { expect } from '@playwright/test' +import combinateImport from 'combinate' +import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from '../tests/utils/isSpaMode' + +// somehow playwright does not correctly import default exports +const combinate = (combinateImport as any).default as typeof combinateImport + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + 'NotFound error during hydration for routeId', + ], +}) +test.describe('not-found', () => { + test(`global not found`, async ({ page }) => { + const response = await page.goto(`/this-page-does-not-exist/foo/bar`) + + expect(response?.status()).toBe(isSpaMode ? 200 : 404) + + await expect( + page.getByTestId('default-not-found-component'), + ).toBeInViewport() + }) + + test.describe('throw notFound()', () => { + const navigationTestMatrix = combinate({ + // TODO beforeLoad! + thrower: [/* 'beforeLoad',*/ 'head', 'loader'] as const, + preload: [false, true] 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',*/ 'head', '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() + }) + }) + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/params.spec.ts b/e2e/react-start/basic-hydrate-false/tests/params.spec.ts new file mode 100644 index 00000000000..737c82f35d2 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/params.spec.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test' + +import { test } from '@tanstack/router-e2e-utils' + +test.beforeEach(async ({ page }) => { + await page.goto('/') +}) + +test.use({ + whitelistErrors: [ + /Failed to load resource: the server responded with a status of 404/, + ], +}) +test.describe('Unicode route rendering', () => { + test('should render non-latin route correctly', async ({ page, baseURL }) => { + await page.goto('/대한민국') + + await expect(page.locator('body')).toContainText('Hello "/대한민국"!') + + expect(page.url()).toBe(`${baseURL}/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD`) + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/prerendering.spec.ts b/e2e/react-start/basic-hydrate-false/tests/prerendering.spec.ts new file mode 100644 index 00000000000..7718fe86ef0 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/prerendering.spec.ts @@ -0,0 +1,43 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +test.describe('Prerender Static Path Discovery', () => { + test.skip(!isPrerender, 'Skipping since not in prerender mode') + test.describe('Build Output Verification', () => { + test('should automatically discover and prerender static routes', () => { + // Check that static routes were automatically discovered and prerendered + const distDir = join(process.cwd(), 'dist', 'client') + + // These static routes should be automatically discovered and prerendered + expect(existsSync(join(distDir, 'index.html'))).toBe(true) + expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) + expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) + expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) + + // Pathless layouts should NOT be prerendered (they start with _) + expect(existsSync(join(distDir, '_layout', 'index.html'))).toBe(false) // /_layout + + // API routes should NOT be prerendered + + expect(existsSync(join(distDir, 'api', 'users', 'index.html'))).toBe( + false, + ) // /api/users + }) + }) + + test.describe('Static Files Verification', () => { + test('should contain prerendered content in posts.html', () => { + const distDir = join(process.cwd(), 'dist', 'client') + expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) + + // "Select a post." should be in the prerendered HTML + const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') + expect(html).toContain('Select a post.') + }) + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/redirect.spec.ts b/e2e/react-start/basic-hydrate-false/tests/redirect.spec.ts new file mode 100644 index 00000000000..bc36de96230 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/redirect.spec.ts @@ -0,0 +1,250 @@ +import queryString from 'node:querystring' +import { expect } from '@playwright/test' +import combinateImport from 'combinate' +import { + getDummyServerPort, + getTestServerPort, + test, +} from '@tanstack/router-e2e-utils' +import { isSpaMode } from '../tests/utils/isSpaMode' +import { isPreview } from '../tests/utils/isPreview' +import packageJson from '../package.json' with { type: 'json' } + +// somehow playwright does not correctly import default exports +const combinate = (combinateImport as any).default as typeof combinateImport + +const PORT = await getTestServerPort( + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, +) + +const EXTERNAL_HOST_PORT = await getDummyServerPort(packageJson.name) + +test.describe('redirects', () => { + test.describe('internal', () => { + const internalNavigationTestMatrix = combinate({ + thrower: ['beforeLoad', 'loader'] as const, + reloadDocument: [false, true] as const, + preload: [false, true] as const, + }) + + internalNavigationTestMatrix.forEach( + ({ thrower, reloadDocument, preload }) => { + test(`internal target, navigation: thrower: ${thrower}, reloadDocument: ${reloadDocument}, preload: ${preload}`, async ({ + page, + }) => { + await page.goto( + `/redirect/internal${preload === false ? '?preload=false' : ''}`, + ) + + const link = page.getByTestId( + `via-${thrower}${reloadDocument ? '-reloadDocument' : ''}`, + ) + + await page.waitForLoadState('networkidle') + let requestHappened = false + + const requestPromise = new Promise((resolve) => { + page.on('request', (request) => { + if ( + request.url().startsWith(`http://localhost:${PORT}/_serverFn/`) + ) { + requestHappened = true + resolve() + } + }) + }) + await link.focus() + + const expectRequestHappened = preload && !reloadDocument + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, expectRequestHappened ? 5000 : 500), + ) + await Promise.race([requestPromise, timeoutPromise]) + expect(requestHappened).toBe(expectRequestHappened) + let fullPageLoad = false + page.on('domcontentloaded', () => { + fullPageLoad = true + }) + + await link.click() + + const url = `http://localhost:${PORT}/posts` + await page.waitForURL(url) + expect(page.url()).toBe(url) + await expect(page.getByTestId('PostsIndexComponent')).toBeInViewport() + expect(fullPageLoad).toBe(reloadDocument) + }) + }, + ) + + const internalDirectVisitTestMatrix = combinate({ + thrower: ['beforeLoad', 'loader'] as const, + reloadDocument: [false, true] as const, + }) + + internalDirectVisitTestMatrix.forEach(({ thrower, reloadDocument }) => { + test(`internal target, direct visit: thrower: ${thrower}, reloadDocument: ${reloadDocument}`, async ({ + page, + }) => { + await page.goto(`/redirect/internal/via-${thrower}`) + await page.waitForLoadState('networkidle') + + const url = `http://localhost:${PORT}/posts` + + expect(page.url()).toBe(url) + await expect(page.getByTestId('PostsIndexComponent')).toBeInViewport() + }) + }) + }) + + test.describe('external', () => { + const externalTestMatrix = combinate({ + scenario: ['navigate', 'direct_visit'] as const, + thrower: ['beforeLoad', 'loader'] as const, + }) + + externalTestMatrix.forEach(({ scenario, thrower }) => { + test(`external target: scenario: ${scenario}, thrower: ${thrower}`, async ({ + page, + }) => { + const q = queryString.stringify({ + externalHost: `http://localhost:${EXTERNAL_HOST_PORT}/`, + }) + + if (scenario === 'navigate') { + await page.goto(`/redirect/external?${q}`) + await page.waitForLoadState('networkidle') + const link = page.getByTestId(`via-${thrower}`) + await link.focus() + await link.click() + } else { + await page.goto(`/redirect/external/via-${thrower}?${q}`) + } + + const url = `http://localhost:${EXTERNAL_HOST_PORT}/` + + await page.waitForURL(url) + expect(page.url()).toBe(url) + }) + }) + }) + + test.describe('serverFn', () => { + const serverFnTestMatrix = combinate({ + target: ['internal', 'external'] as const, + scenario: ['navigate', 'direct_visit'] as const, + thrower: ['beforeLoad', 'loader'] as const, + reloadDocument: [false, true] as const, + }) + + serverFnTestMatrix.forEach( + ({ target, thrower, scenario, reloadDocument }) => { + test(`serverFn redirects to target: ${target}, scenario: ${scenario}, thrower: ${thrower}, reloadDocument: ${reloadDocument}`, async ({ + page, + }) => { + let fullPageLoad = false + const q = queryString.stringify({ + externalHost: `http://localhost:${EXTERNAL_HOST_PORT}/`, + reloadDocument, + }) + + if (scenario === 'navigate') { + await page.goto(`/redirect/${target}/serverFn?${q}`) + await page.waitForLoadState('networkidle') + + const link = page.getByTestId( + `via-${thrower}${reloadDocument ? '-reloadDocument' : ''}`, + ) + + page.on('domcontentloaded', () => { + fullPageLoad = true + }) + + await link.focus() + await page.waitForLoadState('networkidle') + await link.click() + } else { + await page.goto(`/redirect/${target}/serverFn/via-${thrower}?${q}`) + } + + const url = + target === 'internal' + ? `http://localhost:${PORT}/posts` + : `http://localhost:${EXTERNAL_HOST_PORT}/` + + await page.waitForURL(url) + + expect(page.url()).toBe(url) + + if (target === 'internal' && scenario === 'navigate') { + await expect( + page.getByTestId('PostsIndexComponent'), + ).toBeInViewport() + expect(fullPageLoad).toBe(reloadDocument) + } + }) + }, + ) + }) + + test.describe('useServerFn', () => { + const useServerFnTestMatrix = combinate({ + target: ['internal', 'external'] as const, + reloadDocument: [false, true] as const, + }) + + useServerFnTestMatrix.forEach(({ target, reloadDocument }) => { + test(`useServerFn redirects to target: ${target}, reloadDocument: ${reloadDocument}`, async ({ + page, + }) => { + const q = queryString.stringify({ + externalHost: `http://localhost:${EXTERNAL_HOST_PORT}/`, + reloadDocument, + }) + + await page.goto(`/redirect/${target}/serverFn/via-useServerFn?${q}`) + + await page.waitForLoadState('networkidle') + + const button = page.getByTestId('redirect-on-click') + + let fullPageLoad = false + page.on('domcontentloaded', () => { + fullPageLoad = true + }) + + await button.click() + + const url = + target === 'internal' + ? `http://localhost:${PORT}/posts` + : `http://localhost:${EXTERNAL_HOST_PORT}/` + await page.waitForURL(url) + expect(page.url()).toBe(url) + if (target === 'internal') { + await expect(page.getByTestId('PostsIndexComponent')).toBeInViewport() + expect(fullPageLoad).toBe(reloadDocument) + } + }) + }) + }) + + test('multiple Set-Cookie headers are preserved on redirect', async ({ + page, + }) => { + // This test verifies that multiple Set-Cookie headers are not lost during redirect + await page.goto('/multi-cookie-redirect') + + // Wait for redirect to complete + await page.waitForURL(/\/multi-cookie-redirect\/target/) + + // Should redirect to target page + await expect(page.getByTestId('multi-cookie-redirect-target')).toBeVisible() + expect(page.url()).toContain('/multi-cookie-redirect/target') + + // Verify all three cookies were preserved during the redirect + await expect(page.getByTestId('cookie-session')).toHaveText('session-value') + await expect(page.getByTestId('cookie-csrf')).toHaveText('csrf-token-value') + await expect(page.getByTestId('cookie-theme')).toHaveText('dark') + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/script-duplication.spec.ts b/e2e/react-start/basic-hydrate-false/tests/script-duplication.spec.ts new file mode 100644 index 00000000000..2ed98a3a73f --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/script-duplication.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test' + +test.describe('Script Duplication Prevention', () => { + test('should not create duplicate scripts on SSR route', async ({ page }) => { + await page.goto('/scripts') + + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + const scriptCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + + expect(scriptCount).toBe(1) + + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts during client-side navigation', async ({ + page, + }) => { + await page.goto('/') + + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + const firstNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(firstNavCount).toBe(1) + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + const secondNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(secondNavCount).toBe(1) + + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts with multiple navigation cycles', async ({ + page, + }) => { + await page.goto('/') + + for (let i = 0; i < 3; i++) { + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + } + + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + const finalCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(finalCount).toBe(1) + + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate inline scripts', async ({ page }) => { + await page.goto('/inline-scripts') + + await expect( + page.getByTestId('inline-scripts-test-heading'), + ).toBeInViewport() + + const script1Count = await page.evaluate(() => { + const scripts = Array.from(document.querySelectorAll('script:not([src])')) + return scripts.filter( + (script) => + script.textContent && + script.textContent.includes('window.INLINE_SCRIPT_1 = true'), + ).length + }) + + const script2Count = await page.evaluate(() => { + const scripts = Array.from(document.querySelectorAll('script:not([src])')) + return scripts.filter( + (script) => + script.textContent && + script.textContent.includes('window.INLINE_SCRIPT_2 = "test"'), + ).length + }) + + expect(script1Count).toBe(1) + expect(script2Count).toBe(1) + + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) + + test('should not create duplicate inline scripts during client-side navigation', async ({ + page, + }) => { + await page.goto('/') + + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect( + page.getByTestId('inline-scripts-test-heading'), + ).toBeInViewport() + + const firstNavScript1Count = await page.evaluate(() => { + const scripts = Array.from(document.querySelectorAll('script:not([src])')) + return scripts.filter( + (script) => + script.textContent && + script.textContent.includes('window.INLINE_SCRIPT_1 = true'), + ).length + }) + expect(firstNavScript1Count).toBe(1) + + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect( + page.getByTestId('inline-scripts-test-heading'), + ).toBeInViewport() + + const secondNavScript1Count = await page.evaluate(() => { + const scripts = Array.from(document.querySelectorAll('script:not([src])')) + return scripts.filter( + (script) => + script.textContent && + script.textContent.includes('window.INLINE_SCRIPT_1 = true'), + ).length + }) + expect(secondNavScript1Count).toBe(1) + + // Verify the scripts are still working + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/search-params.spec.ts b/e2e/react-start/basic-hydrate-false/tests/search-params.spec.ts new file mode 100644 index 00000000000..74c1aa54dc9 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/search-params.spec.ts @@ -0,0 +1,100 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from 'tests/utils/isSpaMode' +import { isPrerender } from './utils/isPrerender' +import type { Response } from '@playwright/test' + +function expectRedirect(response: Response | null, endsWith: string) { + expect(response).not.toBeNull() + expect(response!.request().redirectedFrom()).not.toBeNull() + const redirectUrl = response! + .request() + .redirectedFrom()! + .redirectedTo() + ?.url() + expect(redirectUrl).toBeDefined() + expect(redirectUrl!.endsWith(endsWith)) +} + +function expectNoRedirect(response: Response | null) { + expect(response).not.toBeNull() + const request = response!.request() + expect(request.redirectedFrom()).toBeNull() +} + +test.describe('/search-params/loader-throws-redirect', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/loader-throws-redirect') + + if (!isSpaMode && !isPrerender) { + expectRedirect(response, '/search-params/loader-throws-redirect?step=a') + } + + await expect(page.getByTestId('search-param')).toContainText('a') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=a')) + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto( + '/search-params/loader-throws-redirect?step=b', + ) + expectNoRedirect(response) + await expect(page.getByTestId('search-param')).toContainText('b') + expect(page.url().endsWith('/search-params/loader-throws-redirect?step=b')) + }) +}) + +test.describe('/search-params/default', () => { + test('Directly visiting the route without search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default') + if (!isSpaMode && !isPrerender) { + expectRedirect(response, '/search-params/default?default=d1') + } + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() + }) + + test('Directly visiting the route with search param set', async ({ + page, + }) => { + const response = await page.goto('/search-params/default?default=d2') + expectNoRedirect(response) + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() + }) + + test('navigating to the route without search param set', async ({ page }) => { + await page.goto('/search-params/') + await page.getByTestId('link-to-default-without-search').click() + + await expect(page.getByTestId('search-default')).toContainText('d1') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page.url().endsWith('/search-params/default?default=d1'), + ).toBeTruthy() + }) + + test('navigating to the route with search param set', async ({ page }) => { + await page.goto('/search-params/') + await page.getByTestId('link-to-default-with-search').click() + + await expect(page.getByTestId('search-default')).toContainText('d2') + await expect(page.getByTestId('context-hello')).toContainText('world') + expect( + page.url().endsWith('/search-params/default?default=d2'), + ).toBeTruthy() + }) +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/setup/global.setup.ts b/e2e/react-start/basic-hydrate-false/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/react-start/basic-hydrate-false/tests/setup/global.teardown.ts b/e2e/react-start/basic-hydrate-false/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/setup/global.teardown.ts @@ -0,0 +1,6 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function teardown() { + await e2eStopDummyServer(packageJson.name) +} diff --git a/e2e/react-start/basic-hydrate-false/tests/streaming.spec.ts b/e2e/react-start/basic-hydrate-false/tests/streaming.spec.ts new file mode 100644 index 00000000000..15f60b7deb9 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/streaming.spec.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('Navigating to deferred route', async ({ page }) => { + await page.goto('/') + + await page.getByRole('link', { name: 'Deferred' }).click() + + await expect(page.getByTestId('regular-person')).toContainText('John Doe') + await expect(page.getByTestId('deferred-person')).toContainText( + 'Tanner Linsley', + ) + await expect(page.getByTestId('deferred-stuff')).toContainText( + 'Hello deferred!', + ) +}) + +test('Directly visiting the deferred route', async ({ page }) => { + await page.goto('/deferred') + + await expect(page.getByTestId('regular-person')).toContainText('John Doe') + await expect(page.getByTestId('deferred-person')).toContainText( + 'Tanner Linsley', + ) + await expect(page.getByTestId('deferred-stuff')).toContainText( + 'Hello deferred!', + ) +}) + +test('streaming loader data', async ({ page }) => { + await page.goto('/stream') + + await expect(page.getByTestId('promise-data')).toContainText('promise-data') + await expect(page.getByTestId('stream-data')).toContainText('stream-data') +}) diff --git a/e2e/react-start/basic-hydrate-false/tests/utils/isPrerender.ts b/e2e/react-start/basic-hydrate-false/tests/utils/isPrerender.ts new file mode 100644 index 00000000000..d5d991d4545 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/utils/isPrerender.ts @@ -0,0 +1 @@ +export const isPrerender: boolean = process.env.MODE === 'prerender' diff --git a/e2e/react-start/basic-hydrate-false/tests/utils/isPreview.ts b/e2e/react-start/basic-hydrate-false/tests/utils/isPreview.ts new file mode 100644 index 00000000000..7ea362a83ed --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/utils/isPreview.ts @@ -0,0 +1 @@ +export const isPreview: boolean = process.env.MODE === 'preview' diff --git a/e2e/react-start/basic-hydrate-false/tests/utils/isSpaMode.ts b/e2e/react-start/basic-hydrate-false/tests/utils/isSpaMode.ts new file mode 100644 index 00000000000..b4edb829a8f --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tests/utils/isSpaMode.ts @@ -0,0 +1 @@ +export const isSpaMode: boolean = process.env.MODE === 'spa' diff --git a/e2e/react-start/basic-hydrate-false/tsconfig.json b/e2e/react-start/basic-hydrate-false/tsconfig.json new file mode 100644 index 00000000000..b3a2d67dfa6 --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/basic-hydrate-false/vite.config.ts b/e2e/react-start/basic-hydrate-false/vite.config.ts new file mode 100644 index 00000000000..ecab96f2d8b --- /dev/null +++ b/e2e/react-start/basic-hydrate-false/vite.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' + +const spaModeConfiguration = { + enabled: true, + prerender: { + outputPath: 'index.html', + }, +} + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found/via-beforeLoad', + '/not-found/via-head', + '/not-found/via-loader', + '/users', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + spa: isSpaMode ? spaModeConfiguration : undefined, + prerender: isPrerender ? prerenderConfiguration : undefined, + }), + viteReact(), + ], +}) diff --git a/examples/react/start-basic/src/routes/__root.tsx b/examples/react/start-basic/src/routes/__root.tsx index 346409e9d91..8e9d52658e6 100644 --- a/examples/react/start-basic/src/routes/__root.tsx +++ b/examples/react/start-basic/src/routes/__root.tsx @@ -13,6 +13,7 @@ import appCss from '~/styles/app.css?url' import { seo } from '~/utils/seo' export const Route = createRootRoute({ + hydrate: false, head: () => ({ meta: [ { diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx index 617498c8572..ba4bfeb0cab 100644 --- a/packages/react-router/src/HeadContent.tsx +++ b/packages/react-router/src/HeadContent.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Asset } from './Asset' import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' +import { getHydrateStatus } from './hydrate-status' import type { RouterManagedTag } from '@tanstack/router-core' /** @@ -115,6 +116,13 @@ export const useTags = () => { const preloadLinks = useRouterState({ select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) + + // If hydrate is false, don't include modulepreload links + if (!shouldHydrate) { + return [] + } + const preloadLinks: Array = [] state.matches diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index 3765e5790d8..3b9da8ec11f 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -1,6 +1,7 @@ import { Asset } from './Asset' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' +import { getHydrateStatus } from './hydrate-status' import type { RouterManagedTag } from '@tanstack/router-core' /** @@ -14,8 +15,14 @@ import type { RouterManagedTag } from '@tanstack/router-core' export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce + + const hydrateStatus = useRouterState({ + select: (state) => getHydrateStatus(state.matches, router), + }) + const assetScripts = useRouterState({ select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) const assetScripts: Array = [] const manifest = router.ssr?.manifest @@ -29,6 +36,25 @@ export const Scripts = () => { manifest.routes[route.id]?.assets ?.filter((d) => d.tag === 'script') .forEach((asset) => { + // If hydrate is false, skip client entry scripts + if (!shouldHydrate) { + // Check by attribute + if ( + asset.attrs?.['data-tsr-client-entry'] === 'true' || + asset.attrs?.['data-tsr-client-entry'] === true + ) { + return + } + + // Also check by content as fallback + const scriptContent = asset.children?.toString() || '' + if ( + scriptContent.includes('virtual:tanstack-start-client-entry') + ) { + return + } + } + assetScripts.push({ tag: 'script', attrs: { ...asset.attrs, nonce }, @@ -42,9 +68,11 @@ export const Scripts = () => { structuralSharing: true as any, }) - const { scripts } = useRouterState({ - select: (state) => ({ - scripts: ( + const scripts = useRouterState({ + select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) + + const allScripts = ( state.matches .map((match) => match.scripts!) .flat(1) @@ -57,14 +85,62 @@ export const Scripts = () => { nonce, }, children, - })), - }), + })) + + // If hydrate is false, remove client entry imports but keep React Refresh for HMR + if (!shouldHydrate) { + return allScripts + .map((script) => { + const scriptContent = script.children?.toString() || '' + + // If this script contains the client entry import, remove that import + // but keep the React Refresh setup for development HMR + if (scriptContent.includes('virtual:tanstack-start-client-entry')) { + // Remove the import line(s) that load the client entry + const modifiedContent = scriptContent + .split('\n') + .filter( + (line) => + !line.includes('virtual:tanstack-start-client-entry'), + ) + .join('\n') + .trim() + + // If there's still content (React Refresh setup), keep it + if (modifiedContent) { + return { + ...script, + children: modifiedContent, + } + } + // If filtering removed everything, exclude this script + return null + } + + return script + }) + .filter(Boolean) as Array + } + + return allScripts + }, structuralSharing: true as any, }) + // Warn about conflicting hydrate options + if (hydrateStatus.hasConflict) { + console.warn( + '⚠️ [TanStack Router] Conflicting hydrate options detected in route matches.\n' + + 'Some routes have hydrate: false while others have hydrate: true.\n' + + 'The page will be hydrated, but this may not be the intended behavior.\n' + + 'Please ensure all routes in the match have consistent hydrate settings.', + ) + } + let serverBufferedScript: RouterManagedTag | undefined = undefined - if (router.serverSsr) { + // Only include server buffered script if we're hydrating + if (router.serverSsr && hydrateStatus.shouldHydrate) { serverBufferedScript = router.serverSsr.takeBufferedScripts() } diff --git a/packages/react-router/src/hydrate-status.ts b/packages/react-router/src/hydrate-status.ts new file mode 100644 index 00000000000..6f814bd5670 --- /dev/null +++ b/packages/react-router/src/hydrate-status.ts @@ -0,0 +1,30 @@ +export function getHydrateStatus( + matches: Array, + router: any, +): { + shouldHydrate: boolean + hasConflict: boolean +} { + let hasExplicitFalse = false + let hasExplicitTrue = false + const defaultHydrate = router.options.defaultHydrate ?? true + + matches.forEach((match) => { + const route = router.looseRoutesById[match.routeId] + const hydrateOption = route?.options.hydrate ?? defaultHydrate + + if (hydrateOption === false) { + hasExplicitFalse = true + } else if (hydrateOption === true && route?.options.hydrate !== undefined) { + // Only count as explicit true if it was actually set on the route + hasExplicitTrue = true + } + }) + + const hasConflict = hasExplicitFalse && hasExplicitTrue + + // If any route has false, don't hydrate (even if there's a conflict) + const shouldHydrate = !hasExplicitFalse + + return { shouldHydrate, hasConflict } +} diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67d..9ba0d4771d7 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -8,7 +8,12 @@ import type { RouteById, RouteIds, } from './routeInfo' -import type { AnyRouter, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + HydrateOption, + RegisteredRouter, + SSROption, +} from './router' import type { Constrain, ControlledPromise } from './utils' export type AnyMatchAndValue = { match: any; value: any } @@ -170,6 +175,8 @@ export interface RouteMatch< staticData: StaticDataRouteOption /** This attribute is not reactive */ ssr?: SSROption + /** This attribute is not reactive */ + hydrate?: HydrateOption _forcePending?: boolean _displayPending?: boolean } @@ -193,6 +200,7 @@ export interface PreValidationErrorHandlingRouteMatch< | { status: 'error'; error: unknown } staticData: StaticDataRouteOption ssr?: boolean | 'data-only' + hydrate?: boolean } export type MakePreValidationErrorHandlingRouteMatchUnion< diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 53d726ef05a..6e9dd90854a 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -15,7 +15,13 @@ import type { } from './Matches' import type { RootRouteId } from './root' import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo' -import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + HydrateOption, + Register, + RegisteredRouter, + SSROption, +} from './router' import type { BuildLocationFn, NavigateFn } from './RouterProvider' import type { Assign, @@ -953,6 +959,8 @@ export interface FilebaseRouteOptionsInterface< ) => Awaitable) > + hydrate?: undefined | HydrateOption + // This async function is called before a route is loaded. // If an error is thrown here, the route's loader will not be called. // If thrown during a navigation, the navigation will be cancelled and the error will be passed to the `onError` function. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 19b77531297..925b08796c2 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -142,6 +142,8 @@ export interface RouterOptionsExtensions export type SSROption = boolean | 'data-only' +export type HydrateOption = boolean + export interface RouterOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -393,6 +395,13 @@ export interface RouterOptions< */ defaultSsr?: SSROption + /** + * The default `hydrate` a route should use if no `hydrate` is provided. + * + * @default true + */ + defaultHydrate?: HydrateOption + search?: { /** * Configures how unknown search params (= not returned by any `validateSearch`) are treated. @@ -617,7 +626,7 @@ export type RouterConstructorOptions< TRouterHistory, TDehydrated >, - 'context' | 'serializationAdapters' | 'defaultSsr' + 'context' | 'serializationAdapters' | 'defaultSsr' | 'defaultHydrate' > & RouterContextOptions diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index 8e6f268f977..317c859b5bf 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -16,22 +16,33 @@ export async function getStartManifest() { rootRoute.assets = rootRoute.assets || [] - let script = `import('${startManifest.clientEntry}')` + // In dev mode, add React Refresh separately so it can be kept for hydrate: false if (process.env.TSS_DEV_SERVER === 'true') { const { injectedHeadScripts } = await import( 'tanstack-start-injected-head-scripts:v' ) if (injectedHeadScripts) { - script = `${injectedHeadScripts + ';'}${script}` + // Add React Refresh script (keep for HMR even when hydrate: false) + rootRoute.assets.push({ + tag: 'script', + attrs: { + type: 'module', + async: true, + }, + children: injectedHeadScripts, + }) } } + + // Add client entry (will be filtered when hydrate: false) rootRoute.assets.push({ tag: 'script', attrs: { type: 'module', async: true, + 'data-tsr-client-entry': 'true', }, - children: script, + children: `import('${startManifest.clientEntry}')`, }) const manifest = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35c2e6f9a20..ad920353d5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1259,6 +1259,88 @@ importers: specifier: ^4.49.1 version: 4.49.1 + e2e/react-start/basic-hydrate-false: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + express: + specifier: ^5.1.0 + version: 5.1.0 + http-proxy-middleware: + specifier: ^3.0.5 + version: 3.0.5 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tailwindcss/postcss': + specifier: ^4.1.15 + version: 4.1.15 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + combinate: + specifier: ^1.1.11 + version: 1.1.11 + postcss: + specifier: ^8.5.1 + version: 8.5.6 + srvx: + specifier: ^0.8.6 + version: 0.8.15 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + zod: + specifier: ^3.24.2 + version: 3.25.57 + e2e/react-start/basic-react-query: dependencies: '@tanstack/react-query':