Skip to content

Commit

Permalink
fix(start): fix usage of use() with React 19 (#2963)
Browse files Browse the repository at this point in the history
both router and react 19 were modifying the Promise object in a similar manner which caused a conflict.

See https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactFiberThenable.js#L162-L228 for how React 19 modifies the Promise object.

Both used an additional `status` property to track the Promise status, but router was using the value "success" and React was using the value "fulfilled" to mark a fulfilled Promise.

This change stores the router specific Promise tracking values using a symbol to avoid any potential collision.

fixes #2953
  • Loading branch information
schiller-manuel authored Dec 9, 2024
1 parent 2663b94 commit 54dad5f
Show file tree
Hide file tree
Showing 12 changed files with 116 additions and 117 deletions.
1 change: 0 additions & 1 deletion docs/framework/react/api/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 0 additions & 29 deletions docs/framework/react/api/router/DeferredPromiseType.md

This file was deleted.

6 changes: 4 additions & 2 deletions docs/framework/react/api/router/awaitComponent.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ 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

The `Await` component accepts the following props:

### `props.promise` prop

- Type: [`DeferredPromise<T>`](./DeferredPromiseType.md)
- Type: `Promise<T>`
- Required
- The deferred promise to await.
- The promise to await.

### `props.children` prop

Expand Down
5 changes: 4 additions & 1 deletion docs/framework/react/api/router/deferFunction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`<Await>`](./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.
Expand All @@ -15,7 +18,7 @@ The `defer` function accepts a single argument, the `promise` to wrap with a def

## defer returns

- A [`DeferredPromise<T>`](./DeferredPromiseType.md) that can be passed to the [`useAwaited`](./useAwaitedHook.md) hook or the [`<Await>`](./awaitComponent.md) component.
- A promise that can be passed to the [`useAwaited`](./useAwaitedHook.md) hook or the [`<Await>`](./awaitComponent.md) component.

## Examples

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/react/api/router/useAwaitedHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The `useAwaited` hook accepts a single argument, an `options` object.

### `options.promise` option

- Type: [`DeferredPromise<T>`](./DeferredPromiseType.md)
- Type: `Promise<T>`
- Required
- The deferred promise to await.

Expand Down
12 changes: 7 additions & 5 deletions docs/framework/react/guide/deferred-data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,8 +28,7 @@ export const Route = createFileRoute('/posts/$postId')({

return {
fastData,
// Wrap the slow promise in `defer()`
deferredSlowData: defer(slowDataPromise),
deferredSlowData: slowDataPromise,
}
},
})
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<Await>` component trigger suspense boundaries, allowing the server to stream html up to that point
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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: () => <p>Loading..</p>,
Expand Down
24 changes: 12 additions & 12 deletions packages/react-router/src/awaited.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -14,38 +14,36 @@ export function useAwaited<T>({
promise: _promise,
}: AwaitOptions<T>): [T, DeferredPromise<T>] {
const router = useRouter()
const promise = _promise as DeferredPromise<T>
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<T>(
Expand All @@ -68,5 +66,7 @@ function AwaitInner<T>(
},
): React.JSX.Element {
const [data] = useAwaited(props)
console.log('AwaitInner', data)

return props.children(data) as React.JSX.Element
}
71 changes: 35 additions & 36 deletions packages/react-router/src/defer.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import { defaultSerializeError } from './router'

export const TSR_DEFERRED_PROMISE = Symbol.for('TSR_DEFERRED_PROMISE')

export type DeferredPromiseState<T> = {
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<T> = Promise<T> & DeferredPromiseState<T>

Expand All @@ -30,25 +29,25 @@ export function defer<T>(
},
) {
const promise = _promise as DeferredPromise<T>
// 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
}
4 changes: 3 additions & 1 deletion packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -262,6 +262,8 @@ export type {
RouterListener,
AnyRouterWithContext,
ExtractedEntry,
ExtractedStream,
ExtractedPromise,
StreamState,
} from './router'

Expand Down
19 changes: 15 additions & 4 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -145,16 +146,26 @@ export type InferRouterContext<TRouteTree extends AnyRoute> =
? TRouterContext
: AnyContext

export type ExtractedEntry = {
export interface ExtractedBaseEntry {
dataType: '__beforeLoadContext' | 'loaderData'
type: 'promise' | 'stream'
type: string
path: Array<string>
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<any>
}

export type ExtractedEntry = ExtractedStream | ExtractedPromise

export type StreamState = {
promises: Array<ControlledPromise<string | null>>
}
Expand Down
Loading

0 comments on commit 54dad5f

Please sign in to comment.