Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: tsr autostreaming #6518

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
import { useServerInsertedHTML } from 'next/navigation'

export function Providers(props: { children: React.ReactNode }) {
const [queryClient] = React.useState(
Expand All @@ -20,7 +21,7 @@ export function Providers(props: { children: React.ReactNode }) {

return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
<ReactQueryStreamedHydration useInjectServerHTML={useServerInsertedHTML}>
{props.children}
</ReactQueryStreamedHydration>
{<ReactQueryDevtools initialIsOpen={false} />}
Expand Down
32 changes: 32 additions & 0 deletions examples/react/tsr-suspense-streaming/package.json
@@ -0,0 +1,32 @@
{
"name": "@tanstack/query-example-tsr-suspense-streaming",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "node server",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build src/entry-client.tsx --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"serve": "NODE_ENV=production node server"
},
"dependencies": {
"@tanstack/react-router": "0.0.1-beta.248",
"@tanstack/react-router-server": "0.0.1-beta.248",
"@tanstack/router-devtools": "0.0.1-beta.248",
"@tanstack/react-query-next-experimental": "workspace:*",
"@tanstack/react-query": "workspace:*",
"@tanstack/react-query-devtools": "workspace:*",
"compression": "^1.7.4",
"express": "^4.18.2",
"get-port": "^7.0.0",
"isbot": "^3.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.7"
}
}
103 changes: 103 additions & 0 deletions examples/react/tsr-suspense-streaming/server.js
@@ -0,0 +1,103 @@
import express, { Router } from 'express'
import getPort, { portNumbers } from 'get-port'

const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD

const api = Router().get('/wait', async (req, res) => {
const wait = +req.query.wait || 0
await new Promise((resolve) => setTimeout(resolve, wait))
res.json(`waited ${wait}ms`)
})

export async function createServer(
root = process.cwd(),
isProd = process.env.NODE_ENV === 'production',
hmrPort,
) {
const app = express()

app.use('/api', api)

/**
* @type {import('vite').ViteDevServer}
*/
let vite
if (!isProd) {
vite = await (
await import('vite')
).createServer({
root,
logLevel: isTest ? 'error' : 'info',
server: {
middlewareMode: true,
watch: {
// During tests we edit the files too fast and sometimes chokidar
// misses change events, so enforce polling for consistency
usePolling: true,
interval: 100,
},
hmr: {
port: hmrPort,
},
},
appType: 'custom',
})
// use vite's connect instance as middleware
app.use(vite.middlewares)
} else {
app.use((await import('compression')).default())
}

app.use('*', async (req, res, next) => {
if (req.url.startsWith('/api')) return next()

try {
const url = req.originalUrl

if (url.includes('.')) {
console.warn(`${url} is not valid router path`)
res.status(404)
res.end(`${url} is not valid router path`)
return
}

// Extract the head from vite's index transformation hook
let viteHead = !isProd
? await vite.transformIndexHtml(
url,
`<html><head></head><body></body></html>`,
)
: ''

viteHead = viteHead.substring(
viteHead.indexOf('<head>') + 6,
viteHead.indexOf('</head>'),
)

const entry = await (async () => {
if (!isProd) {
return vite.ssrLoadModule('/src/entry-server.tsx')
} else {
return import('./dist/server/entry-server.tsx')
}
})()

console.log('Rendering: ', url, '...')
entry.render({ req, res, url, head: viteHead })
} catch (e) {
!isProd && vite.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})

return { app, vite }
}

if (!isTest) {
createServer().then(async ({ app }) =>
app.listen(await getPort({ port: portNumbers(3000, 3100) }), () => {
console.log('Client Server: http://localhost:3000')
}),
)
}
9 changes: 9 additions & 0 deletions examples/react/tsr-suspense-streaming/src/entry-client.tsx
@@ -0,0 +1,9 @@
import { hydrateRoot } from 'react-dom/client'

import { StartClient } from '@tanstack/react-router-server/client'
import { createRouter } from './router'

const router = createRouter()
router.hydrate()

hydrateRoot(document, <StartClient router={router} />)
73 changes: 73 additions & 0 deletions examples/react/tsr-suspense-streaming/src/entry-server.tsx
@@ -0,0 +1,73 @@
import { renderToPipeableStream } from 'react-dom/server'
import type { PipeableStream } from 'react-dom/server'
import { createMemoryHistory } from '@tanstack/react-router'
import {
StartServer,
transformStreamWithRouter,
} from '@tanstack/react-router-server/server'
import isbot from 'isbot'
import type { ServerResponse } from 'http'
import type { Request as ExpressRequest } from 'express'

// index.js
import { createRouter } from './router'

export async function render(opts: {
url: string
head: string
req: ExpressRequest
res: ServerResponse
}) {
const router = createRouter()

const memoryHistory = createMemoryHistory({
initialEntries: [opts.url],
})

// Update the history and context
router.update({
history: memoryHistory,
context: {
head: opts.head,
},
})

// Wait for the router to load critical data
// (streamed data will continue to load in the background)
await router.load()

// Track errors
let didError = false

// Clever way to get the right callback. Thanks Remix!
const callbackName = isbot(opts.req.headers['user-agent'])
? 'onAllReady'
: 'onShellReady'

// Render the app to a readable stream
let stream!: PipeableStream

await new Promise<void>((resolve) => {
stream = renderToPipeableStream(<StartServer router={router} />, {
[callbackName]: () => {
opts.res.statusCode = didError ? 500 : 200
opts.res.setHeader('Content-Type', 'text/html')
resolve()
},
onError: (err) => {
didError = true
console.log(err)
},
})
})

// Add our Router transform to the stream
const transforms = [transformStreamWithRouter(router)]

const transformedStream = transforms.reduce(
(stream, transform) => stream.pipe(transform as any),
stream,
)

transformedStream.pipe(opts.res)
}
161 changes: 161 additions & 0 deletions examples/react/tsr-suspense-streaming/src/router.tsx
@@ -0,0 +1,161 @@
import {
QueryClient,
QueryClientProvider,
useSuspenseQuery,
} from '@tanstack/react-query'
import {
Outlet,
Route,
Router,
rootRouteWithContext,
useRouter,
} from '@tanstack/react-router'
import { Suspense, useState } from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { DehydrateRouter } from '@tanstack/react-router-server/client'

function WaitComponent(props: { wait: number }) {
const { data } = useSuspenseQuery({
queryKey: ['wait', props.wait],
queryFn: async () => {
const path = `/api/wait?wait=${props.wait}`
const url = 'http://localhost:3000' + path

console.log('fetching', url)
const res = (await (await fetch(url)).json()) as string
return res
},
})

return <div>result: {data}</div>
}

function useInjectHTML(cb: () => React.ReactNode) {
const router = useRouter()
console.log('dehydrateddata', router.dehydratedData)
router // TODO: can we change type in TSR so we can return ReactNode instead of just string?
.injectHtml(cb as any)
}

const rootRoute = rootRouteWithContext<{
head: string
}>()({
component: RootComponent,
})
function RootComponent() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
},
},
}),
)

