From cbb046ab92b66dfc4ad1e1ea30d4b8beae6f2c24 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:24:10 +0100 Subject: [PATCH 1/2] [Fiber] Warn for Conditional Use of use() Based on Cache (#37104) This is a cherry-pick of https://github.com/react/react/pull/34030, with a feature flag gating and a test coverage. The flag is disabled by default and dynamic for FB builds to understand first how noisy this warning can be. --- See https://github.com/react/react/pull/34030 for more context on the change. --- .../react-reconciler/src/ReactChildFiber.js | 2 +- .../react-reconciler/src/ReactFiberHooks.js | 9 +- .../src/ReactFiberThenable.js | 89 +++++++++- .../src/ReactFiberWorkLoop.js | 16 +- .../src/__tests__/ActivitySuspense-test.js | 44 ++--- .../ReactConditionalUseWarning-test.js | 161 ++++++++++++++++++ packages/shared/ReactFeatureFlags.js | 2 + .../ReactFeatureFlags.native-fb-dynamic.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + scripts/error-codes/codes.json | 3 +- 16 files changed, 303 insertions(+), 31 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactConditionalUseWarning-test.js diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index b9ce7d5e153..dc359db238b 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -285,7 +285,7 @@ function unwrapThenable(thenable: Thenable): T { if (thenableState === null) { thenableState = createThenableState(); } - return trackUsedThenable(thenableState, thenable, index); + return trackUsedThenable(thenableState, thenable, index, null); } function coerceRef(workInProgress: Fiber, element: ReactElement): void { diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 2558e9648b7..1eac572f1c8 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -144,6 +144,7 @@ import {now} from './Scheduler'; import { trackUsedThenable, checkIfUseWrappedInTryCatch, + checkIfUseWasUsedBefore, createThenableState, SuspenseException, SuspenseActionException, @@ -651,6 +652,7 @@ function finishRenderingHooks( } else { workInProgress.dependencies._debugThenableState = thenableState; } + checkIfUseWasUsedBefore(workInProgress, thenableState); } // We can assume the previous dispatcher is always this one, since we set it @@ -1100,7 +1102,12 @@ function useThenable(thenable: Thenable): T { if (thenableState === null) { thenableState = createThenableState(); } - const result = trackUsedThenable(thenableState, thenable, index); + const result = trackUsedThenable( + thenableState, + thenable, + index, + __DEV__ ? currentlyRenderingFiber : null, + ); // When something suspends with `use`, we replay the component with the // "re-render" dispatcher instead of the "mount" or "update" dispatcher. diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index 59557c61f62..6eae22061c4 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -16,6 +16,7 @@ import type { } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; +import type {Fiber} from './ReactInternalTypes'; import {callLazyInitInDEV} from './ReactFiberCallUserSpace'; @@ -23,10 +24,15 @@ import {getWorkInProgressRoot} from './ReactFiberWorkLoop'; import ReactSharedInternals from 'shared/ReactSharedInternals'; -import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; +import { + enableAsyncDebugInfo, + enableConditionalUseWarning, +} from 'shared/ReactFeatureFlags'; import noop from 'shared/noop'; +import {HostRoot} from './ReactWorkTags'; + opaque type ThenableStateDev = { didWarnAboutUncachedPromise: boolean, thenables: Array>, @@ -104,10 +110,23 @@ export function isThenableResolved(thenable: Thenable): boolean { return status === 'fulfilled' || status === 'rejected'; } +// DEV-only +let lastSuspendedFiber: null | Fiber = null; +let lastSuspendedStack: null | Error = null; +let didIssueUseWarning = false; + +export function hasPotentialUseWarnings(): boolean { + return enableConditionalUseWarning && lastSuspendedFiber !== null; +} +export function clearUseWarnings() { + lastSuspendedFiber = null; +} + export function trackUsedThenable( thenableState: ThenableState, thenable: Thenable, index: number, + fiber: null | Fiber, // DEV-only ): T { if (__DEV__ && ReactSharedInternals.actQueue !== null) { ReactSharedInternals.didUsePromise = true; @@ -298,6 +317,23 @@ export function trackUsedThenable( suspendedThenable = thenable; if (__DEV__) { needsToResetSuspendedThenableDEV = true; + if ( + enableConditionalUseWarning && + !didIssueUseWarning && + fiber !== null && + // Only track initial mount for now to avoid warning too much for updates. + fiber.alternate === null + ) { + lastSuspendedFiber = fiber; + // Stash an error in case we end up triggering the use() warning. + // This ensures that we have a stack trace at the location of the first use() + // call since there won't be a second one we have to do that eagerly. + lastSuspendedStack = new Error( + 'This library called use() to suspend in a previous render but ' + + 'did not call use() when it finished. This indicates an incorrect use of use(). ' + + 'Learn more: https://react.dev/warnings/conditional-use-of-use', + ); + } } throw SuspenseException; } @@ -390,3 +426,54 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) { ); } } + +function areSameKeyPath(a: Fiber, b: Fiber): boolean { + if (a === b) { + return true; + } + if ( + a.tag !== b.tag || + a.type !== b.type || + a.key !== b.key || + a.index !== b.index + ) { + return false; + } + if (a.tag === HostRoot && a.stateNode !== b.stateNode) { + // These are both roots but they're different roots so they're not in the same tree. + return false; + } + if (a.return === null || b.return === null) { + return false; + } + return areSameKeyPath(a.return, b.return); +} + +export function checkIfUseWasUsedBefore( + unsuspendedFiber: Fiber, + thenableState: null | ThenableState, +): void { + if (__DEV__ && enableConditionalUseWarning) { + if ( + lastSuspendedFiber !== null && + areSameKeyPath(lastSuspendedFiber, unsuspendedFiber) + ) { + if (thenableState !== null) { + // It's still using use() ever after resolving. We could warn for different number of them but for + // now we treat this as ok and clear the state. + lastSuspendedFiber = null; + lastSuspendedStack = null; + } else { + // The last suspended Fiber using use() is no longer using use() in the same position. + // That's suspicious. Likely it was unblocked by conditionally using use() which is incorrect. + if (lastSuspendedStack !== null && !didIssueUseWarning) { + didIssueUseWarning = true; + // We pass the error object instead of custom message so that the browser displays the error natively. + console['error'](lastSuspendedStack); + } + lastSuspendedFiber = null; + lastSuspendedStack = null; + } + } + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index edd18cbbb69..0f5acc6eed5 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -392,6 +392,8 @@ import { SuspenseyCommitException, getSuspendedThenable, isThenableResolved, + hasPotentialUseWarnings, + clearUseWarnings, } from './ReactFiberThenable'; import {schedulePostPaintCallback} from './ReactPostPaintCallback'; import { @@ -845,12 +847,24 @@ export function requestUpdateLane(fiber: Fiber): Lane { transition._updatedFibers = new Set(); } transition._updatedFibers.add(fiber); + if ( + hasPotentialUseWarnings() && + resolveUpdatePriority() === DiscreteEventPriority + ) { + // If we're updating inside a discrete event, then this might be a new user interaction + // and not just an automatically resolved loading sequence. Don't warn unless it happens again. + clearUseWarnings(); + } } return requestTransitionLane(transition); } - return eventPriorityToLane(resolveUpdatePriority()); + const priority = resolveUpdatePriority(); + if (__DEV__ && priority === DiscreteEventPriority) { + clearUseWarnings(); + } + return eventPriorityToLane(priority); } function requestRetryLane(fiber: Fiber) { diff --git a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js index 3a783ff3267..b0e57a4b8d8 100644 --- a/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js +++ b/packages/react-reconciler/src/__tests__/ActivitySuspense-test.js @@ -41,47 +41,39 @@ describe('Activity Suspense', () => { function resolveText(text) { const record = textCache.get(text); if (record === undefined) { + const promise = Promise.resolve(text); + promise.status = 'fulfilled'; + promise.value = text; const newRecord = { - status: 'resolved', - value: text, + promise, }; textCache.set(text, newRecord); - } else if (record.status === 'pending') { + } else if (record.promise.status === 'pending') { const resolve = record.resolve; - record.status = 'resolved'; - record.value = text; - resolve(); + record.promise.status = 'fulfilled'; + record.promise.value = text; + resolve(text); } } function readText(text) { - const record = textCache.get(text); - if (record !== undefined) { - switch (record.status) { - case 'pending': - Scheduler.log(`Suspend! [${text}]`); - return use(record.value); - case 'rejected': - throw record.value; - case 'resolved': - return record.value; - } - } else { - Scheduler.log(`Suspend! [${text}]`); + let record = textCache.get(text); + if (record === undefined) { let resolve; const promise = new Promise(_resolve => { resolve = _resolve; }); - - const newRecord = { - status: 'pending', - value: promise, + promise.status = 'pending'; + record = { + promise, resolve, }; - textCache.set(text, newRecord); - - return use(promise); + textCache.set(text, record); + } + if (record.promise.status === 'pending') { + Scheduler.log(`Suspend! [${text}]`); } + return use(record.promise); } function Text({text}) { diff --git a/packages/react-reconciler/src/__tests__/ReactConditionalUseWarning-test.js b/packages/react-reconciler/src/__tests__/ReactConditionalUseWarning-test.js new file mode 100644 index 00000000000..3216b8e430d --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactConditionalUseWarning-test.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +let React; +let ReactNoop; +let Scheduler; +let act; +let assertLog; +let use; +let Suspense; +let startTransition; + +describe('conditional use warning', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + use = React.use; + Suspense = React.Suspense; + startTransition = React.startTransition; + }); + + // @gate __DEV__ && enableConditionalUseWarning + it('warns if use(promise) is called conditionally based on a cache', async () => { + let cachedValue; + let resolve; + const promise = new Promise(r => { + resolve = value => { + cachedValue = value; + r(value); + }; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function Async() { + if (cachedValue !== undefined) { + return ; + } + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render( + }> + + , + ); + }); + assertLog(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + spyOnDev(console, 'error').mockImplementation(() => {}); + try { + await act(() => { + startTransition(() => { + root.render( + }> + + , + ); + }); + }); + assertLog(['Loading...']); + expect(root).toMatchRenderedOutput('Initial'); + + await act(() => resolve('Async')); + assertLog(['Async']); + expect(root).toMatchRenderedOutput('Async'); + + expect(console.error).toHaveBeenCalledTimes(1); + const warning = console.error.mock.calls[0][0]; + expect(warning).toBeInstanceOf(Error); + expect(warning.message).toBe( + 'This library called use() to suspend in a previous render but ' + + 'did not call use() when it finished. This indicates an incorrect use of use(). ' + + 'Learn more: https://react.dev/warnings/conditional-use-of-use', + ); + + await act(() => { + root.render( + }> + + , + ); + }); + assertLog(['Async']); + expect(root).toMatchRenderedOutput('Async'); + expect(console.error).toHaveBeenCalledTimes(1); + } finally { + if (__DEV__) { + console.error.mockRestore(); + } + } + }); + + it('does not warn if use(promise) is called unconditionally', async () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function Async() { + return ; + } + + const root = ReactNoop.createRoot(); + spyOnDev(console, 'error').mockImplementation(() => {}); + try { + await act(() => { + root.render( + }> + + , + ); + }); + assertLog(['Loading...']); + expect(root).toMatchRenderedOutput('Loading...'); + + await act(() => resolve('Async')); + assertLog(['Async']); + expect(root).toMatchRenderedOutput('Async'); + + await act(() => { + root.render( + }> + + , + ); + }); + assertLog(['Async']); + expect(root).toMatchRenderedOutput('Async'); + if (__DEV__) { + expect(console.error).not.toHaveBeenCalled(); + } + } finally { + if (__DEV__) { + console.error.mockRestore(); + } + } + }); +}); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 45ec1737611..c1055613162 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -154,6 +154,8 @@ export const enableInfiniteRenderLoopDetection: boolean = false; */ export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; +export const enableConditionalUseWarning: boolean = false; + export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; export const enableFragmentRefsInstanceHandles: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index b81f9db1053..36832e2ab07 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -25,3 +25,4 @@ export const enableFragmentRefsScrollIntoView = __VARIANT__; export const enableFragmentRefsInstanceHandles = __VARIANT__; export const enableFragmentRefsTextNodes = __VARIANT__; export const enableViewTransitionForPersistenceMode = __VARIANT__; +export const enableConditionalUseWarning = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 9b5d9497f85..362f88e41d6 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -27,6 +27,7 @@ export const { enableFragmentRefsInstanceHandles, enableFragmentRefsTextNodes, enableViewTransitionForPersistenceMode, + enableConditionalUseWarning, } = dynamicFlags; // The rest of the flags are static for better dead code elimination. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 30ace506bc3..a1575c75b0b 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -33,6 +33,7 @@ export const enableMoveBefore: boolean = true; export const enableFizzExternalRuntime: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; +export const enableConditionalUseWarning: boolean = false; export const enableLegacyCache: boolean = false; export const enableLegacyFBSupport: boolean = false; export const enableLegacyHidden: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index bbd4530b022..9da4540c53c 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -54,6 +54,7 @@ export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; +export const enableConditionalUseWarning: boolean = false; export const enableEffectEventMutationPhase: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index dd45f875a28..b896cf00cb0 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -28,6 +28,7 @@ export const enableMoveBefore = false; export const enableFizzExternalRuntime = true; export const enableInfiniteRenderLoopDetection = false; export const enableInfiniteRenderLoopDetectionForceThrow = false; +export const enableConditionalUseWarning = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; export const enableLegacyHidden = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index c7f9c591d69..6ca7619a456 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -56,6 +56,7 @@ export const disableClientCache: boolean = true; export const enableInfiniteRenderLoopDetection: boolean = false; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false; +export const enableConditionalUseWarning: boolean = false; export const enableReactTestRendererWarning: boolean = false; export const disableLegacyMode: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 59bda0e3c2b..4058ac003c0 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -28,6 +28,7 @@ export const enableSchedulingProfiler: boolean = __VARIANT__; export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__; export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__; +export const enableConditionalUseWarning: boolean = __VARIANT__; export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__; export const enableSuspenseyImages: boolean = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index a600292c965..4c2371bab69 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -20,6 +20,7 @@ export const { disableSchedulerTimeoutInWorkLoop, enableInfiniteRenderLoopDetection, enableInfiniteRenderLoopDetectionForceThrow, + enableConditionalUseWarning, enableNoCloningMemoCache, enableObjectFiber, enableRetryLaneExpiration, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index fb7e296452c..f2eb43edb17 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -591,5 +591,6 @@ "603": "Recoverable Exception: This is not a real error! It's an implementation detail of `use(browser())` to defer rendering to the browser. `use(browser())` can only be used inside a `` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `` boundary above it.", "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", - "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue." + "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.", + "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use" } From 9b5b4d51e5be870d396a9568701259e5eb053668 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 31 Jul 2026 13:22:30 -0400 Subject: [PATCH 2/2] [Flight] Add 'pending_weak' to Flight thenable protocol (#37154) Added behind a new experimental flag, `enableFlightWeakThenables`. Adds a new thenable status to the Flight protocol: `'pending_weak'`. Unlike a regular pending thenable, a weak thenable does not block the stream from closing. If it settles while the stream is still open, its value is emitted like a normal pending thenable. Otherwise its reference is left unfulfilled and on the client it stays forever pending, without erroring, even when the connection closes. It's up to the client to handle the unresolved promise in an appropriate way. The motivating use case is being able to encode metadata about a Flight stream into the response itself. For example, a framework might want to track whether a page varies by search params. It could represent this in the response as a `Promise` that resolves to `true` as soon as the component being rendered in the stream accesses search params. If the thenable never resolves by the time the stream closes, then the client knows that no search params were ever accessed. In the future we could add a higher-level API for encoding this kind of information. For now, we intentionally start with the low-level primitive so frameworks can experiment in userspace without adding significantly to React's surface area. Internally, Flight already uses its own private thenable statuses, like `'resolved_model'`, and the protocol is designed to treat any status besides `'fulfilled'` and `'rejected'` as equivalent to `'pending'`, so `'pending_weak'` slots into the existing machinery. On the wire, a weak reference is encoded as `$w`, next to `$@` for regular promises, so the client knows its row may intentionally never arrive. On the client, a weak reference behaves like any other pending promise until the response closes; then, instead of erroring, it is left forever pending. --- .../react-client/src/ReactFlightClient.js | 151 ++++++-- .../src/__tests__/ReactFlightDOMEdge-test.js | 350 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 240 +++++++++--- packages/shared/ReactFeatureFlags.js | 6 + packages/shared/ReactTypes.js | 6 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 11 files changed, 680 insertions(+), 79 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index b23b172b02c..b49f1f15023 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -46,6 +46,7 @@ import { enableProfilerTimer, enableComponentPerformanceTrack, enableAsyncDebugInfo, + enableFlightWeakThenables, } from 'shared/ReactFeatureFlags'; import { @@ -150,12 +151,21 @@ const ROW_CHUNK_BY_LENGTH = 4; type RowParserState = 0 | 1 | 2 | 3 | 4; const PENDING = 'pending'; +// A weak Promise reference. Behaves like PENDING except that when the stream +// closes it transitions to HALTED instead of erroring, because the server +// may intentionally never emit it. Only used when enableFlightWeakThenables +// is on. +const PENDING_WEAK = 'pending_weak'; const BLOCKED = 'blocked'; const RESOLVED_MODEL = 'resolved_model'; const RESOLVED_MODULE = 'resolved_module'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; -const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes. +// Means it never resolves, even when the connection closes. The shared +// terminal state of a weak chunk that didn't settle before close, of any +// pending chunk at close when partial streams are allowed, and of DEV-only +// debug halts. +const HALTED = 'halted'; const __PROTO__ = '__proto__'; @@ -171,6 +181,15 @@ type PendingChunk = { _debugInfo: ReactDebugInfo, // DEV-only then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; +type PendingWeakChunk = { + status: 'pending_weak', + value: null | Array mixed)>, + reason: null | Array mixed)>, + _children: Array> | ProfilingResult, // Profiling-only + _debugChunk: null | SomeChunk, // DEV-only + _debugInfo: ReactDebugInfo, // DEV-only + then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, +}; type BlockedChunk = { status: 'blocked', value: null | Array mixed)>, @@ -238,6 +257,7 @@ type HaltedChunk = { }; type SomeChunk = | PendingChunk + | PendingWeakChunk | BlockedChunk | ResolvedModelChunk | ResolvedModuleChunk @@ -306,6 +326,7 @@ function reactPromiseThen( } break; case PENDING: + case PENDING_WEAK: case BLOCKED: if (typeof resolve === 'function') { if (chunk.value === null) { @@ -449,6 +470,7 @@ function readChunk(chunk: SomeChunk): T { case INITIALIZED: return chunk.value; case PENDING: + case PENDING_WEAK: case BLOCKED: case HALTED: // eslint-disable-next-line no-throw-literal @@ -479,6 +501,13 @@ function createPendingChunk(response: Response): PendingChunk { return new ReactPromise(PENDING, null, null); } +function createPendingWeakChunk(response: Response): PendingWeakChunk { + // Unlike a regular pending chunk, a weak chunk may never settle, so it + // doesn't retain a strong reference to the Response while it waits. + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + return new ReactPromise(PENDING_WEAK, null, null); +} + function releasePendingChunk(response: Response, chunk: SomeChunk): void { if (__DEV__ && chunk.status === PENDING) { if (--response._pendingChunks === 0) { @@ -497,6 +526,22 @@ function releasePendingChunk(response: Response, chunk: SomeChunk): void { } } +function createHaltedChunk(response: Response): HaltedChunk { + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + return new ReactPromise(HALTED, null, null); +} + +// Transition a chunk to HALTED: it will never resolve, even when the +// connection closes. Clears any listeners to release their closures. Future +// .then() calls on HALTED chunks are no-ops. +function haltChunk(response: Response, chunk: SomeChunk): void { + releasePendingChunk(response, chunk); + const haltedChunk: HaltedChunk = chunk as any; + haltedChunk.status = HALTED; + haltedChunk.value = null; + haltedChunk.reason = null; +} + function createBlockedChunk(response: Response): BlockedChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors return new ReactPromise(BLOCKED, null, null); @@ -755,7 +800,11 @@ function triggerErrorOnChunk( chunk: SomeChunk, error: mixed, ): void { - if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + if ( + chunk.status !== PENDING && + chunk.status !== PENDING_WEAK && + chunk.status !== BLOCKED + ) { // If we get more data to an already resolved ID, we assume that it's // a stream chunk since any other row shouldn't have more than one entry. const streamChunk: InitializedStreamChunk = chunk as any; @@ -767,7 +816,7 @@ function triggerErrorOnChunk( releasePendingChunk(response, chunk); const listeners = chunk.reason; - if (__DEV__ && chunk.status === PENDING) { + if (__DEV__ && (chunk.status === PENDING || chunk.status === PENDING_WEAK)) { // Lazily initialize any debug info and block the initializing chunk on any unresolved entries. if (chunk._debugChunk != null) { const prevHandler = initializingHandler; @@ -898,7 +947,7 @@ function resolveModelChunk( chunk: SomeChunk, value: UninitializedModel, ): void { - if (chunk.status !== PENDING) { + if (chunk.status !== PENDING && chunk.status !== PENDING_WEAK) { // If we get more data to an already resolved ID, we assume that it's // a stream chunk since any other row shouldn't have more than one entry. const streamChunk: InitializedStreamChunk = chunk as any; @@ -928,7 +977,11 @@ function resolveModuleChunk( chunk: SomeChunk, value: ClientReference, ): void { - if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + if ( + chunk.status !== PENDING && + chunk.status !== PENDING_WEAK && + chunk.status !== BLOCKED + ) { // We already resolved. We didn't expect to see this. return; } @@ -980,7 +1033,7 @@ let isInitializingDebugInfo: boolean = false; function initializeDebugChunk( response: Response, - chunk: ResolvedModelChunk | PendingChunk, + chunk: ResolvedModelChunk | PendingChunk | PendingWeakChunk, ): void { const debugChunk = chunk._debugChunk; if (debugChunk !== null) { @@ -1010,7 +1063,8 @@ function initializeDebugChunk( break; } case BLOCKED: - case PENDING: { + case PENDING: + case PENDING_WEAK: { waitForReference( initializedChunk, debugInfo, @@ -1032,7 +1086,8 @@ function initializeDebugChunk( break; } case BLOCKED: - case PENDING: { + case PENDING: + case PENDING_WEAK: { // Signal to the caller that we need to wait. waitForReference( debugChunk, @@ -1168,6 +1223,10 @@ export function reportGlobalError( // because we won't be getting any new data to resolve it. if (chunk.status === PENDING) { triggerErrorOnChunk(response, chunk, error); + } else if (enableFlightWeakThenables && chunk.status === PENDING_WEAK) { + // A weak Promise reference may never be emitted by the server. It + // stays forever pending instead of erroring. + haltChunk(response, chunk); } else if (chunk.status === INITIALIZED && chunk.reason !== null) { chunk.reason.error(error); } @@ -1502,11 +1561,7 @@ function getChunk(response: Response, id: number): SomeChunk { if (response._allowPartialStream) { // For partial streams, chunks accessed after close should be HALTED // (never resolve). - chunk = createPendingChunk(response); - const haltedChunk: HaltedChunk = chunk as any; - haltedChunk.status = HALTED; - haltedChunk.value = null; - haltedChunk.reason = null; + chunk = createHaltedChunk(response); } else { // We have already errored the response and we're not going to get // anything more streaming in so this will immediately error. @@ -1520,6 +1575,25 @@ function getChunk(response: Response, id: number): SomeChunk { return chunk; } +// Like getChunk, but for weak Promise references. The server may never emit +// the row for a weak reference, so an unresolved weak chunk halts (stays +// forever pending) instead of erroring when the stream closes. +function getWeakChunk(response: Response, id: number): SomeChunk { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + if (response._closed) { + // The stream already closed without emitting this row, so it will + // never resolve. + chunk = createHaltedChunk(response); + } else { + chunk = createPendingWeakChunk(response); + } + chunks.set(id, chunk); + } + return chunk; +} + function fulfillReference( response: Response, reference: InitializationReference, @@ -1574,7 +1648,8 @@ function fulfillReference( } // Fallthrough } - case PENDING: { + case PENDING: + case PENDING_WEAK: { // If we're not yet initialized we need to skip what we've already drilled // through and then wait for the next value to become available. path.splice(0, i - 1); @@ -1778,7 +1853,7 @@ function rejectReference( } function waitForReference( - referencedChunk: PendingChunk | BlockedChunk, + referencedChunk: PendingChunk | PendingWeakChunk | BlockedChunk, parentObject: Object, key: string, response: Response, @@ -2124,7 +2199,8 @@ function getOutlinedModel( break; } case BLOCKED: - case PENDING: { + case PENDING: + case PENDING_WEAK: { return waitForReference( referencedChunk, parentObject, @@ -2233,6 +2309,7 @@ function getOutlinedModel( } return chunkValue; case PENDING: + case PENDING_WEAK: case BLOCKED: return waitForReference( chunk, @@ -2469,6 +2546,23 @@ function parseModelString( } return chunk; } + case 'w': { + if (enableFlightWeakThenables) { + // Weak Promise + const id = parseInt(value.slice(2), 16); + const chunk = getWeakChunk(response, id); + if (enableProfilerTimer && enableComponentPerformanceTrack) { + if ( + initializingChunk !== null && + isArray(initializingChunk._children) + ) { + initializingChunk._children.push(chunk); + } + } + return chunk; + } + return undefined; + } case 'S': { // Symbol return Symbol.for(value.slice(2)); @@ -3035,14 +3129,14 @@ function resolveDebugHalt(response: Response, id: number): void { chunks.set(id, (chunk = createPendingChunk(response))); } else { } - if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + if ( + chunk.status !== PENDING && + chunk.status !== PENDING_WEAK && + chunk.status !== BLOCKED + ) { return; } - releasePendingChunk(response, chunk); - const haltedChunk: HaltedChunk = chunk as any; - haltedChunk.status = HALTED; - haltedChunk.value = null; - haltedChunk.reason = null; + haltChunk(response, chunk); } function resolveModel( @@ -5428,14 +5522,11 @@ export function close(weakResponse: WeakResponse): void { // For partial streams, we halt pending chunks instead of erroring them. response._closed = true; response._chunks.forEach(chunk => { - if (chunk.status === PENDING) { - // Clear listeners to release closures and transition to HALTED. - // Future .then() calls on HALTED chunks are no-ops. - releasePendingChunk(response, chunk); - const haltedChunk: HaltedChunk = chunk as any; - haltedChunk.status = HALTED; - haltedChunk.value = null; - haltedChunk.reason = null; + if ( + chunk.status === PENDING || + (enableFlightWeakThenables && chunk.status === PENDING_WEAK) + ) { + haltChunk(response, chunk); } else if (chunk.status === INITIALIZED && chunk.reason !== null) { // Stream chunk - close gracefully instead of erroring. chunk.reason.close('"$undefined"'); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index eaccb0758d0..0c1f9add5b4 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -2424,4 +2424,354 @@ describe('ReactFlightDOMEdge', () => { ).toString(), ).toBe('function () { [omitted code] }'); }); + + // A thenable with status 'pending_weak' doesn't keep the Flight stream + // open. If it settles before the stream closes for other reasons its value + // is emitted like a normal pending thenable; otherwise its reference is + // left unfulfilled and stays forever pending on the client. + // + // A framework-style tracker for whether a page accessed its search params + // during a render. The params object is instrumented so that the first + // access settles the usedSearchParams thenable. It settles synchronously + // at the access point, so an access is guaranteed to be encoded before + // the response closes. + function createSearchParams(values) { + const listeners = []; + const usedSearchParams = { + status: 'pending_weak', + value: undefined, + then(onFulfill) { + if (usedSearchParams.status === 'fulfilled') { + onFulfill(usedSearchParams.value); + } else { + listeners.push(onFulfill); + } + }, + }; + const searchParams = new Proxy(values, { + get(target, key) { + if (usedSearchParams.status === 'pending_weak') { + usedSearchParams.status = 'fulfilled'; + usedSearchParams.value = true; + for (let i = 0; i < listeners.length; i++) { + listeners[i](true); + } + listeners.length = 0; + } + return target[key]; + }, + }); + return {searchParams, usedSearchParams}; + } + + it('emits the value of a weak-pending thenable that settles during the render', async () => { + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'}); + + function Page() { + return
{'Results for ' + searchParams.q}
; + } + + let response; + await serverAct(() => { + const stream = ReactServerDOMServer.renderToReadableStream({ + usedSearchParams, + root: , + }); + // Start consuming immediately, like a server that pipes the response + // while it renders. + response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + }); + + const result = await response; + expect(await result.usedSearchParams).toBe(true); + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result.root), + ); + expect(await readResult(ssrStream)).toBe('
Results for react
'); + }); + + // @gate enableFlightWeakThenables + it('completes the response without waiting for a weak-pending thenable that never settles', async () => { + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'}); + + function Page() { + return
Static content
; + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + usedSearchParams, + root: , + }), + ); + const [stream1, stream2] = stream.tee(); + + let content = null; + const readPromise = readResult(stream1).then(c => (content = c)); + await serverAct(async () => {}); + // The response completed even though the weak thenable never settled. + expect(content).not.toBe(null); + await readPromise; + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + + // Accessing the params after the response already completed doesn't do + // anything. + expect(searchParams.q).toBe('react'); + + // The reference is left forever pending, without erroring. + const raced = await Promise.race([ + result.usedSearchParams, + Promise.resolve('never accessed'), + ]); + expect(raced).toBe('never accessed'); + }); + + it('emits the value of a weak-pending thenable that settles while the response is still streaming', async () => { + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'}); + + let resolveData; + const data = new Promise(res => (resolveData = res)); + async function Results() { + const filter = await data; + return
{'Results for ' + searchParams[filter]}
; + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + usedSearchParams, + root: , + }), + ); + const [stream1, stream2] = stream.tee(); + + let content = null; + const readPromise = readResult(stream1).then(c => (content = c)); + + // The response stays open while the data is loading — because of the + // async component, not because of the unresolved weak thenable. + await serverAct(async () => {}); + expect(content).toBe(null); + + // The data resolves, the component accesses the search params, and the + // response completes. + await serverAct(() => resolveData('q')); + await serverAct(async () => {}); + expect(content).not.toBe(null); + await readPromise; + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + expect(await result.usedSearchParams).toBe(true); + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result.root), + ); + expect(await readResult(ssrStream)).toBe('
Results for react
'); + }); + + // @gate !enableFlightWeakThenables + it('treats a weak-pending thenable like a normal pending thenable when the flag is off', async () => { + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'}); + + function Page() { + return
Static content
; + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + usedSearchParams, + root: , + }), + ); + const [stream1, stream2] = stream.tee(); + + let content = null; + const readPromise = readResult(stream1).then(c => (content = c)); + + // Without the flag, the unknown thenable status is treated as an + // ordinary pending thenable, which keeps the response open. + await serverAct(async () => {}); + expect(content).toBe(null); + + // Accessing the params settles the thenable and lets the response + // complete. + await serverAct(() => { + expect(searchParams.q).toBe('react'); + }); + await serverAct(async () => {}); + expect(content).not.toBe(null); + await readPromise; + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + expect(await result.usedSearchParams).toBe(true); + }); + + // @gate enableFlightWeakThenables + it('supports linked lists of weak-pending thenables', async () => { + // Weak thenables compose recursively: the value that a weak-pending + // thenable settles with can itself contain more weak-pending thenables. + // A linked list of them forms an async sequence that never blocks the + // response from completing. Modeled here as a framework tracking which + // params a page accessed during a dynamic render, encoded into the + // response itself as WeakThenable<{value: T, next: WeakThenable<...>}>. + function instrumentParams(params) { + function createWeakNode() { + const listeners = []; + const node = { + status: 'pending_weak', + value: undefined, + then(onFulfill) { + if (node.status === 'fulfilled') { + onFulfill(node.value); + } else { + listeners.push(onFulfill); + } + }, + }; + return {node, listeners}; + } + let tail = createWeakNode(); + const head = tail.node; + const accessed = new Set(); + const instrumentedParams = new Proxy(params, { + get(target, name) { + if ( + typeof name === 'string' && + name in target && + !accessed.has(name) + ) { + accessed.add(name); + const settledTail = tail; + tail = createWeakNode(); + // Settle the tail of the list synchronously at the access point + // so it's guaranteed to be encoded before the response closes. + const result = {value: name, next: tail.node}; + settledTail.node.status = 'fulfilled'; + settledTail.node.value = result; + for (let i = 0; i < settledTail.listeners.length; i++) { + settledTail.listeners[i](result); + } + settledTail.listeners.length = 0; + } + return target[name]; + }, + }); + return {params: instrumentedParams, accessedParams: head}; + } + + const {params, accessedParams} = instrumentParams({ + a: 'value-of-a', + b: 'value-of-b', + c: 'value-of-c', + }); + + function Page() { + // The page reads param a during the render. + return 'Accessed: ' + params.a; + } + + let resolveNormal; + const pending = new Promise(res => { + resolveNormal = res; + }); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream({ + accessedParams, + page: , + pending, + }), + ); + const [stream1, stream2] = stream.tee(); + + let content = null; + const readPromise = readResult(stream1).then(c => (content = c)); + + // While the normal pending promise holds the stream open, param c is + // accessed, settling the next node of the list. + await serverAct(() => { + expect(params.c).toBe('value-of-c'); + }); + await serverAct(async () => {}); + expect(content).toBe(null); + + // Param b is never accessed, so the tail of the list stays unsettled. + // It doesn't keep the response open: once the normal promise resolves, + // the response completes. + await serverAct(() => { + resolveNormal('done'); + }); + await serverAct(async () => {}); + expect(content).not.toBe(null); + await readPromise; + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + expect(result.page).toBe('Accessed: value-of-a'); + + // Wait until the full response has been processed. + await serverAct(async () => {}); + + // Read the accessed params off the list, synchronously. A node that + // was never settled by the server stays forever pending, without + // erroring, which marks the end of the accessed params. + function readNode(node) { + // Attach a no-op listener to force Flight to synchronously unwrap a + // node that was received but not yet initialized. + node.then(() => {}); + if (node.status !== 'fulfilled') { + return null; + } + return node.value; + } + + const accessed = []; + let node = result.accessedParams; + while (node !== null) { + const entry = readNode(node); + if (entry === null) { + break; + } + accessed.push(entry.value); + node = entry.next; + } + expect(accessed).toEqual(['a', 'c']); + }); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 726c7dcdc15..2220e2a5bff 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -16,6 +16,7 @@ import { enableProfilerTimer, enableComponentPerformanceTrack, enableAsyncDebugInfo, + enableFlightWeakThenables, } from 'shared/ReactFeatureFlags'; import { @@ -1078,12 +1079,12 @@ function emitRequestedDebugThenable( ); } -function serializeThenable( +function createThenableTask( request: Request, task: Task, thenable: Thenable, -): number { - const newTask = createTask( +): Task { + return createTask( request, thenable as any, // will be replaced by the value before we retry. used for debug info. task.keyPath, // the server component sequence continues through Promise-as-a-child. @@ -1098,9 +1099,16 @@ function serializeThenable( __DEV__ ? task.debugStack : null, __DEV__ ? task.debugTask : null, ); +} +function serializeThenable( + request: Request, + task: Task, + thenable: Thenable, +): number { switch (thenable.status) { case 'fulfilled': { + const newTask = createThenableTask(request, task, thenable); forwardDebugInfoFromThenable(request, newTask, thenable, null, null); // We have the resolved value, we can go ahead and schedule it for serialization. newTask.model = thenable.value; @@ -1108,12 +1116,102 @@ function serializeThenable( return newTask.id; } case 'rejected': { + const newTask = createThenableTask(request, task, thenable); forwardDebugInfoFromThenable(request, newTask, thenable, null, null); const x = thenable.reason; erroredTask(request, newTask, x); return newTask.id; } + case 'pending_weak': { + if (enableFlightWeakThenables) { + // A weak-pending thenable doesn't block the stream from closing, so + // we don't create a task for it yet. We only reserve an id for its + // reference. If it settles while the stream is still open, we + // create the task at that point, the same as if we had serialized + // an already settled thenable. + // + // Delivery is driven by the thenable's notification. If the stream + // closes before the listeners are notified, the value is dropped + // and the reference is left unfulfilled. Since the stream may close + // synchronously when the last task completes, a thenable that + // notifies its listeners synchronously (unlike a native Promise, + // which notifies in a microtask) is guaranteed delivery of any + // value it settles with before the stream closes. + const id = request.nextChunkId++; + // The parent task is mutated as serialization continues, so we + // snapshot the context that the new task needs if it's created + // later. + const keyPath = task.keyPath; + const implicitSlot = task.implicitSlot; + const formatContext = task.formatContext; + const lastTimestamp = + enableProfilerTimer && + (enableComponentPerformanceTrack || enableAsyncDebugInfo) + ? task.time + : 0; + const debugOwner = __DEV__ ? task.debugOwner : null; + const debugStack = __DEV__ ? task.debugStack : null; + const debugTask = __DEV__ ? task.debugTask : null; + let settled = false; + thenable.then( + (value: any) => { + if (settled || request.status > OPEN) { + // Too late. The stream already closed (or the request was + // aborted), so the reference stays unfulfilled. + return; + } + settled = true; + const newTask = createTaskWithID( + request, + id, + value, + keyPath, + implicitSlot, + formatContext, + request.abortableTasks, + lastTimestamp, + debugOwner, + debugStack, + debugTask, + ); + forwardDebugInfoFromCurrentContext(request, newTask, thenable); + pingTask(request, newTask); + }, + (reason: mixed) => { + if (settled || request.status > OPEN) { + return; + } + settled = true; + const newTask = createTaskWithID( + request, + id, + thenable as any, // never rendered. used for debug info. + keyPath, + implicitSlot, + formatContext, + request.abortableTasks, + lastTimestamp, + debugOwner, + debugStack, + debugTask, + ); + if ( + enableProfilerTimer && + (enableComponentPerformanceTrack || enableAsyncDebugInfo) + ) { + // If this is async we need to time when this task finishes. + newTask.timed = true; + } + erroredTask(request, newTask, reason); + enqueueFlush(request); + }, + ); + return id; + } + // Fallthrough + } default: { + const newTask = createThenableTask(request, task, thenable); if (request.status === ABORTING) { // We can no longer accept any resolved values request.abortableTasks.delete(newTask); @@ -1127,59 +1225,56 @@ function serializeThenable( } return newTask.id; } - if (typeof thenable.status === 'string') { + if (typeof thenable.status !== 'string') { // Only instrument the thenable if the status if not defined. If // it's defined, but an unknown value, assume it's been instrumented by // some custom userspace implementation. We treat it as "pending". - break; + const pendingThenable: PendingThenable = thenable as any; + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = + thenable as any; + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = thenable as any; + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); } - const pendingThenable: PendingThenable = thenable as any; - pendingThenable.status = 'pending'; - pendingThenable.then( - fulfilledValue => { - if (thenable.status === 'pending') { - const fulfilledThenable: FulfilledThenable = thenable as any; - fulfilledThenable.status = 'fulfilled'; - fulfilledThenable.value = fulfilledValue; - } + thenable.then( + value => { + forwardDebugInfoFromCurrentContext(request, newTask, thenable); + newTask.model = value; + pingTask(request, newTask); }, - (error: mixed) => { - if (thenable.status === 'pending') { - const rejectedThenable: RejectedThenable = thenable as any; - rejectedThenable.status = 'rejected'; - rejectedThenable.reason = error; + reason => { + if (newTask.status === PENDING) { + if ( + enableProfilerTimer && + (enableComponentPerformanceTrack || enableAsyncDebugInfo) + ) { + // If this is async we need to time when this task finishes. + newTask.timed = true; + } + // We expect that the only status it might be otherwise is ABORTED. + // When we abort we emit chunks in each pending task slot and don't need + // to do so again here. + erroredTask(request, newTask, reason); + enqueueFlush(request); } }, ); - break; + return newTask.id; } } - - thenable.then( - value => { - forwardDebugInfoFromCurrentContext(request, newTask, thenable); - newTask.model = value; - pingTask(request, newTask); - }, - reason => { - if (newTask.status === PENDING) { - if ( - enableProfilerTimer && - (enableComponentPerformanceTrack || enableAsyncDebugInfo) - ) { - // If this is async we need to time when this task finishes. - newTask.timed = true; - } - // We expect that the only status it might be otherwise is ABORTED. - // When we abort we emit chunks in each pending task slot and don't need - // to do so again here. - erroredTask(request, newTask, reason); - enqueueFlush(request); - } - }, - ); - - return newTask.id; } function serializeReadableStream( @@ -2760,9 +2855,36 @@ function createTask( debugOwner: null | ReactComponentInfo, // DEV-only debugStack: null | Error, // DEV-only debugTask: null | ConsoleTask, // DEV-only +): Task { + return createTaskWithID( + request, + request.nextChunkId++, + model, + keyPath, + implicitSlot, + formatContext, + abortSet, + lastTimestamp, + debugOwner, + debugStack, + debugTask, + ); +} + +function createTaskWithID( + request: Request, + id: number, + model: ReactClientValue, + keyPath: ReactKey, + implicitSlot: boolean, + formatContext: FormatContext, + abortSet: Set, + lastTimestamp: number, // Profiling-only + debugOwner: null | ReactComponentInfo, // DEV-only + debugStack: null | Error, // DEV-only + debugTask: null | ConsoleTask, // DEV-only ): Task { request.pendingChunks++; - const id = request.nextChunkId++; if (typeof model === 'object' && model !== null) { // If we're about to write this into a new task we can assign it an ID early so that // any other references can refer to the value we're about to write. @@ -2942,6 +3064,10 @@ function serializePromiseID(id: number): string { return '$@' + id.toString(16); } +function serializeWeakPromiseID(id: number): string { + return '$w' + id.toString(16); +} + function serializeServerReferenceID(id: number): string { return '$h' + id.toString(16); } @@ -3828,13 +3954,20 @@ function renderModelDestructive( const existingReference = writtenObjects.get(value); // $FlowFixMe[method-unbinding] if (typeof value.then === 'function') { + // A weak-pending thenable may never emit, so its reference is marked + // on the wire ($w instead of $@). That way the client knows to leave + // it forever pending, instead of erroring it, if the stream closes + // first. if (existingReference !== undefined) { if (task.keyPath !== null || task.implicitSlot) { // If we're in some kind of context we can't reuse the result of this render or // previous renders of this element. We only reuse Promises if they're not wrapped // by another Server Component. const promiseId = serializeThenable(request, task, value as any); - return serializePromiseID(promiseId); + return enableFlightWeakThenables && + (value as any).status === 'pending_weak' + ? serializeWeakPromiseID(promiseId) + : serializePromiseID(promiseId); } else if (modelRoot === value) { // This is the ID we're currently emitting so we need to write it // once but if we discover it again, we refer to it by id. @@ -3847,7 +3980,10 @@ function renderModelDestructive( // We assume that any object with a .then property is a "Thenable" type, // or a Promise type. Either of which can be represented by a Promise. const promiseId = serializeThenable(request, task, value as any); - const promiseReference = serializePromiseID(promiseId); + const promiseReference = + enableFlightWeakThenables && (value as any).status === 'pending_weak' + ? serializeWeakPromiseID(promiseId) + : serializePromiseID(promiseId); writtenObjects.set(value, promiseReference); return promiseReference; } @@ -6157,6 +6293,12 @@ function finishAbortedTask( request.completedErrorChunks.push(processedChunk); } +// "Halting" a task means finishing it without emitting anything into its +// slot: the reference is intentionally left unfulfilled and never resolves +// on the client. This is how an aborted prerender leaves its pending work. +// It's also the same outcome as a weak-pending thenable that never settles +// (see serializeThenable) — halting is initiated by the request aborting, +// weakness by the value itself. function haltTask(task: Task, request: Request): void { if (task.status !== PENDING) { // If this is already completed/errored we don't abort it. diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index c1055613162..247e43d0751 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -79,6 +79,12 @@ export const enableLegacyCache = __EXPERIMENTAL__; export const enableAsyncIterableChildren = __EXPERIMENTAL__; +// Support thenables with status 'pending_weak' in Flight. A weak-pending +// thenable doesn't keep the stream open; if it resolves before the stream +// closes for other reasons, its value is emitted, otherwise its reference is +// left unfulfilled. +export const enableFlightWeakThenables = __EXPERIMENTAL__; + export const enableTaint = __EXPERIMENTAL__; export const enableViewTransition: boolean = true; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 71487a69ebe..0de151ce970 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -126,6 +126,11 @@ export interface PendingThenable extends ThenableImpl { _debugInfo?: null | ReactDebugInfo; } +export interface WeakPendingThenable extends ThenableImpl { + status: 'pending_weak'; + _debugInfo?: null | ReactDebugInfo; +} + export interface FulfilledThenable extends ThenableImpl { status: 'fulfilled'; value: T; @@ -141,6 +146,7 @@ export interface RejectedThenable extends ThenableImpl { export type Thenable = | UntrackedThenable | PendingThenable + | WeakPendingThenable | FulfilledThenable | RejectedThenable; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 362f88e41d6..575c1faac9a 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -41,6 +41,7 @@ export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; +export const enableFlightWeakThenables: boolean = false; export const enableCPUSuspense: boolean = true; export const enableCreateEventHandleAPI: boolean = false; export const enableBrowserAPI: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index a1575c75b0b..7dd8c4add2d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -26,6 +26,7 @@ export const disableSchedulerTimeoutInWorkLoop: boolean = false; export const disableTextareaChildren: boolean = false; export const enableAsyncDebugInfo: boolean = true; export const enableAsyncIterableChildren: boolean = false; +export const enableFlightWeakThenables: boolean = false; export const enableCPUSuspense: boolean = false; export const enableCreateEventHandleAPI: boolean = false; export const enableBrowserAPI: boolean = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 9da4540c53c..945b01b7308 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -20,6 +20,7 @@ export const enablePerformanceIssueReporting: boolean = false; export const enableUpdaterTracking: boolean = false; export const enableLegacyCache: boolean = __EXPERIMENTAL__; export const enableAsyncIterableChildren: boolean = false; +export const enableFlightWeakThenables: boolean = false; export const enableTaint: boolean = true; export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index b896cf00cb0..ec5b5263179 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -21,6 +21,7 @@ export const disableSchedulerTimeoutInWorkLoop = false; export const disableTextareaChildren = false; export const enableAsyncDebugInfo = true; export const enableAsyncIterableChildren = false; +export const enableFlightWeakThenables = false; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; export const enableBrowserAPI = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 6ca7619a456..d35cf325f33 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -20,6 +20,7 @@ export const enablePerformanceIssueReporting: boolean = false; export const enableUpdaterTracking: boolean = false; export const enableLegacyCache: boolean = true; export const enableAsyncIterableChildren: boolean = false; +export const enableFlightWeakThenables: boolean = false; export const enableTaint: boolean = true; export const disableCommentsAsDOMContainers: boolean = true; export const disableInputAttributeSyncing: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 4c2371bab69..498003ba709 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -72,6 +72,7 @@ export const disableLegacyContext = __EXPERIMENTAL__; export const enableLegacyCache: boolean = true; export const enableAsyncIterableChildren: boolean = false; +export const enableFlightWeakThenables: boolean = false; export const enableTaint: boolean = false;