From da8c9f29100763862c5f74f106789ae5ae05026c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 20 Nov 2019 17:56:11 -0800 Subject: [PATCH] Only show most recent transition, per queue When multiple transitions update the same queue, only the most recent one should be allowed to finish. Do not display intermediate states. For example, if you click on multiple tabs in quick succession, we should not switch to any tab that isn't the last one you clicked. --- .../react-reconciler/src/ReactFiberHooks.js | 41 +- .../react-reconciler/src/ReactFiberThrow.js | 6 + .../src/ReactFiberWorkLoop.js | 54 ++- .../react-reconciler/src/ReactUpdateQueue.js | 45 ++- .../ReactTransition-test.internal.js | 356 ++++++++++++++++++ packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 12 files changed, 491 insertions(+), 19 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 1ce9683707c2f..18402f0d3fdc7 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -19,6 +19,7 @@ import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {TransitionInstance} from './ReactFiberTransition'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import {preventIntermediateStates} from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {NoWork, Sync} from './ReactFiberExpirationTime'; @@ -50,6 +51,7 @@ import invariant from 'shared/invariant'; import getComponentName from 'shared/getComponentName'; import is from 'shared/objectIs'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; +import {SuspendOnTask} from './ReactFiberThrow'; import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; import { startTransition, @@ -705,7 +707,10 @@ function updateReducer( let newBaseQueueFirst = null; let newBaseQueueLast = null; let update = first; + let lastProcessedTransitionTime = NoWork; + let lastSkippedTransitionTime = NoWork; do { + const suspenseConfig = update.suspenseConfig; const updateExpirationTime = update.expirationTime; if (updateExpirationTime < renderExpirationTime) { // Priority is insufficient. Skip this update. If this is the first @@ -730,6 +735,16 @@ function updateReducer( currentlyRenderingFiber.expirationTime = updateExpirationTime; markUnprocessedUpdateTime(updateExpirationTime); } + + if (suspenseConfig !== null) { + // This update is part of a transition + if ( + lastSkippedTransitionTime === NoWork || + lastSkippedTransitionTime > updateExpirationTime + ) { + lastSkippedTransitionTime = updateExpirationTime; + } + } } else { // This update does have sufficient priority. @@ -751,10 +766,7 @@ function updateReducer( // TODO: We should skip this update if it was already committed but currently // we have no way of detecting the difference between a committed and suspended // update here. - markRenderEventTimeAndConfig( - updateExpirationTime, - update.suspenseConfig, - ); + markRenderEventTimeAndConfig(updateExpirationTime, suspenseConfig); // Process this update. if (update.eagerReducer === reducer) { @@ -765,10 +777,31 @@ function updateReducer( const action = update.action; newState = reducer(newState, action); } + + if (suspenseConfig !== null) { + // This update is part of a transition + if ( + lastProcessedTransitionTime === NoWork || + lastProcessedTransitionTime > updateExpirationTime + ) { + lastProcessedTransitionTime = updateExpirationTime; + } + } } update = update.next; } while (update !== null && update !== first); + if ( + preventIntermediateStates && + lastProcessedTransitionTime !== NoWork && + lastSkippedTransitionTime !== NoWork + ) { + // There are multiple updates scheduled on this queue, but only some of + // them were processed. To avoid showing an intermediate state, abort + // the current render and restart at a level that includes them all. + throw new SuspendOnTask(lastSkippedTransitionTime); + } + if (newBaseQueueLast === null) { newBaseState = newState; } else { diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index f56b1a04b33a3..5bf24d4ea3003 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -61,6 +61,12 @@ import {Sync} from './ReactFiberExpirationTime'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +// Throw an object with this type to abort the current render and restart at +// a different level. +export function SuspendOnTask(expirationTime: ExpirationTime) { + this.retryTime = expirationTime; +} + function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index b31e47ce5a068..142defdbc2545 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -127,6 +127,7 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, + SuspendOnTask, } from './ReactFiberThrow'; import { commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber, @@ -201,13 +202,14 @@ const LegacyUnbatchedContext = /* */ 0b001000; const RenderContext = /* */ 0b010000; const CommitContext = /* */ 0b100000; -type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; const RootIncomplete = 0; const RootFatalErrored = 1; -const RootErrored = 2; -const RootSuspended = 3; -const RootSuspendedWithDelay = 4; -const RootCompleted = 5; +const RootSuspendedOnTask = 2; +const RootErrored = 3; +const RootSuspended = 4; +const RootSuspendedWithDelay = 5; +const RootCompleted = 6; export type Thenable = { then(resolve: () => mixed, reject?: () => mixed): Thenable | void, @@ -238,7 +240,7 @@ let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null; // The work left over by components that were visited during this render. Only // includes unprocessed updates, not work in bailed out children. let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork; - +let workInProgressRootRestartTime: ExpirationTime = NoWork; // If we're pinged while rendering we don't always restart immediately. // This flag determines if it might be worthwhile to restart if an opportunity // happens latere. @@ -708,7 +710,12 @@ function performConcurrentWorkOnRoot(root, didTimeout) { throw fatalError; } - if (workInProgress !== null) { + if (workInProgressRootExitStatus === RootSuspendedOnTask) { + // Can't finish rendering at this level. Exit early and restart at the + // specified time. + markRootSuspendedAtTime(root, expirationTime); + root.nextKnownPendingLevel = workInProgressRootRestartTime; + } else if (workInProgress !== null) { // There's still work left over. Exit without committing. stopInterruptedWorkLoopTimer(); } else { @@ -749,7 +756,8 @@ function finishConcurrentRender( switch (exitStatus) { case RootIncomplete: - case RootFatalErrored: { + case RootFatalErrored: + case RootSuspendedOnTask: { invariant(false, 'Root did not complete. This is a bug in React.'); } // Flow knows about invariant, so it complains if I add a break @@ -1036,7 +1044,12 @@ function performSyncWorkOnRoot(root) { throw fatalError; } - if (workInProgress !== null) { + if (workInProgressRootExitStatus === RootSuspendedOnTask) { + // Can't finish rendering at this level. Exit early and restart at the + // specified time. + markRootSuspendedAtTime(root, expirationTime); + root.nextKnownPendingLevel = workInProgressRootRestartTime; + } else if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. invariant( false, @@ -1264,6 +1277,7 @@ function prepareFreshStack(root, expirationTime) { workInProgressRootLatestSuspenseTimeout = Sync; workInProgressRootCanSuspendUsingConfig = null; workInProgressRootNextUnprocessedUpdateTime = NoWork; + workInProgressRootRestartTime = NoWork; workInProgressRootHasPendingPing = false; if (enableSchedulerTracing) { @@ -1284,6 +1298,20 @@ function handleError(root, thrownValue) { resetHooksAfterThrow(); resetCurrentDebugFiberInDEV(); + // Check if this is a SuspendOnTask exception. This is the one type of + // exception that is allowed to happen at the root. + // TODO: I think instanceof is OK here? A brand check seems unnecessary + // since this is always thrown by the renderer and not across realms + // or packages. + if (thrownValue instanceof SuspendOnTask) { + // Can't finish rendering at this level. Exit early and restart at + // the specified time. + workInProgressRootExitStatus = RootSuspendedOnTask; + workInProgressRootRestartTime = thrownValue.retryTime; + workInProgress = null; + return; + } + if (workInProgress === null || workInProgress.return === null) { // Expected to be working on a non-root fiber. This is a fatal error // because there's no ancestor that can handle it; the root is @@ -2624,15 +2652,17 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { try { return originalBeginWork(current, unitOfWork, expirationTime); } catch (originalError) { + // Filter out special exception types if ( originalError !== null && typeof originalError === 'object' && - typeof originalError.then === 'function' + // Promise + (typeof originalError.then === 'function' || + // SuspendOnTask exception + originalError instanceof SuspendOnTask) ) { - // Don't replay promises. Treat everything else like an error. throw originalError; } - // Keep this code in sync with handleError; any changes here must have // corresponding changes there. resetContextDependencies(); diff --git a/packages/react-reconciler/src/ReactUpdateQueue.js b/packages/react-reconciler/src/ReactUpdateQueue.js index aaa4e9c1160d7..3f42c1b224a68 100644 --- a/packages/react-reconciler/src/ReactUpdateQueue.js +++ b/packages/react-reconciler/src/ReactUpdateQueue.js @@ -97,13 +97,17 @@ import { } from './ReactFiberNewContext'; import {Callback, ShouldCapture, DidCapture} from 'shared/ReactSideEffectTags'; -import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags'; +import { + debugRenderPhaseSideEffectsForStrictMode, + preventIntermediateStates, +} from 'shared/ReactFeatureFlags'; import {StrictMode} from './ReactTypeOfMode'; import { markRenderEventTimeAndConfig, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; +import {SuspendOnTask} from './ReactFiberThrow'; import invariant from 'shared/invariant'; import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration'; @@ -413,15 +417,18 @@ export function processUpdateQueue( if (first !== null) { let update = first; + let lastProcessedTransitionTime = NoWork; + let lastSkippedTransitionTime = NoWork; do { const updateExpirationTime = update.expirationTime; + const suspenseConfig = update.suspenseConfig; if (updateExpirationTime < renderExpirationTime) { // Priority is insufficient. Skip this update. If this is the first // skipped update, the previous update/state is the new base // update/state. const clone: Update = { - expirationTime: update.expirationTime, - suspenseConfig: update.suspenseConfig, + expirationTime: updateExpirationTime, + suspenseConfig, tag: update.tag, payload: update.payload, @@ -439,6 +446,16 @@ export function processUpdateQueue( if (updateExpirationTime > newExpirationTime) { newExpirationTime = updateExpirationTime; } + + if (suspenseConfig !== null) { + // This update is part of a transition + if ( + lastSkippedTransitionTime === NoWork || + lastSkippedTransitionTime > updateExpirationTime + ) { + lastSkippedTransitionTime = updateExpirationTime; + } + } } else { // This update does have sufficient priority. @@ -487,6 +504,17 @@ export function processUpdateQueue( } } } + + if (suspenseConfig !== null) { + // This update is part of a transition + if ( + lastProcessedTransitionTime === NoWork || + lastProcessedTransitionTime > updateExpirationTime + ) { + lastProcessedTransitionTime = updateExpirationTime; + } + } + update = update.next; if (update === null || update === first) { pendingQueue = queue.shared.pending; @@ -502,6 +530,17 @@ export function processUpdateQueue( } } } while (true); + + if ( + preventIntermediateStates && + lastProcessedTransitionTime !== NoWork && + lastSkippedTransitionTime !== NoWork + ) { + // There are multiple updates scheduled on this queue, but only some of + // them were processed. To avoid showing an intermediate state, abort + // the current render and restart at a level that includes them all. + throw new SuspendOnTask(lastSkippedTransitionTime); + } } if (newBaseQueueLast === null) { diff --git a/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js b/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js index 5eb280ff684ee..7c3f8a43019eb 100644 --- a/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js @@ -506,4 +506,360 @@ describe('ReactTransition', () => { ); }, ); + + it.experimental( + 'when multiple transitions update the same queue, only the most recent ' + + 'one is allowed to finish (no intermediate states)', + async () => { + const CONFIG = { + timeoutMs: 100000, + }; + + const Tab = React.forwardRef(({label, setTab}, ref) => { + const [startTransition, isPending] = useTransition(CONFIG); + + React.useImperativeHandle( + ref, + () => ({ + go() { + startTransition(() => setTab(label)); + }, + }), + [label], + ); + + return ( + + ); + }); + + const tabButtonA = React.createRef(null); + const tabButtonB = React.createRef(null); + const tabButtonC = React.createRef(null); + + const ContentA = createAsyncText('A'); + const ContentB = createAsyncText('B'); + const ContentC = createAsyncText('C'); + + function App() { + Scheduler.unstable_yieldValue('App'); + + const [tab, setTab] = useState('A'); + + let content; + switch (tab) { + case 'A': + content = ; + break; + case 'B': + content = ; + break; + case 'C': + content = ; + break; + default: + content = ; + break; + } + + return ( + <> +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ }>{content} + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await ContentA.resolve(); + }); + expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'A']); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C
  • +
