Skip to content

Commit 5944042

Browse files
authored
Implement Navigation API backed default indicator for DOM renderer (facebook#33162)
Stacked on facebook#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.
1 parent b480865 commit 5944042

File tree

11 files changed

+240
-6
lines changed

11 files changed

+240
-6
lines changed

.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ module.exports = {
579579
JSONValue: 'readonly',
580580
JSResourceReference: 'readonly',
581581
MouseEventHandler: 'readonly',
582+
NavigateEvent: 'readonly',
582583
PropagationPhases: 'readonly',
583584
PropertyDescriptor: 'readonly',
584585
React$AbstractComponent: 'readonly',
@@ -634,5 +635,6 @@ module.exports = {
634635
AsyncLocalStorage: 'readonly',
635636
async_hooks: 'readonly',
636637
globalThis: 'readonly',
638+
navigation: 'readonly',
637639
},
638640
};

fixtures/flight/src/actions.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import {setServerState} from './ServerState.js';
44

5+
async function sleep(ms) {
6+
return new Promise(resolve => setTimeout(resolve, ms));
7+
}
8+
59
export async function like() {
10+
// Test loading state
11+
await sleep(1000);
612
setServerState('Liked!');
713
return new Promise((resolve, reject) => resolve('Liked'));
814
}
@@ -20,5 +26,7 @@ export async function greet(formData) {
2026
}
2127

2228
export async function increment(n) {
29+
// Test loading state
30+
await sleep(1000);
2331
return n + 1;
2432
}

fixtures/view-transition/src/components/Page.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import './Page.css';
1818

1919
import transitions from './Transitions.module.css';
2020

21+
async function sleep(ms) {
22+
return new Promise(resolve => setTimeout(resolve, ms));
23+
}
24+
2125
const a = (
2226
<div key="a">
2327
<ViewTransition>
@@ -106,7 +110,13 @@ export default function Page({url, navigate}) {
106110
document.body
107111
)
108112
) : (
109-
<button onClick={() => startTransition(() => setShowModal(true))}>
113+
<button
114+
onClick={() =>
115+
startTransition(async () => {
116+
setShowModal(true);
117+
await sleep(2000);
118+
})
119+
}>
110120
Show Modal
111121
</button>
112122
);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,9 @@ import {
9595
defaultOnCaughtError,
9696
defaultOnRecoverableError,
9797
} from 'react-reconciler/src/ReactFiberReconciler';
98+
import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator';
9899
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
99100

100-
function defaultOnDefaultTransitionIndicator(): void | (() => void) {
101-
// TODO: Implement the default
102-
return function () {};
103-
}
104-
105101
// $FlowFixMe[missing-this-annot]
106102
function ReactDOMRoot(internalRoot: FiberRoot) {
107103
this._internalRoot = internalRoot;

scripts/flow/environment.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,127 @@ declare const Bun: {
429429
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
430430
): number,
431431
};
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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ module.exports = {
3535
FinalizationRegistry: 'readonly',
3636

3737
ScrollTimeline: 'readonly',
38+
navigation: 'readonly',
3839

3940
// Vendor specific
4041
MSApp: 'readonly',

scripts/rollup/validate/eslintrc.cjs2015.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = {
3333
globalThis: 'readonly',
3434
FinalizationRegistry: 'readonly',
3535
ScrollTimeline: 'readonly',
36+
navigation: 'readonly',
3637
// Vendor specific
3738
MSApp: 'readonly',
3839
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',

scripts/rollup/validate/eslintrc.esm.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ module.exports = {
3535
FinalizationRegistry: 'readonly',
3636

3737
ScrollTimeline: 'readonly',
38+
navigation: 'readonly',
3839

3940
// Vendor specific
4041
MSApp: 'readonly',

scripts/rollup/validate/eslintrc.fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ module.exports = {
3535
FinalizationRegistry: 'readonly',
3636

3737
ScrollTimeline: 'readonly',
38+
navigation: 'readonly',
3839

3940
// Vendor specific
4041
MSApp: 'readonly',

scripts/rollup/validate/eslintrc.rn.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ module.exports = {
3535
FinalizationRegistry: 'readonly',
3636

3737
ScrollTimeline: 'readonly',
38+
navigation: 'readonly',
3839

3940
// Vendor specific
4041
MSApp: 'readonly',

0 commit comments

Comments
 (0)