diff --git a/docs/start/framework/react/guide/server-entry-point.md b/docs/start/framework/react/guide/server-entry-point.md
index 5acc8ee550..62759dbfa5 100644
--- a/docs/start/framework/react/guide/server-entry-point.md
+++ b/docs/start/framework/react/guide/server-entry-point.md
@@ -89,6 +89,58 @@ export default createServerEntry({
})
```
+## Per-request SSR options
+
+The default server entry handler accepts request options as its second argument.
+Use this when you want to keep Start's default handler but override one setting
+for a specific request.
+
+```tsx
+// src/server.ts
+import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
+
+export default createServerEntry({
+ fetch(request) {
+ return handler.fetch(request, {
+ ssr: {
+ streaming: {
+ render: request.headers.get('x-render-stream') !== 'false',
+ },
+ },
+ })
+ },
+})
+```
+
+`ssr.streaming` in request options is a partial override. For example,
+`{ render: false }` disables render streaming for that request.
+
+If you need full handler-level control, create the Start handler yourself:
+
+```tsx
+// src/server.ts
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/react-start/server'
+import { createServerEntry } from '@tanstack/react-start/server-entry'
+
+const handler = createStartHandler({
+ handler: defaultStreamHandler,
+ ssr: {
+ streaming: ({ request }) => {
+ if (request.headers.get('user-agent')?.includes('Googlebot')) {
+ return { render: false }
+ }
+
+ return { render: true }
+ },
+ },
+})
+
+export default createServerEntry({ fetch: handler })
+```
+
## Server Configuration
The server entry point is where you can configure server-specific behavior:
diff --git a/e2e/react-start/streaming-ssr/package.json b/e2e/react-start/streaming-ssr/package.json
index 0cbadeae74..8146977266 100644
--- a/e2e/react-start/streaming-ssr/package.json
+++ b/e2e/react-start/streaming-ssr/package.json
@@ -26,6 +26,7 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
+ "@vitejs/plugin-react": "^6.0.1",
"srvx": "^0.11.9",
"typescript": "^6.0.2",
"vite": "^8.0.14"
diff --git a/e2e/react-start/streaming-ssr/playwright.config.ts b/e2e/react-start/streaming-ssr/playwright.config.ts
index 052ebe879d..066d584e60 100644
--- a/e2e/react-start/streaming-ssr/playwright.config.ts
+++ b/e2e/react-start/streaming-ssr/playwright.config.ts
@@ -8,9 +8,11 @@ const PORT = await getTestServerPort(
`${packageJson.name}${isPreview ? '_preview' : ''}`,
)
const baseURL = `http://localhost:${PORT}`
+const streamingEntryForm =
+ process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'
-const ssrCommand = `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`
-const previewCommand = `VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`
+const ssrCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`
+const previewCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} pnpm preview --port ${PORT}`
/**
* See https://playwright.dev/docs/test-configuration.
diff --git a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
index e1e3b367f1..ba37c2ed80 100644
--- a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
+++ b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SyncOnlyRouteImport } from './routes/sync-only'
+import { Route as StreamingPolicyRouteImport } from './routes/streaming-policy'
import { Route as StreamRouteImport } from './routes/stream'
import { Route as SlowRenderRouteImport } from './routes/slow-render'
import { Route as QueryHeavyRouteImport } from './routes/query-heavy'
@@ -26,6 +27,11 @@ const SyncOnlyRoute = SyncOnlyRouteImport.update({
path: '/sync-only',
getParentRoute: () => rootRouteImport,
} as any)
+const StreamingPolicyRoute = StreamingPolicyRouteImport.update({
+ id: '/streaming-policy',
+ path: '/streaming-policy',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StreamRoute = StreamRouteImport.update({
id: '/stream',
path: '/stream',
@@ -88,6 +94,7 @@ export interface FileRoutesByFullPath {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRoutesByTo {
@@ -101,6 +108,7 @@ export interface FileRoutesByTo {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRoutesById {
@@ -115,6 +123,7 @@ export interface FileRoutesById {
'/query-heavy': typeof QueryHeavyRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/sync-only': typeof SyncOnlyRoute
}
export interface FileRouteTypes {
@@ -130,6 +139,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
+ | '/streaming-policy'
| '/sync-only'
fileRoutesByTo: FileRoutesByTo
to:
@@ -143,6 +153,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
+ | '/streaming-policy'
| '/sync-only'
id:
| '__root__'
@@ -156,6 +167,7 @@ export interface FileRouteTypes {
| '/query-heavy'
| '/slow-render'
| '/stream'
+ | '/streaming-policy'
| '/sync-only'
fileRoutesById: FileRoutesById
}
@@ -170,6 +182,7 @@ export interface RootRouteChildren {
QueryHeavyRoute: typeof QueryHeavyRoute
SlowRenderRoute: typeof SlowRenderRoute
StreamRoute: typeof StreamRoute
+ StreamingPolicyRoute: typeof StreamingPolicyRoute
SyncOnlyRoute: typeof SyncOnlyRoute
}
@@ -182,6 +195,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SyncOnlyRouteImport
parentRoute: typeof rootRouteImport
}
+ '/streaming-policy': {
+ id: '/streaming-policy'
+ path: '/streaming-policy'
+ fullPath: '/streaming-policy'
+ preLoaderRoute: typeof StreamingPolicyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/stream': {
id: '/stream'
path: '/stream'
@@ -266,6 +286,7 @@ const rootRouteChildren: RootRouteChildren = {
QueryHeavyRoute: QueryHeavyRoute,
SlowRenderRoute: SlowRenderRoute,
StreamRoute: StreamRoute,
+ StreamingPolicyRoute: StreamingPolicyRoute,
SyncOnlyRoute: SyncOnlyRoute,
}
export const routeTree = rootRouteImport
diff --git a/e2e/react-start/streaming-ssr/src/routes/index.tsx b/e2e/react-start/streaming-ssr/src/routes/index.tsx
index 54236a1fc5..9c074ff522 100644
--- a/e2e/react-start/streaming-ssr/src/routes/index.tsx
+++ b/e2e/react-start/streaming-ssr/src/routes/index.tsx
@@ -52,6 +52,12 @@ function Index() {
{' '}
- Tests nested components with deferred data
+
+
+ Streaming Policy
+ {' '}
+ - Tests request-controlled SSR streaming policy
+
)
diff --git a/e2e/react-start/streaming-ssr/src/routes/streaming-policy.tsx b/e2e/react-start/streaming-ssr/src/routes/streaming-policy.tsx
new file mode 100644
index 0000000000..dbf3300df9
--- /dev/null
+++ b/e2e/react-start/streaming-ssr/src/routes/streaming-policy.tsx
@@ -0,0 +1,41 @@
+import { Await, createFileRoute, useRouter } from '@tanstack/react-router'
+import { Suspense } from 'react'
+
+export const Route = createFileRoute('/streaming-policy')({
+ loader: () => {
+ return {
+ immediate: 'Immediate policy content',
+ deferred: new Promise((resolve) => {
+ setTimeout(() => resolve('Deferred policy content'), 100)
+ }),
+ }
+ },
+ component: StreamingPolicyRoute,
+})
+
+function StreamingPolicyRoute() {
+ const router = useRouter()
+ const loaderData = Route.useLoaderData()
+ const serverSsr = router.serverSsr
+ const render = serverSsr?.shouldStream('render')
+ const head = serverSsr?.shouldStream('head')
+
+ return (
+
+
Streaming Policy Test
+
+ {String(render)}
+
+
+ {String(head)}
+
+
{loaderData.immediate}
+
Loading policy content... }>
+ {value}
}
+ />
+
+
+ )
+}
diff --git a/e2e/react-start/streaming-ssr/src/server.ts b/e2e/react-start/streaming-ssr/src/server.ts
new file mode 100644
index 0000000000..5ed98c4756
--- /dev/null
+++ b/e2e/react-start/streaming-ssr/src/server.ts
@@ -0,0 +1,67 @@
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/react-start/server'
+import defaultServerEntry, {
+ createServerEntry,
+} from '@tanstack/react-start/server-entry'
+import type { SsrStreamingResolverResult } from '@tanstack/react-start/server'
+
+type SsrStreamingOverride = {
+ render?: boolean
+ head?: boolean
+}
+
+function getStreamingPolicy(request: Request): SsrStreamingResolverResult {
+ const url = new URL(request.url)
+
+ switch (url.searchParams.get('streaming')) {
+ case 'all':
+ return {
+ render: true,
+ head: true,
+ }
+ case 'none':
+ return {
+ render: false,
+ }
+ case 'render-only':
+ return {
+ render: true,
+ }
+ case 'head-only':
+ return {
+ render: false,
+ head: true,
+ }
+ default:
+ return undefined
+ }
+}
+
+function getStreamingOverride(
+ request: Request,
+): SsrStreamingOverride | undefined {
+ return getStreamingPolicy(request)
+}
+
+const handler = createStartHandler({
+ handler: defaultStreamHandler,
+ ssr: {
+ streaming: ({ request }) => getStreamingPolicy(request),
+ },
+})
+
+export default createServerEntry({
+ fetch(request) {
+ if (process.env.STREAMING_SSR_ENTRY_FORM === 'default-entry') {
+ return defaultServerEntry.fetch(request, {
+ ssr: {
+ streaming: getStreamingOverride(request),
+ },
+ } as any)
+ }
+
+ return handler(request)
+ },
+})
diff --git a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
index 90ee16edc0..da06e31e1c 100644
--- a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
@@ -1,6 +1,13 @@
import { expect, test, testWithHydration } from './fixtures'
+import { expectDeferredRouteToStream } from '../../../streaming-ssr-assertions'
test.describe('Deferred data streaming', () => {
+ test('streams the shell before deferred data resolves', async ({
+ baseURL,
+ }) => {
+ await expectDeferredRouteToStream(baseURL, { expectFallback: true })
+ })
+
test('shows immediate data right away and deferred data after loading', async ({
page,
}) => {
diff --git a/e2e/react-start/streaming-ssr/tests/home.spec.ts b/e2e/react-start/streaming-ssr/tests/home.spec.ts
index e231d007fb..92e75e137e 100644
--- a/e2e/react-start/streaming-ssr/tests/home.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/home.spec.ts
@@ -17,6 +17,7 @@ test.describe('Home page', () => {
await expect(
page.getByRole('link', { name: 'Stream' }).first(),
).toBeVisible()
+ await expect(page.getByTestId('link-streaming-policy')).toBeVisible()
},
)
diff --git a/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
index 319219c519..041e18e00f 100644
--- a/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
@@ -1,5 +1,7 @@
import { expect, test } from './fixtures'
+test.skip(process.env.MODE !== 'preview', 'Only runs against vite preview')
+
test('vite preview streams HTML incrementally', async ({ page }) => {
// /deferred has immediate data + deferred data with a ~1s delay.
// Without streaming, compression buffers the entire response.
diff --git a/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
index 5e90d1f527..942f297c60 100644
--- a/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
@@ -1,6 +1,13 @@
import { expect, test, testWithHydration } from './fixtures'
+import { expectQueryHeavyRouteToStream } from '../../../streaming-ssr-assertions'
test.describe('Query heavy route (9 useSuspenseQuery)', () => {
+ test('streams fast query reveals before slow queries resolve', async ({
+ baseURL,
+ }) => {
+ await expectQueryHeavyRouteToStream(baseURL)
+ })
+
test('all queries resolve with server data', async ({ page }) => {
await page.goto('/query-heavy')
diff --git a/e2e/react-start/streaming-ssr/tests/streaming-policy.spec.ts b/e2e/react-start/streaming-ssr/tests/streaming-policy.spec.ts
new file mode 100644
index 0000000000..a943e7a53f
--- /dev/null
+++ b/e2e/react-start/streaming-ssr/tests/streaming-policy.spec.ts
@@ -0,0 +1,49 @@
+import { expect, test } from './fixtures'
+
+const streamingEntryForm =
+ process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'
+
+test.describe(`ssr.streaming policy (${streamingEntryForm})`, () => {
+ test('resolves render and head channels from the request', async ({
+ request,
+ }) => {
+ const cases = [
+ ['all', 'true', 'true'],
+ ['none', 'false', 'false'],
+ ['render-only', 'true', 'false'],
+ ['head-only', 'false', 'true'],
+ ] as const
+
+ for (const [mode, render, head] of cases) {
+ const response = await request.get(`/streaming-policy?streaming=${mode}`)
+ const html = await response.text()
+
+ expect(response.ok()).toBe(true)
+ expect(html).toContain(`${render}
`)
+ expect(html).toContain(`${head}
`)
+ }
+ })
+
+ test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false })
+
+ for (const mode of ['none', 'head-only'] as const) {
+ test(`renders the full policy page when streaming=${mode}`, async ({
+ page,
+ }) => {
+ await page.goto(`/streaming-policy?streaming=${mode}`)
+
+ await expect(page.getByTestId('policy-render')).toContainText('false')
+ await expect(page.getByTestId('policy-head')).toContainText(
+ mode === 'head-only' ? 'true' : 'false',
+ )
+ await expect(page.getByTestId('policy-immediate')).toContainText(
+ 'Immediate policy content',
+ )
+ await expect(page.getByTestId('policy-deferred')).toContainText(
+ 'Deferred policy content',
+ )
+ })
+ }
+ })
+})
diff --git a/e2e/react-start/streaming-ssr/vite.config.ts b/e2e/react-start/streaming-ssr/vite.config.ts
index 275bfacde2..0590c197a5 100644
--- a/e2e/react-start/streaming-ssr/vite.config.ts
+++ b/e2e/react-start/streaming-ssr/vite.config.ts
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
export default defineConfig({
server: {
port: 3000,
},
- plugins: [tanstackStart()],
+ plugins: [tanstackStart(), viteReact()],
})
diff --git a/e2e/solid-start/basic/playwright.config.ts b/e2e/solid-start/basic/playwright.config.ts
index 6924134cd1..f1cfe6dbed 100644
--- a/e2e/solid-start/basic/playwright.config.ts
+++ b/e2e/solid-start/basic/playwright.config.ts
@@ -54,6 +54,8 @@ export default defineConfig({
PORT: String(PORT),
E2E_DIST_DIR: distDir,
E2E_PORT_KEY: e2ePortKey,
+ STREAMING_SSR_ENTRY_FORM:
+ process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler',
},
},
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index c9fba32156..2b3402a031 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as UsersRouteImport } from './routes/users'
+import { Route as StreamingPolicyRouteImport } from './routes/streaming-policy'
import { Route as StreamRouteImport } from './routes/stream'
import { Route as ScriptsRouteImport } from './routes/scripts'
import { Route as RawStreamRouteImport } from './routes/raw-stream'
@@ -75,6 +76,11 @@ const UsersRoute = UsersRouteImport.update({
path: '/users',
getParentRoute: () => rootRouteImport,
} as any)
+const StreamingPolicyRoute = StreamingPolicyRouteImport.update({
+ id: '/streaming-policy',
+ path: '/streaming-policy',
+ getParentRoute: () => rootRouteImport,
+} as any)
const StreamRoute = StreamRouteImport.update({
id: '/stream',
path: '/stream',
@@ -399,6 +405,7 @@ export interface FileRoutesByFullPath {
'/raw-stream': typeof RawStreamRouteWithChildren
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/users': typeof UsersRouteWithChildren
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
@@ -455,6 +462,7 @@ export interface FileRoutesByTo {
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
@@ -515,6 +523,7 @@ export interface FileRoutesById {
'/raw-stream': typeof RawStreamRouteWithChildren
'/scripts': typeof ScriptsRoute
'/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
'/users': typeof UsersRouteWithChildren
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
@@ -578,6 +587,7 @@ export interface FileRouteTypes {
| '/raw-stream'
| '/scripts'
| '/stream'
+ | '/streaming-policy'
| '/users'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
@@ -634,6 +644,7 @@ export interface FileRouteTypes {
| '/links'
| '/scripts'
| '/stream'
+ | '/streaming-policy'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
@@ -693,6 +704,7 @@ export interface FileRouteTypes {
| '/raw-stream'
| '/scripts'
| '/stream'
+ | '/streaming-policy'
| '/users'
| '/not-found/parent-boundary'
| '/specialChars/malformed'
@@ -756,6 +768,7 @@ export interface RootRouteChildren {
RawStreamRoute: typeof RawStreamRouteWithChildren
ScriptsRoute: typeof ScriptsRoute
StreamRoute: typeof StreamRoute
+ StreamingPolicyRoute: typeof StreamingPolicyRoute
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute
@@ -776,6 +789,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof UsersRouteImport
parentRoute: typeof rootRouteImport
}
+ '/streaming-policy': {
+ id: '/streaming-policy'
+ path: '/streaming-policy'
+ fullPath: '/streaming-policy'
+ preLoaderRoute: typeof StreamingPolicyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/stream': {
id: '/stream'
path: '/stream'
@@ -1404,6 +1424,7 @@ const rootRouteChildren: RootRouteChildren = {
RawStreamRoute: RawStreamRouteWithChildren,
ScriptsRoute: ScriptsRoute,
StreamRoute: StreamRoute,
+ StreamingPolicyRoute: StreamingPolicyRoute,
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute,
diff --git a/e2e/solid-start/basic/src/routes/streaming-policy.tsx b/e2e/solid-start/basic/src/routes/streaming-policy.tsx
new file mode 100644
index 0000000000..ebc918da6c
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/streaming-policy.tsx
@@ -0,0 +1,37 @@
+import { Await, createFileRoute, useRouter } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+export const Route = createFileRoute('/streaming-policy')({
+ loader: () => {
+ return {
+ immediate: 'Immediate policy content',
+ deferred: new Promise((resolve) => {
+ setTimeout(() => resolve('Deferred policy content'), 100)
+ }),
+ }
+ },
+ component: StreamingPolicyRoute,
+})
+
+function StreamingPolicyRoute() {
+ const router = useRouter()
+ const loaderData = Route.useLoaderData()
+ const serverSsr = router.serverSsr
+ const render = serverSsr?.shouldStream('render')
+ const head = serverSsr?.shouldStream('head')
+
+ return (
+
+
Streaming Policy Test
+
{String(render)}
+
{String(head)}
+
{loaderData().immediate}
+
Loading policy content... }>
+ {value}
}
+ />
+
+
+ )
+}
diff --git a/e2e/solid-start/basic/src/server.ts b/e2e/solid-start/basic/src/server.ts
index d48e6df349..19f9b836ce 100644
--- a/e2e/solid-start/basic/src/server.ts
+++ b/e2e/solid-start/basic/src/server.ts
@@ -1,11 +1,69 @@
-// 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/solid-start/server-entry'
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/solid-start/server'
+import defaultServerEntry, {
+ createServerEntry,
+} from '@tanstack/solid-start/server-entry'
+import type { SsrStreamingResolverResult } from '@tanstack/solid-start/server'
+
+type SsrStreamingOverride = {
+ render?: boolean
+ head?: boolean
+}
console.log("[server-entry]: using custom server entry in 'src/server.ts'")
-export default {
- fetch(request: Request) {
- return handler.fetch(request)
- },
+function getStreamingPolicy(request: Request): SsrStreamingResolverResult {
+ const url = new URL(request.url)
+
+ switch (url.searchParams.get('streaming')) {
+ case 'all':
+ return {
+ render: true,
+ head: true,
+ }
+ case 'none':
+ return {
+ render: false,
+ }
+ case 'render-only':
+ return {
+ render: true,
+ }
+ case 'head-only':
+ return {
+ render: false,
+ head: true,
+ }
+ default:
+ return undefined
+ }
+}
+
+function getStreamingOverride(
+ request: Request,
+): SsrStreamingOverride | undefined {
+ return getStreamingPolicy(request)
}
+
+const handler = createStartHandler({
+ handler: defaultStreamHandler,
+ ssr: {
+ streaming: ({ request }) => getStreamingPolicy(request),
+ },
+})
+
+export default createServerEntry({
+ fetch(request) {
+ if (process.env.STREAMING_SSR_ENTRY_FORM === 'default-entry') {
+ return defaultServerEntry.fetch(request, {
+ ssr: {
+ streaming: getStreamingOverride(request),
+ },
+ } as any)
+ }
+
+ return handler(request)
+ },
+})
diff --git a/e2e/solid-start/basic/tests/streaming-policy.spec.ts b/e2e/solid-start/basic/tests/streaming-policy.spec.ts
new file mode 100644
index 0000000000..c5fd68bc6b
--- /dev/null
+++ b/e2e/solid-start/basic/tests/streaming-policy.spec.ts
@@ -0,0 +1,50 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+const streamingEntryForm =
+ process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'
+
+test.describe(`ssr.streaming policy (${streamingEntryForm})`, () => {
+ test('resolves render and head channels from the request', async ({
+ request,
+ }) => {
+ const cases = [
+ ['all', 'true', 'true'],
+ ['none', 'false', 'false'],
+ ['render-only', 'true', 'false'],
+ ['head-only', 'false', 'true'],
+ ] as const
+
+ for (const [mode, render, head] of cases) {
+ const response = await request.get(`/streaming-policy?streaming=${mode}`)
+ const html = await response.text()
+
+ expect(response.ok()).toBe(true)
+ expect(html).toContain(`${render}
`)
+ expect(html).toContain(`${head}
`)
+ }
+ })
+
+ test.describe('with JavaScript disabled', () => {
+ test.use({ javaScriptEnabled: false })
+
+ for (const mode of ['none', 'head-only'] as const) {
+ test(`renders the full policy page when streaming=${mode}`, async ({
+ page,
+ }) => {
+ await page.goto(`/streaming-policy?streaming=${mode}`)
+
+ await expect(page.getByTestId('policy-render')).toContainText('false')
+ await expect(page.getByTestId('policy-head')).toContainText(
+ mode === 'head-only' ? 'true' : 'false',
+ )
+ await expect(page.getByTestId('policy-immediate')).toContainText(
+ 'Immediate policy content',
+ )
+ await expect(page.getByTestId('policy-deferred')).toContainText(
+ 'Deferred policy content',
+ )
+ })
+ }
+ })
+})
diff --git a/e2e/solid-start/streaming-ssr/package.json b/e2e/solid-start/streaming-ssr/package.json
new file mode 100644
index 0000000000..04102ad3e2
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "tanstack-solid-start-e2e-streaming-ssr",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "dev:e2e": "vite dev",
+ "build": "vite build && tsc --noEmit",
+ "start": "pnpx srvx --prod -s ../client dist/server/server.js",
+ "preview": "vite preview",
+ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium",
+ "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test preview-streaming.spec.ts --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/solid-query": "^5.90.9",
+ "@tanstack/solid-router": "workspace:^",
+ "@tanstack/solid-router-ssr-query": "workspace:^",
+ "@tanstack/solid-start": "workspace:^",
+ "solid-js": "^1.9.10"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/node": "^22.10.2",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.14",
+ "vite-plugin-solid": "^2.11.11"
+ }
+}
diff --git a/e2e/solid-start/streaming-ssr/playwright.config.ts b/e2e/solid-start/streaming-ssr/playwright.config.ts
new file mode 100644
index 0000000000..066d584e60
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const isPreview = process.env.MODE === 'preview'
+
+const PORT = await getTestServerPort(
+ `${packageJson.name}${isPreview ? '_preview' : ''}`,
+)
+const baseURL = `http://localhost:${PORT}`
+const streamingEntryForm =
+ process.env.STREAMING_SSR_ENTRY_FORM ?? 'create-start-handler'
+
+const ssrCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`
+const previewCommand = `STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} VITE_SERVER_PORT=${PORT} pnpm build && STREAMING_SSR_ENTRY_FORM=${streamingEntryForm} pnpm preview --port ${PORT}`
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+
+ reporter: [['line']],
+
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL,
+ },
+
+ webServer: {
+ command: isPreview ? previewCommand : ssrCommand,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts b/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts
new file mode 100644
index 0000000000..52eaa6b873
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts
@@ -0,0 +1,303 @@
+/* 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 SyncOnlyRouteImport } from './routes/sync-only'
+import { Route as StreamingPolicyRouteImport } from './routes/streaming-policy'
+import { Route as StreamRouteImport } from './routes/stream'
+import { Route as SlowRenderRouteImport } from './routes/slow-render'
+import { Route as QueryHeavyRouteImport } from './routes/query-heavy'
+import { Route as NestedDeferredRouteImport } from './routes/nested-deferred'
+import { Route as ManyPromisesRouteImport } from './routes/many-promises'
+import { Route as FastSerialRouteImport } from './routes/fast-serial'
+import { Route as DeferredRejectionRouteImport } from './routes/deferred-rejection'
+import { Route as DeferredRouteImport } from './routes/deferred'
+import { Route as ConcurrentRouteImport } from './routes/concurrent'
+import { Route as IndexRouteImport } from './routes/index'
+
+const SyncOnlyRoute = SyncOnlyRouteImport.update({
+ id: '/sync-only',
+ path: '/sync-only',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const StreamingPolicyRoute = StreamingPolicyRouteImport.update({
+ id: '/streaming-policy',
+ path: '/streaming-policy',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const StreamRoute = StreamRouteImport.update({
+ id: '/stream',
+ path: '/stream',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SlowRenderRoute = SlowRenderRouteImport.update({
+ id: '/slow-render',
+ path: '/slow-render',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const QueryHeavyRoute = QueryHeavyRouteImport.update({
+ id: '/query-heavy',
+ path: '/query-heavy',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const NestedDeferredRoute = NestedDeferredRouteImport.update({
+ id: '/nested-deferred',
+ path: '/nested-deferred',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ManyPromisesRoute = ManyPromisesRouteImport.update({
+ id: '/many-promises',
+ path: '/many-promises',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const FastSerialRoute = FastSerialRouteImport.update({
+ id: '/fast-serial',
+ path: '/fast-serial',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DeferredRejectionRoute = DeferredRejectionRouteImport.update({
+ id: '/deferred-rejection',
+ path: '/deferred-rejection',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DeferredRoute = DeferredRouteImport.update({
+ id: '/deferred',
+ path: '/deferred',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ConcurrentRoute = ConcurrentRouteImport.update({
+ id: '/concurrent',
+ path: '/concurrent',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/concurrent': typeof ConcurrentRoute
+ '/deferred': typeof DeferredRoute
+ '/deferred-rejection': typeof DeferredRejectionRoute
+ '/fast-serial': typeof FastSerialRoute
+ '/many-promises': typeof ManyPromisesRoute
+ '/nested-deferred': typeof NestedDeferredRoute
+ '/query-heavy': typeof QueryHeavyRoute
+ '/slow-render': typeof SlowRenderRoute
+ '/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
+ '/sync-only': typeof SyncOnlyRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/concurrent': typeof ConcurrentRoute
+ '/deferred': typeof DeferredRoute
+ '/deferred-rejection': typeof DeferredRejectionRoute
+ '/fast-serial': typeof FastSerialRoute
+ '/many-promises': typeof ManyPromisesRoute
+ '/nested-deferred': typeof NestedDeferredRoute
+ '/query-heavy': typeof QueryHeavyRoute
+ '/slow-render': typeof SlowRenderRoute
+ '/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
+ '/sync-only': typeof SyncOnlyRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/concurrent': typeof ConcurrentRoute
+ '/deferred': typeof DeferredRoute
+ '/deferred-rejection': typeof DeferredRejectionRoute
+ '/fast-serial': typeof FastSerialRoute
+ '/many-promises': typeof ManyPromisesRoute
+ '/nested-deferred': typeof NestedDeferredRoute
+ '/query-heavy': typeof QueryHeavyRoute
+ '/slow-render': typeof SlowRenderRoute
+ '/stream': typeof StreamRoute
+ '/streaming-policy': typeof StreamingPolicyRoute
+ '/sync-only': typeof SyncOnlyRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/concurrent'
+ | '/deferred'
+ | '/deferred-rejection'
+ | '/fast-serial'
+ | '/many-promises'
+ | '/nested-deferred'
+ | '/query-heavy'
+ | '/slow-render'
+ | '/stream'
+ | '/streaming-policy'
+ | '/sync-only'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/concurrent'
+ | '/deferred'
+ | '/deferred-rejection'
+ | '/fast-serial'
+ | '/many-promises'
+ | '/nested-deferred'
+ | '/query-heavy'
+ | '/slow-render'
+ | '/stream'
+ | '/streaming-policy'
+ | '/sync-only'
+ id:
+ | '__root__'
+ | '/'
+ | '/concurrent'
+ | '/deferred'
+ | '/deferred-rejection'
+ | '/fast-serial'
+ | '/many-promises'
+ | '/nested-deferred'
+ | '/query-heavy'
+ | '/slow-render'
+ | '/stream'
+ | '/streaming-policy'
+ | '/sync-only'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ ConcurrentRoute: typeof ConcurrentRoute
+ DeferredRoute: typeof DeferredRoute
+ DeferredRejectionRoute: typeof DeferredRejectionRoute
+ FastSerialRoute: typeof FastSerialRoute
+ ManyPromisesRoute: typeof ManyPromisesRoute
+ NestedDeferredRoute: typeof NestedDeferredRoute
+ QueryHeavyRoute: typeof QueryHeavyRoute
+ SlowRenderRoute: typeof SlowRenderRoute
+ StreamRoute: typeof StreamRoute
+ StreamingPolicyRoute: typeof StreamingPolicyRoute
+ SyncOnlyRoute: typeof SyncOnlyRoute
+}
+
+declare module '@tanstack/solid-router' {
+ interface FileRoutesByPath {
+ '/sync-only': {
+ id: '/sync-only'
+ path: '/sync-only'
+ fullPath: '/sync-only'
+ preLoaderRoute: typeof SyncOnlyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/streaming-policy': {
+ id: '/streaming-policy'
+ path: '/streaming-policy'
+ fullPath: '/streaming-policy'
+ preLoaderRoute: typeof StreamingPolicyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/stream': {
+ id: '/stream'
+ path: '/stream'
+ fullPath: '/stream'
+ preLoaderRoute: typeof StreamRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/slow-render': {
+ id: '/slow-render'
+ path: '/slow-render'
+ fullPath: '/slow-render'
+ preLoaderRoute: typeof SlowRenderRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/query-heavy': {
+ id: '/query-heavy'
+ path: '/query-heavy'
+ fullPath: '/query-heavy'
+ preLoaderRoute: typeof QueryHeavyRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/nested-deferred': {
+ id: '/nested-deferred'
+ path: '/nested-deferred'
+ fullPath: '/nested-deferred'
+ preLoaderRoute: typeof NestedDeferredRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/many-promises': {
+ id: '/many-promises'
+ path: '/many-promises'
+ fullPath: '/many-promises'
+ preLoaderRoute: typeof ManyPromisesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/fast-serial': {
+ id: '/fast-serial'
+ path: '/fast-serial'
+ fullPath: '/fast-serial'
+ preLoaderRoute: typeof FastSerialRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/deferred-rejection': {
+ id: '/deferred-rejection'
+ path: '/deferred-rejection'
+ fullPath: '/deferred-rejection'
+ preLoaderRoute: typeof DeferredRejectionRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/deferred': {
+ id: '/deferred'
+ path: '/deferred'
+ fullPath: '/deferred'
+ preLoaderRoute: typeof DeferredRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/concurrent': {
+ id: '/concurrent'
+ path: '/concurrent'
+ fullPath: '/concurrent'
+ preLoaderRoute: typeof ConcurrentRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ ConcurrentRoute: ConcurrentRoute,
+ DeferredRoute: DeferredRoute,
+ DeferredRejectionRoute: DeferredRejectionRoute,
+ FastSerialRoute: FastSerialRoute,
+ ManyPromisesRoute: ManyPromisesRoute,
+ NestedDeferredRoute: NestedDeferredRoute,
+ QueryHeavyRoute: QueryHeavyRoute,
+ SlowRenderRoute: SlowRenderRoute,
+ StreamRoute: StreamRoute,
+ StreamingPolicyRoute: StreamingPolicyRoute,
+ SyncOnlyRoute: SyncOnlyRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/solid-start'
+declare module '@tanstack/solid-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/solid-start/streaming-ssr/src/router.tsx b/e2e/solid-start/streaming-ssr/src/router.tsx
new file mode 100644
index 0000000000..9e84d8316d
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/router.tsx
@@ -0,0 +1,18 @@
+import { QueryClient } from '@tanstack/solid-query'
+import { createRouter } from '@tanstack/solid-router'
+import { setupRouterSsrQueryIntegration } from '@tanstack/solid-router-ssr-query'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ const queryClient = new QueryClient()
+ const router = createRouter({
+ routeTree,
+ context: { queryClient },
+ scrollRestoration: true,
+ })
+ setupRouterSsrQueryIntegration({
+ router,
+ queryClient,
+ })
+ return router
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/__root.tsx b/e2e/solid-start/streaming-ssr/src/routes/__root.tsx
new file mode 100644
index 0000000000..6ddac9188e
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/__root.tsx
@@ -0,0 +1,99 @@
+import { createSignal } from 'solid-js'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRouteWithContext,
+} from '@tanstack/solid-router'
+import { HydrationScript } from 'solid-js/web'
+import type { QueryClient } from '@tanstack/solid-query'
+
+export const Route = createRootRouteWithContext<{
+ queryClient: QueryClient
+}>()({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'Streaming SSR Tests' },
+ ],
+ }),
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function HydrationCheck() {
+ const [status, setStatus] = createSignal<'pending' | 'hydrated'>('pending')
+
+ return (
+
+
+
+ {status()}
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx
new file mode 100644
index 0000000000..e84d6d4397
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx
@@ -0,0 +1,80 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+function createConcurrentPromises(
+ count: number,
+ delayMs: number,
+): Array> {
+ const sharedPromise = new Promise((resolve) =>
+ setTimeout(resolve, delayMs),
+ )
+ return Array.from({ length: count }, (_, i) =>
+ sharedPromise.then(() => `concurrent-${i + 1}`),
+ )
+}
+
+export const Route = createFileRoute('/concurrent')({
+ loader: async () => {
+ const batch1 = createConcurrentPromises(5, 100)
+ const batch2 = createConcurrentPromises(5, 200)
+ const batch3 = createConcurrentPromises(5, 300)
+
+ return {
+ concurrent1_1: batch1[0],
+ concurrent1_2: batch1[1],
+ concurrent1_3: batch1[2],
+ concurrent1_4: batch1[3],
+ concurrent1_5: batch1[4],
+ concurrent2_1: batch2[0],
+ concurrent2_2: batch2[1],
+ concurrent2_3: batch2[2],
+ concurrent2_4: batch2[3],
+ concurrent2_5: batch2[4],
+ concurrent3_1: batch3[0],
+ concurrent3_2: batch3[1],
+ concurrent3_3: batch3[2],
+ concurrent3_4: batch3[3],
+ concurrent3_5: batch3[4],
+ }
+ },
+ component: Concurrent,
+})
+
+function PromiseItem(props: { promise: Promise; testId: string }) {
+ return (
+ Loading...}
+ >
+ {value}
}
+ />
+
+ )
+}
+
+function Concurrent() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Concurrent Resolution Test (15 promises in 3 batches)
+
Tests multiple promises resolving at the exact same instant.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx b/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx
new file mode 100644
index 0000000000..b837303519
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx
@@ -0,0 +1,38 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+const deferredErrorMessage = 'Error in deferred object'
+
+export const Route = createFileRoute('/deferred-rejection')({
+ loader: async () => {
+ return {
+ deferredData: new Promise((_resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error(deferredErrorMessage))
+ }, 1000)
+ }),
+ }
+ },
+ errorComponent: ({ error }) => (
+ {error.message}
+ ),
+ component: DeferredRejection,
+})
+
+function DeferredRejection() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Deferred Rejection Test
+ Loading deferred...}
+ >
+ {value}
}
+ />
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx b/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx
new file mode 100644
index 0000000000..db01a11ec3
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx
@@ -0,0 +1,93 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+import { Suspense } from 'solid-js'
+
+const getImmediateData = createServerFn({ method: 'GET' })
+ .validator((data: { name: string }) => data)
+ .handler(({ data }) => {
+ return {
+ name: data.name,
+ timestamp: Date.now(),
+ source: 'server' as const,
+ }
+ })
+
+const getSlowData = createServerFn({ method: 'GET' })
+ .validator((data: { name: string; delay: number }) => data)
+ .handler(async ({ data }) => {
+ await new Promise((r) => setTimeout(r, data.delay))
+ return {
+ name: data.name,
+ timestamp: Date.now(),
+ source: 'server' as const,
+ }
+ })
+
+export const Route = createFileRoute('/deferred')({
+ loader: async () => {
+ return {
+ deferredData: new Promise<{ message: string; source: string }>((r) =>
+ setTimeout(
+ () =>
+ r({
+ message: 'Deferred data loaded!',
+ source: typeof window === 'undefined' ? 'server' : 'client',
+ }),
+ 1000,
+ ),
+ ),
+ deferredServerData: getSlowData({
+ data: { name: 'Slow User', delay: 800 },
+ }),
+ immediateData: await getImmediateData({ data: { name: 'Fast User' } }),
+ loaderSource: typeof window === 'undefined' ? 'server' : 'client',
+ }
+ },
+ component: Deferred,
+})
+
+function Deferred() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Deferred Data Test
+
+ Immediate: {data().immediateData.name} @{' '}
+ {data().immediateData.timestamp}
+
+
+ Immediate source: {data().immediateData.source}
+
+
+ Loader source: {data().loaderSource}
+
+
Loading deferred... }
+ >
+ (
+
+ {value.message} (source: {value.source})
+
+ )}
+ />
+
+ Loading server data...
+ }
+ >
+ (
+
+ Server: {value.name} @ {value.timestamp} (source: {value.source})
+
+ )}
+ />
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx b/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx
new file mode 100644
index 0000000000..7f6e072482
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx
@@ -0,0 +1,47 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+
+const getSmallData = createServerFn({ method: 'GET' }).handler(() => {
+ return {
+ value: 'small-data',
+ timestamp: Date.now(),
+ source: 'server' as const,
+ }
+})
+
+export const Route = createFileRoute('/fast-serial')({
+ loader: async () => {
+ const data = await getSmallData()
+ return {
+ serverData: data,
+ staticData: 'This is static data',
+ timestamp: Date.now(),
+ loaderSource: typeof window === 'undefined' ? 'server' : 'client',
+ }
+ },
+ component: FastSerial,
+})
+
+function FastSerial() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Fast Serialization Test
+
This route tests when serialization completes before render.
+
+ Server: {data().serverData.value} @ {data().serverData.timestamp}
+
+
+ Server function source: {data().serverData.source}
+
+
+ Loader source: {data().loaderSource}
+
+
Static: {data().staticData}
+
+ Loader timestamp: {data().timestamp}
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/index.tsx b/e2e/solid-start/streaming-ssr/src/routes/index.tsx
new file mode 100644
index 0000000000..1798b20406
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/index.tsx
@@ -0,0 +1,64 @@
+import { Link, createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+ component: Index,
+})
+
+function Index() {
+ return (
+
+
Streaming SSR Test Scenarios
+
This e2e project tests various SSR streaming scenarios:
+
+ -
+
+ Sync Only
+ {' '}
+ - Tests synchronous serialization with no deferred/streaming data
+
+ -
+
+ Deferred Data
+ {' '}
+ - Tests deferred promises resolving after initial render
+
+ -
+
+ Deferred Rejection
+ {' '}
+ - Tests deferred promise rejections render through the error boundary
+
+ -
+
+ ReadableStream
+ {' '}
+ - Tests streaming data via ReadableStream
+
+ -
+
+ Fast Serialization
+ {' '}
+ - Tests when serialization completes before render finishes
+
+ -
+
+ Slow Render
+ {' '}
+ - Tests when render takes longer than serialization
+
+ -
+
+ Nested Deferred
+ {' '}
+ - Tests nested components with deferred data
+
+ -
+
+ Streaming Policy
+ {' '}
+ - Tests request-controlled SSR streaming policy
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx b/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx
new file mode 100644
index 0000000000..89fa8a3365
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx
@@ -0,0 +1,112 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+function createDelayedPromise(value: T, delayMs: number): Promise {
+ return new Promise((resolve) => setTimeout(() => resolve(value), delayMs))
+}
+
+export const Route = createFileRoute('/many-promises')({
+ loader: async () => {
+ return {
+ immediate1: createDelayedPromise('immediate-1', 0),
+ immediate2: createDelayedPromise('immediate-2', 10),
+ immediate3: createDelayedPromise('immediate-3', 20),
+ fast1: createDelayedPromise('fast-1', 50),
+ fast2: createDelayedPromise('fast-2', 75),
+ fast3: createDelayedPromise('fast-3', 100),
+ fast4: createDelayedPromise('fast-4', 125),
+ medium1: createDelayedPromise('medium-1', 150),
+ medium2: createDelayedPromise('medium-2', 200),
+ medium3: createDelayedPromise('medium-3', 250),
+ slow1: createDelayedPromise('slow-1', 300),
+ slow2: createDelayedPromise('slow-2', 400),
+ slow3: createDelayedPromise('slow-3', 500),
+ verySlow1: createDelayedPromise('very-slow-1', 600),
+ verySlow2: createDelayedPromise('very-slow-2', 800),
+ }
+ },
+ component: ManyPromises,
+})
+
+function PromiseItem(props: {
+ promise: Promise
+ testId: string
+ label: string
+}) {
+ return (
+
+ Loading {props.label}...
+
+ }
+ >
+ (
+
+ {props.label}: {value}
+
+ )}
+ />
+
+ )
+}
+
+function ManyPromises() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Many Promises Test (15 deferred)
+
Tests streaming with many concurrent deferred promises.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx b/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx
new file mode 100644
index 0000000000..6ffb171dbb
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx
@@ -0,0 +1,112 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+import { Suspense } from 'solid-js'
+
+const getLevel1Data = createServerFn({ method: 'GET' }).handler(async () => {
+ await new Promise((r) => setTimeout(r, 200))
+ return { level: 1, timestamp: Date.now() }
+})
+
+const getLevel2Data = createServerFn({ method: 'GET' }).handler(async () => {
+ await new Promise((r) => setTimeout(r, 400))
+ return { level: 2, timestamp: Date.now() }
+})
+
+const getLevel3Data = createServerFn({ method: 'GET' }).handler(async () => {
+ await new Promise((r) => setTimeout(r, 600))
+ return { level: 3, timestamp: Date.now() }
+})
+
+export const Route = createFileRoute('/nested-deferred')({
+ loader: async () => {
+ return {
+ level1: getLevel1Data(),
+ level2: getLevel2Data(),
+ level3: getLevel3Data(),
+ plainDeferred: new Promise((r) =>
+ setTimeout(() => r('Plain deferred resolved!'), 300),
+ ),
+ }
+ },
+ component: NestedDeferred,
+})
+
+function Level2Content(props: {
+ level3: Promise<{ level: number; timestamp: number }>
+}) {
+ return (
+
+ Loading level 3...
}
+ >
+ (
+
+ Level 3: {value.level} @ {value.timestamp}
+
+ )}
+ />
+
+
+ )
+}
+
+function Level1Content(props: {
+ level2: Promise<{ level: number; timestamp: number }>
+ level3: Promise<{ level: number; timestamp: number }>
+}) {
+ return (
+
+ Loading level 2...
}
+ >
+ (
+
+ Level 2: {value.level} @ {value.timestamp}
+
+
+ )}
+ />
+
+
+ )
+}
+
+function NestedDeferred() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Nested Deferred Test
+
+ Tests multiple nested deferred promises resolving at different times.
+
+
Loading plain... }
+ >
+ {value}
}
+ />
+
+
+ Loading level 1...
}
+ >
+ (
+
+ Level 1: {value.level} @ {value.timestamp}
+
+
+ )}
+ />
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx b/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx
new file mode 100644
index 0000000000..0f1a3e5796
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx
@@ -0,0 +1,162 @@
+import { queryOptions, useQuery } from '@tanstack/solid-query'
+import { createFileRoute } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+type QueryType = 'sync' | 'fast-async' | 'slow-async'
+
+type QueryData = {
+ type: QueryType
+ id: number
+ value: string
+ source: string
+}
+
+function makeQueryOptions(
+ type: QueryType,
+ id: number,
+ value: string,
+ delayMs = 0,
+) {
+ return queryOptions({
+ queryKey: ['streaming-ssr-query-heavy', type, id],
+ queryFn: async (): Promise => {
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ }
+
+ return {
+ type,
+ id,
+ value,
+ source: typeof window === 'undefined' ? 'server' : 'client',
+ }
+ },
+ staleTime: Infinity,
+ })
+}
+
+const syncQuery1 = makeQueryOptions('sync', 1, 'sync-value-1')
+const syncQuery2 = makeQueryOptions('sync', 2, 'sync-value-2')
+const syncQuery3 = makeQueryOptions('sync', 3, 'sync-value-3')
+const fastAsyncQuery1 = makeQueryOptions('fast-async', 1, 'fast-async-1', 50)
+const fastAsyncQuery2 = makeQueryOptions('fast-async', 2, 'fast-async-2', 75)
+const fastAsyncQuery3 = makeQueryOptions('fast-async', 3, 'fast-async-3', 100)
+const slowAsyncQuery1 = makeQueryOptions('slow-async', 1, 'slow-async-1', 2000)
+const slowAsyncQuery2 = makeQueryOptions('slow-async', 2, 'slow-async-2', 3000)
+const slowAsyncQuery3 = makeQueryOptions('slow-async', 3, 'slow-async-3', 4000)
+
+type QueryOptions = ReturnType
+
+export const Route = createFileRoute('/query-heavy')({
+ component: QueryHeavy,
+})
+
+function QueryDisplay(props: { queryOpts: QueryOptions; testId: string }) {
+ const query = useQuery(() => props.queryOpts)
+
+ return
+}
+
+function QueryDataView(props: { data: QueryData | undefined; testId: string }) {
+ return (
+
+ {props.data?.value ?? 'loading...'} (source:{' '}
+ {props.data?.source ?? 'loading...'})
+
+ )
+}
+
+function QuerySuspense(props: {
+ queryOpts: QueryOptions
+ testId: string
+ fallback: string
+}) {
+ return (
+ {props.fallback}
+ }
+ >
+
+
+ )
+}
+
+function QueryHeavy() {
+ return (
+
+
Query Heavy Test (9 useSuspenseQuery calls)
+
Tests multiple useSuspenseQuery with mixed sync/async queryFn.
+
+ All queries should show "source: server" if SSR streaming works
+ correctly.
+
+
+
+
+
Sync Queries (immediate)
+
+
+
+
+
+
+
Fast Async Queries (50-100ms)
+
+
+
+
+
+
+
Slow Async Queries (200-400ms)
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx b/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx
new file mode 100644
index 0000000000..46d72c0e38
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx
@@ -0,0 +1,73 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { createServerFn } from '@tanstack/solid-start'
+import { Suspense } from 'solid-js'
+
+const getQuickData = createServerFn({ method: 'GET' }).handler(() => {
+ return {
+ name: 'Quick data',
+ timestamp: Date.now(),
+ source: 'server' as const,
+ }
+})
+
+function SlowComponent(props: { data: string; index: number }) {
+ const startTime = Date.now()
+ while (Date.now() - startTime < 100) {
+ // Simulate slow render work.
+ }
+ return {props.data}
+}
+
+export const Route = createFileRoute('/slow-render')({
+ loader: async () => {
+ const quickData = await getQuickData()
+ return {
+ quickData,
+ deferredData: new Promise<{ message: string; source: string }>((r) =>
+ setTimeout(
+ () =>
+ r({
+ message: 'Deferred resolved!',
+ source: typeof window === 'undefined' ? 'server' : 'client',
+ }),
+ 50,
+ ),
+ ),
+ loaderSource: typeof window === 'undefined' ? 'server' : 'client',
+ }
+ },
+ component: SlowRender,
+})
+
+function SlowRender() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Slow Render Test
+
Tests when render takes longer than serialization.
+
+ Quick: {data().quickData.name} @ {data().quickData.timestamp}
+
+
+ Quick data source: {data().quickData.source}
+
+
+ Loader source: {data().loaderSource}
+
+
Loading... }>
+ (
+
+ {value.message} (source: {value.source})
+
+ )}
+ />
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/stream.tsx b/e2e/solid-start/streaming-ssr/src/routes/stream.tsx
new file mode 100644
index 0000000000..7e058737ca
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/stream.tsx
@@ -0,0 +1,91 @@
+import { Await, createFileRoute } from '@tanstack/solid-router'
+import { createEffect, createSignal, onCleanup, Suspense } from 'solid-js'
+
+export const Route = createFileRoute('/stream')({
+ component: StreamRoute,
+ loader() {
+ return {
+ promise: new Promise((resolve) =>
+ setTimeout(() => resolve('promise-resolved'), 150),
+ ),
+ stream: new ReadableStream({
+ async start(controller) {
+ for (let i = 0; i < 5; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ controller.enqueue(`chunk-${i}`)
+ }
+ controller.close()
+ },
+ }),
+ }
+ },
+})
+
+const decoder = new TextDecoder('utf-8')
+
+function StreamRoute() {
+ const data = Route.useLoaderData()
+ const [streamData, setStreamData] = createSignal>([])
+ const [streamComplete, setStreamComplete] = createSignal(false)
+ let reader: ReadableStreamDefaultReader | undefined
+
+ createEffect(() => {
+ const stream = data().stream
+ if (stream.locked) {
+ return
+ }
+
+ setStreamData([])
+ setStreamComplete(false)
+
+ async function readStream() {
+ try {
+ 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])
+ }
+ setStreamComplete(true)
+ } catch (e) {
+ if (!(e instanceof TypeError && String(e).includes('cancelled'))) {
+ console.error('Stream error:', e)
+ }
+ }
+ }
+
+ void readStream()
+ onCleanup(() => {
+ reader?.cancel().catch(() => {})
+ reader = undefined
+ })
+ })
+
+ return (
+
+
ReadableStream Test
+ Loading promise...}
+ >
+ {value}
}
+ />
+
+
+
Stream chunks:
+
+ {streamData().map((chunk, i) => (
+
{chunk}
+ ))}
+
+ {streamComplete() && (
+
Stream complete!
+ )}
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/streaming-policy.tsx b/e2e/solid-start/streaming-ssr/src/routes/streaming-policy.tsx
new file mode 100644
index 0000000000..d74949c78a
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/streaming-policy.tsx
@@ -0,0 +1,37 @@
+import { Await, createFileRoute, useRouter } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+
+export const Route = createFileRoute('/streaming-policy')({
+ loader: () => {
+ return {
+ immediate: 'Immediate policy content',
+ deferred: new Promise((resolve) => {
+ setTimeout(() => resolve('Deferred policy content'), 100)
+ }),
+ }
+ },
+ component: StreamingPolicyRoute,
+})
+
+function StreamingPolicyRoute() {
+ const router = useRouter()
+ const loaderData = Route.useLoaderData()
+ const serverSsr = router.serverSsr
+ const render = serverSsr?.shouldStream('render')
+ const head = serverSsr?.shouldStream('head')
+
+ return (
+
+
Streaming Policy Test
+
{String(render)}
+
{String(head)}
+
{loaderData().immediate}
+
Loading policy content... }>
+ {value}
}
+ />
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx b/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx
new file mode 100644
index 0000000000..3ef015e599
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx
@@ -0,0 +1,31 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/sync-only')({
+ loader: async () => {
+ return {
+ message: 'Hello from sync loader!',
+ timestamp: Date.now(),
+ items: ['item-1', 'item-2', 'item-3'],
+ source: typeof window === 'undefined' ? 'server' : 'client',
+ }
+ },
+ component: SyncOnly,
+})
+
+function SyncOnly() {
+ const data = Route.useLoaderData()
+
+ return (
+
+
Synchronous Serialization Test
+
{data().message}
+
Loaded at: {data().timestamp}
+
Source: {data().source}
+
+ {data().items.map((item) => (
+ - {item}
+ ))}
+
+
+ )
+}
diff --git a/e2e/solid-start/streaming-ssr/src/server.ts b/e2e/solid-start/streaming-ssr/src/server.ts
new file mode 100644
index 0000000000..b83f784aac
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/src/server.ts
@@ -0,0 +1,67 @@
+import {
+ createStartHandler,
+ defaultStreamHandler,
+} from '@tanstack/solid-start/server'
+import defaultServerEntry, {
+ createServerEntry,
+} from '@tanstack/solid-start/server-entry'
+import type { SsrStreamingResolverResult } from '@tanstack/solid-start/server'
+
+type SsrStreamingOverride = {
+ render?: boolean
+ head?: boolean
+}
+
+function getStreamingPolicy(request: Request): SsrStreamingResolverResult {
+ const url = new URL(request.url)
+
+ switch (url.searchParams.get('streaming')) {
+ case 'all':
+ return {
+ render: true,
+ head: true,
+ }
+ case 'none':
+ return {
+ render: false,
+ }
+ case 'render-only':
+ return {
+ render: true,
+ }
+ case 'head-only':
+ return {
+ render: false,
+ head: true,
+ }
+ default:
+ return undefined
+ }
+}
+
+function getStreamingOverride(
+ request: Request,
+): SsrStreamingOverride | undefined {
+ return getStreamingPolicy(request)
+}
+
+const handler = createStartHandler({
+ handler: defaultStreamHandler,
+ ssr: {
+ streaming: ({ request }) => getStreamingPolicy(request),
+ },
+})
+
+export default createServerEntry({
+ fetch(request) {
+ if (process.env.STREAMING_SSR_ENTRY_FORM === 'default-entry') {
+ return defaultServerEntry.fetch(request, {
+ ssr: {
+ streaming: getStreamingOverride(request),
+ },
+ } as any)
+ }
+
+ return handler(request)
+ },
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts
new file mode 100644
index 0000000000..91138987ad
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts
@@ -0,0 +1,372 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Client-side navigation between all routes', () => {
+ test.beforeEach(async ({ page }) => {
+ // Start from home page
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ })
+
+ test('home -> sync-only -> home works', async ({ page }) => {
+ // Navigate to sync-only
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Sync Only' })
+ .click()
+ await expect(page).toHaveURL('/sync-only')
+ await expect(page.getByTestId('sync-message')).toBeVisible()
+
+ // Navigate back to home
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ await expect(page.getByTestId('index-title')).toBeVisible()
+ })
+
+ test('home -> deferred -> home works', async ({ page }) => {
+ // Navigate to deferred (use exact: true to avoid matching "Nested Deferred")
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Deferred', exact: true })
+ .click()
+ await expect(page).toHaveURL('/deferred')
+ await expect(page.getByTestId('immediate-data')).toBeVisible()
+
+ // Wait for deferred data
+ await expect(page.getByTestId('deferred-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Navigate back to home
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+
+ test('home -> stream -> home works (no stream locking error)', async ({
+ page,
+ }) => {
+ // This test specifically validates the ReadableStream locking fix
+ // Console errors are monitored by the fixture automatically
+
+ // Navigate to stream
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+
+ // Wait for stream to start (at least one chunk or promise resolved)
+ await expect(page.getByTestId('promise-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Navigate back to home before stream completes
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+
+ test('home -> stream -> wait for completion -> home works', async ({
+ page,
+ }) => {
+ // Navigate to stream
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+
+ // Wait for stream to complete
+ await expect(page.getByTestId('stream-complete')).toBeVisible({
+ timeout: 10000,
+ })
+
+ // Navigate back to home
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ await expect(page.getByTestId('index-title')).toBeVisible()
+ })
+
+ test('home -> stream -> home -> stream again works (fresh stream each time)', async ({
+ page,
+ }) => {
+ // Console errors are monitored by the fixture automatically
+
+ // First navigation to stream
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+ await expect(page.getByTestId('promise-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Navigate back to home
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+
+ // Second navigation to stream - should get fresh stream without errors
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+
+ // Wait for stream to complete
+ await expect(page.getByTestId('stream-complete')).toBeVisible({
+ timeout: 10000,
+ })
+
+ // Verify all chunks are present
+ await expect(page.getByTestId('stream-chunk-0')).toBeVisible()
+ await expect(page.getByTestId('stream-chunk-4')).toBeVisible()
+ })
+
+ test('home -> fast-serial -> home works', async ({ page }) => {
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Fast Serial' })
+ .click()
+ await expect(page).toHaveURL('/fast-serial')
+ await expect(page.getByTestId('server-data')).toBeVisible()
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+
+ test('home -> slow-render -> home works', async ({ page }) => {
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Slow Render' })
+ .click()
+ await expect(page).toHaveURL('/slow-render')
+ await expect(page.getByTestId('quick-data')).toBeVisible()
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+
+ test('home -> nested-deferred -> home works', async ({ page }) => {
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Nested Deferred' })
+ .click()
+ await expect(page).toHaveURL('/nested-deferred')
+
+ // Wait for all levels to load
+ await expect(page.getByTestId('level3-data')).toBeVisible({ timeout: 5000 })
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+
+ test('rapid navigation between routes works', async ({ page }) => {
+ // Console errors are monitored by the fixture automatically
+
+ // Rapid navigation sequence
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Sync Only' })
+ .click()
+ await expect(page).toHaveURL('/sync-only')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Deferred', exact: true })
+ .click()
+ await expect(page).toHaveURL('/deferred')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Fast Serial' })
+ .click()
+ await expect(page).toHaveURL('/fast-serial')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Slow Render' })
+ .click()
+ await expect(page).toHaveURL('/slow-render')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Nested Deferred' })
+ .click()
+ await expect(page).toHaveURL('/nested-deferred')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+})
+
+test.describe('Direct navigation followed by client navigation', () => {
+ test('direct to stream -> client nav to deferred works', async ({ page }) => {
+ // Direct navigation to stream
+ await page.goto('/stream')
+ await expect(page.getByTestId('promise-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Client navigation to deferred (use exact: true)
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Deferred', exact: true })
+ .click()
+ await expect(page).toHaveURL('/deferred')
+ await expect(page.getByTestId('immediate-data')).toBeVisible()
+ })
+
+ test('direct to deferred -> client nav to stream works', async ({ page }) => {
+ // Console errors are monitored by the fixture automatically
+
+ // Direct navigation to deferred
+ await page.goto('/deferred')
+ await expect(page.getByTestId('immediate-data')).toBeVisible()
+
+ // Client navigation to stream
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+ await expect(page.getByTestId('stream-complete')).toBeVisible({
+ timeout: 10000,
+ })
+ })
+
+ test('direct to sync-only -> client nav to all routes works', async ({
+ page,
+ }) => {
+ await page.goto('/sync-only')
+ await expect(page.getByTestId('sync-message')).toBeVisible()
+
+ // Navigate through all routes
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Deferred', exact: true })
+ .click()
+ await expect(page).toHaveURL('/deferred')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Stream' })
+ .click()
+ await expect(page).toHaveURL('/stream')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Fast Serial' })
+ .click()
+ await expect(page).toHaveURL('/fast-serial')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Home' })
+ .click()
+ await expect(page).toHaveURL('/')
+ })
+})
+
+testWithHydration.describe('Hydration after client navigation', () => {
+ testWithHydration(
+ 'interactive elements work after navigating to deferred',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Deferred', exact: true })
+ .click()
+ await expect(page).toHaveURL('/deferred')
+
+ // Wait for the page to be fully loaded
+ await expect(page.getByTestId('immediate-data')).toBeVisible()
+ },
+ )
+
+ testWithHydration(
+ 'interactive elements work after navigating to fast-serial',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Fast Serial' })
+ .click()
+ await expect(page).toHaveURL('/fast-serial')
+
+ // Wait for page to load
+ await expect(page.getByTestId('server-data')).toBeVisible()
+ },
+ )
+
+ testWithHydration(
+ 'interactive elements work after navigating to nested-deferred',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Nested Deferred' })
+ .click()
+ await expect(page).toHaveURL('/nested-deferred')
+
+ // Wait for page to load
+ await expect(page.getByTestId('plain-deferred')).toBeVisible({
+ timeout: 5000,
+ })
+ },
+ )
+
+ testWithHydration(
+ 'interactive elements work after navigating to slow-render',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ await page
+ .getByRole('navigation')
+ .getByRole('link', { name: 'Slow Render' })
+ .click()
+ await expect(page).toHaveURL('/slow-render')
+
+ // Wait for page to load (slow-render has blocking loops)
+ await expect(page.getByTestId('quick-data')).toBeVisible()
+ },
+ )
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts
new file mode 100644
index 0000000000..10e32c6809
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts
@@ -0,0 +1,100 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Concurrent promise resolution (15 promises in 3 batches)', () => {
+ test('all concurrent promises resolve correctly', async ({ page }) => {
+ await page.goto('/concurrent')
+
+ // Batch 1 (5 promises at 100ms)
+ await expect(page.getByTestId('concurrent-1-1')).toContainText(
+ 'concurrent-1',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-1-2')).toContainText(
+ 'concurrent-2',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-1-3')).toContainText(
+ 'concurrent-3',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-1-4')).toContainText(
+ 'concurrent-4',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-1-5')).toContainText(
+ 'concurrent-5',
+ { timeout: 5000 },
+ )
+
+ // Batch 2 (5 promises at 200ms)
+ await expect(page.getByTestId('concurrent-2-1')).toContainText(
+ 'concurrent-1',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-2-2')).toContainText(
+ 'concurrent-2',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-2-3')).toContainText(
+ 'concurrent-3',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-2-4')).toContainText(
+ 'concurrent-4',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-2-5')).toContainText(
+ 'concurrent-5',
+ { timeout: 5000 },
+ )
+
+ // Batch 3 (5 promises at 300ms)
+ await expect(page.getByTestId('concurrent-3-1')).toContainText(
+ 'concurrent-1',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-3-2')).toContainText(
+ 'concurrent-2',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-3-3')).toContainText(
+ 'concurrent-3',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-3-4')).toContainText(
+ 'concurrent-4',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('concurrent-3-5')).toContainText(
+ 'concurrent-5',
+ { timeout: 5000 },
+ )
+ })
+
+ test('batch 1 resolves before batch 3', async ({ page }) => {
+ await page.goto('/concurrent', { waitUntil: 'commit' })
+
+ // Batch 1 should be visible before batch 3
+ await expect(page.getByTestId('concurrent-1-1')).toBeVisible({
+ timeout: 3000,
+ })
+
+ // Eventually batch 3 should also be visible
+ await expect(page.getByTestId('concurrent-3-5')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+
+ testWithHydration(
+ 'hydration works with concurrent resolutions',
+ async ({ page }) => {
+ await page.goto('/concurrent')
+ await page.waitForLoadState('networkidle')
+
+ // Wait for all batches
+ await expect(page.getByTestId('concurrent-3-5')).toBeVisible({
+ timeout: 5000,
+ })
+ },
+ )
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts
new file mode 100644
index 0000000000..3b8144072c
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts
@@ -0,0 +1,21 @@
+import { expect, test } from './fixtures'
+
+test.use({
+ whitelistErrors: ['Error in deferred object'],
+})
+
+test('rejected deferred Await renders the route error boundary without killing SSR', async ({
+ page,
+}) => {
+ await page.goto('/deferred-rejection')
+
+ await expect(page.getByTestId('deferred-error-boundary')).toContainText(
+ 'Error in deferred object',
+ { timeout: 5000 },
+ )
+
+ await page.goto('/sync-only')
+ await expect(page.getByTestId('sync-message')).toContainText(
+ 'Hello from sync loader!',
+ )
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts b/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts
new file mode 100644
index 0000000000..da06e31e1c
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts
@@ -0,0 +1,127 @@
+import { expect, test, testWithHydration } from './fixtures'
+import { expectDeferredRouteToStream } from '../../../streaming-ssr-assertions'
+
+test.describe('Deferred data streaming', () => {
+ test('streams the shell before deferred data resolves', async ({
+ baseURL,
+ }) => {
+ await expectDeferredRouteToStream(baseURL, { expectFallback: true })
+ })
+
+ test('shows immediate data right away and deferred data after loading', async ({
+ page,
+ }) => {
+ await page.goto('/deferred')
+
+ // Immediate data should be available right away
+ await expect(page.getByTestId('immediate-data')).toBeVisible()
+ await expect(page.getByTestId('immediate-data')).toContainText(
+ 'Immediate: Fast User',
+ )
+
+ // Verify immediate data came from server
+ await expect(page.getByTestId('immediate-source')).toContainText(
+ 'Immediate source: server',
+ )
+ await expect(page.getByTestId('loader-source')).toContainText(
+ 'Loader source: server',
+ )
+
+ // Deferred data should eventually appear with server source
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'Deferred data loaded!',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('deferred-server-data')).toContainText(
+ 'Server: Slow User',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('deferred-server-data')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ })
+
+ test('shows loading states for deferred content', async ({ page }) => {
+ // Navigate with cache disabled to ensure fresh load
+ await page.goto('/deferred', { waitUntil: 'commit' })
+
+ // Should see loading states initially (may be very brief)
+ // We check that deferred content eventually shows
+ await expect(page.getByTestId('deferred-data')).toBeVisible({
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('deferred-server-data')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+
+ testWithHydration(
+ 'hydration works - interactive elements respond',
+ async ({ page }) => {
+ await page.goto('/deferred')
+ await page.waitForLoadState('networkidle')
+
+ // Wait for all deferred content to load
+ await expect(page.getByTestId('deferred-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Verify all data came from server after hydration
+ await expect(page.getByTestId('loader-source')).toContainText(
+ 'Loader source: server',
+ )
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'source: server',
+ )
+ },
+ )
+
+ test('client-side navigation to deferred route works', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Navigate via client-side routing using nav link
+ await page.getByRole('link', { name: 'Deferred' }).first().click()
+ await expect(page).toHaveURL('/deferred')
+
+ // Data should load
+ await expect(page.getByTestId('immediate-data')).toContainText('Fast User')
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'Deferred data loaded!',
+ { timeout: 5000 },
+ )
+ })
+
+ test('all data sources are server - proves SSR streaming works', async ({
+ page,
+ }) => {
+ await page.goto('/deferred')
+
+ // Wait for all deferred content
+ await expect(page.getByTestId('deferred-data')).toBeVisible({
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('deferred-server-data')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Count all elements showing 'server' source - should be 4:
+ // 1. immediate-source
+ // 2. loader-source
+ // 3. deferred-data (contains "source: server")
+ // 4. deferred-server-data (contains "source: server")
+ await expect(page.getByTestId('immediate-source')).toContainText('server')
+ await expect(page.getByTestId('loader-source')).toContainText('server')
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'source: server',
+ )
+ await expect(page.getByTestId('deferred-server-data')).toContainText(
+ 'source: server',
+ )
+ })
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts
new file mode 100644
index 0000000000..339d6408ea
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Fast serialization (serialization completes before render)', () => {
+ test('initial HTML contains router bootstrap + barrier', async ({ page }) => {
+ let responseHtml = ''
+ await page.route('/fast-serial', async (route) => {
+ const response = await route.fetch()
+ responseHtml = await response.text()
+ await route.fulfill({ response })
+ })
+
+ await page.goto('/fast-serial')
+ await expect(page.getByTestId('server-data')).toBeVisible()
+
+ expect(responseHtml).toContain('$_TSR')
+ expect(responseHtml).toContain('$_TSR.router')
+ expect(responseHtml).toContain('$_TSR.e()')
+ expect(responseHtml).toContain('$tsr-stream-barrier')
+ })
+
+ test('all data is available immediately', async ({ page }) => {
+ await page.goto('/fast-serial')
+ await page.waitForLoadState('networkidle')
+
+ // All data should be visible
+ await expect(page.getByTestId('server-data')).toContainText('small-data')
+ await expect(page.getByTestId('static-data')).toContainText(
+ 'This is static data',
+ )
+ await expect(page.getByTestId('loader-timestamp')).toBeVisible()
+
+ // Verify data came from server (proves SSR streaming worked)
+ await expect(page.getByTestId('loader-source')).toContainText(
+ 'Loader source: server',
+ )
+ await expect(page.getByTestId('server-fn-source')).toContainText(
+ 'Server function source: server',
+ )
+ })
+
+ testWithHydration('hydration works correctly', async ({ page }) => {
+ await page.goto('/fast-serial')
+ await page.waitForLoadState('networkidle')
+
+ // Verify data came from server after hydration
+ await expect(page.getByTestId('loader-source')).toContainText(
+ 'Loader source: server',
+ )
+ })
+
+ test('direct navigation renders correctly', async ({ page }) => {
+ // Direct navigation (SSR)
+ await page.goto('/fast-serial')
+
+ // Should render without errors and show server source
+ await expect(page.getByTestId('server-data')).toBeVisible()
+ await expect(page.getByTestId('loader-source')).toContainText(
+ 'Loader source: server',
+ )
+ })
+
+ test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Navigate via nav link
+ await page.getByRole('link', { name: 'Fast Serial' }).first().click()
+ await expect(page).toHaveURL('/fast-serial')
+
+ await expect(page.getByTestId('server-data')).toContainText('small-data')
+ })
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/fixtures.ts b/e2e/solid-start/streaming-ssr/tests/fixtures.ts
new file mode 100644
index 0000000000..88bcfcc08c
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/fixtures.ts
@@ -0,0 +1,90 @@
+import { test as base, expect, type Page } from '@playwright/test'
+
+/**
+ * Verifies that framework hydration has completed by clicking the global
+ * hydration check button and verifying the status changes.
+ *
+ * This is the canonical way to verify hydration in streaming-ssr tests.
+ * The HydrationCheck component is rendered in the root layout (__root.tsx).
+ *
+ * The function retries clicking until the status changes to 'hydrated',
+ * which handles the case where the button is visible from SSR before
+ * the framework has finished hydrating.
+ */
+async function verifyHydration(
+ page: Page,
+ options: { timeout?: number } = {},
+): Promise {
+ const timeout = options.timeout ?? 10000
+ const button = page.getByTestId('hydration-check-btn')
+ const status = page.getByTestId('hydration-status')
+
+ // Ensure the button is visible
+ await expect(button).toBeVisible()
+
+ // Retry clicking until hydration succeeds
+ // This handles the case where SSR renders the button before the framework hydrates
+ await expect(async () => {
+ // Click the button to trigger hydration verification
+ await button.click()
+
+ // Check if the status changed to 'hydrated'
+ await expect(status).toHaveText('hydrated', { timeout: 100 })
+ }).toPass({ timeout })
+}
+
+export interface StreamingSsrOptions {
+ /**
+ * List of error message patterns to ignore in console output.
+ */
+ whitelistErrors: Array
+}
+
+/**
+ * Base test fixture for streaming-ssr e2e tests.
+ * Provides console error monitoring.
+ */
+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)
+
+ // Assert no unexpected console errors
+ expect(errorMessages).toEqual([])
+ },
+})
+
+/**
+ * Extended test fixture that automatically verifies hydration at the end.
+ * Use this for tests where you want to confirm framework hydration succeeded.
+ */
+export const testWithHydration = test.extend({
+ page: async ({ page }, use) => {
+ await use(page)
+
+ // Automatically verify hydration at the end of the test
+ await verifyHydration(page)
+ },
+})
+
+// Re-export expect for convenience
+export { expect }
diff --git a/e2e/solid-start/streaming-ssr/tests/home.spec.ts b/e2e/solid-start/streaming-ssr/tests/home.spec.ts
new file mode 100644
index 0000000000..92e75e137e
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/home.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Home page', () => {
+ testWithHydration(
+ 'renders index page with all navigation links',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ await expect(page.getByTestId('index-title')).toContainText(
+ 'Streaming SSR Test Scenarios',
+ )
+ // Check links exist (they're in the nav and the body)
+ await expect(
+ page.getByRole('link', { name: 'Deferred' }).first(),
+ ).toBeVisible()
+ await expect(
+ page.getByRole('link', { name: 'Stream' }).first(),
+ ).toBeVisible()
+ await expect(page.getByTestId('link-streaming-policy')).toBeVisible()
+ },
+ )
+
+ testWithHydration(
+ 'navigation from home to routes works',
+ async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Use the link in the content (not nav)
+ await page.getByTestId('link-deferred').click()
+ await expect(page).toHaveURL('/deferred')
+ },
+ )
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts
new file mode 100644
index 0000000000..034ab74220
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts
@@ -0,0 +1,93 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Many promises streaming (15 deferred)', () => {
+ test('all 15 promises eventually resolve', async ({ page }) => {
+ await page.goto('/many-promises')
+
+ // Immediate group (0-20ms)
+ await expect(page.getByTestId('immediate-1')).toContainText('immediate-1', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('immediate-2')).toContainText('immediate-2', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('immediate-3')).toContainText('immediate-3', {
+ timeout: 5000,
+ })
+
+ // Fast group (50-125ms)
+ await expect(page.getByTestId('fast-1')).toContainText('fast-1', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('fast-2')).toContainText('fast-2', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('fast-3')).toContainText('fast-3', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('fast-4')).toContainText('fast-4', {
+ timeout: 5000,
+ })
+
+ // Medium group (150-250ms)
+ await expect(page.getByTestId('medium-1')).toContainText('medium-1', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('medium-2')).toContainText('medium-2', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('medium-3')).toContainText('medium-3', {
+ timeout: 5000,
+ })
+
+ // Slow group (300-500ms)
+ await expect(page.getByTestId('slow-1')).toContainText('slow-1', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('slow-2')).toContainText('slow-2', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('slow-3')).toContainText('slow-3', {
+ timeout: 5000,
+ })
+
+ // Very slow group (600-800ms)
+ await expect(page.getByTestId('very-slow-1')).toContainText('very-slow-1', {
+ timeout: 5000,
+ })
+ await expect(page.getByTestId('very-slow-2')).toContainText('very-slow-2', {
+ timeout: 5000,
+ })
+ })
+
+ testWithHydration('hydration works with many promises', async ({ page }) => {
+ await page.goto('/many-promises')
+ await page.waitForLoadState('networkidle')
+
+ // Wait for all promises to resolve
+ await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Navigate via nav link
+ await page.getByRole('link', { name: 'Many Promises' }).click()
+ await expect(page).toHaveURL('/many-promises')
+
+ // All promises should eventually resolve
+ await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('fast promises resolve before slow ones', async ({ page }) => {
+ // Navigate and check ordering - faster promises should be visible first
+ await page.goto('/many-promises', { waitUntil: 'commit' })
+
+ // Immediate promises should appear first
+ await expect(page.getByTestId('immediate-1')).toBeVisible({ timeout: 2000 })
+
+ // By the time we check very-slow, all should be visible
+ await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
+ })
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts
new file mode 100644
index 0000000000..dbf6df4265
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts
@@ -0,0 +1,77 @@
+import { expect, test, testWithHydration } from './fixtures'
+
+test.describe('Nested deferred (multiple levels of deferred data)', () => {
+ test('all levels of deferred data eventually resolve', async ({ page }) => {
+ await page.goto('/nested-deferred')
+
+ // Plain deferred should resolve first (300ms)
+ await expect(page.getByTestId('plain-deferred')).toContainText(
+ 'Plain deferred resolved!',
+ { timeout: 5000 },
+ )
+
+ // Level 1 should resolve (200ms)
+ await expect(page.getByTestId('level1-data')).toContainText('Level 1:', {
+ timeout: 5000,
+ })
+
+ // Level 2 should resolve (400ms)
+ await expect(page.getByTestId('level2-data')).toContainText('Level 2:', {
+ timeout: 5000,
+ })
+
+ // Level 3 should resolve (600ms)
+ await expect(page.getByTestId('level3-data')).toContainText('Level 3:', {
+ timeout: 5000,
+ })
+ })
+
+ test('shows loading states while data is loading', async ({ page }) => {
+ // Use fast navigation to catch loading states
+ await page.goto('/nested-deferred', { waitUntil: 'commit' })
+
+ // Eventually all data should be visible
+ await expect(page.getByTestId('level3-data')).toBeVisible({
+ timeout: 10000,
+ })
+ })
+
+ testWithHydration(
+ 'hydration works with nested deferred',
+ async ({ page }) => {
+ await page.goto('/nested-deferred')
+ await page.waitForLoadState('networkidle')
+
+ // Wait for all data
+ await expect(page.getByTestId('level3-data')).toBeVisible({
+ timeout: 10000,
+ })
+ },
+ )
+
+ test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Navigate via nav link
+ await page.getByRole('link', { name: 'Nested Deferred' }).first().click()
+ await expect(page).toHaveURL('/nested-deferred')
+
+ // All levels should eventually render
+ await expect(page.getByTestId('level3-data')).toBeVisible({
+ timeout: 10000,
+ })
+ })
+
+ test('data resolves in expected order (fastest first)', async ({ page }) => {
+ await page.goto('/nested-deferred')
+
+ // Wait for all to be visible
+ await expect(page.getByTestId('level1-data')).toBeVisible({ timeout: 5000 })
+ await expect(page.getByTestId('level2-data')).toBeVisible({ timeout: 5000 })
+ await expect(page.getByTestId('level3-data')).toBeVisible({ timeout: 5000 })
+ await expect(page.getByTestId('plain-deferred')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts
new file mode 100644
index 0000000000..041e18e00f
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from './fixtures'
+
+test.skip(process.env.MODE !== 'preview', 'Only runs against vite preview')
+
+test('vite preview streams HTML incrementally', async ({ page }) => {
+ // /deferred has immediate data + deferred data with a ~1s delay.
+ // Without streaming, compression buffers the entire response.
+ await page.goto('/deferred', { waitUntil: 'commit' })
+
+ // Immediate data should arrive in the first chunk
+ await expect(page.getByTestId('immediate-data')).toBeVisible({
+ timeout: 3000,
+ })
+
+ // Deferred data (1s delay) should still be loading at this point
+ await expect(page.getByTestId('deferred-loading')).toBeVisible()
+
+ // Wait for it to resolve
+ await expect(page.getByTestId('deferred-data')).toContainText(
+ 'Deferred data loaded!',
+ { timeout: 5000 },
+ )
+})
diff --git a/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts
new file mode 100644
index 0000000000..942f297c60
--- /dev/null
+++ b/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts
@@ -0,0 +1,157 @@
+import { expect, test, testWithHydration } from './fixtures'
+import { expectQueryHeavyRouteToStream } from '../../../streaming-ssr-assertions'
+
+test.describe('Query heavy route (9 useSuspenseQuery)', () => {
+ test('streams fast query reveals before slow queries resolve', async ({
+ baseURL,
+ }) => {
+ await expectQueryHeavyRouteToStream(baseURL)
+ })
+
+ test('all queries resolve with server data', async ({ page }) => {
+ await page.goto('/query-heavy')
+
+ // Sync queries should show server source
+ await expect(page.getByTestId('sync-query-1')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('sync-query-2')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('sync-query-3')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+
+ // Fast async queries should show server source
+ await expect(page.getByTestId('fast-async-query-1')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('fast-async-query-2')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('fast-async-query-3')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+
+ // Slow async queries should show server source
+ await expect(page.getByTestId('slow-async-query-1')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('slow-async-query-2')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('slow-async-query-3')).toContainText(
+ 'source: server',
+ { timeout: 5000 },
+ )
+ })
+
+ test('sync queries have correct values', async ({ page }) => {
+ await page.goto('/query-heavy')
+
+ await expect(page.getByTestId('sync-query-1')).toContainText('sync-value-1')
+ await expect(page.getByTestId('sync-query-2')).toContainText('sync-value-2')
+ await expect(page.getByTestId('sync-query-3')).toContainText('sync-value-3')
+ })
+
+ test('async queries have correct values', async ({ page }) => {
+ await page.goto('/query-heavy')
+
+ await expect(page.getByTestId('fast-async-query-1')).toContainText(
+ 'fast-async-1',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('fast-async-query-2')).toContainText(
+ 'fast-async-2',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('fast-async-query-3')).toContainText(
+ 'fast-async-3',
+ { timeout: 5000 },
+ )
+
+ await expect(page.getByTestId('slow-async-query-1')).toContainText(
+ 'slow-async-1',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('slow-async-query-2')).toContainText(
+ 'slow-async-2',
+ { timeout: 5000 },
+ )
+ await expect(page.getByTestId('slow-async-query-3')).toContainText(
+ 'slow-async-3',
+ { timeout: 5000 },
+ )
+ })
+
+ testWithHydration('hydration works with many queries', async ({ page }) => {
+ await page.goto('/query-heavy')
+ await page.waitForLoadState('networkidle')
+
+ // Wait for all queries to resolve
+ await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+
+ test('client-side navigation works', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+
+ // Navigate via nav link
+ await page.getByRole('link', { name: 'Query Heavy' }).click()
+ await expect(page).toHaveURL('/query-heavy')
+
+ // All queries should eventually resolve (on client)
+ await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
+ timeout: 5000,
+ })
+ })
+
+ test('no hydration mismatch - queries streamed from server', async ({
+ page,
+ }) => {
+ // This test verifies that query data is streamed from server
+ // If it wasn't, the queries would re-execute on client and show 'client' as source
+ await page.goto('/query-heavy')
+
+ // Wait for all queries
+ await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
+ timeout: 5000,
+ })
+
+ // Verify all show 'server' - this proves data was streamed, not re-fetched
+ const serverSourceCount = await page
+ .locator('[data-testid*="query-"]')
+ .filter({ hasText: 'source: server' })
+ .count()
+ expect(serverSourceCount).toBe(9)
+ })
+
+ test('emits query stream data before stream end marker', async ({
+ request,
+ }) => {
+ const response = await request.get('/query-heavy')
+ const html = await response.text()
+ const endMarker = '$_TSR.e()'
+ const endIndex = html.indexOf(endMarker)
+ const slowAsyncPayloadIndex = html.indexOf('"slow-async-3"')
+ const lastScriptOpen = html.lastIndexOf('', endIndex)
+
+ expect(endIndex).toBeGreaterThan(-1)
+ expect(lastScriptOpen).toBeGreaterThan(lastScriptClose)
+ expect(slowAsyncPayloadIndex).toBeGreaterThan(-1)
+ expect(slowAsyncPayloadIndex).toBeLessThan(endIndex)
+ expect(html.slice(lastScriptOpen, endIndex)).toContain('.return(void 0)')
+ expect(endIndex).toBeLessThan(html.indexOf('