diff --git a/.eslintrc.js b/.eslintrc.js index 49846c1f5e9bc..c1eb5b34ebe82 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -579,6 +579,7 @@ module.exports = { JSONValue: 'readonly', JSResourceReference: 'readonly', MouseEventHandler: 'readonly', + NavigateEvent: 'readonly', PropagationPhases: 'readonly', PropertyDescriptor: 'readonly', React$AbstractComponent: 'readonly', @@ -634,5 +635,6 @@ module.exports = { AsyncLocalStorage: 'readonly', async_hooks: 'readonly', globalThis: 'readonly', + navigation: 'readonly', }, }; diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index aa19871a9dcbb..0b9b9c315d647 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -2,7 +2,13 @@ import {setServerState} from './ServerState.js'; +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export async function like() { + // Test loading state + await sleep(1000); setServerState('Liked!'); return new Promise((resolve, reject) => resolve('Liked')); } @@ -20,5 +26,7 @@ export async function greet(formData) { } export async function increment(n) { + // Test loading state + await sleep(1000); return n + 1; } diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 9744313c4f5ea..db2cd0aff08c7 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -18,6 +18,10 @@ import './Page.css'; import transitions from './Transitions.module.css'; +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + const a = (
@@ -106,7 +110,13 @@ export default function Page({url, navigate}) { document.body ) ) : ( - ); diff --git a/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js new file mode 100644 index 0000000000000..8f1a32d826c1a --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js @@ -0,0 +1,89 @@ +/** + * 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. + * + * @flow + */ + +export function defaultOnDefaultTransitionIndicator(): void | (() => void) { + if (typeof navigation !== 'object') { + // If the Navigation API is not available, then this is a noop. + return; + } + + let isCancelled = false; + let pendingResolve: null | (() => void) = null; + + function handleNavigate(event: NavigateEvent) { + if (event.canIntercept && event.info === 'react-transition') { + event.intercept({ + handler() { + return new Promise(resolve => (pendingResolve = resolve)); + }, + focusReset: 'manual', + scroll: 'manual', + }); + } + } + + function handleNavigateComplete() { + if (pendingResolve !== null) { + // If this was not our navigation completing, we were probably cancelled. + // We'll start a new one below. + pendingResolve(); + pendingResolve = null; + } + if (!isCancelled) { + // Some other navigation completed but we should still be running. + // Start another fake one to keep the loading indicator going. + startFakeNavigation(); + } + } + + // $FlowFixMe + navigation.addEventListener('navigate', handleNavigate); + // $FlowFixMe + navigation.addEventListener('navigatesuccess', handleNavigateComplete); + // $FlowFixMe + navigation.addEventListener('navigateerror', handleNavigateComplete); + + function startFakeNavigation() { + if (isCancelled) { + // We already stopped this Transition. + return; + } + if (navigation.transition) { + // There is an on-going Navigation already happening. Let's wait for it to + // finish before starting our fake one. + return; + } + // Trigger a fake navigation to the same page + const currentEntry = navigation.currentEntry; + if (currentEntry && currentEntry.url != null) { + navigation.navigate(currentEntry.url, { + state: currentEntry.getState(), + info: 'react-transition', // indicator to routers to ignore this navigation + history: 'replace', + }); + } + } + + // Delay the start a bit in case this is a fast navigation. + setTimeout(startFakeNavigation, 100); + + return function () { + isCancelled = true; + // $FlowFixMe + navigation.removeEventListener('navigate', handleNavigate); + // $FlowFixMe + navigation.removeEventListener('navigatesuccess', handleNavigateComplete); + // $FlowFixMe + navigation.removeEventListener('navigateerror', handleNavigateComplete); + if (pendingResolve !== null) { + pendingResolve(); + pendingResolve = null; + } + }; +} diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index ef2c9ddf193eb..97f4c83515364 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -95,13 +95,9 @@ import { defaultOnCaughtError, defaultOnRecoverableError, } from 'react-reconciler/src/ReactFiberReconciler'; +import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator'; import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags'; -function defaultOnDefaultTransitionIndicator(): void | (() => void) { - // TODO: Implement the default - return function () {}; -} - // $FlowFixMe[missing-this-annot] function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot; diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index dd5173cd83720..6fbad9adac195 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/ReactFiberAsyncAction.js b/packages/react-reconciler/src/ReactFiberAsyncAction.js index f57d7597d640a..9d1194874d46f 100644 --- a/packages/react-reconciler/src/ReactFiberAsyncAction.js +++ b/packages/react-reconciler/src/ReactFiberAsyncAction.js @@ -15,7 +15,10 @@ import type { import type {Lane} from './ReactFiberLane'; import type {Transition} from 'react/src/ReactStartTransition'; -import {requestTransitionLane} from './ReactFiberRootScheduler'; +import { + requestTransitionLane, + ensureScheduleIsScheduled, +} from './ReactFiberRootScheduler'; import {NoLane} from './ReactFiberLane'; import { hasScheduledTransitionWork, @@ -24,9 +27,13 @@ import { import { enableComponentPerformanceTrack, enableProfilerTimer, + enableDefaultTransitionIndicator, } from 'shared/ReactFeatureFlags'; import {clearEntangledAsyncTransitionTypes} from './ReactFiberTransitionTypes'; +import noop from 'shared/noop'; +import reportGlobalError from 'shared/reportGlobalError'; + // If there are multiple, concurrent async actions, they are entangled. All // transition updates that occur while the async action is still in progress // are treated as part of the action. @@ -46,6 +53,21 @@ let currentEntangledLane: Lane = NoLane; // until the async action scope has completed. let currentEntangledActionThenable: Thenable | null = null; +// Track the default indicator for every root. undefined means we haven't +// had any roots registered yet. null means there's more than one callback. +// If there's more than one callback we bailout to not supporting isomorphic +// default indicators. +let isomorphicDefaultTransitionIndicator: + | void + | null + | (() => void | (() => void)) = undefined; +// The clean up function for the currently running indicator. +let pendingIsomorphicIndicator: null | (() => void) = null; +// The number of roots that have pending Transitions that depend on the +// started isomorphic indicator. +let pendingEntangledRoots: number = 0; +let needsIsomorphicIndicator: boolean = false; + export function entangleAsyncAction( transition: Transition, thenable: Thenable, @@ -66,6 +88,12 @@ export function entangleAsyncAction( }, }; currentEntangledActionThenable = entangledThenable; + if (enableDefaultTransitionIndicator) { + needsIsomorphicIndicator = true; + // We'll check if we need a default indicator in a microtask. Ensure + // we have this scheduled even if no root is scheduled. + ensureScheduleIsScheduled(); + } } currentEntangledPendingCount++; thenable.then(pingEngtangledActionScope, pingEngtangledActionScope); @@ -86,6 +114,9 @@ function pingEngtangledActionScope() { } } clearEntangledAsyncTransitionTypes(); + if (pendingEntangledRoots === 0) { + stopIsomorphicDefaultIndicator(); + } if (currentEntangledListeners !== null) { // All the actions have finished. Close the entangled async action scope // and notify all the listeners. @@ -98,6 +129,7 @@ function pingEngtangledActionScope() { currentEntangledListeners = null; currentEntangledLane = NoLane; currentEntangledActionThenable = null; + needsIsomorphicIndicator = false; for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); @@ -161,3 +193,71 @@ export function peekEntangledActionLane(): Lane { export function peekEntangledActionThenable(): Thenable | null { return currentEntangledActionThenable; } + +export function registerDefaultIndicator( + onDefaultTransitionIndicator: () => void | (() => void), +): void { + if (!enableDefaultTransitionIndicator) { + return; + } + if (isomorphicDefaultTransitionIndicator === undefined) { + isomorphicDefaultTransitionIndicator = onDefaultTransitionIndicator; + } else if ( + isomorphicDefaultTransitionIndicator !== onDefaultTransitionIndicator + ) { + isomorphicDefaultTransitionIndicator = null; + // Stop any on-going indicator since it's now ambiguous. + stopIsomorphicDefaultIndicator(); + } +} + +export function startIsomorphicDefaultIndicatorIfNeeded() { + if (!enableDefaultTransitionIndicator) { + return; + } + if (!needsIsomorphicIndicator) { + return; + } + if ( + isomorphicDefaultTransitionIndicator != null && + pendingIsomorphicIndicator === null + ) { + try { + pendingIsomorphicIndicator = + isomorphicDefaultTransitionIndicator() || noop; + } catch (x) { + pendingIsomorphicIndicator = noop; + reportGlobalError(x); + } + } +} + +function stopIsomorphicDefaultIndicator() { + if (!enableDefaultTransitionIndicator) { + return; + } + if (pendingIsomorphicIndicator !== null) { + const cleanup = pendingIsomorphicIndicator; + pendingIsomorphicIndicator = null; + cleanup(); + } +} + +function releaseIsomorphicIndicator() { + if (--pendingEntangledRoots === 0) { + stopIsomorphicDefaultIndicator(); + } +} + +export function hasOngoingIsomorphicIndicator(): boolean { + return pendingIsomorphicIndicator !== null; +} + +export function retainIsomorphicIndicator(): () => void { + pendingEntangledRoots++; + return releaseIsomorphicIndicator; +} + +export function markIsomorphicIndicatorHandled(): void { + needsIsomorphicIndicator = false; +} diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index b7b17a080b172..03d78545d4ea5 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 fdada6b065a29..bd7f3267efd2d 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 164ec2c6edd7d..cb439bd68fb36 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/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index dbba2329cfc54..ab7b1fcdd11e9 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -125,6 +125,7 @@ export { defaultOnRecoverableError, } from './ReactFiberErrorLogger'; import {getLabelForLane, TotalLanes} from 'react-reconciler/src/ReactFiberLane'; +import {registerDefaultIndicator} from './ReactFiberAsyncAction'; type OpaqueRoot = FiberRoot; @@ -259,7 +260,7 @@ export function createContainer( ): OpaqueRoot { const hydrate = false; const initialChildren = null; - return createFiberRoot( + const root = createFiberRoot( containerInfo, tag, hydrate, @@ -274,6 +275,8 @@ export function createContainer( onDefaultTransitionIndicator, transitionCallbacks, ); + registerDefaultIndicator(onDefaultTransitionIndicator); + return root; } export function createHydrationContainer( @@ -323,6 +326,8 @@ export function createHydrationContainer( transitionCallbacks, ); + registerDefaultIndicator(onDefaultTransitionIndicator); + // TODO: Move this to FiberRoot constructor root.context = getContextForSubtree(null); diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index cc2a528010e77..e9d107bcb899c 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 f7c26580bb95a..142812dab3915 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -20,11 +20,13 @@ import { enableComponentPerformanceTrack, enableYieldingBeforePassive, enableGestureTransition, + enableDefaultTransitionIndicator, } from 'shared/ReactFeatureFlags'; import { NoLane, NoLanes, SyncLane, + DefaultLane, getHighestPriorityLane, getNextLanes, includesSyncLane, @@ -78,6 +80,17 @@ import { resetNestedUpdateFlag, syncNestedUpdateFlag, } from './ReactProfilerTimer'; +import {peekEntangledActionLane} from './ReactFiberAsyncAction'; + +import noop from 'shared/noop'; +import reportGlobalError from 'shared/reportGlobalError'; + +import { + startIsomorphicDefaultIndicatorIfNeeded, + hasOngoingIsomorphicIndicator, + retainIsomorphicIndicator, + markIsomorphicIndicatorHandled, +} from './ReactFiberAsyncAction'; // 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 @@ -124,6 +137,20 @@ export function ensureRootIsScheduled(root: FiberRoot): void { // without consulting the schedule. mightHavePendingSyncWork = true; + ensureScheduleIsScheduled(); + + if ( + __DEV__ && + !disableLegacyMode && + ReactSharedInternals.isBatchingLegacy && + root.tag === LegacyRoot + ) { + // Special `act` case: Record whenever a legacy update is scheduled. + ReactSharedInternals.didScheduleLegacyUpdate = true; + } +} + +export function ensureScheduleIsScheduled(): void { // At the end of the current event, go through each of the roots and ensure // there's a task scheduled for each one at the correct priority. if (__DEV__ && ReactSharedInternals.actQueue !== null) { @@ -138,16 +165,6 @@ export function ensureRootIsScheduled(root: FiberRoot): void { scheduleImmediateRootScheduleTask(); } } - - if ( - __DEV__ && - !disableLegacyMode && - ReactSharedInternals.isBatchingLegacy && - root.tag === LegacyRoot - ) { - // Special `act` case: Record whenever a legacy update is scheduled. - ReactSharedInternals.didScheduleLegacyUpdate = true; - } } export function flushSyncWorkOnAllRoots() { @@ -256,8 +273,14 @@ function processRootScheduleInMicrotask() { // render it synchronously anyway. We do this during a popstate event to // preserve the scroll position of the previous page. syncTransitionLanes = currentEventTransitionLane; + } else if (enableDefaultTransitionIndicator) { + // If we have a Transition scheduled by this event it might be paired + // with Default lane scheduled loading indicators. To unbatch it from + // other events later on, flush it early to determine whether it + // rendered an indicator. This ensures that setState in default priority + // event doesn't trigger onDefaultTransitionIndicator. + syncTransitionLanes = DefaultLane; } - currentEventTransitionLane = NoLane; } const currentTime = now(); @@ -315,6 +338,46 @@ function processRootScheduleInMicrotask() { if (!hasPendingCommitEffects()) { flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false); } + + 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 if we need to start an isomorphic indicator like if an async action + // was started. + startIsomorphicDefaultIndicatorIfNeeded(); + // 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. + if (hasOngoingIsomorphicIndicator()) { + // We already have an isomorphic indicator going which means it has to + // also apply to this root since it implies all roots have the same one. + // We retain this indicator so that it keeps going until we commit this + // root. + root.pendingIndicator = retainIsomorphicIndicator(); + } else { + try { + const onDefaultTransitionIndicator = + root.onDefaultTransitionIndicator; + root.pendingIndicator = onDefaultTransitionIndicator() || noop; + } catch (x) { + root.pendingIndicator = noop; + reportGlobalError(x); + } + } + } + root = root.next; + } } function scheduleTaskForRootDuringMicrotask( @@ -645,7 +708,15 @@ export function requestTransitionLane( // over. Our heuristic for that is whenever we enter a concurrent work loop. if (currentEventTransitionLane === NoLane) { // All transitions within the same event are assigned the same lane. - currentEventTransitionLane = claimNextTransitionLane(); + const actionScopeLane = peekEntangledActionLane(); + currentEventTransitionLane = + actionScopeLane !== NoLane + ? // We're inside an async action scope. Reuse the same lane. + actionScopeLane + : // We may or may not be inside an async action scope. If we are, this + // is the first update in that scope. Either way, we need to get a + // fresh transition lane. + claimNextTransitionLane(); } return currentEventTransitionLane; } @@ -653,3 +724,13 @@ 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; + markIsomorphicIndicatorHandled(); + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 95285c936eb93..cd5c1c1468523 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, @@ -356,7 +359,6 @@ import { requestTransitionLane, } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext'; -import {peekEntangledActionLane} from './ReactFiberAsyncAction'; import {logUncaughtError} from './ReactFiberErrorLogger'; import { deleteScheduledGesture, @@ -779,14 +781,7 @@ export function requestUpdateLane(fiber: Fiber): Lane { transition._updatedFibers.add(fiber); } - const actionScopeLane = peekEntangledActionLane(); - return actionScopeLane !== NoLane - ? // We're inside an async action scope. Reuse the same lane. - actionScopeLane - : // We may or may not be inside an async action scope. If we are, this - // is the first update in that scope. Either way, we need to get a - // fresh transition lane. - requestTransitionLane(transition); + return requestTransitionLane(transition); } return eventPriorityToLane(resolveUpdatePriority()); @@ -3601,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 b364d4ec47abb..25840749a1adf 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 0000000000000..fb698e821aa0d --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js @@ -0,0 +1,480 @@ +/** + * 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(() => { + 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 + start(() => promise); + }); + + assertLog(['start']); + + await act(async () => { + await resolve('Hello'); + }); + + assertLog(['stop']); + + expect(root).toMatchRenderedOutput('Hi'); + }); + + // @gate enableDefaultTransitionIndicator + it('triggers the default indicator while an async transition is ongoing (isomorphic)', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + function App() { + 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 + React.startTransition(() => promise); + }); + + assertLog(['start']); + + await act(async () => { + await resolve('Hello'); + }); + + assertLog(['stop']); + + expect(root).toMatchRenderedOutput('Hi'); + }); + + it('does not triggers isomorphic async action default indicator if there are two different ones', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + function App() { + return 'Hi'; + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + // Initialize second root. This is now ambiguous which indicator to use. + ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start2'); + return () => { + Scheduler.log('stop2'); + }; + }, + }); + await act(() => { + root.render(); + }); + + assertLog([]); + + await act(() => { + // Start an async action but we haven't called setState yet + React.startTransition(() => promise); + }); + + assertLog([]); + + await act(async () => { + await resolve('Hello'); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput('Hi'); + }); + + it('does not triggers isomorphic async action default indicator if there is a loading state', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + let update; + function App() { + const [state, setState] = useState(false); + update = setState; + return state ? 'Loading' : 'Hi'; + } + + const root = ReactNoop.createRoot({ + onDefaultTransitionIndicator() { + Scheduler.log('start'); + return () => { + Scheduler.log('stop'); + }; + }, + }); + await act(() => { + root.render(); + }); + + assertLog([]); + + await act(() => { + update(true); + React.startTransition(() => promise.then(() => update(false))); + }); + + assertLog([]); + + expect(root).toMatchRenderedOutput('Loading'); + + await act(async () => { + await resolve('Hello'); + }); + + assertLog([]); + + 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'); + }); +}); diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index c3fe40eeef302..d66ef65d9d318 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -429,3 +429,127 @@ declare const Bun: { input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer, ): number, }; + +// Navigation API + +declare const navigation: Navigation; + +interface NavigationResult { + committed: Promise; + finished: Promise; +} + +declare class Navigation extends EventTarget { + entries(): NavigationHistoryEntry[]; + +currentEntry: NavigationHistoryEntry | null; + updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void; + +transition: NavigationTransition | null; + + +canGoBack: boolean; + +canGoForward: boolean; + + navigate(url: string, options?: NavigationNavigateOptions): NavigationResult; + reload(options?: NavigationReloadOptions): NavigationResult; + + traverseTo(key: string, options?: NavigationOptions): NavigationResult; + back(options?: NavigationOptions): NavigationResult; + forward(options?: NavigationOptions): NavigationResult; + + onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null; + onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null; + onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null; + oncurrententrychange: + | ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) + | null; + + // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this. +} + +declare class NavigationTransition { + +navigationType: NavigationTypeString; + +from: NavigationHistoryEntry; + +finished: Promise; +} + +interface NavigationHistoryEntryEventMap { + dispose: Event; +} + +interface NavigationHistoryEntry extends EventTarget { + +key: string; + +id: string; + +url: string | null; + +index: number; + +sameDocument: boolean; + + getState(): mixed; + + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null; + + // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this. +} + +declare var NavigationHistoryEntry: { + prototype: NavigationHistoryEntry, + new(): NavigationHistoryEntry, +}; + +type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse'; + +interface NavigationUpdateCurrentEntryOptions { + state: mixed; +} + +interface NavigationOptions { + info?: mixed; +} + +interface NavigationNavigateOptions extends NavigationOptions { + state?: mixed; + history?: 'auto' | 'push' | 'replace'; +} + +interface NavigationReloadOptions extends NavigationOptions { + state?: mixed; +} + +declare class NavigationCurrentEntryChangeEvent extends Event { + constructor(type: string, eventInit?: any): void; + + +navigationType: NavigationTypeString | null; + +from: NavigationHistoryEntry; +} + +declare class NavigateEvent extends Event { + constructor(type: string, eventInit?: any): void; + + +navigationType: NavigationTypeString; + +canIntercept: boolean; + +userInitiated: boolean; + +hashChange: boolean; + +hasUAVisualTransition: boolean; + +destination: NavigationDestination; + +signal: AbortSignal; + +formData: FormData | null; + +downloadRequest: string | null; + +info?: mixed; + + intercept(options?: NavigationInterceptOptions): void; + scroll(): void; +} + +interface NavigationInterceptOptions { + handler?: () => Promise; + focusReset?: 'after-transition' | 'manual'; + scroll?: 'after-transition' | 'manual'; +} + +declare class NavigationDestination { + +url: string; + +key: string | null; + +id: string | null; + +index: number; + +sameDocument: boolean; + + getState(): mixed; +} diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 88d17772d7b21..65fd6129904e6 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index 8e87c8dbe0203..fa0b471330f4a 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -33,6 +33,7 @@ module.exports = { globalThis: 'readonly', FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index 8b4bba35796fa..a5ea7afb972e7 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index f0602e79e5074..afee2f1199eb1 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 052edabdc0f92..2420898bebecd 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly',