diff --git a/e2e/solid-start/basic/src/raw-stream-fns.ts b/e2e/solid-start/basic/src/raw-stream-fns.ts new file mode 100644 index 00000000000..3c2f4fca09f --- /dev/null +++ b/e2e/solid-start/basic/src/raw-stream-fns.ts @@ -0,0 +1,480 @@ +import { RawStream, createServerFn } from '@tanstack/solid-start' + +// Helper to create a delayed Uint8Array stream +function createDelayedStream( + chunks: Array, + delayMs: number, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + controller.enqueue(chunk) + } + controller.close() + }, + }) +} + +// Helper to create a stream with variable delays per chunk +// Each entry is [chunk, delayBeforeMs] - delay happens BEFORE enqueueing the chunk +function createVariableDelayStream( + chunksWithDelays: Array<[Uint8Array, number]>, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + for (const [chunk, delayMs] of chunksWithDelays) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + controller.enqueue(chunk) + } + controller.close() + }, + }) +} + +// Helper to encode text to Uint8Array +function encode(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +// Export helpers for use in components and SSR routes +export { encode, createDelayedStream, concatBytes } + +// Expected data for hint tests - defined here for both server and client verification +// Test 7: Text hint with pure text +export const TEST7_CHUNKS = [ + encode('Hello, '), + encode('World! '), + encode('This is text.'), +] +export const TEST7_EXPECTED = concatBytes(TEST7_CHUNKS) + +// Test 8: Text hint with pure binary (invalid UTF-8) +export const TEST8_CHUNKS = [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), +] +export const TEST8_EXPECTED = concatBytes(TEST8_CHUNKS) + +// Test 9: Text hint with mixed content +export const TEST9_CHUNKS = [ + encode('Valid UTF-8 text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' More text'), +] +export const TEST9_EXPECTED = concatBytes(TEST9_CHUNKS) + +// Test 10: Binary hint with text data +export const TEST10_CHUNKS = [encode('This is text but using binary hint')] +export const TEST10_EXPECTED = concatBytes(TEST10_CHUNKS) + +// Test 11: Binary hint with pure binary +export const TEST11_CHUNKS = [ + new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]), +] +export const TEST11_EXPECTED = concatBytes(TEST11_CHUNKS) + +// Helper to concatenate byte arrays +function concatBytes(chunks: Array): Uint8Array { + const totalLength = chunks.reduce((acc, c) => acc + c.length, 0) + const result = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.length + } + return result +} + +// Test 1: Simple single raw stream +export const singleRawStreamFn = createServerFn().handler(async () => { + const stream = createDelayedStream( + [encode('chunk1'), encode('chunk2'), encode('chunk3')], + 50, + ) + return { + message: 'Single stream test', + data: new RawStream(stream), + } +}) + +// Test 2: Multiple raw streams +export const multipleRawStreamsFn = createServerFn().handler(async () => { + const stream1 = createDelayedStream( + [encode('stream1-a'), encode('stream1-b')], + 30, + ) + const stream2 = createDelayedStream( + [encode('stream2-a'), encode('stream2-b')], + 50, + ) + return { + message: 'Multiple streams test', + first: new RawStream(stream1), + second: new RawStream(stream2), + } +}) + +// Test 3: JSON streaming ends before raw stream +export const jsonEndsFirstFn = createServerFn().handler(async () => { + // Slow raw stream (takes 500ms total) + const slowStream = createDelayedStream( + [encode('slow-1'), encode('slow-2'), encode('slow-3'), encode('slow-4')], + 125, + ) + return { + message: 'JSON ends first test', + timestamp: Date.now(), + slowData: new RawStream(slowStream), + } +}) + +// Test 4: Raw stream ends before JSON streaming (fast stream, deferred JSON) +export const rawEndsFirstFn = createServerFn().handler(async () => { + // Fast raw stream (completes quickly) + const fastStream = createDelayedStream([encode('fast-done')], 10) + + // Deferred promise - NOT awaited, so it streams as deferred JSON + const deferredData = new Promise((resolve) => + setTimeout(() => resolve('deferred-json-data'), 200), + ) + + return { + message: 'Raw ends first test', + deferredData, + fastData: new RawStream(fastStream), + } +}) + +// Test 5: Large binary data +export const largeBinaryFn = createServerFn().handler(async () => { + // Create 1KB chunks + const chunk = new Uint8Array(1024) + for (let i = 0; i < chunk.length; i++) { + chunk[i] = i % 256 + } + + const stream = createDelayedStream([chunk, chunk, chunk], 20) + + return { + message: 'Large binary test', + size: 3072, + binary: new RawStream(stream), + } +}) + +// Test 6: Mixed streaming (promise + raw stream) +export const mixedStreamingFn = createServerFn().handler(async () => { + const rawStream = createDelayedStream( + [encode('mixed-raw-1'), encode('mixed-raw-2')], + 50, + ) + + return { + immediate: 'immediate-value', + deferred: new Promise((resolve) => + setTimeout(() => resolve('deferred-value'), 100), + ), + raw: new RawStream(rawStream), + } +}) + +// Test 7: Text hint with pure text data (should use UTF-8 encoding) +export const textHintPureTextFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST7_CHUNKS, 30) + return { + message: 'Text hint with pure text', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 8: Text hint with pure binary data (should fallback to base64) +export const textHintPureBinaryFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST8_CHUNKS, 30) + return { + message: 'Text hint with pure binary', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 9: Text hint with mixed content (some UTF-8, some binary) +export const textHintMixedFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST9_CHUNKS, 30) + return { + message: 'Text hint with mixed content', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 10: Binary hint with text data (should still use base64) +export const binaryHintTextFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST10_CHUNKS, 30) + return { + message: 'Binary hint with text data', + data: new RawStream(stream, { hint: 'binary' }), + } +}) + +// Test 11: Binary hint with pure binary data +export const binaryHintBinaryFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST11_CHUNKS, 30) + return { + message: 'Binary hint with binary data', + data: new RawStream(stream, { hint: 'binary' }), + } +}) + +// ============================================================================ +// MULTIPLEXING TESTS - Verify correct interleaving of multiple streams +// ============================================================================ + +// Expected data for multiplexing tests +// Test 12: Two streams with interleaved timing +// Stream A: sends at 0ms, 150ms, 200ms (3 chunks with pauses) +// Stream B: sends at 50ms, 100ms, 250ms (3 chunks, different rhythm) +export const TEST12_STREAM_A_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('A1-first'), 0], // immediate + [encode('A2-after-pause'), 150], // 150ms pause + [encode('A3-quick'), 50], // 50ms after A2 +] +export const TEST12_STREAM_B_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('B1-start'), 50], // 50ms after start + [encode('B2-continue'), 50], // 50ms after B1 + [encode('B3-final'), 150], // 150ms pause then final +] +export const TEST12_STREAM_A_EXPECTED = concatBytes( + TEST12_STREAM_A_CHUNKS.map(([chunk]) => chunk), +) +export const TEST12_STREAM_B_EXPECTED = concatBytes( + TEST12_STREAM_B_CHUNKS.map(([chunk]) => chunk), +) + +// Test 13: Burst-pause-burst pattern (single stream) +// 3 chunks quickly, long pause, 3 more chunks quickly +export const TEST13_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('burst1-a'), 10], + [encode('burst1-b'), 10], + [encode('burst1-c'), 10], + [encode('pause-then-burst2-a'), 200], // long pause + [encode('burst2-b'), 10], + [encode('burst2-c'), 10], +] +export const TEST13_EXPECTED = concatBytes( + TEST13_CHUNKS.map(([chunk]) => chunk), +) + +// Test 14: Three concurrent streams with different patterns +// Stream A: fast steady (every 30ms) +// Stream B: slow steady (every 100ms) +// Stream C: burst pattern (quick-pause-quick) +export const TEST14_STREAM_A_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('A1'), 30], + [encode('A2'), 30], + [encode('A3'), 30], + [encode('A4'), 30], +] +export const TEST14_STREAM_B_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('B1-slow'), 100], + [encode('B2-slow'), 100], +] +export const TEST14_STREAM_C_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('C1-burst'), 20], + [encode('C2-burst'), 20], + [encode('C3-after-pause'), 150], +] +export const TEST14_STREAM_A_EXPECTED = concatBytes( + TEST14_STREAM_A_CHUNKS.map(([chunk]) => chunk), +) +export const TEST14_STREAM_B_EXPECTED = concatBytes( + TEST14_STREAM_B_CHUNKS.map(([chunk]) => chunk), +) +export const TEST14_STREAM_C_EXPECTED = concatBytes( + TEST14_STREAM_C_CHUNKS.map(([chunk]) => chunk), +) + +// Test 12: Interleaved multiplexing - two streams with variable delays +export const interleavedStreamsFn = createServerFn().handler(async () => { + const streamA = createVariableDelayStream(TEST12_STREAM_A_CHUNKS) + const streamB = createVariableDelayStream(TEST12_STREAM_B_CHUNKS) + + return { + message: 'Interleaved streams test', + streamA: new RawStream(streamA), + streamB: new RawStream(streamB), + } +}) + +// Test 13: Burst-pause-burst pattern +export const burstPauseBurstFn = createServerFn().handler(async () => { + const stream = createVariableDelayStream(TEST13_CHUNKS) + + return { + message: 'Burst-pause-burst test', + data: new RawStream(stream), + } +}) + +// Test 14: Three concurrent streams +export const threeStreamsFn = createServerFn().handler(async () => { + const streamA = createVariableDelayStream(TEST14_STREAM_A_CHUNKS) + const streamB = createVariableDelayStream(TEST14_STREAM_B_CHUNKS) + const streamC = createVariableDelayStream(TEST14_STREAM_C_CHUNKS) + + return { + message: 'Three concurrent streams test', + fast: new RawStream(streamA), + slow: new RawStream(streamB), + burst: new RawStream(streamC), + } +}) + +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +// Test 15: Empty stream (zero bytes) +export const emptyStreamFn = createServerFn().handler(async () => { + // Stream that immediately closes with no data + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + return { + message: 'Empty stream test', + data: new RawStream(stream), + } +}) + +// Test 16: Stream that errors mid-flight +export const errorStreamFn = createServerFn().handler(async () => { + // Stream that sends some data then errors + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encode('chunk-before-error')) + await new Promise((resolve) => setTimeout(resolve, 50)) + controller.error(new Error('Intentional stream error')) + }, + }) + + return { + message: 'Error stream test', + data: new RawStream(stream), + } +}) + +// Helpers for consuming streams (exported for use in components) +// Note: RawStream is the marker class used in loaders/server functions, +// but after SSR deserialization it becomes ReadableStream. +// We accept both types to handle the TypeScript mismatch. +export function createStreamConsumer() { + const decoder = new TextDecoder() + + return async function consumeStream( + stream: ReadableStream | RawStream, + ): Promise { + // Handle both RawStream (from type system) and ReadableStream (runtime) + const actualStream = + stream instanceof RawStream + ? stream.stream + : (stream as ReadableStream) + const reader = actualStream.getReader() + const chunks: Array = [] + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(decoder.decode(value, { stream: true })) + } + + return chunks.join('') + } +} + +export async function consumeBinaryStream( + stream: ReadableStream | RawStream, +): Promise { + // Handle both RawStream (from type system) and ReadableStream (runtime) + const actualStream = + stream instanceof RawStream + ? stream.stream + : (stream as ReadableStream) + const reader = actualStream.getReader() + let totalBytes = 0 + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.length + } + + return totalBytes +} + +// Helper to collect all bytes from a stream +export async function collectBytes( + stream: ReadableStream | RawStream, +): Promise { + const actualStream = + stream instanceof RawStream + ? stream.stream + : (stream as ReadableStream) + const reader = actualStream.getReader() + const chunks: Array = [] + let totalLength = 0 + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + totalLength += value.length + } + + const result = new Uint8Array(totalLength) + let pos = 0 + for (const chunk of chunks) { + result.set(chunk, pos) + pos += chunk.length + } + return result +} + +// Compare two Uint8Arrays byte-by-byte +export function compareBytes( + actual: Uint8Array, + expected: Uint8Array, +): { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} { + if (actual.length !== expected.length) { + return { + match: false, + mismatchIndex: -1, // -1 indicates length mismatch + actualLength: actual.length, + expectedLength: expected.length, + } + } + for (let i = 0; i < actual.length; i++) { + if (actual[i] !== expected[i]) { + return { + match: false, + mismatchIndex: i, + actualLength: actual.length, + expectedLength: expected.length, + } + } + } + return { + match: true, + mismatchIndex: null, + actualLength: actual.length, + expectedLength: expected.length, + } +} diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 553ced91ff7..238008b2049 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as Char45824Char54620Char48124Char44397RouteImport } from './rout import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' +import { Route as RawStreamRouteImport } from './routes/raw-stream' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' @@ -24,6 +25,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' +import { Route as RawStreamIndexRouteImport } from './routes/raw-stream/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' @@ -31,6 +33,12 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId' import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' +import { Route as RawStreamSsrTextHintRouteImport } from './routes/raw-stream/ssr-text-hint' +import { Route as RawStreamSsrSingleRouteImport } from './routes/raw-stream/ssr-single' +import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ssr-multiple' +import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' +import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' +import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad' @@ -72,6 +80,11 @@ const ScriptsRoute = ScriptsRouteImport.update({ path: '/scripts', getParentRoute: () => rootRouteImport, } as any) +const RawStreamRoute = RawStreamRouteImport.update({ + id: '/raw-stream', + path: '/raw-stream', + getParentRoute: () => rootRouteImport, +} as any) const PostsRoute = PostsRouteImport.update({ id: '/posts', path: '/posts', @@ -126,6 +139,11 @@ const RedirectIndexRoute = RedirectIndexRouteImport.update({ path: '/redirect/', getParentRoute: () => rootRouteImport, } as any) +const RawStreamIndexRoute = RawStreamIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => RawStreamRoute, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -163,6 +181,36 @@ const RedirectTargetRoute = RedirectTargetRouteImport.update({ path: '/redirect/$target', getParentRoute: () => rootRouteImport, } as any) +const RawStreamSsrTextHintRoute = RawStreamSsrTextHintRouteImport.update({ + id: '/ssr-text-hint', + path: '/ssr-text-hint', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrSingleRoute = RawStreamSsrSingleRouteImport.update({ + id: '/ssr-single', + path: '/ssr-single', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrMultipleRoute = RawStreamSsrMultipleRouteImport.update({ + id: '/ssr-multiple', + path: '/ssr-multiple', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrMixedRoute = RawStreamSsrMixedRouteImport.update({ + id: '/ssr-mixed', + path: '/ssr-mixed', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrBinaryHintRoute = RawStreamSsrBinaryHintRouteImport.update({ + id: '/ssr-binary-hint', + path: '/ssr-binary-hint', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ + id: '/client-call', + path: '/client-call', + getParentRoute: () => RawStreamRoute, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -274,6 +322,7 @@ export interface FileRoutesByFullPath { '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren @@ -283,6 +332,12 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute @@ -290,6 +345,7 @@ export interface FileRoutesByFullPath { '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute + '/raw-stream/': typeof RawStreamIndexRoute '/redirect': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute @@ -320,12 +376,19 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found': typeof NotFoundIndexRoute '/posts': typeof PostsIndexRoute + '/raw-stream': typeof RawStreamIndexRoute '/redirect': typeof RedirectIndexRoute '/search-params': typeof SearchParamsIndexRoute '/users': typeof UsersIndexRoute @@ -353,6 +416,7 @@ export interface FileRoutesById { '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren @@ -363,6 +427,12 @@ export interface FileRoutesById { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute @@ -370,6 +440,7 @@ export interface FileRoutesById { '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute + '/raw-stream/': typeof RawStreamIndexRoute '/redirect/': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute @@ -397,6 +468,7 @@ export interface FileRouteTypes { | '/inline-scripts' | '/links' | '/posts' + | '/raw-stream' | '/scripts' | '/stream' | '/users' @@ -406,6 +478,12 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' @@ -413,6 +491,7 @@ export interface FileRouteTypes { | '/multi-cookie-redirect' | '/not-found/' | '/posts/' + | '/raw-stream/' | '/redirect' | '/search-params/' | '/users/' @@ -443,12 +522,19 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/search-params/default' | '/search-params/loader-throws-redirect' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found' | '/posts' + | '/raw-stream' | '/redirect' | '/search-params' | '/users' @@ -475,6 +561,7 @@ export interface FileRouteTypes { | '/inline-scripts' | '/links' | '/posts' + | '/raw-stream' | '/scripts' | '/stream' | '/users' @@ -485,6 +572,12 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' @@ -492,6 +585,7 @@ export interface FileRouteTypes { | '/multi-cookie-redirect/' | '/not-found/' | '/posts/' + | '/raw-stream/' | '/redirect/' | '/search-params/' | '/users/' @@ -519,6 +613,7 @@ export interface RootRouteChildren { InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren + RawStreamRoute: typeof RawStreamRouteWithChildren ScriptsRoute: typeof ScriptsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren @@ -563,6 +658,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof ScriptsRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream': { + id: '/raw-stream' + path: '/raw-stream' + fullPath: '/raw-stream' + preLoaderRoute: typeof RawStreamRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -640,6 +742,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RedirectIndexRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream/': { + id: '/raw-stream/' + path: '/' + fullPath: '/raw-stream/' + preLoaderRoute: typeof RawStreamIndexRouteImport + parentRoute: typeof RawStreamRoute + } '/posts/': { id: '/posts/' path: '/' @@ -689,6 +798,48 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof RedirectTargetRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream/ssr-text-hint': { + id: '/raw-stream/ssr-text-hint' + path: '/ssr-text-hint' + fullPath: '/raw-stream/ssr-text-hint' + preLoaderRoute: typeof RawStreamSsrTextHintRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-single': { + id: '/raw-stream/ssr-single' + path: '/ssr-single' + fullPath: '/raw-stream/ssr-single' + preLoaderRoute: typeof RawStreamSsrSingleRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-multiple': { + id: '/raw-stream/ssr-multiple' + path: '/ssr-multiple' + fullPath: '/raw-stream/ssr-multiple' + preLoaderRoute: typeof RawStreamSsrMultipleRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-mixed': { + id: '/raw-stream/ssr-mixed' + path: '/ssr-mixed' + fullPath: '/raw-stream/ssr-mixed' + preLoaderRoute: typeof RawStreamSsrMixedRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-binary-hint': { + id: '/raw-stream/ssr-binary-hint' + path: '/ssr-binary-hint' + fullPath: '/raw-stream/ssr-binary-hint' + preLoaderRoute: typeof RawStreamSsrBinaryHintRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/client-call': { + id: '/raw-stream/client-call' + path: '/client-call' + fullPath: '/raw-stream/client-call' + preLoaderRoute: typeof RawStreamClientCallRouteImport + parentRoute: typeof RawStreamRoute + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -893,6 +1044,30 @@ const PostsRouteChildren: PostsRouteChildren = { const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) +interface RawStreamRouteChildren { + RawStreamClientCallRoute: typeof RawStreamClientCallRoute + RawStreamSsrBinaryHintRoute: typeof RawStreamSsrBinaryHintRoute + RawStreamSsrMixedRoute: typeof RawStreamSsrMixedRoute + RawStreamSsrMultipleRoute: typeof RawStreamSsrMultipleRoute + RawStreamSsrSingleRoute: typeof RawStreamSsrSingleRoute + RawStreamSsrTextHintRoute: typeof RawStreamSsrTextHintRoute + RawStreamIndexRoute: typeof RawStreamIndexRoute +} + +const RawStreamRouteChildren: RawStreamRouteChildren = { + RawStreamClientCallRoute: RawStreamClientCallRoute, + RawStreamSsrBinaryHintRoute: RawStreamSsrBinaryHintRoute, + RawStreamSsrMixedRoute: RawStreamSsrMixedRoute, + RawStreamSsrMultipleRoute: RawStreamSsrMultipleRoute, + RawStreamSsrSingleRoute: RawStreamSsrSingleRoute, + RawStreamSsrTextHintRoute: RawStreamSsrTextHintRoute, + RawStreamIndexRoute: RawStreamIndexRoute, +} + +const RawStreamRouteWithChildren = RawStreamRoute._addFileChildren( + RawStreamRouteChildren, +) + interface UsersRouteChildren { UsersUserIdRoute: typeof UsersUserIdRoute UsersIndexRoute: typeof UsersIndexRoute @@ -952,6 +1127,7 @@ const rootRouteChildren: RootRouteChildren = { InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, + RawStreamRoute: RawStreamRouteWithChildren, ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/__root.tsx b/e2e/solid-start/basic/src/routes/__root.tsx index 8e0d9a33a6c..3b1ec274f5d 100644 --- a/e2e/solid-start/basic/src/routes/__root.tsx +++ b/e2e/solid-start/basic/src/routes/__root.tsx @@ -139,6 +139,14 @@ function RootComponent() { > redirect {' '} + + Raw Stream + {' '} +