+ A + , + ); + + // Navigate to tab B + await act(async () => { + tabButtonB.current.go(); + expect(Scheduler).toFlushAndYieldThrough([ + // Turn on B's pending state + 'Tab B (pending...)', + // Partially render B + 'App', + 'Tab A', + 'Tab B', + ]); + + // While we're still in the middle of rendering B, switch to C. + tabButtonC.current.go(); + }); + expect(Scheduler).toHaveYielded([ + // Toggle the pending flags + 'Tab B', + 'Tab C (pending...)', + + // Start rendering B... + 'App', + // ...but bail out, since C is more recent. These should not be logged: + // 'Tab A', + // 'Tab B', + // 'Tab C (pending...)', + // 'Suspend! [B]', + // 'Loading...', + + // Now render C + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C (pending...)
  • +
+ A + , + ); + + // Finish loading B + await act(async () => { + ContentB.resolve(); + }); + // Should not switch to tab B because we've since clicked on C. + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C (pending...)
  • +
+ A + , + ); + + // Finish loading C + await act(async () => { + ContentC.resolve(); + }); + expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'C']); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C
  • +
+ C + , + ); + }, + ); + + // Same as previous test, but for class update queue. + it.experimental( + 'when multiple transitions update the same queue, only the most recent ' + + 'one is allowed to finish (no intermediate states) (classes)', + async () => { + const CONFIG = { + timeoutMs: 100000, + }; + + const Tab = React.forwardRef(({label, setTab}, ref) => { + const [startTransition, isPending] = useTransition(CONFIG); + + React.useImperativeHandle( + ref, + () => ({ + go() { + startTransition(() => setTab(label)); + }, + }), + [label], + ); + + return ( + + ); + }); + + const tabButtonA = React.createRef(null); + const tabButtonB = React.createRef(null); + const tabButtonC = React.createRef(null); + + const ContentA = createAsyncText('A'); + const ContentB = createAsyncText('B'); + const ContentC = createAsyncText('C'); + + class App extends React.Component { + state = {tab: 'A'}; + setTab = tab => this.setState({tab}); + render() { + Scheduler.unstable_yieldValue('App'); + + let content; + switch (this.state.tab) { + case 'A': + content = ; + break; + case 'B': + content = ; + break; + case 'C': + content = ; + break; + default: + content = ; + break; + } + + return ( + <> +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ }> + {content} + + + ); + } + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + await ContentA.resolve(); + }); + expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'A']); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C
  • +
