diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index b23b172b02cc..b49f1f15023b 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-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index b9ce7d5e153f..dc359db238bb 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 2558e9648b7e..1eac572f1c84 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 59557c61f620..6eae22061c48 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 edd18cbbb698..0f5acc6eed5f 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 3a783ff32671..b0e57a4b8d8f 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 000000000000..3216b8e430da --- /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/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index eaccb0758d04..0c1f9add5b44 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 726c7dcdc159..2220e2a5bff3 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 45ec1737611a..247e43d0751a 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; @@ -154,6 +160,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/ReactTypes.js b/packages/shared/ReactTypes.js index 71487a69ebed..0de151ce9700 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-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index b81f9db1053d..36832e2ab078 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 9b5d9497f858..575c1faac9a1 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. @@ -40,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 30ace506bc38..7dd8c4add2dd 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; @@ -33,6 +34,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 bbd4530b022e..945b01b73085 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; @@ -54,6 +55,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 dd45f875a28e..ec5b5263179b 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; @@ -28,6 +29,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 c7f9c591d69e..d35cf325f337 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; @@ -56,6 +57,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 59bda0e3c2bd..4058ac003c0d 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 a600292c9652..498003ba7090 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, @@ -71,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; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index fb7e296452cf..f2eb43edb17e 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" }