Skip to content

Implement Navigation API backed default indicator for DOM renderer #33162

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 3 commits into from
May 13, 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 () => {
setShowModal(true);
await sleep(2000);
})
}>
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do const setTimeout = window.setTimeout; in the module scope so we're more likely to have access to a non-patched timeout (like we do in Scheduler).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't really know if it's better or worse to have a patched one. Depends on use case and up to the patcher to figure out. Arguably for priority purposes it is better to have it be idle.


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
124 changes: 124 additions & 0 deletions scripts/flow/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,127 @@ declare const Bun: {
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
): number,
};

// Navigation API

declare const navigation: Navigation;

interface NavigationResult {
committed: Promise<NavigationHistoryEntry>;
finished: Promise<NavigationHistoryEntry>;
}

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<void>;
}

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<void>;
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;
}
1 change: 1 addition & 0 deletions scripts/rollup/validate/eslintrc.cjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',

ScrollTimeline: 'readonly',
navigation: 'readonly',

// Vendor specific
MSApp: 'readonly',
Expand Down
1 change: 1 addition & 0 deletions scripts/rollup/validate/eslintrc.cjs2015.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module.exports = {
globalThis: 'readonly',
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
Expand Down
1 change: 1 addition & 0 deletions scripts/rollup/validate/eslintrc.esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',

ScrollTimeline: 'readonly',
navigation: 'readonly',

// Vendor specific
MSApp: 'readonly',
Expand Down
1 change: 1 addition & 0 deletions scripts/rollup/validate/eslintrc.fb.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',

ScrollTimeline: 'readonly',
navigation: 'readonly',

// Vendor specific
MSApp: 'readonly',
Expand Down
1 change: 1 addition & 0 deletions scripts/rollup/validate/eslintrc.rn.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',

ScrollTimeline: 'readonly',
navigation: 'readonly',

// Vendor specific
MSApp: 'readonly',
Expand Down
Loading