Skip to content

[pull] main from facebook:main #148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ module.exports = {
JSONValue: 'readonly',
JSResourceReference: 'readonly',
MouseEventHandler: 'readonly',
NavigateEvent: 'readonly',
PropagationPhases: 'readonly',
PropertyDescriptor: 'readonly',
React$AbstractComponent: 'readonly',
Expand Down Expand Up @@ -634,5 +635,6 @@ module.exports = {
AsyncLocalStorage: 'readonly',
async_hooks: 'readonly',
globalThis: 'readonly',
navigation: 'readonly',
},
};
8 changes: 8 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand All @@ -20,5 +26,7 @@ export async function greet(formData) {
}

export async function increment(n) {
// Test loading state
await sleep(1000);
return n + 1;
}
12 changes: 11 additions & 1 deletion fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div key="a">
<ViewTransition>
Expand Down Expand Up @@ -106,7 +110,13 @@ export default function Page({url, navigate}) {
document.body
)
) : (
<button onClick={() => startTransition(() => setShowModal(true))}>
<button
onClick={() =>
startTransition(async () => {
await sleep(2000);
setShowModal(true);
})
}>
Show Modal
</button>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
6 changes: 1 addition & 5 deletions packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
102 changes: 101 additions & 1 deletion packages/react-reconciler/src/ReactFiberAsyncAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -46,6 +53,21 @@ let currentEntangledLane: Lane = NoLane;
// until the async action scope has completed.
let currentEntangledActionThenable: Thenable<void> | 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<S>(
transition: Transition,
thenable: Thenable<S>,
Expand All @@ -66,6 +88,12 @@ export function entangleAsyncAction<S>(
},
};
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);
Expand All @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -161,3 +193,71 @@ export function peekEntangledActionLane(): Lane {
export function peekEntangledActionThenable(): Thenable<void> | 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;
}
18 changes: 18 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
import type {Fiber, FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane';
import {
includesLoadingIndicatorLanes,
includesOnlySuspenseyCommitEligibleLanes,
includesOnlyViewTransitionEligibleLanes,
} from './ReactFiberLane';
Expand Down Expand Up @@ -60,6 +61,7 @@ import {
enableViewTransition,
enableFragmentRefs,
enableEagerAlternateStateNodeCleanup,
enableDefaultTransitionIndicator,
} from 'shared/ReactFeatureFlags';
import {
FunctionComponent,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2216,6 +2221,7 @@ function commitMutationEffectsOnFiber(
case HostRoot: {
const prevProfilerEffectDuration = pushNestedEffectDurations();

pushRootMutationContext();
if (supportsResources) {
prepareToCommitHoistables();

Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading