mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
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:
parent
b480865db0
commit
59440424d0
|
|
@ -579,6 +579,7 @@ module.exports = {
|
||||||
JSONValue: 'readonly',
|
JSONValue: 'readonly',
|
||||||
JSResourceReference: 'readonly',
|
JSResourceReference: 'readonly',
|
||||||
MouseEventHandler: 'readonly',
|
MouseEventHandler: 'readonly',
|
||||||
|
NavigateEvent: 'readonly',
|
||||||
PropagationPhases: 'readonly',
|
PropagationPhases: 'readonly',
|
||||||
PropertyDescriptor: 'readonly',
|
PropertyDescriptor: 'readonly',
|
||||||
React$AbstractComponent: 'readonly',
|
React$AbstractComponent: 'readonly',
|
||||||
|
|
@ -634,5 +635,6 @@ module.exports = {
|
||||||
AsyncLocalStorage: 'readonly',
|
AsyncLocalStorage: 'readonly',
|
||||||
async_hooks: 'readonly',
|
async_hooks: 'readonly',
|
||||||
globalThis: 'readonly',
|
globalThis: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
|
|
||||||
import {setServerState} from './ServerState.js';
|
import {setServerState} from './ServerState.js';
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
export async function like() {
|
export async function like() {
|
||||||
|
// Test loading state
|
||||||
|
await sleep(1000);
|
||||||
setServerState('Liked!');
|
setServerState('Liked!');
|
||||||
return new Promise((resolve, reject) => resolve('Liked'));
|
return new Promise((resolve, reject) => resolve('Liked'));
|
||||||
}
|
}
|
||||||
|
|
@ -20,5 +26,7 @@ export async function greet(formData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function increment(n) {
|
export async function increment(n) {
|
||||||
|
// Test loading state
|
||||||
|
await sleep(1000);
|
||||||
return n + 1;
|
return n + 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import './Page.css';
|
||||||
|
|
||||||
import transitions from './Transitions.module.css';
|
import transitions from './Transitions.module.css';
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
const a = (
|
const a = (
|
||||||
<div key="a">
|
<div key="a">
|
||||||
<ViewTransition>
|
<ViewTransition>
|
||||||
|
|
@ -106,7 +110,13 @@ export default function Page({url, navigate}) {
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => startTransition(() => setShowModal(true))}>
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
setShowModal(true);
|
||||||
|
await sleep(2000);
|
||||||
|
})
|
||||||
|
}>
|
||||||
Show Modal
|
Show Modal
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
89
packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js
vendored
Normal file
89
packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -95,13 +95,9 @@ import {
|
||||||
defaultOnCaughtError,
|
defaultOnCaughtError,
|
||||||
defaultOnRecoverableError,
|
defaultOnRecoverableError,
|
||||||
} from 'react-reconciler/src/ReactFiberReconciler';
|
} from 'react-reconciler/src/ReactFiberReconciler';
|
||||||
|
import {defaultOnDefaultTransitionIndicator} from './ReactDOMDefaultTransitionIndicator';
|
||||||
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
|
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
|
||||||
|
|
||||||
function defaultOnDefaultTransitionIndicator(): void | (() => void) {
|
|
||||||
// TODO: Implement the default
|
|
||||||
return function () {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// $FlowFixMe[missing-this-annot]
|
// $FlowFixMe[missing-this-annot]
|
||||||
function ReactDOMRoot(internalRoot: FiberRoot) {
|
function ReactDOMRoot(internalRoot: FiberRoot) {
|
||||||
this._internalRoot = internalRoot;
|
this._internalRoot = internalRoot;
|
||||||
|
|
|
||||||
|
|
@ -429,3 +429,127 @@ declare const Bun: {
|
||||||
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
|
input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer,
|
||||||
): number,
|
): 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ module.exports = {
|
||||||
FinalizationRegistry: 'readonly',
|
FinalizationRegistry: 'readonly',
|
||||||
|
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
|
|
||||||
// Vendor specific
|
// Vendor specific
|
||||||
MSApp: 'readonly',
|
MSApp: 'readonly',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ module.exports = {
|
||||||
globalThis: 'readonly',
|
globalThis: 'readonly',
|
||||||
FinalizationRegistry: 'readonly',
|
FinalizationRegistry: 'readonly',
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
// Vendor specific
|
// Vendor specific
|
||||||
MSApp: 'readonly',
|
MSApp: 'readonly',
|
||||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
|
__REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ module.exports = {
|
||||||
FinalizationRegistry: 'readonly',
|
FinalizationRegistry: 'readonly',
|
||||||
|
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
|
|
||||||
// Vendor specific
|
// Vendor specific
|
||||||
MSApp: 'readonly',
|
MSApp: 'readonly',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ module.exports = {
|
||||||
FinalizationRegistry: 'readonly',
|
FinalizationRegistry: 'readonly',
|
||||||
|
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
|
|
||||||
// Vendor specific
|
// Vendor specific
|
||||||
MSApp: 'readonly',
|
MSApp: 'readonly',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ module.exports = {
|
||||||
FinalizationRegistry: 'readonly',
|
FinalizationRegistry: 'readonly',
|
||||||
|
|
||||||
ScrollTimeline: 'readonly',
|
ScrollTimeline: 'readonly',
|
||||||
|
navigation: 'readonly',
|
||||||
|
|
||||||
// Vendor specific
|
// Vendor specific
|
||||||
MSApp: 'readonly',
|
MSApp: 'readonly',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user