diff --git a/.eslintrc.js b/.eslintrc.js index 49846c1f5e9bc..c1eb5b34ebe82 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', }, }; diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index aa19871a9dcbb..0b9b9c315d647 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -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; } diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 9744313c4f5ea..d7f7bc0983601 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -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 = (
@@ -106,7 +110,13 @@ export default function Page({url, navigate}) { document.body ) ) : ( - ); diff --git a/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js new file mode 100644 index 0000000000000..8f1a32d826c1a --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js @@ -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; + } + }; +} diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index ef2c9ddf193eb..97f4c83515364 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -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; diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index c3fe40eeef302..d66ef65d9d318 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -429,3 +429,127 @@ declare const Bun: { input: string | $TypedArray | DataView | ArrayBuffer | SharedArrayBuffer, ): number, }; + +// Navigation API + +declare const navigation: Navigation; + +interface NavigationResult { + committed: Promise; + finished: Promise; +} + +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; +} + +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; + 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; +} diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 88d17772d7b21..65fd6129904e6 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index 8e87c8dbe0203..fa0b471330f4a 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -33,6 +33,7 @@ module.exports = { globalThis: 'readonly', FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index 8b4bba35796fa..a5ea7afb972e7 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index f0602e79e5074..afee2f1199eb1 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 052edabdc0f92..2420898bebecd 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -35,6 +35,7 @@ module.exports = { FinalizationRegistry: 'readonly', ScrollTimeline: 'readonly', + navigation: 'readonly', // Vendor specific MSApp: 'readonly',