Raw Stream Tests

+ + + +
+ + + + ) +} diff --git a/e2e/solid-start/basic/src/routes/raw-stream/client-call.tsx b/e2e/solid-start/basic/src/routes/raw-stream/client-call.tsx new file mode 100644 index 00000000000..a0c9d8adac6 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/client-call.tsx @@ -0,0 +1,488 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createSignal } from 'solid-js' +import { + TEST10_EXPECTED, + TEST11_EXPECTED, + TEST12_STREAM_A_EXPECTED, + TEST12_STREAM_B_EXPECTED, + TEST13_EXPECTED, + TEST14_STREAM_A_EXPECTED, + TEST14_STREAM_B_EXPECTED, + TEST14_STREAM_C_EXPECTED, + TEST7_EXPECTED, + TEST8_EXPECTED, + TEST9_EXPECTED, + binaryHintBinaryFn, + binaryHintTextFn, + burstPauseBurstFn, + collectBytes, + compareBytes, + consumeBinaryStream, + createStreamConsumer, + emptyStreamFn, + errorStreamFn, + interleavedStreamsFn, + jsonEndsFirstFn, + largeBinaryFn, + mixedStreamingFn, + multipleRawStreamsFn, + rawEndsFirstFn, + singleRawStreamFn, + textHintMixedFn, + textHintPureBinaryFn, + textHintPureTextFn, + threeStreamsFn, +} from '../../raw-stream-fns' + +function ClientCallTests() { + const [results, setResults] = createSignal>({}) + const [loading, setLoading] = createSignal>({}) + + const consumeStream = createStreamConsumer() + + const runTest = async ( + testName: string, + fn: () => Promise, + processor: (result: any) => Promise, + ) => { + setLoading((prev) => ({ ...prev, [testName]: true })) + try { + const result = await fn() + const processed = await processor(result) + setResults((prev) => ({ ...prev, [testName]: processed })) + } catch (error) { + setResults((prev) => ({ + ...prev, + [testName]: { error: String(error) }, + })) + } finally { + setLoading((prev) => ({ ...prev, [testName]: false })) + } + } + + return ( +
+

Client-Side Server Function Calls (RPC)

+

+ These tests invoke server functions directly from the client, using the + binary framing protocol for RawStream data. +

+ + {/* Test 1: Single Raw Stream */} +
+

Test 1: Single Raw Stream

+ +
{JSON.stringify(results().test1)}
+
+ + {/* Test 2: Multiple Raw Streams */} +
+

Test 2: Multiple Raw Streams

+ +
{JSON.stringify(results().test2)}
+
+ + {/* Test 3: JSON Ends First */} +
+

Test 3: JSON Ends Before Raw Stream

+ +
{JSON.stringify(results().test3)}
+
+ + {/* Test 4: Raw Ends First */} +
+

Test 4: Raw Stream Ends Before JSON

+ +
{JSON.stringify(results().test4)}
+
+ + {/* Test 5: Large Binary */} +
+

Test 5: Large Binary Data

+ +
{JSON.stringify(results().test5)}
+
+ + {/* Test 6: Mixed Streaming */} +
+

Test 6: Mixed Streaming

+ +
{JSON.stringify(results().test6)}
+
+ + {/* Hint Tests Section */} +

Hint Parameter Tests (RPC)

+

+ These tests verify that hint parameter works correctly for RPC calls. + Note: RPC always uses binary framing regardless of hint. +

+ + {/* Test 7: Text Hint with Pure Text */} +
+

Test 7: Text Hint - Pure Text

+ +
{JSON.stringify(results().test7)}
+
+ + {/* Test 8: Text Hint with Pure Binary */} +
+

Test 8: Text Hint - Pure Binary

+ +
{JSON.stringify(results().test8)}
+
+ + {/* Test 9: Text Hint with Mixed Content */} +
+

Test 9: Text Hint - Mixed Content

+ +
{JSON.stringify(results().test9)}
+
+ + {/* Test 10: Binary Hint with Text Data */} +
+

Test 10: Binary Hint - Text Data

+ +
+          {JSON.stringify(results().test10)}
+        
+
+ + {/* Test 11: Binary Hint with Binary Data */} +
+

Test 11: Binary Hint - Binary Data

+ +
+          {JSON.stringify(results().test11)}
+        
+
+ + {/* Multiplexing Tests Section */} +

Multiplexing Tests (RPC)

+

+ These tests verify correct interleaving of multiple concurrent streams. +

+ + {/* Test 12: Interleaved Streams */} +
+

Test 12: Interleaved Streams

+ +
+          {JSON.stringify(results().test12)}
+        
+
+ + {/* Test 13: Burst-Pause-Burst */} +
+

Test 13: Burst-Pause-Burst

+ +
+          {JSON.stringify(results().test13)}
+        
+
+ + {/* Test 14: Three Concurrent Streams */} +
+

Test 14: Three Concurrent Streams

+ +
+          {JSON.stringify(results().test14)}
+        
+
+ + {/* Edge Case Tests Section */} +

Edge Case Tests (RPC)

+

+ These tests verify edge cases like empty streams and error handling. +

+ + {/* Test 15: Empty Stream */} +
+

Test 15: Empty Stream

+ +
+          {JSON.stringify(results().test15)}
+        
+
+ + {/* Test 16: Stream Error */} +
+

Test 16: Stream Error

+ +
+          {JSON.stringify(results().test16)}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/client-call')({ + component: ClientCallTests, +}) diff --git a/e2e/solid-start/basic/src/routes/raw-stream/index.tsx b/e2e/solid-start/basic/src/routes/raw-stream/index.tsx new file mode 100644 index 00000000000..8fe5133edee --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/index.tsx @@ -0,0 +1,74 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/raw-stream/')({ + component: RawStreamIndex, +}) + +function RawStreamIndex() { + return ( +
+

Select a test category above to begin testing.

+
    +
  • + + Client Calls + + - Test RawStream via direct server function calls (RPC) +
  • +
  • + + SSR Single + + - Test single RawStream from route loader (SSR) +
  • +
  • + + SSR Multiple + + - Test multiple RawStreams from route loader (SSR) +
  • +
  • + + SSR Mixed + + + {' '} + - Test RawStream mixed with deferred data from loader (SSR) + +
  • +
  • + + SSR Text Hint + + - Test RawStream with hint: 'text' from loader (SSR) +
  • +
  • + + SSR Binary Hint + + - Test RawStream with hint: 'binary' from loader (SSR) +
  • +
+
+ ) +} diff --git a/e2e/solid-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx b/e2e/solid-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx new file mode 100644 index 00000000000..2c41daff7fa --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx @@ -0,0 +1,141 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { RawStream } from '@tanstack/solid-start' +import { createEffect, createSignal } from 'solid-js' +import { + collectBytes, + compareBytes, + concatBytes, + createDelayedStream, + encode, +} from '../../raw-stream-fns' + +// Expected data - defined at module level for client-side verification +const TEXT_CHUNKS = [encode('Binary '), encode('hint '), encode('with text')] +const TEXT_EXPECTED = concatBytes(TEXT_CHUNKS) + +const BINARY_CHUNKS = [ + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]), +] +const BINARY_EXPECTED = concatBytes(BINARY_CHUNKS) + +type TextMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number + asText: string +} + +type BinaryMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} + +function SSRBinaryHintTest() { + const loaderData = Route.useLoaderData() + const [textMatch, setTextMatch] = createSignal(null) + const [binaryMatch, setBinaryMatch] = createSignal(null) + const [isLoading, setIsLoading] = createSignal(true) + const [error, setError] = createSignal(null) + + createEffect(() => { + const { textData, binaryData } = loaderData() + if (!textData || !binaryData) { + return + } + setIsLoading(true) + setError(null) + Promise.all([collectBytes(textData), collectBytes(binaryData)]) + .then(([textBytes, binaryBytes]) => { + const textComp = compareBytes(textBytes, TEXT_EXPECTED) + const decoder = new TextDecoder() + setTextMatch({ + ...textComp, + actualLength: textBytes.length, + expectedLength: TEXT_EXPECTED.length, + asText: decoder.decode(textBytes), + }) + const binaryComp = compareBytes(binaryBytes, BINARY_EXPECTED) + setBinaryMatch({ + ...binaryComp, + actualLength: binaryBytes.length, + expectedLength: BINARY_EXPECTED.length, + }) + setIsLoading(false) + }) + .catch((err) => { + setError(String(err)) + setIsLoading(false) + }) + }) + + return ( +
+

SSR Binary Hint Test

+

+ This route tests RawStream with hint: 'binary' from loader. Binary hint + always uses base64 encoding (default behavior). +

+ +
+
+ Message: {loaderData().message} +
+
+ Text Data: + {error() + ? `Error: ${error()}` + : isLoading() + ? 'Loading...' + : textMatch()?.asText} +
+
+ Text Bytes Match: + {isLoading() ? 'Loading...' : textMatch()?.match ? 'true' : 'false'} +
+
+ Binary Bytes Match: + {isLoading() ? 'Loading...' : binaryMatch()?.match ? 'true' : 'false'} +
+
+          {JSON.stringify({
+            message: loaderData().message,
+            textMatch: textMatch(),
+            binaryMatch: binaryMatch(),
+            isLoading: isLoading(),
+            error: error(),
+          })}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/ssr-binary-hint')({ + loader: async () => { + // Text data with binary hint - should still use base64 (default behavior) + const textStream = createDelayedStream( + [encode('Binary '), encode('hint '), encode('with text')], + 30, + ) + + // Pure binary stream with binary hint + const binaryStream = createDelayedStream( + [ + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]), + ], + 30, + ) + + return { + message: 'SSR Binary Hint Test', + textData: new RawStream(textStream, { hint: 'binary' }), + binaryData: new RawStream(binaryStream, { hint: 'binary' }), + } + }, + component: SSRBinaryHintTest, +}) diff --git a/e2e/solid-start/basic/src/routes/raw-stream/ssr-mixed.tsx b/e2e/solid-start/basic/src/routes/raw-stream/ssr-mixed.tsx new file mode 100644 index 00000000000..5ccd05cc1ff --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/ssr-mixed.tsx @@ -0,0 +1,96 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { RawStream } from '@tanstack/solid-start' +import { Suspense, createEffect, createSignal } from 'solid-js' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +function SSRMixedTest() { + const loaderData = Route.useLoaderData() + const [streamContent, setStreamContent] = createSignal('') + const [isConsuming, setIsConsuming] = createSignal(true) + const [error, setError] = createSignal(null) + + createEffect(() => { + const rawData = loaderData().rawData + if (!rawData) { + return + } + const consumeStream = createStreamConsumer() + setIsConsuming(true) + setError(null) + consumeStream(rawData) + .then((content) => { + setStreamContent(content) + setIsConsuming(false) + }) + .catch((err) => { + setError(String(err)) + setIsConsuming(false) + }) + }) + + return ( +
+

SSR Mixed Streaming Test

+

+ This route returns a mix of immediate data, deferred promises, and + RawStream from its loader. +

+ +
+
+ Immediate: {loaderData().immediate} +
+
+ Deferred: + Loading deferred...}> + {value}} + /> + +
+
+ Stream Content: + {error() + ? `Error: ${error()}` + : isConsuming() + ? 'Loading...' + : streamContent()} +
+
+          {JSON.stringify({
+            immediate: loaderData().immediate,
+            streamContent: streamContent(),
+            isConsuming: isConsuming(),
+            error: error(),
+          })}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/ssr-mixed')({ + loader: () => { + const rawStream = createDelayedStream( + [encode('mixed-ssr-1'), encode('mixed-ssr-2')], + 50, + ) + + // Deferred promise that resolves after a delay + const deferredData = new Promise((resolve) => + setTimeout(() => resolve('deferred-ssr-value'), 100), + ) + + return { + immediate: 'immediate-ssr-value', + deferred: deferredData, + rawData: new RawStream(rawStream), + } + }, + component: SSRMixedTest, +}) diff --git a/e2e/solid-start/basic/src/routes/raw-stream/ssr-multiple.tsx b/e2e/solid-start/basic/src/routes/raw-stream/ssr-multiple.tsx new file mode 100644 index 00000000000..adb389e7542 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/ssr-multiple.tsx @@ -0,0 +1,97 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { RawStream } from '@tanstack/solid-start' +import { createEffect, createSignal } from 'solid-js' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +function SSRMultipleTest() { + const loaderData = Route.useLoaderData() + const [firstContent, setFirstContent] = createSignal('') + const [secondContent, setSecondContent] = createSignal('') + const [isConsuming, setIsConsuming] = createSignal(true) + const [error, setError] = createSignal(null) + + createEffect(() => { + const first = loaderData().first + const second = loaderData().second + if (!first || !second) { + return + } + const consumeStream = createStreamConsumer() + setIsConsuming(true) + setError(null) + Promise.all([consumeStream(first), consumeStream(second)]) + .then(([content1, content2]) => { + setFirstContent(content1) + setSecondContent(content2) + setIsConsuming(false) + }) + .catch((err) => { + setError(String(err)) + setIsConsuming(false) + }) + }) + + return ( +
+

