From 172ed4061c740782c51069a348d025ad7c44f257 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 11:58:42 -0700 Subject: [PATCH 01/30] fix: router stream injection --- packages/react-router/src/RouterProvider.tsx | 2 - packages/react-router/src/ScriptOnce.tsx | 2 +- packages/react-router/src/index.tsx | 2 +- packages/react-router/src/router.ts | 40 ++- packages/start-client/src/serialization.tsx | 4 +- .../start-server/src/defaultRenderHandler.tsx | 8 +- .../start-server/src/defaultStreamHandler.tsx | 58 ++-- .../src/transformStreamWithRouter.ts | 269 ++++++++++-------- 8 files changed, 218 insertions(+), 167 deletions(-) diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index e5b40ebaa2..79a56792e6 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -54,8 +54,6 @@ export type BuildLocationFn = < }, ) => ParsedLocation -export type InjectedHtmlEntry = string | (() => Promise | string) - export function RouterContextProvider< TRouter extends AnyRouter = RegisteredRouter, TDehydrated extends Record = Record, diff --git a/packages/react-router/src/ScriptOnce.tsx b/packages/react-router/src/ScriptOnce.tsx index f4398deeeb..3a93a87bb8 100644 --- a/packages/react-router/src/ScriptOnce.tsx +++ b/packages/react-router/src/ScriptOnce.tsx @@ -9,6 +9,6 @@ export function ScriptOnce({ }) { const router = useRouter() - router.injectScript(children, { logScript: log }) + router.injectScript(() => children, { logScript: log }) return null } diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 8269250fdb..b11221ca4c 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -262,6 +262,7 @@ export type { StreamState, TSRGlobal, TSRGlobalMatch, + InjectedHtmlEntry, } from './router' export { RouterProvider, RouterContextProvider } from './RouterProvider' @@ -271,7 +272,6 @@ export type { MatchLocation, NavigateFn, BuildLocationFn, - InjectedHtmlEntry, } from './RouterProvider' export { diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 2611b08a3f..8a705b382c 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -195,6 +195,8 @@ export type RouterContextOptions = export type TrailingSlashOption = 'always' | 'never' | 'preserve' +export type InjectedHtmlEntry = Promise<() => string> + export interface RouterOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -3099,24 +3101,33 @@ export class Router< this.manifest = ctx.router.manifest } - injectedHtml: Array<() => string> = [] - injectHtml = (html: string) => { - const cb = () => { - this.injectedHtml = this.injectedHtml.filter((d) => d !== cb) - return html - } - - this.injectedHtml.push(cb) + injectedHtml: Array = [] + injectHtml = (getHtml: () => string | Promise) => { + const promise = Promise.resolve() + .then(getHtml) + .then((html) => { + return () => { + // Remove the promise from the array + this.injectedHtml = this.injectedHtml.filter((d) => promise !== d) + // Return the html + return html + } + }) + this.injectedHtml.push(promise) } - injectScript = (script: string, opts?: { logScript?: boolean }) => { - this.injectHtml( - ``, - ) + }; if (typeof __TSR__ !== 'undefined') __TSR__.cleanScripts()` + }) } streamedKeys: Set = new Set() @@ -3147,7 +3158,8 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` this.streamedKeys.add(key) this.injectScript( - `__TSR__.streamedValues['${key}'] = { value: ${this.serializer?.(this.options.transformer.stringify(value))}}`, + () => + `__TSR__.streamedValues['${key}'] = { value: ${this.serializer?.(this.options.transformer.stringify(value))}}`, ) } diff --git a/packages/start-client/src/serialization.tsx b/packages/start-client/src/serialization.tsx index 173329c1d9..e069669c1a 100644 --- a/packages/start-client/src/serialization.tsx +++ b/packages/start-client/src/serialization.tsx @@ -297,7 +297,7 @@ function InnerDehydratePromise({ entry }: { entry: ServerExtractedPromise }) { }, )})` - router.injectScript(code) + router.injectScript(() => code) return <> } @@ -321,7 +321,7 @@ function DehydrateStream({ entry }: { entry: ServerExtractedStream }) { )}))` : `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.close()` - router.injectScript(code) + router.injectScript(() => code) return <> }} diff --git a/packages/start-server/src/defaultRenderHandler.tsx b/packages/start-server/src/defaultRenderHandler.tsx index 8e866e16b5..a0b1aa39a0 100644 --- a/packages/start-server/src/defaultRenderHandler.tsx +++ b/packages/start-server/src/defaultRenderHandler.tsx @@ -3,16 +3,16 @@ import { StartServer } from './StartServer' import type { HandlerCallback } from './defaultStreamHandler' import type { AnyRouter } from '@tanstack/react-router' -export const defaultRenderHandler: HandlerCallback = ({ +export const defaultRenderHandler: HandlerCallback = async ({ router, responseHeaders, }) => { try { let html = ReactDOMServer.renderToString() - html = html.replace( - ``, - `${router.injectedHtml.map((d) => d()).join('')}`, + const injectedHtml = await Promise.all(router.injectedHtml).then((htmls) => + htmls.join(''), ) + html = html.replace(``, `${injectedHtml}`) return new Response(`${html}`, { status: router.state.statusCode, headers: responseHeaders, diff --git a/packages/start-server/src/defaultStreamHandler.tsx b/packages/start-server/src/defaultStreamHandler.tsx index 3a8707017a..edf23a3757 100644 --- a/packages/start-server/src/defaultStreamHandler.tsx +++ b/packages/start-server/src/defaultStreamHandler.tsx @@ -3,7 +3,7 @@ import { isbot } from 'isbot' import ReactDOMServer from 'react-dom/server' import { StartServer } from './StartServer' import { - transformReadableStreamWithRouter, + // transformReadableStreamWithRouter, transformStreamWithRouter, } from './transformStreamWithRouter' import type { AnyRouter } from '@tanstack/react-router' @@ -19,33 +19,33 @@ export const defaultStreamHandler: HandlerCallback = async ({ router, responseHeaders, }) => { - if (typeof ReactDOMServer.renderToReadableStream === 'function') { - const stream = await ReactDOMServer.renderToReadableStream( - , - { - signal: request.signal, - }, - ) + // if (typeof ReactDOMServer.renderToReadableStream === 'function') { + // const stream = await ReactDOMServer.renderToReadableStream( + // , + // { + // signal: request.signal, + // }, + // ) - if (isbot(request.headers.get('User-Agent'))) { - await stream.allReady - } + // if (isbot(request.headers.get('User-Agent'))) { + // await stream.allReady + // } - const transforms = [transformReadableStreamWithRouter(router)] + // const transforms = [transformReadableStreamWithRouter(router)] - const transformedStream = transforms.reduce( - (stream, transform) => stream.pipeThrough(transform), - stream as ReadableStream, - ) + // const transformedStream = transforms.reduce( + // (stream, transform) => stream.pipeThrough(transform), + // stream as ReadableStream, + // ) - return new Response(transformedStream, { - status: router.state.statusCode, - headers: responseHeaders, - }) - } + // return new Response(transformedStream, { + // status: router.state.statusCode, + // headers: responseHeaders, + // }) + // } if (typeof ReactDOMServer.renderToPipeableStream === 'function') { - const passthrough = new PassThrough() + const reactAppPassthrough = new PassThrough() try { const pipeable = ReactDOMServer.renderToPipeableStream( @@ -54,12 +54,12 @@ export const defaultStreamHandler: HandlerCallback = async ({ ...(isbot(request.headers.get('User-Agent')) ? { onAllReady() { - pipeable.pipe(passthrough) + pipeable.pipe(reactAppPassthrough) }, } : { onShellReady() { - pipeable.pipe(passthrough) + pipeable.pipe(reactAppPassthrough) }, }), onError: (error, info) => { @@ -71,14 +71,12 @@ export const defaultStreamHandler: HandlerCallback = async ({ console.error('Error in renderToPipeableStream:', e) } - const transforms = [transformStreamWithRouter(router)] - - const transformedStream = transforms.reduce( - (stream, transform) => (stream as any).pipe(transform), - passthrough, + const responseStream = transformStreamWithRouter( + router, + reactAppPassthrough, ) - return new Response(transformedStream as any, { + return new Response(responseStream as any, { status: router.state.statusCode, headers: responseHeaders, }) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index d17cba0f24..8ff355bbee 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -1,47 +1,33 @@ -import { Transform } from 'node:stream' +import { PassThrough } from 'node:stream' +import type { Readable } from 'node:stream' import type { AnyRouter } from '@tanstack/react-router' -export function transformStreamWithRouter(router: AnyRouter) { - const callbacks = transformHtmlCallbacks(() => - router.injectedHtml.map((d) => d()).join(''), - ) - return new Transform({ - transform(chunk, _encoding, callback) { - callbacks - .transform(chunk, this.push.bind(this)) - .then(() => callback()) - .catch((err) => callback(err)) - }, - flush(callback) { - callbacks - .flush(this.push.bind(this)) - .then(() => callback()) - .catch((err) => callback(err)) - }, - }) -} +function createRouterStream(router: AnyRouter): Readable { + const routerStream = new PassThrough() + + async function digestRouterInjections(): Promise { + try { + while (router.injectedHtml.length > 0) { + // Wait for any of the injected promises to settle + const getHtml = await Promise.race(router.injectedHtml) + // On success, as long as the routerStream is not destroyed, + // push the html + if (!routerStream.destroyed) { + routerStream.push(getHtml()) + } + } + } catch (error) { + console.error('Error processing HTML injection:', error) + routerStream.destroy( + error instanceof Error ? error : new Error(String(error)), + ) + } + } -export function transformReadableStreamWithRouter(router: AnyRouter) { - const callbacks = transformHtmlCallbacks(() => - router.injectedHtml.map((d) => d()).join(''), - ) - - const encoder = new TextEncoder() - - return new TransformStream({ - transform(chunk, controller) { - return callbacks.transform(chunk, (chunkToPush) => { - controller.enqueue(encoder.encode(chunkToPush)) - return true - }) - }, - flush(controller) { - return callbacks.flush((chunkToPush) => { - controller.enqueue(chunkToPush) - return true - }) - }, - }) + // Start digesting router injections into the routerStream + digestRouterInjections() + + return routerStream } // regex pattern for matching closing body and html tags @@ -50,88 +36,145 @@ const patternBodyEnd = /(<\/body>)/ const patternHtmlEnd = /(<\/html>)/ // regex pattern for matching closing tags -const pattern = /(<\/[a-zA-Z][\w:.-]*?>)/g - +const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g const textDecoder = new TextDecoder() -function transformHtmlCallbacks(getHtml: () => string) { +export function transformStreamWithRouter( + router: AnyRouter, + appStream: Readable, +) { + const finalPassThrough = new PassThrough() + const routerStream = createRouterStream(router) + let routerStreamBuffer = '' + let pendingClosingTags = '' + let isRouterStreamDone = false + let isAppStreamDone = false + + // Buffer the routerStream until the appStream has an open body tag let bodyStarted = false let leftover = '' - // If a closing tag is split across chunks, store the HTML to add after it - // This expects that all the HTML that's added is closed properly let leftoverHtml = '' - return { - // eslint-disable-next-line @typescript-eslint/require-await - async transform(chunk: any, push: (chunkToPush: string) => boolean) { - const chunkString = leftover + textDecoder.decode(chunk) + // Buffer the routerStream so we can flush it when the appStream has an open body tag + routerStream.on('data', (chunk) => { + const decoded = textDecoder.decode(chunk) - const bodyStartMatch = chunkString.match(patternBodyStart) - const bodyEndMatch = chunkString.match(patternBodyEnd) - const htmlEndMatch = chunkString.match(patternHtmlEnd) + console.log('routerStream data', decoded) - try { - if (bodyStartMatch) { - bodyStarted = true - } + if (!bodyStarted) { + // console.log('Non-body routerStream data', decoded) + routerStreamBuffer += decoded + } else { + finalPassThrough.write(decoded) + } + }) - if (!bodyStarted) { - push(chunkString) - leftover = '' - return - } + appStream.on('data', (chunk) => { + const chunkString = leftover + textDecoder.decode(chunk) + + const bodyStartMatch = chunkString.match(patternBodyStart) + const bodyEndMatch = chunkString.match(patternBodyEnd) + const htmlEndMatch = chunkString.match(patternHtmlEnd) + + if (bodyStartMatch) { + bodyStarted = true + } + + const getBufferedRouterStream = () => { + const html = routerStreamBuffer + routerStreamBuffer = '' + return html + } + + if (!bodyStarted) { + finalPassThrough.write(chunkString) + leftover = '' + return + } + + // If the body has already ended, we need to hold on to the closing tags + // until the routerStream has finished + if ( + bodyEndMatch && + htmlEndMatch && + bodyEndMatch.index! < htmlEndMatch.index! + ) { + const bodyIndex = bodyEndMatch.index! + pendingClosingTags = chunkString.slice(bodyIndex) + finalPassThrough.write( + getBufferedRouterStream() + chunkString.slice(0, bodyIndex), + ) + leftover = '' + return + } + + // For all other closing tags, add the arbitrary HTML after them + let result + let lastIndex = 0 + + while ((result = patternClosingTag.exec(chunkString)) !== null) { + lastIndex = result.index + result[0].length + } + + // If a closing tag was found, add the arbitrary HTML and send it through + if (lastIndex > 0) { + const processed = + chunkString.slice(0, lastIndex) + + getBufferedRouterStream() + + leftoverHtml + finalPassThrough.write(processed) + leftover = chunkString.slice(lastIndex) + } else { + // If no closing tag was found, store the chunk to process with the next one + leftover = chunkString + leftoverHtml += getBufferedRouterStream() + } + }) - const html = getHtml() - - // If a sequence was found - if ( - bodyEndMatch && - htmlEndMatch && - bodyEndMatch.index! < htmlEndMatch.index! - ) { - const bodyIndex = bodyEndMatch.index! + bodyEndMatch[0].length - const htmlIndex = htmlEndMatch.index! + htmlEndMatch[0].length - - // Add the arbitrary HTML before the closing body tag - const processed = - chunkString.slice(0, bodyIndex) + - html + - chunkString.slice(bodyIndex, htmlIndex) + - chunkString.slice(htmlIndex) - - push(processed) - leftover = '' - } else { - // For all other closing tags, add the arbitrary HTML after them - let result - let lastIndex = 0 - - while ((result = pattern.exec(chunkString)) !== null) { - lastIndex = result.index + result[0].length - } - - // If a closing tag was found, add the arbitrary HTML and send it through - if (lastIndex > 0) { - const processed = - chunkString.slice(0, lastIndex) + html + leftoverHtml - push(processed) - leftover = chunkString.slice(lastIndex) - } else { - // If no closing tag was found, store the chunk to process with the next one - leftover = chunkString - leftoverHtml += html - } - } - } catch (err) { - console.error('Error transforming HTML:', err) - throw err + routerStream.on('end', () => { + isRouterStreamDone = true + + if (isAppStreamDone) { + if (pendingClosingTags) { + finalPassThrough.write(pendingClosingTags) } - }, - // eslint-disable-next-line @typescript-eslint/require-await - async flush(push: (chunkToPush: string) => boolean) { - if (leftover) { - push(leftover) + finalPassThrough.end() + } + }) + + appStream.on('end', () => { + isAppStreamDone = true + + if (isRouterStreamDone) { + if (routerStreamBuffer || pendingClosingTags) { + finalPassThrough.write(routerStreamBuffer + pendingClosingTags) } - }, - } + finalPassThrough.end() + } + }) + + return finalPassThrough } + +// export function transformReadableStreamWithRouter(router: AnyRouter) { +// const callbacks = transformHtmlCallbacks(() => +// router.injectedHtml.map((d) => d()).join(''), +// ) + +// const encoder = new TextEncoder() + +// return new TransformStream({ +// transform(chunk, controller) { +// return callbacks.transform(chunk, (chunkToPush) => { +// controller.enqueue(encoder.encode(chunkToPush)) +// return true +// }) +// }, +// flush(controller) { +// return callbacks.flush((chunkToPush) => { +// controller.enqueue(chunkToPush) +// return true +// }) +// }, +// }) +// } From 8d8ebf8f987849f67ce64f5a1198f6d729c4e7d0 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 15:09:50 -0700 Subject: [PATCH 02/30] fix: use web streams instead --- .../start-server/src/defaultStreamHandler.tsx | 48 +-- .../src/transformStreamWithRouter.ts | 287 +++++++++++------- 2 files changed, 195 insertions(+), 140 deletions(-) diff --git a/packages/start-server/src/defaultStreamHandler.tsx b/packages/start-server/src/defaultStreamHandler.tsx index edf23a3757..46a503c9d0 100644 --- a/packages/start-server/src/defaultStreamHandler.tsx +++ b/packages/start-server/src/defaultStreamHandler.tsx @@ -2,10 +2,12 @@ import { PassThrough } from 'node:stream' import { isbot } from 'isbot' import ReactDOMServer from 'react-dom/server' import { StartServer } from './StartServer' + import { - // transformReadableStreamWithRouter, - transformStreamWithRouter, + transformPipeableStreamWithRouter, + transformReadableStreamWithRouter, } from './transformStreamWithRouter' +import type { ReadableStream } from 'node:stream/web' import type { AnyRouter } from '@tanstack/react-router' export type HandlerCallback = (ctx: { @@ -19,30 +21,28 @@ export const defaultStreamHandler: HandlerCallback = async ({ router, responseHeaders, }) => { - // if (typeof ReactDOMServer.renderToReadableStream === 'function') { - // const stream = await ReactDOMServer.renderToReadableStream( - // , - // { - // signal: request.signal, - // }, - // ) - - // if (isbot(request.headers.get('User-Agent'))) { - // await stream.allReady - // } + if (typeof ReactDOMServer.renderToReadableStream === 'function') { + const stream = await ReactDOMServer.renderToReadableStream( + , + { + signal: request.signal, + }, + ) - // const transforms = [transformReadableStreamWithRouter(router)] + if (isbot(request.headers.get('User-Agent'))) { + await stream.allReady + } - // const transformedStream = transforms.reduce( - // (stream, transform) => stream.pipeThrough(transform), - // stream as ReadableStream, - // ) + const responseStream = transformReadableStreamWithRouter( + router, + stream as unknown as ReadableStream, + ) - // return new Response(transformedStream, { - // status: router.state.statusCode, - // headers: responseHeaders, - // }) - // } + return new Response(responseStream as any, { + status: router.state.statusCode, + headers: responseHeaders, + }) + } if (typeof ReactDOMServer.renderToPipeableStream === 'function') { const reactAppPassthrough = new PassThrough() @@ -71,7 +71,7 @@ export const defaultStreamHandler: HandlerCallback = async ({ console.error('Error in renderToPipeableStream:', e) } - const responseStream = transformStreamWithRouter( + const responseStream = transformPipeableStreamWithRouter( router, reactAppPassthrough, ) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 7cc1245b82..152b24c569 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -1,9 +1,25 @@ -import { PassThrough } from 'node:stream' -import type { Readable } from 'node:stream' +import { ReadableStream } from 'node:stream/web' +import { Readable } from 'node:stream' import type { AnyRouter } from '@tanstack/react-router' +export function transformReadableStreamWithRouter( + router: AnyRouter, + routerStream: ReadableStream, +) { + return transformStreamWithRouter(router, routerStream) +} + +export function transformPipeableStreamWithRouter( + router: AnyRouter, + routerStream: Readable, +) { + return Readable.fromWeb( + transformStreamWithRouter(router, Readable.toWeb(routerStream)), + ) +} + function createRouterStream(router: AnyRouter) { - const routerStream = new PassThrough() + const routerStream = createPassthrough() let digesting = false @@ -50,146 +66,185 @@ const patternHtmlEnd = /(<\/html>)/ // regex pattern for matching closing tags const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g + const textDecoder = new TextDecoder() + +type ReadablePassthrough = { + stream: ReadableStream + write: (chunk: string) => void + end: (chunk?: string) => void + destroy: (error: unknown) => void + destroyed: boolean +} + +function createPassthrough() { + let controller: ReadableStreamDefaultController + const stream = new ReadableStream({ + start(c) { + controller = c + }, + }) + + const res: ReadablePassthrough = { + stream, + write: (chunk) => { + controller.enqueue(chunk) + }, + end: (chunk) => { + if (chunk) { + controller.enqueue(chunk) + } + controller.close() + res.destroyed = true + }, + destroy: (error) => { + controller.error(error) + }, + destroyed: false, + } + + return res +} + +async function readStream( + stream: ReadableStream, + opts: { + onData?: (chunk: ReadableStreamReadValueResult) => void + onEnd?: () => void + onError?: (error: unknown) => void + }, +) { + try { + const reader = stream.getReader() + let chunk + while (!(chunk = await reader.read()).done) { + opts.onData?.(chunk) + } + opts.onEnd?.() + } catch (error) { + opts.onError?.(error) + } +} + export function transformStreamWithRouter( router: AnyRouter, - appStream: Readable, + appStream: ReadableStream, ) { - const finalPassThrough = new PassThrough() + const finalPassThrough = createPassthrough() const [routerStream, digestRouterStream] = createRouterStream(router) + let routerStreamBuffer = '' let pendingClosingTags = '' - let isRouterStreamDone = false - let isAppStreamDone = false - - let bodyStarted = false + let isRouterStreamDone = false as boolean + let isAppStreamDone = false as boolean + let bodyStarted = false as boolean let leftover = '' let leftoverHtml = '' - const textDecoder = new TextDecoder() - - // Buffer and handle `routerStream` data - routerStream.on('data', (chunk) => { - const decoded = textDecoder.decode(chunk) - - if (!bodyStarted) { - routerStreamBuffer += decoded - } else { - if (!finalPassThrough.write(decoded)) { - routerStream.pause() - finalPassThrough.once('drain', () => routerStream.resume()) - } - } - }) - - const getBufferedRouterStream = () => { + function getBufferedRouterStream() { const html = routerStreamBuffer routerStreamBuffer = '' return html } - appStream.on('data', (chunk) => { - digestRouterStream() - - const chunkString = leftover + textDecoder.decode(chunk) - const bodyStartMatch = chunkString.match(patternBodyStart) - const bodyEndMatch = chunkString.match(patternBodyEnd) - const htmlEndMatch = chunkString.match(patternHtmlEnd) + function finish() { + const finalHtml = leftoverHtml + pendingClosingTags + finalPassThrough.end(finalHtml) + } - if (bodyStartMatch) { - bodyStarted = true + function decodeChunk(chunk: unknown): string { + if (chunk instanceof Uint8Array) { + return textDecoder.decode(chunk) } + return String(chunk) + } - if (!bodyStarted) { - if (!finalPassThrough.write(chunkString)) { - appStream.pause() - finalPassThrough.once('drain', () => appStream.resume()) + // Buffer and handle `routerStream` data + readStream(routerStream.stream, { + onData: (chunk) => { + const text = decodeChunk(chunk.value) + + if (!bodyStarted) { + routerStreamBuffer += text + } else { + finalPassThrough.write(text) } - leftover = '' - return - } + }, + onEnd: () => { + console.log('routerStream done') + isRouterStreamDone = true + if (isAppStreamDone) finish() + }, + onError: (error) => { + console.error('Error reading routerStream:', error) + finalPassThrough.destroy(error) + }, + }) - if ( - bodyEndMatch && - htmlEndMatch && - bodyEndMatch.index! < htmlEndMatch.index! - ) { - const bodyIndex = bodyEndMatch.index! - pendingClosingTags = chunkString.slice(bodyIndex) - finalPassThrough.write( - getBufferedRouterStream() + chunkString.slice(0, bodyIndex), - ) - leftover = '' - return - } + // Transform the appStream + readStream(appStream, { + onData: (chunk) => { + digestRouterStream() - let result - let lastIndex = 0 - while ((result = patternClosingTag.exec(chunkString)) !== null) { - lastIndex = result.index + result[0].length - } + const text = decodeChunk(chunk.value) - if (lastIndex > 0) { - const processed = - chunkString.slice(0, lastIndex) + - getBufferedRouterStream() + - leftoverHtml - finalPassThrough.write(processed) - leftover = chunkString.slice(lastIndex) - } else { - leftover = chunkString - leftoverHtml += getBufferedRouterStream() - } - }) + const chunkString = leftover + text + const bodyStartMatch = chunkString.match(patternBodyStart) + const bodyEndMatch = chunkString.match(patternBodyEnd) + const htmlEndMatch = chunkString.match(patternHtmlEnd) - function finish() { - finalPassThrough.end(leftoverHtml + pendingClosingTags) - } + if (bodyStartMatch) { + bodyStarted = true + } - appStream.on('end', () => { - digestRouterStream(true) - isAppStreamDone = true - if (isRouterStreamDone) finish() - }) + if (!bodyStarted) { + finalPassThrough.write(chunkString) + leftover = '' + return + } - routerStream.on('end', () => { - isRouterStreamDone = true - if (isAppStreamDone) finish() - }) + if ( + bodyEndMatch && + htmlEndMatch && + bodyEndMatch.index! < htmlEndMatch.index! + ) { + const bodyIndex = bodyEndMatch.index! + pendingClosingTags = chunkString.slice(bodyIndex) + finalPassThrough.write( + getBufferedRouterStream() + chunkString.slice(0, bodyIndex), + ) + leftover = '' + return + } - // Error handling - appStream.on('error', (err) => { - console.error('Error in appStream:', err) - finalPassThrough.destroy(err) - }) + let result + let lastIndex = 0 + while ((result = patternClosingTag.exec(chunkString)) !== null) { + lastIndex = result.index + result[0].length + } - routerStream.on('error', (err) => { - console.error('Error in routerStream:', err) - finalPassThrough.destroy(err) + if (lastIndex > 0) { + const processed = + chunkString.slice(0, lastIndex) + + getBufferedRouterStream() + + leftoverHtml + finalPassThrough.write(processed) + leftover = chunkString.slice(lastIndex) + } else { + leftover = chunkString + leftoverHtml += getBufferedRouterStream() + } + }, + onEnd: () => { + digestRouterStream(true) + isAppStreamDone = true + if (isRouterStreamDone) finish() + }, + onError: (error) => { + console.error('Error reading appStream:', error) + finalPassThrough.destroy(error) + }, }) - return finalPassThrough + return finalPassThrough.stream } - -// export function transformReadableStreamWithRouter(router: AnyRouter) { -// const callbacks = transformHtmlCallbacks(() => -// router.injectedHtml.map((d) => d()).join(''), -// ) - -// const encoder = new TextEncoder() - -// return new TransformStream({ -// transform(chunk, controller) { -// return callbacks.transform(chunk, (chunkToPush) => { -// controller.enqueue(encoder.encode(chunkToPush)) -// return true -// }) -// }, -// flush(controller) { -// return callbacks.flush((chunkToPush) => { -// controller.enqueue(chunkToPush) -// return true -// }) -// }, -// }) -// } From 450043dee31928cc78708322090b437c5daf38bc Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 15:17:20 -0700 Subject: [PATCH 03/30] no log --- packages/start-server/src/transformStreamWithRouter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 152b24c569..0f49a3a3c5 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -171,7 +171,6 @@ export function transformStreamWithRouter( } }, onEnd: () => { - console.log('routerStream done') isRouterStreamDone = true if (isAppStreamDone) finish() }, From 78c4af981c5c4599c36dd85445f5a08b740ecb5b Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 16:03:33 -0700 Subject: [PATCH 04/30] fix: more explicit stream timings --- .../src/transformStreamWithRouter.ts | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 0f49a3a3c5..0858fd427b 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -21,42 +21,35 @@ export function transformPipeableStreamWithRouter( function createRouterStream(router: AnyRouter) { const routerStream = createPassthrough() - let digesting = false - - async function digestRouterStream(isEnd: boolean = false): Promise { - if (!digesting) { - digesting = true - - try { - while (router.injectedHtml.length > 0) { - // Wait for any of the injected promises to settle - const getHtml = await Promise.race(router.injectedHtml) - // On success, as long as the routerStream is not destroyed, - // push the html - const html = getHtml() - if (!routerStream.destroyed) { - routerStream.write(html) - } - } - } catch (error) { - console.error('Error processing HTML injection:', error) - routerStream.destroy( - error instanceof Error ? error : new Error(String(error)), - ) - } + let isAppRendering = true - digesting = false - } + async function digestRouterStream(): Promise { + try { + while (isAppRendering || router.injectedHtml.length) { + // Wait for any of the injected promises to settle + const getHtml = await Promise.race(router.injectedHtml) + // On success, push the html - if (isEnd && !routerStream.destroyed) { - routerStream.end() + if (!routerStream.destroyed) { + routerStream.write(getHtml()) + } + } + } catch (error) { + console.error('Error processing HTML injection:', error) + routerStream.destroy( + error instanceof Error ? error : new Error(String(error)), + ) } } // Start digesting router injections into the routerStream digestRouterStream() - return [routerStream, digestRouterStream] as const + const appDoneRendering = () => { + isAppRendering = false + } + + return [routerStream, appDoneRendering] as const } // regex pattern for matching closing body and html tags @@ -131,7 +124,7 @@ export function transformStreamWithRouter( appStream: ReadableStream, ) { const finalPassThrough = createPassthrough() - const [routerStream, digestRouterStream] = createRouterStream(router) + const [routerStream, appDoneRendering] = createRouterStream(router) let routerStreamBuffer = '' let pendingClosingTags = '' @@ -183,8 +176,6 @@ export function transformStreamWithRouter( // Transform the appStream readStream(appStream, { onData: (chunk) => { - digestRouterStream() - const text = decodeChunk(chunk.value) const chunkString = leftover + text @@ -235,7 +226,7 @@ export function transformStreamWithRouter( } }, onEnd: () => { - digestRouterStream(true) + appDoneRendering() isAppStreamDone = true if (isRouterStreamDone) finish() }, From b4f0cd510f952631548749d8735f26032cfc86b3 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 16:06:06 -0700 Subject: [PATCH 05/30] fix: router stream lifecycle --- .../start-server/src/transformStreamWithRouter.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 0858fd427b..2fd4580576 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -34,6 +34,7 @@ function createRouterStream(router: AnyRouter) { routerStream.write(getHtml()) } } + routerStream.end() } catch (error) { console.error('Error processing HTML injection:', error) routerStream.destroy( @@ -128,8 +129,6 @@ export function transformStreamWithRouter( let routerStreamBuffer = '' let pendingClosingTags = '' - let isRouterStreamDone = false as boolean - let isAppStreamDone = false as boolean let bodyStarted = false as boolean let leftover = '' let leftoverHtml = '' @@ -140,11 +139,6 @@ export function transformStreamWithRouter( return html } - function finish() { - const finalHtml = leftoverHtml + pendingClosingTags - finalPassThrough.end(finalHtml) - } - function decodeChunk(chunk: unknown): string { if (chunk instanceof Uint8Array) { return textDecoder.decode(chunk) @@ -164,8 +158,8 @@ export function transformStreamWithRouter( } }, onEnd: () => { - isRouterStreamDone = true - if (isAppStreamDone) finish() + const finalHtml = leftoverHtml + pendingClosingTags + finalPassThrough.end(finalHtml) }, onError: (error) => { console.error('Error reading routerStream:', error) @@ -227,8 +221,6 @@ export function transformStreamWithRouter( }, onEnd: () => { appDoneRendering() - isAppStreamDone = true - if (isRouterStreamDone) finish() }, onError: (error) => { console.error('Error reading appStream:', error) From 3c4401186802bc788a6f0145eba6b60cc6f7fd9f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 16:26:45 -0700 Subject: [PATCH 06/30] fix: router stream lifecycel --- .../start-server/src/transformStreamWithRouter.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 2fd4580576..a09183d31b 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -25,7 +25,7 @@ function createRouterStream(router: AnyRouter) { async function digestRouterStream(): Promise { try { - while (isAppRendering || router.injectedHtml.length) { + while (router.injectedHtml.length) { // Wait for any of the injected promises to settle const getHtml = await Promise.race(router.injectedHtml) // On success, push the html @@ -34,7 +34,14 @@ function createRouterStream(router: AnyRouter) { routerStream.write(getHtml()) } } - routerStream.end() + + if (isAppRendering) { + setImmediate(() => { + digestRouterStream() + }) + } else { + routerStream.end() + } } catch (error) { console.error('Error processing HTML injection:', error) routerStream.destroy( From ec1eac7a3d408c858f598d923a103efffad7ad04 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 22 Jan 2025 17:12:17 -0700 Subject: [PATCH 07/30] checkpoint --- packages/react-router/src/Match.tsx | 9 +- packages/react-router/src/router.ts | 13 +- packages/start-client/src/Meta.tsx | 3 +- packages/start-client/src/index.tsx | 2 +- packages/start-client/src/serialization.tsx | 192 +++++++----------- packages/start-server/src/StartServer.tsx | 10 - .../start-server/src/createStartHandler.ts | 14 +- 7 files changed, 90 insertions(+), 153 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index b0602e646a..4f44ddc886 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -241,14 +241,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ throw router.getMatch(match.id)?.loadPromise } - return ( - <> - {out} - {router.AfterEachMatch ? ( - - ) : null} - - ) + return <>{out} }) export const Outlet = React.memo(function OutletImpl() { diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 8a705b382c..29f7fec489 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -748,13 +748,7 @@ export class Router< dehydratedData?: TDehydrated viewTransitionPromise?: ControlledPromise manifest?: Manifest - AfterEachMatch?: (props: { - match: Pick< - AnyRouteMatch, - 'id' | 'status' | 'error' | 'loadPromise' | 'minPendingPromise' - > - matchIndex: number - }) => any + onMatchSettled?: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any serializeLoaderData?: ( type: '__beforeLoadContext' | 'loaderData', loaderData: any, @@ -2681,6 +2675,11 @@ export class Router< })) } + this.onMatchSettled?.({ + router: this, + match: this.getMatch(matchId)!, + }) + // Last but not least, wait for the the components // to be preloaded before we resolve the match await route._componentsPromise diff --git a/packages/start-client/src/Meta.tsx b/packages/start-client/src/Meta.tsx index 54e1bdb72e..d33fd73e51 100644 --- a/packages/start-client/src/Meta.tsx +++ b/packages/start-client/src/Meta.tsx @@ -121,9 +121,8 @@ export const useMetaElements = () => { ))} <> - + @@ -176,9 +173,9 @@ export function AfterEachMatch(props: { match: any; matchIndex: number }) { } return acc }, - { temp: fullMatch[dataType] }, + { temp: match[dataType] }, ).temp - : fullMatch[dataType] + : match[dataType] }) if ( @@ -188,7 +185,7 @@ export function AfterEachMatch(props: { match: any; matchIndex: number }) { ) { const initCode = `__TSR__.initMatch(${jsesc( { - index: props.matchIndex, + index: match.index, __beforeLoadContext: router.options.transformer.stringify( serializedBeforeLoadData, ), @@ -208,20 +205,63 @@ export function AfterEachMatch(props: { match: any; matchIndex: number }) { }, )})` - return ( - <> - - {extracted - ? extracted.map((d) => { - if (d.type === 'stream') { - return - } - - return - }) - : null} - - ) + router.injectScript(() => initCode) + + if (extracted) { + extracted.forEach((entry) => { + if (entry.type === 'promise') return injectPromise(entry) + return injectStream(entry) + }) + } + + function injectPromise(entry: ServerExtractedPromise) { + entry.promise.then(() => { + const code = `__TSR__.resolvePromise(${jsesc( + { + id: entry.id, + matchIndex: entry.matchIndex, + promiseState: entry.promise[TSR_DEFERRED_PROMISE], + } satisfies ResolvePromiseState, + { + isScriptContext: true, + wrap: true, + json: true, + }, + )})` + + router.injectScript(() => code) + }) + } + + function injectStream(entry: ServerExtractedStream) { + ;(async () => { + try { + const reader = entry.stream.getReader() + let chunk: ReadableStreamReadResult | null = null + while (!(chunk = await reader.read()).done) { + injectChunk(chunk.value) + } + // reader.releaseLock() + } catch (err) { + console.error('stream read error', err) + } + })() + + function injectChunk(chunk: string | null) { + const code = chunk + ? `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.enqueue(new TextEncoder().encode(${jsesc( + chunk.toString(), + { + isScriptContext: true, + wrap: true, + json: true, + }, + )}))` + : `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.close()` + + router.injectScript(() => code) + } + } } return null @@ -268,102 +308,6 @@ export function replaceBy( return obj } -function DehydratePromise({ entry }: { entry: ServerExtractedPromise }) { - return ( -
- - - -
- ) -} - -function InnerDehydratePromise({ entry }: { entry: ServerExtractedPromise }) { - const router = useRouter() - if (entry.promise[TSR_DEFERRED_PROMISE].status === 'pending') { - throw entry.promise - } - - const code = `__TSR__.resolvePromise(${jsesc( - { - id: entry.id, - matchIndex: entry.matchIndex, - promiseState: entry.promise[TSR_DEFERRED_PROMISE], - } satisfies ResolvePromiseState, - { - isScriptContext: true, - wrap: true, - json: true, - }, - )})` - - router.injectScript(() => code) - - return <> -} - -function DehydrateStream({ entry }: { entry: ServerExtractedStream }) { - invariant(entry.streamState, 'StreamState should be defined') - const router = useRouter() - - return ( - { - const code = chunk - ? `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.enqueue(new TextEncoder().encode(${jsesc( - chunk.toString(), - { - isScriptContext: true, - wrap: true, - json: true, - }, - )}))` - : `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.close()` - - router.injectScript(() => code) - - return <> - }} - /> - ) -} - -// Readable stream with state is a stream that has a promise that resolves to the next chunk -function createStreamState({ - stream, -}: { - stream: ReadableStream -}): StreamState { - const streamState: StreamState = { - promises: [], - } - - const reader = stream.getReader() - - const read = (index: number): any => { - streamState.promises[index] = createControlledPromise() - - return reader.read().then(({ done, value }) => { - if (done) { - streamState.promises[index]!.resolve(null) - reader.releaseLock() - return - } - - streamState.promises[index]!.resolve(value) - - return read(index + 1) - }) - } - - read(0).catch((err: any) => { - console.error('stream read error', err) - }) - - return streamState -} - function StreamChunks({ streamState, children, diff --git a/packages/start-server/src/StartServer.tsx b/packages/start-server/src/StartServer.tsx index 47498313da..7bf52d6e66 100644 --- a/packages/start-server/src/StartServer.tsx +++ b/packages/start-server/src/StartServer.tsx @@ -1,21 +1,11 @@ import * as React from 'react' import { Context } from '@tanstack/react-cross-context' import { RouterProvider } from '@tanstack/react-router' -import jsesc from 'jsesc' -import { AfterEachMatch } from '@tanstack/start-client' import type { AnyRouter } from '@tanstack/react-router' export function StartServer(props: { router: TRouter }) { - props.router.AfterEachMatch = AfterEachMatch - props.router.serializer = (value) => - jsesc(value, { - isScriptContext: true, - wrap: true, - json: true, - }) - const hydrationContext = Context.get('TanStackRouterHydrationContext', {}) const hydrationCtxValue = React.useMemo( diff --git a/packages/start-server/src/createStartHandler.ts b/packages/start-server/src/createStartHandler.ts index a0001951d5..ae2968d8d6 100644 --- a/packages/start-server/src/createStartHandler.ts +++ b/packages/start-server/src/createStartHandler.ts @@ -1,6 +1,11 @@ import { createMemoryHistory } from '@tanstack/react-router' -import { mergeHeaders, serializeLoaderData } from '@tanstack/start-client' +import { + mergeHeaders, + onMatchSettled, + serializeLoaderData, +} from '@tanstack/start-client' import { eventHandler, getResponseHeaders, toWebRequest } from 'h3' +import jsesc from 'jsesc' import type { H3Event } from 'h3' import type { AnyRouter, Manifest } from '@tanstack/react-router' import type { HandlerCallback } from './defaultStreamHandler' @@ -32,6 +37,13 @@ export function createStartHandler({ // Inject a few of the SSR helpers and defaults router.serializeLoaderData = serializeLoaderData + router.onMatchSettled = onMatchSettled + router.serializer = (value) => + jsesc(value, { + isScriptContext: true, + wrap: true, + json: true, + }) if (getRouterManifest) { router.manifest = getRouterManifest() From d9a5fac6a0c2176f03b75abb84b90808d2c92ec6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 23 Jan 2025 17:41:15 +0100 Subject: [PATCH 08/30] checkpoint --- packages/react-router/src/index.tsx | 1 - packages/react-router/src/router.ts | 8 ++-- packages/start-client/src/Meta.tsx | 4 +- packages/start-client/src/serialization.tsx | 39 ------------------- .../src/transformStreamWithRouter.ts | 2 +- 5 files changed, 7 insertions(+), 47 deletions(-) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index b11221ca4c..9a0822086c 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -259,7 +259,6 @@ export type { ClientExtractedEntry, ClientExtractedPromise, ControllablePromise, - StreamState, TSRGlobal, TSRGlobalMatch, InjectedHtmlEntry, diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 29f7fec489..0327ed7d63 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -180,10 +180,6 @@ export type ClientExtractedEntry = | ClientExtractedStream | ClientExtractedPromise -export type StreamState = { - promises: Array> -} - export type RouterContextOptions = AnyContext extends InferRouterContext ? { @@ -3102,13 +3098,17 @@ export class Router< injectedHtml: Array = [] injectHtml = (getHtml: () => string | Promise) => { + console.log(' %%%%% ##### injectHtml') const promise = Promise.resolve() .then(getHtml) .then((html) => { return () => { // Remove the promise from the array + console.log('##### injectHtml Removing promise', this.injectedHtml.length) this.injectedHtml = this.injectedHtml.filter((d) => promise !== d) + console.log('##### injectHtml after Removing promise', this.injectedHtml.length) // Return the html + console.log('##### injectHtml Returning html', html) return html } }) diff --git a/packages/start-client/src/Meta.tsx b/packages/start-client/src/Meta.tsx index d33fd73e51..5bac392c00 100644 --- a/packages/start-client/src/Meta.tsx +++ b/packages/start-client/src/Meta.tsx @@ -121,8 +121,8 @@ export const useMetaElements = () => { ))} <> - - + ( return obj } -function StreamChunks({ - streamState, - children, - __index = 0, -}: { - streamState: StreamState - children: (chunk: string | null) => React.JSX.Element - __index?: number -}) { - const promise = streamState.promises[__index] - - if (!promise) { - return null - } - - if (promise.status === 'pending') { - throw promise - } - - const chunk = promise.value! - - return ( - <> - {children(chunk)} -
- - - -
- - ) -} - function deepImmutableSetByPath(obj: T, path: Array, value: any): T { // immutable set by path retaining array and object references if (path.length === 0) { diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index a09183d31b..346f1879b8 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -202,7 +202,7 @@ export function transformStreamWithRouter( const bodyIndex = bodyEndMatch.index! pendingClosingTags = chunkString.slice(bodyIndex) finalPassThrough.write( - getBufferedRouterStream() + chunkString.slice(0, bodyIndex), + chunkString.slice(0, bodyIndex) + getBufferedRouterStream(), ) leftover = '' return From c97ca14119b642d77c2e464d413ef1db5113a772 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jan 2025 16:42:35 +0000 Subject: [PATCH 09/30] ci: apply automated fixes --- packages/react-router/src/router.ts | 10 ++++++++-- packages/start-client/src/Meta.tsx | 5 +++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 0327ed7d63..b1f2c6b85a 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -3104,9 +3104,15 @@ export class Router< .then((html) => { return () => { // Remove the promise from the array - console.log('##### injectHtml Removing promise', this.injectedHtml.length) + console.log( + '##### injectHtml Removing promise', + this.injectedHtml.length, + ) this.injectedHtml = this.injectedHtml.filter((d) => promise !== d) - console.log('##### injectHtml after Removing promise', this.injectedHtml.length) + console.log( + '##### injectHtml after Removing promise', + this.injectedHtml.length, + ) // Return the html console.log('##### injectHtml Returning html', html) return html diff --git a/packages/start-client/src/Meta.tsx b/packages/start-client/src/Meta.tsx index 5bac392c00..54e1bdb72e 100644 --- a/packages/start-client/src/Meta.tsx +++ b/packages/start-client/src/Meta.tsx @@ -121,8 +121,9 @@ export const useMetaElements = () => { ))} <> - - + Date: Thu, 23 Jan 2025 22:00:33 -0700 Subject: [PATCH 10/30] so close! --- .../react/api/router/RouterOptionsType.md | 17 - docs/framework/react/guide/ssr.md | 18 +- .../src/router.tsx | 4 +- .../react-router-with-query/src/index.tsx | 4 +- packages/react-router/src/Match.tsx | 26 +- packages/react-router/src/Matches.tsx | 2 +- packages/react-router/src/RouterProvider.tsx | 4 +- packages/react-router/src/ScriptOnce.tsx | 40 --- packages/react-router/src/Transitioner.tsx | 2 +- packages/react-router/src/awaited.tsx | 28 +- packages/react-router/src/index.tsx | 41 +-- .../react-router/src/isServerSideError.tsx | 23 -- packages/react-router/src/router.ts | 300 +++--------------- packages/react-router/src/routerContext.tsx | 8 +- packages/react-router/src/serializer.ts | 24 ++ .../react-router/tests/transformer.test.tsx | 2 +- packages/react-router/tsconfig.json | 8 +- packages/start-client/package.json | 9 +- packages/start-client/src/DehydrateRouter.tsx | 3 - packages/start-client/src/Meta.tsx | 28 +- packages/start-client/src/Scripts.tsx | 6 +- packages/start-client/src/StartClient.tsx | 5 +- packages/start-client/src/createMiddleware.ts | 6 +- packages/start-client/src/createServerFn.ts | 32 +- packages/start-client/src/index.tsx | 15 +- .../src/serializer.ts} | 91 ++---- packages/start-client/src/serverFnFetcher.tsx | 14 +- packages/start-client/src/ssr-client.tsx | 206 ++++++++++++ packages/start-client/tsconfig.json | 8 +- packages/start-client/vite.config.ts | 3 +- .../src/index.tsx | 14 +- .../package.json | 1 + .../src/index.tsx | 16 +- packages/start-server/package.json | 1 + packages/start-server/src/StartServer.tsx | 17 +- .../start-server/src/createRequestHandler.ts | 12 +- .../start-server/src/createStartHandler.ts | 24 +- .../start-server/src/defaultRenderHandler.tsx | 4 +- .../src/ssr-server.ts} | 262 ++++++++------- .../src/transformStreamWithRouter.ts | 165 ++++++---- .../src/tsrScript.ts | 49 +-- .../src/vite-env.d.ts | 0 packages/start-server/tsconfig.json | 2 +- .../vite-minify-plugin.ts | 0 packages/start-server/vite.config.ts | 5 +- packages/start/vite-minify-plugin.ts | 25 -- pnpm-lock.yaml | 247 +++++++++++++- 47 files changed, 945 insertions(+), 876 deletions(-) delete mode 100644 packages/react-router/src/ScriptOnce.tsx delete mode 100644 packages/react-router/src/isServerSideError.tsx create mode 100644 packages/react-router/src/serializer.ts delete mode 100644 packages/start-client/src/DehydrateRouter.tsx rename packages/{react-router/src/transformer.ts => start-client/src/serializer.ts} (56%) create mode 100644 packages/start-client/src/ssr-client.tsx rename packages/{start-client/src/serialization.tsx => start-server/src/ssr-server.ts} (62%) rename packages/{start-client => start-server}/src/tsrScript.ts (64%) rename packages/{start-client => start-server}/src/vite-env.d.ts (100%) rename packages/{start-client => start-server}/vite-minify-plugin.ts (100%) delete mode 100644 packages/start/vite-minify-plugin.ts diff --git a/docs/framework/react/api/router/RouterOptionsType.md b/docs/framework/react/api/router/RouterOptionsType.md index d3c6f4ad96..5de8c3bdaa 100644 --- a/docs/framework/react/api/router/RouterOptionsType.md +++ b/docs/framework/react/api/router/RouterOptionsType.md @@ -276,23 +276,6 @@ const router = createRouter({ - Type: `(err: TSerializedError) => unknown` - This method is called to define how errors are deserialized from the router's dehydrated state. -### `transformer` property - -- Type: `RouterTransformer` -- Optional -- The transformer that will be used when sending data between the server and the client during SSR. -- Defaults to a very lightweight transformer that supports a few basic types. See the [SSR guide](../../guide/ssr.md) for more information. - -#### `transformer.stringify` method - -- Type: `(obj: unknown) => string` -- This method is called when stringifying data to be sent to the client. - -#### `transformer.parse` method - -- Type: `(str: string) => unknown` -- This method is called when parsing the string encoded by the server. - ### `trailingSlash` property - Type: `'always' | 'never' | 'preserve'` diff --git a/docs/framework/react/guide/ssr.md b/docs/framework/react/guide/ssr.md index 8959423a8b..997d918c7d 100644 --- a/docs/framework/react/guide/ssr.md +++ b/docs/framework/react/guide/ssr.md @@ -201,28 +201,32 @@ This pattern can be useful for pages that have slow or high-latency data fetchin Streaming dehydration/hydration is an advanced pattern that goes beyond markup and allows you to dehydrate and stream any supporting data from the server to the client and rehydrate it on arrival. This is useful for applications that may need to further use/manage the underlying data that was used to render the initial markup on the server. -## Data Transformers +## Data Serialization -When using SSR, data passed between the server and the client must be serialized before it is sent across network-boundaries. By default, TanStack Router will serialize data using a very lightweight serializer that supports a few basic types beyond JSON.stringify/JSON.parse. +When using SSR, data passed between the server and the client must be serialized before it is sent across network-boundaries. TanStack Router handles this serialization using a very lightweight serializer that supports common data types beyond JSON.stringify/JSON.parse. Out of the box, the following types are supported: -- `Date` - `undefined` +- `Date` +- `Error` +- `FormData` If you feel that there are other types that should be supported by default, please open an issue on the TanStack Router repository. -If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. This is where the `transformer` option on `createRouter` comes in. +If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. We are currently working on both a more robust serializer and a way to customize the serializer for your application. Open an issue if you are interested in helping out! + + -The Data Transformer API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network. +The Data Serialization API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network. -The following example shows usage with [SuperJSON](https://github.com/blitz-js/superjson), however, anything that implements [`Router Transformer`](../api/router/RouterOptionsType.md#transformer-property) can be used. + ```tsx import { SuperJSON } from 'superjson' const router = createRouter({ - transformer: SuperJSON, + serializer: SuperJSON, }) ``` diff --git a/examples/react/basic-ssr-streaming-file-based/src/router.tsx b/examples/react/basic-ssr-streaming-file-based/src/router.tsx index 07c33c6d9f..5fef76e635 100644 --- a/examples/react/basic-ssr-streaming-file-based/src/router.tsx +++ b/examples/react/basic-ssr-streaming-file-based/src/router.tsx @@ -1,7 +1,7 @@ import { createRouter as createReactRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' -import SuperJSON from 'superjson' +// import SuperJSON from 'superjson' export function createRouter() { return createReactRouter({ @@ -10,7 +10,7 @@ export function createRouter() { head: '', }, defaultPreload: 'intent', - transformer: SuperJSON, + // serializer: SuperJSON, }) } diff --git a/packages/react-router-with-query/src/index.tsx b/packages/react-router-with-query/src/index.tsx index 255cb0d536..4291c2ee98 100644 --- a/packages/react-router-with-query/src/index.tsx +++ b/packages/react-router-with-query/src/index.tsx @@ -49,7 +49,7 @@ export function routerWithQueryClient( } } else { // On the client, pick up the deferred data from the stream - const dehydratedClient = router.getStreamedValue( + const dehydratedClient = router.clientSsr!.getStreamedValue( '__QueryClient__' + hash(options.queryKey), ) @@ -75,7 +75,7 @@ export function routerWithQueryClient( ) { streamedQueryKeys.add(hash(options.queryKey)) - router.streamValue( + router.serverSsr!.streamValue( '__QueryClient__' + hash(options.queryKey), dehydrate(queryClient, { shouldDehydrateMutation: () => false, diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 4f44ddc886..dd5e4f0ddd 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -10,7 +10,6 @@ import { createControlledPromise, pick } from './utils' import { CatchNotFound, isNotFound } from './not-found' import { isRedirect } from './redirects' import { matchContext } from './matchContext' -import { defaultDeserializeError, isServerSideError } from './isServerSideError' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { rootRouteId } from './root' @@ -157,19 +156,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ErrorComponent if (match.status === 'notFound') { - let error: unknown - if (isServerSideError(match.error)) { - const deserializeError = - router.options.errorSerializer?.deserialize ?? defaultDeserializeError - - error = deserializeError(match.error.data) - } else { - error = match.error - } - - invariant(isNotFound(error), 'Expected a notFound error') - - return renderRouteNotFound(router, route, error) + invariant(isNotFound(match.error), 'Expected a notFound error') + return renderRouteNotFound(router, route, match.error) } if (match.status === 'redirected') { @@ -201,13 +189,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ) } - if (isServerSideError(match.error)) { - const deserializeError = - router.options.errorSerializer?.deserialize ?? defaultDeserializeError - throw deserializeError(match.error.data) - } else { - throw match.error - } + throw match.error } if (match.status === 'pending') { @@ -241,7 +223,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ throw router.getMatch(match.id)?.loadPromise } - return <>{out} + return out }) export const Outlet = React.memo(function OutletImpl() { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 324ac95686..a70ed9e9a8 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -219,7 +219,7 @@ export function Matches() { // Do not render a root Suspense during SSR or hydrating from SSR const ResolvedSuspense = - router.isServer || (typeof document !== 'undefined' && window.__TSR__) + router.isServer || (typeof document !== 'undefined' && router.clientSsr) ? SafeFragment : React.Suspense diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 79a56792e6..59f8e53347 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -77,7 +77,9 @@ export function RouterContextProvider< const routerContext = getRouterContext() const provider = ( - {children} + + {children} + ) if (router.options.Wrap) { diff --git a/packages/react-router/src/ScriptOnce.tsx b/packages/react-router/src/ScriptOnce.tsx deleted file mode 100644 index 425bbdbfa1..0000000000 --- a/packages/react-router/src/ScriptOnce.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import jsesc from 'jsesc' -import { useRouter } from './useRouter' - -export function ScriptOnce({ - children, - log, - sync, -}: { - children: string - log?: boolean - sync?: boolean -}) { - const router = useRouter() - if (typeof document !== 'undefined') { - return null - } - - if (!sync) { - router.injectScript(() => children, { logScript: log }) - return null - } - - return ( - ` - }) - } - - streamedKeys: Set = new Set() - - getStreamedValue = (key: string): T | undefined => { - if (this.isServer) { - return undefined - } - - const streamedValue = window.__TSR__?.streamedValues[key] - - if (!streamedValue) { - return - } - - if (!streamedValue.parsed) { - streamedValue.parsed = this.options.transformer.parse(streamedValue.value) - } - - return streamedValue.parsed + serverSsr?: { + injectedHtml: Array + injectHtml: (getHtml: () => string | Promise) => void + injectScript: ( + getScript: () => string | Promise, + opts?: { logScript?: boolean }, + ) => void + streamValue: (key: string, value: any) => void + streamedKeys: Set + reduceBeforeLoadContext: ( + beforeLoadContext: any, + opts: { match: AnyRouteMatch }, + ) => any + onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any } - streamValue = (key: string, value: any) => { - warning( - !this.streamedKeys.has(key), - 'Key has already been streamed: ' + key, - ) - - this.streamedKeys.add(key) - this.injectScript( - () => - `__TSR__.streamedValues['${key}'] = { value: ${this.serializer?.(this.options.transformer.stringify(value))}}`, - ) + clientSsr?: { + getStreamedValue: (key: string) => T | undefined } _handleNotFound = ( diff --git a/packages/react-router/src/routerContext.tsx b/packages/react-router/src/routerContext.tsx index 1e5ed33540..8c54b1dc44 100644 --- a/packages/react-router/src/routerContext.tsx +++ b/packages/react-router/src/routerContext.tsx @@ -1,5 +1,11 @@ import * as React from 'react' -import type { Router } from './router' +import type { AnyRouter, Router } from './router' + +declare global { + interface Window { + __TSR_ROUTER_CONTEXT__?: React.Context + } +} const routerContext = React.createContext>(null!) diff --git a/packages/react-router/src/serializer.ts b/packages/react-router/src/serializer.ts new file mode 100644 index 0000000000..f315cdec33 --- /dev/null +++ b/packages/react-router/src/serializer.ts @@ -0,0 +1,24 @@ +export interface StartSerializer { + stringify: (obj: unknown) => string + parse: (str: string) => unknown + encode: (value: T) => T + decode: (value: T) => T +} + +export type SerializerStringifyBy = T extends TSerializable + ? T + : T extends (...args: Array) => any + ? 'Function is not serializable' + : { [K in keyof T]: SerializerStringifyBy } + +export type SerializerParseBy = T extends TSerializable + ? T + : T extends React.JSX.Element + ? ReadableStream + : { [K in keyof T]: SerializerParseBy } + +export type Serializable = Date | undefined | Error | FormData + +export type SerializerStringify = SerializerStringifyBy + +export type SerializerParse = SerializerParseBy diff --git a/packages/react-router/tests/transformer.test.tsx b/packages/react-router/tests/transformer.test.tsx index e4cf06110f..f87789a4db 100644 --- a/packages/react-router/tests/transformer.test.tsx +++ b/packages/react-router/tests/transformer.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { defaultTransformer as tf } from '../src/transformer' +import { startSerializer as tf } from '../src/transformer' describe('transformer.stringify', () => { it('should stringify dates', () => { diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json index 2c71ee70f6..372bb7b491 100644 --- a/packages/react-router/tsconfig.json +++ b/packages/react-router/tsconfig.json @@ -4,11 +4,5 @@ "jsx": "react-jsx", "jsxImportSource": "react" }, - "include": [ - "src", - "tests", - "vite.config.ts", - "eslint.config.ts", - "../start/src/client/DehydrateRouter.tsx" - ] + "include": ["src", "tests", "vite.config.ts", "eslint.config.ts"] } diff --git a/packages/start-client/package.json b/packages/start-client/package.json index b82523dada..c4adbc4930 100644 --- a/packages/start-client/package.json +++ b/packages/start-client/package.json @@ -61,17 +61,16 @@ "node": ">=12" }, "dependencies": { - "@tanstack/react-router": "workspace:^", "@tanstack/react-cross-context": "workspace:^", - "tiny-invariant": "^1.3.3", + "@tanstack/react-router": "workspace:^", "jsesc": "^3.0.2", + "tiny-invariant": "^1.3.3", "vinxi": "^0.5.1" }, "devDependencies": { + "@testing-library/react": "^16.1.0", "@types/jsesc": "^3.0.3", - "@vitejs/plugin-react": "^4.3.4", - "esbuild": "^0.24.2", - "@testing-library/react": "^16.1.0" + "@vitejs/plugin-react": "^4.3.4" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", diff --git a/packages/start-client/src/DehydrateRouter.tsx b/packages/start-client/src/DehydrateRouter.tsx deleted file mode 100644 index 4d227858e8..0000000000 --- a/packages/start-client/src/DehydrateRouter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function DehydrateRouter() { - return null -} diff --git a/packages/start-client/src/Meta.tsx b/packages/start-client/src/Meta.tsx index 54e1bdb72e..ebfa96b811 100644 --- a/packages/start-client/src/Meta.tsx +++ b/packages/start-client/src/Meta.tsx @@ -1,9 +1,6 @@ -import { ScriptOnce, useRouter, useRouterState } from '@tanstack/react-router' +import { useRouter, useRouterState } from '@tanstack/react-router' import * as React from 'react' -import jsesc from 'jsesc' -import { Context } from '@tanstack/react-cross-context' import { Asset } from './Asset' -import minifiedScript from './tsrScript?script-string' import type { RouterManagedTag } from '@tanstack/react-router' export const useMeta = () => { @@ -81,7 +78,7 @@ export const useMeta = () => { state.matches .map((match) => router.looseRoutesById[match.routeId]!) .forEach((route) => - router.manifest?.routes[route.id]?.preloads + router.ssr?.manifest?.routes[route.id]?.preloads ?.filter(Boolean) .forEach((preload) => { preloadMeta.push({ @@ -108,32 +105,13 @@ export const useMeta = () => { } export const useMetaElements = () => { - const router = useRouter() const meta = useMeta() - const dehydratedCtx = React.useContext( - Context.get('TanStackRouterHydrationContext', {}), - ) - return ( <> - {meta.map((asset, i) => ( + {meta.map((asset) => ( ))} - <> - - - ) } diff --git a/packages/start-client/src/Scripts.tsx b/packages/start-client/src/Scripts.tsx index 19d6b305d3..d6fa882553 100644 --- a/packages/start-client/src/Scripts.tsx +++ b/packages/start-client/src/Scripts.tsx @@ -12,8 +12,10 @@ export const Scripts = () => { state.matches .map((match) => router.looseRoutesById[match.routeId]!) .forEach((route) => - router.manifest?.routes[route.id]?.assets - ?.filter((d) => d.tag === 'script') + router + .ssr!.manifest?.routes[route.id]?.assets?.filter( + (d) => d.tag === 'script', + ) .forEach((asset) => { assetScripts.push({ tag: 'script', diff --git a/packages/start-client/src/StartClient.tsx b/packages/start-client/src/StartClient.tsx index 499d81d161..8cf0378cb7 100644 --- a/packages/start-client/src/StartClient.tsx +++ b/packages/start-client/src/StartClient.tsx @@ -1,11 +1,10 @@ import { RouterProvider } from '@tanstack/react-router' -import { afterHydrate } from './serialization' +import { hydrate } from './ssr-client' import type { AnyRouter } from '@tanstack/react-router' export function StartClient(props: { router: AnyRouter }) { if (!props.router.state.matches.length) { - props.router.hydrate() - afterHydrate({ router: props.router }) + hydrate(props.router) } return diff --git a/packages/start-client/src/createMiddleware.ts b/packages/start-client/src/createMiddleware.ts index cdc4a09804..566a082b2c 100644 --- a/packages/start-client/src/createMiddleware.ts +++ b/packages/start-client/src/createMiddleware.ts @@ -2,10 +2,10 @@ import type { ConstrainValidator, Method } from './createServerFn' import type { Assign, Constrain, - DefaultTransformerStringify, Expand, ResolveValidatorInput, ResolveValidatorOutput, + SerializerStringify, } from '@tanstack/react-router' export type MergeAllMiddleware< @@ -111,7 +111,7 @@ export type MiddlewareServerNextFn = < TNewClientAfterContext = undefined, >(ctx?: { context?: TNewServerContext - sendContext?: DefaultTransformerStringify + sendContext?: SerializerStringify }) => Promise< ServerResultWithContext > @@ -148,7 +148,7 @@ export type MiddlewareClientNextFn = < TNewClientContext = undefined, >(ctx?: { context?: TNewClientContext - sendContext?: DefaultTransformerStringify + sendContext?: SerializerStringify headers?: HeadersInit }) => Promise> diff --git a/packages/start-client/src/createServerFn.ts b/packages/start-client/src/createServerFn.ts index 45f3e2a9ed..fb3723a51b 100644 --- a/packages/start-client/src/createServerFn.ts +++ b/packages/start-client/src/createServerFn.ts @@ -1,19 +1,15 @@ -import { - defaultTransformer, - isNotFound, - isRedirect, - warning, -} from '@tanstack/react-router' +import { isNotFound, isRedirect, warning } from '@tanstack/react-router' import { mergeHeaders } from './headers' import { globalMiddleware } from './registerGlobalMiddleware' +import { startSerializer } from './serializer' import type { AnyValidator, Constrain, - DefaultTransformerParse, - DefaultTransformerStringify, Expand, ResolveValidatorInput, - TransformerStringify, + SerializerParse, + SerializerStringify, + SerializerStringifyBy, Validator, } from '@tanstack/react-router' import type { @@ -81,8 +77,8 @@ export interface OptionalFetcherDataOptions export type FetcherData = TResponse extends JsonResponse - ? DefaultTransformerParse> - : DefaultTransformerParse + ? SerializerParse> + : SerializerParse export type RscStream = { __cacheState: T @@ -92,9 +88,7 @@ export type Method = 'GET' | 'POST' export type ServerFn = ( ctx: ServerFnCtx, -) => - | Promise> - | DefaultTransformerStringify +) => Promise> | SerializerStringify export interface ServerFnCtx { method: TMethod @@ -125,8 +119,8 @@ type ServerFnBaseOptions< functionId: string } -export type ValidatorTransformerStringify = Validator< - TransformerStringify< +export type ValidatorSerializerStringify = Validator< + SerializerStringifyBy< ResolveValidatorInput, Date | undefined | FormData >, @@ -135,7 +129,7 @@ export type ValidatorTransformerStringify = Validator< export type ConstrainValidator = unknown extends TValidator ? TValidator - : Constrain> + : Constrain> export interface ServerFnMiddleware { middleware: ( @@ -307,12 +301,12 @@ function extractFormDataContext(formData: FormData) { } try { - const context = defaultTransformer.parse(serializedContext) + const context = startSerializer.parse(serializedContext) return { context, data: formData, } - } catch (e) { + } catch { return { data: formData, } diff --git a/packages/start-client/src/index.tsx b/packages/start-client/src/index.tsx index ab186e48d5..bbdb5c0dfe 100644 --- a/packages/start-client/src/index.tsx +++ b/packages/start-client/src/index.tsx @@ -62,7 +62,6 @@ export { globalMiddleware, } from './registerGlobalMiddleware' export { serverOnly, clientOnly } from './envOnly' -export { DehydrateRouter } from './DehydrateRouter' export { json } from './json' export { Meta } from './Meta' export { Scripts } from './Scripts' @@ -71,4 +70,16 @@ export { mergeHeaders } from './headers' export { renderRsc } from './renderRSC' export { useServerFn } from './useServerFn' export { serverFnFetcher } from './serverFnFetcher' -export { serializeLoaderData, onMatchSettled } from './serialization' +export { + type DehydratedRouterState, + type DehydratedRouteMatch, + type DehydratedRouter, + type ClientExtractedBaseEntry, + type StartSsrGlobal, + type ClientExtractedEntry, + type SsrMatch, + type ClientExtractedPromise, + type ClientExtractedStream, + type ResolvePromiseState, +} from './ssr-client' +export { startSerializer } from './serializer' diff --git a/packages/react-router/src/transformer.ts b/packages/start-client/src/serializer.ts similarity index 56% rename from packages/react-router/src/transformer.ts rename to packages/start-client/src/serializer.ts index fad1fb2f89..0754b2b9f5 100644 --- a/packages/react-router/src/transformer.ts +++ b/packages/start-client/src/serializer.ts @@ -1,20 +1,14 @@ -import { isPlainObject } from './utils' +import { isPlainObject } from '@tanstack/react-router' +import type { StartSerializer } from '@tanstack/react-router' -export interface RouterTransformer { - stringify: (obj: unknown) => string - parse: (str: string) => unknown - encode: (value: T) => T - decode: (value: T) => T -} - -export const defaultTransformer: RouterTransformer = { +export const startSerializer: StartSerializer = { stringify: (value: any) => JSON.stringify(value, function replacer(key, val) { const ogVal = this[key] - const transformer = transformers.find((t) => t.stringifyCondition(ogVal)) + const serializer = serializers.find((t) => t.stringifyCondition(ogVal)) - if (transformer) { - return transformer.stringify(ogVal) + if (serializer) { + return serializer.stringify(ogVal) } return val @@ -23,10 +17,10 @@ export const defaultTransformer: RouterTransformer = { JSON.parse(value, function parser(key, val) { const ogVal = this[key] if (isPlainObject(ogVal)) { - const transformer = transformers.find((t) => t.parseCondition(ogVal)) + const serializer = serializers.find((t) => t.parseCondition(ogVal)) - if (transformer) { - return transformer.parse(ogVal) + if (serializer) { + return serializer.parse(ogVal) } } @@ -35,21 +29,21 @@ export const defaultTransformer: RouterTransformer = { encode: (value: any) => { // When encoding, dive first if (Array.isArray(value)) { - return value.map((v) => defaultTransformer.encode(v)) + return value.map((v) => startSerializer.encode(v)) } if (isPlainObject(value)) { return Object.fromEntries( Object.entries(value).map(([key, v]) => [ key, - defaultTransformer.encode(v), + startSerializer.encode(v), ]), ) } - const transformer = transformers.find((t) => t.stringifyCondition(value)) - if (transformer) { - return transformer.stringify(value) + const serializer = serializers.find((t) => t.stringifyCondition(value)) + if (serializer) { + return serializer.stringify(value) } return value @@ -57,21 +51,21 @@ export const defaultTransformer: RouterTransformer = { decode: (value: any) => { // Attempt transform first if (isPlainObject(value)) { - const transformer = transformers.find((t) => t.parseCondition(value)) - if (transformer) { - return transformer.parse(value) + const serializer = serializers.find((t) => t.parseCondition(value)) + if (serializer) { + return serializer.parse(value) } } if (Array.isArray(value)) { - return value.map((v) => defaultTransformer.decode(v)) + return value.map((v) => startSerializer.decode(v)) } if (isPlainObject(value)) { return Object.fromEntries( Object.entries(value).map(([key, v]) => [ key, - defaultTransformer.decode(v), + startSerializer.decode(v), ]), ) } @@ -80,7 +74,7 @@ export const defaultTransformer: RouterTransformer = { }, } -const createTransformer = ( +const createSerializer = ( key: TKey, check: (value: any) => value is TInput, toValue: (value: TInput) => TSerialized, @@ -94,10 +88,10 @@ const createTransformer = ( }) // Keep these ordered by predicted frequency -// Make sure to keep DefaultSerializeable in sync with these transformers -// Also, make sure that they are unit tested in transformer.test.tsx -const transformers = [ - createTransformer( +// Make sure to keep DefaultSerializeable in sync with these serializers +// Also, make sure that they are unit tested in serializer.test.tsx +const serializers = [ + createSerializer( // Key 'undefined', // Check @@ -107,7 +101,7 @@ const transformers = [ // From () => undefined, ), - createTransformer( + createSerializer( // Key 'date', // Check @@ -117,17 +111,22 @@ const transformers = [ // From (v) => new Date(v), ), - createTransformer( + createSerializer( // Key 'error', // Check (v): v is Error => v instanceof Error, // To - (v) => ({ ...v, message: v.message, stack: v.stack, cause: v.cause }), + (v) => ({ + ...v, + message: v.message, + stack: process.env.NODE_ENV === 'development' ? v.stack : undefined, + cause: v.cause, + }), // From (v) => Object.assign(new Error(v.message), v), ), - createTransformer( + createSerializer( // Key 'formData', // Check @@ -166,27 +165,3 @@ const transformers = [ }, ), ] as const - -export type TransformerStringify = T extends TSerializable - ? T - : T extends (...args: Array) => any - ? 'Function is not serializable' - : { [K in keyof T]: TransformerStringify } - -export type TransformerParse = T extends TSerializable - ? T - : T extends React.JSX.Element - ? ReadableStream - : { [K in keyof T]: TransformerParse } - -export type DefaultSerializable = Date | undefined | Error | FormData - -export type DefaultTransformerStringify = TransformerStringify< - T, - DefaultSerializable -> - -export type DefaultTransformerParse = TransformerParse< - T, - DefaultSerializable -> diff --git a/packages/start-client/src/serverFnFetcher.tsx b/packages/start-client/src/serverFnFetcher.tsx index e5de59b6b3..940f46fda0 100644 --- a/packages/start-client/src/serverFnFetcher.tsx +++ b/packages/start-client/src/serverFnFetcher.tsx @@ -1,10 +1,10 @@ import { - defaultTransformer, encode, isNotFound, isPlainObject, isRedirect, } from '@tanstack/react-router' +import { startSerializer } from './serializer' import type { MiddlewareOptions } from './createServerFn' export async function serverFnFetcher( @@ -37,7 +37,7 @@ export async function serverFnFetcher( if (first.method === 'GET') { // If the method is GET, we need to move the payload to the query string const encodedPayload = encode({ - payload: defaultTransformer.stringify({ + payload: startSerializer.stringify({ data: first.data, context: first.context, }), @@ -64,7 +64,7 @@ export async function serverFnFetcher( if (response.headers.get('content-type')?.includes('application/json')) { // Even though the response is JSON, we need to decode it // because the server may have transformed it - const json = defaultTransformer.decode(await response.json()) + const json = startSerializer.decode(await response.json()) // If the response is a redirect or not found, throw it // for the router to handle @@ -95,7 +95,7 @@ export async function serverFnFetcher( // If the response is JSON, return it parsed const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { - return defaultTransformer.decode(await response.json()) + return startSerializer.decode(await response.json()) } else { // Otherwise, return the text as a fallback // If the user wants more than this, they can pass a @@ -107,14 +107,14 @@ export async function serverFnFetcher( function getFetcherRequestOptions(opts: MiddlewareOptions) { if (opts.method === 'POST') { if (opts.data instanceof FormData) { - opts.data.set('__TSR_CONTEXT', defaultTransformer.stringify(opts.context)) + opts.data.set('__TSR_CONTEXT', startSerializer.stringify(opts.context)) return { body: opts.data, } } return { - body: defaultTransformer.stringify({ + body: startSerializer.stringify({ data: opts.data ?? null, context: opts.context, }), @@ -130,7 +130,7 @@ async function handleResponseErrors(response: Response) { const isJson = contentType && contentType.includes('application/json') if (isJson) { - throw defaultTransformer.decode(await response.json()) + throw startSerializer.decode(await response.json()) } throw new Error(await response.text()) diff --git a/packages/start-client/src/ssr-client.tsx b/packages/start-client/src/ssr-client.tsx new file mode 100644 index 0000000000..dd4b10c6e8 --- /dev/null +++ b/packages/start-client/src/ssr-client.tsx @@ -0,0 +1,206 @@ +import { isPlainObject } from '@tanstack/react-router' + +import invariant from 'tiny-invariant' + +import { startSerializer } from './serializer' +import type { + AnyRouter, + ControllablePromise, + DeferredPromiseState, + MakeRouteMatch, + Manifest, +} from '@tanstack/react-router' + +declare global { + interface Window { + __TSR_SSR__?: StartSsrGlobal + } +} + +export interface StartSsrGlobal { + matches: Array + streamedValues: Record< + string, + { + value: any + parsed: any + } + > + cleanScripts: () => void + dehydrated?: any + queue: Array<() => boolean> + runQueue: () => void + initMatch: (match: SsrMatch) => void + resolvePromise: (p: ResolvePromiseState) => void +} + +export interface SsrMatch { + index: number + __beforeLoadContext?: string + loaderData?: string + extracted: Record +} + +export type ClientExtractedEntry = + | ClientExtractedStream + | ClientExtractedPromise + +export interface ClientExtractedPromise extends ClientExtractedBaseEntry { + type: 'promise' + value?: ControllablePromise +} + +export interface ClientExtractedStream extends ClientExtractedBaseEntry { + type: 'stream' + value?: ReadableStream & { controller?: ReadableStreamDefaultController } +} + +export interface ClientExtractedBaseEntry { + type: string + path: Array +} + +export interface ResolvePromiseState { + id: number + matchIndex: number + promiseState: DeferredPromiseState +} + +export interface DehydratedRouterState { + dehydratedMatches: Array +} + +export type DehydratedRouteMatch = Pick< + MakeRouteMatch, + 'id' | 'status' | 'updatedAt' | 'loaderData' +> + +export interface DehydratedRouter { + state: DehydratedRouterState + manifest: Manifest | undefined + dehydratedData: any +} + +export function hydrate(router: AnyRouter) { + invariant( + window.__TSR_SSR__?.dehydrated, + 'Expected to find a dehydrated data on window.__TSR_SSR__.dehydrated... but we did not. Please file an issue!', + ) + + const { state, manifest, dehydratedData } = startSerializer.parse( + window.__TSR_SSR__.dehydrated, + ) as DehydratedRouter + + router.ssr = { + manifest, + serializer: startSerializer, + } + + router.clientSsr = { + getStreamedValue: (key: string): T | undefined => { + if (router.isServer) { + return undefined + } + + const streamedValue = window.__TSR_SSR__?.streamedValues[key] + + if (!streamedValue) { + return + } + + if (!streamedValue.parsed) { + streamedValue.parsed = router.ssr!.serializer.parse(streamedValue.value) + } + + return streamedValue.parsed + }, + } + + // Allow the user to handle custom hydration data + router.options.hydrate?.(dehydratedData) + + // Hydrate the router state + const matches = router.matchRoutes(router.state.location).map((match) => { + const route = router.looseRoutesById[match.routeId]! + + const dehydratedMatch = state.dehydratedMatches.find( + (d) => d.id === match.id, + ) + + invariant( + dehydratedMatch, + `Could not find a client-side match for dehydrated match with id: ${match.id}!`, + ) + + // Right after hydration and before the first render, we need to rehydrate each match + // This includes rehydrating the loaderData and also using the beforeLoadContext + // to reconstruct any context that was serialized on the server + + const dMatch = window.__TSR_SSR__?.matches[match.index] + if (dMatch) { + const parentMatch = router.state.matches[match.index - 1] + const parentContext = parentMatch?.context ?? router.options.context ?? {} + if (dMatch.__beforeLoadContext) { + match.__beforeLoadContext = router.ssr!.serializer.parse( + dMatch.__beforeLoadContext, + ) as any + + match.context = { + ...parentContext, + ...match.context, + ...match.__beforeLoadContext, + } + } + + if (dMatch.loaderData) { + match.loaderData = router.ssr!.serializer.parse(dMatch.loaderData) + } + + const extracted = dMatch.extracted + + Object.entries(extracted).forEach(([_, ex]: any) => { + deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) + }) + } + + const headFnContent = route.options.head?.({ + matches: router.state.matches, + match, + params: match.params, + loaderData: match.loaderData, + }) + + Object.assign(match, { + meta: headFnContent?.meta, + links: headFnContent?.links, + scripts: headFnContent?.scripts, + }) + + return { + ...match, + ...dehydratedMatch, + } + }) + + router.__store.setState((s) => { + return { + ...s, + matches: matches, + } + }) +} + +function deepMutableSetByPath(obj: T, path: Array, value: any) { + // mutable set by path retaining array and object references + if (path.length === 1) { + ;(obj as any)[path[0]!] = value + } + + const [key, ...rest] = path + + if (Array.isArray(obj)) { + deepMutableSetByPath(obj[Number(key)], rest, value) + } else if (isPlainObject(obj)) { + deepMutableSetByPath((obj as any)[key!], rest, value) + } +} diff --git a/packages/start-client/tsconfig.json b/packages/start-client/tsconfig.json index 51dda9abf2..5f141a9ce5 100644 --- a/packages/start-client/tsconfig.json +++ b/packages/start-client/tsconfig.json @@ -4,5 +4,11 @@ "jsx": "react-jsx", "module": "esnext" }, - "include": ["src", "vite.config.ts"] + "include": [ + "src", + "vite.config.ts", + "src/tsrScript.ts", + "../start-server/src/ssr-server.ts", + "../start-server/src/vite-env.d.ts" + ] } diff --git a/packages/start-client/vite.config.ts b/packages/start-client/vite.config.ts index e05e5cc394..4691019d91 100644 --- a/packages/start-client/vite.config.ts +++ b/packages/start-client/vite.config.ts @@ -2,11 +2,10 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/config/vite' import react from '@vitejs/plugin-react' import packageJson from './package.json' -import minifyScriptPlugin from './vite-minify-plugin' import type { ViteUserConfig } from 'vitest/config' const config = defineConfig({ - plugins: [minifyScriptPlugin(), react()] as ViteUserConfig['plugins'], + plugins: [react()] as ViteUserConfig['plugins'], test: { name: packageJson.name, watch: false, diff --git a/packages/start-server-functions-fetcher/src/index.tsx b/packages/start-server-functions-fetcher/src/index.tsx index 7a81e62b9f..98b44134c8 100644 --- a/packages/start-server-functions-fetcher/src/index.tsx +++ b/packages/start-server-functions-fetcher/src/index.tsx @@ -1,10 +1,10 @@ import { - defaultTransformer, encode, isNotFound, isPlainObject, isRedirect, } from '@tanstack/react-router' +import { startSerializer } from '@tanstack/start-client' import type { MiddlewareClientFnOptions } from '@tanstack/start-client' export async function serverFnFetcher( @@ -39,7 +39,7 @@ export async function serverFnFetcher( if (first.method === 'GET') { // If the method is GET, we need to move the payload to the query string const encodedPayload = encode({ - payload: defaultTransformer.stringify({ + payload: startSerializer.stringify({ data: first.data, context: first.context, }), @@ -66,7 +66,7 @@ export async function serverFnFetcher( if (response.headers.get('content-type')?.includes('application/json')) { // Even though the response is JSON, we need to decode it // because the server may have transformed it - const json = defaultTransformer.decode(await response.json()) + const json = startSerializer.decode(await response.json()) // If the response is a redirect or not found, throw it // for the router to handle @@ -97,7 +97,7 @@ export async function serverFnFetcher( // If the response is JSON, return it parsed const contentType = response.headers.get('content-type') if (contentType && contentType.includes('application/json')) { - return defaultTransformer.decode(await response.json()) + return startSerializer.decode(await response.json()) } else { // Otherwise, return the text as a fallback // If the user wants more than this, they can pass a @@ -109,14 +109,14 @@ export async function serverFnFetcher( function getFetcherRequestOptions(opts: MiddlewareClientFnOptions) { if (opts.method === 'POST') { if (opts.data instanceof FormData) { - opts.data.set('__TSR_CONTEXT', defaultTransformer.stringify(opts.context)) + opts.data.set('__TSR_CONTEXT', startSerializer.stringify(opts.context)) return { body: opts.data, } } return { - body: defaultTransformer.stringify({ + body: startSerializer.stringify({ data: opts.data ?? null, context: opts.context, }), @@ -132,7 +132,7 @@ async function handleResponseErrors(response: Response) { const isJson = contentType && contentType.includes('application/json') if (isJson) { - throw defaultTransformer.decode(await response.json()) + throw startSerializer.decode(await response.json()) } throw new Error(await response.text()) diff --git a/packages/start-server-functions-handler/package.json b/packages/start-server-functions-handler/package.json index 7d0f867c9f..06c3e0566e 100644 --- a/packages/start-server-functions-handler/package.json +++ b/packages/start-server-functions-handler/package.json @@ -62,6 +62,7 @@ }, "dependencies": { "@tanstack/react-router": "workspace:^", + "@tanstack/start-client": "workspace:^", "@tanstack/start-server": "workspace:^", "@vitejs/plugin-react": "^4.3.4", "tiny-invariant": "^1.3.3" diff --git a/packages/start-server-functions-handler/src/index.tsx b/packages/start-server-functions-handler/src/index.tsx index 9a5b962a42..7f96efd1b3 100644 --- a/packages/start-server-functions-handler/src/index.tsx +++ b/packages/start-server-functions-handler/src/index.tsx @@ -1,9 +1,4 @@ -import { - defaultTransformer, - isNotFound, - isPlainObject, - isRedirect, -} from '@tanstack/react-router' +import { isNotFound, isPlainObject, isRedirect } from '@tanstack/react-router' import invariant from 'tiny-invariant' import { eventHandler, @@ -11,6 +6,7 @@ import { getResponseStatus, toWebRequest, } from '@tanstack/start-server' +import { startSerializer } from '@tanstack/start-client' // @ts-expect-error import _serverFnManifest from 'tsr:server-fn-manifest' import type { H3Event } from '@tanstack/start-server' @@ -139,12 +135,12 @@ export async function handleServerRequest(request: Request, _event?: H3Event) { } // If there's a payload, we need to parse it - return defaultTransformer.parse(search.payload) + return startSerializer.parse(search.payload) } // For non-form, non-get const jsonPayloadAsString = await request.text() - return defaultTransformer.parse(jsonPayloadAsString) + return startSerializer.parse(jsonPayloadAsString) })() const result = await action(arg) @@ -183,7 +179,7 @@ export async function handleServerRequest(request: Request, _event?: H3Event) { } return new Response( - result !== undefined ? defaultTransformer.stringify(result) : undefined, + result !== undefined ? startSerializer.stringify(result) : undefined, { status: getResponseStatus(getEvent()), headers: { @@ -215,7 +211,7 @@ export async function handleServerRequest(request: Request, _event?: H3Event) { console.error(error) console.info() - return new Response(defaultTransformer.stringify(error), { + return new Response(startSerializer.stringify(error), { status: 500, headers: { 'Content-Type': 'application/json', diff --git a/packages/start-server/package.json b/packages/start-server/package.json index 73385040a9..502bfecec1 100644 --- a/packages/start-server/package.json +++ b/packages/start-server/package.json @@ -72,6 +72,7 @@ }, "devDependencies": { "@types/jsesc": "^3.0.3", + "esbuild": "^0.19.12", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.7.2" diff --git a/packages/start-server/src/StartServer.tsx b/packages/start-server/src/StartServer.tsx index 7bf52d6e66..646ee5e450 100644 --- a/packages/start-server/src/StartServer.tsx +++ b/packages/start-server/src/StartServer.tsx @@ -1,24 +1,9 @@ import * as React from 'react' -import { Context } from '@tanstack/react-cross-context' import { RouterProvider } from '@tanstack/react-router' import type { AnyRouter } from '@tanstack/react-router' export function StartServer(props: { router: TRouter }) { - const hydrationContext = Context.get('TanStackRouterHydrationContext', {}) - - const hydrationCtxValue = React.useMemo( - () => ({ - router: props.router.dehydrate(), - payload: props.router.options.dehydrate?.(), - }), - [props.router], - ) - - return ( - - - - ) + return } diff --git a/packages/start-server/src/createRequestHandler.ts b/packages/start-server/src/createRequestHandler.ts index 3973c0b89e..77418f1a44 100644 --- a/packages/start-server/src/createRequestHandler.ts +++ b/packages/start-server/src/createRequestHandler.ts @@ -1,5 +1,6 @@ import { createMemoryHistory } from '@tanstack/react-router' -import { mergeHeaders, serializeLoaderData } from '@tanstack/start-client' +import { mergeHeaders } from '@tanstack/start-client' +import { attachRouterServerSsrUtils, dehydrateRouter } from './ssr-server' import type { AnyRouter, Manifest } from '@tanstack/react-router' import type { HandlerCallback } from './defaultStreamHandler' @@ -19,12 +20,7 @@ export function createRequestHandler({ return async (cb) => { const router = createRouter() - // Inject a few of the SSR helpers and defaults - router.serializeLoaderData = serializeLoaderData - - if (getRouterManifest) { - router.manifest = getRouterManifest() - } + attachRouterServerSsrUtils(router, getRouterManifest?.()) const url = new URL(request.url, 'http://localhost') @@ -42,6 +38,8 @@ export function createRequestHandler({ await router.load() + dehydrateRouter(router) + const responseHeaders = getRequestHeaders({ router, }) diff --git a/packages/start-server/src/createStartHandler.ts b/packages/start-server/src/createStartHandler.ts index ae2968d8d6..606ca46ffb 100644 --- a/packages/start-server/src/createStartHandler.ts +++ b/packages/start-server/src/createStartHandler.ts @@ -1,11 +1,7 @@ import { createMemoryHistory } from '@tanstack/react-router' -import { - mergeHeaders, - onMatchSettled, - serializeLoaderData, -} from '@tanstack/start-client' +import { mergeHeaders } from '@tanstack/start-client' import { eventHandler, getResponseHeaders, toWebRequest } from 'h3' -import jsesc from 'jsesc' +import { attachRouterServerSsrUtils, dehydrateRouter } from './ssr-server' import type { H3Event } from 'h3' import type { AnyRouter, Manifest } from '@tanstack/react-router' import type { HandlerCallback } from './defaultStreamHandler' @@ -35,19 +31,7 @@ export function createStartHandler({ const router = createRouter() - // Inject a few of the SSR helpers and defaults - router.serializeLoaderData = serializeLoaderData - router.onMatchSettled = onMatchSettled - router.serializer = (value) => - jsesc(value, { - isScriptContext: true, - wrap: true, - json: true, - }) - - if (getRouterManifest) { - router.manifest = getRouterManifest() - } + attachRouterServerSsrUtils(router, getRouterManifest?.()) // Update the router with the history and context router.update({ @@ -56,6 +40,8 @@ export function createStartHandler({ await router.load() + dehydrateRouter(router) + const responseHeaders = getRequestHeaders({ event, router, diff --git a/packages/start-server/src/defaultRenderHandler.tsx b/packages/start-server/src/defaultRenderHandler.tsx index a0b1aa39a0..2ff85974f6 100644 --- a/packages/start-server/src/defaultRenderHandler.tsx +++ b/packages/start-server/src/defaultRenderHandler.tsx @@ -9,8 +9,8 @@ export const defaultRenderHandler: HandlerCallback = async ({ }) => { try { let html = ReactDOMServer.renderToString() - const injectedHtml = await Promise.all(router.injectedHtml).then((htmls) => - htmls.join(''), + const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( + (htmls) => htmls.join(''), ) html = html.replace(``, `${injectedHtml}`) return new Response(`${html}`, { diff --git a/packages/start-client/src/serialization.tsx b/packages/start-server/src/ssr-server.ts similarity index 62% rename from packages/start-client/src/serialization.tsx rename to packages/start-server/src/ssr-server.ts index e5b0706fa5..1f38cc21a4 100644 --- a/packages/start-client/src/serialization.tsx +++ b/packages/start-server/src/ssr-server.ts @@ -4,16 +4,27 @@ import { isPlainArray, isPlainObject, pick, + warning, } from '@tanstack/react-router' import jsesc from 'jsesc' +import { startSerializer } from '@tanstack/start-client' +import minifiedTsrBootStrapScript from './tsrScript?script-string' +import type { + ClientExtractedBaseEntry, + DehydratedRouter, + ResolvePromiseState, + SsrMatch, +} from '@tanstack/start-client' import type { AnyRouteMatch, AnyRouter, - ClientExtractedBaseEntry, DeferredPromise, - TSRGlobalMatch, + Manifest, } from '@tanstack/react-router' -import type { ResolvePromiseState } from './tsrScript' + +export type ServerExtractedEntry = + | ServerExtractedStream + | ServerExtractedPromise export interface ServerExtractedBaseEntry extends ClientExtractedBaseEntry { dataType: '__beforeLoadContext' | 'loaderData' @@ -26,15 +37,108 @@ export interface ServerExtractedStream extends ServerExtractedBaseEntry { stream: ReadableStream } -export type ServerExtractedEntry = - | ServerExtractedStream - | ServerExtractedPromise export interface ServerExtractedPromise extends ServerExtractedBaseEntry { type: 'promise' promise: DeferredPromise } -export function serializeLoaderData( +export function attachRouterServerSsrUtils( + router: AnyRouter, + manifest: Manifest | undefined, +) { + router.ssr = { + manifest, + serializer: startSerializer, + } + + router.serverSsr = { + injectedHtml: [], + streamedKeys: new Set(), + injectHtml: (getHtml) => { + const promise = Promise.resolve().then(getHtml) + router.serverSsr!.injectedHtml.push(promise) + router.emit({ + type: 'onInjectedHtml', + promise, + }) + }, + injectScript: (getScript, opts) => { + router.serverSsr!.injectHtml(async () => { + const script = await getScript() + return `` + }) + }, + streamValue: (key, value) => { + warning( + !router.serverSsr!.streamedKeys.has(key), + 'Key has already been streamed: ' + key, + ) + + router.serverSsr!.streamedKeys.add(key) + router.serverSsr!.injectScript( + () => + `__TSR_SSR__.streamedValues['${key}'] = { value: ${router.serializer?.(router.ssr!.serializer.stringify(value))}}`, + ) + }, + reduceBeforeLoadContext: (ctx, { match }) => { + return extractAsyncDataToMatch( + '__beforeLoadContext', + ctx.beforeLoadContext, + { + router: router, + match, + }, + ) + }, + onMatchSettled, + } + + router.serverSsr!.injectScript(() => minifiedTsrBootStrapScript, { + logScript: false, + }) +} + +export function dehydrateRouter(router: AnyRouter) { + const dehydratedRouter: DehydratedRouter = { + state: { + dehydratedMatches: router.state.matches.map((d) => { + return { + ...pick(d, ['id', 'status', 'updatedAt']), + // If an error occurs server-side during SSRing, + // send a small subset of the error to the client + error: d.error + ? router.ssr!.serializer.stringify(d.error) + : undefined, + // NOTE: We don't send the loader data here, because + // there is a potential that it needs to be streamed. + // Instead, we render it next to the route match in the HTML + // which gives us the potential to stream it via suspense. + } + }), + }, + manifest: router.ssr!.manifest, + dehydratedData: router.options.dehydrate?.(), + } + + router.serverSsr!.injectScript( + () => + `__TSR_SSR__.dehydrated = ${jsesc( + router.ssr!.serializer.stringify(dehydratedRouter), + { + isScriptContext: true, + wrap: true, + json: true, + }, + )}`, + ) +} + +export function extractAsyncDataToMatch( dataType: '__beforeLoadContext' | 'loaderData', data: any, ctx: { @@ -91,67 +195,12 @@ export function serializeLoaderData( return replacedLoaderData } -// Right after hydration and before the first render, we need to rehydrate each match -// This includes rehydrating the loaderData and also using the beforeLoadContext -// to reconstruct any context that was serialized on the server -export function afterHydrate({ router }: { router: AnyRouter }) { - router.state.matches.forEach((match) => { - const route = router.looseRoutesById[match.routeId]! - const dMatch = window.__TSR__?.matches[match.index] - if (dMatch) { - const parentMatch = router.state.matches[match.index - 1] - const parentContext = parentMatch?.context ?? router.options.context ?? {} - if (dMatch.__beforeLoadContext) { - match.__beforeLoadContext = router.options.transformer.parse( - dMatch.__beforeLoadContext, - ) as any - - match.context = { - ...parentContext, - ...match.context, - ...match.__beforeLoadContext, - } - } - - if (dMatch.loaderData) { - match.loaderData = router.options.transformer.parse(dMatch.loaderData) - } - - const extracted = dMatch.extracted - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (extracted) { - Object.entries(extracted).forEach(([_, ex]: any) => { - deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) - }) - } - } - - const headFnContent = route.options.head?.({ - matches: router.state.matches, - match, - params: match.params, - loaderData: match.loaderData, - }) - - Object.assign(match, { - meta: headFnContent?.meta, - links: headFnContent?.links, - scripts: headFnContent?.scripts, - }) - }) -} - export function onMatchSettled(opts: { router: AnyRouter match: AnyRouteMatch }) { const { router, match } = opts - if (!router.isServer) { - return null - } - const extracted = (match as any).extracted as | undefined | Array @@ -181,13 +230,13 @@ export function onMatchSettled(opts: { serializedLoaderData !== undefined || extracted?.length ) { - const initCode = `__TSR__.initMatch(${jsesc( + const initCode = `__TSR_SSR__.initMatch(${jsesc( { index: match.index, - __beforeLoadContext: router.options.transformer.stringify( + __beforeLoadContext: router.ssr!.serializer.stringify( serializedBeforeLoadData, ), - loaderData: router.options.transformer.stringify(serializedLoaderData), + loaderData: router.ssr!.serializer.stringify(serializedLoaderData), extracted: extracted ? Object.fromEntries( extracted.map((entry) => { @@ -195,7 +244,7 @@ export function onMatchSettled(opts: { }), ) : {}, - } satisfies TSRGlobalMatch, + } satisfies SsrMatch, { isScriptContext: true, wrap: true, @@ -203,7 +252,7 @@ export function onMatchSettled(opts: { }, )})` - router.injectScript(() => initCode) + router.serverSsr!.injectScript(() => initCode) if (extracted) { extracted.forEach((entry) => { @@ -214,7 +263,7 @@ export function onMatchSettled(opts: { function injectPromise(entry: ServerExtractedPromise) { entry.promise.then(() => { - const code = `__TSR__.resolvePromise(${jsesc( + const code = `__TSR_SSR__.resolvePromise(${jsesc( { id: entry.id, matchIndex: entry.matchIndex, @@ -227,7 +276,7 @@ export function onMatchSettled(opts: { }, )})` - router.injectScript(() => code) + router.serverSsr!.injectScript(() => code) }) } @@ -247,7 +296,7 @@ export function onMatchSettled(opts: { function injectChunk(chunk: string | null) { const code = chunk - ? `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.enqueue(new TextEncoder().encode(${jsesc( + ? `__TSR_SSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.enqueue(new TextEncoder().encode(${jsesc( chunk.toString(), { isScriptContext: true, @@ -255,9 +304,9 @@ export function onMatchSettled(opts: { json: true, }, )}))` - : `__TSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.close()` + : `__TSR_SSR__.matches[${entry.matchIndex}].extracted[${entry.id}].value.controller.close()` - router.injectScript(() => code) + router.serverSsr!.injectScript(() => code) } } } @@ -265,6 +314,33 @@ export function onMatchSettled(opts: { return null } +function deepImmutableSetByPath(obj: T, path: Array, value: any): T { + // immutable set by path retaining array and object references + if (path.length === 0) { + return value + } + + const [key, ...rest] = path + + if (Array.isArray(obj)) { + return obj.map((item, i) => { + if (i === Number(key)) { + return deepImmutableSetByPath(item, rest, value) + } + return item + }) as T + } + + if (isPlainObject(obj)) { + return { + ...obj, + [key!]: deepImmutableSetByPath((obj as any)[key!], rest, value), + } + } + + return obj +} + export function replaceBy( obj: T, cb: (value: any, path: Array) => any, @@ -305,45 +381,3 @@ export function replaceBy( return obj } - -function deepImmutableSetByPath(obj: T, path: Array, value: any): T { - // immutable set by path retaining array and object references - if (path.length === 0) { - return value - } - - const [key, ...rest] = path - - if (Array.isArray(obj)) { - return obj.map((item, i) => { - if (i === Number(key)) { - return deepImmutableSetByPath(item, rest, value) - } - return item - }) as T - } - - if (isPlainObject(obj)) { - return { - ...obj, - [key!]: deepImmutableSetByPath((obj as any)[key!], rest, value), - } - } - - return obj -} - -function deepMutableSetByPath(obj: T, path: Array, value: any) { - // mutable set by path retaining array and object references - if (path.length === 1) { - ;(obj as any)[path[0]!] = value - } - - const [key, ...rest] = path - - if (Array.isArray(obj)) { - deepMutableSetByPath(obj[Number(key)], rest, value) - } else if (isPlainObject(obj)) { - deepMutableSetByPath((obj as any)[key!], rest, value) - } -} diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 346f1879b8..cbcd384e3e 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -1,5 +1,6 @@ import { ReadableStream } from 'node:stream/web' import { Readable } from 'node:stream' +import { createControlledPromise } from '@tanstack/react-router' import type { AnyRouter } from '@tanstack/react-router' export function transformReadableStreamWithRouter( @@ -18,52 +19,11 @@ export function transformPipeableStreamWithRouter( ) } -function createRouterStream(router: AnyRouter) { - const routerStream = createPassthrough() - - let isAppRendering = true - - async function digestRouterStream(): Promise { - try { - while (router.injectedHtml.length) { - // Wait for any of the injected promises to settle - const getHtml = await Promise.race(router.injectedHtml) - // On success, push the html - - if (!routerStream.destroyed) { - routerStream.write(getHtml()) - } - } - - if (isAppRendering) { - setImmediate(() => { - digestRouterStream() - }) - } else { - routerStream.end() - } - } catch (error) { - console.error('Error processing HTML injection:', error) - routerStream.destroy( - error instanceof Error ? error : new Error(String(error)), - ) - } - } - - // Start digesting router injections into the routerStream - digestRouterStream() - - const appDoneRendering = () => { - isAppRendering = false - } - - return [routerStream, appDoneRendering] as const -} - // regex pattern for matching closing body and html tags const patternBodyStart = /()/ const patternHtmlEnd = /(<\/html>)/ +const patternHeadEnd = /(<\/head>)/ // regex pattern for matching closing tags const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g @@ -132,8 +92,8 @@ export function transformStreamWithRouter( appStream: ReadableStream, ) { const finalPassThrough = createPassthrough() - const [routerStream, appDoneRendering] = createRouterStream(router) + let isAppRendering = true as boolean let routerStreamBuffer = '' let pendingClosingTags = '' let bodyStarted = false as boolean @@ -153,38 +113,81 @@ export function transformStreamWithRouter( return String(chunk) } - // Buffer and handle `routerStream` data - readStream(routerStream.stream, { - onData: (chunk) => { - const text = decodeChunk(chunk.value) + const injectedHtmlDonePromise = createControlledPromise() - if (!bodyStarted) { - routerStreamBuffer += text - } else { - finalPassThrough.write(text) - } + let processingCount = 0 + + // Process any already-injected HTML + router.serverSsr!.injectedHtml.forEach((promise) => { + console.log('pre-added injected html') + handleInjectedHtml(promise) + }) + + // Listen for any new injected HTML + const stopListeningToInjectedHtml = router.subscribe( + 'onInjectedHtml', + (e) => { + console.log('subscription injected html') + handleInjectedHtml(e.promise) }, - onEnd: () => { - const finalHtml = leftoverHtml + pendingClosingTags + ) + + function handleInjectedHtml(promise: Promise) { + // If the app is done rendering and we've already resolved the promise, + // stop processing + if (!isAppRendering && injectedHtmlDonePromise.status !== 'pending') { + console.log('stop listening to injected html') + stopListeningToInjectedHtml() + return + } + + processingCount++ + + promise + .then((html) => { + console.log('injected html') + if (!bodyStarted) { + routerStreamBuffer += html + } else { + finalPassThrough.write(html) + } + }) + .catch(injectedHtmlDonePromise.reject) + .finally(() => { + processingCount-- + console.log('processing count', processingCount) + if (!isAppRendering && processingCount === 0) { + injectedHtmlDonePromise.resolve() + } + }) + } + + injectedHtmlDonePromise + .then(() => { + const finalHtml = + leftoverHtml + getBufferedRouterStream() + pendingClosingTags + finalPassThrough.end(finalHtml) - }, - onError: (error) => { - console.error('Error reading routerStream:', error) - finalPassThrough.destroy(error) - }, - }) + }) + .catch((err) => { + console.error('Error reading routerStream:', err) + finalPassThrough.destroy(err) + }) // Transform the appStream readStream(appStream, { onData: (chunk) => { + console.log('app data') const text = decodeChunk(chunk.value) const chunkString = leftover + text const bodyStartMatch = chunkString.match(patternBodyStart) const bodyEndMatch = chunkString.match(patternBodyEnd) const htmlEndMatch = chunkString.match(patternHtmlEnd) + const headEndMatch = chunkString.match(patternHeadEnd) if (bodyStartMatch) { + console.log('body start') bodyStarted = true } @@ -194,16 +197,35 @@ export function transformStreamWithRouter( return } + // If either the body end or html end is in the chunk, + // We need to get all of our data in asap if ( bodyEndMatch && htmlEndMatch && bodyEndMatch.index! < htmlEndMatch.index! ) { - const bodyIndex = bodyEndMatch.index! - pendingClosingTags = chunkString.slice(bodyIndex) - finalPassThrough.write( - chunkString.slice(0, bodyIndex) + getBufferedRouterStream(), - ) + const bodyEndIndex = bodyEndMatch.index! + pendingClosingTags = chunkString.slice(bodyEndIndex) + + let html = '' + + if (headEndMatch) { + // If the body start is also in the chunk, + // let's insert our buffered router stream before the end of + // the head tag + const headEndIndex = headEndMatch.index! + html = + chunkString.slice(0, headEndIndex) + + getBufferedRouterStream() + + chunkString.slice(headEndIndex + ''.length, bodyEndIndex) + } else { + // If the body start is not in the chunk, + // let's insert our buffered router stream at the end of the body tag + html = chunkString.slice(0, bodyEndIndex) + getBufferedRouterStream() + } + + finalPassThrough.write(html) + leftover = '' return } @@ -219,6 +241,7 @@ export function transformStreamWithRouter( chunkString.slice(0, lastIndex) + getBufferedRouterStream() + leftoverHtml + finalPassThrough.write(processed) leftover = chunkString.slice(lastIndex) } else { @@ -227,7 +250,17 @@ export function transformStreamWithRouter( } }, onEnd: () => { - appDoneRendering() + console.log('app done rendering') + // Stop listening to any new injected HTML + stopListeningToInjectedHtml() + + // Mark the app as done rendering + isAppRendering = false + + // If there are no pending promises, resolve the injectedHtmlDonePromise + if (processingCount === 0) { + injectedHtmlDonePromise.resolve() + } }, onError: (error) => { console.error('Error reading appStream:', error) diff --git a/packages/start-client/src/tsrScript.ts b/packages/start-server/src/tsrScript.ts similarity index 64% rename from packages/start-client/src/tsrScript.ts rename to packages/start-server/src/tsrScript.ts index dbaacb1244..59a087ef64 100644 --- a/packages/start-client/src/tsrScript.ts +++ b/packages/start-server/src/tsrScript.ts @@ -1,35 +1,13 @@ -import type { - ControllablePromise, - DeferredPromiseState, - TSRGlobal, - TSRGlobalMatch, -} from '@tanstack/react-router' +import type { ControllablePromise } from '@tanstack/react-router' +import type { StartSsrGlobal } from '@tanstack/start-client' -export interface ResolvePromiseState { - id: number - matchIndex: number - promiseState: DeferredPromiseState -} -interface StartTSRGlobal extends TSRGlobal { - queue: Array<() => boolean> - runQueue: () => void - initMatch: (match: TSRGlobalMatch) => void - resolvePromise: (p: ResolvePromiseState) => void -} - -declare module '@tanstack/react-router' { - interface Register { - __TSR__: StartTSRGlobal - } -} - -const __TSR__: StartTSRGlobal = { +const __TSR_SSR__: StartSsrGlobal = { matches: [], streamedValues: {}, queue: [], runQueue: () => { let changed = false as boolean - __TSR__.queue = __TSR__.queue.filter((fn) => { + __TSR_SSR__.queue = __TSR_SSR__.queue.filter((fn) => { if (fn()) { changed = true return false @@ -37,13 +15,13 @@ const __TSR__: StartTSRGlobal = { return true }) if (changed) { - __TSR__.runQueue() + __TSR_SSR__.runQueue() } }, initMatch: (match) => { - __TSR__.queue.push(() => { - if (!__TSR__.matches[match.index]) { - __TSR__.matches[match.index] = match + __TSR_SSR__.queue.push(() => { + if (!__TSR_SSR__.matches[match.index]) { + __TSR_SSR__.matches[match.index] = match Object.entries(match.extracted).forEach(([id, ex]) => { if (ex.type === 'stream') { let controller @@ -64,7 +42,6 @@ const __TSR__: StartTSRGlobal = { }, }) ex.value.controller = controller - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (ex.type === 'promise') { let resolve: ControllablePromise['reject'] | undefined let reject: ControllablePromise['reject'] | undefined @@ -82,11 +59,11 @@ const __TSR__: StartTSRGlobal = { return true }) - __TSR__.runQueue() + __TSR_SSR__.runQueue() }, resolvePromise: (p) => { - __TSR__.queue.push(() => { - const match = __TSR__.matches[p.matchIndex] + __TSR_SSR__.queue.push(() => { + const match = __TSR_SSR__.matches[p.matchIndex] if (match) { const ex = match.extracted[p.id] if ( @@ -102,7 +79,7 @@ const __TSR__: StartTSRGlobal = { return false }) - __TSR__.runQueue() + __TSR_SSR__.runQueue() }, cleanScripts: () => { document.querySelectorAll('.tsr-once').forEach((el) => { @@ -111,4 +88,4 @@ const __TSR__: StartTSRGlobal = { }, } -window.__TSR__ = __TSR__ +window.__TSR_SSR__ = __TSR_SSR__ diff --git a/packages/start-client/src/vite-env.d.ts b/packages/start-server/src/vite-env.d.ts similarity index 100% rename from packages/start-client/src/vite-env.d.ts rename to packages/start-server/src/vite-env.d.ts diff --git a/packages/start-server/tsconfig.json b/packages/start-server/tsconfig.json index 51dda9abf2..017bc15e1e 100644 --- a/packages/start-server/tsconfig.json +++ b/packages/start-server/tsconfig.json @@ -4,5 +4,5 @@ "jsx": "react-jsx", "module": "esnext" }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "../start-client/src/tsrScript.ts"] } diff --git a/packages/start-client/vite-minify-plugin.ts b/packages/start-server/vite-minify-plugin.ts similarity index 100% rename from packages/start-client/vite-minify-plugin.ts rename to packages/start-server/vite-minify-plugin.ts diff --git a/packages/start-server/vite.config.ts b/packages/start-server/vite.config.ts index 9cdf6497ee..e05e5cc394 100644 --- a/packages/start-server/vite.config.ts +++ b/packages/start-server/vite.config.ts @@ -2,10 +2,11 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/config/vite' import react from '@vitejs/plugin-react' import packageJson from './package.json' +import minifyScriptPlugin from './vite-minify-plugin' import type { ViteUserConfig } from 'vitest/config' const config = defineConfig({ - plugins: [react()] as ViteUserConfig['plugins'], + plugins: [minifyScriptPlugin(), react()] as ViteUserConfig['plugins'], test: { name: packageJson.name, watch: false, @@ -16,7 +17,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: './src/index.tsx', srcDir: './src', + entry: './src/index.tsx', }), ) diff --git a/packages/start/vite-minify-plugin.ts b/packages/start/vite-minify-plugin.ts deleted file mode 100644 index d8da290266..0000000000 --- a/packages/start/vite-minify-plugin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { transform as esbuildTransform } from 'esbuild' -import type { Plugin } from 'vite' - -export default function minifyScriptPlugin(): Plugin { - return { - name: 'vite-plugin-minify-script', - enforce: 'pre', - async transform(code, id) { - if (!id.endsWith('?script-string')) { - return null - } - - const result = await esbuildTransform(code, { - loader: 'ts', - minify: true, - target: 'esnext', - }) - - return { - code: `export default ${JSON.stringify(result.code)};`, - map: null, - } - }, - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2f66498c4..ffc5a19c38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3837,9 +3837,6 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(terser@5.36.0)(tsx@4.19.2)(yaml@2.6.1)) - esbuild: - specifier: ^0.24.2 - version: 0.24.2 packages/start-config: dependencies: @@ -4001,6 +3998,9 @@ importers: '@types/jsesc': specifier: ^3.0.3 version: 3.0.3 + esbuild: + specifier: ^0.19.12 + version: 0.19.12 react: specifier: ^18.2.0 version: 18.3.1 @@ -4063,6 +4063,9 @@ importers: '@tanstack/react-router': specifier: workspace:* version: link:../react-router + '@tanstack/start-client': + specifier: workspace:^ + version: link:../start-client '@tanstack/start-server': specifier: workspace:* version: link:../start-server @@ -4431,6 +4434,12 @@ packages: '@emnapi/wasi-threads@1.0.1': resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.20.2': resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} engines: {node: '>=12'} @@ -4455,6 +4464,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.20.2': resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} engines: {node: '>=12'} @@ -4479,6 +4494,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.20.2': resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} engines: {node: '>=12'} @@ -4503,6 +4524,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.20.2': resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} engines: {node: '>=12'} @@ -4527,6 +4554,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.20.2': resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} engines: {node: '>=12'} @@ -4551,6 +4584,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.20.2': resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} engines: {node: '>=12'} @@ -4575,6 +4614,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.20.2': resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} engines: {node: '>=12'} @@ -4599,6 +4644,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.20.2': resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} engines: {node: '>=12'} @@ -4623,6 +4674,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.20.2': resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} engines: {node: '>=12'} @@ -4647,6 +4704,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.20.2': resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} engines: {node: '>=12'} @@ -4671,6 +4734,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.20.2': resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} engines: {node: '>=12'} @@ -4695,6 +4764,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.20.2': resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} engines: {node: '>=12'} @@ -4719,6 +4794,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.20.2': resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} engines: {node: '>=12'} @@ -4743,6 +4824,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.20.2': resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} engines: {node: '>=12'} @@ -4767,6 +4854,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.20.2': resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} engines: {node: '>=12'} @@ -4791,6 +4884,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.20.2': resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} engines: {node: '>=12'} @@ -4815,6 +4914,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.20.2': resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} engines: {node: '>=12'} @@ -4845,6 +4950,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.20.2': resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} engines: {node: '>=12'} @@ -4887,6 +4998,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.20.2': resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} engines: {node: '>=12'} @@ -4911,6 +5028,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.20.2': resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} engines: {node: '>=12'} @@ -4935,6 +5058,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.20.2': resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} engines: {node: '>=12'} @@ -4959,6 +5088,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.20.2': resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} engines: {node: '>=12'} @@ -4983,6 +5118,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.20.2': resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} engines: {node: '>=12'} @@ -7608,6 +7749,11 @@ packages: peerDependencies: esbuild: '>=0.12 <1' + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -11449,6 +11595,9 @@ snapshots: dependencies: tslib: 2.8.1 + '@esbuild/aix-ppc64@0.19.12': + optional: true + '@esbuild/aix-ppc64@0.20.2': optional: true @@ -11461,6 +11610,9 @@ snapshots: '@esbuild/aix-ppc64@0.24.2': optional: true + '@esbuild/android-arm64@0.19.12': + optional: true + '@esbuild/android-arm64@0.20.2': optional: true @@ -11473,6 +11625,9 @@ snapshots: '@esbuild/android-arm64@0.24.2': optional: true + '@esbuild/android-arm@0.19.12': + optional: true + '@esbuild/android-arm@0.20.2': optional: true @@ -11485,6 +11640,9 @@ snapshots: '@esbuild/android-arm@0.24.2': optional: true + '@esbuild/android-x64@0.19.12': + optional: true + '@esbuild/android-x64@0.20.2': optional: true @@ -11497,6 +11655,9 @@ snapshots: '@esbuild/android-x64@0.24.2': optional: true + '@esbuild/darwin-arm64@0.19.12': + optional: true + '@esbuild/darwin-arm64@0.20.2': optional: true @@ -11509,6 +11670,9 @@ snapshots: '@esbuild/darwin-arm64@0.24.2': optional: true + '@esbuild/darwin-x64@0.19.12': + optional: true + '@esbuild/darwin-x64@0.20.2': optional: true @@ -11521,6 +11685,9 @@ snapshots: '@esbuild/darwin-x64@0.24.2': optional: true + '@esbuild/freebsd-arm64@0.19.12': + optional: true + '@esbuild/freebsd-arm64@0.20.2': optional: true @@ -11533,6 +11700,9 @@ snapshots: '@esbuild/freebsd-arm64@0.24.2': optional: true + '@esbuild/freebsd-x64@0.19.12': + optional: true + '@esbuild/freebsd-x64@0.20.2': optional: true @@ -11545,6 +11715,9 @@ snapshots: '@esbuild/freebsd-x64@0.24.2': optional: true + '@esbuild/linux-arm64@0.19.12': + optional: true + '@esbuild/linux-arm64@0.20.2': optional: true @@ -11557,6 +11730,9 @@ snapshots: '@esbuild/linux-arm64@0.24.2': optional: true + '@esbuild/linux-arm@0.19.12': + optional: true + '@esbuild/linux-arm@0.20.2': optional: true @@ -11569,6 +11745,9 @@ snapshots: '@esbuild/linux-arm@0.24.2': optional: true + '@esbuild/linux-ia32@0.19.12': + optional: true + '@esbuild/linux-ia32@0.20.2': optional: true @@ -11581,6 +11760,9 @@ snapshots: '@esbuild/linux-ia32@0.24.2': optional: true + '@esbuild/linux-loong64@0.19.12': + optional: true + '@esbuild/linux-loong64@0.20.2': optional: true @@ -11593,6 +11775,9 @@ snapshots: '@esbuild/linux-loong64@0.24.2': optional: true + '@esbuild/linux-mips64el@0.19.12': + optional: true + '@esbuild/linux-mips64el@0.20.2': optional: true @@ -11605,6 +11790,9 @@ snapshots: '@esbuild/linux-mips64el@0.24.2': optional: true + '@esbuild/linux-ppc64@0.19.12': + optional: true + '@esbuild/linux-ppc64@0.20.2': optional: true @@ -11617,6 +11805,9 @@ snapshots: '@esbuild/linux-ppc64@0.24.2': optional: true + '@esbuild/linux-riscv64@0.19.12': + optional: true + '@esbuild/linux-riscv64@0.20.2': optional: true @@ -11629,6 +11820,9 @@ snapshots: '@esbuild/linux-riscv64@0.24.2': optional: true + '@esbuild/linux-s390x@0.19.12': + optional: true + '@esbuild/linux-s390x@0.20.2': optional: true @@ -11641,6 +11835,9 @@ snapshots: '@esbuild/linux-s390x@0.24.2': optional: true + '@esbuild/linux-x64@0.19.12': + optional: true + '@esbuild/linux-x64@0.20.2': optional: true @@ -11656,6 +11853,9 @@ snapshots: '@esbuild/netbsd-arm64@0.24.2': optional: true + '@esbuild/netbsd-x64@0.19.12': + optional: true + '@esbuild/netbsd-x64@0.20.2': optional: true @@ -11677,6 +11877,9 @@ snapshots: '@esbuild/openbsd-arm64@0.24.2': optional: true + '@esbuild/openbsd-x64@0.19.12': + optional: true + '@esbuild/openbsd-x64@0.20.2': optional: true @@ -11689,6 +11892,9 @@ snapshots: '@esbuild/openbsd-x64@0.24.2': optional: true + '@esbuild/sunos-x64@0.19.12': + optional: true + '@esbuild/sunos-x64@0.20.2': optional: true @@ -11701,6 +11907,9 @@ snapshots: '@esbuild/sunos-x64@0.24.2': optional: true + '@esbuild/win32-arm64@0.19.12': + optional: true + '@esbuild/win32-arm64@0.20.2': optional: true @@ -11713,6 +11922,9 @@ snapshots: '@esbuild/win32-arm64@0.24.2': optional: true + '@esbuild/win32-ia32@0.19.12': + optional: true + '@esbuild/win32-ia32@0.20.2': optional: true @@ -11725,6 +11937,9 @@ snapshots: '@esbuild/win32-ia32@0.24.2': optional: true + '@esbuild/win32-x64@0.19.12': + optional: true + '@esbuild/win32-x64@0.20.2': optional: true @@ -14586,6 +14801,32 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 From 779efb0ee816a0f957d507fdf0c1a2e361b56b55 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 22:20:52 -0700 Subject: [PATCH 11/30] fixes --- packages/start-server/src/tsrScript.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/start-server/src/tsrScript.ts b/packages/start-server/src/tsrScript.ts index 59a087ef64..ebeab0863c 100644 --- a/packages/start-server/src/tsrScript.ts +++ b/packages/start-server/src/tsrScript.ts @@ -22,7 +22,7 @@ const __TSR_SSR__: StartSsrGlobal = { __TSR_SSR__.queue.push(() => { if (!__TSR_SSR__.matches[match.index]) { __TSR_SSR__.matches[match.index] = match - Object.entries(match.extracted).forEach(([id, ex]) => { + Object.entries(match.extracted).forEach(([_id, ex]) => { if (ex.type === 'stream') { let controller ex.value = new ReadableStream({ @@ -42,7 +42,7 @@ const __TSR_SSR__: StartSsrGlobal = { }, }) ex.value.controller = controller - } else if (ex.type === 'promise') { + } else { let resolve: ControllablePromise['reject'] | undefined let reject: ControllablePromise['reject'] | undefined From ae2102357b895c01003976fbdda372d08eb19891 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 22:25:21 -0700 Subject: [PATCH 12/30] readable streams --- packages/start-server/src/ssr-server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 1f38cc21a4..342751f988 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -1,5 +1,6 @@ import { TSR_DEFERRED_PROMISE, + createControlledPromise, defer, isPlainArray, isPlainObject, @@ -281,6 +282,11 @@ export function onMatchSettled(opts: { } function injectStream(entry: ServerExtractedStream) { + const controlledPromise = createControlledPromise() + + // Inject a promise that resolves when the stream is done + // We do this to keep the stream open until we're done + router.serverSsr!.injectHtml(() => controlledPromise.then(() => '')) ;(async () => { try { const reader = entry.stream.getReader() @@ -288,9 +294,10 @@ export function onMatchSettled(opts: { while (!(chunk = await reader.read()).done) { injectChunk(chunk.value) } - // reader.releaseLock() + controlledPromise.resolve() } catch (err) { console.error('stream read error', err) + controlledPromise.reject(err) } })() From 75e94dd3d70d4caf25eb4ba598ed65f8b4737fac Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 22:28:49 -0700 Subject: [PATCH 13/30] inject promises --- packages/start-server/src/ssr-server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 342751f988..8c9e1c35d6 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -263,8 +263,10 @@ export function onMatchSettled(opts: { } function injectPromise(entry: ServerExtractedPromise) { - entry.promise.then(() => { - const code = `__TSR_SSR__.resolvePromise(${jsesc( + router.serverSsr!.injectHtml(async () => { + await entry.promise + + return `__TSR_SSR__.resolvePromise(${jsesc( { id: entry.id, matchIndex: entry.matchIndex, @@ -276,8 +278,6 @@ export function onMatchSettled(opts: { json: true, }, )})` - - router.serverSsr!.injectScript(() => code) }) } From 94a245d5ef24624ec18d1b10767e7934f1aa1069 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 22:55:47 -0700 Subject: [PATCH 14/30] almost there --- packages/react-router/src/router.ts | 17 +++----------- packages/start-server/src/ssr-server.ts | 15 ++++++++++++- .../src/transformStreamWithRouter.ts | 22 +++---------------- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 5513d7e0fe..082d0b036b 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -663,16 +663,6 @@ export class Router< isViewTransitionTypesSupported?: boolean = undefined subscribers = new Set>() viewTransitionPromise?: ControlledPromise - manifest?: Manifest - serializeLoaderData?: ( - type: '__beforeLoadContext' | 'loaderData', - loaderData: any, - ctx: { - router: AnyRouter - match: AnyRouteMatch - }, - ) => any - serializer?: (data: any) => string // Must build in constructor __store!: Store> @@ -2503,12 +2493,10 @@ export class Router< let loaderData = await route.options.loader?.(getLoaderContext()) - if (this.serializeLoaderData) { - loaderData = this.serializeLoaderData( - 'loaderData', + if (this.serverSsr?.reduceLoaderData) { + loaderData = this.serverSsr.reduceLoaderData( loaderData, { - router: this, match: this.getMatch(matchId)!, }, ) @@ -2949,6 +2937,7 @@ export class Router< beforeLoadContext: any, opts: { match: AnyRouteMatch }, ) => any + reduceLoaderData: (loaderData: any, opts: { match: AnyRouteMatch }) => any onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any } diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 8c9e1c35d6..184d790a58 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -83,7 +83,14 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` router.serverSsr!.streamedKeys.add(key) router.serverSsr!.injectScript( () => - `__TSR_SSR__.streamedValues['${key}'] = { value: ${router.serializer?.(router.ssr!.serializer.stringify(value))}}`, + `__TSR_SSR__.streamedValues['${key}'] = { value: ${jsesc( + router.ssr!.serializer.stringify(value), + { + isScriptContext: true, + wrap: true, + json: true, + }, + )}}`, ) }, reduceBeforeLoadContext: (ctx, { match }) => { @@ -96,6 +103,12 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` }, ) }, + reduceLoaderData: (loaderData, { match }) => { + return extractAsyncDataToMatch('loaderData', loaderData, { + router: router, + match, + }) + }, onMatchSettled, } diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index cbcd384e3e..831c9159f9 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -184,7 +184,6 @@ export function transformStreamWithRouter( const bodyStartMatch = chunkString.match(patternBodyStart) const bodyEndMatch = chunkString.match(patternBodyEnd) const htmlEndMatch = chunkString.match(patternHtmlEnd) - const headEndMatch = chunkString.match(patternHeadEnd) if (bodyStartMatch) { console.log('body start') @@ -207,24 +206,9 @@ export function transformStreamWithRouter( const bodyEndIndex = bodyEndMatch.index! pendingClosingTags = chunkString.slice(bodyEndIndex) - let html = '' - - if (headEndMatch) { - // If the body start is also in the chunk, - // let's insert our buffered router stream before the end of - // the head tag - const headEndIndex = headEndMatch.index! - html = - chunkString.slice(0, headEndIndex) + - getBufferedRouterStream() + - chunkString.slice(headEndIndex + ''.length, bodyEndIndex) - } else { - // If the body start is not in the chunk, - // let's insert our buffered router stream at the end of the body tag - html = chunkString.slice(0, bodyEndIndex) + getBufferedRouterStream() - } - - finalPassThrough.write(html) + finalPassThrough.write( + chunkString.slice(0, bodyEndIndex) + getBufferedRouterStream(), + ) leftover = '' return From df99ace5b7811529e037736f143fcf5677ba81b0 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 23:04:49 -0700 Subject: [PATCH 15/30] please --- packages/react-router/src/router.ts | 25 ++--------------- packages/start-server/src/ssr-server.ts | 36 +++++++++---------------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 082d0b036b..290dd6116d 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -2340,17 +2340,10 @@ export class Router< matches, } - let beforeLoadContext = + const beforeLoadContext = (await route.options.beforeLoad?.(beforeLoadFnContext)) ?? {} - if (this.serverSsr?.reduceBeforeLoadContext) { - beforeLoadContext = this.serverSsr.reduceBeforeLoadContext( - beforeLoadContext, - { match: this.getMatch(matchId)! }, - ) - } - if ( isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext) @@ -2490,18 +2483,9 @@ export class Router< })) // Kick off the loader! - let loaderData = + const loaderData = await route.options.loader?.(getLoaderContext()) - if (this.serverSsr?.reduceLoaderData) { - loaderData = this.serverSsr.reduceLoaderData( - loaderData, - { - match: this.getMatch(matchId)!, - }, - ) - } - handleRedirectAndNotFound( this.getMatch(matchId)!, loaderData, @@ -2933,11 +2917,6 @@ export class Router< ) => void streamValue: (key: string, value: any) => void streamedKeys: Set - reduceBeforeLoadContext: ( - beforeLoadContext: any, - opts: { match: AnyRouteMatch }, - ) => any - reduceLoaderData: (loaderData: any, opts: { match: AnyRouteMatch }) => any onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any } diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 184d790a58..7f98105ea3 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -93,22 +93,6 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` )}}`, ) }, - reduceBeforeLoadContext: (ctx, { match }) => { - return extractAsyncDataToMatch( - '__beforeLoadContext', - ctx.beforeLoadContext, - { - router: router, - match, - }, - ) - }, - reduceLoaderData: (loaderData, { match }) => { - return extractAsyncDataToMatch('loaderData', loaderData, { - router: router, - match, - }) - }, onMatchSettled, } @@ -160,10 +144,6 @@ export function extractAsyncDataToMatch( router: AnyRouter }, ) { - if (!ctx.router.isServer) { - return data - } - ;(ctx.match as any).extracted = (ctx.match as any).extracted || [] const extracted = (ctx.match as any).extracted @@ -222,6 +202,11 @@ export function onMatchSettled(opts: { const [serializedBeforeLoadData, serializedLoaderData] = ( ['__beforeLoadContext', 'loaderData'] as const ).map((dataType) => { + const data = extractAsyncDataToMatch(dataType, match[dataType], { + router: router, + match, + }) + return extracted ? extracted.reduce( (acc: any, entry: ServerExtractedEntry) => { @@ -234,11 +219,16 @@ export function onMatchSettled(opts: { } return acc }, - { temp: match[dataType] }, + { temp: data }, ).temp - : match[dataType] + : data }) + // return extractAsyncDataToMatch('__beforeLoadContext', ctx.beforeLoadContext, { + // router: router, + // match, + // }) + if ( serializedBeforeLoadData !== undefined || serializedLoaderData !== undefined || @@ -276,7 +266,7 @@ export function onMatchSettled(opts: { } function injectPromise(entry: ServerExtractedPromise) { - router.serverSsr!.injectHtml(async () => { + router.serverSsr!.injectScript(async () => { await entry.promise return `__TSR_SSR__.resolvePromise(${jsesc( From 96927c1f64663b59e1545985bf4569ce796f1d39 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 23 Jan 2025 23:26:34 -0700 Subject: [PATCH 16/30] yEssssss --- packages/start-server/src/ssr-server.ts | 15 +++++++-------- .../start-server/src/transformStreamWithRouter.ts | 9 +-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 7f98105ea3..7841e92a92 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -195,10 +195,6 @@ export function onMatchSettled(opts: { }) { const { router, match } = opts - const extracted = (match as any).extracted as - | undefined - | Array - const [serializedBeforeLoadData, serializedLoaderData] = ( ['__beforeLoadContext', 'loaderData'] as const ).map((dataType) => { @@ -207,6 +203,10 @@ export function onMatchSettled(opts: { match, }) + const extracted = (match as any).extracted as + | undefined + | Array + return extracted ? extracted.reduce( (acc: any, entry: ServerExtractedEntry) => { @@ -224,10 +224,9 @@ export function onMatchSettled(opts: { : data }) - // return extractAsyncDataToMatch('__beforeLoadContext', ctx.beforeLoadContext, { - // router: router, - // match, - // }) + const extracted = (match as any).extracted as + | undefined + | Array if ( serializedBeforeLoadData !== undefined || diff --git a/packages/start-server/src/transformStreamWithRouter.ts b/packages/start-server/src/transformStreamWithRouter.ts index 831c9159f9..1a97db36b1 100644 --- a/packages/start-server/src/transformStreamWithRouter.ts +++ b/packages/start-server/src/transformStreamWithRouter.ts @@ -119,7 +119,6 @@ export function transformStreamWithRouter( // Process any already-injected HTML router.serverSsr!.injectedHtml.forEach((promise) => { - console.log('pre-added injected html') handleInjectedHtml(promise) }) @@ -127,7 +126,6 @@ export function transformStreamWithRouter( const stopListeningToInjectedHtml = router.subscribe( 'onInjectedHtml', (e) => { - console.log('subscription injected html') handleInjectedHtml(e.promise) }, ) @@ -136,7 +134,6 @@ export function transformStreamWithRouter( // If the app is done rendering and we've already resolved the promise, // stop processing if (!isAppRendering && injectedHtmlDonePromise.status !== 'pending') { - console.log('stop listening to injected html') stopListeningToInjectedHtml() return } @@ -145,7 +142,6 @@ export function transformStreamWithRouter( promise .then((html) => { - console.log('injected html') if (!bodyStarted) { routerStreamBuffer += html } else { @@ -155,7 +151,7 @@ export function transformStreamWithRouter( .catch(injectedHtmlDonePromise.reject) .finally(() => { processingCount-- - console.log('processing count', processingCount) + if (!isAppRendering && processingCount === 0) { injectedHtmlDonePromise.resolve() } @@ -177,7 +173,6 @@ export function transformStreamWithRouter( // Transform the appStream readStream(appStream, { onData: (chunk) => { - console.log('app data') const text = decodeChunk(chunk.value) const chunkString = leftover + text @@ -186,7 +181,6 @@ export function transformStreamWithRouter( const htmlEndMatch = chunkString.match(patternHtmlEnd) if (bodyStartMatch) { - console.log('body start') bodyStarted = true } @@ -234,7 +228,6 @@ export function transformStreamWithRouter( } }, onEnd: () => { - console.log('app done rendering') // Stop listening to any new injected HTML stopListeningToInjectedHtml() From a5e9861dbe5dffa00a8a98bec758edad2b8bf406 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 24 Jan 2025 00:11:07 -0700 Subject: [PATCH 17/30] YESSSSS --- packages/start-client/src/ssr-client.tsx | 57 +++++++++++--------- packages/start-server/src/ssr-server.ts | 21 ++------ packages/start-server/src/tsrScript.ts | 67 ++++++++++++------------ 3 files changed, 70 insertions(+), 75 deletions(-) diff --git a/packages/start-client/src/ssr-client.tsx b/packages/start-client/src/ssr-client.tsx index dd4b10c6e8..27d7fda5aa 100644 --- a/packages/start-client/src/ssr-client.tsx +++ b/packages/start-client/src/ssr-client.tsx @@ -35,10 +35,13 @@ export interface StartSsrGlobal { } export interface SsrMatch { - index: number + id: string __beforeLoadContext?: string loaderData?: string + error?: string extracted: Record + updatedAt: MakeRouteMatch['updatedAt'] + status: MakeRouteMatch['status'] } export type ClientExtractedEntry = @@ -76,7 +79,6 @@ export type DehydratedRouteMatch = Pick< > export interface DehydratedRouter { - state: DehydratedRouterState manifest: Manifest | undefined dehydratedData: any } @@ -87,7 +89,7 @@ export function hydrate(router: AnyRouter) { 'Expected to find a dehydrated data on window.__TSR_SSR__.dehydrated... but we did not. Please file an issue!', ) - const { state, manifest, dehydratedData } = startSerializer.parse( + const { manifest, dehydratedData } = startSerializer.parse( window.__TSR_SSR__.dehydrated, ) as DehydratedRouter @@ -123,26 +125,24 @@ export function hydrate(router: AnyRouter) { const matches = router.matchRoutes(router.state.location).map((match) => { const route = router.looseRoutesById[match.routeId]! - const dehydratedMatch = state.dehydratedMatches.find( - (d) => d.id === match.id, - ) - - invariant( - dehydratedMatch, - `Could not find a client-side match for dehydrated match with id: ${match.id}!`, - ) - // Right after hydration and before the first render, we need to rehydrate each match // This includes rehydrating the loaderData and also using the beforeLoadContext // to reconstruct any context that was serialized on the server - const dMatch = window.__TSR_SSR__?.matches[match.index] - if (dMatch) { + const dehydratedMatch = window.__TSR_SSR__!.matches.find( + (d) => d.id === match.id, + ) + + if (dehydratedMatch) { + Object.assign(match, dehydratedMatch) + const parentMatch = router.state.matches[match.index - 1] const parentContext = parentMatch?.context ?? router.options.context ?? {} - if (dMatch.__beforeLoadContext) { + + // Handle beforeLoadContext + if (dehydratedMatch.__beforeLoadContext) { match.__beforeLoadContext = router.ssr!.serializer.parse( - dMatch.__beforeLoadContext, + dehydratedMatch.__beforeLoadContext, ) as any match.context = { @@ -152,15 +152,27 @@ export function hydrate(router: AnyRouter) { } } - if (dMatch.loaderData) { - match.loaderData = router.ssr!.serializer.parse(dMatch.loaderData) + // Handle loaderData + if (dehydratedMatch.loaderData) { + match.loaderData = router.ssr!.serializer.parse( + dehydratedMatch.loaderData, + ) } - const extracted = dMatch.extracted + // Handle error + if (dehydratedMatch.error) { + match.error = router.ssr!.serializer.parse(dehydratedMatch.error) + } - Object.entries(extracted).forEach(([_, ex]: any) => { + // Handle extracted + Object.entries((match as any).extracted).forEach(([_, ex]: any) => { deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) }) + } else { + Object.assign(match, { + status: 'success', + updatedAt: Date.now(), + }) } const headFnContent = route.options.head?.({ @@ -176,10 +188,7 @@ export function hydrate(router: AnyRouter) { scripts: headFnContent?.scripts, }) - return { - ...match, - ...dehydratedMatch, - } + return match }) router.__store.setState((s) => { diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index 7841e92a92..a8a842ac25 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -103,22 +103,6 @@ ${jsesc(script, { quotes: 'backtick' })}\`)` export function dehydrateRouter(router: AnyRouter) { const dehydratedRouter: DehydratedRouter = { - state: { - dehydratedMatches: router.state.matches.map((d) => { - return { - ...pick(d, ['id', 'status', 'updatedAt']), - // If an error occurs server-side during SSRing, - // send a small subset of the error to the client - error: d.error - ? router.ssr!.serializer.stringify(d.error) - : undefined, - // NOTE: We don't send the loader data here, because - // there is a potential that it needs to be streamed. - // Instead, we render it next to the route match in the HTML - // which gives us the potential to stream it via suspense. - } - }), - }, manifest: router.ssr!.manifest, dehydratedData: router.options.dehydrate?.(), } @@ -235,11 +219,12 @@ export function onMatchSettled(opts: { ) { const initCode = `__TSR_SSR__.initMatch(${jsesc( { - index: match.index, + id: match.id, __beforeLoadContext: router.ssr!.serializer.stringify( serializedBeforeLoadData, ), loaderData: router.ssr!.serializer.stringify(serializedLoaderData), + error: router.ssr!.serializer.stringify(match.error), extracted: extracted ? Object.fromEntries( extracted.map((entry) => { @@ -247,6 +232,8 @@ export function onMatchSettled(opts: { }), ) : {}, + updatedAt: match.updatedAt, + status: match.status, } satisfies SsrMatch, { isScriptContext: true, diff --git a/packages/start-server/src/tsrScript.ts b/packages/start-server/src/tsrScript.ts index ebeab0863c..ef360aa4af 100644 --- a/packages/start-server/src/tsrScript.ts +++ b/packages/start-server/src/tsrScript.ts @@ -20,41 +20,40 @@ const __TSR_SSR__: StartSsrGlobal = { }, initMatch: (match) => { __TSR_SSR__.queue.push(() => { - if (!__TSR_SSR__.matches[match.index]) { - __TSR_SSR__.matches[match.index] = match - Object.entries(match.extracted).forEach(([_id, ex]) => { - if (ex.type === 'stream') { - let controller - ex.value = new ReadableStream({ - start(c) { - controller = { - enqueue: (chunk: unknown) => { - try { - c.enqueue(chunk) - } catch {} - }, - close: () => { - try { - c.close() - } catch {} - }, - } - }, - }) - ex.value.controller = controller - } else { - let resolve: ControllablePromise['reject'] | undefined - let reject: ControllablePromise['reject'] | undefined + __TSR_SSR__.matches.push(match) - ex.value = new Promise((_resolve, _reject) => { - reject = _reject - resolve = _resolve - }) as ControllablePromise - ex.value.reject = reject! - ex.value.resolve = resolve! - } - }) - } + Object.entries(match.extracted).forEach(([_id, ex]) => { + if (ex.type === 'stream') { + let controller + ex.value = new ReadableStream({ + start(c) { + controller = { + enqueue: (chunk: unknown) => { + try { + c.enqueue(chunk) + } catch {} + }, + close: () => { + try { + c.close() + } catch {} + }, + } + }, + }) + ex.value.controller = controller + } else { + let resolve: ControllablePromise['reject'] | undefined + let reject: ControllablePromise['reject'] | undefined + + ex.value = new Promise((_resolve, _reject) => { + reject = _reject + resolve = _resolve + }) as ControllablePromise + ex.value.reject = reject! + ex.value.resolve = resolve! + } + }) return true }) From 7bceb8ad0b5a6011811581f4e1bc596fbc1a4be1 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 24 Jan 2025 01:37:59 -0700 Subject: [PATCH 18/30] almost there --- e2e/start/basic/app/routes/stream.tsx | 15 ++++- packages/react-router/src/router.ts | 4 +- packages/start-client/src/ssr-client.tsx | 10 ++- packages/start-server/src/ssr-server.ts | 66 +++++++++++-------- .../src/transformStreamWithRouter.ts | 18 ++--- packages/start-server/src/tsrScript.ts | 43 ++++++++++-- 6 files changed, 107 insertions(+), 49 deletions(-) diff --git a/e2e/start/basic/app/routes/stream.tsx b/e2e/start/basic/app/routes/stream.tsx index 7a6401ac3d..ad9aad225b 100644 --- a/e2e/start/basic/app/routes/stream.tsx +++ b/e2e/start/basic/app/routes/stream.tsx @@ -10,8 +10,16 @@ export const Route = createFileRoute('/stream')({ ), stream: new ReadableStream({ start(controller) { - controller.enqueue('stream-data') - controller.close() + controller.enqueue('stream-data-1') + setTimeout(() => { + controller.enqueue('stream-data-2') + }, 300) + setTimeout(() => { + controller.enqueue('stream-data-3') + }, 450) + setTimeout(() => { + controller.close() + }, 600) }, }), } @@ -34,7 +42,8 @@ function Home() { const { value, done: readerDone } = await reader.read() done = readerDone if (value) { - result += decoder.decode(value, { stream: !done }) + const decoded = decoder.decode(value, { stream: !done }) + result += decoded } } setStreamData(result) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 290dd6116d..1074eec696 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -2910,11 +2910,11 @@ export class Router< serverSsr?: { injectedHtml: Array - injectHtml: (getHtml: () => string | Promise) => void + injectHtml: (getHtml: () => string | Promise) => Promise injectScript: ( getScript: () => string | Promise, opts?: { logScript?: boolean }, - ) => void + ) => Promise streamValue: (key: string, value: any) => void streamedKeys: Set onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any diff --git a/packages/start-client/src/ssr-client.tsx b/packages/start-client/src/ssr-client.tsx index 27d7fda5aa..e33d5234d5 100644 --- a/packages/start-client/src/ssr-client.tsx +++ b/packages/start-client/src/ssr-client.tsx @@ -31,7 +31,13 @@ export interface StartSsrGlobal { queue: Array<() => boolean> runQueue: () => void initMatch: (match: SsrMatch) => void - resolvePromise: (p: ResolvePromiseState) => void + resolvePromise: (opts: { + matchId: string + id: string + promiseState: DeferredPromiseState + }) => void + injectChunk: (opts: { matchId: string; id: string; chunk: string }) => void + closeStream: (opts: { matchId: string; id: string }) => void } export interface SsrMatch { @@ -64,8 +70,8 @@ export interface ClientExtractedBaseEntry { } export interface ResolvePromiseState { + matchId: string id: number - matchIndex: number promiseState: DeferredPromiseState } diff --git a/packages/start-server/src/ssr-server.ts b/packages/start-server/src/ssr-server.ts index a8a842ac25..f2dff8d671 100644 --- a/packages/start-server/src/ssr-server.ts +++ b/packages/start-server/src/ssr-server.ts @@ -1,6 +1,5 @@ import { TSR_DEFERRED_PROMISE, - createControlledPromise, defer, isPlainArray, isPlainObject, @@ -62,9 +61,11 @@ export function attachRouterServerSsrUtils( type: 'onInjectedHtml', promise, }) + + return promise.then(() => {}) }, injectScript: (getScript, opts) => { - router.serverSsr!.injectHtml(async () => { + return router.serverSsr!.injectHtml(async () => { const script = await getScript() return `