Repository: facebook/react
Pull request #33160: [Fiber] Trigger default transition indicator if needed
Author: login: sebmarkbage
Labels: name: CLA Signed
name: React Core Team
description: Opened by a member of the React Core Team
Created: 2025-05-09T02:59:39Z | Merged: 2025-05-13T19:45:12Z

Stacked on #33159.

This implements `onDefaultTransitionIndicator`.

The sequence is:

1) In `markRootUpdated` we schedule Transition updates as needing `indicatorLanes` on the root. This tracks the lanes that currently need an indicator to either start or remain going until this lane commits.
2) Track mutations during any commit. We use the same hook that view transitions use here but instead of tracking it just per view transition scope, we also track a global boolean for the whole root.
3) If a sync/default commit had any mutations, then we clear the indicator lane for the `currentEventTransitionLane`. This requires that the lane is still active while we do these commits. See #33159. In other words, a sync update gets associated with the current transition and it is assumed to be rendering the loading state for that corresponding transition so we don't need a default indicator for this lane.
4) At the end of `processRootScheduleInMicrotask`, right before we're about to enter a new "event transition lane" scope, it is no longer possible to render any more loading states for the current transition lane. That's when we invoke `onDefaultTransitionIndicator` for any roots that have new indicator lanes.
5) When we commit, we remove the finished lanes from `indicatorLanes` and once that reaches zero again, then we can clean up the default indicator. This approach means that you can start multiple different transitions while an indicator is still going but it won't stop/restart each time. Instead, it'll wait until all are done before stopping.

Follow ups:

- [x] Default updates are currently not enough to cancel because those aren't flush in the same microtask. That's unfortunate. #33186
- [x] Handle async actions before the setState. Since these don't necessarily have a root this is tricky. #33190
- [x] Disable for `useDeferredValue`. ~Since it also goes through `markRootUpdated` and schedules a Transition lane it'll get a default indicator even though it probably shouldn't have one.~ EDIT: Turns out this just works because it doesn't go through `markRootUpdated` when work is left behind.
- [x] Implement built-in DOM version by default. #33162


Diff:
diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js
index dd5173cd8372..6fbad9adac19 100644
--- a/packages/react-noop-renderer/src/createReactNoop.js
+++ b/packages/react-noop-renderer/src/createReactNoop.js
@@ -1142,9 +1142,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
     // TODO: Turn this on once tests are fixed
     // console.error(error);
   }