SSR Multiple RawStreams Test

+

+ This route returns multiple RawStreams from its loader. Each stream is + independently serialized during SSR. +

+ +
+
+ Message: {loaderData().message} +
+
+ First Stream: + {error() + ? `Error: ${error()}` + : isConsuming() + ? 'Loading...' + : firstContent()} +
+
+ Second Stream: + {error() + ? `Error: ${error()}` + : isConsuming() + ? 'Loading...' + : secondContent()} +
+
+          {JSON.stringify({
+            message: loaderData().message,
+            firstContent: firstContent(),
+            secondContent: secondContent(),
+            isConsuming: isConsuming(),
+            error: error(),
+          })}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/ssr-multiple')({ + loader: async () => { + const stream1 = createDelayedStream( + [encode('multi-1a'), encode('multi-1b')], + 30, + ) + const stream2 = createDelayedStream( + [encode('multi-2a'), encode('multi-2b')], + 50, + ) + return { + message: 'SSR Multiple Streams Test', + first: new RawStream(stream1), + second: new RawStream(stream2), + } + }, + component: SSRMultipleTest, +}) diff --git a/e2e/solid-start/basic/src/routes/raw-stream/ssr-single.tsx b/e2e/solid-start/basic/src/routes/raw-stream/ssr-single.tsx new file mode 100644 index 00000000000..c59e2a03414 --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/ssr-single.tsx @@ -0,0 +1,91 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { RawStream } from '@tanstack/solid-start' +import { createEffect, createSignal } from 'solid-js' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +function SSRSingleTest() { + const loaderData = Route.useLoaderData() + const [streamContent, setStreamContent] = createSignal('') + const [isConsuming, setIsConsuming] = createSignal(true) + const [error, setError] = createSignal(null) + + createEffect(() => { + const rawData = loaderData().rawData + if (!rawData) { + return + } + const consumeStream = createStreamConsumer() + setIsConsuming(true) + setError(null) + consumeStream(rawData) + .then((content) => { + setStreamContent(content) + setIsConsuming(false) + }) + .catch((err) => { + setError(String(err)) + setIsConsuming(false) + }) + }) + + return ( +
+

SSR Single RawStream Test

+

+ This route returns a single RawStream from its loader. The stream is + serialized during SSR using base64 encoding. +

+ +
+
+ Message: {loaderData().message} +
+
+ Has Timestamp:{' '} + {typeof loaderData().timestamp === 'number' ? 'true' : 'false'} +
+
+ Stream Content: + {error() + ? `Error: ${error()}` + : isConsuming() + ? 'Loading...' + : streamContent()} +
+
+ RawData Type: {typeof loaderData().rawData} | hasStream: + {loaderData().rawData && 'getReader' in loaderData().rawData + ? 'true' + : 'false'} +
+
+          {JSON.stringify({
+            message: loaderData().message,
+            streamContent: streamContent(),
+            isConsuming: isConsuming(),
+            error: error(),
+          })}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/ssr-single')({ + loader: async () => { + const stream = createDelayedStream( + [encode('ssr-chunk1'), encode('ssr-chunk2'), encode('ssr-chunk3')], + 50, + ) + return { + message: 'SSR Single Stream Test', + timestamp: Date.now(), + rawData: new RawStream(stream), + } + }, + component: SSRSingleTest, +}) diff --git a/e2e/solid-start/basic/src/routes/raw-stream/ssr-text-hint.tsx b/e2e/solid-start/basic/src/routes/raw-stream/ssr-text-hint.tsx new file mode 100644 index 00000000000..f776474bfdc --- /dev/null +++ b/e2e/solid-start/basic/src/routes/raw-stream/ssr-text-hint.tsx @@ -0,0 +1,192 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { RawStream } from '@tanstack/solid-start' +import { createEffect, createSignal } from 'solid-js' +import { + collectBytes, + compareBytes, + concatBytes, + createDelayedStream, + encode, +} from '../../raw-stream-fns' + +// Expected data - defined at module level for client-side verification +const PURE_TEXT_CHUNKS = [ + encode('Hello '), + encode('World '), + encode('from SSR!'), +] +const PURE_TEXT_EXPECTED = concatBytes(PURE_TEXT_CHUNKS) + +const MIXED_CHUNKS = [ + encode('Valid text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' more text'), +] +const MIXED_EXPECTED = concatBytes(MIXED_CHUNKS) + +// Pure binary data (invalid UTF-8) - must use base64 fallback +const PURE_BINARY_CHUNKS = [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), +] +const PURE_BINARY_EXPECTED = concatBytes(PURE_BINARY_CHUNKS) + +type TextMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number + asText: string +} + +type BinaryMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} + +function SSRTextHintTest() { + const loaderData = Route.useLoaderData() + const [pureTextMatch, setPureTextMatch] = createSignal(null) + const [mixedMatch, setMixedMatch] = createSignal(null) + const [pureBinaryMatch, setPureBinaryMatch] = + createSignal(null) + const [isLoading, setIsLoading] = createSignal(true) + const [error, setError] = createSignal(null) + + createEffect(() => { + const { pureText, mixedContent, pureBinary } = loaderData() + if (!pureText || !mixedContent || !pureBinary) { + return + } + setIsLoading(true) + setError(null) + Promise.all([ + collectBytes(pureText), + collectBytes(mixedContent), + collectBytes(pureBinary), + ]) + .then(([pureBytes, mixedBytes, pureBinaryBytes]) => { + const pureComp = compareBytes(pureBytes, PURE_TEXT_EXPECTED) + const decoder = new TextDecoder() + setPureTextMatch({ + ...pureComp, + actualLength: pureBytes.length, + expectedLength: PURE_TEXT_EXPECTED.length, + asText: decoder.decode(pureBytes), + }) + const mixedComp = compareBytes(mixedBytes, MIXED_EXPECTED) + setMixedMatch({ + ...mixedComp, + actualLength: mixedBytes.length, + expectedLength: MIXED_EXPECTED.length, + }) + const pureBinaryComp = compareBytes( + pureBinaryBytes, + PURE_BINARY_EXPECTED, + ) + setPureBinaryMatch({ + ...pureBinaryComp, + actualLength: pureBinaryBytes.length, + expectedLength: PURE_BINARY_EXPECTED.length, + }) + setIsLoading(false) + }) + .catch((err) => { + setError(String(err)) + setIsLoading(false) + }) + }) + + return ( +
+

SSR Text Hint Test

+

+ This route tests RawStream with hint: 'text' from loader. Text hint + optimizes for UTF-8 content but falls back to base64 for invalid UTF-8. +

