Skip to content

Commit

Permalink
tsr autostreaming
Browse files Browse the repository at this point in the history
  • Loading branch information
juliusmarminge committed Dec 10, 2023
1 parent f459f28 commit 1e81a93
Show file tree
Hide file tree
Showing 12 changed files with 977 additions and 422 deletions.
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')
}),
)
}
10 changes: 10 additions & 0 deletions examples/react/tsr-suspense-streaming/src/entry-client.tsx
@@ -0,0 +1,10 @@
import * as React from 'react'
import ReactDOM from 'react-dom/client'

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

const router = createRouter()
router.hydrate()

ReactDOM.hydrateRoot(document, <StartClient router={router} />)
78 changes: 78 additions & 0 deletions examples/react/tsr-suspense-streaming/src/entry-server.tsx
@@ -0,0 +1,78 @@
import * as React from 'react'
import ReactDOMServer, { 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 { ServerResponse } from 'http'
import express from 'express'

// index.js
import { createRouter } from './router'
import { QueryClient } from '@tanstack/react-query'

type ReactReadableStream = ReadableStream<Uint8Array> & {
allReady?: Promise<void> | undefined
}

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

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

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

// 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 = ReactDOMServer.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)
}
134 changes: 134 additions & 0 deletions examples/react/tsr-suspense-streaming/src/router.tsx
@@ -0,0 +1,134 @@
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 { TanStackRouterDevtools } from '@tanstack/router-devtools'

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()
// TODO: can we change type in TSR so we can return ReactNode instead of just string?
router.injectHtml(cb as any)
}

const rootRoute = rootRouteWithContext()({
component: RootComponent,
})
function RootComponent() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
},
}),
)

return (
<div>
<h1>TSR + TSQ Automatic Streaming Hydration</h1>
<Suspense fallback={'Global suspense boundary yeyo'}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration useInjectServerHTML={useInjectHTML}>
<Outlet />
</ReactQueryStreamedHydration>
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools position="bottom-right" />
</QueryClientProvider>
</Suspense>
</div>
)
}

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]),
})
}

declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
19 changes: 19 additions & 0 deletions examples/react/tsr-suspense-streaming/tsconfig.json
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ES2022",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["server.js", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

0 comments on commit 1e81a93

Please sign in to comment.