+ A + , + ); + + // Navigate to tab B + await act(async () => { + tabButtonB.current.go(); + expect(Scheduler).toFlushAndYieldThrough([ + // Turn on B's pending state + 'Tab B (pending...)', + // Partially render B + 'App', + 'Tab A', + 'Tab B', + ]); + + // While we're still in the middle of rendering B, switch to C. + tabButtonC.current.go(); + }); + expect(Scheduler).toHaveYielded([ + // Toggle the pending flags + 'Tab B', + 'Tab C (pending...)', + + // Start rendering B... + // NOTE: This doesn't get logged like in the hooks version of this + // test because the update queue bails out before entering the render + // method. + // 'App', + // ...but bail out, since C is more recent. These should not be logged: + // 'Tab A', + // 'Tab B', + // 'Tab C (pending...)', + // 'Suspend! [B]', + // 'Loading...', + + // Now render C + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C (pending...)
  • +
+ A + , + ); + + // Finish loading B + await act(async () => { + ContentB.resolve(); + }); + // Should not switch to tab B because we've since clicked on C. + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C (pending...)
  • +
+ A + , + ); + + // Finish loading C + await act(async () => { + ContentC.resolve(); + }); + expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'C']); + expect(root).toMatchRenderedOutput( + <> +
    +
  • Tab A
  • +
  • Tab B
  • +
  • Tab C
  • +
