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('')) + }) +}) diff --git a/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts new file mode 100644 index 0000000000..c3631a7fc0 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts @@ -0,0 +1,73 @@ +import { expect, test, testWithHydration } from './fixtures' + +test.describe('Slow render (render takes longer than serialization)', () => { + test('all data eventually renders with server source', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Quick data should be available with server source + await expect(page.getByTestId('quick-data')).toContainText('Quick:') + await expect(page.getByTestId('quick-source')).toContainText( + 'Quick data source: server', + ) + await expect(page.getByTestId('loader-source')).toContainText( + 'Loader source: server', + ) + + // Deferred data should resolve with server source + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'Deferred resolved!', + { timeout: 5000 }, + ) + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + { timeout: 5000 }, + ) + + // Slow components should have rendered + await expect(page.getByTestId('slow-component-1')).toBeVisible() + await expect(page.getByTestId('slow-component-2')).toBeVisible() + await expect(page.getByTestId('slow-component-3')).toBeVisible() + }) + + testWithHydration('hydration works after slow render', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for content and verify server source + await expect(page.getByTestId('slow-component-1')).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: 'Slow Render' }).first().click() + await expect(page).toHaveURL('/slow-render') + + await expect(page.getByTestId('quick-data')).toBeVisible({ timeout: 10000 }) + }) + + test('all data sources are server - proves SSR streaming works', async ({ + page, + }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for deferred content + await expect(page.getByTestId('deferred-resolved')).toBeVisible({ + timeout: 5000, + }) + + // Verify all sources are server + await expect(page.getByTestId('quick-source')).toContainText('server') + await expect(page.getByTestId('loader-source')).toContainText('server') + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + ) + }) +}) diff --git a/e2e/solid-start/streaming-ssr/tests/stream.spec.ts b/e2e/solid-start/streaming-ssr/tests/stream.spec.ts new file mode 100644 index 0000000000..5e4c134f82 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1,46 @@ +import { expect, test, testWithHydration } from './fixtures' + +test.describe('ReadableStream streaming', () => { + testWithHydration('promise data resolves correctly', async ({ page }) => { + await page.goto('/stream') + + // Promise should resolve + await expect(page.getByTestId('promise-data')).toContainText( + 'promise-resolved', + { timeout: 5000 }, + ) + }) + + testWithHydration('stream chunks arrive incrementally', async ({ page }) => { + await page.goto('/stream') + + // Wait for stream to complete + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + + // All chunks should be present + await expect(page.getByTestId('stream-chunk-0')).toContainText('chunk-0') + await expect(page.getByTestId('stream-chunk-1')).toContainText('chunk-1') + await expect(page.getByTestId('stream-chunk-2')).toContainText('chunk-2') + await expect(page.getByTestId('stream-chunk-3')).toContainText('chunk-3') + await expect(page.getByTestId('stream-chunk-4')).toContainText('chunk-4') + }) + + testWithHydration( + 'client-side navigation to stream route works', + async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate via nav link + await page.getByRole('link', { name: 'Stream' }).first().click() + await expect(page).toHaveURL('/stream') + + // Wait for stream to complete + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + }, + ) +}) diff --git a/e2e/solid-start/streaming-ssr/tests/streaming-policy.spec.ts b/e2e/solid-start/streaming-ssr/tests/streaming-policy.spec.ts new file mode 100644 index 0000000000..a943e7a53f --- /dev/null +++ b/e2e/solid-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/solid-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts new file mode 100644 index 0000000000..a0060a07ee --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test' +import { test, testWithHydration } from './fixtures' + +/** + * Tests for synchronous serialization - no deferred data, no streaming. + * This is the most common case where all loader data is immediately available. + * The hydration scripts should be included in the initial HTML response. + */ + +test('Sync-only route renders with loader data', async ({ page }) => { + await page.goto('/sync-only') + + // Verify the page content is rendered + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) + + // Verify loader data items are rendered + await expect(page.getByTestId('sync-item-item-1')).toBeVisible() + await expect(page.getByTestId('sync-item-item-2')).toBeVisible() + await expect(page.getByTestId('sync-item-item-3')).toBeVisible() + + // Verify data came from server (proves SSR streaming worked) + await expect(page.getByTestId('sync-source')).toContainText('Source: server') +}) + +testWithHydration('Sync-only route hydrates correctly', async ({ page }) => { + await page.goto('/sync-only') + + // Verify client-side navigation works (proves hydration succeeded) + await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click() + await expect(page.getByTestId('index-title')).toBeVisible() + + // Navigate back to sync-only via client-side navigation (use nav link to be specific) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Sync Only' }) + .click() + await expect(page.getByTestId('sync-title')).toBeVisible() +}) + +test('Sync-only route has bootstrap scripts in initial HTML', async ({ + page, +}) => { + // Intercept the response to check the raw HTML + let responseHtml = '' + await page.route('/sync-only', async (route) => { + const response = await route.fetch() + responseHtml = await response.text() + await route.fulfill({ response }) + }) + + await page.goto('/sync-only') + + // Wait for page to load + await expect(page.getByTestId('sync-title')).toBeVisible() + + // The HTML should contain the bootstrap scripts + // $_TSR.router should be present (the dehydrated router state) + expect(responseHtml).toContain('$_TSR') + expect(responseHtml).toContain('$_TSR.router') + // The serialization end marker should be present + expect(responseHtml).toContain('$_TSR.e()') + + // SSR should include the barrier script tag in the HTML (rendered by ) + // This is the critical marker transformStreamWithRouter can scan for. + expect(responseHtml).toContain('$tsr-stream-barrier') +}) + +test('Navigating to sync-only from home page', async ({ page }) => { + await page.goto('/') + + await page.getByTestId('link-sync-only').click() + + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) +}) diff --git a/e2e/solid-start/streaming-ssr/tsconfig.json b/e2e/solid-start/streaming-ssr/tsconfig.json new file mode 100644 index 0000000000..ff0556e7a8 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/solid-start/streaming-ssr/vite.config.ts b/e2e/solid-start/streaming-ssr/vite.config.ts new file mode 100644 index 0000000000..a9913da087 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart(), viteSolid({ ssr: true })], +}) diff --git a/e2e/streaming-ssr-assertions.ts b/e2e/streaming-ssr-assertions.ts new file mode 100644 index 0000000000..b684c6b914 --- /dev/null +++ b/e2e/streaming-ssr-assertions.ts @@ -0,0 +1,173 @@ +import { expect } from '@playwright/test' + +type DeferredRouteMarker = 'immediate' | 'fallback' | 'deferred' | 'documentEnd' +type QueryHeavyRouteMarker = 'shell' | 'fastQuery' | 'slowQuery' | 'documentEnd' + +type RequiredDeferredRouteMarker = Exclude + +type MarkerTimings = Partial> & + Record +type QueryHeavyMarkerTimings = Record + +const deferredRouteMarkers = { + immediate: 'data-testid="immediate-data"', + fallback: 'data-testid="deferred-loading"', + deferred: 'Deferred data loaded!', + documentEnd: '', +} satisfies Record + +const queryHeavyRouteMarkers = { + shell: 'Query Heavy Test', + fastQuery: 'data-testid="fast-async-query-1"', + slowQuery: 'data-testid="slow-async-query-3"', + documentEnd: '', +} satisfies Record + +export async function expectDeferredRouteToStream( + baseURL: string | undefined, + options: { expectFallback?: boolean } = {}, +) { + if (!baseURL) { + throw new Error('Playwright baseURL is required') + } + + const startedAt = performance.now() + const response = await fetch( + new URL('/deferred?streaming=render-only', baseURL), + { + headers: { + accept: 'text/html', + 'accept-encoding': 'identity', + 'user-agent': 'Mozilla/5.0 streaming-ssr-test', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body).not.toBeNull() + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + const markerTimings: Partial> = {} + const pendingMarkers = new Set( + (Object.keys(deferredRouteMarkers) as Array).filter( + (markerName) => options.expectFallback || markerName !== 'fallback', + ), + ) + let chunkCount = 0 + let html = '' + + for (;;) { + const { done, value } = await reader.read() + + if (done) { + break + } + + chunkCount += 1 + html += decoder.decode(value, { stream: true }) + + const elapsed = performance.now() - startedAt + + for (const markerName of pendingMarkers) { + if (html.includes(deferredRouteMarkers[markerName])) { + markerTimings[markerName] = elapsed + pendingMarkers.delete(markerName) + } + } + } + + html += decoder.decode() + + const responsePreview = + html.length > 2000 + ? `${html.slice(0, 1000)}\n...\n${html.slice(-1000)}` + : html + + expect( + Array.from(pendingMarkers), + `Missing streaming markers in response:\n${responsePreview}`, + ).toEqual([]) + + const timings = markerTimings as MarkerTimings + + expect(chunkCount).toBeGreaterThan(1) + expect(timings.deferred - timings.immediate).toBeGreaterThan(500) + expect(timings.documentEnd - timings.immediate).toBeGreaterThan(500) + + if (options.expectFallback) { + expect(timings.fallback).toBeDefined() + expect(timings.deferred - timings.fallback!).toBeGreaterThan(500) + } +} + +export async function expectQueryHeavyRouteToStream( + baseURL: string | undefined, +) { + if (!baseURL) { + throw new Error('Playwright baseURL is required') + } + + const startedAt = performance.now() + const response = await fetch( + new URL('/query-heavy?streaming=render-only', baseURL), + { + headers: { + accept: 'text/html', + 'accept-encoding': 'identity', + 'user-agent': 'Mozilla/5.0 streaming-ssr-test', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body).not.toBeNull() + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + const markerTimings: Partial> = {} + const pendingMarkers = new Set( + Object.keys(queryHeavyRouteMarkers) as Array, + ) + let chunkCount = 0 + let html = '' + + for (;;) { + const { done, value } = await reader.read() + + if (done) { + break + } + + chunkCount += 1 + html += decoder.decode(value, { stream: true }) + + const elapsed = performance.now() - startedAt + + for (const markerName of pendingMarkers) { + if (html.includes(queryHeavyRouteMarkers[markerName])) { + markerTimings[markerName] = elapsed + pendingMarkers.delete(markerName) + } + } + } + + html += decoder.decode() + + const responsePreview = + html.length > 2000 + ? `${html.slice(0, 1000)}\n...\n${html.slice(-1000)}` + : html + + expect( + Array.from(pendingMarkers), + `Missing query-heavy streaming markers in response:\n${responsePreview}`, + ).toEqual([]) + + const timings = markerTimings as QueryHeavyMarkerTimings + + expect(chunkCount).toBeGreaterThan(1) + expect(timings.fastQuery - timings.shell).toBeGreaterThan(0) + expect(timings.slowQuery - timings.fastQuery).toBeGreaterThan(100) + expect(timings.documentEnd - timings.fastQuery).toBeGreaterThan(100) +} diff --git a/e2e/vue-start/basic/playwright.config.ts b/e2e/vue-start/basic/playwright.config.ts index 6924134cd1..f1cfe6dbed 100644 --- a/e2e/vue-start/basic/playwright.config.ts +++ b/e2e/vue-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/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index 016a292835..036bb796fb 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-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' @@ -72,6 +73,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', @@ -378,6 +384,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 @@ -431,6 +438,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 @@ -488,6 +496,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 @@ -548,6 +557,7 @@ export interface FileRouteTypes { | '/raw-stream' | '/scripts' | '/stream' + | '/streaming-policy' | '/users' | '/not-found/parent-boundary' | '/specialChars/malformed' @@ -601,6 +611,7 @@ export interface FileRouteTypes { | '/links' | '/scripts' | '/stream' + | '/streaming-policy' | '/not-found/parent-boundary' | '/specialChars/malformed' | '/api/users' @@ -657,6 +668,7 @@ export interface FileRouteTypes { | '/raw-stream' | '/scripts' | '/stream' + | '/streaming-policy' | '/users' | '/not-found/parent-boundary' | '/specialChars/malformed' @@ -717,6 +729,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 @@ -735,6 +748,13 @@ declare module '@tanstack/vue-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' @@ -1341,6 +1361,7 @@ const rootRouteChildren: RootRouteChildren = { RawStreamRoute: RawStreamRouteWithChildren, ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, + StreamingPolicyRoute: StreamingPolicyRoute, UsersRoute: UsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute, diff --git a/e2e/vue-start/basic/src/routes/streaming-policy.tsx b/e2e/vue-start/basic/src/routes/streaming-policy.tsx new file mode 100644 index 0000000000..ec36b052dd --- /dev/null +++ b/e2e/vue-start/basic/src/routes/streaming-policy.tsx @@ -0,0 +1,46 @@ +import { Await, createFileRoute, useRouter } from '@tanstack/vue-router' +import { Suspense, defineComponent } from 'vue' + +const StreamingPolicyRoute = defineComponent({ + setup() { + 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.value.immediate}
    + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () =>
    Loading policy content...
    , + }} +
    +
    + ) + }, +}) + +export const Route = createFileRoute('/streaming-policy')({ + loader: () => { + return { + immediate: 'Immediate policy content', + deferred: new Promise((resolve) => { + setTimeout(() => resolve('Deferred policy content'), 100) + }), + } + }, + component: StreamingPolicyRoute, +}) diff --git a/e2e/vue-start/basic/src/server.ts b/e2e/vue-start/basic/src/server.ts index 8195b0ae95..14675fa47e 100644 --- a/e2e/vue-start/basic/src/server.ts +++ b/e2e/vue-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/vue-start/server-entry' +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/vue-start/server' +import defaultServerEntry, { + createServerEntry, +} from '@tanstack/vue-start/server-entry' +import type { SsrStreamingResolverResult } from '@tanstack/vue-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/vue-start/basic/tests/streaming-policy.spec.ts b/e2e/vue-start/basic/tests/streaming-policy.spec.ts new file mode 100644 index 0000000000..c5fd68bc6b --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/package.json b/e2e/vue-start/streaming-ssr/package.json new file mode 100644 index 0000000000..f2273b3030 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-vue-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/vue-query": "^5.90.0", + "@tanstack/vue-router": "workspace:^", + "@tanstack/vue-router-ssr-query": "workspace:^", + "@tanstack/vue-start": "workspace:^", + "vue": "^3.5.16" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.14" + } +} diff --git a/e2e/vue-start/streaming-ssr/playwright.config.ts b/e2e/vue-start/streaming-ssr/playwright.config.ts new file mode 100644 index 0000000000..066d584e60 --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/src/routeTree.gen.ts b/e2e/vue-start/streaming-ssr/src/routeTree.gen.ts new file mode 100644 index 0000000000..2826ec4511 --- /dev/null +++ b/e2e/vue-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/vue-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/vue-start' +declare module '@tanstack/vue-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/vue-start/streaming-ssr/src/router.tsx b/e2e/vue-start/streaming-ssr/src/router.tsx new file mode 100644 index 0000000000..257eb8dac9 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/router.tsx @@ -0,0 +1,18 @@ +import { QueryClient } from '@tanstack/vue-query' +import { createRouter } from '@tanstack/vue-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/vue-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/vue-start/streaming-ssr/src/routes/__root.tsx b/e2e/vue-start/streaming-ssr/src/routes/__root.tsx new file mode 100644 index 0000000000..d3b3339720 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/__root.tsx @@ -0,0 +1,103 @@ +import { + Body, + HeadContent, + Html, + Link, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/vue-router' +import { defineComponent, ref } from 'vue' +import type { QueryClient } from '@tanstack/vue-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 ( + + + + + + + + + + + + ) +} + +const HydrationCheck = defineComponent({ + setup() { + const status = ref<'pending' | 'hydrated'>('pending') + + return () => ( +
    + + + {status.value} + +
    + ) + }, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx new file mode 100644 index 0000000000..87ed245768 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx @@ -0,0 +1,87 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +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 ( + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () => ( +
    Loading...
    + ), + }} +
    + ) +} + +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/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx b/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx new file mode 100644 index 0000000000..08aea3bcea --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx @@ -0,0 +1,45 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +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

    + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () => ( +
    Loading deferred...
    + ), + }} +
    +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx b/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx new file mode 100644 index 0000000000..892f9928c9 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx @@ -0,0 +1,106 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +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.value.immediateData.name} @{' '} + {data.value.immediateData.timestamp} +
    +
    + Immediate source: {data.value.immediateData.source} +
    +
    + Loader source: {data.value.loaderSource} +
    + + {{ + default: () => ( + ( +
    + {value.message} (source: {value.source}) +
    + )} + /> + ), + fallback: () => ( +
    Loading deferred...
    + ), + }} +
    + + {{ + default: () => ( + ( +
    + Server: {value.name} @ {value.timestamp} (source:{' '} + {value.source}) +
    + )} + /> + ), + fallback: () => ( +
    Loading server data...
    + ), + }} +
    +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx b/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx new file mode 100644 index 0000000000..ea976c7551 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-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.value.serverData.value} @{' '} + {data.value.serverData.timestamp} +
    +
    + Server function source: {data.value.serverData.source} +
    +
    + Loader source: {data.value.loaderSource} +
    +
    Static: {data.value.staticData}
    +
    + Loader timestamp: {data.value.timestamp} +
    +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/index.tsx b/e2e/vue-start/streaming-ssr/src/routes/index.tsx new file mode 100644 index 0000000000..2a347755b1 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/index.tsx @@ -0,0 +1,64 @@ +import { Link, createFileRoute } from '@tanstack/vue-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/vue-start/streaming-ssr/src/routes/many-promises.tsx b/e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx new file mode 100644 index 0000000000..4b666885f9 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx @@ -0,0 +1,115 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +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 ( + + {{ + default: () => ( + ( +
    + {props.label}: {value} +
    + )} + /> + ), + fallback: () => ( +
    + Loading {props.label}... +
    + ), + }} +
    + ) +} + +function ManyPromises() { + const data = Route.useLoaderData() + + return ( +
    +

    Many Promises Test (15 deferred)

    +

    Tests streaming with many concurrent deferred promises.

    + + + + + + + + + + + + + + + +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx b/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx new file mode 100644 index 0000000000..c79af191f1 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx @@ -0,0 +1,137 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +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 ( +
    + + {{ + default: () => ( + ( +
    + Level 3: {value.level} @ {value.timestamp} +
    + )} + /> + ), + fallback: () => ( +
    Loading level 3...
    + ), + }} +
    +
    + ) +} + +function Level1Content(props: { + level2: Promise<{ level: number; timestamp: number }> + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
    + + {{ + default: () => ( + ( +
    + Level 2: {value.level} @ {value.timestamp} + +
    + )} + /> + ), + fallback: () => ( +
    Loading level 2...
    + ), + }} +
    +
    + ) +} + +function NestedDeferred() { + const data = Route.useLoaderData() + + return ( +
    +

    Nested Deferred Test

    +

    + Tests multiple nested deferred promises resolving at different times. +

    + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () => ( +
    Loading plain...
    + ), + }} +
    +
    + + {{ + default: () => ( + ( +
    + Level 1: {value.level} @ {value.timestamp} + +
    + )} + /> + ), + fallback: () => ( +
    Loading level 1...
    + ), + }} +
    +
    +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx b/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx new file mode 100644 index 0000000000..fb9ee3980a --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx @@ -0,0 +1,180 @@ +import { queryOptions, useQuery } from '@tanstack/vue-query' +import { createFileRoute } from '@tanstack/vue-router' +import { Suspense, defineComponent } from 'vue' +import type { PropType } from 'vue' + +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', 200) +const slowAsyncQuery2 = makeQueryOptions('slow-async', 2, 'slow-async-2', 300) +const slowAsyncQuery3 = makeQueryOptions('slow-async', 3, 'slow-async-3', 400) + +type QueryOptions = ReturnType + +const QueryDisplay = defineComponent({ + props: { + queryOpts: { + type: Object as PropType, + required: true, + }, + testId: { + type: String, + required: true, + }, + }, + async setup(props) { + const query = useQuery(props.queryOpts) + await query.suspense() + + return () => { + const data = query.data.value + + return ( +
    + {data?.value} (source: {data?.source}) +
    + ) + } + }, +}) + +function QuerySuspense(props: { + queryOpts: QueryOptions + testId: string + fallback: string +}) { + return ( + + {{ + default: () => ( + + ), + fallback: () => ( +
    {props.fallback}
    + ), + }} +
    + ) +} + +const QueryHeavy = defineComponent({ + setup() { + 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)

    + + + +
    +
    +
    + ) + }, +}) + +export const Route = createFileRoute('/query-heavy')({ + component: QueryHeavy, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx b/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx new file mode 100644 index 0000000000..3edb015290 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx @@ -0,0 +1,78 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +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.value.quickData.name} @ {data.value.quickData.timestamp} +
    +
    + Quick data source: {data.value.quickData.source} +
    +
    + Loader source: {data.value.loaderSource} +
    + + {{ + default: () => ( + ( +
    + {value.message} (source: {value.source}) +
    + )} + /> + ), + fallback: () =>
    Loading...
    , + }} +
    + + + +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/stream.tsx b/e2e/vue-start/streaming-ssr/src/routes/stream.tsx new file mode 100644 index 0000000000..a8b04edc8f --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/stream.tsx @@ -0,0 +1,161 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { + Suspense, + defineComponent, + onMounted, + onUnmounted, + ref, + watch, +} from 'vue' + +export const Route = createFileRoute('/stream')({ + component: () => , + 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') + +const StreamRoute = defineComponent({ + setup() { + const data = Route.useLoaderData() + const streamData = ref>([]) + const streamComplete = ref(false) + let reader: ReadableStreamDefaultReader | undefined + let mounted = false + let activeStream: ReadableStream | undefined + let reading = false + let readTimer: ReturnType | undefined + + function clearReadTimer() { + if (readTimer) { + clearTimeout(readTimer) + readTimer = undefined + } + } + + function scheduleRead(delay = 0) { + if (!mounted || reading) { + return + } + + clearReadTimer() + readTimer = setTimeout(() => { + readTimer = undefined + void readStream() + }, delay) + } + + async function readStream() { + const stream = data.value.stream + if (!mounted || reading || activeStream === stream) { + return + } + + if (stream.locked) { + scheduleRead(10) + return + } + + streamData.value = [] + streamComplete.value = false + + let activeReader: ReadableStreamDefaultReader | undefined + + try { + activeReader = stream.getReader() + reader = activeReader + activeStream = stream + reading = true + + let chunk + while (!(chunk = await activeReader.read()).done) { + let value = chunk.value + if (typeof value !== 'string') { + value = decoder.decode(value, { stream: !chunk.done }) + } + streamData.value = [...streamData.value, value] + } + streamComplete.value = true + } catch (e) { + const message = String(e) + if (e instanceof TypeError && message.includes('locked')) { + activeStream = undefined + scheduleRead(10) + } else if (!(e instanceof TypeError && message.includes('cancelled'))) { + console.error('Stream error:', e) + } + } finally { + activeReader?.releaseLock() + if (reader === activeReader) { + reader = undefined + } + reading = false + } + } + + onMounted(() => { + mounted = true + scheduleRead() + }) + watch( + () => data.value.stream, + () => { + activeStream = undefined + scheduleRead() + }, + ) + onUnmounted(() => { + mounted = false + clearReadTimer() + reader?.cancel().catch(() => {}) + reader = undefined + }) + + return () => ( +
    +

    ReadableStream Test

    + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () => ( +
    Loading promise...
    + ), + }} +
    +
    +

    Stream chunks:

    +
    + {streamData.value.map((chunk, i) => ( +
    {chunk}
    + ))} +
    + {streamComplete.value && ( +
    Stream complete!
    + )} +
    +
    + ) + }, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/streaming-policy.tsx b/e2e/vue-start/streaming-ssr/src/routes/streaming-policy.tsx new file mode 100644 index 0000000000..cc7fef87c0 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/streaming-policy.tsx @@ -0,0 +1,46 @@ +import { Await, createFileRoute, useRouter } from '@tanstack/vue-router' +import { Suspense, defineComponent } from 'vue' + +const StreamingPolicyRoute = defineComponent({ + setup() { + 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.value.immediate}
    + + {{ + default: () => ( + ( +
    {value}
    + )} + /> + ), + fallback: () =>
    Loading policy content...
    , + }} +
    +
    + ) + }, +}) + +export const Route = createFileRoute('/streaming-policy')({ + loader: () => { + return { + immediate: 'Immediate policy content', + deferred: new Promise((resolve) => { + setTimeout(() => resolve('Deferred policy content'), 100) + }), + } + }, + component: StreamingPolicyRoute, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx b/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx new file mode 100644 index 0000000000..c84b582f1f --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/vue-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.value.message}

    +

    Loaded at: {data.value.timestamp}

    +

    Source: {data.value.source}

    +
      + {data.value.items.map((item) => ( +
    • {item}
    • + ))} +
    +
    + ) +} diff --git a/e2e/vue-start/streaming-ssr/src/server.ts b/e2e/vue-start/streaming-ssr/src/server.ts new file mode 100644 index 0000000000..6c7a52e752 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/server.ts @@ -0,0 +1,67 @@ +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/vue-start/server' +import defaultServerEntry, { + createServerEntry, +} from '@tanstack/vue-start/server-entry' +import type { SsrStreamingResolverResult } from '@tanstack/vue-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/vue-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/vue-start/streaming-ssr/tests/client-navigation.spec.ts new file mode 100644 index 0000000000..91138987ad --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/vue-start/streaming-ssr/tests/concurrent.spec.ts new file mode 100644 index 0000000000..10e32c6809 --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts new file mode 100644 index 0000000000..3b8144072c --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/deferred.spec.ts b/e2e/vue-start/streaming-ssr/tests/deferred.spec.ts new file mode 100644 index 0000000000..f7a30083ed --- /dev/null +++ b/e2e/vue-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) + }) + + 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/vue-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/vue-start/streaming-ssr/tests/fast-serial.spec.ts new file mode 100644 index 0000000000..339d6408ea --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/fixtures.ts b/e2e/vue-start/streaming-ssr/tests/fixtures.ts new file mode 100644 index 0000000000..88bcfcc08c --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/home.spec.ts b/e2e/vue-start/streaming-ssr/tests/home.spec.ts new file mode 100644 index 0000000000..92e75e137e --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/vue-start/streaming-ssr/tests/many-promises.spec.ts new file mode 100644 index 0000000000..034ab74220 --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/vue-start/streaming-ssr/tests/nested-deferred.spec.ts new file mode 100644 index 0000000000..dbf6df4265 --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/vue-start/streaming-ssr/tests/preview-streaming.spec.ts new file mode 100644 index 0000000000..041e18e00f --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/vue-start/streaming-ssr/tests/query-heavy.spec.ts new file mode 100644 index 0000000000..942f297c60 --- /dev/null +++ b/e2e/vue-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('')) + }) +}) diff --git a/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts new file mode 100644 index 0000000000..c3631a7fc0 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts @@ -0,0 +1,73 @@ +import { expect, test, testWithHydration } from './fixtures' + +test.describe('Slow render (render takes longer than serialization)', () => { + test('all data eventually renders with server source', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Quick data should be available with server source + await expect(page.getByTestId('quick-data')).toContainText('Quick:') + await expect(page.getByTestId('quick-source')).toContainText( + 'Quick data source: server', + ) + await expect(page.getByTestId('loader-source')).toContainText( + 'Loader source: server', + ) + + // Deferred data should resolve with server source + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'Deferred resolved!', + { timeout: 5000 }, + ) + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + { timeout: 5000 }, + ) + + // Slow components should have rendered + await expect(page.getByTestId('slow-component-1')).toBeVisible() + await expect(page.getByTestId('slow-component-2')).toBeVisible() + await expect(page.getByTestId('slow-component-3')).toBeVisible() + }) + + testWithHydration('hydration works after slow render', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for content and verify server source + await expect(page.getByTestId('slow-component-1')).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: 'Slow Render' }).first().click() + await expect(page).toHaveURL('/slow-render') + + await expect(page.getByTestId('quick-data')).toBeVisible({ timeout: 10000 }) + }) + + test('all data sources are server - proves SSR streaming works', async ({ + page, + }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for deferred content + await expect(page.getByTestId('deferred-resolved')).toBeVisible({ + timeout: 5000, + }) + + // Verify all sources are server + await expect(page.getByTestId('quick-source')).toContainText('server') + await expect(page.getByTestId('loader-source')).toContainText('server') + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + ) + }) +}) diff --git a/e2e/vue-start/streaming-ssr/tests/stream.spec.ts b/e2e/vue-start/streaming-ssr/tests/stream.spec.ts new file mode 100644 index 0000000000..5e4c134f82 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1,46 @@ +import { expect, test, testWithHydration } from './fixtures' + +test.describe('ReadableStream streaming', () => { + testWithHydration('promise data resolves correctly', async ({ page }) => { + await page.goto('/stream') + + // Promise should resolve + await expect(page.getByTestId('promise-data')).toContainText( + 'promise-resolved', + { timeout: 5000 }, + ) + }) + + testWithHydration('stream chunks arrive incrementally', async ({ page }) => { + await page.goto('/stream') + + // Wait for stream to complete + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + + // All chunks should be present + await expect(page.getByTestId('stream-chunk-0')).toContainText('chunk-0') + await expect(page.getByTestId('stream-chunk-1')).toContainText('chunk-1') + await expect(page.getByTestId('stream-chunk-2')).toContainText('chunk-2') + await expect(page.getByTestId('stream-chunk-3')).toContainText('chunk-3') + await expect(page.getByTestId('stream-chunk-4')).toContainText('chunk-4') + }) + + testWithHydration( + 'client-side navigation to stream route works', + async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate via nav link + await page.getByRole('link', { name: 'Stream' }).first().click() + await expect(page).toHaveURL('/stream') + + // Wait for stream to complete + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + }, + ) +}) diff --git a/e2e/vue-start/streaming-ssr/tests/streaming-policy.spec.ts b/e2e/vue-start/streaming-ssr/tests/streaming-policy.spec.ts new file mode 100644 index 0000000000..a943e7a53f --- /dev/null +++ b/e2e/vue-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/vue-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts new file mode 100644 index 0000000000..a0060a07ee --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test' +import { test, testWithHydration } from './fixtures' + +/** + * Tests for synchronous serialization - no deferred data, no streaming. + * This is the most common case where all loader data is immediately available. + * The hydration scripts should be included in the initial HTML response. + */ + +test('Sync-only route renders with loader data', async ({ page }) => { + await page.goto('/sync-only') + + // Verify the page content is rendered + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) + + // Verify loader data items are rendered + await expect(page.getByTestId('sync-item-item-1')).toBeVisible() + await expect(page.getByTestId('sync-item-item-2')).toBeVisible() + await expect(page.getByTestId('sync-item-item-3')).toBeVisible() + + // Verify data came from server (proves SSR streaming worked) + await expect(page.getByTestId('sync-source')).toContainText('Source: server') +}) + +testWithHydration('Sync-only route hydrates correctly', async ({ page }) => { + await page.goto('/sync-only') + + // Verify client-side navigation works (proves hydration succeeded) + await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click() + await expect(page.getByTestId('index-title')).toBeVisible() + + // Navigate back to sync-only via client-side navigation (use nav link to be specific) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Sync Only' }) + .click() + await expect(page.getByTestId('sync-title')).toBeVisible() +}) + +test('Sync-only route has bootstrap scripts in initial HTML', async ({ + page, +}) => { + // Intercept the response to check the raw HTML + let responseHtml = '' + await page.route('/sync-only', async (route) => { + const response = await route.fetch() + responseHtml = await response.text() + await route.fulfill({ response }) + }) + + await page.goto('/sync-only') + + // Wait for page to load + await expect(page.getByTestId('sync-title')).toBeVisible() + + // The HTML should contain the bootstrap scripts + // $_TSR.router should be present (the dehydrated router state) + expect(responseHtml).toContain('$_TSR') + expect(responseHtml).toContain('$_TSR.router') + // The serialization end marker should be present + expect(responseHtml).toContain('$_TSR.e()') + + // SSR should include the barrier script tag in the HTML (rendered by ) + // This is the critical marker transformStreamWithRouter can scan for. + expect(responseHtml).toContain('$tsr-stream-barrier') +}) + +test('Navigating to sync-only from home page', async ({ page }) => { + await page.goto('/') + + await page.getByTestId('link-sync-only').click() + + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) +}) diff --git a/e2e/vue-start/streaming-ssr/tsconfig.json b/e2e/vue-start/streaming-ssr/tsconfig.json new file mode 100644 index 0000000000..390dad84c0 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "vue", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/vue-start/streaming-ssr/vite.config.ts b/e2e/vue-start/streaming-ssr/vite.config.ts new file mode 100644 index 0000000000..6519ec54c8 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart(), vueJsx()], +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ff94646687..20c76f048a 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -94,8 +94,7 @@ "dependencies": { "@tanstack/history": "workspace:*", "@tanstack/react-store": "^0.9.3", - "@tanstack/router-core": "workspace:*", - "isbot": "^5.1.22" + "@tanstack/router-core": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 46275a5421..800ded581d 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -1,6 +1,5 @@ import { PassThrough } from 'node:stream' import ReactDOMServer from 'react-dom/server' -import { isbot } from 'isbot' import { createSsrStreamResponse, transformPipeableStreamWithRouter, @@ -12,9 +11,9 @@ import type { ReactNode } from 'react' const noop = () => {} -// Bot responses wait for `allReady` so crawlers receive complete HTML. -// If the request disconnects during that wait, React may not settle quickly; -// unblock the wait so the response pipeline can abort and clean up. +// Full-document responses wait for `allReady`. If the request disconnects +// during that wait, React may not settle quickly; unblock the wait so the +// response pipeline can abort and clean up. async function waitForReadyOrAbort( ready: Promise, signal: AbortSignal, @@ -52,6 +51,8 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: ReactNode }) => { + const shouldStreamRender = router.serverSsr!.shouldStream('render') + if (typeof ReactDOMServer.renderToReadableStream === 'function') { const stream = await ReactDOMServer.renderToReadableStream(children, { signal: request.signal, @@ -64,7 +65,7 @@ export const renderRouterToStream = async ({ }, }) - if (isbot(request.headers.get('User-Agent'))) { + if (!shouldStreamRender) { await waitForReadyOrAbort(stream.allReady, request.signal) } @@ -150,14 +151,14 @@ export const renderRouterToStream = async ({ pipeable = ReactDOMServer.renderToPipeableStream(children, { nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(isbot(request.headers.get('User-Agent')) + ...(shouldStreamRender ? { - onAllReady() { + onShellReady() { pipeable!.pipe(reactAppPassthrough) }, } : { - onShellReady() { + onAllReady() { pipeable!.pipe(reactAppPassthrough) }, }), diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 74e180a552..ebcc92c35c 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -14,25 +14,57 @@ vi.mock('react-dom/server', () => ({ const { renderRouterToStream } = await import('../src/ssr/renderRouterToStream') +const alwaysStream = { + render: true, + head: true, +} + +const neverStream = { + render: false, + head: false, +} + afterEach(() => { reactDomServerMocks.renderToReadableStream = undefined reactDomServerMocks.renderToPipeableStream.mockReset() vi.restoreAllMocks() }) -async function buildRouter() { +async function buildRouter(streaming = alwaysStream) { const rootRoute = createRootRoute({ component: () => null }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), routeTree: rootRoute, }) router.isServer = true - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ router, manifest: undefined, streaming }) await router.load() await router.serverSsr!.dehydrate() return router } +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function makeReadableReactStream(allReady: Promise) { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('ok'), + ) + controller.close() + }, + }) as ReadableStream & { allReady: Promise } + + stream.allReady = allReady + return stream +} + async function expectBodyRejects(response: Response, message: string) { const reader = response.body!.getReader() await expect( @@ -52,6 +84,113 @@ function unwrapResponse( } describe('renderRouterToStream - pipeable sync errors', () => { + test('render channel=true uses onShellReady for bot UA', async () => { + let options!: { onShellReady?: () => void; onAllReady?: () => void } + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, opts) => { + options = opts + return { abort: vi.fn(), pipe: vi.fn() } + }, + ) + + const router = await buildRouter(alwaysStream) + try { + await renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'user-agent': 'Googlebot' }, + }), + router, + responseHeaders: new Headers(), + children: null, + }) + + expect(options.onShellReady).toEqual(expect.any(Function)) + expect(options.onAllReady).toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('render channel=false uses onAllReady for human UA', async () => { + let options!: { onShellReady?: () => void; onAllReady?: () => void } + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, opts) => { + options = opts + return { abort: vi.fn(), pipe: vi.fn() } + }, + ) + + const router = await buildRouter(neverStream) + try { + await renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'user-agent': 'Mozilla/5.0' }, + }), + router, + responseHeaders: new Headers(), + children: null, + }) + + expect(options.onAllReady).toEqual(expect.any(Function)) + expect(options.onShellReady).toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('readable stream waits for allReady only when render=false', async () => { + const ready = deferred() + reactDomServerMocks.renderToReadableStream = vi.fn(() => + Promise.resolve(makeReadableReactStream(ready.promise)), + ) + + const router = await buildRouter(neverStream) + try { + const renderPromise = renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }) + + await expect( + Promise.race([ + renderPromise.then(() => 'resolved'), + new Promise<'pending'>((resolve) => + setTimeout(() => resolve('pending'), 50), + ), + ]), + ).resolves.toBe('pending') + + ready.resolve() + await expect(renderPromise).resolves.toBeDefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('readable stream does not wait for allReady when render=true', async () => { + const ready = deferred() + reactDomServerMocks.renderToReadableStream = vi.fn(() => + Promise.resolve(makeReadableReactStream(ready.promise)), + ) + + const router = await buildRouter(alwaysStream) + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ).resolves.toBeDefined() + } finally { + ready.resolve() + router.serverSsr?.cleanup() + } + }) + test('sync onError before pipeable is assigned still aborts pipeable', async () => { const abort = vi.fn() reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( diff --git a/packages/router-core/package.json b/packages/router-core/package.json index a1ca664a70..87e79f5281 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -184,6 +184,7 @@ "dependencies": { "@tanstack/history": "workspace:*", "cookie-es": "^3.0.0", + "isbot": "^5.1.22", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" }, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..f6a948758d 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -111,6 +111,7 @@ import type { AnySerializationAdapter, ValidateSerializableInput, } from './ssr/serializer/transformer' +import type { SsrStreamingChannel } from './ssr/streaming' import type { GetStoreConfig, RouterStores } from './stores' export type ControllablePromise = Promise & { @@ -791,6 +792,8 @@ export type ClearCacheFn = (opts?: { }) => void export interface ServerSsr { + /** Framework-only: whether the named SSR channel may stream. */ + shouldStream: (channel: SsrStreamingChannel) => boolean /** Framework-only: injects router-owned HTML into the SSR stream. */ injectHtml: (html: string) => void /** Framework-only: injects a router-owned script tag into the SSR stream. */ diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 32377b2bea..dfca392fe5 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -6,10 +6,13 @@ import { getOrigin, } from './ssr-server' import { normalizeSsrResponse } from './handlerCallback' +import { resolveSsrStreaming } from './streaming' +import { isPromise } from '../utils' import type { HandlerCallback } from './handlerCallback' import type { AnyHeaders } from './headers' import type { AnyRouter } from '../router' import type { ServerManifest } from '../manifest' +import type { SsrStreamingOption } from './streaming' export type RequestHandler = ( cb: HandlerCallback, @@ -19,21 +22,20 @@ export function createRequestHandler({ createRouter, request, getRouterManifest, + ssr, }: { createRouter: () => TRouter request: Request getRouterManifest?: () => ServerManifest | Promise + ssr?: { + streaming?: SsrStreamingOption + } }): RequestHandler { return async (cb) => { const router = createRouter() let responseOwnsCleanup = false try { - attachRouterServerSsrUtils({ - router, - manifest: await getRouterManifest?.(), - }) - // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. const { url } = getNormalizedURL(request.url, 'http://localhost') const origin = getOrigin(request) @@ -50,6 +52,25 @@ export function createRequestHandler({ origin: router.options.origin ?? origin, }) + const maybeStreaming = resolveSsrStreaming({ + request, + router, + streaming: ssr?.streaming, + }) + const streaming = isPromise(maybeStreaming) + ? await maybeStreaming + : maybeStreaming + const maybeManifest = getRouterManifest?.() + const manifest = isPromise(maybeManifest) + ? await maybeManifest + : maybeManifest + + attachRouterServerSsrUtils({ + router, + manifest, + streaming, + }) + await router.load() await router.serverSsr?.dehydrate() diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index df5b838676..1965fb2103 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -24,3 +24,17 @@ export { getNormalizedURL, getOrigin, } from './ssr-server' +export { + getDefaultSsrStreamingValue, + resolveSsrStreaming, + shouldStreamSsrChannel, +} from './streaming' +export type { + ResolvedSsrStreaming, + SsrStreamingChannel, + SsrStreamingConfig, + SsrStreamingOption, + SsrStreamingOverride, + SsrStreamingResolverContext, + SsrStreamingResolverResult, +} from './streaming' diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 31db2f5e41..e8c1af7ffe 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -13,11 +13,13 @@ import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants' import { dehydrateSsrMatchId } from './ssr-match-id' import { defaultSerovalPlugins } from './serializer/seroval-plugins' import { makeSsrSerovalPlugin } from './serializer/transformer' +import { shouldStreamSsrChannel } from './streaming' import type { LRUCache } from '../lru-cache' import type { DehydratedMatch, DehydratedRouter } from './types' import type { AnySerializationAdapter } from './serializer/transformer' import type { AnyRouter, ServerSsr } from '../router' import type { AnyRouteMatch } from '../Matches' +import type { ResolvedSsrStreaming } from './streaming' import type { Manifest, ManifestRoute, @@ -379,11 +381,19 @@ export function attachRouterServerSsrUtils({ router, manifest, getRequestAssets, + streaming, }: { router: AnyRouter manifest: ServerManifest | undefined getRequestAssets?: () => ManifestRouteAssets | undefined + streaming: ResolvedSsrStreaming }) { + if (!streaming) { + throw new Error( + 'Invariant failed: attachRouterServerSsrUtils requires a resolved SSR streaming policy. Use createRequestHandler/createStartHandler or pass the result of resolveSsrStreaming().', + ) + } + router.ssr = { get manifest() { if (!manifest) return manifest @@ -476,6 +486,7 @@ export function attachRouterServerSsrUtils({ }) const serverSsr: ServerSsr = { + shouldStream: (channel) => shouldStreamSsrChannel(streaming, channel), injectHtml: (html: string) => { if (!html || cleanupStarted) return // Buffer the HTML so it can be retrieved via takeBufferedHtml() diff --git a/packages/router-core/src/ssr/streaming.ts b/packages/router-core/src/ssr/streaming.ts new file mode 100644 index 0000000000..4c43be1219 --- /dev/null +++ b/packages/router-core/src/ssr/streaming.ts @@ -0,0 +1,172 @@ +import { isbot } from 'isbot' +import { isPromise } from '../utils' +import type { AnyRouter } from '../router' +import type { Awaitable } from '../utils' + +export type SsrStreamingConfig = { + render: boolean + head?: boolean +} + +export type ResolvedSsrStreaming = { + render: boolean + head: boolean +} + +export type SsrStreamingOverride = { + render?: boolean + head?: boolean +} + +export type SsrStreamingChannel = keyof ResolvedSsrStreaming + +export type SsrStreamingResolverContext = { + request: Request + router: AnyRouter +} + +export type SsrStreamingResolverResult = SsrStreamingConfig | undefined + +export type SsrStreamingOption = + | SsrStreamingConfig + | (( + ctx: SsrStreamingResolverContext, + ) => Awaitable) + +export function getDefaultSsrStreamingValue(request: Request): boolean { + return !isbot(request.headers.get('user-agent')) +} + +function createDefaultSsrStreaming(request: Request): ResolvedSsrStreaming { + return { + render: getDefaultSsrStreamingValue(request), + head: false, + } +} + +function assertValidConfigKey(key: string) { + if (key !== 'render' && key !== 'head') { + throw new TypeError( + `Unknown ssr.streaming option "${key}". Expected "render" or "head".`, + ) + } +} + +function normalizeStreamingBoolean(value: unknown, path: string): boolean { + if (typeof value === 'boolean') { + return value + } + + throw new TypeError( + `Invalid ssr.streaming value for "${path}". Expected a boolean.`, + ) +} + +export function shouldStreamSsrChannel( + streaming: ResolvedSsrStreaming, + channel: SsrStreamingChannel, +): boolean { + if (channel === 'render') { + return streaming.render + } + + if (channel === 'head') { + return streaming.head + } + + throw new TypeError( + `Unknown ssr.streaming channel "${channel}". Expected "render" or "head".`, + ) +} + +function normalizeSsrStreaming( + request: Request, + raw: SsrStreamingResolverResult, +): ResolvedSsrStreaming { + if (raw === undefined) { + return createDefaultSsrStreaming(request) + } + + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new TypeError( + 'Invalid ssr.streaming value. Expected an object, function result, or undefined.', + ) + } + + for (const key of Object.keys(raw)) { + assertValidConfigKey(key) + } + + return { + render: normalizeStreamingBoolean(raw.render, 'render'), + head: + raw.head === undefined + ? false + : normalizeStreamingBoolean(raw.head, 'head'), + } +} + +function applySsrStreamingOverride( + streaming: ResolvedSsrStreaming, + override: SsrStreamingOverride | undefined, +): ResolvedSsrStreaming { + if (override === undefined) { + return streaming + } + + if ( + override === null || + typeof override !== 'object' || + Array.isArray(override) + ) { + throw new TypeError( + 'Invalid ssr.streaming override value. Expected an object or undefined.', + ) + } + + for (const key of Object.keys(override)) { + assertValidConfigKey(key) + } + + return { + render: + override.render === undefined + ? streaming.render + : normalizeStreamingBoolean(override.render, 'render'), + head: + override.head === undefined + ? streaming.head + : normalizeStreamingBoolean(override.head, 'head'), + } +} + +export function resolveSsrStreaming(opts: { + request: Request + router: AnyRouter + streaming?: SsrStreamingOption + streamingOverride?: SsrStreamingOverride +}): ResolvedSsrStreaming | Promise { + if (typeof opts.streaming !== 'function') { + return applySsrStreamingOverride( + normalizeSsrStreaming(opts.request, opts.streaming), + opts.streamingOverride, + ) + } + + const raw = opts.streaming({ + request: opts.request, + router: opts.router, + }) + + return isPromise(raw) + ? raw.then((value) => + applySsrStreamingOverride( + normalizeSsrStreaming(opts.request, value), + opts.streamingOverride, + ), + ) + : applySsrStreamingOverride( + normalizeSsrStreaming(opts.request, raw), + opts.streamingOverride, + ) +} diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 4393f955b3..22a84a8a96 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -785,10 +785,15 @@ function makeMainStream( const chunkString = leftover ? leftover + text : text - // If we already saw , everything else is tail. Keep it bounded - // and held until router scripts are ready so injection remains before . - if (state >= MergeState.HoldingTail) { - appendTail(chunkString) + // If we already saw , keep the captured close tail held so + // router scripts can still land before it. Later app chunks can stream + // before that held tail; Solid uses this shape for Suspense reveal + // templates/scripts emitted after the document shell. + if (state === MergeState.HoldingTail) { + flushPendingRouterHtml() + if (cleanedUp || isDone()) return + writeChunk(chunkString) + if (cleanedUp || isDone()) return leftover = '' continue } diff --git a/packages/router-core/tests/createRequestHandler-streaming.test.ts b/packages/router-core/tests/createRequestHandler-streaming.test.ts new file mode 100644 index 0000000000..36c527aa49 --- /dev/null +++ b/packages/router-core/tests/createRequestHandler-streaming.test.ts @@ -0,0 +1,147 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createRequestHandler } from '../src/ssr/createRequestHandler' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' + +const HUMAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36' +const BOT_UA = 'Mozilla/5.0 compatible Googlebot/2.1' + +function makeRequest(userAgent = HUMAN_UA, headers?: Record) { + return new Request('http://localhost/', { + headers: { + 'user-agent': userAgent, + ...headers, + }, + }) +} + +function makeRouter(opts?: { onLoad?: (router: AnyRouter) => void }) { + const rootRoute = new BaseRootRoute({}) + let router!: AnyRouter + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + loader: () => { + opts?.onLoad?.(router) + }, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + + return router +} + +describe('createRequestHandler streaming policy', () => { + test('resolves streaming before router.load', async () => { + const seenInLoader: Array<{ render: boolean; head: boolean }> = [] + const router = makeRouter({ + onLoad: (requestRouter) => { + seenInLoader.push({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }) + }, + }) + + await createRequestHandler({ + request: makeRequest(HUMAN_UA), + createRouter: () => router, + ssr: { + streaming: { + render: false, + head: true, + }, + }, + })(async () => new Response('ok')) + + expect(seenInLoader).toEqual([ + { + render: false, + head: true, + }, + ]) + }) + + test('default human request streams render and leaves head off', async () => { + const router = makeRouter() + + await createRequestHandler({ + request: makeRequest(HUMAN_UA), + createRouter: () => router, + })(async ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(true) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(false) + return new Response('ok') + }) + }) + + test('default bot request blocks render and leaves head off', async () => { + const router = makeRouter() + + await createRequestHandler({ + request: makeRequest(BOT_UA), + createRouter: () => router, + })(async ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(false) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(false) + return new Response('ok') + }) + }) + + test('resolver can use request headers', async () => { + const router = makeRouter() + + await createRequestHandler({ + request: makeRequest(HUMAN_UA, { 'x-streaming': 'none' }), + createRouter: () => router, + ssr: { + streaming: ({ request }) => { + if (request.headers.get('x-streaming') === 'none') { + return { render: false } + } + return undefined + }, + }, + })(async ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(false) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(false) + return new Response('ok') + }) + }) + + test('handler sees same policy that loader saw', async () => { + const seenInLoader: Array<{ render: boolean; head: boolean }> = [] + const router = makeRouter({ + onLoad: (requestRouter) => { + seenInLoader.push({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }) + }, + }) + + await createRequestHandler({ + request: makeRequest(HUMAN_UA), + createRouter: () => router, + ssr: { + streaming: { + render: true, + }, + }, + })(async ({ router: requestRouter }) => { + expect({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }).toEqual(seenInLoader[0]) + return new Response('ok') + }) + }) +}) diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index 0cc8832abd..a4e9e42af6 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -7,6 +7,11 @@ import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' import { createTestRouter } from './routerTestUtils' +const alwaysStream = { + render: true, + head: true, +} + /** * CI-stable tests for the SSR cleanup contract. These do not rely on GC * timing; they exercise the observable behavior: @@ -41,7 +46,11 @@ function deferred() { describe('serverSsr.cleanup', () => { test('onCleanup listeners run exactly once', () => { const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) let calls = 0 router.serverSsr!.onCleanup(() => { @@ -56,7 +65,11 @@ describe('serverSsr.cleanup', () => { test('listener that re-enters cleanup() does not re-fire siblings', () => { const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) const order: Array = [] router.serverSsr!.onCleanup(() => { @@ -74,7 +87,11 @@ describe('serverSsr.cleanup', () => { test('listener errors are swallowed and do not stop subsequent listeners', () => { const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) const order: Array = [] router.serverSsr!.onCleanup(() => { @@ -93,7 +110,11 @@ describe('serverSsr.cleanup', () => { test('cleanup before pending serialization resolves drops late scripts safely', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -109,7 +130,11 @@ describe('serverSsr.cleanup', () => { test('serialization completion does not clear render-finished listeners', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -136,7 +161,11 @@ describe('serverSsr.cleanup', () => { test('render-finished listeners can synchronously finish serialization', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -159,7 +188,11 @@ describe('serverSsr.cleanup', () => { test('late serialization listener runs safely and returns unsubscribe', async () => { const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -176,7 +209,11 @@ describe('serverSsr.cleanup', () => { test('stream fast path only reserves when no SSR work is pending', async () => { const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -193,7 +230,11 @@ describe('serverSsr.cleanup', () => { test('stream fast path rejects while SSR work is pending', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -221,7 +262,11 @@ describe('serverSsr.cleanup', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const router = buildRouter() try { - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) const calls: Array = [] router.serverSsr!.onInjectedHtml(() => { @@ -253,7 +298,11 @@ describe('serverSsr.cleanup', () => { ], } - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) expect(calls).toEqual(['attach']) router.serverSsr!.cleanup() diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index f76535622f..49c685222d 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -12,6 +12,11 @@ import type { } from '../src/manifest' import type { DehydratedRouter } from '../src/ssr/types' +const alwaysStream = { + render: true, + head: true, +} + function buildRouter() { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ @@ -74,6 +79,7 @@ async function dehydrateManifest() { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() @@ -121,6 +127,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() @@ -155,6 +162,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, getRequestAssets: () => requestAssets, }) @@ -191,6 +199,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, getRequestAssets: () => requestAssets, }) @@ -209,6 +218,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() @@ -271,6 +281,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() @@ -299,6 +310,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() @@ -372,6 +384,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { attachRouterServerSsrUtils({ router, manifest, + streaming: alwaysStream, }) await router.load() diff --git a/packages/router-core/tests/ssr-server-streaming.test.ts b/packages/router-core/tests/ssr-server-streaming.test.ts new file mode 100644 index 0000000000..738963f5f0 --- /dev/null +++ b/packages/router-core/tests/ssr-server-streaming.test.ts @@ -0,0 +1,126 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createRequestHandler } from '../src/ssr/createRequestHandler' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' +import type { ResolvedSsrStreaming } from '../src/ssr/streaming' + +function makeRouter(opts?: { onLoad?: (router: AnyRouter) => void }) { + const rootRoute = new BaseRootRoute({}) + let router!: AnyRouter + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + loader: () => { + opts?.onLoad?.(router) + }, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + + return router +} + +describe('serverSsr streaming policy', () => { + test('serverSsr exposes resolved streaming decisions', () => { + const router = makeRouter() + const streaming: ResolvedSsrStreaming = { + render: true, + head: false, + } + + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming, + }) + + expect(router.serverSsr!.shouldStream('render')).toBe(true) + expect(router.serverSsr!.shouldStream('head')).toBe(false) + }) + + test('attach requires a resolved streaming policy', () => { + const router = makeRouter() + + expect(() => + attachRouterServerSsrUtils({ + router, + manifest: undefined, + } as any), + ).toThrow(/requires a resolved SSR streaming policy/) + }) + + test('serverSsr.shouldStream rejects unknown channels at runtime', () => { + const router = makeRouter() + + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: { + render: true, + head: true, + }, + }) + + expect(() => + router.serverSsr!.shouldStream('future-channel' as any), + ).toThrow('Unknown ssr.streaming channel "future-channel"') + }) + + test('streaming policy is available before router.load', async () => { + const seenInLoader: Array<{ render: boolean; head: boolean }> = [] + const router = makeRouter({ + onLoad: (requestRouter) => { + seenInLoader.push({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }) + }, + }) + + await createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => router, + ssr: { + streaming: { + render: false, + head: true, + }, + }, + })(async () => new Response('ok')) + + expect(seenInLoader).toEqual([ + { + render: false, + head: true, + }, + ]) + }) + + test('cleanup does not mutate captured streaming decisions', () => { + const router = makeRouter() + const streaming: ResolvedSsrStreaming = { + render: true, + head: false, + } + + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming, + }) + + const captured = router.serverSsr! + router.serverSsr!.cleanup() + + expect(captured.shouldStream('render')).toBe(true) + expect(captured.shouldStream('head')).toBe(false) + }) +}) diff --git a/packages/router-core/tests/ssr-streaming.test-d.ts b/packages/router-core/tests/ssr-streaming.test-d.ts new file mode 100644 index 0000000000..249e72f8e7 --- /dev/null +++ b/packages/router-core/tests/ssr-streaming.test-d.ts @@ -0,0 +1,148 @@ +import { expectTypeOf, test } from 'vitest' +import { createRequestHandler } from '../src/ssr/createRequestHandler' +import { resolveSsrStreaming } from '../src/ssr/streaming' +import type { AnyRouter } from '../src' + +declare const request: Request +declare const createRouter: () => AnyRouter + +test('createRequestHandler accepts object streaming policy', () => { + createRequestHandler({ + request, + createRouter, + ssr: { + streaming: { + render: true, + }, + }, + }) +}) + +test('createRequestHandler accepts explicit head streaming policy', () => { + createRequestHandler({ + request, + createRouter, + ssr: { + streaming: { + render: false, + head: true, + }, + }, + }) +}) + +test('createRequestHandler accepts streaming resolver', () => { + createRequestHandler({ + request, + createRouter, + ssr: { + streaming: async ({ request: req, router }) => { + expectTypeOf(req).toEqualTypeOf() + expectTypeOf(router).toEqualTypeOf() + + return { + render: false, + } + }, + }, + }) +}) + +test('createRequestHandler rejects invalid streaming values', () => { + createRequestHandler({ + request, + createRouter, + ssr: { + // @ts-expect-error + streaming: true, + }, + }) + + createRequestHandler({ + request, + createRouter, + ssr: { + // @ts-expect-error + streaming: 'yes', + }, + }) + + createRequestHandler({ + request, + createRouter, + ssr: { + streaming: { + // @ts-expect-error + rsc: true, + }, + }, + }) + + createRequestHandler({ + request, + createRouter, + ssr: { + // @ts-expect-error + streaming: { + head: true, + }, + }, + }) + + createRequestHandler({ + request, + createRouter, + ssr: { + streaming: { + // @ts-expect-error + render: 'stream', + }, + }, + }) + + createRequestHandler({ + request, + createRouter, + ssr: { + // @ts-expect-error + streaming: () => ({ head: false }), + }, + }) +}) + +test('resolveSsrStreaming accepts partial request overrides', () => { + resolveSsrStreaming({ + request, + router: createRouter(), + streamingOverride: { + head: true, + }, + }) + + resolveSsrStreaming({ + request, + router: createRouter(), + streamingOverride: { + render: false, + head: true, + }, + }) + + resolveSsrStreaming({ + request, + router: createRouter(), + streamingOverride: { + // @ts-expect-error + rsc: true, + }, + }) + + resolveSsrStreaming({ + request, + router: createRouter(), + streamingOverride: { + // @ts-expect-error + head: 'true', + }, + }) +}) diff --git a/packages/router-core/tests/ssr-streaming.test.ts b/packages/router-core/tests/ssr-streaming.test.ts new file mode 100644 index 0000000000..e0c3fa4129 --- /dev/null +++ b/packages/router-core/tests/ssr-streaming.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { + resolveSsrStreaming, + shouldStreamSsrChannel, +} from '../src/ssr/streaming' +import { createTestRouter } from './routerTestUtils' + +const HUMAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36' +const BOT_UA = 'Mozilla/5.0 compatible Googlebot/2.1' + +function makeRequest(userAgent = HUMAN_UA) { + return new Request('http://localhost/', { + headers: { + 'user-agent': userAgent, + }, + }) +} + +function makeRouter() { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) +} + +function isPromiseLike(value: unknown): value is Promise { + return Boolean( + value && + typeof value === 'object' && + typeof (value as Promise).then === 'function', + ) +} + +describe('resolveSsrStreaming', () => { + test('default policy: human UA streams render and leaves head off', () => { + const streaming = resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + }) + + expect(isPromiseLike(streaming)).toBe(false) + expect(streaming).toEqual({ render: true, head: false }) + }) + + test('default policy: bot UA blocks render and leaves head off', () => { + const streaming = resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + }) + + expect(streaming).toEqual({ render: false, head: false }) + }) + + test('object config requires render and defaults head to false', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + streaming: { + render: true, + }, + }) + + expect(shouldStreamSsrChannel(streaming, 'render')).toBe(true) + expect(shouldStreamSsrChannel(streaming, 'head')).toBe(false) + }) + + test('object config can opt into head streaming explicitly', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: { + render: false, + head: true, + }, + }) + + expect(shouldStreamSsrChannel(streaming, 'render')).toBe(false) + expect(shouldStreamSsrChannel(streaming, 'head')).toBe(true) + }) + + test('resolver receives request and router', async () => { + const router = makeRouter() + const request = makeRequest(HUMAN_UA) + const resolver = vi.fn(() => ({ render: true })) + + await resolveSsrStreaming({ + request, + router, + streaming: resolver, + }) + + expect(resolver).toHaveBeenCalledWith({ + request, + router, + }) + }) + + test('resolver returning undefined uses the built-in default policy', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + streaming: () => undefined, + }) + + expect(streaming).toEqual({ render: false, head: false }) + }) + + test('async resolver is awaited', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: async () => ({ render: false, head: true }), + }) + + expect(streaming).toEqual({ render: false, head: true }) + }) + + test('request override patches the resolved policy', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + streaming: { + render: true, + }, + streamingOverride: { + head: true, + }, + }) + + expect(streaming).toEqual({ render: true, head: true }) + }) + + test('request override can preserve the default render decision', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + streamingOverride: { + head: true, + }, + }) + + expect(streaming).toEqual({ render: false, head: true }) + }) + + test('request override can replace render after async resolution', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(BOT_UA), + router: makeRouter(), + streaming: async () => ({ render: false }), + streamingOverride: { + render: true, + }, + }) + + expect(streaming).toEqual({ render: true, head: false }) + }) + + test('boolean streaming values are rejected', () => { + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: true as any, + }), + ).toThrow('Invalid ssr.streaming value') + }) + + test('unknown config options are rejected', () => { + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: { + render: false, + rsc: true, + } as any, + }), + ).toThrow('Unknown ssr.streaming option "rsc"') + }) + + test('unknown runtime channel reads are rejected', async () => { + const streaming = await resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: { + render: false, + }, + }) + + expect(() => shouldStreamSsrChannel(streaming, 'toString' as any)).toThrow( + 'Unknown ssr.streaming channel "toString"', + ) + }) + + test('invalid object values throw instead of becoming truthy policy', () => { + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: { + render: 'false', + } as any, + }), + ).toThrow('Expected a boolean') + + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streaming: { + head: false, + } as any, + }), + ).toThrow('Invalid ssr.streaming value for "render"') + }) + + test('invalid request override values throw', () => { + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streamingOverride: true as any, + }), + ).toThrow('Invalid ssr.streaming override value') + + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streamingOverride: { + rsc: true, + } as any, + }), + ).toThrow('Unknown ssr.streaming option "rsc"') + + expect(() => + resolveSsrStreaming({ + request: makeRequest(HUMAN_UA), + router: makeRouter(), + streamingOverride: { + head: 'true', + } as any, + }), + ).toThrow('Expected a boolean') + }) +}) diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 13b0072e24..04d66dc956 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -20,6 +20,11 @@ import { import { createTestRouter } from './routerTestUtils' import type { RouterManagedTag } from '../src/manifest' +const alwaysStream = { + render: true, + head: true, +} + const MAX_LEFTOVER_CHARS = 2048 const MAX_ROUTER_HTML_CHARS = 16 * 1024 * 1024 @@ -206,7 +211,11 @@ describe('transformStreamWithRouter — real SSR scripts', () => { test('flushes stream-end scripts before body close when serialization finishes before transform starts', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -247,7 +256,11 @@ describe('transformStreamWithRouter — real SSR scripts', () => { test('fuses the final resolver and stream-end scripts', async () => { const streamed = createControlledStream() const router = createRealSsrRouter({ streamed: streamed.stream }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -286,7 +299,11 @@ describe('transformStreamWithRouter — real SSR scripts', () => { test('flushes stream-end scripts even when no barrier marker was emitted', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -317,7 +334,11 @@ describe('transformStreamWithRouter — real SSR scripts', () => { test('keeps stream scripts before uppercase body close', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ + router, + manifest: undefined, + streaming: alwaysStream, + }) await router.load() await router.serverSsr!.dehydrate() @@ -897,6 +918,47 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { full.indexOf(''), ) }) + + test('streams app chunks emitted after the document close before final tail', async () => { + const { router, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + + const out = transformStreamWithRouter(router as any, upstream.stream as any) + const reader = (out as any).getReader() + + upstream.push('
    shell
    ') + + const shell = await reader.read() + expect(shell.done).toBe(false) + const shellText = Buffer.from(shell.value).toString('utf8') + expect(shellText).toContain('
    shell
    ') + expect(shellText).not.toContain('') + + upstream.push( + '', + ) + + const reveal = await reader.read() + expect(reveal.done).toBe(false) + const revealText = Buffer.from(reveal.value).toString('utf8') + expect(revealText).toContain('') + expect(revealText).not.toContain('') + + upstream.close() + finishSerialization() + + let rest = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + rest += Buffer.from(value).toString('utf8') + } + + const full = shellText + revealText + rest + expect(full.indexOf('')).toBeLessThan( + full.indexOf(''), + ) + }) }) describe('transformStreamWithRouter — injected HTML ordering', () => { diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json index 6b8e7a2949..c81c597d78 100644 --- a/packages/solid-router/package.json +++ b/packages/solid-router/package.json @@ -107,8 +107,7 @@ "@solid-primitives/refs": "^1.0.8", "@solidjs/meta": "^0.29.4", "@tanstack/history": "workspace:*", - "@tanstack/router-core": "workspace:*", - "isbot": "^5.1.22" + "@tanstack/router-core": "workspace:*" }, "devDependencies": { "@solidjs/testing-library": "^0.8.10", diff --git a/packages/solid-router/src/awaited.tsx b/packages/solid-router/src/awaited.tsx index fb1edb14e0..8646214443 100644 --- a/packages/solid-router/src/awaited.tsx +++ b/packages/solid-router/src/awaited.tsx @@ -31,19 +31,7 @@ export function Await( }, ) { if (!('fallback' in props)) { - const [resource] = Solid.createResource( - () => defer(props.promise), - (p) => p, - { - deferStream: true, - }, - ) - - return ( - - {(data) => props.children(data())} - - ) + return } return ( @@ -64,5 +52,9 @@ function AwaitInner( (p) => p, ) - return props.children(resource() as T) + return ( + + {(data) => props.children(data())} + + ) } diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..853972f01c 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -1,5 +1,4 @@ import * as Solid from 'solid-js/web' -import { isbot } from 'isbot' import { createSsrStreamResponse, transformReadableStreamWithRouter, @@ -11,8 +10,9 @@ import type { AnyRouter } from '@tanstack/router-core' const noop = () => {} -// Bot responses wait for the server renderer before streaming. If the request -// disconnects during that wait, unblock so the pipe can abort and clean up. +// Full-document responses wait for the server renderer before streaming. If +// the request disconnects during that wait, unblock so the pipe can abort and +// clean up. async function waitForReadyOrAbort( ready: Promise, signal: AbortSignal, @@ -44,6 +44,7 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: () => JSXElement }) => { + const shouldStreamRender = router.serverSsr!.shouldStream('render') const { writable, readable } = new TransformStream() const docType = Solid.ssr('') @@ -121,7 +122,7 @@ export const renderRouterToStream = async ({ }) } - if (isbot(request.headers.get('User-Agent'))) { + if (!shouldStreamRender) { await waitForReadyOrAbort( Promise.resolve(stream as unknown), request.signal, diff --git a/packages/solid-router/tests/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index ba5ee51ca0..befb513d0a 100644 --- a/packages/solid-router/tests/renderRouterToStream.test.tsx +++ b/packages/solid-router/tests/renderRouterToStream.test.tsx @@ -19,6 +19,16 @@ vi.mock('solid-js/web', async () => { // Imported after mock so the wrapper picks up the mocked binding. const { renderRouterToStream } = await import('../src/ssr/renderRouterToStream') +const alwaysStream = { + render: true, + head: true, +} + +const neverStream = { + render: false, + head: false, +} + function unwrapResponse( result: Awaited>, ) { @@ -31,7 +41,7 @@ afterEach(() => { vi.restoreAllMocks() }) -async function buildRouter() { +async function buildRouter(streaming = alwaysStream) { const rootRoute = createRootRoute({ component: () => null, }) @@ -40,11 +50,19 @@ async function buildRouter() { routeTree: rootRoute, }) router.isServer = true - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ router, manifest: undefined, streaming }) await router.load() return router } +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + function drainBody(response: Response) { const reader = response.body!.getReader() return (async () => { @@ -56,6 +74,71 @@ function drainBody(response: Response) { } describe('renderRouterToStream - bot abort', () => { + test('render channel=true does not wait for full render', async () => { + const neverReady = new Promise(() => {}) + solidMocks.pipeTo.mockImplementationOnce(() => Promise.resolve()) + solidMocks.renderToStream.mockImplementationOnce( + () => + ({ + then: neverReady.then.bind(neverReady), + pipeTo: solidMocks.pipeTo, + }) as any, + ) + + const router = await buildRouter(alwaysStream) + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ).resolves.toBeDefined() + + expect(solidMocks.pipeTo).toHaveBeenCalled() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('render channel=false waits for full render', async () => { + const ready = deferred() + solidMocks.pipeTo.mockImplementationOnce(() => Promise.resolve()) + solidMocks.renderToStream.mockImplementationOnce( + () => + ({ + then: ready.promise.then.bind(ready.promise), + pipeTo: solidMocks.pipeTo, + }) as any, + ) + + const router = await buildRouter(neverStream) + try { + const renderPromise = renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: () => null, + }) + + await expect( + Promise.race([ + renderPromise.then(() => 'resolved'), + new Promise<'pending'>((resolve) => + setTimeout(() => resolve('pending'), 50), + ), + ]), + ).resolves.toBe('pending') + + ready.resolve() + await expect(renderPromise).resolves.toBeDefined() + expect(solidMocks.pipeTo).toHaveBeenCalled() + } finally { + router.serverSsr?.cleanup() + } + }) + test('request abort during bot wait returns and terminates response stream', async () => { const neverReady = new Promise(() => {}) solidMocks.pipeTo.mockImplementationOnce( @@ -74,7 +157,7 @@ describe('renderRouterToStream - bot abort', () => { }) as any, ) - const router = await buildRouter() + const router = await buildRouter(neverStream) const abortController = new AbortController() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 90d4e706f9..99e5e696c3 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -19,6 +19,7 @@ import { isSsrResponse, normalizeSsrResponse, replaceSsrResponse, + resolveSsrStreaming, stripSsrResponseBody, } from '@tanstack/router-core/ssr/server' import { @@ -56,6 +57,7 @@ import type { HandlerCallback, HandlerCallbackResult, SsrResponse, + SsrStreamingOption, } from '@tanstack/router-core/ssr/server' import type { FinalManifestOptions } from './finalManifest' @@ -65,8 +67,19 @@ type AnyMiddlewareServerFn = | AnyRequestMiddleware['options']['server'] | AnyFunctionMiddleware['options']['server'] +function isPromiseLike(value: T | Promise): value is Promise { + return Boolean( + value && + typeof value === 'object' && + typeof (value as Promise).then === 'function', + ) +} + export interface CreateStartHandlerOptions extends FinalManifestOptions { handler: HandlerCallback + ssr?: { + streaming?: SsrStreamingOption + } } function getStartResponseHeaders(opts: { router: AnyRouter }) { @@ -377,12 +390,17 @@ function handlerToMiddleware( export function createStartHandler( cbOrOptions: HandlerCallback | CreateStartHandlerOptions, ): RequestHandler { - const handlerOptions: FinalManifestOptions = - typeof cbOrOptions === 'function' ? {} : cbOrOptions + const startHandlerOptions = + typeof cbOrOptions === 'function' ? undefined : cbOrOptions const cb: HandlerCallback = typeof cbOrOptions === 'function' ? cbOrOptions : cbOrOptions.handler + const { + handler: _handler, + ssr, + ...finalManifestOptions + } = startHandlerOptions ?? {} const finalManifestResolver = createFinalManifestResolver({ - ...handlerOptions, + ...finalManifestOptions, cacheCreateTransform: process.env.TSS_DEV_SERVER !== 'true', }) const resolveManifestForRequest = @@ -577,9 +595,20 @@ export function createStartHandler( const routerInstance = await getRouter() + const maybeStreaming = resolveSsrStreaming({ + request, + router: routerInstance, + streaming: ssr?.streaming, + streamingOverride: requestOpts?.ssr?.streaming, + }) + const streaming = isPromiseLike(maybeStreaming) + ? await maybeStreaming + : maybeStreaming + attachRouterServerSsrUtils({ router: routerInstance, manifest, + streaming, getRequestAssets: () => getStartContext({ throwIfNotFound: false })?.requestAssets, }) diff --git a/packages/start-server-core/src/index.tsx b/packages/start-server-core/src/index.tsx index ee818e0cf1..d52ad81dd0 100644 --- a/packages/start-server-core/src/index.tsx +++ b/packages/start-server-core/src/index.tsx @@ -17,10 +17,20 @@ export { attachRouterServerSsrUtils, createRequestHandler, defineHandlerCallback, + resolveSsrStreaming, transformReadableStreamWithRouter, transformPipeableStreamWithRouter, } from '@tanstack/router-core/ssr/server' -export type { HandlerCallback } from '@tanstack/router-core/ssr/server' +export type { + HandlerCallback, + ResolvedSsrStreaming, + SsrStreamingChannel, + SsrStreamingConfig, + SsrStreamingOption, + SsrStreamingOverride, + SsrStreamingResolverContext, + SsrStreamingResolverResult, +} from '@tanstack/router-core/ssr/server' export * from './request-response' diff --git a/packages/start-server-core/src/request-handler.ts b/packages/start-server-core/src/request-handler.ts index 0c491e00c9..4da5c57fe4 100644 --- a/packages/start-server-core/src/request-handler.ts +++ b/packages/start-server-core/src/request-handler.ts @@ -1,4 +1,5 @@ import type { OnEarlyHints, ResponseLinkHeaderOptions } from './early-hints' +import type { SsrStreamingOverride } from '@tanstack/router-core/ssr/server' type BaseContext = { nonce?: string @@ -57,8 +58,22 @@ type InlineCssOptions = { inlineCss?: boolean } +type SsrOptions = { + ssr?: { + /** + * Overrides the resolved SSR streaming policy for this request. + * + * This is a partial patch on top of the handler-level or built-in policy. + * For example, `{ head: true }` enables head streaming while preserving + * the existing render streaming decision. + */ + streaming?: SsrStreamingOverride + } +} + export type RequestOptions = EarlyHintsOptions & InlineCssOptions & + SsrOptions & (TRegister extends { server: { requestContext: infer TRequestContext } } diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index 5b7c7d3a7a..a52ea220f3 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -50,14 +50,23 @@ const getStoreConfig = () => ({ batch: (fn: () => void) => fn(), }) -function makeRouter() { +const alwaysStream = { + render: true, + head: true, +} + +function makeRouter(opts?: { onLoad?: (router: any) => void }) { const rootRoute = new BaseRootRoute({}) + let router!: any const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', component: () => null, + loader: () => { + opts?.onLoad?.(router) + }, }) - const router = new RouterCore( + router = new RouterCore( { history: createMemoryHistory({ initialEntries: ['/'] }), routeTree: rootRoute.addChildren([indexRoute]), @@ -69,7 +78,11 @@ function makeRouter() { } function makeStreamResponse(router: ReturnType) { - attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + attachRouterServerSsrUtils({ + router: router as any, + manifest: undefined, + streaming: alwaysStream, + }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')) @@ -325,6 +338,145 @@ describe('createStartHandler SSR cleanup ownership', () => { }) }) +describe('createStartHandler streaming policy', () => { + it('resolves streaming before router.load', async () => { + const seenInLoader: Array<{ render: boolean; head: boolean }> = [] + const router = makeRouter({ + onLoad: (requestRouter) => { + seenInLoader.push({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }) + }, + }) + startMocks.router = router + + const handler = createStartHandler({ + handler: () => new Response('ok'), + ssr: { + streaming: { + render: false, + head: true, + }, + }, + }) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(200) + expect(seenInLoader).toEqual([ + { + render: false, + head: true, + }, + ]) + }) + + it('resolver can use request headers', async () => { + const router = makeRouter() + startMocks.router = router + + const handler = createStartHandler({ + handler: ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(false) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(false) + return new Response('ok') + }, + ssr: { + streaming: ({ request }) => { + if (request.headers.get('x-streaming') === 'none') { + return { render: false } + } + return undefined + }, + }, + }) + + const response = await handler( + new Request('http://localhost/', { + headers: { + 'x-streaming': 'none', + }, + }), + {}, + ) + + expect(response.status).toBe(200) + }) + + it('request options patch the handler-level streaming policy', async () => { + const seenInLoader: Array<{ render: boolean; head: boolean }> = [] + const router = makeRouter({ + onLoad: (requestRouter) => { + seenInLoader.push({ + render: requestRouter.serverSsr!.shouldStream('render'), + head: requestRouter.serverSsr!.shouldStream('head'), + }) + }, + }) + startMocks.router = router + + const handler = createStartHandler({ + handler: ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(false) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(true) + return new Response('ok') + }, + ssr: { + streaming: { + render: false, + }, + }, + }) + + const response = await handler(new Request('http://localhost/'), { + ssr: { + streaming: { + head: true, + }, + }, + }) + + expect(response.status).toBe(200) + expect(seenInLoader).toEqual([ + { + render: false, + head: true, + }, + ]) + }) + + it('request options can preserve the built-in render policy', async () => { + const router = makeRouter() + startMocks.router = router + + const handler = createStartHandler({ + handler: ({ router: requestRouter }) => { + expect(requestRouter.serverSsr!.shouldStream('render')).toBe(false) + expect(requestRouter.serverSsr!.shouldStream('head')).toBe(true) + return new Response('ok') + }, + }) + + const response = await handler( + new Request('http://localhost/', { + headers: { + 'user-agent': 'Mozilla/5.0 compatible Googlebot/2.1', + }, + }), + { + ssr: { + streaming: { + head: true, + }, + }, + }, + ) + + expect(response.status).toBe(200) + }) +}) + describe('createStartHandler inlineCss option', () => { const request = new Request('https://example.com/') diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index 9e429bb0b1..91faac7217 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -76,8 +76,7 @@ "@tanstack/history": "workspace:*", "@tanstack/router-core": "workspace:*", "@tanstack/vue-store": "^0.9.3", - "@vue/runtime-dom": "^3.5.25", - "isbot": "^5.1.22" + "@vue/runtime-dom": "^3.5.25" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..2d9417b332 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -1,7 +1,6 @@ import { ReadableStream as NodeReadableStream } from 'node:stream/web' import * as Vue from 'vue' import { pipeToWebWritable, renderToString } from 'vue/server-renderer' -import { isbot } from 'isbot' import { createSsrStreamResponse, transformReadableStreamWithRouter, @@ -75,8 +74,9 @@ export const renderRouterToStream = async ({ App: Component }) => { const app = Vue.createSSRApp(App, { router }) + const shouldStreamRender = router.serverSsr!.shouldStream('render') - if (isbot(request.headers.get('User-Agent'))) { + if (!shouldStreamRender) { try { let cleanupAbortListener: (() => void) | undefined const abortPromise = new Promise((_, reject) => { @@ -185,18 +185,6 @@ export const renderRouterToStream = async ({ }, }) - // `pipeToWebWritable` returns void (see @vue/server-renderer). Pass a - // proxy writable so request aborts can abort the real TransformStream - // writer even while Vue holds a lock on the proxy writable. - try { - pipeToWebWritable(app, {}, vueWritable) - } catch (err) { - console.error('Error in Vue pipeToWebWritable:', err) - // Setup failed before any pipe was wired; abort writable so the - // readable side errors instead of hanging until the lifetime timeout. - abortVuePipe(err) - } - if (request.signal.aborted) { abortVuePipe(request.signal.reason) } else { @@ -207,6 +195,20 @@ export const renderRouterToStream = async ({ }) } + // `pipeToWebWritable` returns void (see @vue/server-renderer). Pass a + // proxy writable so request aborts can abort the real TransformStream + // writer even while Vue holds a lock on the proxy writable. + if (!request.signal.aborted) { + try { + pipeToWebWritable(app, {}, vueWritable) + } catch (err) { + console.error('Error in Vue pipeToWebWritable:', err) + // Setup failed before any pipe was wired; abort writable so the + // readable side errors instead of hanging until the lifetime timeout. + abortVuePipe(err) + } + } + const doctypedStream = prependDoctype(readable) const responseStream = transformReadableStreamWithRouter( router, diff --git a/packages/vue-router/tests/renderRouterToStream.test.tsx b/packages/vue-router/tests/renderRouterToStream.test.tsx index 527bfa0e03..407b9a1c7b 100644 --- a/packages/vue-router/tests/renderRouterToStream.test.tsx +++ b/packages/vue-router/tests/renderRouterToStream.test.tsx @@ -25,6 +25,16 @@ vi.mock('vue/server-renderer', async () => { // Imported after mock so the wrapper picks up the mocked binding. const { renderRouterToStream } = await import('../src/ssr/renderRouterToStream') +const alwaysStream = { + render: true, + head: true, +} + +const neverStream = { + render: false, + head: false, +} + function unwrapResponse( result: Awaited>, ) { @@ -37,7 +47,7 @@ afterEach(() => { vi.restoreAllMocks() }) -async function buildRouter() { +async function buildRouter(streaming = alwaysStream) { const rootRoute = createRootRoute({ component: { template: '
    ' } as any, }) @@ -46,7 +56,7 @@ async function buildRouter() { routeTree: rootRoute, }) router.isServer = true - attachRouterServerSsrUtils({ router, manifest: undefined }) + attachRouterServerSsrUtils({ router, manifest: undefined, streaming }) await router.load() return router } @@ -62,11 +72,54 @@ function drainBody(response: Response) { } describe('renderRouterToStream - sync setup failures', () => { - test('bot string response injects final scripts and cleans up', async () => { + test('render channel=true uses streaming renderer', async () => { + rendererMocks.pipeToWebWritable.mockImplementationOnce(() => {}) + const router = await buildRouter(alwaysStream) + + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { template: '
    ' } as any, + }), + ) + + expect(response).toBeDefined() + expect(rendererMocks.pipeToWebWritable).toHaveBeenCalledOnce() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('render channel=false uses full-document renderer', async () => { + rendererMocks.renderToString.mockResolvedValueOnce( + '
    full
    ', + ) + const router = await buildRouter(neverStream) + await router.serverSsr!.dehydrate() + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { template: '
    ' } as any, + }), + ) + + expect(await response.text()).toContain('
    full
    ') + expect(rendererMocks.renderToString).toHaveBeenCalledOnce() + expect(rendererMocks.pipeToWebWritable).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + }) + + test('blocked render full response injects final scripts and cleans up', async () => { rendererMocks.renderToString.mockResolvedValueOnce( '
    bot
    ', ) - const router = await buildRouter() + const router = await buildRouter(neverStream) await router.serverSsr!.dehydrate() const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a7c3a7348..b90c4747ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,7 +78,7 @@ importers: version: 1.26.2(eslint@9.22.0(jiti@2.7.0))(ts-api-utils@2.4.0(typescript@6.0.2))(typescript@6.0.2) '@nx/devkit': specifier: 22.7.5 - version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))) + version: 22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3)) '@playwright/test': specifier: ^1.57.0 version: 1.58.0 @@ -138,7 +138,7 @@ importers: version: 4.0.3 nx: specifier: 22.7.5 - version: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23)) + version: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) prettier: specifier: ^3.8.0 version: 3.8.1 @@ -281,7 +281,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^5.5.0 - version: 5.5.0(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) + version: 5.5.0(debug@4.4.3)(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) '@platformatic/flame': specifier: ^1.6.0 version: 1.6.0 @@ -342,7 +342,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^5.5.0 - version: 5.5.0(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) + version: 5.5.0(debug@4.4.3)(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) '@datadog/pprof': specifier: ^5.13.2 version: 5.13.2 @@ -415,7 +415,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^5.5.0 - version: 5.5.0(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) + version: 5.5.0(debug@4.4.3)(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) '@datadog/pprof': specifier: ^5.13.2 version: 5.13.2 @@ -476,7 +476,7 @@ importers: devDependencies: '@codspeed/vitest-plugin': specifier: ^5.5.0 - version: 5.5.0(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) + version: 5.5.0(debug@4.4.3)(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4) '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -2216,7 +2216,7 @@ importers: version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^0.11.9 version: 0.11.12 @@ -2314,7 +2314,7 @@ importers: version: 9.2.1 nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: ^6.0.2 version: 6.0.2 @@ -3350,7 +3350,7 @@ importers: version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) typescript: specifier: ^6.0.2 version: 6.0.2 @@ -3440,6 +3440,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^0.11.9 version: 0.11.12 @@ -5189,6 +5192,46 @@ importers: specifier: ^2.11.11 version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/streaming-ssr: + dependencies: + '@tanstack/solid-query': + specifier: ^5.99.0 + version: 5.99.0(solid-js@1.9.12) + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/solid-router-ssr-query + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.12 + version: 1.9.12 + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/virtual-routes: dependencies: '@tanstack/solid-router': @@ -6993,6 +7036,46 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/streaming-ssr: + dependencies: + '@tanstack/vue-query': + specifier: ^5.99.0 + version: 5.99.0(vue@3.5.25(typescript@6.0.2)) + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../../packages/vue-router + '@tanstack/vue-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/vue-router-ssr-query + '@tanstack/vue-start': + specifier: workspace:* + version: link:../../../packages/vue-start + vue: + specifier: ^3.5.16 + version: 3.5.25(typescript@6.0.2) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.5 + version: 5.1.5(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@6.0.2)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/virtual-routes: dependencies: '@tanstack/virtual-file-routes': @@ -9065,7 +9148,7 @@ importers: version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -9807,7 +9890,7 @@ importers: version: 0.5.20(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.260522-beta(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: nitro-nightly@4.0.0-20251010-091516-7cafddba(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.9.2)(lru-cache@11.5.1)(mysql2@3.15.3)(rolldown@1.1.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.1.18 version: 4.2.2 @@ -11556,7 +11639,7 @@ importers: version: 25.0.9 nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -11768,7 +11851,7 @@ importers: version: 25.0.9 nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -12594,9 +12677,6 @@ importers: '@tanstack/router-core': specifier: workspace:* version: link:../router-core - isbot: - specifier: ^5.1.22 - version: 5.1.28 devDependencies: '@testing-library/jest-dom': specifier: ^6.6.3 @@ -12869,6 +12949,9 @@ importers: cookie-es: specifier: ^3.0.0 version: 3.1.1 + isbot: + specifier: ^5.1.22 + version: 5.1.28 seroval: specifier: ^1.5.4 version: 1.5.4 @@ -13104,9 +13187,6 @@ importers: '@tanstack/router-core': specifier: workspace:* version: link:../router-core - isbot: - specifier: ^5.1.22 - version: 5.1.28 devDependencies: '@solidjs/testing-library': specifier: ^0.8.10 @@ -13405,7 +13485,7 @@ importers: version: 0.1.7 h3-v2: specifier: npm:h3@2.0.1-rc.20 - version: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)) + version: h3@2.0.1-rc.20(crossws@0.4.6(srvx@0.11.17)) seroval: specifier: ^1.5.4 version: 1.5.4 @@ -13502,9 +13582,6 @@ importers: '@vue/runtime-dom': specifier: ^3.5.25 version: 3.5.25 - isbot: - specifier: ^5.1.22 - version: 5.1.28 devDependencies: '@testing-library/jest-dom': specifier: ^6.6.3 @@ -14527,12 +14604,18 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.4.5': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -14542,6 +14625,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -16426,6 +16512,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -16796,6 +16888,9 @@ packages: '@oxc-project/types@0.132.0': resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] @@ -17963,6 +18058,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -17975,6 +18076,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.9': resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -17987,6 +18094,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -17999,6 +18112,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18011,6 +18130,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18025,6 +18150,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18039,6 +18171,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18053,6 +18192,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18067,6 +18213,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18081,6 +18234,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18095,6 +18255,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18107,6 +18274,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} engines: {node: '>=14.0.0'} @@ -18117,6 +18290,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18129,6 +18307,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -18141,6 +18325,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0': resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} @@ -19469,6 +19659,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -21284,8 +21477,8 @@ packages: srvx: optional: true - crossws@0.4.5: - resolution: {integrity: sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA==} + crossws@0.4.6: + resolution: {integrity: sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg==} peerDependencies: srvx: '>=0.11.5' peerDependenciesMeta: @@ -21793,21 +21986,6 @@ packages: miniflare: optional: true - env-runner@0.1.9: - resolution: {integrity: sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg==} - hasBin: true - peerDependencies: - '@netlify/runtime': ^4.1.23 - '@vercel/queue': ^0.2.0 - miniflare: ^4.20260515.0 - peerDependenciesMeta: - '@netlify/runtime': - optional: true - '@vercel/queue': - optional: true - miniflare: - optional: true - envinfo@7.14.0: resolution: {integrity: sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==} engines: {node: '>=4'} @@ -22586,18 +22764,17 @@ packages: crossws: optional: true - h3@2.0.1-rc.20: - resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} + h3@2.0.1-rc.2: + resolution: {integrity: sha512-2vS7OETzPDzGQxmmcs6ttu7p0NW25zAdkPXYOr43dn4GZf81uUljJvupa158mcpUGpsQUqIy4O4THWUQT1yVeA==} engines: {node: '>=20.11.1'} - hasBin: true peerDependencies: crossws: ^0.4.1 peerDependenciesMeta: crossws: optional: true - h3@2.0.1-rc.22: - resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} + h3@2.0.1-rc.20: + resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} hasBin: true peerDependencies: @@ -22676,9 +22853,6 @@ packages: hookable@6.1.0: resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} - hookable@6.1.1: - resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} - hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -22787,9 +22961,6 @@ packages: httpxy@0.3.1: resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} - httpxy@0.5.3: - resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==} - human-id@4.1.1: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true @@ -23875,45 +24046,30 @@ packages: netlify-redirector@0.5.0: resolution: {integrity: sha512-4zdzIP+6muqPCuE8avnrgDJ6KW/2+UpHTRcTbMXCIRxiRmyrX+IZ4WSJGZdHPWF3WmQpXpy603XxecZ9iygN7w==} + nf3@0.1.12: + resolution: {integrity: sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ==} + nf3@0.3.11: resolution: {integrity: sha512-ObKp/SA3f1g1f/OMeDlRWaZmqGgk7A0NnDIbeO7c/MV4r/quMlpP/BsqMGuTi3lUlXbC1On8YH7ICM2u2bIAOw==} - nf3@0.3.17: - resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==} - nf3@0.3.6: resolution: {integrity: sha512-/XRUUILTAyuy1XunyVQuqGp8aEmZ2TfRTn8Rji+FA4xqv20qzL4jV7Reqbuey2XucKgPeRVcEYGScmJM0UnB6Q==} - nitro-nightly@3.0.260522-beta: - resolution: {integrity: sha512-Pm0AiQ1nLcreUFZKJcmVI4l9K/ygXoFamIPhc44XJHj9vmt25Rlqjv55frw6sS0L1qHrySpo3xyVVBmR9aTBqA==} + nitro-nightly@4.0.0-20251010-091516-7cafddba: + resolution: {integrity: sha512-biADkmoR/Nb9OmUURTfDI2BpGo2IMEt2RbsiMhjqdwz2w3tEm+tx0Hd28LXjBCwid+l8jpqyfmpwc8jzwdng3w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@vercel/queue': ^0.2.0 - dotenv: '*' - giget: '*' - jiti: ^2.6.1 - rollup: ^4.60.3 + rolldown: '*' vite: ^8.0.14 xml2js: ^0.6.2 - zephyr-agent: ^0.2.0 peerDependenciesMeta: - '@vercel/queue': - optional: true - dotenv: - optional: true - giget: - optional: true - jiti: - optional: true - rollup: + rolldown: optional: true vite: optional: true xml2js: optional: true - zephyr-agent: - optional: true nitro@3.0.1-alpha.2: resolution: {integrity: sha512-YviDY5J/trS821qQ1fpJtpXWIdPYiOizC/meHavlm1Hfuhx//H+Egd1+4C5SegJRgtWMnRPW9n//6Woaw81cTQ==} @@ -24117,9 +24273,6 @@ packages: ocache@0.1.2: resolution: {integrity: sha512-lI34wjM7cahEdrq2I5obbF7MEdE97vULf6vNj6ZCzwEadzyXO1w7QOl2qzzG4IL8cyO7wDtXPj9CqW/aG3mn7g==} - ocache@0.1.4: - resolution: {integrity: sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ==} - ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -24923,6 +25076,10 @@ packages: renderkid@3.0.0: resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + rendu@0.0.6: + resolution: {integrity: sha512-nZ512Dw0MxKiIYfCVv8DPe6ig4m0Qt3FOYBJEXrammjIYBBPuHaudc0AGfYx+iyOw2q0itAtPywiVZXtTFCsig==} + hasBin: true + repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -25019,6 +25176,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-visualizer@6.0.5: resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} engines: {node: '>=18'} @@ -25432,6 +25594,16 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.17: + resolution: {integrity: sha512-43yM4luKfCJamyCMhrUeHUPOrf8TdZe7kN8s5zayZCH5OeprYqi49Aso5ZvHXR4aB+DHaRNO/diNFgZSMNG8Xw==} + engines: {node: '>=20.16.0'} + hasBin: true + + srvx@0.8.16: + resolution: {integrity: sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ==} + engines: {node: '>=20.16.0'} + hasBin: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -25843,6 +26015,7 @@ packages: tsconfck@3.1.4: resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -26005,6 +26178,9 @@ packages: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.21: + resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -26120,32 +26296,32 @@ packages: uploadthing: optional: true - unstorage@2.0.0-alpha.5: - resolution: {integrity: sha512-Sj8btci21Twnd6M+N+MHhjg3fVn6lAPElPmvFTe0Y/wR0WImErUdA1PzlAaUavHylJ7uDiFwlZDQKm0elG4b7g==} + unstorage@2.0.0-alpha.3: + resolution: {integrity: sha512-BeoqISVh8jxqnPseHH7/92twe2VkQztrudXg8RFZVbXb4ckkFdpLk1LnNvsUndDltyodBMVxgI6V7JcbJYt2VQ==} peerDependencies: - '@azure/app-configuration': ^1.9.0 - '@azure/cosmos': ^4.7.0 - '@azure/data-tables': ^13.3.1 - '@azure/identity': ^4.13.0 - '@azure/keyvault-secrets': ^4.10.0 - '@azure/storage-blob': ^12.29.1 + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 '@capacitor/preferences': ^6.0.3 || ^7.0.0 - '@deno/kv': '>=0.12.0' + '@deno/kv': '>=0.9.0' '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.35.6 - '@vercel/blob': '>=0.27.3' + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' '@vercel/functions': ^2.2.12 || ^3.0.0 '@vercel/kv': ^1.0.1 aws4fetch: ^1.0.20 - chokidar: ^4 || ^5 - db0: '>=0.3.4' - idb-keyval: ^6.2.2 - ioredis: ^5.8.2 + chokidar: ^4.0.3 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 lru-cache: ^11.2.2 - mongodb: ^6 || ^7 - ofetch: '*' - uploadthing: ^7.7.4 + mongodb: ^6.20.0 + ofetch: ^1.4.1 + uploadthing: ^7.4.4 peerDependenciesMeta: '@azure/app-configuration': optional: true @@ -26194,20 +26370,20 @@ packages: uploadthing: optional: true - unstorage@2.0.0-alpha.6: - resolution: {integrity: sha512-w5vLYCJtnSx3OBtDk7cG4c1p3dfAnHA4WSZq9Xsurjbl2wMj7zqfOIjaHQI1Bl7yKzUxXAi+kbMr8iO2RhJmBA==} + unstorage@2.0.0-alpha.5: + resolution: {integrity: sha512-Sj8btci21Twnd6M+N+MHhjg3fVn6lAPElPmvFTe0Y/wR0WImErUdA1PzlAaUavHylJ7uDiFwlZDQKm0elG4b7g==} peerDependencies: - '@azure/app-configuration': ^1.11.0 - '@azure/cosmos': ^4.9.1 - '@azure/data-tables': ^13.3.2 + '@azure/app-configuration': ^1.9.0 + '@azure/cosmos': ^4.7.0 + '@azure/data-tables': ^13.3.1 '@azure/identity': ^4.13.0 '@azure/keyvault-secrets': ^4.10.0 - '@azure/storage-blob': ^12.31.0 - '@capacitor/preferences': ^6 || ^7 || ^8 - '@deno/kv': '>=0.13.0' + '@azure/storage-blob': ^12.29.1 + '@capacitor/preferences': ^6.0.3 || ^7.0.0 + '@deno/kv': '>=0.12.0' '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.36.2 + '@upstash/redis': ^1.35.6 '@vercel/blob': '>=0.27.3' '@vercel/functions': ^2.2.12 || ^3.0.0 '@vercel/kv': ^1.0.1 @@ -26215,8 +26391,8 @@ packages: chokidar: ^4 || ^5 db0: '>=0.3.4' idb-keyval: ^6.2.2 - ioredis: ^5.9.3 - lru-cache: ^11.2.6 + ioredis: ^5.8.2 + lru-cache: ^11.2.2 mongodb: ^6 || ^7 ofetch: '*' uploadthing: ^7.7.4 @@ -26268,8 +26444,8 @@ packages: uploadthing: optional: true - unstorage@2.0.0-alpha.7: - resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==} + unstorage@2.0.0-alpha.6: + resolution: {integrity: sha512-w5vLYCJtnSx3OBtDk7cG4c1p3dfAnHA4WSZq9Xsurjbl2wMj7zqfOIjaHQI1Bl7yKzUxXAi+kbMr8iO2RhJmBA==} peerDependencies: '@azure/app-configuration': ^1.11.0 '@azure/cosmos': ^4.9.1 @@ -27700,7 +27876,7 @@ snapshots: '@bramus/specificity@2.4.2': dependencies: - css-tree: 3.1.0 + css-tree: 3.2.1 '@bundled-es-modules/cookie@2.0.1': dependencies: @@ -27708,7 +27884,7 @@ snapshots: '@bundled-es-modules/statuses@1.0.1': dependencies: - statuses: 2.0.1 + statuses: 2.0.2 '@bundled-es-modules/tough-cookie@0.1.6': dependencies: @@ -28019,9 +28195,9 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260317.1': optional: true - '@codspeed/core@5.5.0': + '@codspeed/core@5.5.0(debug@4.4.3)': dependencies: - axios: 1.17.0 + axios: 1.17.0(debug@4.4.3) find-up: 6.3.0 form-data: 4.0.5 node-gyp-build: 4.8.4 @@ -28030,9 +28206,9 @@ snapshots: - debug - supports-color - '@codspeed/vitest-plugin@5.5.0(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4)': + '@codspeed/vitest-plugin@5.5.0(debug@4.4.3)(tinybench@2.9.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vitest@4.1.4)': dependencies: - '@codspeed/core': 5.5.0 + '@codspeed/core': 5.5.0(debug@4.4.3) tinybench: 2.9.0 vite: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) vitest: 4.1.4(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.7.0(@types/node@25.0.9)(typescript@6.0.2))(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -28177,6 +28353,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.4.5': dependencies: '@emnapi/wasi-threads': 1.0.4 @@ -28187,6 +28369,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 @@ -28200,6 +28387,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 @@ -29388,7 +29580,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.4': @@ -29875,6 +30067,20 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@neon-rs/load@0.0.4': {} '@netlify/api@14.0.7': @@ -30236,13 +30442,13 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.0 - '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23)))': + '@nx/devkit@22.7.5(nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.5 - nx: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23)) + nx: 22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3) semver: 7.8.2 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -30376,6 +30582,9 @@ snapshots: '@oxc-project/types@0.132.0': {} + '@oxc-project/types@0.137.0': + optional: true + '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -31528,72 +31737,108 @@ snapshots: '@rolldown/binding-android-arm64@1.0.2': optional: true + '@rolldown/binding-android-arm64@1.1.3': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': optional: true '@rolldown/binding-darwin-arm64@1.0.2': optional: true + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.9': optional: true '@rolldown/binding-darwin-x64@1.0.2': optional: true + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': optional: true '@rolldown/binding-freebsd-x64@1.0.2': optional: true + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.2': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.2': optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': optional: true '@rolldown/binding-linux-arm64-musl@1.0.2': optional: true + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': optional: true '@rolldown/binding-linux-ppc64-gnu@1.0.2': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': optional: true '@rolldown/binding-linux-s390x-gnu@1.0.2': optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': optional: true '@rolldown/binding-linux-x64-gnu@1.0.2': optional: true + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': optional: true '@rolldown/binding-linux-x64-musl@1.0.2': optional: true + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': optional: true '@rolldown/binding-openharmony-arm64@1.0.2': optional: true + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -31602,6 +31847,14 @@ snapshots: - '@emnapi/runtime' optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@rolldown/binding-wasm32-wasi@1.0.2': dependencies: '@emnapi/core': 1.10.0 @@ -31609,18 +31862,31 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.2': optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': optional: true '@rolldown/binding-win32-x64-msvc@1.0.2': optional: true + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + '@rolldown/pluginutils@1.0.0': {} '@rolldown/pluginutils@1.0.0-beta.19': {} @@ -32920,6 +33186,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -33551,7 +33822,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.4.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -34050,7 +34321,7 @@ snapshots: '@vue/compiler-core@3.5.25': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@vue/shared': 3.5.25 entities: 4.5.0 estree-walker: 2.0.2 @@ -34063,14 +34334,14 @@ snapshots: '@vue/compiler-sfc@3.5.25': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@vue/compiler-core': 3.5.25 '@vue/compiler-dom': 3.5.25 '@vue/compiler-ssr': 3.5.25 '@vue/shared': 3.5.25 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.8 + postcss: 8.5.15 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.25': @@ -34597,7 +34868,7 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axios@1.16.0: + axios@1.16.0(debug@4.4.3): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 @@ -34605,7 +34876,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.17.0: + axios@1.17.0(debug@4.4.3): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.5 @@ -35363,9 +35634,14 @@ snapshots: optionalDependencies: srvx: 0.11.15 - crossws@0.4.5(srvx@0.11.15): + crossws@0.4.6(srvx@0.11.17): optionalDependencies: - srvx: 0.11.15 + srvx: 0.11.17 + optional: true + + crossws@0.4.6(srvx@0.8.16): + optionalDependencies: + srvx: 0.8.16 css-loader@7.1.2(webpack@5.97.1): dependencies: @@ -35826,13 +36102,6 @@ snapshots: optionalDependencies: miniflare: 4.20260317.0 - env-runner@0.1.9: - dependencies: - crossws: 0.4.5(srvx@0.11.15) - exsolve: 1.0.8 - httpxy: 0.5.3 - srvx: 0.11.15 - envinfo@7.14.0: {} environment@1.1.0: {} @@ -36946,19 +37215,21 @@ snapshots: optionalDependencies: crossws: 0.4.4(srvx@0.11.15) - h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)): + h3@2.0.1-rc.2(crossws@0.4.6(srvx@0.8.16)): dependencies: - rou3: 0.8.1 - srvx: 0.11.15 + cookie-es: 2.0.0 + fetchdts: 0.1.7 + rou3: 0.7.12 + srvx: 0.8.16 optionalDependencies: - crossws: 0.4.5(srvx@0.11.15) + crossws: 0.4.6(srvx@0.8.16) - h3@2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)): + h3@2.0.1-rc.20(crossws@0.4.6(srvx@0.11.17)): dependencies: rou3: 0.8.1 srvx: 0.11.15 optionalDependencies: - crossws: 0.4.5(srvx@0.11.15) + crossws: 0.4.6(srvx@0.11.17) handle-thing@2.0.1: {} @@ -37014,8 +37285,6 @@ snapshots: hookable@6.1.0: {} - hookable@6.1.1: {} - hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 @@ -37166,8 +37435,6 @@ snapshots: httpxy@0.3.1: {} - httpxy@0.5.3: {} - human-id@4.1.1: {} human-signals@5.0.0: {} @@ -37281,7 +37548,7 @@ snapshots: pathe: 2.0.3 sharp: 0.34.4 svgo: 4.0.0 - ufo: 1.6.1 + ufo: 1.6.3 unstorage: 1.17.4(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2) xss: 1.0.15 transitivePeerDependencies: @@ -38241,32 +38508,33 @@ snapshots: netlify-redirector@0.5.0: {} - nf3@0.3.11: {} + nf3@0.1.12: {} - nf3@0.3.17: {} + nf3@0.3.11: {} nf3@0.3.6: {} - nitro-nightly@3.0.260522-beta(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)): + nitro-nightly@4.0.0-20251010-091516-7cafddba(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@4.0.3)(ioredis@5.9.2)(lru-cache@11.5.1)(mysql2@3.15.3)(rolldown@1.1.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: consola: 3.4.2 - crossws: 0.4.5(srvx@0.11.15) + cookie-es: 2.0.0 + crossws: 0.4.6(srvx@0.8.16) db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) - env-runner: 0.1.9 - h3: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) - hookable: 6.1.1 - nf3: 0.3.17 - ocache: 0.1.4 - ofetch: 2.0.0-alpha.3 + esbuild: 0.25.10 + fetchdts: 0.1.7 + h3: 2.0.1-rc.2(crossws@0.4.6(srvx@0.8.16)) + jiti: 2.7.0 + nf3: 0.1.12 + ofetch: 1.5.1 ohash: 2.0.11 - rolldown: 1.0.2 - srvx: 0.11.15 - unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + rendu: 0.0.6 + rollup: 4.56.0 + srvx: 0.8.16 + undici: 7.27.2 + unenv: 2.0.0-rc.21 + unstorage: 2.0.0-alpha.3(@netlify/blobs@10.1.0)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.5.1)(ofetch@1.5.1) optionalDependencies: - dotenv: 17.4.2 - giget: 2.0.0 - jiti: 2.7.0 + rolldown: 1.1.3 vite: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' @@ -38280,7 +38548,6 @@ snapshots: - '@electric-sql/pglite' - '@libsql/client' - '@netlify/blobs' - - '@netlify/runtime' - '@planetscale/database' - '@upstash/redis' - '@vercel/blob' @@ -38293,7 +38560,6 @@ snapshots: - idb-keyval - ioredis - lru-cache - - miniflare - mongodb - mysql2 - sqlite3 @@ -38403,6 +38669,59 @@ snapshots: - sqlite3 - uploadthing + nitro@3.0.260311-beta(@electric-sql/pglite@0.3.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(miniflare@4.20260317.0)(mysql2@3.15.3)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)): + dependencies: + consola: 3.4.2 + crossws: 0.4.4(srvx@0.11.15) + db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) + env-runner: 0.1.6(miniflare@4.20260317.0) + h3: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.15)) + hookable: 6.1.0 + nf3: 0.3.11 + ocache: 0.1.2 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.0.0-rc.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + srvx: 0.11.15 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.6(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.4.2 + giget: 2.0.0 + jiti: 2.7.0 + vite: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@emnapi/core' + - '@emnapi/runtime' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + nitropack@2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(mysql2@3.15.3)(rolldown@1.0.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -38585,7 +38904,7 @@ snapshots: nwsapi@2.2.16: {} - nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23)): + nx@22.7.5(@swc-node/register@1.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@swc/core@1.15.33(@swc/helpers@0.5.23))(@swc/types@0.1.26)(typescript@6.0.2))(@swc/core@1.15.33(@swc/helpers@0.5.23))(debug@4.4.3): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -38600,7 +38919,7 @@ snapshots: ansi-styles: 4.3.0 argparse: 2.0.1 asynckit: 0.4.0 - axios: 1.16.0 + axios: 1.16.0(debug@4.4.3) balanced-match: 4.0.3 base64-js: 1.5.1 bl: 4.1.0 @@ -38751,10 +39070,6 @@ snapshots: dependencies: ohash: 2.0.11 - ocache@0.1.4: - dependencies: - ohash: 2.0.11 - ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -39697,6 +40012,11 @@ snapshots: lodash: 4.17.21 strip-ansi: 6.0.1 + rendu@0.0.6: + dependencies: + cookie-es: 2.0.0 + srvx: 0.8.16 + repeat-string@1.6.1: {} require-directory@2.1.1: {} @@ -39787,6 +40107,30 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + rolldown@1.0.0-rc.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.9 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + rolldown@1.0.2: dependencies: '@oxc-project/types': 0.132.0 @@ -39808,6 +40152,28 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.2 '@rolldown/binding-win32-x64-msvc': 1.0.2 + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + optional: true + rollup-plugin-visualizer@6.0.5(rolldown@1.0.2)(rollup@4.56.0): dependencies: open: 8.4.2 @@ -40329,6 +40695,11 @@ snapshots: srvx@0.11.15: {} + srvx@0.11.17: + optional: true + + srvx@0.8.16: {} + stable-hash-x@0.2.0: {} stack-trace@0.0.10: {} @@ -40850,6 +41221,14 @@ snapshots: undici@7.27.2: {} + unenv@2.0.0-rc.21: + dependencies: + defu: 6.1.4 + exsolve: 1.0.8 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.3 + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -40951,24 +41330,25 @@ snapshots: db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) ioredis: 5.9.2 - unstorage@2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.3(@netlify/blobs@10.1.0)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.5.1)(ofetch@1.5.1): optionalDependencies: '@netlify/blobs': 10.1.0 - chokidar: 5.0.0 + chokidar: 4.0.3 db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) ioredis: 5.9.2 lru-cache: 11.5.1 - ofetch: 2.0.0-alpha.3 + ofetch: 1.5.1 - unstorage@2.0.0-alpha.6(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: '@netlify/blobs': 10.1.0 chokidar: 5.0.0 db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) + ioredis: 5.9.2 lru-cache: 11.5.1 ofetch: 2.0.0-alpha.3 - unstorage@2.0.0-alpha.7(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.6(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: '@netlify/blobs': 10.1.0 chokidar: 5.0.0