Implement Navigation API backed default indicator for DOM renderer (#33162)

Stacked on #33160.

By default, if `onDefaultTransitionIndicator` is not overridden, this
will trigger a fake Navigation event using the Navigation API. This is
intercepted to create an on-going navigation until we complete the
Transition. Basically each default Transition is simulated as a
Navigation.

This triggers the native browser loading state (in Chrome at least). So
now by default the browser spinner spins during a Transition if no other
loading state is provided. Firefox and Safari hasn't shipped Navigation
API yet and even in the flag Safari has, it doesn't actually trigger the
native loading state.

To ensures that you can still use other Navigations concurrently, we
don't start our fake Navigation if there's one on-going already.
Similarly if our fake Navigation gets interrupted by another. We wait
for on-going ones to finish and then start a new fake one if we're
supposed to be still pending.

There might be other routers on the page that might listen to intercept
Navigation Events. Typically you'd expect them not to trigger a refetch
when navigating to the same state. However, if they want to detect this
we provide the `"react-transition"` string in the `info` field for this
purpose.
This commit is contained in:
Sebastian Markbåge 2025-05-13 16:00:38 -04:00 committed by GitHub
parent b480865db0
commit 59440424d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 240 additions and 6 deletions

View File

@ -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',
},
};

View File

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

View File

@ -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>
@ -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>
);

View File

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

View File

@ -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;

View File

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

View File

@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',

View File

@ -33,6 +33,7 @@ module.exports = {
globalThis: 'readonly',
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

View File

@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',

View File

@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',

View File

@ -35,6 +35,7 @@ module.exports = {
FinalizationRegistry: 'readonly',
ScrollTimeline: 'readonly',
navigation: 'readonly',
// Vendor specific
MSApp: 'readonly',