-  function onDefaultTransitionIndicator(): void | (() => void) {
-    // TODO: Allow this as an option.
-  }
+  function onDefaultTransitionIndicator(): void | (() => void) {}
 
   let idCounter = 0;
 
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js
index b7b17a080b17..03d78545d4ea 100644
--- a/packages/react-reconciler/src/ReactFiberCommitWork.js
+++ b/packages/react-reconciler/src/ReactFiberCommitWork.js
@@ -20,6 +20,7 @@ import type {
 import type {Fiber, FiberRoot} from './ReactInternalTypes';
 import type {Lanes} from './ReactFiberLane';
 import {
+  includesLoadingIndicatorLanes,
   includesOnlySuspenseyCommitEligibleLanes,
   includesOnlyViewTransitionEligibleLanes,
 } from './ReactFiberLane';
@@ -60,6 +61,7 @@ import {
   enableViewTransition,
   enableFragmentRefs,
   enableEagerAlternateStateNodeCleanup,
+  enableDefaultTransitionIndicator,
 } from 'shared/ReactFeatureFlags';
 import {
   FunctionComponent,
@@ -268,13 +270,16 @@ import {
 } from './ReactFiberCommitViewTransitions';
 import {
   viewTransitionMutationContext,
+  pushRootMutationContext,
   pushMutationContext,
   popMutationContext,
+  rootMutationContext,
 } from './ReactFiberMutationTracking';
 import {
   trackNamedViewTransition,
   untrackNamedViewTransition,
 } from './ReactFiberDuplicateViewTransitions';
+import {markIndicatorHandled} from './ReactFiberRootScheduler';
 
 // Used during the commit phase to track the state of the Offscreen component stack.
 // Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
@@ -2216,6 +2221,7 @@ function commitMutationEffectsOnFiber(
     case HostRoot: {
       const prevProfilerEffectDuration = pushNestedEffectDurations();
 
+      pushRootMutationContext();
       if (supportsResources) {
         prepareToCommitHoistables();
 
@@ -2265,6 +2271,18 @@ function commitMutationEffectsOnFiber(
         );
       }
 
+      popMutationContext(false);
+
+      if (
+        enableDefaultTransitionIndicator &&
+        rootMutationContext &&
+        includesLoadingIndicatorLanes(lanes)
+      ) {
+        // This root had a mutation. Mark this root as having rendered a manual
+        // loading state.
+        markIndicatorHandled(root);
+      }
+
       break;
     }
     case HostPortal: {
diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js
index fdada6b065a2..bd7f3267efd2 100644
--- a/packages/react-reconciler/src/ReactFiberLane.js
+++ b/packages/react-reconciler/src/ReactFiberLane.js
@@ -27,6 +27,7 @@ import {
   transitionLaneExpirationMs,
   retryLaneExpirationMs,
   disableLegacyMode,
+  enableDefaultTransitionIndicator,
 } from 'shared/ReactFeatureFlags';
 import {isDevToolsPresent} from './ReactFiberDevToolsHook';
 import {clz32} from './clz32';
@@ -640,6 +641,10 @@ export function includesOnlySuspenseyCommitEligibleLanes(
   );
 }
 
+export function includesLoadingIndicatorLanes(lanes: Lanes): boolean {
+  return (lanes & (SyncLane | DefaultLane)) !== NoLanes;
+}
+
 export function includesBlockingLane(lanes: Lanes): boolean {
   const SyncDefaultLanes =
     InputContinuousHydrationLane |
@@ -766,6 +771,10 @@ export function createLaneMap<T>(initial: T): LaneMap<T> {
 
 export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
   root.pendingLanes |= updateLane;
+  if (enableDefaultTransitionIndicator) {
+    // Mark that this lane might need a loading indicator to be shown.
+    root.indicatorLanes |= updateLane & TransitionLanes;
+  }
 
   // If there are any suspended transitions, it's possible this new update
   // could unblock them. Clear the suspended lanes so that we can try rendering
@@ -847,6 +856,10 @@ export function markRootFinished(
   root.pingedLanes = NoLanes;
   root.warmLanes = NoLanes;
 
+  if (enableDefaultTransitionIndicator) {
+    root.indicatorLanes &= remainingLanes;
+  }
+
   root.expiredLanes &= remainingLanes;
 
   root.entangledLanes &= remainingLanes;
diff --git a/packages/react-reconciler/src/ReactFiberMutationTracking.js b/packages/react-reconciler/src/ReactFiberMutationTracking.js
index 164ec2c6edd7..cb439bd68fb3 100644
--- a/packages/react-reconciler/src/ReactFiberMutationTracking.js
+++ b/packages/react-reconciler/src/ReactFiberMutationTracking.js
@@ -7,10 +7,23 @@
  * @flow
  */
 
-import {enableViewTransition} from 'shared/ReactFeatureFlags';
+import {
+  enableDefaultTransitionIndicator,
+  enableViewTransition,
+} from 'shared/ReactFeatureFlags';
 
+export let rootMutationContext: boolean = false;
 export let viewTransitionMutationContext: boolean = false;
 
+export function pushRootMutationContext(): void {
+  if (enableDefaultTransitionIndicator) {
+    rootMutationContext = false;
+  }
+  if (enableViewTransition) {
+    viewTransitionMutationContext = false;
+  }
+}
+
 export function pushMutationContext(): boolean {
   if (!enableViewTransition) {
     return false;
@@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
 
 export function popMutationContext(prev: boolean): void {
   if (enableViewTransition) {
+    if (viewTransitionMutationContext) {
+      rootMutationContext = true;
+    }
     viewTransitionMutationContext = prev;
   }
 }
 
 export function trackHostMutation(): void {
+  // This is extremely hot function that must be inlined. Don't add more stuff.
   if (enableViewTransition) {
     viewTransitionMutationContext = true;
+  } else if (enableDefaultTransitionIndicator) {
+    // We only set this if enableViewTransition is not on. Otherwise we track
+    // it on the viewTransitionMutationContext and collect it when we pop
+    // to avoid more than a single operation in this hot path.
+    rootMutationContext = true;
   }
 }
diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js
index cc2a528010e7..e9d107bcb899 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.js
@@ -79,6 +79,9 @@ function FiberRootNode(
   this.pingedLanes = NoLanes;
   this.warmLanes = NoLanes;
   this.expiredLanes = NoLanes;
+  if (enableDefaultTransitionIndicator) {
+    this.indicatorLanes = NoLanes;
+  }
   this.errorRecoveryDisabledLanes = NoLanes;
   this.shellSuspendCounter = 0;
 
@@ -94,6 +97,7 @@ function FiberRootNode(
 
   if (enableDefaultTransitionIndicator) {
     this.onDefaultTransitionIndicator = onDefaultTransitionIndicator;
+    this.pendingIndicator = null;
   }
 
   this.pooledCache = null;
diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js
index 9fc6728cf993..7daed077eee5 100644
--- a/packages/react-reconciler/src/ReactFiberRootScheduler.js
+++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js
@@ -20,6 +20,7 @@ import {
   enableComponentPerformanceTrack,
   enableYieldingBeforePassive,
   enableGestureTransition,
+  enableDefaultTransitionIndicator,
 } from 'shared/ReactFeatureFlags';
 import {
   NoLane,
@@ -80,6 +81,9 @@ import {
 } from './ReactProfilerTimer';
 import {peekEntangledActionLane} from './ReactFiberAsyncAction';
 
+import noop from 'shared/noop';
+import reportGlobalError from 'shared/reportGlobalError';
+
 // A linked list of all the roots with pending work. In an idiomatic app,
 // there's only a single root, but we do support multi root apps, hence this
 // extra complexity. But this module is optimized for the single root case.
@@ -316,8 +320,33 @@ function processRootScheduleInMicrotask() {
     flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
   }
 
-  // Reset Event Transition Lane so that we allocate a new one next time.
-  currentEventTransitionLane = NoLane;
+  if (currentEventTransitionLane !== NoLane) {
+    // Reset Event Transition Lane so that we allocate a new one next time.
+    currentEventTransitionLane = NoLane;
+    startDefaultTransitionIndicatorIfNeeded();
+  }
+}
+
+function startDefaultTransitionIndicatorIfNeeded() {
+  if (!enableDefaultTransitionIndicator) {
+    return;
+  }
+  // Check all the roots if there are any new indicators needed.
+  let root = firstScheduledRoot;
+  while (root !== null) {
+    if (root.indicatorLanes !== NoLanes && root.pendingIndicator === null) {
+      // We have new indicator lanes that requires a loading state. Start the
+      // default transition indicator.
+      try {
+        const onDefaultTransitionIndicator = root.onDefaultTransitionIndicator;
+        root.pendingIndicator = onDefaultTransitionIndicator() || noop;
+      } catch (x) {
+        root.pendingIndicator = noop;
+        reportGlobalError(x);
+      }
+    }
+    root = root.next;
+  }
 }
 
 function scheduleTaskForRootDuringMicrotask(
@@ -664,3 +693,12 @@ export function requestTransitionLane(
 export function didCurrentEventScheduleTransition(): boolean {
   return currentEventTransitionLane !== NoLane;
 }
+
+export function markIndicatorHandled(root: FiberRoot): void {
+  if (enableDefaultTransitionIndicator) {
+    // The current transition event rendered a synchronous loading state.
+    // Clear it from the indicator lanes. We don't need to show a separate
+    // loading state for this lane.
+    root.indicatorLanes &= ~currentEventTransitionLane;
+  }
+}
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index c62691aa1f8c..cd5c1c146852 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -52,11 +52,14 @@ import {
   enableThrottledScheduling,
   enableViewTransition,
   enableGestureTransition,
+  enableDefaultTransitionIndicator,
 } from 'shared/ReactFeatureFlags';
 import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
 import ReactSharedInternals from 'shared/ReactSharedInternals';
 import is from 'shared/objectIs';
 
+import reportGlobalError from 'shared/reportGlobalError';
+
 import {
   // Aliased because `act` will override and push to an internal queue
   scheduleCallback as Scheduler_scheduleCallback,
@@ -3593,6 +3596,33 @@ function flushLayoutEffects(): void {
   const finishedWork = pendingFinishedWork;
   const lanes = pendingEffectsLanes;
 
+  if (enableDefaultTransitionIndicator) {
+    const cleanUpIndicator = root.pendingIndicator;
+    if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) {
+      // We have now committed all Transitions that needed the default indicator
+      // so we can now run the clean up function. We do this in the layout phase
+      // so it has the same semantics as if you did it with a useLayoutEffect or
+      // if it was reset automatically with useOptimistic.
+      const prevTransition = ReactSharedInternals.T;
+      ReactSharedInternals.T = null;
+      const previousPriority = getCurrentUpdatePriority();
+      setCurrentUpdatePriority(DiscreteEventPriority);
+      const prevExecutionContext = executionContext;
+      executionContext |= CommitContext;
+      root.pendingIndicator = null;
+      try {
+        cleanUpIndicator();
+      } catch (x) {
+        reportGlobalError(x);
+      } finally {
+        // Reset the priority to the previous non-sync value.
+        executionContext = prevExecutionContext;
+        setCurrentUpdatePriority(previousPriority);
+        ReactSharedInternals.T = prevTransition;
+      }
+    }
+  }
+
   const subtreeHasLayoutEffects =
     (finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
   const rootHasLayoutEffect = (finishedWork.flags & LayoutMask) !== NoFlags;
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index b364d4ec47ab..25840749a1ad 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -248,6 +248,7 @@ type BaseFiberRootProperties = {
   pingedLanes: Lanes,
   warmLanes: Lanes,
   expiredLanes: Lanes,
+  indicatorLanes: Lanes, // enableDefaultTransitionIndicator only
   errorRecoveryDisabledLanes: Lanes,
   shellSuspendCounter: number,
 
@@ -280,7 +281,9 @@ type BaseFiberRootProperties = {
     errorInfo: {+componentStack?: ?string},
   ) => void,
 
+  // enableDefaultTransitionIndicator only
   onDefaultTransitionIndicator: () => void | (() => void),
+  pendingIndicator: null | (() => void),
 
   formState: ReactFormState<any, any> | null,
 
diff --git a/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js b/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
new file mode 100644
index 000000000000..bc5c3ec66951
--- /dev/null
+++ b/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
@@ -0,0 +1,358 @@
+/**
+ * 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.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let use;
+let useOptimistic;
+let useState;
+let useTransition;
+let useDeferredValue;
+let assertLog;
+let waitForPaint;
+
+describe('ReactDefaultTransitionIndicator', () => {
+  beforeEach(() => {
+    jest.resetModules();
+
+    React = require('react');
+    ReactNoop = require('react-noop-renderer');
+    Scheduler = require('scheduler');
+    const InternalTestUtils = require('internal-test-utils');
+    act = InternalTestUtils.act;
+    assertLog = InternalTestUtils.assertLog;
+    waitForPaint = InternalTestUtils.waitForPaint;
+    use = React.use;
+    useOptimistic = React.useOptimistic;
+    useState = React.useState;
+    useTransition = React.useTransition;
+    useDeferredValue = React.useDeferredValue;
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while a transition is on-going', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    function App() {
+      return use(promise);
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App />);
+      });
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is a sync mutation', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setState] = useState('');
+      update = setState;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      // TODO: This should not require a discrete update ideally but work for default too.
+      ReactNoop.discreteUpdates(() => {
+        update('Loading...');
+      });
+      React.startTransition(() => {
+        update('');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an optimistic update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setOptimistic] = useOptimistic('');
+      update = setOptimistic;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      React.startTransition(() => {
+        update('Loading...');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an isPending update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let start;
+    function App({children}) {
+      const [isPending, startTransition] = useTransition();
+      start = startTransition;
+      return (
+        <div>
+          {isPending ? 'Loading...' : ''}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      start(() => {
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while an async transition is ongoing', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    let start;
+    function App() {
+      const [, startTransition] = useTransition();
+      start = startTransition;
+      return 'Hi';
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      root.render(<App />);
+    });
+
+    assertLog([]);
+
+    await act(() => {
+      // Start an async action but we haven't called setState yet
+      // TODO: This should ideally work with React.startTransition too but we don't know the root.
+      start(() => promise);
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hi');
+  });
+
+  it('should not trigger for useDeferredValue (sync)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      root.render(<App value="Hello" />);
+      await waitForPaint(['Hi']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+
+    assertLog([]);
+
+    await act(async () => {
+      root.render(<App value="Bye" />);
+      await waitForPaint(['Hello']);
+      expect(root).toMatchRenderedOutput('Hello');
+    });
+
+    assertLog(['Bye']);
+
+    expect(root).toMatchRenderedOutput('Bye');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('should not trigger for useDeferredValue (transition)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      React.startTransition(() => {
+        root.render(<App value="Hello" />);
+      });
+      await waitForPaint(['start', 'Hi', 'stop']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+});


Reviews:
user: login: rickhanlonii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:02:10Z
user: login: rickhanlonii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:03:43Z
user: login: rickhanlonii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:04:34Z
user: login: rickhanlonii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:11:34Z
user: login: rickhanlonii
body: siiiick
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T18:11:40Z
user: login: rickhanlonii
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:12:55Z
user: login: acdlite
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T19:37:46Z

Review comments:
diff_hunk: @@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
 
 export function popMutationContext(prev: boolean): void {
   if (enableViewTransition) {
+    if (viewTransitionMutationContext) {
path: packages/react-reconciler/src/ReactFiberMutationTracking.js
user: login: rickhanlonii
body: What's the thinking here?
created_at: 2025-05-13T18:02:10Z
author_association: MEMBER
diff_hunk: @@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
 
 export function popMutationContext(prev: boolean): void {
   if (enableViewTransition) {
+    if (viewTransitionMutationContext) {
path: packages/react-reconciler/src/ReactFiberMutationTracking.js
user: login: rickhanlonii
body: Oh, this is the thing below where we track it with a `viewTransitionMutationContext` to avoid multiple operations in the hot path.
created_at: 2025-05-13T18:03:43Z
author_association: MEMBER
diff_hunk: @@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
 
 export function popMutationContext(prev: boolean): void {
   if (enableViewTransition) {
+    if (viewTransitionMutationContext) {
path: packages/react-reconciler/src/ReactFiberMutationTracking.js
user: login: rickhanlonii
body: Do you want a `enableDefaultTransitionIndicator` check here too then?
created_at: 2025-05-13T18:04:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,358 @@
+/**
+ * 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.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let use;
+let useOptimistic;
+let useState;
+let useTransition;
+let useDeferredValue;
+let assertLog;
+let waitForPaint;
+
+describe('ReactDefaultTransitionIndicator', () => {
+  beforeEach(() => {
+    jest.resetModules();
+
+    React = require('react');
+    ReactNoop = require('react-noop-renderer');
+    Scheduler = require('scheduler');
+    const InternalTestUtils = require('internal-test-utils');
+    act = InternalTestUtils.act;
+    assertLog = InternalTestUtils.assertLog;
+    waitForPaint = InternalTestUtils.waitForPaint;
+    use = React.use;
+    useOptimistic = React.useOptimistic;
+    useState = React.useState;
+    useTransition = React.useTransition;
+    useDeferredValue = React.useDeferredValue;
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while a transition is on-going', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    function App() {
+      return use(promise);
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App />);
+      });
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is a sync mutation', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setState] = useState('');
+      update = setState;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      // TODO: This should not require a discrete update ideally but work for default too.
+      ReactNoop.discreteUpdates(() => {
+        update('Loading...');
+      });
+      React.startTransition(() => {
+        update('');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an optimistic update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setOptimistic] = useOptimistic('');
+      update = setOptimistic;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      React.startTransition(() => {
+        update('Loading...');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an isPending update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let start;
+    function App({children}) {
+      const [isPending, startTransition] = useTransition();
+      start = startTransition;
+      return (
+        <div>
+          {isPending ? 'Loading...' : ''}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      start(() => {
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while an async transition is ongoing', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    let start;
+    function App() {
+      const [, startTransition] = useTransition();
+      start = startTransition;
+      return 'Hi';
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      root.render(<App />);
+    });
+
+    assertLog([]);
+
+    await act(() => {
+      // Start an async action but we haven't called setState yet
+      // TODO: This should ideally work with React.startTransition too but we don't know the root.
+      start(() => promise);
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hi');
+  });
+
+  it('should not trigger for useDeferredValue (sync)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      root.render(<App value="Hello" />);
+      await waitForPaint(['Hi']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+
+    assertLog([]);
+
+    await act(async () => {
+      root.render(<App value="Bye" />);
+      await waitForPaint(['Hello']);
+      expect(root).toMatchRenderedOutput('Hello');
+    });
+
+    assertLog(['Bye']);
+
+    expect(root).toMatchRenderedOutput('Bye');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('should not trigger for useDeferredValue (transition)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      React.startTransition(() => {
+        root.render(<App value="Hello" />);
+      });
+      await waitForPaint(['start', 'Hi', 'stop']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+});
path: packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
user: login: rickhanlonii
body: How about a test that it cleans up if the transitions errors (both with the hook, which would trigger an error boundary, and isomorphic which would not)?
created_at: 2025-05-13T18:11:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,358 @@
+/**
+ * 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.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let use;
+let useOptimistic;
+let useState;
+let useTransition;
+let useDeferredValue;
+let assertLog;
+let waitForPaint;
+
+describe('ReactDefaultTransitionIndicator', () => {
+  beforeEach(() => {
+    jest.resetModules();
+
+    React = require('react');
+    ReactNoop = require('react-noop-renderer');
+    Scheduler = require('scheduler');
+    const InternalTestUtils = require('internal-test-utils');
+    act = InternalTestUtils.act;
+    assertLog = InternalTestUtils.assertLog;
+    waitForPaint = InternalTestUtils.waitForPaint;
+    use = React.use;
+    useOptimistic = React.useOptimistic;
+    useState = React.useState;
+    useTransition = React.useTransition;
+    useDeferredValue = React.useDeferredValue;
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while a transition is on-going', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    function App() {
+      return use(promise);
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App />);
+      });
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is a sync mutation', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setState] = useState('');
+      update = setState;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      // TODO: This should not require a discrete update ideally but work for default too.
+      ReactNoop.discreteUpdates(() => {
+        update('Loading...');
+      });
+      React.startTransition(() => {
+        update('');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an optimistic update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let update;
+    function App({children}) {
+      const [state, setOptimistic] = useOptimistic('');
+      update = setOptimistic;
+      return (
+        <div>
+          {state}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      React.startTransition(() => {
+        update('Loading...');
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('does not trigger the default indicator if there is an isPending update', async () => {
+    const promiseA = Promise.resolve('Hi');
+    let resolveB;
+    const promiseB = new Promise(r => (resolveB = r));
+    let start;
+    function App({children}) {
+      const [isPending, startTransition] = useTransition();
+      start = startTransition;
+      return (
+        <div>
+          {isPending ? 'Loading...' : ''}
+          {children}
+        </div>
+      );
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      React.startTransition(() => {
+        root.render(<App>{promiseA}</App>);
+      });
+    });
+
+    assertLog(['start', 'stop']);
+
+    expect(root).toMatchRenderedOutput(<div>Hi</div>);
+
+    await act(() => {
+      start(() => {
+        root.render(<App>{promiseB}</App>);
+      });
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
+
+    await act(async () => {
+      await resolveB('Hello');
+    });
+
+    assertLog([]);
+
+    expect(root).toMatchRenderedOutput(<div>Hello</div>);
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('triggers the default indicator while an async transition is ongoing', async () => {
+    let resolve;
+    const promise = new Promise(r => (resolve = r));
+    let start;
+    function App() {
+      const [, startTransition] = useTransition();
+      start = startTransition;
+      return 'Hi';
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(() => {
+      root.render(<App />);
+    });
+
+    assertLog([]);
+
+    await act(() => {
+      // Start an async action but we haven't called setState yet
+      // TODO: This should ideally work with React.startTransition too but we don't know the root.
+      start(() => promise);
+    });
+
+    assertLog(['start']);
+
+    await act(async () => {
+      await resolve('Hello');
+    });
+
+    assertLog(['stop']);
+
+    expect(root).toMatchRenderedOutput('Hi');
+  });
+
+  it('should not trigger for useDeferredValue (sync)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      root.render(<App value="Hello" />);
+      await waitForPaint(['Hi']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+
+    assertLog([]);
+
+    await act(async () => {
+      root.render(<App value="Bye" />);
+      await waitForPaint(['Hello']);
+      expect(root).toMatchRenderedOutput('Hello');
+    });
+
+    assertLog(['Bye']);
+
+    expect(root).toMatchRenderedOutput('Bye');
+  });
+
+  // @gate enableDefaultTransitionIndicator
+  it('should not trigger for useDeferredValue (transition)', async () => {
+    function Text({text}) {
+      Scheduler.log(text);
+      return text;
+    }
+    function App({value}) {
+      const deferredValue = useDeferredValue(value, 'Hi');
+      return <Text text={deferredValue} />;
+    }
+
+    const root = ReactNoop.createRoot({
+      onDefaultTransitionIndicator() {
+        Scheduler.log('start');
+        return () => {
+          Scheduler.log('stop');
+        };
+      },
+    });
+    await act(async () => {
+      React.startTransition(() => {
+        root.render(<App value="Hello" />);
+      });
+      await waitForPaint(['start', 'Hi', 'stop']);
+      expect(root).toMatchRenderedOutput('Hi');
+    });
+
+    assertLog(['Hello']);
+
+    expect(root).toMatchRenderedOutput('Hello');
+  });
+});
path: packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
user: login: rickhanlonii
body: And if these can setState, should we have tests for that?
created_at: 2025-05-13T18:12:55Z
author_association: MEMBER

PR comments:
user: login: react-sizebot
created_at: 2025-05-09T03:03:17Z
body: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-stable;
-->




Comparing: 0cac32d60dd4482b27fe8a54dffbabceb22c6272...5ac399621f5f3da15778b92b47a7f0c12a8ea20b

## Critical size changes

Includes critical production bundles, as well as any change greater than 2%:


  | Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip |
  | ---- | --- | ---- | ------- | -------- | --------- | ------------ |
| [oss-stable/react-dom/cjs/react-dom.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-stable/react-dom/cjs/react-dom.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **=** | 6.68 kB | 6.68 kB | +0.05% | 1.83 kB | 1.83 kB
| [oss-stable/react-dom/cjs/react-dom-client.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-stable/react-dom/cjs/react-dom-client.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.02%** | 528.08 kB | 528.17 kB | = | 93.17 kB | 93.15 kB
| [oss-experimental/react-dom/cjs/react-dom.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-dom/cjs/react-dom.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **=** | 6.69 kB | 6.69 kB | +0.05% | 1.83 kB | 1.83 kB
| [oss-experimental/react-dom/cjs/react-dom-client.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-dom/cjs/react-dom-client.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.28%** | 646.43 kB | 648.27 kB | +0.24% | 113.73 kB | 114.00 kB
| [facebook-www/ReactDOM-prod.classic.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/facebook-www/ReactDOM-prod.classic.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.03%** | 674.14 kB | 674.36 kB | +0.03% | 118.40 kB | 118.44 kB
| [facebook-www/ReactDOM-prod.modern.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/facebook-www/ReactDOM-prod.modern.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.03%** | 664.42 kB | 664.64 kB | +0.02% | 116.80 kB | 116.82 kB

## Significant size changes

Includes any change greater than 0.2%:


<details>
<summary>Expand to show</summary>

  | Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip |
  | ---- | --- | ---- | ------- | -------- | --------- | ------------ |
| [oss-experimental/react-art/cjs/react-art.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-art/cjs/react-art.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.54%** | 348.49 kB | 350.36 kB | +0.55% | 58.77 kB | 59.09 kB
| [oss-experimental/react-reconciler/cjs/react-reconciler.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-reconciler/cjs/react-reconciler.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.40%** | 475.83 kB | 477.74 kB | +0.44% | 75.93 kB | 76.27 kB
| [oss-experimental/react-reconciler/cjs/react-reconciler.profiling.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-reconciler/cjs/react-reconciler.profiling.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.36%** | 535.88 kB | 537.79 kB | +0.46% | 83.97 kB | 84.36 kB
| [oss-experimental/react-art/cjs/react-art.development.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-art/cjs/react-art.development.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.31%** | 670.01 kB | 672.06 kB | +0.29% | 105.49 kB | 105.79 kB
| [oss-experimental/react-dom/cjs/react-dom-client.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-dom/cjs/react-dom-client.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.28%** | 646.43 kB | 648.27 kB | +0.24% | 113.73 kB | 114.00 kB
| [oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.28%** | 660.84 kB | 662.68 kB | +0.24% | 117.27 kB | 117.55 kB
| [oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.26%** | 708.57 kB | 710.41 kB | +0.25% | 122.76 kB | 123.07 kB
| [oss-experimental/react-reconciler/cjs/react-reconciler.development.js](https://react-builds.vercel.app/commits/5ac399621f5f3da15778b92b47a7f0c12a8ea20b/files/oss-experimental/react-reconciler/cjs/react-reconciler.development.js?compare=0cac32d60dd4482b27fe8a54dffbabceb22c6272) | **+0.25%** | 795.41 kB | 797.42 kB | +0.34% | 123.82 kB | 124.24 kB
</details>


<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 5ac399621f5f3da15778b92b47a7f0c12a8ea20b
</p>

author_association: NONE
user: login: rickhanlonii
created_at: 2025-05-13T17:56:29Z
body: > a sync update gets associated with the current transition and it is assumed to be rendering the loading state

Does this also mean that we can associate these in the performance tracks? I've found myself explaining that "the transition starts, then there is a sync render to render the pending state, and then the transition is rendered" a ton when reviewing profiles for folks. Would be nice if this was shown inline somehow, like labeling the render "Render Loading" or something.
author_association: MEMBER