diff --git a/docs/framework/react/api/router.md b/docs/framework/react/api/router.md index 5045b84433..4078ee2536 100644 --- a/docs/framework/react/api/router.md +++ b/docs/framework/react/api/router.md @@ -53,7 +53,6 @@ title: Router API - Types - [`ActiveLinkOptions Type`](./router/ActiveLinkOptionsType.md) - [`AsyncRouteComponent Type`](./router/AsyncRouteComponentType.md) - - [`DeferredPromise Type`](./router/DeferredPromiseType.md) - [`HistoryState Interface`](./router/historyStateInterface.md) - [`LinkOptions Type`](./router/LinkOptionsType.md) - [`LinkProps Type`](./router/LinkPropsType.md) diff --git a/docs/framework/react/api/router/DeferredPromiseType.md b/docs/framework/react/api/router/DeferredPromiseType.md deleted file mode 100644 index ef9bfb33ca..0000000000 --- a/docs/framework/react/api/router/DeferredPromiseType.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -id: DeferredPromiseType -title: DeferredPromise type ---- - -The `DeferredPromise` type is used to describe a promise that can be resolved or rejected after it has been created. - -```tsx -type DeferredPromise = Promise & { - __deferredState: DeferredPromiseState -} - -type DeferredPromiseState = { uid: string } & ( - | { - status: 'pending' - data?: T - error?: unknown - } - | { - status: 'success' - data: T - } - | { - status: 'error' - data?: T - error: unknown - } -) -``` diff --git a/docs/framework/react/api/router/awaitComponent.md b/docs/framework/react/api/router/awaitComponent.md index 14f797494e..ed1d67e1ee 100644 --- a/docs/framework/react/api/router/awaitComponent.md +++ b/docs/framework/react/api/router/awaitComponent.md @@ -4,6 +4,8 @@ title: Await component --- The `Await` component is a component that suspends until the provided promise is resolved or rejected. +This is only necessary for React 18. +If you are using React 19, you can use the `use()` hook instead. ## Await props @@ -11,9 +13,9 @@ The `Await` component accepts the following props: ### `props.promise` prop -- Type: [`DeferredPromise`](./DeferredPromiseType.md) +- Type: `Promise` - Required -- The deferred promise to await. +- The promise to await. ### `props.children` prop diff --git a/docs/framework/react/api/router/deferFunction.md b/docs/framework/react/api/router/deferFunction.md index 8a8a7876ef..929b9d7c63 100644 --- a/docs/framework/react/api/router/deferFunction.md +++ b/docs/framework/react/api/router/deferFunction.md @@ -3,6 +3,9 @@ id: deferFunction title: defer function --- +> [!CAUTION] +> You don't need to call `defer` manually anymore, Promises are handled automatically now. + The `defer` function wraps a promise with a deferred state object that can be used to inspect the promise's state. This deferred promise can then be passed to the [`useAwaited`](./useAwaitedHook.md) hook or the [``](./awaitComponent.md) component for suspending until the promise is resolved or rejected. The `defer` function accepts a single argument, the `promise` to wrap with a deferred state object. @@ -15,7 +18,7 @@ The `defer` function accepts a single argument, the `promise` to wrap with a def ## defer returns -- A [`DeferredPromise`](./DeferredPromiseType.md) that can be passed to the [`useAwaited`](./useAwaitedHook.md) hook or the [``](./awaitComponent.md) component. +- A promise that can be passed to the [`useAwaited`](./useAwaitedHook.md) hook or the [``](./awaitComponent.md) component. ## Examples diff --git a/docs/framework/react/api/router/useAwaitedHook.md b/docs/framework/react/api/router/useAwaitedHook.md index fb0ebd72e7..0641de8662 100644 --- a/docs/framework/react/api/router/useAwaitedHook.md +++ b/docs/framework/react/api/router/useAwaitedHook.md @@ -11,7 +11,7 @@ The `useAwaited` hook accepts a single argument, an `options` object. ### `options.promise` option -- Type: [`DeferredPromise`](./DeferredPromiseType.md) +- Type: `Promise` - Required - The deferred promise to await. diff --git a/docs/framework/react/guide/deferred-data-loading.md b/docs/framework/react/guide/deferred-data-loading.md index ac296b8fd0..bd0bb92707 100644 --- a/docs/framework/react/guide/deferred-data-loading.md +++ b/docs/framework/react/guide/deferred-data-loading.md @@ -9,9 +9,9 @@ Deferred data loading is a pattern that allows the router to render the next loc If you are using a library like [TanStack Query](https://react-query.tanstack.com) or any other data fetching library, then deferred data loading works a bit differently. Skip ahead to the [Deferred Data Loading with External Libraries](#deferred-data-loading-with-external-libraries) section for more information. -## Deferred Data Loading with `defer` and `Await` +## Deferred Data Loading with and `Await` -To defer slow or non-critical data, wrap an **unawaited/unresolved** promise in the `defer` function and return it anywhere in your loader response: +To defer slow or non-critical data, return an **unawaited/unresolved** promise anywhere in your loader response: ```tsx // src/routes/posts.$postId.tsx @@ -28,8 +28,7 @@ export const Route = createFileRoute('/posts/$postId')({ return { fastData, - // Wrap the slow promise in `defer()` - deferredSlowData: defer(slowDataPromise), + deferredSlowData: slowDataPromise, } }, }) @@ -71,6 +70,9 @@ The `Await` component resolves the promise by triggering the nearest suspense bo If the promise is rejected, the `Await` component will throw the serialized error, which can be caught by the nearest error boundary. +> [!TIP] +> In React 19, you can use the `use()` hook instead of `Await` + ## Deferred Data Loading with External libraries When your strategy for fetching information for the route relies on [External Data Loading](./external-data-loading.md) with an external library like [TanStack Query](https://react-query.tanstack.com), deferred data loading works a bit differently, as the library handles the data fetching and caching for you outside of TanStack Router. @@ -142,7 +144,7 @@ Please read the entire [SSR Guide](/docs/guide/server-streaming) for step by ste The following is a high-level overview of how deferred data streaming works with TanStack Router: - Server - - Promises wrapped in `defer()` are marked and tracked as they are returned from route loaders + - Promises are marked and tracked as they are returned from route loaders - All loaders resolve and any deferred promises are serialized and embedded into the html - The route begins to render - Deferred promises rendered with the `` component trigger suspense boundaries, allowing the server to stream html up to that point diff --git a/examples/react/basic-ssr-streaming-file-based/src/routes/error.tsx b/examples/react/basic-ssr-streaming-file-based/src/routes/error.tsx index fff03d37f8..8ab6d2b52d 100644 --- a/examples/react/basic-ssr-streaming-file-based/src/routes/error.tsx +++ b/examples/react/basic-ssr-streaming-file-based/src/routes/error.tsx @@ -1,4 +1,4 @@ -import { Await, createFileRoute, defer } from '@tanstack/react-router' +import { Await, createFileRoute } from '@tanstack/react-router' import * as React from 'react' async function loadData() { @@ -9,10 +9,10 @@ async function loadData() { export const Route = createFileRoute('/error')({ component: ErrorComponent, - loader: async () => { + loader: () => { if (Math.random() > 0.5) throw new Error('Random error!') return { - deferredData: defer(loadData()), + deferredData: loadData(), } }, pendingComponent: () =>

