diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index dd5173cd83..6fbad9adac 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 b7b17a080b..03d78545d4 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 fdada6b065..bd7f3267ef 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(initial: T): LaneMap { 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 164ec2c6ed..cb439bd68f 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 cc2a528010..e9d107bcb8 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 9fc6728cf9..7daed077ee 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 c62691aa1f..cd5c1c1468 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 b364d4ec47..25840749a1 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 | 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 0000000000..bc5c3ec669 --- /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(); + }); + }); + + 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 ( +
+ {state} + {children} +
+ ); + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(() => { + React.startTransition(() => { + root.render({promiseA}); + }); + }); + + assertLog(['start', 'stop']); + + expect(root).toMatchRenderedOutput(
Hi
); + + 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({promiseB}); + }); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Loading...Hi
); + + await act(async () => { + await resolveB('Hello'); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Hello
); + }); + + // @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 ( +
+ {state} + {children} +
+ ); + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(() => { + React.startTransition(() => { + root.render({promiseA}); + }); + }); + + assertLog(['start', 'stop']); + + expect(root).toMatchRenderedOutput(
Hi
); + + await act(() => { + React.startTransition(() => { + update('Loading...'); + root.render({promiseB}); + }); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Loading...Hi
); + + await act(async () => { + await resolveB('Hello'); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Hello
); + }); + + // @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 ( +
+ {isPending ? 'Loading...' : ''} + {children} +
+ ); + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(() => { + React.startTransition(() => { + root.render({promiseA}); + }); + }); + + assertLog(['start', 'stop']); + + expect(root).toMatchRenderedOutput(
Hi
); + + await act(() => { + start(() => { + root.render({promiseB}); + }); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Loading...Hi
); + + await act(async () => { + await resolveB('Hello'); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput(
Hello
); + }); + + // @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(); + }); + + 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 ; + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(async () => { + root.render(); + await waitForPaint(['Hi']); + expect(root).toMatchRenderedOutput('Hi'); + }); + + assertLog(['Hello']); + + expect(root).toMatchRenderedOutput('Hello'); + + assertLog([]); + + await act(async () => { + root.render(); + 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 ; + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(async () => { + React.startTransition(() => { + root.render(); + }); + await waitForPaint(['start', 'Hi', 'stop']); + expect(root).toMatchRenderedOutput('Hi'); + }); + + assertLog(['Hello']); + + expect(root).toMatchRenderedOutput('Hello'); + }); +});