diff --git a/docs/start/framework/react/client-entry-point.md b/docs/start/framework/react/client-entry-point.md index 02d0ecd426f..4e143fcb4d6 100644 --- a/docs/start/framework/react/client-entry-point.md +++ b/docs/start/framework/react/client-entry-point.md @@ -70,4 +70,46 @@ hydrateRoot( ) ``` +## Custom `useId` prefix + +You can customize the prefix of react's `useId` hook. + +```tsx +// src/client.tsx +import { StartClient } from '@tanstack/react-start/client' +import { StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { ErrorBoundary } from './components/ErrorBoundary' + +hydrateRoot( + document, + + + + + , + { + identifierPrefix: 'custom-prefix', // ⚠️ These values must be the identical to prevent hydration errors! + }, +) +``` + +```tsx +// src/router.tsx +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + ssr: { + identifierPrefix: 'custom-prefix', // ⚠️ These values must be the identical to prevent hydration errors! + }, + }) + + return router +} +``` + The client entry point gives you full control over how your application initializes on the client side while working seamlessly with TanStack Start's server-side rendering. diff --git a/e2e/react-start/custom-identifier-prefix/.gitignore b/e2e/react-start/custom-identifier-prefix/.gitignore new file mode 100644 index 00000000000..a79d5cf1299 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/.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/custom-identifier-prefix/.prettierignore b/e2e/react-start/custom-identifier-prefix/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/.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/custom-identifier-prefix/package.json b/e2e/react-start/custom-identifier-prefix/package.json new file mode 100644 index 00000000000..a7bd1764e9b --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/package.json @@ -0,0 +1,45 @@ +{ + "name": "tanstack-react-start-e2e-custom-identifier-prefix", + "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", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "start:spa": "node server.js", + "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": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode" + }, + "dependencies": { + "@tanstack/react-router": "workspace:*", + "@tanstack/react-router-devtools": "workspace:*", + "@tanstack/react-start": "workspace:*", + "express": "^5.1.0", + "http-proxy-middleware": "^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", + "@tanstack/router-e2e-utils": "workspace:*", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "combinate": "^1.1.11", + "postcss": "^8.5.1", + "srvx": "^0.8.6", + "tailwindcss": "^3.4.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/custom-identifier-prefix/playwright.config.ts b/e2e/react-start/custom-identifier-prefix/playwright.config.ts new file mode 100644 index 00000000000..ef3f68c5edf --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/playwright.config.ts @@ -0,0 +1,60 @@ +import { defineConfig, devices } from '@playwright/test' +import { + getDummyServerPort, + getTestServerPort, +} from '@tanstack/router-e2e-utils' +import { isSpaMode } from './tests/utils/isSpaMode' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort( + `${packageJson.name}${isSpaMode ? '_spa' : ''}`, +) +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` + +console.log('running in spa mode: ', isSpaMode.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: isSpaMode ? spaModeCommand : ssrModeCommand, + 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/custom-identifier-prefix/postcss.config.mjs b/e2e/react-start/custom-identifier-prefix/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png b/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png new file mode 100644 index 00000000000..09c8324f8c6 Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/android-chrome-192x192.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png b/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png new file mode 100644 index 00000000000..11d626ea3d0 Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/android-chrome-512x512.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png b/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png new file mode 100644 index 00000000000..5a9423cc02c Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/apple-touch-icon.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png b/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png new file mode 100644 index 00000000000..e3389b00443 Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon-16x16.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png b/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png new file mode 100644 index 00000000000..900c77d444c Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon-32x32.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon.ico b/e2e/react-start/custom-identifier-prefix/public/favicon.ico new file mode 100644 index 00000000000..1a1751676f7 Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon.ico differ diff --git a/e2e/react-start/custom-identifier-prefix/public/favicon.png b/e2e/react-start/custom-identifier-prefix/public/favicon.png new file mode 100644 index 00000000000..1e77bc06091 Binary files /dev/null and b/e2e/react-start/custom-identifier-prefix/public/favicon.png differ diff --git a/e2e/react-start/custom-identifier-prefix/public/script.js b/e2e/react-start/custom-identifier-prefix/public/script.js new file mode 100644 index 00000000000..897477e7d0a --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/public/script.js @@ -0,0 +1,2 @@ +console.log('SCRIPT_1 loaded') +window.SCRIPT_1 = true diff --git a/e2e/react-start/custom-identifier-prefix/public/script2.js b/e2e/react-start/custom-identifier-prefix/public/script2.js new file mode 100644 index 00000000000..819af30daf9 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/public/script2.js @@ -0,0 +1,2 @@ +console.log('SCRIPT_2 loaded') +window.SCRIPT_2 = true diff --git a/e2e/react-start/custom-identifier-prefix/public/site.webmanifest b/e2e/react-start/custom-identifier-prefix/public/site.webmanifest new file mode 100644 index 00000000000..fa99de77db6 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/server.js b/e2e/react-start/custom-identifier-prefix/server.js new file mode 100644 index 00000000000..d618ab4bce3 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/src/client.tsx b/e2e/react-start/custom-identifier-prefix/src/client.tsx new file mode 100644 index 00000000000..9a554e0474e --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/client.tsx @@ -0,0 +1,19 @@ +// 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, + + + , + { + identifierPrefix: 'myapp', + }, + ) +}) diff --git a/e2e/react-start/custom-identifier-prefix/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/custom-identifier-prefix/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..15f316681cc --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/src/components/NotFound.tsx b/e2e/react-start/custom-identifier-prefix/src/components/NotFound.tsx new file mode 100644 index 00000000000..af4e0e74946 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/src/routeTree.gen.ts b/e2e/react-start/custom-identifier-prefix/src/routeTree.gen.ts new file mode 100644 index 00000000000..9ee945d10b2 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/routeTree.gen.ts @@ -0,0 +1,68 @@ +/* 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 { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/custom-identifier-prefix/src/router.tsx b/e2e/react-start/custom-identifier-prefix/src/router.tsx new file mode 100644 index 00000000000..1c257ebf903 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/router.tsx @@ -0,0 +1,19 @@ +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: () => , + ssr: { + identifierPrefix: 'myapp', + }, + }) + + return router +} diff --git a/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx b/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx new file mode 100644 index 00000000000..8e645b76127 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/routes/__root.tsx @@ -0,0 +1,123 @@ +/// +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 + +
+
+ {children} +
This is an inline styled div
+ + + + + + + ) +} diff --git a/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx b/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx new file mode 100644 index 00000000000..9fe9f0c230d --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/routes/index.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useId } from 'react' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + const id = useId() + + return ( +
+

{id}

+
+ ) +} diff --git a/e2e/react-start/custom-identifier-prefix/src/server.ts b/e2e/react-start/custom-identifier-prefix/src/server.ts new file mode 100644 index 00000000000..00d13b4d178 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/src/styles/app.css b/e2e/react-start/custom-identifier-prefix/src/styles/app.css new file mode 100644 index 00000000000..c53c8706654 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/styles/app.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@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/custom-identifier-prefix/src/utils/seo.ts b/e2e/react-start/custom-identifier-prefix/src/utils/seo.ts new file mode 100644 index 00000000000..2d1cf2ea5d4 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/src/utils/seo.ts @@ -0,0 +1,37 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'twitter:title', content: title }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + ...(description + ? [ + { name: 'description', content: description }, + { name: 'twitter:description', content: description }, + { name: 'og:description', content: description }, + ] + : []), + ...(keywords ? [{ name: 'keywords', content: keywords }] : []), + ...(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/custom-identifier-prefix/tailwind.config.mjs b/e2e/react-start/custom-identifier-prefix/tailwind.config.mjs new file mode 100644 index 00000000000..e49f4eb776e --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], +} diff --git a/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts b/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts new file mode 100644 index 00000000000..8ad90c10b08 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/tests/identifier-prefix.spec.ts @@ -0,0 +1,15 @@ +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('Custom identifier prefix', async ({ page }) => { + await page.goto('/') + + await expect(page.getByTestId('tested-element')).toContainText('myapp') +}) diff --git a/e2e/react-start/custom-identifier-prefix/tests/setup/global.setup.ts b/e2e/react-start/custom-identifier-prefix/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/tests/setup/global.teardown.ts b/e2e/react-start/custom-identifier-prefix/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/tests/utils/isSpaMode.ts b/e2e/react-start/custom-identifier-prefix/tests/utils/isSpaMode.ts new file mode 100644 index 00000000000..b4edb829a8f --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/tests/utils/isSpaMode.ts @@ -0,0 +1 @@ +export const isSpaMode: boolean = process.env.MODE === 'spa' diff --git a/e2e/react-start/custom-identifier-prefix/tsconfig.json b/e2e/react-start/custom-identifier-prefix/tsconfig.json new file mode 100644 index 00000000000..b3a2d67dfa6 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/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/custom-identifier-prefix/vite.config.ts b/e2e/react-start/custom-identifier-prefix/vite.config.ts new file mode 100644 index 00000000000..b91cd686950 --- /dev/null +++ b/e2e/react-start/custom-identifier-prefix/vite.config.ts @@ -0,0 +1,28 @@ +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' + +const spaModeConfiguration = { + enabled: true, + prerender: { + outputPath: 'index.html', + }, +} + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden + tanstackStart({ + spa: isSpaMode ? spaModeConfiguration : undefined, + }), + viteReact(), + ], +}) diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 0c96c2f4c53..a9fcdcec55a 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -24,6 +24,7 @@ export const renderRouterToStream = async ({ const stream = await ReactDOMServer.renderToReadableStream(children, { signal: request.signal, nonce: router.options.ssr?.nonce, + identifierPrefix: router.options.ssr?.identifierPrefix, }) if (isbot(request.headers.get('User-Agent'))) { @@ -46,6 +47,7 @@ export const renderRouterToStream = async ({ try { const pipeable = ReactDOMServer.renderToPipeableStream(children, { nonce: router.options.ssr?.nonce, + identifierPrefix: router.options.ssr?.identifierPrefix, ...(isbot(request.headers.get('User-Agent')) ? { onAllReady() { diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index b75e88e78e8..fb61bcdb905 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -12,7 +12,9 @@ export const renderRouterToString = async ({ children: ReactNode }) => { try { - let html = ReactDOMServer.renderToString(children) + let html = ReactDOMServer.renderToString(children, { + identifierPrefix: router.options.ssr?.identifierPrefix, + }) router.serverSsr!.setRenderFinished() const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( (htmls) => htmls.join(''), diff --git a/packages/react-router/src/ssr/serializer.ts b/packages/react-router/src/ssr/serializer.ts index 9a1743c18a8..b9db7457d15 100644 --- a/packages/react-router/src/ssr/serializer.ts +++ b/packages/react-router/src/ssr/serializer.ts @@ -4,4 +4,8 @@ declare module '@tanstack/router-core' { export interface SerializerExtensions { ReadableStream: React.JSX.Element } + + interface RouterSSROptionsExtensions { + identifierPrefix?: string + } } diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 50273b30e0a..2c43db25f0e 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -244,6 +244,7 @@ export type { ClearCacheFn, CreateRouterFn, SSROption, + RouterSSROptionsExtensions, } from './router' export * from './config' diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 78b5e11608b..54baa52d87f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -136,6 +136,10 @@ export interface RouterOptionsExtensions export type SSROption = boolean | 'data-only' +export interface RouterSSROptionsExtensions { + nonce?: string +} + export interface RouterOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -465,9 +469,7 @@ export interface RouterOptions< */ rewrite?: LocationRewrite origin?: string - ssr?: { - nonce?: string - } + ssr?: RouterSSROptionsExtensions } export type LocationRewrite = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad8e1808efd..158514e2b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1366,6 +1366,82 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + e2e/react-start/custom-identifier-prefix: + 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 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.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.52.0 + version: 1.52.0 + '@tanstack/router-e2e-utils': + specifier: workspace:* + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.6) + 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.7 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.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.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + 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.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + zod: + specifier: ^3.24.2 + version: 3.25.57 + e2e/react-start/query-integration: dependencies: '@tanstack/react-query': @@ -5346,7 +5422,7 @@ importers: version: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -27349,7 +27425,7 @@ snapshots: optionalDependencies: vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) - vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0): + vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -27378,7 +27454,7 @@ snapshots: '@types/node': 22.10.2 '@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4) '@vitest/ui': 3.0.6(vitest@3.2.4) - jsdom: 27.0.0(postcss@8.5.6) + jsdom: 25.0.1 transitivePeerDependencies: - jiti - less @@ -27393,7 +27469,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0): + vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -27422,7 +27498,7 @@ snapshots: '@types/node': 22.10.2 '@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0))(vitest@3.2.4) '@vitest/ui': 3.0.6(vitest@3.2.4) - jsdom: 25.0.1 + jsdom: 27.0.0(postcss@8.5.6) transitivePeerDependencies: - jiti - less