Loading..

, diff --git a/packages/react-router/src/awaited.tsx b/packages/react-router/src/awaited.tsx index e315faae92..52e68f0f95 100644 --- a/packages/react-router/src/awaited.tsx +++ b/packages/react-router/src/awaited.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import warning from 'tiny-warning' import { useRouter } from './useRouter' import { defaultSerializeError } from './router' -import { defer } from './defer' +import { TSR_DEFERRED_PROMISE, defer } from './defer' import { defaultDeserializeError, isServerSideError } from './isServerSideError' import type { DeferredPromise } from './defer' @@ -14,38 +14,36 @@ export function useAwaited({ promise: _promise, }: AwaitOptions): [T, DeferredPromise] { const router = useRouter() - const promise = _promise as DeferredPromise + const promise = defer(_promise) - defer(promise) - - if (promise.status === 'pending') { + if (promise[TSR_DEFERRED_PROMISE].status === 'pending') { throw promise } - if (promise.status === 'error') { + if (promise[TSR_DEFERRED_PROMISE].status === 'error') { if (typeof document !== 'undefined') { - if (isServerSideError(promise.error)) { + if (isServerSideError(promise[TSR_DEFERRED_PROMISE].error)) { throw ( router.options.errorSerializer?.deserialize ?? defaultDeserializeError - )(promise.error.data as any) + )(promise[TSR_DEFERRED_PROMISE].error.data as any) } else { warning( false, "Encountered a server-side error that doesn't fit the expected shape", ) - throw promise.error + throw promise[TSR_DEFERRED_PROMISE].error } } else { throw { data: ( router.options.errorSerializer?.serialize ?? defaultSerializeError - )(promise.error), + )(promise[TSR_DEFERRED_PROMISE].error), __isServerError: true, } } } - - return [promise.data as any, promise] + console.log('useAwaited', promise[TSR_DEFERRED_PROMISE]) + return [promise[TSR_DEFERRED_PROMISE].data, promise] } export function Await( @@ -68,5 +66,7 @@ function AwaitInner( }, ): React.JSX.Element { const [data] = useAwaited(props) + console.log('AwaitInner', data) + return props.children(data) as React.JSX.Element } diff --git a/packages/react-router/src/defer.ts b/packages/react-router/src/defer.ts index 1302cb6432..0256e2f878 100644 --- a/packages/react-router/src/defer.ts +++ b/packages/react-router/src/defer.ts @@ -1,25 +1,24 @@ import { defaultSerializeError } from './router' +export const TSR_DEFERRED_PROMISE = Symbol.for('TSR_DEFERRED_PROMISE') + export type DeferredPromiseState = { - uid: string - resolve?: () => void - reject?: () => void -} & ( - | { - status: 'pending' - data?: T - error?: unknown - } - | { - status: 'success' - data: T - } - | { - status: 'error' - data?: T - error: unknown - } -) + [TSR_DEFERRED_PROMISE]: + | { + status: 'pending' + data?: T + error?: unknown + } + | { + status: 'success' + data: T + } + | { + status: 'error' + data?: T + error: unknown + } +} export type DeferredPromise = Promise & DeferredPromiseState @@ -30,25 +29,25 @@ export function defer( }, ) { const promise = _promise as DeferredPromise + // this is already deferred promise + if ((promise as any)[TSR_DEFERRED_PROMISE]) { + return promise + } + promise[TSR_DEFERRED_PROMISE] = { status: 'pending' } - if (!(promise as any).status) { - Object.assign(promise, { - status: 'pending', + promise + .then((data) => { + promise[TSR_DEFERRED_PROMISE].status = 'success' + promise[TSR_DEFERRED_PROMISE].data = data + console.log('defer then', promise[TSR_DEFERRED_PROMISE]) + }) + .catch((error) => { + promise[TSR_DEFERRED_PROMISE].status = 'error' + ;(promise[TSR_DEFERRED_PROMISE] as any).error = { + data: (options?.serializeError ?? defaultSerializeError)(error), + __isServerError: true, + } }) - - promise - .then((data) => { - promise.status = 'success' as any - promise.data = data - }) - .catch((error) => { - promise.status = 'error' as any - ;(promise as any).error = { - data: (options?.serializeError ?? defaultSerializeError)(error), - __isServerError: true, - } - }) - } return promise } diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index fd1bc0e833..ac2e0d268b 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -19,7 +19,7 @@ export type { AwaitOptions } from './awaited' export { ScriptOnce } from './ScriptOnce' -export { defer } from './defer' +export { defer, TSR_DEFERRED_PROMISE } from './defer' export type { DeferredPromiseState, DeferredPromise } from './defer' export { CatchBoundary, ErrorComponent } from './CatchBoundary' @@ -262,6 +262,8 @@ export type { RouterListener, AnyRouterWithContext, ExtractedEntry, + ExtractedStream, + ExtractedPromise, StreamState, } from './router' diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index fbc1d3283e..86e7af0321 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -30,6 +30,7 @@ import { import { isRedirect, isResolvedRedirect } from './redirects' import { isNotFound } from './not-found' import { defaultTransformer } from './transformer' +import type { DeferredPromiseState } from './defer' import type * as React from 'react' import type { HistoryLocation, @@ -145,16 +146,26 @@ export type InferRouterContext = ? TRouterContext : AnyContext -export type ExtractedEntry = { +export interface ExtractedBaseEntry { dataType: '__beforeLoadContext' | 'loaderData' - type: 'promise' | 'stream' + type: string path: Array - value: any id: number - streamState?: StreamState matchIndex: number } +export interface ExtractedStream extends ExtractedBaseEntry { + type: 'stream' + streamState: StreamState +} + +export interface ExtractedPromise extends ExtractedBaseEntry { + type: 'promise' + promiseState: DeferredPromiseState +} + +export type ExtractedEntry = ExtractedStream | ExtractedPromise + export type StreamState = { promises: Array> } diff --git a/packages/start/src/client/serialization.tsx b/packages/start/src/client/serialization.tsx index a1e366dfc8..73688886b9 100644 --- a/packages/start/src/client/serialization.tsx +++ b/packages/start/src/client/serialization.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { ScriptOnce, + TSR_DEFERRED_PROMISE, createControlledPromise, defer, isPlainArray, @@ -14,6 +15,8 @@ import type { AnyRouteMatch, AnyRouter, ExtractedEntry, + ExtractedPromise, + ExtractedStream, StreamState, } from '@tanstack/react-router' @@ -41,27 +44,31 @@ export function serializeLoaderData( ? 'promise' : undefined - if (type) { - const entry: ExtractedEntry = { + // If it's a stream, we need to tee it so we can read it multiple times + if (type === 'stream') { + const [copy1, copy2] = value.tee() + const entry: ExtractedStream = { dataType, type, path, id: extracted.length, - value, matchIndex: ctx.match.index, + streamState: createStreamState({ stream: copy2 }), } extracted.push(entry) - - // If it's a stream, we need to tee it so we can read it multiple times - if (type === 'stream') { - const [copy1, copy2] = value.tee() - entry.streamState = createStreamState({ stream: copy2 }) - - return copy1 - } else { - defer(value) + return copy1 + } else if (type === 'promise') { + defer(value) + const entry: ExtractedPromise = { + dataType, + type, + path, + id: extracted.length, + matchIndex: ctx.match.index, + promiseState: value, } + extracted.push(entry) } return value @@ -246,7 +253,7 @@ export function replaceBy( return obj } -function DehydratePromise({ entry }: { entry: ExtractedEntry }) { +function DehydratePromise({ entry }: { entry: ExtractedPromise }) { return (
@@ -256,24 +263,27 @@ function DehydratePromise({ entry }: { entry: ExtractedEntry }) { ) } -function InnerDehydratePromise({ entry }: { entry: ExtractedEntry }) { +function InnerDehydratePromise({ entry }: { entry: ExtractedPromise }) { const router = useRouter() - if (entry.value.status === 'pending') { - throw entry.value + if (entry.promiseState[TSR_DEFERRED_PROMISE].status === 'pending') { + throw entry.promiseState } - const code = `__TSR__.resolvePromise(${jsesc(entry, { - isScriptContext: true, - wrap: true, - json: true, - })})` + const code = `__TSR__.resolvePromise(${jsesc( + { ...entry, value: entry.promiseState[TSR_DEFERRED_PROMISE] }, + { + isScriptContext: true, + wrap: true, + json: true, + }, + )})` router.injectScript(code) return <> } -function DehydrateStream({ entry }: { entry: ExtractedEntry }) { +function DehydrateStream({ entry }: { entry: ExtractedStream }) { invariant(entry.streamState, 'StreamState should be defined') const router = useRouter()