return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<script
type="module"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
`,
}}
/>
<script type="module" src="/@vite/client" />
<script type="module" src="/src/entry-client.tsx" />
</head>
<body>
<h1>TSR + TSQ Automatic Streaming Hydration</h1>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration useInjectServerHTML={useInjectHTML}>
<Suspense fallback={'Global suspense boundary yeyo'}>
<Outlet />
</Suspense>
</ReactQueryStreamedHydration>
<ReactQueryDevtools />
</QueryClientProvider>
<DehydrateRouter />
</body>
</html>
)
}

const indexRoute = new Route({
getParentRoute: () => rootRoute,
path: '/',
component: IndexRouteComponent,
})
function IndexRouteComponent() {
return (
<>
<Suspense fallback={<div>waiting 100....</div>}>
<WaitComponent wait={100} />
</Suspense>
<Suspense fallback={<div>waiting 200....</div>}>
<WaitComponent wait={200} />
</Suspense>
<Suspense fallback={<div>waiting 300....</div>}>
<WaitComponent wait={300} />
</Suspense>
<Suspense fallback={<div>waiting 400....</div>}>
<WaitComponent wait={400} />
</Suspense>
<Suspense fallback={<div>waiting 500....</div>}>
<WaitComponent wait={500} />
</Suspense>
<Suspense fallback={<div>waiting 600....</div>}>
<WaitComponent wait={600} />
</Suspense>
<Suspense fallback={<div>waiting 700....</div>}>
<WaitComponent wait={700} />
</Suspense>

<fieldset>
<legend>
combined <code>Suspense</code>-container
</legend>
<Suspense
fallback={
<>
<div>waiting 800....</div>
<div>waiting 900....</div>
<div>waiting 1000....</div>
</>
}
>
<WaitComponent wait={800} />
<WaitComponent wait={900} />
<WaitComponent wait={1000} />
</Suspense>
</fieldset>
</>
)
}

export function createRouter() {
return new Router({
routeTree: rootRoute.addChildren([indexRoute]),
context: {
head: '',
},
})
}

declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}