diff --git a/e2e/react-start/encoding/.gitignore b/e2e/react-start/encoding/.gitignore new file mode 100644 index 00000000000..a79d5cf1299 --- /dev/null +++ b/e2e/react-start/encoding/.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/encoding/.prettierignore b/e2e/react-start/encoding/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/react-start/encoding/.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/encoding/package.json b/e2e/react-start/encoding/package.json new file mode 100644 index 00000000000..acfc1a2346a --- /dev/null +++ b/e2e/react-start/encoding/package.json @@ -0,0 +1,38 @@ +{ + "name": "tanstack-react-start-e2e-encoding", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "start": "node .output/server/index.mjs", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "tailwind-merge": "^2.6.0", + "vite": "6.3.5", + "zod": "^3.24.2" + }, + "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", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/e2e/react-start/encoding/playwright.config.ts b/e2e/react-start/encoding/playwright.config.ts new file mode 100644 index 00000000000..b0c365f8bd1 --- /dev/null +++ b/e2e/react-start/encoding/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test' +import { + getDummyServerPort, + getTestServerPort, +} from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +/** + * 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: `VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_NODE_ENV="test" VITE_EXTERNAL_PORT=${EXTERNAL_PORT} VITE_SERVER_PORT=${PORT} PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/encoding/postcss.config.mjs b/e2e/react-start/encoding/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/e2e/react-start/encoding/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/e2e/react-start/encoding/src/client.tsx b/e2e/react-start/encoding/src/client.tsx new file mode 100644 index 00000000000..5fcd1c5f19d --- /dev/null +++ b/e2e/react-start/encoding/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' +import { createRouter } from './router' + +console.log("[client-entry]: using custom client entry in 'src/client.tsx'") + +const router = createRouter() + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/e2e/react-start/encoding/src/components/CustomMessage.tsx b/e2e/react-start/encoding/src/components/CustomMessage.tsx new file mode 100644 index 00000000000..d00e4eac60b --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/src/components/DefaultCatchBoundary.tsx b/e2e/react-start/encoding/src/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000000..15f316681cc --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/src/components/NotFound.tsx b/e2e/react-start/encoding/src/components/NotFound.tsx new file mode 100644 index 00000000000..af4e0e74946 --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/src/routeTree.gen.ts b/e2e/react-start/encoding/src/routeTree.gen.ts new file mode 100644 index 00000000000..2ca196a7ca8 --- /dev/null +++ b/e2e/react-start/encoding/src/routeTree.gen.ts @@ -0,0 +1,153 @@ +/* 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 type { CreateFileRoute, FileRoutesByPath } from '@tanstack/react-router' +import type { + CreateServerFileRoute, + ServerFileRoutesByPath, +} from '@tanstack/react-start/server' + +import { Route as rootRouteImport } from './routes/__root' +import { Route as NoCharsetRouteImport } from './routes/no-charset' +import { Route as CharsetRouteImport } from './routes/charset' +import { Route as IndexRouteImport } from './routes/index' + +const NoCharsetRoute = NoCharsetRouteImport.update({ + id: '/no-charset', + path: '/no-charset', + getParentRoute: () => rootRouteImport, +} as any) +const CharsetRoute = CharsetRouteImport.update({ + id: '/charset', + path: '/charset', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/charset': typeof CharsetRoute + '/no-charset': typeof NoCharsetRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/charset': typeof CharsetRoute + '/no-charset': typeof NoCharsetRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/charset': typeof CharsetRoute + '/no-charset': typeof NoCharsetRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/charset' | '/no-charset' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/charset' | '/no-charset' + id: '__root__' | '/' | '/charset' | '/no-charset' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + CharsetRoute: typeof CharsetRoute + NoCharsetRoute: typeof NoCharsetRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/charset': { + id: '/charset' + path: '/charset' + fullPath: '/charset' + preLoaderRoute: typeof CharsetRouteImport + parentRoute: typeof rootRouteImport + } + '/no-charset': { + id: '/no-charset' + path: '/no-charset' + fullPath: '/no-charset' + preLoaderRoute: typeof NoCharsetRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +declare module './routes/index' { + const createFileRoute: CreateFileRoute< + '/', + FileRoutesByPath['/']['parentRoute'], + FileRoutesByPath['/']['id'], + FileRoutesByPath['/']['path'], + FileRoutesByPath['/']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/']['parentRoute'], + ServerFileRoutesByPath['/']['id'], + ServerFileRoutesByPath['/']['path'], + ServerFileRoutesByPath['/']['fullPath'], + unknown + > +} +declare module './routes/charset' { + const createFileRoute: CreateFileRoute< + '/charset', + FileRoutesByPath['/charset']['parentRoute'], + FileRoutesByPath['/charset']['id'], + FileRoutesByPath['/charset']['path'], + FileRoutesByPath['/charset']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/charset']['parentRoute'], + ServerFileRoutesByPath['/charset']['id'], + ServerFileRoutesByPath['/charset']['path'], + ServerFileRoutesByPath['/charset']['fullPath'], + unknown + > +} +declare module './routes/no-charset' { + const createFileRoute: CreateFileRoute< + '/no-charset', + FileRoutesByPath['/no-charset']['parentRoute'], + FileRoutesByPath['/no-charset']['id'], + FileRoutesByPath['/no-charset']['path'], + FileRoutesByPath['/no-charset']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/no-charset']['parentRoute'], + ServerFileRoutesByPath['/no-charset']['id'], + ServerFileRoutesByPath['/no-charset']['path'], + ServerFileRoutesByPath['/no-charset']['fullPath'], + unknown + > +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + CharsetRoute: CharsetRoute, + NoCharsetRoute: NoCharsetRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-start/encoding/src/router.tsx b/e2e/react-start/encoding/src/router.tsx new file mode 100644 index 00000000000..c76eb0210cc --- /dev/null +++ b/e2e/react-start/encoding/src/router.tsx @@ -0,0 +1,22 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' +import { NotFound } from './components/NotFound' + +export function createRouter() { + const router = createTanStackRouter({ + routeTree, + defaultPreload: 'intent', + defaultErrorComponent: DefaultCatchBoundary, + defaultNotFoundComponent: () => , + scrollRestoration: true, + }) + + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/react-start/encoding/src/routes/__root.tsx b/e2e/react-start/encoding/src/routes/__root.tsx new file mode 100644 index 00000000000..388c3cbfe6d --- /dev/null +++ b/e2e/react-start/encoding/src/routes/__root.tsx @@ -0,0 +1,64 @@ +/// +import * as React from 'react' +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' +import { NotFound } from '~/components/NotFound' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + ], + }), + 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 ( + + + + + + {children} + + + + + ) +} diff --git a/e2e/react-start/encoding/src/routes/charset.tsx b/e2e/react-start/encoding/src/routes/charset.tsx new file mode 100644 index 00000000000..b522ef192ad --- /dev/null +++ b/e2e/react-start/encoding/src/routes/charset.tsx @@ -0,0 +1,14 @@ +export const Route = createFileRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + ], + }), + component: Page, +}) + +function Page() { + return

Charset

+} diff --git a/e2e/react-start/encoding/src/routes/index.tsx b/e2e/react-start/encoding/src/routes/index.tsx new file mode 100644 index 00000000000..b522ef192ad --- /dev/null +++ b/e2e/react-start/encoding/src/routes/index.tsx @@ -0,0 +1,14 @@ +export const Route = createFileRoute({ + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + ], + }), + component: Page, +}) + +function Page() { + return

Charset

+} diff --git a/e2e/react-start/encoding/src/routes/no-charset.tsx b/e2e/react-start/encoding/src/routes/no-charset.tsx new file mode 100644 index 00000000000..4ef0458325a --- /dev/null +++ b/e2e/react-start/encoding/src/routes/no-charset.tsx @@ -0,0 +1,7 @@ +export const Route = createFileRoute({ + component: Page, +}) + +function Page() { + return

No charset

+} diff --git a/e2e/react-start/encoding/src/server.ts b/e2e/react-start/encoding/src/server.ts new file mode 100644 index 00000000000..77cb4a13169 --- /dev/null +++ b/e2e/react-start/encoding/src/server.ts @@ -0,0 +1,13 @@ +// DO NOT DELETE THIS FILE!!! +// This file is a good smoke test to make sure the custom server entry is working +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/react-start/server' +import { createRouter } from './router' + +console.log("[server-entry]: using custom server entry in 'src/server.ts'") + +export default createStartHandler({ + createRouter, +})(defaultStreamHandler) diff --git a/e2e/react-start/encoding/tailwind.config.mjs b/e2e/react-start/encoding/tailwind.config.mjs new file mode 100644 index 00000000000..e49f4eb776e --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/tests/charset-encoding.spec.ts b/e2e/react-start/encoding/tests/charset-encoding.spec.ts new file mode 100644 index 00000000000..8b949e4697d --- /dev/null +++ b/e2e/react-start/encoding/tests/charset-encoding.spec.ts @@ -0,0 +1,52 @@ +import { expect } from '@playwright/test' +import { test } from './fixture' + +test.describe('Encoding', () => { + test('asserts dehydration script is injected before when no charset is present', async ({ + page, + }) => { + // In this test setup, the no-charset route does not have a charset meta tag. + const response = await page.goto('/no-charset') + const html = await response?.text() + + expect(html).toBeDefined() + if (!html) return + + // Case-insensitive search for charset meta, TSR script, and closing head tag + const htmlLower = html.toLowerCase() + const charsetIndex = htmlLower.search(/') + + // Charset should NOT exist, but the TSR script and head tag should + expect(charsetIndex).toBe(-1) + expect(tsrScriptIndex).toBeGreaterThan(-1) + expect(headEndIndex).toBeGreaterThan(-1) + + // In the fallback case, the TSR dehydration script should appear BEFORE the closing tag. + expect(tsrScriptIndex).toBeLessThan(headEndIndex) + }) + + test('asserts charset meta tag appears before dehydration script when present on a route', async ({ + page, + }) => { + // This route specifically adds a charset meta tag. + const response = await page.goto('/charset') + const html = await response?.text() + + expect(html).toBeDefined() + if (!html) return + + // Case-insensitive search for charset meta and TSR script + const htmlLower = html.toLowerCase() + const charsetIndex = htmlLower.search(/ +} +export const test = base.extend({ + whitelistErrors: [[], { option: true }], + page: async ({ page, whitelistErrors }, use) => { + const errorMessages: Array = [] + page.on('console', (m) => { + if (m.type() === 'error') { + const text = m.text() + for (const whitelistError of whitelistErrors) { + if ( + (typeof whitelistError === 'string' && + text.includes(whitelistError)) || + (whitelistError instanceof RegExp && whitelistError.test(text)) + ) { + return + } + } + errorMessages.push(text) + } + }) + await use(page) + expect(errorMessages).toEqual([]) + }, +}) diff --git a/e2e/react-start/encoding/tests/setup/global.setup.ts b/e2e/react-start/encoding/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/tests/setup/global.teardown.ts b/e2e/react-start/encoding/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/tsconfig.json b/e2e/react-start/encoding/tsconfig.json new file mode 100644 index 00000000000..b3a2d67dfa6 --- /dev/null +++ b/e2e/react-start/encoding/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/encoding/vite.config.ts b/e2e/react-start/encoding/vite.config.ts new file mode 100644 index 00000000000..54430698260 --- /dev/null +++ b/e2e/react-start/encoding/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ tsr: { verboseFileRoutes: false } }), + ], +}) diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 54c0197bb58..2383fbbb966 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -24,6 +24,8 @@ const patternBodyStart = /()/ const patternHtmlEnd = /(<\/html>)/ const patternHeadStart = /()/ +const patternHeadEnd = /(<\/head>)/ +const patternCharset = /(\s]+\2.*?>)/i // regex pattern for matching closing tags const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g @@ -98,6 +100,7 @@ export function transformStreamWithRouter( let pendingClosingTags = '' let bodyStarted = false as boolean let headStarted = false as boolean + let headScriptInjected = false as boolean let leftover = '' let leftoverHtml = '' @@ -181,18 +184,35 @@ export function transformStreamWithRouter( } } - if (!headStarted) { - const headStartMatch = chunkString.match(patternHeadStart) - if (headStartMatch) { - headStarted = true - const index = headStartMatch.index! - const headTag = headStartMatch[0] - const remaining = chunkString.slice(index + headTag.length) - finalPassThrough.write( - chunkString.slice(0, index) + headTag + getBufferedRouterStream(), - ) - // make sure to only write `remaining` until the next closing tag - chunkString = remaining + if (!headScriptInjected) { + if (!headStarted) { + const headStartMatch = chunkString.match(patternHeadStart) + if (headStartMatch) { + headStarted = true + } + } + + if (headStarted) { + const charsetMatch = chunkString.match(patternCharset) + + if (charsetMatch) { + headScriptInjected = true + const index = charsetMatch.index! + charsetMatch[0]!.length + finalPassThrough.write( + chunkString.slice(0, index) + getBufferedRouterStream(), + ) + chunkString = chunkString.slice(index) + } else { + const headEndMatch = chunkString.match(patternHeadEnd) + if (headEndMatch) { + headScriptInjected = true + const index = headEndMatch.index! + finalPassThrough.write( + chunkString.slice(0, index) + getBufferedRouterStream(), + ) + chunkString = chunkString.slice(index) + } + } } }