@samitouri / QOS-React / commits / 59440424d0

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.

Sebastian Markbåge committed May 13, 2025 at 16:00 UTC 59440424d05360cca32ca6f46ae33661f70d43e2
11 files changed +240 -6
.eslintrc.js
+2
@@ -579,6 +579,7 @@ module.exports = {
579 JSONValue: 'readonly',
580 JSResourceReference: 'readonly',
581 MouseEventHandler: 'readonly',
582 + NavigateEvent: 'readonly',
583 PropagationPhases: 'readonly',
584 PropertyDescriptor: 'readonly',
585 React$AbstractComponent: 'readonly',
@@ -634,5 +635,6 @@ module.exports = {
635 AsyncLocalStorage: 'readonly',
636 async_hooks: 'readonly',
637 globalThis: 'readonly',
638 + navigation: 'readonly',
639 },
640 };
fixtures/flight/src/actions.js
+8
@@ -2,7 +2,13 @@
2
3 import {setServerState} from './ServerState.js';
4
5 +async function sleep(ms) {
6 + return new Promise(resolve => setTimeout(resolve, ms));
7 +}
8 +
9 export async function like() {
10 + // Test loading state
11 + await sleep(1000);
12 setServerState('Liked!');
13 return new Promise((resolve, reject) => resolve('Liked'));
14 }
@@ -20,5 +26,7 @@ export async function greet(formData) {
26 }
27
28 export async function increment(n) {
29 + // Test loading state
30 + await sleep(1000);
31 return n + 1;
32 }
fixtures/view-transition/src/components/Page.js
+11 -1
@@ -18,6 +18,10 @@ import './Page.css';
18
19 import transitions from './Transitions.module.css';
20
21 +async function sleep(ms) {
22 + return new Promise(resolve => setTimeout(resolve, ms));
23 +}
24 +
25 const a = (
26 <div key="a">
27 <ViewTransition>
@@ -106,7 +110,13 @@ export default function Page({url, navigate}) {
110 document.body
111 )
112 ) : (
109 - <button onClick={() => startTransition(() => setShowModal(true))}>
113 + <button
114 + onClick={() =>
115 + startTransition(async () => {
116 + setShowModal(true);
117 + await sleep(2000);
118 + })
119 + }>
120 Show Modal
121 </button>
122 );
packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js new
+89
@@ -0,0 +1,89 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export function defaultOnDefaultTransitionIndicator(): void | (() => void) {
11 + if (typeof navigation !== 'object') {
12 + // If the Navigation API is not available, then this is a noop.
13 + return;
14 + }
15 +
16 + let isCancelled = false;
17 + let pendingResolve: null | (() => void) = null;
18 +
19 + function handleNavigate(event: NavigateEvent) {
20 + if (event.canIntercept && event.info === 'react-transition') {
21 + event.intercept({
22 + handler() {
23 + return new Promise(resolve => (pendingResolve = resolve));
24 + },
25 + focusReset: 'manual',
26 + scroll: 'manual',
27 + });
28 + }
29 + }
30 +
31 + function handleNavigateComplete() {
32 + if (pendingResolve !== null) {
33 + // If this was not our navigation completing, we were probably cancelled.
34 + // We'll start a new one below.
35 + pendingResolve();
36 + pendingResolve = null;
37 + }
38 + if (!isCancelled) {
39 + // Some other navigation completed but we should still be running.
40 + // Start another fake one to keep the loading indicator going.
41 + startFakeNavigation();
42 + }
43 + }
44 +
45 + // $FlowFixMe
46 + navigation.addEventListener('navigate', handleNavigate);
47 + // $FlowFixMe
48 + navigation.addEventListener('navigatesuccess', handleNavigateComplete);
49 + // $FlowFixMe
50 + navigation.addEventListener('navigateerror', handleNavigateComplete);
51 +
52 + function startFakeNavigation() {
53 + if (isCancelled) {
54 + // We already stopped this Transition.
55 + return;
56 + }
57 + if (navigation.transition) {
58 + // There is an on-going Navigation already happening. Let's wait for it to
59 + // finish before starting our fake one.
60 + return;
61 + }
62 + // Trigger a fake navigation to the same page
63 + const currentEntry = navigation.currentEntry;
64 + if (currentEntry && currentEntry.url != null) {
65 + navigation.navigate(currentEntry.url, {
66 + state: currentEntry.getState(),
67 + info: 'react-transition', // indicator to routers to ignore this navigation
68 + history: 'replace',
69 + });
70 + }
71 + }
72 +
73 + // Delay the start a bit in case this is a fast navigation.
74 + setTimeout(startFakeNavigation, 100);
75 +
76 + return function () {
77 + isCancelled = true;
78 + // $FlowFixMe
79 + navigation.removeEventListener('navigate', handleNavigate);
80 + // $FlowFixMe
81 + navigation.removeEventListener('navigatesuccess', handleNavigateComplete);
82 + // $FlowFixMe
83 + navigation.removeEventListener('navigateerror', handleNavigateComplete);
84 + if (pendingResolve !== null) {
85 + pendingResolve();
86 + pendingResolve = null;
87 + }
88 + };
89 +}
packages/react-dom/src/client/ReactDOMRoot.js
+1 -5
@@ -95,13 +95,9 @@ import {
95 defaultOnCaughtError,
96 defaultOnRecoverableError,
97 } from 'react-reconciler/src/ReactFiberReconciler';
98 +import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator';
99 import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
100
100 -function defaultOnDefaultTransitionIndicator(): void | (() => void) {
101 - // TODO: Implement the default
102 - return function () {};
103 -}
104 -
101 // $FlowFixMe[missing-this-annot]
102 function ReactDOMRoot(internalRoot: FiberRoot) {
103 this._internalRoot = internalRoot;
scripts/flow/environment.js
+124
@@ -429,3 +429,127 @@ declare const Bun: {
429 input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
430 ): number,
431 };
432 +
433 +// Navigation API
434 +
435 +declare const navigation: Navigation;
436 +
437 +interface NavigationResult {
438 + committed: Promise<NavigationHistoryEntry>;
439 + finished: Promise<NavigationHistoryEntry>;
440 +}
441 +
442 +declare class Navigation extends EventTarget {
443 + entries(): NavigationHistoryEntry[];
444 + +currentEntry: NavigationHistoryEntry | null;
445 + updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
446 + +transition: NavigationTransition | null;
447 +
448 + +canGoBack: boolean;
449 + +canGoForward: boolean;
450 +
451 + navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
452 + reload(options?: NavigationReloadOptions): NavigationResult;
453 +
454 + traverseTo(key: string, options?: NavigationOptions): NavigationResult;
455 + back(options?: NavigationOptions): NavigationResult;
456 + forward(options?: NavigationOptions): NavigationResult;
457 +
458 + onnavigate: ((this: Navigation, ev: NavigateEvent) => any) | null;
459 + onnavigatesuccess: ((this: Navigation, ev: Event) => any) | null;
460 + onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any) | null;
461 + oncurrententrychange:
462 + | ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)
463 + | null;
464 +
465 + // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
466 +}
467 +
468 +declare class NavigationTransition {
469 + +navigationType: NavigationTypeString;
470 + +from: NavigationHistoryEntry;
471 + +finished: Promise<void>;
472 +}
473 +
474 +interface NavigationHistoryEntryEventMap {
475 + dispose: Event;
476 +}
477 +
478 +interface NavigationHistoryEntry extends EventTarget {
479 + +key: string;
480 + +id: string;
481 + +url: string | null;
482 + +index: number;
483 + +sameDocument: boolean;
484 +
485 + getState(): mixed;
486 +
487 + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any) | null;
488 +
489 + // TODO: Implement addEventListener overrides. Doesn't seem like Flow supports this.
490 +}
491 +
492 +declare var NavigationHistoryEntry: {
493 + prototype: NavigationHistoryEntry,
494 + new(): NavigationHistoryEntry,
495 +};
496 +
497 +type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse';
498 +
499 +interface NavigationUpdateCurrentEntryOptions {
500 + state: mixed;
501 +}
502 +
503 +interface NavigationOptions {
504 + info?: mixed;
505 +}
506 +
507 +interface NavigationNavigateOptions extends NavigationOptions {
508 + state?: mixed;
509 + history?: 'auto' | 'push' | 'replace';
510 +}
511 +
512 +interface NavigationReloadOptions extends NavigationOptions {
513 + state?: mixed;
514 +}
515 +
516 +declare class NavigationCurrentEntryChangeEvent extends Event {
517 + constructor(type: string, eventInit?: any): void;
518 +
519 + +navigationType: NavigationTypeString | null;
520 + +from: NavigationHistoryEntry;
521 +}
522 +
523 +declare class NavigateEvent extends Event {
524 + constructor(type: string, eventInit?: any): void;
525 +
526 + +navigationType: NavigationTypeString;
527 + +canIntercept: boolean;
528 + +userInitiated: boolean;
529 + +hashChange: boolean;
530 + +hasUAVisualTransition: boolean;
531 + +destination: NavigationDestination;
532 + +signal: AbortSignal;
533 + +formData: FormData | null;
534 + +downloadRequest: string | null;
535 + +info?: mixed;
536 +
537 + intercept(options?: NavigationInterceptOptions): void;
538 + scroll(): void;
539 +}
540 +
541 +interface NavigationInterceptOptions {
542 + handler?: () => Promise<void>;
543 + focusReset?: 'after-transition' | 'manual';
544 + scroll?: 'after-transition' | 'manual';
545 +}
546 +
547 +declare class NavigationDestination {
548 + +url: string;
549 + +key: string | null;
550 + +id: string | null;
551 + +index: number;
552 + +sameDocument: boolean;
553 +
554 + getState(): mixed;
555 +}
scripts/rollup/validate/eslintrc.cjs.js
+1
@@ -35,6 +35,7 @@ module.exports = {
35 FinalizationRegistry: 'readonly',
36
37 ScrollTimeline: 'readonly',
38 + navigation: 'readonly',
39
40 // Vendor specific
41 MSApp: 'readonly',
scripts/rollup/validate/eslintrc.cjs2015.js
+1
@@ -33,6 +33,7 @@ module.exports = {
33 globalThis: 'readonly',
34 FinalizationRegistry: 'readonly',
35 ScrollTimeline: 'readonly',
36 + navigation: 'readonly',
37 // Vendor specific
38 MSApp: 'readonly',
39 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
scripts/rollup/validate/eslintrc.esm.js
+1
@@ -35,6 +35,7 @@ module.exports = {
35 FinalizationRegistry: 'readonly',
36
37 ScrollTimeline: 'readonly',
38 + navigation: 'readonly',
39
40 // Vendor specific
41 MSApp: 'readonly',
scripts/rollup/validate/eslintrc.fb.js
+1
@@ -35,6 +35,7 @@ module.exports = {
35 FinalizationRegistry: 'readonly',
36
37 ScrollTimeline: 'readonly',
38 + navigation: 'readonly',
39
40 // Vendor specific
41 MSApp: 'readonly',
scripts/rollup/validate/eslintrc.rn.js
+1
@@ -35,6 +35,7 @@ module.exports = {
35 FinalizationRegistry: 'readonly',
36
37 ScrollTimeline: 'readonly',
38 + navigation: 'readonly',
39
40 // Vendor specific
41 MSApp: 'readonly',