+ +
+
+ Message: {loaderData().message} +
+
+ Pure Text: + {error() + ? `Error: ${error()}` + : isLoading() + ? 'Loading...' + : pureTextMatch()?.asText} +
+
+ Pure Text Bytes Match: + {isLoading() + ? 'Loading...' + : pureTextMatch()?.match + ? 'true' + : 'false'} +
+
+ Mixed Content Bytes Match: + {isLoading() ? 'Loading...' : mixedMatch()?.match ? 'true' : 'false'} +
+
+ Pure Binary Bytes Match: + {isLoading() + ? 'Loading...' + : pureBinaryMatch()?.match + ? 'true' + : 'false'} +
+
+          {JSON.stringify({
+            message: loaderData().message,
+            pureTextMatch: pureTextMatch(),
+            mixedMatch: mixedMatch(),
+            pureBinaryMatch: pureBinaryMatch(),
+            isLoading: isLoading(),
+            error: error(),
+          })}
+        
+
+
+ ) +} + +export const Route = createFileRoute('/raw-stream/ssr-text-hint')({ + loader: async () => { + // Pure text stream - should use UTF-8 encoding with text hint + const textStream = createDelayedStream( + [encode('Hello '), encode('World '), encode('from SSR!')], + 30, + ) + + // Mixed content stream - text hint should use UTF-8 for valid text, base64 for binary + const mixedStream = createDelayedStream( + [ + encode('Valid text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' more text'), + ], + 30, + ) + + // Pure binary stream - text hint must fallback to base64 for all chunks + const pureBinaryStream = createDelayedStream( + [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), + ], + 30, + ) + + return { + message: 'SSR Text Hint Test', + pureText: new RawStream(textStream, { hint: 'text' }), + mixedContent: new RawStream(mixedStream, { hint: 'text' }), + pureBinary: new RawStream(pureBinaryStream, { hint: 'text' }), + } + }, + component: SSRTextHintTest, +}) diff --git a/e2e/solid-start/basic/tests/raw-stream.spec.ts b/e2e/solid-start/basic/tests/raw-stream.spec.ts new file mode 100644 index 00000000000..b47b6c0c22b --- /dev/null +++ b/e2e/solid-start/basic/tests/raw-stream.spec.ts @@ -0,0 +1,666 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +/** + * These tests verify the RawStream binary streaming functionality. + * + * RawStream allows returning ReadableStream from server functions + * with efficient binary encoding: + * - Server functions (RPC): Binary framing protocol + * - SSR loaders: Base64 encoding via seroval's stream mechanism + */ + +// Wait time for hydration to complete after page load +// This needs to be long enough for React hydration to attach event handlers +const HYDRATION_WAIT = 1000 + +test.describe('RawStream - Client RPC Tests', () => { + test('Single raw stream - returns stream with binary data', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + // Wait for hydration + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test1-result')).toContainText( + 'Single stream test', + ) + }) + + test('Multiple raw streams - returns multiple independent streams', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test2-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test2-btn').click() + + await expect(page.getByTestId('test2-result')).toContainText( + 'stream1-astream1-b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test2-result')).toContainText( + 'stream2-astream2-b', + ) + await expect(page.getByTestId('test2-result')).toContainText( + 'Multiple streams test', + ) + }) + + test('JSON ends before raw stream - handles timing correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test3-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test3-btn').click() + + await expect(page.getByTestId('test3-result')).toContainText( + 'slow-1slow-2slow-3slow-4', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test3-result')).toContainText( + 'JSON ends first test', + ) + await expect(page.getByTestId('test3-result')).toContainText('hasTimestamp') + }) + + test('Raw stream ends before JSON - handles timing correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test4-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test4-btn').click() + + await expect(page.getByTestId('test4-result')).toContainText('fast-done', { + timeout: 10000, + }) + await expect(page.getByTestId('test4-result')).toContainText( + 'deferred-json-data', + ) + await expect(page.getByTestId('test4-result')).toContainText( + 'Raw ends first test', + ) + }) + + test('Large binary data - handles 3KB of binary correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test5-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test5-btn').click() + + await expect(page.getByTestId('test5-result')).toContainText( + '"sizeMatch":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test5-result')).toContainText( + '"actualSize":3072', + ) + await expect(page.getByTestId('test5-result')).toContainText( + 'Large binary test', + ) + }) + + test('Mixed streaming - Promise and RawStream together', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test6-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test6-btn').click() + + await expect(page.getByTestId('test6-result')).toContainText( + 'immediate-value', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test6-result')).toContainText( + 'deferred-value', + ) + await expect(page.getByTestId('test6-result')).toContainText( + 'mixed-raw-1mixed-raw-2', + ) + }) +}) + +test.describe('RawStream - SSR Loader Tests', () => { + test('SSR single stream - direct navigation', async ({ page }) => { + // Direct navigation = full SSR with base64 encoding + await page.goto('/raw-stream/ssr-single') + await page.waitForURL('/raw-stream/ssr-single') + + // Wait for stream to be consumed (SSR tests need hydration + stream consumption) + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-single-message')).toContainText( + 'SSR Single Stream Test', + ) + await expect(page.getByTestId('ssr-single-timestamp')).toContainText( + 'Has Timestamp: true', + ) + }) + + test('SSR multiple streams - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-multiple') + await page.waitForURL('/raw-stream/ssr-multiple') + + await expect(page.getByTestId('ssr-multiple-first')).toContainText( + 'multi-1amulti-1b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-multiple-second')).toContainText( + 'multi-2amulti-2b', + ) + await expect(page.getByTestId('ssr-multiple-message')).toContainText( + 'SSR Multiple Streams Test', + ) + }) + + // Skip in prerender mode: RawStream + deferred data causes stream chunks to be + // missing from prerendered HTML. This is a known limitation where the prerender + // process doesn't properly capture streaming data when deferred promises are present. + ;(isPrerender ? test.skip : test)( + 'SSR mixed streaming - RawStream with deferred data', + async ({ page }) => { + await page.goto('/raw-stream/ssr-mixed') + await page.waitForURL('/raw-stream/ssr-mixed') + + await expect(page.getByTestId('ssr-mixed-immediate')).toContainText( + 'immediate-ssr-value', + ) + await expect(page.getByTestId('ssr-mixed-stream')).toContainText( + 'mixed-ssr-1mixed-ssr-2', + { timeout: 10000 }, + ) + // Deferred promise should also resolve + await expect(page.getByTestId('ssr-mixed-deferred')).toContainText( + 'deferred-ssr-value', + { timeout: 10000 }, + ) + }, + ) + + test('SSR single stream - client-side navigation', async ({ page }) => { + // Start from index, then navigate client-side to SSR route + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + // Wait for hydration (use navigation to be specific) + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Single' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Client-side navigation + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Single' }) + .click() + await page.waitForURL('/raw-stream/ssr-single') + + // Stream should still work after client navigation + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + }) + + test('SSR multiple streams - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Multiple' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Multiple' }) + .click() + await page.waitForURL('/raw-stream/ssr-multiple') + + await expect(page.getByTestId('ssr-multiple-first')).toContainText( + 'multi-1amulti-1b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-multiple-second')).toContainText( + 'multi-2amulti-2b', + ) + }) +}) + +test.describe('RawStream - Hint Parameter (RPC)', () => { + test('Text hint with pure text - uses UTF-8 encoding', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test7-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test7-btn').click() + + await expect(page.getByTestId('test7-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test7-result')).toContainText( + 'Hello, World! This is text.', + ) + await expect(page.getByTestId('test7-result')).toContainText( + 'Text hint with pure text', + ) + }) + + test('Text hint with pure binary - fallback to base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test8-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test8-btn').click() + + await expect(page.getByTestId('test8-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test8-result')).toContainText( + '"expectedLength":12', + ) + }) + + test('Text hint with mixed content - handles both', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test9-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test9-btn').click() + + await expect(page.getByTestId('test9-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test9-result')).toContainText( + '"expectedLength":30', + ) + }) + + test('Binary hint with text data - uses base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test10-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test10-btn').click() + + await expect(page.getByTestId('test10-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test10-result')).toContainText( + 'This is text but using binary hint', + ) + }) + + test('Binary hint with binary data - uses base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test11-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test11-btn').click() + + await expect(page.getByTestId('test11-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test11-result')).toContainText( + '"expectedLength":6', + ) + }) +}) + +test.describe('RawStream - SSR Hint Parameter Tests', () => { + test('SSR text hint with pure text - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-text-hint') + await page.waitForURL('/raw-stream/ssr-text-hint') + + await expect(page.getByTestId('ssr-text-hint-pure-text')).toContainText( + 'Hello World from SSR!', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-text-hint-pure-match')).toContainText( + 'true', + ) + await expect(page.getByTestId('ssr-text-hint-mixed-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-text-hint-pure-binary-match'), + ).toContainText('true') + }) + + test('SSR text hint - byte-by-byte verification', async ({ page }) => { + await page.goto('/raw-stream/ssr-text-hint') + await page.waitForURL('/raw-stream/ssr-text-hint') + + // Wait for streams to be fully consumed + await expect(page.getByTestId('ssr-text-hint-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + // Check pure text, mixed content, and pure binary all match + const result = await page.getByTestId('ssr-text-hint-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.pureTextMatch?.match).toBe(true) + expect(parsed.mixedMatch?.match).toBe(true) + expect(parsed.pureBinaryMatch?.match).toBe(true) + }) + + test('SSR binary hint with text - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-binary-hint') + await page.waitForURL('/raw-stream/ssr-binary-hint') + + await expect(page.getByTestId('ssr-binary-hint-text')).toContainText( + 'Binary hint with text', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-binary-hint-text-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-binary-hint-binary-match'), + ).toContainText('true') + }) + + test('SSR binary hint - byte-by-byte verification', async ({ page }) => { + await page.goto('/raw-stream/ssr-binary-hint') + await page.waitForURL('/raw-stream/ssr-binary-hint') + + // Wait for streams to be fully consumed + await expect(page.getByTestId('ssr-binary-hint-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + // Check both text and binary data match + const result = await page + .getByTestId('ssr-binary-hint-result') + .textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.textMatch?.match).toBe(true) + expect(parsed.binaryMatch?.match).toBe(true) + }) + + test('SSR text hint - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Text Hint' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Text Hint' }) + .click() + await page.waitForURL('/raw-stream/ssr-text-hint') + + await expect(page.getByTestId('ssr-text-hint-pure-match')).toContainText( + 'true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-text-hint-mixed-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-text-hint-pure-binary-match'), + ).toContainText('true') + }) + + test('SSR binary hint - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Binary Hint' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Binary Hint' }) + .click() + await page.waitForURL('/raw-stream/ssr-binary-hint') + + await expect(page.getByTestId('ssr-binary-hint-text-match')).toContainText( + 'true', + { timeout: 10000 }, + ) + await expect( + page.getByTestId('ssr-binary-hint-binary-match'), + ).toContainText('true') + }) +}) + +test.describe('RawStream - Multiplexing Tests (RPC)', () => { + test('Interleaved streams - two concurrent streams with variable delays', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test12-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test12-btn').click() + + // Both streams should have matching bytes + await expect(page.getByTestId('test12-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + // Verify both streams match + const result = await page.getByTestId('test12-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.streamA?.match).toBe(true) + expect(parsed.streamB?.match).toBe(true) + }) + + test('Burst-pause-burst - single stream with variable timing', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test13-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test13-btn').click() + + await expect(page.getByTestId('test13-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + await expect(page.getByTestId('test13-result')).toContainText( + 'Burst-pause-burst test', + ) + }) + + test('Three concurrent streams - different timing patterns', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test14-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test14-btn').click() + + // All three streams should match + await expect(page.getByTestId('test14-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + // Verify all three streams match + const result = await page.getByTestId('test14-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.fast?.match).toBe(true) + expect(parsed.slow?.match).toBe(true) + expect(parsed.burst?.match).toBe(true) + }) +}) + +test.describe('RawStream - Cross Navigation', () => { + test('Client RPC works after navigating from SSR route', async ({ page }) => { + // Start with SSR route + await page.goto('/raw-stream/ssr-single') + await page.waitForURL('/raw-stream/ssr-single') + + // Wait for SSR stream to complete (ensures hydration is done) + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + + // Navigate to client-call route (use first() to avoid strict mode on multiple matches) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Client Calls' }) + .click() + await page.waitForURL('/raw-stream/client-call') + + // Wait for hydration + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Run RPC test + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + }) + + test('Navigation from home to raw-stream routes', async ({ page }) => { + // Start from home + await page.goto('/') + await page.waitForURL('/') + + // Wait for hydration + await page + .getByRole('link', { name: 'Raw Stream' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Navigate via client-side to raw-stream + await page.getByRole('link', { name: 'Raw Stream' }).click() + await page.waitForURL('/raw-stream') + + // Wait for hydration on the new page + await page.waitForTimeout(HYDRATION_WAIT) + + // Then to client-call (use navigation area to avoid duplicates) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Client Calls' }) + .click() + await page.waitForURL('/raw-stream/client-call') + + // Wait for button + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Run a test + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + }) +}) + +test.describe('RawStream - Edge Cases (RPC)', () => { + test('Empty stream - handles zero-byte stream correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test15-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test15-btn').click() + + await expect(page.getByTestId('test15-result')).toContainText( + '"isEmpty":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test15-result')).toContainText( + '"byteCount":0', + ) + await expect(page.getByTestId('test15-result')).toContainText( + 'Empty stream test', + ) + }) + + test('Stream error - propagates error to client', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test16-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test16-btn').click() + + await expect(page.getByTestId('test16-result')).toContainText( + '"errorCaught":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test16-result')).toContainText( + 'Intentional stream error', + ) + await expect(page.getByTestId('test16-result')).toContainText( + 'Error stream test', + ) + }) +}) diff --git a/e2e/vue-start/basic/src/raw-stream-fns.ts b/e2e/vue-start/basic/src/raw-stream-fns.ts new file mode 100644 index 00000000000..5b515ea7b8a --- /dev/null +++ b/e2e/vue-start/basic/src/raw-stream-fns.ts @@ -0,0 +1,532 @@ +import { RawStream, createServerFn } from '@tanstack/vue-start' + +// Helper to create a delayed Uint8Array stream +function createDelayedStream( + chunks: Array, + delayMs: number, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + controller.enqueue(chunk) + } + controller.close() + }, + }) +} + +// Helper to create a stream with variable delays per chunk +// Each entry is [chunk, delayBeforeMs] - delay happens BEFORE enqueueing the chunk +function createVariableDelayStream( + chunksWithDelays: Array<[Uint8Array, number]>, +): ReadableStream { + return new ReadableStream({ + async start(controller) { + for (const [chunk, delayMs] of chunksWithDelays) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + controller.enqueue(chunk) + } + controller.close() + }, + }) +} + +// Helper to encode text to Uint8Array +function encode(text: string): Uint8Array { + return new TextEncoder().encode(text) +} + +// Export helpers for use in components and SSR routes +export { encode, createDelayedStream, concatBytes } + +// Expected data for hint tests - defined here for both server and client verification +// Test 7: Text hint with pure text +export const TEST7_CHUNKS = [ + encode('Hello, '), + encode('World! '), + encode('This is text.'), +] +export const TEST7_EXPECTED = concatBytes(TEST7_CHUNKS) + +// Test 8: Text hint with pure binary (invalid UTF-8) +export const TEST8_CHUNKS = [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), +] +export const TEST8_EXPECTED = concatBytes(TEST8_CHUNKS) + +// Test 9: Text hint with mixed content +export const TEST9_CHUNKS = [ + encode('Valid UTF-8 text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' More text'), +] +export const TEST9_EXPECTED = concatBytes(TEST9_CHUNKS) + +// Test 10: Binary hint with text data +export const TEST10_CHUNKS = [encode('This is text but using binary hint')] +export const TEST10_EXPECTED = concatBytes(TEST10_CHUNKS) + +// Test 11: Binary hint with pure binary +export const TEST11_CHUNKS = [ + new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]), +] +export const TEST11_EXPECTED = concatBytes(TEST11_CHUNKS) + +// Helper to concatenate byte arrays +function concatBytes(chunks: Array): Uint8Array { + const totalLength = chunks.reduce((acc, c) => acc + c.length, 0) + const result = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.length + } + return result +} + +// Test 1: Simple single raw stream +export const singleRawStreamFn = createServerFn().handler(async () => { + const stream = createDelayedStream( + [encode('chunk1'), encode('chunk2'), encode('chunk3')], + 50, + ) + return { + message: 'Single stream test', + data: new RawStream(stream), + } +}) + +// Test 2: Multiple raw streams +export const multipleRawStreamsFn = createServerFn().handler(async () => { + const stream1 = createDelayedStream( + [encode('stream1-a'), encode('stream1-b')], + 30, + ) + const stream2 = createDelayedStream( + [encode('stream2-a'), encode('stream2-b')], + 50, + ) + return { + message: 'Multiple streams test', + first: new RawStream(stream1), + second: new RawStream(stream2), + } +}) + +// Test 3: JSON streaming ends before raw stream +export const jsonEndsFirstFn = createServerFn().handler(async () => { + // Slow raw stream (takes 500ms total) + const slowStream = createDelayedStream( + [encode('slow-1'), encode('slow-2'), encode('slow-3'), encode('slow-4')], + 125, + ) + return { + message: 'JSON ends first test', + timestamp: Date.now(), + slowData: new RawStream(slowStream), + } +}) + +// Test 4: Raw stream ends before JSON streaming (fast stream, deferred JSON) +export const rawEndsFirstFn = createServerFn().handler(async () => { + // Fast raw stream (completes quickly) + const fastStream = createDelayedStream([encode('fast-done')], 10) + + // Deferred promise - NOT awaited, so it streams as deferred JSON + const deferredData = new Promise((resolve) => + setTimeout(() => resolve('deferred-json-data'), 200), + ) + + return { + message: 'Raw ends first test', + deferredData, + fastData: new RawStream(fastStream), + } +}) + +// Test 5: Large binary data +export const largeBinaryFn = createServerFn().handler(async () => { + // Create 1KB chunks + const chunk = new Uint8Array(1024) + for (let i = 0; i < chunk.length; i++) { + chunk[i] = i % 256 + } + + const stream = createDelayedStream([chunk, chunk, chunk], 20) + + return { + message: 'Large binary test', + size: 3072, + binary: new RawStream(stream), + } +}) + +// Test 6: Mixed streaming (promise + raw stream) +export const mixedStreamingFn = createServerFn().handler(async () => { + const rawStream = createDelayedStream( + [encode('mixed-raw-1'), encode('mixed-raw-2')], + 50, + ) + + return { + immediate: 'immediate-value', + deferred: new Promise((resolve) => + setTimeout(() => resolve('deferred-value'), 100), + ), + raw: new RawStream(rawStream), + } +}) + +// Test 7: Text hint with pure text data (should use UTF-8 encoding) +export const textHintPureTextFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST7_CHUNKS, 30) + return { + message: 'Text hint with pure text', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 8: Text hint with pure binary data (should fallback to base64) +export const textHintPureBinaryFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST8_CHUNKS, 30) + return { + message: 'Text hint with pure binary', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 9: Text hint with mixed content (some UTF-8, some binary) +export const textHintMixedFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST9_CHUNKS, 30) + return { + message: 'Text hint with mixed content', + data: new RawStream(stream, { hint: 'text' }), + } +}) + +// Test 10: Binary hint with text data (should still use base64) +export const binaryHintTextFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST10_CHUNKS, 30) + return { + message: 'Binary hint with text data', + data: new RawStream(stream, { hint: 'binary' }), + } +}) + +// Test 11: Binary hint with pure binary data +export const binaryHintBinaryFn = createServerFn().handler(async () => { + const stream = createDelayedStream(TEST11_CHUNKS, 30) + return { + message: 'Binary hint with binary data', + data: new RawStream(stream, { hint: 'binary' }), + } +}) + +// ============================================================================ +// MULTIPLEXING TESTS - Verify correct interleaving of multiple streams +// ============================================================================ + +// Expected data for multiplexing tests +// Test 12: Two streams with interleaved timing +// Stream A: sends at 0ms, 150ms, 200ms (3 chunks with pauses) +// Stream B: sends at 50ms, 100ms, 250ms (3 chunks, different rhythm) +export const TEST12_STREAM_A_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('A1-first'), 0], // immediate + [encode('A2-after-pause'), 150], // 150ms pause + [encode('A3-quick'), 50], // 50ms after A2 +] +export const TEST12_STREAM_B_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('B1-start'), 50], // 50ms after start + [encode('B2-continue'), 50], // 50ms after B1 + [encode('B3-final'), 150], // 150ms pause then final +] +export const TEST12_STREAM_A_EXPECTED = concatBytes( + TEST12_STREAM_A_CHUNKS.map(([chunk]) => chunk), +) +export const TEST12_STREAM_B_EXPECTED = concatBytes( + TEST12_STREAM_B_CHUNKS.map(([chunk]) => chunk), +) + +// Test 13: Burst-pause-burst pattern (single stream) +// 3 chunks quickly, long pause, 3 more chunks quickly +export const TEST13_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('burst1-a'), 10], + [encode('burst1-b'), 10], + [encode('burst1-c'), 10], + [encode('pause-then-burst2-a'), 200], // long pause + [encode('burst2-b'), 10], + [encode('burst2-c'), 10], +] +export const TEST13_EXPECTED = concatBytes( + TEST13_CHUNKS.map(([chunk]) => chunk), +) + +// Test 14: Three concurrent streams with different patterns +// Stream A: fast steady (every 30ms) +// Stream B: slow steady (every 100ms) +// Stream C: burst pattern (quick-pause-quick) +export const TEST14_STREAM_A_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('A1'), 30], + [encode('A2'), 30], + [encode('A3'), 30], + [encode('A4'), 30], +] +export const TEST14_STREAM_B_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('B1-slow'), 100], + [encode('B2-slow'), 100], +] +export const TEST14_STREAM_C_CHUNKS: Array<[Uint8Array, number]> = [ + [encode('C1-burst'), 20], + [encode('C2-burst'), 20], + [encode('C3-after-pause'), 150], +] +export const TEST14_STREAM_A_EXPECTED = concatBytes( + TEST14_STREAM_A_CHUNKS.map(([chunk]) => chunk), +) +export const TEST14_STREAM_B_EXPECTED = concatBytes( + TEST14_STREAM_B_CHUNKS.map(([chunk]) => chunk), +) +export const TEST14_STREAM_C_EXPECTED = concatBytes( + TEST14_STREAM_C_CHUNKS.map(([chunk]) => chunk), +) + +// Test 12: Interleaved multiplexing - two streams with variable delays +export const interleavedStreamsFn = createServerFn().handler(async () => { + const streamA = createVariableDelayStream(TEST12_STREAM_A_CHUNKS) + const streamB = createVariableDelayStream(TEST12_STREAM_B_CHUNKS) + + return { + message: 'Interleaved streams test', + streamA: new RawStream(streamA), + streamB: new RawStream(streamB), + } +}) + +// Test 13: Burst-pause-burst pattern +export const burstPauseBurstFn = createServerFn().handler(async () => { + const stream = createVariableDelayStream(TEST13_CHUNKS) + + return { + message: 'Burst-pause-burst test', + data: new RawStream(stream), + } +}) + +// Test 14: Three concurrent streams +export const threeStreamsFn = createServerFn().handler(async () => { + const streamA = createVariableDelayStream(TEST14_STREAM_A_CHUNKS) + const streamB = createVariableDelayStream(TEST14_STREAM_B_CHUNKS) + const streamC = createVariableDelayStream(TEST14_STREAM_C_CHUNKS) + + return { + message: 'Three concurrent streams test', + fast: new RawStream(streamA), + slow: new RawStream(streamB), + burst: new RawStream(streamC), + } +}) + +// ============================================================================ +// EDGE CASE TESTS +// ============================================================================ + +// Test 15: Empty stream (zero bytes) +export const emptyStreamFn = createServerFn().handler(async () => { + // Stream that immediately closes with no data + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + + return { + message: 'Empty stream test', + data: new RawStream(stream), + } +}) + +// Test 16: Stream that errors mid-flight +export const errorStreamFn = createServerFn().handler(async () => { + // Stream that sends some data then errors + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encode('chunk-before-error')) + await new Promise((resolve) => setTimeout(resolve, 50)) + controller.error(new Error('Intentional stream error')) + }, + }) + + return { + message: 'Error stream test', + data: new RawStream(stream), + } +}) + +// Helpers for consuming streams (exported for use in components) +// Note: RawStream is the marker class used in loaders/server functions, +// but after SSR deserialization it becomes ReadableStream. +// We accept both types to handle the TypeScript mismatch. +function getActualStream( + stream: ReadableStream | RawStream, +): ReadableStream { + return stream instanceof RawStream + ? stream.stream + : (stream as ReadableStream) +} + +const streamTextCache = new WeakMap< + ReadableStream, + Promise +>() +const streamBytesCache = new WeakMap< + ReadableStream, + Promise +>() +const streamByteCountCache = new WeakMap< + ReadableStream, + Promise +>() + +export function createStreamConsumer() { + const decoder = new TextDecoder() + + return async function consumeStream( + stream: ReadableStream | RawStream, + ): Promise { + const actualStream = getActualStream(stream) + const cached = streamTextCache.get(actualStream) + if (cached) { + return cached + } + + const promise = (async () => { + const reader = actualStream.getReader() + const chunks: Array = [] + + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(decoder.decode(value, { stream: true })) + } + } finally { + reader.releaseLock() + } + + return chunks.join('') + })() + + streamTextCache.set(actualStream, promise) + return promise + } +} + +export async function consumeBinaryStream( + stream: ReadableStream | RawStream, +): Promise { + const actualStream = getActualStream(stream) + const cached = streamByteCountCache.get(actualStream) + if (cached) { + return cached + } + + const promise = (async () => { + const reader = actualStream.getReader() + let totalBytes = 0 + + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.length + } + } finally { + reader.releaseLock() + } + + return totalBytes + })() + + streamByteCountCache.set(actualStream, promise) + return promise +} + +// Helper to collect all bytes from a stream +export async function collectBytes( + stream: ReadableStream | RawStream, +): Promise { + const actualStream = getActualStream(stream) + const cached = streamBytesCache.get(actualStream) + if (cached) { + return cached + } + + const promise = (async () => { + const reader = actualStream.getReader() + const chunks: Array = [] + let totalLength = 0 + + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + totalLength += value.length + } + } finally { + reader.releaseLock() + } + + const result = new Uint8Array(totalLength) + let pos = 0 + for (const chunk of chunks) { + result.set(chunk, pos) + pos += chunk.length + } + return result + })() + + streamBytesCache.set(actualStream, promise) + return promise +} + +// Compare two Uint8Arrays byte-by-byte +export function compareBytes( + actual: Uint8Array, + expected: Uint8Array, +): { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} { + if (actual.length !== expected.length) { + return { + match: false, + mismatchIndex: -1, // -1 indicates length mismatch + actualLength: actual.length, + expectedLength: expected.length, + } + } + for (let i = 0; i < actual.length; i++) { + if (actual[i] !== expected[i]) { + return { + match: false, + mismatchIndex: i, + actualLength: actual.length, + expectedLength: expected.length, + } + } + } + return { + match: true, + mismatchIndex: null, + actualLength: actual.length, + expectedLength: expected.length, + } +} diff --git a/e2e/vue-start/basic/src/routeTree.gen.ts b/e2e/vue-start/basic/src/routeTree.gen.ts index aa30a6bac12..422ba41bb43 100644 --- a/e2e/vue-start/basic/src/routeTree.gen.ts +++ b/e2e/vue-start/basic/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as Char45824Char54620Char48124Char44397RouteImport } from './rout import { Route as UsersRouteImport } from './routes/users' import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' +import { Route as RawStreamRouteImport } from './routes/raw-stream' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' @@ -24,6 +25,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' +import { Route as RawStreamIndexRouteImport } from './routes/raw-stream/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as NotFoundIndexRouteImport } from './routes/not-found/index' import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index' @@ -31,6 +33,12 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId' import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/search-params/loader-throws-redirect' import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default' import { Route as RedirectTargetRouteImport } from './routes/redirect/$target' +import { Route as RawStreamSsrTextHintRouteImport } from './routes/raw-stream/ssr-text-hint' +import { Route as RawStreamSsrSingleRouteImport } from './routes/raw-stream/ssr-single' +import { Route as RawStreamSsrMultipleRouteImport } from './routes/raw-stream/ssr-multiple' +import { Route as RawStreamSsrMixedRouteImport } from './routes/raw-stream/ssr-mixed' +import { Route as RawStreamSsrBinaryHintRouteImport } from './routes/raw-stream/ssr-binary-hint' +import { Route as RawStreamClientCallRouteImport } from './routes/raw-stream/client-call' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader' import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad' @@ -70,6 +78,11 @@ const ScriptsRoute = ScriptsRouteImport.update({ path: '/scripts', getParentRoute: () => rootRouteImport, } as any) +const RawStreamRoute = RawStreamRouteImport.update({ + id: '/raw-stream', + path: '/raw-stream', + getParentRoute: () => rootRouteImport, +} as any) const PostsRoute = PostsRouteImport.update({ id: '/posts', path: '/posts', @@ -124,6 +137,11 @@ const RedirectIndexRoute = RedirectIndexRouteImport.update({ path: '/redirect/', getParentRoute: () => rootRouteImport, } as any) +const RawStreamIndexRoute = RawStreamIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => RawStreamRoute, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -161,6 +179,36 @@ const RedirectTargetRoute = RedirectTargetRouteImport.update({ path: '/redirect/$target', getParentRoute: () => rootRouteImport, } as any) +const RawStreamSsrTextHintRoute = RawStreamSsrTextHintRouteImport.update({ + id: '/ssr-text-hint', + path: '/ssr-text-hint', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrSingleRoute = RawStreamSsrSingleRouteImport.update({ + id: '/ssr-single', + path: '/ssr-single', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrMultipleRoute = RawStreamSsrMultipleRouteImport.update({ + id: '/ssr-multiple', + path: '/ssr-multiple', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrMixedRoute = RawStreamSsrMixedRouteImport.update({ + id: '/ssr-mixed', + path: '/ssr-mixed', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamSsrBinaryHintRoute = RawStreamSsrBinaryHintRouteImport.update({ + id: '/ssr-binary-hint', + path: '/ssr-binary-hint', + getParentRoute: () => RawStreamRoute, +} as any) +const RawStreamClientCallRoute = RawStreamClientCallRouteImport.update({ + id: '/client-call', + path: '/client-call', + getParentRoute: () => RawStreamRoute, +} as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ id: '/$postId', path: '/$postId', @@ -260,6 +308,7 @@ export interface FileRoutesByFullPath { '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren @@ -269,6 +318,12 @@ export interface FileRoutesByFullPath { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute @@ -276,6 +331,7 @@ export interface FileRoutesByFullPath { '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute + '/raw-stream/': typeof RawStreamIndexRoute '/redirect': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute @@ -304,12 +360,19 @@ export interface FileRoutesByTo { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute '/users/$userId': typeof UsersUserIdRoute '/multi-cookie-redirect': typeof MultiCookieRedirectIndexRoute '/not-found': typeof NotFoundIndexRoute '/posts': typeof PostsIndexRoute + '/raw-stream': typeof RawStreamIndexRoute '/redirect': typeof RedirectIndexRoute '/search-params': typeof SearchParamsIndexRoute '/users': typeof UsersIndexRoute @@ -335,6 +398,7 @@ export interface FileRoutesById { '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRouteWithChildren '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute '/users': typeof UsersRouteWithChildren @@ -345,6 +409,12 @@ export interface FileRoutesById { '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-loader': typeof NotFoundViaLoaderRoute '/posts/$postId': typeof PostsPostIdRoute + '/raw-stream/client-call': typeof RawStreamClientCallRoute + '/raw-stream/ssr-binary-hint': typeof RawStreamSsrBinaryHintRoute + '/raw-stream/ssr-mixed': typeof RawStreamSsrMixedRoute + '/raw-stream/ssr-multiple': typeof RawStreamSsrMultipleRoute + '/raw-stream/ssr-single': typeof RawStreamSsrSingleRoute + '/raw-stream/ssr-text-hint': typeof RawStreamSsrTextHintRoute '/redirect/$target': typeof RedirectTargetRouteWithChildren '/search-params/default': typeof SearchParamsDefaultRoute '/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute @@ -352,6 +422,7 @@ export interface FileRoutesById { '/multi-cookie-redirect/': typeof MultiCookieRedirectIndexRoute '/not-found/': typeof NotFoundIndexRoute '/posts/': typeof PostsIndexRoute + '/raw-stream/': typeof RawStreamIndexRoute '/redirect/': typeof RedirectIndexRoute '/search-params/': typeof SearchParamsIndexRoute '/users/': typeof UsersIndexRoute @@ -377,6 +448,7 @@ export interface FileRouteTypes { | '/inline-scripts' | '/links' | '/posts' + | '/raw-stream' | '/scripts' | '/stream' | '/users' @@ -386,6 +458,12 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' @@ -393,6 +471,7 @@ export interface FileRouteTypes { | '/multi-cookie-redirect' | '/not-found/' | '/posts/' + | '/raw-stream/' | '/redirect' | '/search-params/' | '/users/' @@ -421,12 +500,19 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/search-params/default' | '/search-params/loader-throws-redirect' | '/users/$userId' | '/multi-cookie-redirect' | '/not-found' | '/posts' + | '/raw-stream' | '/redirect' | '/search-params' | '/users' @@ -451,6 +537,7 @@ export interface FileRouteTypes { | '/inline-scripts' | '/links' | '/posts' + | '/raw-stream' | '/scripts' | '/stream' | '/users' @@ -461,6 +548,12 @@ export interface FileRouteTypes { | '/not-found/via-beforeLoad' | '/not-found/via-loader' | '/posts/$postId' + | '/raw-stream/client-call' + | '/raw-stream/ssr-binary-hint' + | '/raw-stream/ssr-mixed' + | '/raw-stream/ssr-multiple' + | '/raw-stream/ssr-single' + | '/raw-stream/ssr-text-hint' | '/redirect/$target' | '/search-params/default' | '/search-params/loader-throws-redirect' @@ -468,6 +561,7 @@ export interface FileRouteTypes { | '/multi-cookie-redirect/' | '/not-found/' | '/posts/' + | '/raw-stream/' | '/redirect/' | '/search-params/' | '/users/' @@ -493,6 +587,7 @@ export interface RootRouteChildren { InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren + RawStreamRoute: typeof RawStreamRouteWithChildren ScriptsRoute: typeof ScriptsRoute StreamRoute: typeof StreamRoute UsersRoute: typeof UsersRouteWithChildren @@ -535,6 +630,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof ScriptsRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream': { + id: '/raw-stream' + path: '/raw-stream' + fullPath: '/raw-stream' + preLoaderRoute: typeof RawStreamRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -612,6 +714,13 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RedirectIndexRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream/': { + id: '/raw-stream/' + path: '/' + fullPath: '/raw-stream/' + preLoaderRoute: typeof RawStreamIndexRouteImport + parentRoute: typeof RawStreamRoute + } '/posts/': { id: '/posts/' path: '/' @@ -661,6 +770,48 @@ declare module '@tanstack/vue-router' { preLoaderRoute: typeof RedirectTargetRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream/ssr-text-hint': { + id: '/raw-stream/ssr-text-hint' + path: '/ssr-text-hint' + fullPath: '/raw-stream/ssr-text-hint' + preLoaderRoute: typeof RawStreamSsrTextHintRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-single': { + id: '/raw-stream/ssr-single' + path: '/ssr-single' + fullPath: '/raw-stream/ssr-single' + preLoaderRoute: typeof RawStreamSsrSingleRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-multiple': { + id: '/raw-stream/ssr-multiple' + path: '/ssr-multiple' + fullPath: '/raw-stream/ssr-multiple' + preLoaderRoute: typeof RawStreamSsrMultipleRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-mixed': { + id: '/raw-stream/ssr-mixed' + path: '/ssr-mixed' + fullPath: '/raw-stream/ssr-mixed' + preLoaderRoute: typeof RawStreamSsrMixedRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/ssr-binary-hint': { + id: '/raw-stream/ssr-binary-hint' + path: '/ssr-binary-hint' + fullPath: '/raw-stream/ssr-binary-hint' + preLoaderRoute: typeof RawStreamSsrBinaryHintRouteImport + parentRoute: typeof RawStreamRoute + } + '/raw-stream/client-call': { + id: '/raw-stream/client-call' + path: '/client-call' + fullPath: '/raw-stream/client-call' + preLoaderRoute: typeof RawStreamClientCallRouteImport + parentRoute: typeof RawStreamRoute + } '/posts/$postId': { id: '/posts/$postId' path: '/$postId' @@ -851,6 +1002,30 @@ const PostsRouteChildren: PostsRouteChildren = { const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) +interface RawStreamRouteChildren { + RawStreamClientCallRoute: typeof RawStreamClientCallRoute + RawStreamSsrBinaryHintRoute: typeof RawStreamSsrBinaryHintRoute + RawStreamSsrMixedRoute: typeof RawStreamSsrMixedRoute + RawStreamSsrMultipleRoute: typeof RawStreamSsrMultipleRoute + RawStreamSsrSingleRoute: typeof RawStreamSsrSingleRoute + RawStreamSsrTextHintRoute: typeof RawStreamSsrTextHintRoute + RawStreamIndexRoute: typeof RawStreamIndexRoute +} + +const RawStreamRouteChildren: RawStreamRouteChildren = { + RawStreamClientCallRoute: RawStreamClientCallRoute, + RawStreamSsrBinaryHintRoute: RawStreamSsrBinaryHintRoute, + RawStreamSsrMixedRoute: RawStreamSsrMixedRoute, + RawStreamSsrMultipleRoute: RawStreamSsrMultipleRoute, + RawStreamSsrSingleRoute: RawStreamSsrSingleRoute, + RawStreamSsrTextHintRoute: RawStreamSsrTextHintRoute, + RawStreamIndexRoute: RawStreamIndexRoute, +} + +const RawStreamRouteWithChildren = RawStreamRoute._addFileChildren( + RawStreamRouteChildren, +) + interface UsersRouteChildren { UsersUserIdRoute: typeof UsersUserIdRoute UsersIndexRoute: typeof UsersIndexRoute @@ -910,6 +1085,7 @@ const rootRouteChildren: RootRouteChildren = { InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, + RawStreamRoute: RawStreamRouteWithChildren, ScriptsRoute: ScriptsRoute, StreamRoute: StreamRoute, UsersRoute: UsersRouteWithChildren, diff --git a/e2e/vue-start/basic/src/routes/__root.tsx b/e2e/vue-start/basic/src/routes/__root.tsx index eddd6cf154e..45b729c19c1 100644 --- a/e2e/vue-start/basic/src/routes/__root.tsx +++ b/e2e/vue-start/basic/src/routes/__root.tsx @@ -139,6 +139,14 @@ function RootComponent() { > redirect {' '} + + Raw Stream + {' '} +

Raw Stream Tests

+ + + +
+ + + + ) +} diff --git a/e2e/vue-start/basic/src/routes/raw-stream/client-call.tsx b/e2e/vue-start/basic/src/routes/raw-stream/client-call.tsx new file mode 100644 index 00000000000..2aeb7384875 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/client-call.tsx @@ -0,0 +1,524 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { defineComponent, ref } from 'vue' +import { + TEST10_EXPECTED, + TEST11_EXPECTED, + TEST12_STREAM_A_EXPECTED, + TEST12_STREAM_B_EXPECTED, + TEST13_EXPECTED, + TEST14_STREAM_A_EXPECTED, + TEST14_STREAM_B_EXPECTED, + TEST14_STREAM_C_EXPECTED, + TEST7_EXPECTED, + TEST8_EXPECTED, + TEST9_EXPECTED, + binaryHintBinaryFn, + binaryHintTextFn, + burstPauseBurstFn, + collectBytes, + compareBytes, + consumeBinaryStream, + createStreamConsumer, + emptyStreamFn, + errorStreamFn, + interleavedStreamsFn, + jsonEndsFirstFn, + largeBinaryFn, + mixedStreamingFn, + multipleRawStreamsFn, + rawEndsFirstFn, + singleRawStreamFn, + textHintMixedFn, + textHintPureBinaryFn, + textHintPureTextFn, + threeStreamsFn, +} from '../../raw-stream-fns' + +const ClientCallTests = defineComponent({ + setup() { + const results = ref>({}) + const loading = ref>({}) + + const consumeStream = createStreamConsumer() + + const runTest = async ( + testName: string, + fn: () => Promise, + processor: (result: any) => Promise, + ) => { + loading.value = { ...loading.value, [testName]: true } + try { + const result = await fn() + const processed = await processor(result) + results.value = { ...results.value, [testName]: processed } + } catch (error) { + results.value = { + ...results.value, + [testName]: { error: String(error) }, + } + } finally { + loading.value = { ...loading.value, [testName]: false } + } + } + + return () => ( +
+

Client-Side Server Function Calls (RPC)

+

+ These tests invoke server functions directly from the client, using + the binary framing protocol for RawStream data. +

+ + {/* Test 1: Single Raw Stream */} +
+

Test 1: Single Raw Stream

+ +
+            {JSON.stringify(results.value.test1)}
+          
+
+ + {/* Test 2: Multiple Raw Streams */} +
+

Test 2: Multiple Raw Streams

+ +
+            {JSON.stringify(results.value.test2)}
+          
+
+ + {/* Test 3: JSON Ends First */} +
+

Test 3: JSON Ends Before Raw Stream

+ +
+            {JSON.stringify(results.value.test3)}
+          
+
+ + {/* Test 4: Raw Ends First */} +
+

Test 4: Raw Stream Ends Before JSON

+ +
+            {JSON.stringify(results.value.test4)}
+          
+
+ + {/* Test 5: Large Binary */} +
+

Test 5: Large Binary Data

+ +
+            {JSON.stringify(results.value.test5)}
+          
+
+ + {/* Test 6: Mixed Streaming */} +
+

Test 6: Mixed Streaming

+ +
+            {JSON.stringify(results.value.test6)}
+          
+
+ + {/* Hint Tests Section */} +

Hint Parameter Tests (RPC)

+

+ These tests verify that hint parameter works correctly for RPC calls. + Note: RPC always uses binary framing regardless of hint. +

+ + {/* Test 7: Text Hint with Pure Text */} +
+

Test 7: Text Hint - Pure Text

+ +
+            {JSON.stringify(results.value.test7)}
+          
+
+ + {/* Test 8: Text Hint with Pure Binary */} +
+

Test 8: Text Hint - Pure Binary

+ +
+            {JSON.stringify(results.value.test8)}
+          
+
+ + {/* Test 9: Text Hint with Mixed Content */} +
+

Test 9: Text Hint - Mixed Content

+ +
+            {JSON.stringify(results.value.test9)}
+          
+
+ + {/* Test 10: Binary Hint with Text Data */} +
+

Test 10: Binary Hint - Text Data

+ +
+            {JSON.stringify(results.value.test10)}
+          
+
+ + {/* Test 11: Binary Hint with Binary Data */} +
+

Test 11: Binary Hint - Binary Data

+ +
+            {JSON.stringify(results.value.test11)}
+          
+
+ + {/* Multiplexing Tests Section */} +

Multiplexing Tests (RPC)

+

+ These tests verify correct interleaving of multiple concurrent + streams. +

+ + {/* Test 12: Interleaved Streams */} +
+

Test 12: Interleaved Streams

+ +
+            {JSON.stringify(results.value.test12)}
+          
+
+ + {/* Test 13: Burst-Pause-Burst */} +
+

Test 13: Burst-Pause-Burst

+ +
+            {JSON.stringify(results.value.test13)}
+          
+
+ + {/* Test 14: Three Concurrent Streams */} +
+

Test 14: Three Concurrent Streams

+ +
+            {JSON.stringify(results.value.test14)}
+          
+
+ + {/* Edge Case Tests Section */} +

Edge Case Tests (RPC)

+

+ These tests verify edge cases like empty streams and error handling. +

+ + {/* Test 15: Empty Stream */} +
+

Test 15: Empty Stream

+ +
+            {JSON.stringify(results.value.test15)}
+          
+
+ + {/* Test 16: Stream Error */} +
+

Test 16: Stream Error

+ +
+            {JSON.stringify(results.value.test16)}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/client-call')({ + component: ClientCallTests, +}) diff --git a/e2e/vue-start/basic/src/routes/raw-stream/index.tsx b/e2e/vue-start/basic/src/routes/raw-stream/index.tsx new file mode 100644 index 00000000000..76b7233c013 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/index.tsx @@ -0,0 +1,74 @@ +import { Link, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/raw-stream/')({ + component: RawStreamIndex, +}) + +function RawStreamIndex() { + return ( +
+

Select a test category above to begin testing.

+
    +
  • + + Client Calls + + - Test RawStream via direct server function calls (RPC) +
  • +
  • + + SSR Single + + - Test single RawStream from route loader (SSR) +
  • +
  • + + SSR Multiple + + - Test multiple RawStreams from route loader (SSR) +
  • +
  • + + SSR Mixed + + + {' '} + - Test RawStream mixed with deferred data from loader (SSR) + +
  • +
  • + + SSR Text Hint + + - Test RawStream with hint: 'text' from loader (SSR) +
  • +
  • + + SSR Binary Hint + + - Test RawStream with hint: 'binary' from loader (SSR) +
  • +
+
+ ) +} diff --git a/e2e/vue-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx b/e2e/vue-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx new file mode 100644 index 00000000000..3ded9f67c68 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/ssr-binary-hint.tsx @@ -0,0 +1,204 @@ +import { createFileRoute, useRouter } from '@tanstack/vue-router' +import { RawStream } from '@tanstack/vue-start' +import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { + collectBytes, + compareBytes, + concatBytes, + createDelayedStream, + encode, +} from '../../raw-stream-fns' + +// Expected data - defined at module level for client-side verification +const TEXT_CHUNKS = [encode('Binary '), encode('hint '), encode('with text')] +const TEXT_EXPECTED = concatBytes(TEXT_CHUNKS) + +const BINARY_CHUNKS = [ + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]), +] +const BINARY_EXPECTED = concatBytes(BINARY_CHUNKS) + +type TextMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number + asText: string +} + +type BinaryMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} + +const SSRBinaryHintTest = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const router = useRouter() + const textMatch = ref(null) + const binaryMatch = ref(null) + const isLoading = ref(true) + const error = ref(null) + + let consumeRunId = 0 + const consumeHintStreams = ( + textData: ReadableStream | RawStream | undefined, + binaryData: ReadableStream | RawStream | undefined, + ) => { + if (!textData || !binaryData) { + return Promise.resolve() + } + const currentRun = ++consumeRunId + isLoading.value = true + error.value = null + return Promise.all([collectBytes(textData), collectBytes(binaryData)]) + .then(([textBytes, binaryBytes]) => { + if (currentRun !== consumeRunId) { + return + } + const textComp = compareBytes(textBytes, TEXT_EXPECTED) + const decoder = new TextDecoder() + textMatch.value = { + ...textComp, + actualLength: textBytes.length, + expectedLength: TEXT_EXPECTED.length, + asText: decoder.decode(textBytes), + } + const binaryComp = compareBytes(binaryBytes, BINARY_EXPECTED) + binaryMatch.value = { + ...binaryComp, + actualLength: binaryBytes.length, + expectedLength: BINARY_EXPECTED.length, + } + isLoading.value = false + }) + .catch((err) => { + if (currentRun !== consumeRunId) { + return + } + error.value = String(err) + isLoading.value = false + }) + } + + let stopWatcher: (() => void) | undefined + let lastStreams: + | [ + ReadableStream | RawStream, + ReadableStream | RawStream, + ] + | undefined + let didInvalidate = false + + onMounted(() => { + stopWatcher = watch( + () => [loaderData.value.textData, loaderData.value.binaryData], + ([textData, binaryData]) => { + if (!textData || !binaryData) { + return + } + if ( + lastStreams && + lastStreams[0] === textData && + lastStreams[1] === binaryData + ) { + return + } + lastStreams = [textData, binaryData] + void consumeHintStreams(textData, binaryData) + }, + { immediate: true }, + ) + + if (__TSR_PRERENDER__ && !didInvalidate) { + didInvalidate = true + void router.invalidate({ + filter: (match) => match.routeId === Route.id, + }) + } + }) + + onBeforeUnmount(() => { + stopWatcher?.() + }) + + return () => ( +
+

SSR Binary Hint Test

+

+ This route tests RawStream with hint: 'binary' from loader. Binary + hint always uses base64 encoding (default behavior). +

+ +
+
+ Message: {loaderData.value.message} +
+
+ Text Data: + {error.value + ? `Error: ${error.value}` + : isLoading.value + ? 'Loading...' + : textMatch.value?.asText} +
+
+ Text Bytes Match: + {isLoading.value + ? 'Loading...' + : textMatch.value?.match + ? 'true' + : 'false'} +
+
+ Binary Bytes Match: + {isLoading.value + ? 'Loading...' + : binaryMatch.value?.match + ? 'true' + : 'false'} +
+
+            {JSON.stringify({
+              message: loaderData.value.message,
+              textMatch: textMatch.value,
+              binaryMatch: binaryMatch.value,
+              isLoading: isLoading.value,
+              error: error.value,
+            })}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/ssr-binary-hint')({ + loader: async () => { + // Text data with binary hint - should still use base64 (default behavior) + const textStream = createDelayedStream( + [encode('Binary '), encode('hint '), encode('with text')], + 30, + ) + + // Pure binary stream with binary hint + const binaryStream = createDelayedStream( + [ + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]), + ], + 30, + ) + + return { + message: 'SSR Binary Hint Test', + textData: new RawStream(textStream, { hint: 'binary' }), + binaryData: new RawStream(binaryStream, { hint: 'binary' }), + } + }, + shouldReload: __TSR_PRERENDER__, + component: SSRBinaryHintTest, +}) diff --git a/e2e/vue-start/basic/src/routes/raw-stream/ssr-mixed.tsx b/e2e/vue-start/basic/src/routes/raw-stream/ssr-mixed.tsx new file mode 100644 index 00000000000..1a311a2015b --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/ssr-mixed.tsx @@ -0,0 +1,150 @@ +import { Await, createFileRoute, useRouter } from '@tanstack/vue-router' +import { RawStream } from '@tanstack/vue-start' +import { + Suspense, + defineComponent, + onBeforeUnmount, + onMounted, + ref, + watch, +} from 'vue' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +const SSRMixedTest = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const router = useRouter() + const streamContent = ref('') + const isConsuming = ref(true) + const error = ref(null) + + let consumeRunId = 0 + const consumeRawStream = ( + rawData: ReadableStream | RawStream | undefined, + ) => { + if (!rawData) { + return Promise.resolve() + } + const consumeStream = createStreamConsumer() + const currentRun = ++consumeRunId + isConsuming.value = true + error.value = null + return consumeStream(rawData) + .then((content) => { + if (currentRun !== consumeRunId) { + return + } + streamContent.value = content + isConsuming.value = false + }) + .catch((err) => { + if (currentRun !== consumeRunId) { + return + } + error.value = String(err) + isConsuming.value = false + }) + } + + let stopWatcher: (() => void) | undefined + let lastRawData: ReadableStream | RawStream | undefined + let didInvalidate = false + + onMounted(() => { + stopWatcher = watch( + () => loaderData.value.rawData, + (rawData) => { + if (!rawData || rawData === lastRawData) { + return + } + lastRawData = rawData + void consumeRawStream(rawData) + }, + { immediate: true }, + ) + + if (__TSR_PRERENDER__ && !didInvalidate) { + didInvalidate = true + void router.invalidate({ + filter: (match) => match.routeId === Route.id, + }) + } + }) + + onBeforeUnmount(() => { + stopWatcher?.() + }) + + return () => ( +
+

SSR Mixed Streaming Test

+

+ This route returns a mix of immediate data, deferred promises, and + RawStream from its loader. +

+ +
+
+ Immediate: {loaderData.value.immediate} +
+
+ Deferred: + + {{ + default: () => ( + {value}} + /> + ), + fallback: () => Loading deferred..., + }} + +
+
+ Stream Content: + {error.value + ? `Error: ${error.value}` + : isConsuming.value + ? 'Loading...' + : streamContent.value} +
+
+            {JSON.stringify({
+              immediate: loaderData.value.immediate,
+              streamContent: streamContent.value,
+              isConsuming: isConsuming.value,
+              error: error.value,
+            })}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/ssr-mixed')({ + loader: () => { + const rawStream = createDelayedStream( + [encode('mixed-ssr-1'), encode('mixed-ssr-2')], + 50, + ) + + // Deferred promise that resolves after a delay + const deferredData = new Promise((resolve) => + setTimeout(() => resolve('deferred-ssr-value'), 100), + ) + + return { + immediate: 'immediate-ssr-value', + deferred: deferredData, + rawData: new RawStream(rawStream), + } + }, + shouldReload: __TSR_PRERENDER__, + component: SSRMixedTest, +}) diff --git a/e2e/vue-start/basic/src/routes/raw-stream/ssr-multiple.tsx b/e2e/vue-start/basic/src/routes/raw-stream/ssr-multiple.tsx new file mode 100644 index 00000000000..5a58e68437c --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/ssr-multiple.tsx @@ -0,0 +1,151 @@ +import { createFileRoute, useRouter } from '@tanstack/vue-router' +import { RawStream } from '@tanstack/vue-start' +import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +const SSRMultipleTest = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const router = useRouter() + const firstContent = ref('') + const secondContent = ref('') + const isConsuming = ref(true) + const error = ref(null) + + let consumeRunId = 0 + const consumeRawStreams = ( + first: ReadableStream | RawStream | undefined, + second: ReadableStream | RawStream | undefined, + ) => { + if (!first || !second) { + return Promise.resolve() + } + const consumeStream = createStreamConsumer() + const currentRun = ++consumeRunId + isConsuming.value = true + error.value = null + return Promise.all([consumeStream(first), consumeStream(second)]) + .then(([content1, content2]) => { + if (currentRun !== consumeRunId) { + return + } + firstContent.value = content1 + secondContent.value = content2 + isConsuming.value = false + }) + .catch((err) => { + if (currentRun !== consumeRunId) { + return + } + error.value = String(err) + isConsuming.value = false + }) + } + + let stopWatcher: (() => void) | undefined + let lastStreams: + | [ + ReadableStream | RawStream, + ReadableStream | RawStream, + ] + | undefined + let didInvalidate = false + + onMounted(() => { + stopWatcher = watch( + () => [loaderData.value.first, loaderData.value.second], + ([first, second]) => { + if (!first || !second) { + return + } + if ( + lastStreams && + lastStreams[0] === first && + lastStreams[1] === second + ) { + return + } + lastStreams = [first, second] + void consumeRawStreams(first, second) + }, + { immediate: true }, + ) + + if (__TSR_PRERENDER__ && !didInvalidate) { + didInvalidate = true + void router.invalidate({ + filter: (match) => match.routeId === Route.id, + }) + } + }) + + onBeforeUnmount(() => { + stopWatcher?.() + }) + + return () => ( +
+

SSR Multiple RawStreams Test

+

+ This route returns multiple RawStreams from its loader. Each stream is + independently serialized during SSR. +

+ +
+
+ Message: {loaderData.value.message} +
+
+ First Stream: + {error.value + ? `Error: ${error.value}` + : isConsuming.value + ? 'Loading...' + : firstContent.value} +
+
+ Second Stream: + {error.value + ? `Error: ${error.value}` + : isConsuming.value + ? 'Loading...' + : secondContent.value} +
+
+            {JSON.stringify({
+              message: loaderData.value.message,
+              firstContent: firstContent.value,
+              secondContent: secondContent.value,
+              isConsuming: isConsuming.value,
+              error: error.value,
+            })}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/ssr-multiple')({ + loader: async () => { + const stream1 = createDelayedStream( + [encode('multi-1a'), encode('multi-1b')], + 30, + ) + const stream2 = createDelayedStream( + [encode('multi-2a'), encode('multi-2b')], + 50, + ) + return { + message: 'SSR Multiple Streams Test', + first: new RawStream(stream1), + second: new RawStream(stream2), + } + }, + shouldReload: __TSR_PRERENDER__, + component: SSRMultipleTest, +}) diff --git a/e2e/vue-start/basic/src/routes/raw-stream/ssr-single.tsx b/e2e/vue-start/basic/src/routes/raw-stream/ssr-single.tsx new file mode 100644 index 00000000000..ddb47fe179e --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/ssr-single.tsx @@ -0,0 +1,133 @@ +import { createFileRoute, useRouter } from '@tanstack/vue-router' +import { RawStream } from '@tanstack/vue-start' +import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { + createDelayedStream, + createStreamConsumer, + encode, +} from '../../raw-stream-fns' + +const SSRSingleTest = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const router = useRouter() + const streamContent = ref('') + const isConsuming = ref(true) + const error = ref(null) + + let consumeRunId = 0 + const consumeRawStream = ( + rawData: ReadableStream | RawStream | undefined, + ) => { + if (!rawData) { + return Promise.resolve() + } + const consumeStream = createStreamConsumer() + const currentRun = ++consumeRunId + isConsuming.value = true + error.value = null + return consumeStream(rawData) + .then((content) => { + if (currentRun !== consumeRunId) { + return + } + streamContent.value = content + isConsuming.value = false + }) + .catch((err) => { + if (currentRun !== consumeRunId) { + return + } + error.value = String(err) + isConsuming.value = false + }) + } + + let stopWatcher: (() => void) | undefined + let lastRawData: ReadableStream | RawStream | undefined + let didInvalidate = false + + onMounted(() => { + stopWatcher = watch( + () => loaderData.value.rawData, + (rawData) => { + if (!rawData || rawData === lastRawData) { + return + } + lastRawData = rawData + void consumeRawStream(rawData) + }, + { immediate: true }, + ) + + if (__TSR_PRERENDER__ && !didInvalidate) { + didInvalidate = true + void router.invalidate({ + filter: (match) => match.routeId === Route.id, + }) + } + }) + + onBeforeUnmount(() => { + stopWatcher?.() + }) + + return () => ( +
+

SSR Single RawStream Test

+

+ This route returns a single RawStream from its loader. The stream is + serialized during SSR using base64 encoding. +

+ +
+
+ Message: {loaderData.value.message} +
+
+ Has Timestamp:{' '} + {typeof loaderData.value.timestamp === 'number' ? 'true' : 'false'} +
+
+ Stream Content: + {error.value + ? `Error: ${error.value}` + : isConsuming.value + ? 'Loading...' + : streamContent.value} +
+
+ RawData Type: {typeof loaderData.value.rawData} | hasStream: + {loaderData.value.rawData && 'getReader' in loaderData.value.rawData + ? 'true' + : 'false'} +
+
+            {JSON.stringify({
+              message: loaderData.value.message,
+              streamContent: streamContent.value,
+              isConsuming: isConsuming.value,
+              error: error.value,
+            })}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/ssr-single')({ + loader: async () => { + const stream = createDelayedStream( + [encode('ssr-chunk1'), encode('ssr-chunk2'), encode('ssr-chunk3')], + 50, + ) + return { + message: 'SSR Single Stream Test', + timestamp: Date.now(), + rawData: new RawStream(stream), + } + }, + shouldReload: __TSR_PRERENDER__, + component: SSRSingleTest, +}) diff --git a/e2e/vue-start/basic/src/routes/raw-stream/ssr-text-hint.tsx b/e2e/vue-start/basic/src/routes/raw-stream/ssr-text-hint.tsx new file mode 100644 index 00000000000..aad51b29715 --- /dev/null +++ b/e2e/vue-start/basic/src/routes/raw-stream/ssr-text-hint.tsx @@ -0,0 +1,258 @@ +import { createFileRoute, useRouter } from '@tanstack/vue-router' +import { RawStream } from '@tanstack/vue-start' +import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { + collectBytes, + compareBytes, + concatBytes, + createDelayedStream, + encode, +} from '../../raw-stream-fns' + +// Expected data - defined at module level for client-side verification +const PURE_TEXT_CHUNKS = [ + encode('Hello '), + encode('World '), + encode('from SSR!'), +] +const PURE_TEXT_EXPECTED = concatBytes(PURE_TEXT_CHUNKS) + +const MIXED_CHUNKS = [ + encode('Valid text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' more text'), +] +const MIXED_EXPECTED = concatBytes(MIXED_CHUNKS) + +// Pure binary data (invalid UTF-8) - must use base64 fallback +const PURE_BINARY_CHUNKS = [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), +] +const PURE_BINARY_EXPECTED = concatBytes(PURE_BINARY_CHUNKS) + +type TextMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number + asText: string +} + +type BinaryMatch = { + match: boolean + mismatchIndex: number | null + actualLength: number + expectedLength: number +} + +const SSRTextHintTest = defineComponent({ + setup() { + const loaderData = Route.useLoaderData() + const router = useRouter() + const pureTextMatch = ref(null) + const mixedMatch = ref(null) + const pureBinaryMatch = ref(null) + const isLoading = ref(true) + const error = ref(null) + + let consumeRunId = 0 + const consumeHintStreams = ( + pureText: ReadableStream | RawStream | undefined, + mixedContent: ReadableStream | RawStream | undefined, + pureBinary: ReadableStream | RawStream | undefined, + ) => { + if (!pureText || !mixedContent || !pureBinary) { + return Promise.resolve() + } + const currentRun = ++consumeRunId + isLoading.value = true + error.value = null + return Promise.all([ + collectBytes(pureText), + collectBytes(mixedContent), + collectBytes(pureBinary), + ]) + .then(([pureBytes, mixedBytes, pureBinaryBytes]) => { + if (currentRun !== consumeRunId) { + return + } + const pureComp = compareBytes(pureBytes, PURE_TEXT_EXPECTED) + const decoder = new TextDecoder() + pureTextMatch.value = { + ...pureComp, + actualLength: pureBytes.length, + expectedLength: PURE_TEXT_EXPECTED.length, + asText: decoder.decode(pureBytes), + } + const mixedComp = compareBytes(mixedBytes, MIXED_EXPECTED) + mixedMatch.value = { + ...mixedComp, + actualLength: mixedBytes.length, + expectedLength: MIXED_EXPECTED.length, + } + const pureBinaryComp = compareBytes( + pureBinaryBytes, + PURE_BINARY_EXPECTED, + ) + pureBinaryMatch.value = { + ...pureBinaryComp, + actualLength: pureBinaryBytes.length, + expectedLength: PURE_BINARY_EXPECTED.length, + } + isLoading.value = false + }) + .catch((err) => { + if (currentRun !== consumeRunId) { + return + } + error.value = String(err) + isLoading.value = false + }) + } + + let stopWatcher: (() => void) | undefined + let lastStreams: + | [ + ReadableStream | RawStream, + ReadableStream | RawStream, + ReadableStream | RawStream, + ] + | undefined + let didInvalidate = false + + onMounted(() => { + stopWatcher = watch( + () => [ + loaderData.value.pureText, + loaderData.value.mixedContent, + loaderData.value.pureBinary, + ], + ([pureText, mixedContent, pureBinary]) => { + if (!pureText || !mixedContent || !pureBinary) { + return + } + if ( + lastStreams && + lastStreams[0] === pureText && + lastStreams[1] === mixedContent && + lastStreams[2] === pureBinary + ) { + return + } + lastStreams = [pureText, mixedContent, pureBinary] + void consumeHintStreams(pureText, mixedContent, pureBinary) + }, + { immediate: true }, + ) + + if (__TSR_PRERENDER__ && !didInvalidate) { + didInvalidate = true + void router.invalidate({ + filter: (match) => match.routeId === Route.id, + }) + } + }) + + onBeforeUnmount(() => { + stopWatcher?.() + }) + + return () => ( +
+

SSR Text Hint Test

+

+ This route tests RawStream with hint: 'text' from loader. Text hint + optimizes for UTF-8 content but falls back to base64 for invalid + UTF-8. +

+ +
+
+ Message: {loaderData.value.message} +
+
+ Pure Text: + {error.value + ? `Error: ${error.value}` + : isLoading.value + ? 'Loading...' + : pureTextMatch.value?.asText} +
+
+ Pure Text Bytes Match: + {isLoading.value + ? 'Loading...' + : pureTextMatch.value?.match + ? 'true' + : 'false'} +
+
+ Mixed Content Bytes Match: + {isLoading.value + ? 'Loading...' + : mixedMatch.value?.match + ? 'true' + : 'false'} +
+
+ Pure Binary Bytes Match: + {isLoading.value + ? 'Loading...' + : pureBinaryMatch.value?.match + ? 'true' + : 'false'} +
+
+            {JSON.stringify({
+              message: loaderData.value.message,
+              pureTextMatch: pureTextMatch.value,
+              mixedMatch: mixedMatch.value,
+              pureBinaryMatch: pureBinaryMatch.value,
+              isLoading: isLoading.value,
+              error: error.value,
+            })}
+          
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/raw-stream/ssr-text-hint')({ + loader: async () => { + // Pure text stream - should use UTF-8 encoding with text hint + const textStream = createDelayedStream( + [encode('Hello '), encode('World '), encode('from SSR!')], + 30, + ) + + // Mixed content stream - text hint should use UTF-8 for valid text, base64 for binary + const mixedStream = createDelayedStream( + [ + encode('Valid text'), + new Uint8Array([0xff, 0xfe, 0x80, 0x90]), // Invalid UTF-8 + encode(' more text'), + ], + 30, + ) + + // Pure binary stream - text hint must fallback to base64 for all chunks + const pureBinaryStream = createDelayedStream( + [ + new Uint8Array([0xff, 0xfe, 0x00, 0x01, 0x80, 0x90]), + new Uint8Array([0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0]), + ], + 30, + ) + + return { + message: 'SSR Text Hint Test', + pureText: new RawStream(textStream, { hint: 'text' }), + mixedContent: new RawStream(mixedStream, { hint: 'text' }), + pureBinary: new RawStream(pureBinaryStream, { hint: 'text' }), + } + }, + shouldReload: __TSR_PRERENDER__, + component: SSRTextHintTest, +}) diff --git a/e2e/vue-start/basic/src/vite-env.d.ts b/e2e/vue-start/basic/src/vite-env.d.ts index 4e6e90c8468..e8259b1e33c 100644 --- a/e2e/vue-start/basic/src/vite-env.d.ts +++ b/e2e/vue-start/basic/src/vite-env.d.ts @@ -9,3 +9,5 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + +declare const __TSR_PRERENDER__: boolean diff --git a/e2e/vue-start/basic/tests/raw-stream.spec.ts b/e2e/vue-start/basic/tests/raw-stream.spec.ts new file mode 100644 index 00000000000..b47b6c0c22b --- /dev/null +++ b/e2e/vue-start/basic/tests/raw-stream.spec.ts @@ -0,0 +1,666 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' + +/** + * These tests verify the RawStream binary streaming functionality. + * + * RawStream allows returning ReadableStream from server functions + * with efficient binary encoding: + * - Server functions (RPC): Binary framing protocol + * - SSR loaders: Base64 encoding via seroval's stream mechanism + */ + +// Wait time for hydration to complete after page load +// This needs to be long enough for React hydration to attach event handlers +const HYDRATION_WAIT = 1000 + +test.describe('RawStream - Client RPC Tests', () => { + test('Single raw stream - returns stream with binary data', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + // Wait for hydration + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test1-result')).toContainText( + 'Single stream test', + ) + }) + + test('Multiple raw streams - returns multiple independent streams', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test2-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test2-btn').click() + + await expect(page.getByTestId('test2-result')).toContainText( + 'stream1-astream1-b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test2-result')).toContainText( + 'stream2-astream2-b', + ) + await expect(page.getByTestId('test2-result')).toContainText( + 'Multiple streams test', + ) + }) + + test('JSON ends before raw stream - handles timing correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test3-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test3-btn').click() + + await expect(page.getByTestId('test3-result')).toContainText( + 'slow-1slow-2slow-3slow-4', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test3-result')).toContainText( + 'JSON ends first test', + ) + await expect(page.getByTestId('test3-result')).toContainText('hasTimestamp') + }) + + test('Raw stream ends before JSON - handles timing correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test4-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test4-btn').click() + + await expect(page.getByTestId('test4-result')).toContainText('fast-done', { + timeout: 10000, + }) + await expect(page.getByTestId('test4-result')).toContainText( + 'deferred-json-data', + ) + await expect(page.getByTestId('test4-result')).toContainText( + 'Raw ends first test', + ) + }) + + test('Large binary data - handles 3KB of binary correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test5-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test5-btn').click() + + await expect(page.getByTestId('test5-result')).toContainText( + '"sizeMatch":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test5-result')).toContainText( + '"actualSize":3072', + ) + await expect(page.getByTestId('test5-result')).toContainText( + 'Large binary test', + ) + }) + + test('Mixed streaming - Promise and RawStream together', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test6-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test6-btn').click() + + await expect(page.getByTestId('test6-result')).toContainText( + 'immediate-value', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test6-result')).toContainText( + 'deferred-value', + ) + await expect(page.getByTestId('test6-result')).toContainText( + 'mixed-raw-1mixed-raw-2', + ) + }) +}) + +test.describe('RawStream - SSR Loader Tests', () => { + test('SSR single stream - direct navigation', async ({ page }) => { + // Direct navigation = full SSR with base64 encoding + await page.goto('/raw-stream/ssr-single') + await page.waitForURL('/raw-stream/ssr-single') + + // Wait for stream to be consumed (SSR tests need hydration + stream consumption) + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-single-message')).toContainText( + 'SSR Single Stream Test', + ) + await expect(page.getByTestId('ssr-single-timestamp')).toContainText( + 'Has Timestamp: true', + ) + }) + + test('SSR multiple streams - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-multiple') + await page.waitForURL('/raw-stream/ssr-multiple') + + await expect(page.getByTestId('ssr-multiple-first')).toContainText( + 'multi-1amulti-1b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-multiple-second')).toContainText( + 'multi-2amulti-2b', + ) + await expect(page.getByTestId('ssr-multiple-message')).toContainText( + 'SSR Multiple Streams Test', + ) + }) + + // Skip in prerender mode: RawStream + deferred data causes stream chunks to be + // missing from prerendered HTML. This is a known limitation where the prerender + // process doesn't properly capture streaming data when deferred promises are present. + ;(isPrerender ? test.skip : test)( + 'SSR mixed streaming - RawStream with deferred data', + async ({ page }) => { + await page.goto('/raw-stream/ssr-mixed') + await page.waitForURL('/raw-stream/ssr-mixed') + + await expect(page.getByTestId('ssr-mixed-immediate')).toContainText( + 'immediate-ssr-value', + ) + await expect(page.getByTestId('ssr-mixed-stream')).toContainText( + 'mixed-ssr-1mixed-ssr-2', + { timeout: 10000 }, + ) + // Deferred promise should also resolve + await expect(page.getByTestId('ssr-mixed-deferred')).toContainText( + 'deferred-ssr-value', + { timeout: 10000 }, + ) + }, + ) + + test('SSR single stream - client-side navigation', async ({ page }) => { + // Start from index, then navigate client-side to SSR route + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + // Wait for hydration (use navigation to be specific) + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Single' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Client-side navigation + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Single' }) + .click() + await page.waitForURL('/raw-stream/ssr-single') + + // Stream should still work after client navigation + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + }) + + test('SSR multiple streams - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Multiple' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Multiple' }) + .click() + await page.waitForURL('/raw-stream/ssr-multiple') + + await expect(page.getByTestId('ssr-multiple-first')).toContainText( + 'multi-1amulti-1b', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-multiple-second')).toContainText( + 'multi-2amulti-2b', + ) + }) +}) + +test.describe('RawStream - Hint Parameter (RPC)', () => { + test('Text hint with pure text - uses UTF-8 encoding', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test7-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test7-btn').click() + + await expect(page.getByTestId('test7-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test7-result')).toContainText( + 'Hello, World! This is text.', + ) + await expect(page.getByTestId('test7-result')).toContainText( + 'Text hint with pure text', + ) + }) + + test('Text hint with pure binary - fallback to base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test8-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test8-btn').click() + + await expect(page.getByTestId('test8-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test8-result')).toContainText( + '"expectedLength":12', + ) + }) + + test('Text hint with mixed content - handles both', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test9-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test9-btn').click() + + await expect(page.getByTestId('test9-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test9-result')).toContainText( + '"expectedLength":30', + ) + }) + + test('Binary hint with text data - uses base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test10-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test10-btn').click() + + await expect(page.getByTestId('test10-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test10-result')).toContainText( + 'This is text but using binary hint', + ) + }) + + test('Binary hint with binary data - uses base64', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test11-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test11-btn').click() + + await expect(page.getByTestId('test11-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test11-result')).toContainText( + '"expectedLength":6', + ) + }) +}) + +test.describe('RawStream - SSR Hint Parameter Tests', () => { + test('SSR text hint with pure text - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-text-hint') + await page.waitForURL('/raw-stream/ssr-text-hint') + + await expect(page.getByTestId('ssr-text-hint-pure-text')).toContainText( + 'Hello World from SSR!', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-text-hint-pure-match')).toContainText( + 'true', + ) + await expect(page.getByTestId('ssr-text-hint-mixed-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-text-hint-pure-binary-match'), + ).toContainText('true') + }) + + test('SSR text hint - byte-by-byte verification', async ({ page }) => { + await page.goto('/raw-stream/ssr-text-hint') + await page.waitForURL('/raw-stream/ssr-text-hint') + + // Wait for streams to be fully consumed + await expect(page.getByTestId('ssr-text-hint-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + // Check pure text, mixed content, and pure binary all match + const result = await page.getByTestId('ssr-text-hint-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.pureTextMatch?.match).toBe(true) + expect(parsed.mixedMatch?.match).toBe(true) + expect(parsed.pureBinaryMatch?.match).toBe(true) + }) + + test('SSR binary hint with text - direct navigation', async ({ page }) => { + await page.goto('/raw-stream/ssr-binary-hint') + await page.waitForURL('/raw-stream/ssr-binary-hint') + + await expect(page.getByTestId('ssr-binary-hint-text')).toContainText( + 'Binary hint with text', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-binary-hint-text-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-binary-hint-binary-match'), + ).toContainText('true') + }) + + test('SSR binary hint - byte-by-byte verification', async ({ page }) => { + await page.goto('/raw-stream/ssr-binary-hint') + await page.waitForURL('/raw-stream/ssr-binary-hint') + + // Wait for streams to be fully consumed + await expect(page.getByTestId('ssr-binary-hint-result')).toContainText( + '"match":true', + { timeout: 10000 }, + ) + // Check both text and binary data match + const result = await page + .getByTestId('ssr-binary-hint-result') + .textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.textMatch?.match).toBe(true) + expect(parsed.binaryMatch?.match).toBe(true) + }) + + test('SSR text hint - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Text Hint' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Text Hint' }) + .click() + await page.waitForURL('/raw-stream/ssr-text-hint') + + await expect(page.getByTestId('ssr-text-hint-pure-match')).toContainText( + 'true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('ssr-text-hint-mixed-match')).toContainText( + 'true', + ) + await expect( + page.getByTestId('ssr-text-hint-pure-binary-match'), + ).toContainText('true') + }) + + test('SSR binary hint - client-side navigation', async ({ page }) => { + await page.goto('/raw-stream') + await page.waitForURL('/raw-stream') + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Binary Hint' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page + .getByRole('navigation') + .getByRole('link', { name: 'SSR Binary Hint' }) + .click() + await page.waitForURL('/raw-stream/ssr-binary-hint') + + await expect(page.getByTestId('ssr-binary-hint-text-match')).toContainText( + 'true', + { timeout: 10000 }, + ) + await expect( + page.getByTestId('ssr-binary-hint-binary-match'), + ).toContainText('true') + }) +}) + +test.describe('RawStream - Multiplexing Tests (RPC)', () => { + test('Interleaved streams - two concurrent streams with variable delays', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test12-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test12-btn').click() + + // Both streams should have matching bytes + await expect(page.getByTestId('test12-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + // Verify both streams match + const result = await page.getByTestId('test12-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.streamA?.match).toBe(true) + expect(parsed.streamB?.match).toBe(true) + }) + + test('Burst-pause-burst - single stream with variable timing', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test13-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test13-btn').click() + + await expect(page.getByTestId('test13-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + await expect(page.getByTestId('test13-result')).toContainText( + 'Burst-pause-burst test', + ) + }) + + test('Three concurrent streams - different timing patterns', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test14-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test14-btn').click() + + // All three streams should match + await expect(page.getByTestId('test14-result')).toContainText( + '"match":true', + { timeout: 15000 }, + ) + // Verify all three streams match + const result = await page.getByTestId('test14-result').textContent() + const parsed = JSON.parse(result || '{}') + expect(parsed.fast?.match).toBe(true) + expect(parsed.slow?.match).toBe(true) + expect(parsed.burst?.match).toBe(true) + }) +}) + +test.describe('RawStream - Cross Navigation', () => { + test('Client RPC works after navigating from SSR route', async ({ page }) => { + // Start with SSR route + await page.goto('/raw-stream/ssr-single') + await page.waitForURL('/raw-stream/ssr-single') + + // Wait for SSR stream to complete (ensures hydration is done) + await expect(page.getByTestId('ssr-single-stream')).toContainText( + 'ssr-chunk1ssr-chunk2ssr-chunk3', + { timeout: 10000 }, + ) + + // Navigate to client-call route (use first() to avoid strict mode on multiple matches) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Client Calls' }) + .click() + await page.waitForURL('/raw-stream/client-call') + + // Wait for hydration + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Run RPC test + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + }) + + test('Navigation from home to raw-stream routes', async ({ page }) => { + // Start from home + await page.goto('/') + await page.waitForURL('/') + + // Wait for hydration + await page + .getByRole('link', { name: 'Raw Stream' }) + .waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Navigate via client-side to raw-stream + await page.getByRole('link', { name: 'Raw Stream' }).click() + await page.waitForURL('/raw-stream') + + // Wait for hydration on the new page + await page.waitForTimeout(HYDRATION_WAIT) + + // Then to client-call (use navigation area to avoid duplicates) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Client Calls' }) + .click() + await page.waitForURL('/raw-stream/client-call') + + // Wait for button + await page.getByTestId('test1-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + // Run a test + await page.getByTestId('test1-btn').click() + + await expect(page.getByTestId('test1-result')).toContainText( + 'chunk1chunk2chunk3', + { timeout: 10000 }, + ) + }) +}) + +test.describe('RawStream - Edge Cases (RPC)', () => { + test('Empty stream - handles zero-byte stream correctly', async ({ + page, + }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test15-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test15-btn').click() + + await expect(page.getByTestId('test15-result')).toContainText( + '"isEmpty":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test15-result')).toContainText( + '"byteCount":0', + ) + await expect(page.getByTestId('test15-result')).toContainText( + 'Empty stream test', + ) + }) + + test('Stream error - propagates error to client', async ({ page }) => { + await page.goto('/raw-stream/client-call') + await page.waitForURL('/raw-stream/client-call') + + await page.getByTestId('test16-btn').waitFor({ state: 'visible' }) + await page.waitForTimeout(HYDRATION_WAIT) + + await page.getByTestId('test16-btn').click() + + await expect(page.getByTestId('test16-result')).toContainText( + '"errorCaught":true', + { timeout: 10000 }, + ) + await expect(page.getByTestId('test16-result')).toContainText( + 'Intentional stream error', + ) + await expect(page.getByTestId('test16-result')).toContainText( + 'Error stream test', + ) + }) +}) diff --git a/e2e/vue-start/basic/vite.config.ts b/e2e/vue-start/basic/vite.config.ts index 13b694045ae..96e38ba6ca8 100644 --- a/e2e/vue-start/basic/vite.config.ts +++ b/e2e/vue-start/basic/vite.config.ts @@ -33,6 +33,9 @@ export default defineConfig({ server: { port: 3000, }, + define: { + __TSR_PRERENDER__: JSON.stringify(isPrerender), + }, plugins: [ tailwindcss(), tsConfigPaths({