+ C + , + ); + }, + ); }); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 70d46bc0734bc..4b11d83b72f0b 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -97,3 +97,5 @@ export const enableTrustedTypesIntegration = false; // Flag to turn event.target and event.currentTarget in ReactNative from a reactTag to a component instance export const enableNativeTargetAsInstance = false; + +export const preventIntermediateStates = __EXPERIMENTAL__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 4ebed5ecb744d..e9ae0561f428c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -45,6 +45,7 @@ export const disableLegacyContext = false; export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = false; export const enableTrustedTypesIntegration = false; +export const preventIntermediateStates = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 8b4b894aae2ad..322cc38e37b13 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -40,6 +40,7 @@ export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = false; export const enableTrustedTypesIntegration = false; export const enableNativeTargetAsInstance = false; +export const preventIntermediateStates = __EXPERIMENTAL__; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index b4d5489328a9c..573107a6d2f2c 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -40,6 +40,7 @@ export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = false; export const enableTrustedTypesIntegration = false; export const enableNativeTargetAsInstance = false; +export const preventIntermediateStates = __EXPERIMENTAL__; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 848305f9ab271..80f1ea7db5027 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -40,6 +40,7 @@ export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = false; export const enableTrustedTypesIntegration = false; export const enableNativeTargetAsInstance = false; +export const preventIntermediateStates = __EXPERIMENTAL__; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 92743530e4a18..221b43686cb81 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -38,6 +38,7 @@ export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = false; export const enableTrustedTypesIntegration = false; export const enableNativeTargetAsInstance = false; +export const preventIntermediateStates = __EXPERIMENTAL__; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 6696c8e05ebab..d9d76eb7a3301 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -17,6 +17,7 @@ export const { enableTrustedTypesIntegration, enableSelectiveHydration, enableTrainModelFix, + preventIntermediateStates, } = require('ReactFeatureFlags'); // In www, we have experimental support for gathering data