diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.js b/packages/mui-material/src/Autocomplete/Autocomplete.js index f249219d26da5e..46cb55d3ca6ddd 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.js @@ -24,6 +24,8 @@ import { useDefaultProps } from '../DefaultPropsProvider'; import autocompleteClasses, { getAutocompleteUtilityClass } from './autocompleteClasses'; import capitalize from '../utils/capitalize'; import useSlot from '../utils/useSlot'; +import { useDeferredValue } from '@mui/utils'; +import Fade from '../Fade'; const useUtilityClasses = (ownerState) => { const { @@ -422,6 +424,7 @@ const AutocompleteGroupUl = styled('ul', { export { createFilterOptions }; +const EMPTY_ARRAY = []; const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) { const props = useDefaultProps({ props: inProps, name: 'MuiAutocomplete' }); @@ -572,6 +575,9 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) { className: classes.paper, }); + const groupedOptionsDeferred = useDeferredValue(popupOpen ? groupedOptions : EMPTY_ARRAY); + const isLoading = loading || groupedOptionsDeferred !== groupedOptions; + const [PopperSlot, popperProps] = useSlot('popper', { elementType: Popper, externalForwardedProps, @@ -672,19 +678,25 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) { const renderAutocompletePopperChildren = (children) => ( - - {children} - + + + {children} + + ); let autocompletePopper = null; - if (groupedOptions.length > 0) { + if (groupedOptionsDeferred.length > 0) { autocompletePopper = renderAutocompletePopperChildren( // TODO v7: remove `as` prop and move ListboxComponentProp to externalForwardedProps or remove ListboxComponentProp entirely // https://github.com/mui/material-ui/pull/43994#issuecomment-2401945800 - {groupedOptions.map((option, index) => { + {groupedOptionsDeferred.map((option, index) => { if (groupBy) { return renderGroup({ key: option.key, @@ -698,13 +710,13 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) { })} , ); - } else if (loading && groupedOptions.length === 0) { + } else if (isLoading && groupedOptionsDeferred.length === 0) { autocompletePopper = renderAutocompletePopperChildren( {loadingText} , ); - } else if (groupedOptions.length === 0 && !freeSolo && !loading) { + } else if (groupedOptionsDeferred.length === 0 && !freeSolo && !isLoading) { autocompletePopper = renderAutocompletePopperChildren( void; +export type BatchCallback = (batch: Batch) => void; + +interface Batch { + queue: Set; +} + +let ACTIVE_BATCH: Batch | null = null; +let PENDING_FLUSH: number = 0; +const PENDING_BATCHES: Set = new Set(); + +/** + * Executes the given function inside of a batch. + * + * If a batch doesn't already exist, a new one will be created, and the given + * callback will be executed when it ends. + */ +export function runWithBatch(fn: () => T, batchCallback: BatchCallback): T { + return ReactDOM.unstable_batchedUpdates(() => { + const prevBatch = ACTIVE_BATCH; + const batch = prevBatch == null ? ({ queue: new Set() } as Batch) : prevBatch; + let result: T; + + try { + ACTIVE_BATCH = batch; + result = fn(); + } finally { + ACTIVE_BATCH = prevBatch; + } + + if (batch !== prevBatch) { + batchCallback(batch); + } + return result; + }); +} + +/** + * A batch callback that immediately executes all of the updates + * in every batch (the current one, and any pending). Assumes it's + * called in a ReactDOM batch. + */ +export function blockingBatchCallback(batch: Batch) { + flushPendingBatches(); + batch.queue.forEach((callback) => callback()); +} + +/** + * A batch callback that executes every update in a future macro + * task. Assumes it's called in a ReactDOM batch. + */ +export function nonBlockingBatchCallback(batch: Batch) { + // Apply the pending batches with a timeout so that they + // are executed in a future macrotask, *after* the blocking + // changes have been painted. + // + // The timeout is a bit arbitrary. One of benefits of transitions are + // that they enable a kind of debouncing. With a non-zero timeout, we can + // get some of that benefit in React 17 by allowing non-blocking updates + // from e.g. keystrokes to cancel our previous timeout and further delay + // our deferred work instead of blocking the UI, with the trade-off of an + // increased latency to when the deferred work will be shown. + // + // The value should be something high enough that e.g. actively typing into + // a search box remains responsive, but not so high that the application + // feels slow to respond when you stop typing. + PENDING_BATCHES.add(batch); + window.clearTimeout(PENDING_FLUSH); + PENDING_FLUSH = window.setTimeout(() => { + ReactDOM.unstable_batchedUpdates(flushPendingBatches); + }, 375); +} + +/** + * Creates a batch callback that executes every update in the given + * `startTransition` function. + */ +export function createPassthroughBatchCallback(startTransition: (callback: Callback) => void) { + return (batch: Batch) => { + startTransition(() => { + batch.queue.forEach((callback) => callback()); + }); + }; +} + +/** + * Attempt to enqueue the given state update. + * + * If there is an existing batch, the update will be added to it and + * run later. Otherwise, it will be run immediately, without batching. + */ +export function enqueueStateUpdate(fn: Callback): Callback { + const queue = ACTIVE_BATCH?.queue; + if (queue) { + queue.add(fn); + return () => { + queue.delete(fn); + }; + } else { + fn(); + return () => {}; + } +} + +/** + * Flush any pending batches. Assumes it's called within a ReactDOM batch. + */ +function flushPendingBatches() { + window.clearTimeout(PENDING_FLUSH); + PENDING_FLUSH = 0; + + PENDING_BATCHES.forEach((batch) => { + batch.queue.forEach((callback) => callback()); + }); + PENDING_BATCHES.clear(); +} diff --git a/packages/mui-utils/src/CompatTransitionManager/index.ts b/packages/mui-utils/src/CompatTransitionManager/index.ts new file mode 100644 index 00000000000000..6506c442f7d8d2 --- /dev/null +++ b/packages/mui-utils/src/CompatTransitionManager/index.ts @@ -0,0 +1 @@ +export * from './CompatTransitionManager'; diff --git a/packages/mui-utils/src/index.ts b/packages/mui-utils/src/index.ts index d1b651419059a1..fcdb89b4af6169 100644 --- a/packages/mui-utils/src/index.ts +++ b/packages/mui-utils/src/index.ts @@ -48,4 +48,8 @@ export { default as unstable_resolveComponentProps } from './resolveComponentPro export { default as unstable_extractEventHandlers } from './extractEventHandlers'; export { default as unstable_getReactNodeRef } from './getReactNodeRef'; export { default as unstable_getReactElementRef } from './getReactElementRef'; +export { default as useDeferredValue } from './useDeferredValue'; +export { default as useStateWithTransitions } from './useStateWithTransitions'; +export { default as useReducerWithTransitions } from './useReducerWithTransitions'; +export { default as useTransition } from './useTransition'; export * from './types'; diff --git a/packages/mui-utils/src/useDeferredValue/index.ts b/packages/mui-utils/src/useDeferredValue/index.ts new file mode 100644 index 00000000000000..734c2d16da9f10 --- /dev/null +++ b/packages/mui-utils/src/useDeferredValue/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default } from './useDeferredValue'; diff --git a/packages/mui-utils/src/useDeferredValue/useDeferredValue.ts b/packages/mui-utils/src/useDeferredValue/useDeferredValue.ts new file mode 100644 index 00000000000000..6fd71ac31a8cc5 --- /dev/null +++ b/packages/mui-utils/src/useDeferredValue/useDeferredValue.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { + nonBlockingBatchCallback, + enqueueStateUpdate, + runWithBatch, +} from '../CompatTransitionManager'; + +function useDeferredValue17(value: T): T { + // React 17 doesn't support concurrent rendering. We simulate the behavior + // by only updating to the current value when the previous one is committed. + const [currentValue, setCurrentValue] = React.useState(value); + + React.useEffect(() => { + if (value !== currentValue) { + return runWithBatch( + () => enqueueStateUpdate(() => setCurrentValue(value)), + nonBlockingBatchCallback, + ); + } + }, [value, currentValue]); + + return currentValue; +} + +// See https://github.com/mui/material-ui/issues/41190#issuecomment-2040873379 for why +const safeReact = { ...React }; +const maybeReactUseDeferredValue: undefined | typeof React.useDeferredValue = + safeReact.useDeferredValue; + +const useDeferredValue = + typeof maybeReactUseDeferredValue === 'undefined' + ? useDeferredValue17 + : maybeReactUseDeferredValue; + +export default useDeferredValue; diff --git a/packages/mui-utils/src/useReducerWithTransitions/index.ts b/packages/mui-utils/src/useReducerWithTransitions/index.ts new file mode 100644 index 00000000000000..d85a14247f91a5 --- /dev/null +++ b/packages/mui-utils/src/useReducerWithTransitions/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default } from './useReducerWithTransitions'; diff --git a/packages/mui-utils/src/useReducerWithTransitions/useReducerWithTransitions.ts b/packages/mui-utils/src/useReducerWithTransitions/useReducerWithTransitions.ts new file mode 100644 index 00000000000000..5b4284906eaa25 --- /dev/null +++ b/packages/mui-utils/src/useReducerWithTransitions/useReducerWithTransitions.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { + runWithBatch, + blockingBatchCallback, + enqueueStateUpdate, +} from '../CompatTransitionManager'; + +/** + * Like `useReducer`, but for use with the compatibility version of `useTransition`. + * TODO: Improve typing. + */ +export default function useReducerWithTransitions( + reducer: any, + initializerArg: any, + initializer?: any, +) { + const [state, dispatch] = React.useReducer(reducer, initializerArg, initializer); + const enqueueDispatch = React.useCallback( + (value: any) => { + runWithBatch(() => { + enqueueStateUpdate(() => (dispatch as any)(value)); + }, blockingBatchCallback); + }, + [dispatch], + ); + + return [state, enqueueDispatch]; +} diff --git a/packages/mui-utils/src/useStateWithTransitions/index.ts b/packages/mui-utils/src/useStateWithTransitions/index.ts new file mode 100644 index 00000000000000..e679d8c125e69c --- /dev/null +++ b/packages/mui-utils/src/useStateWithTransitions/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default } from './useStateWithTransitions'; diff --git a/packages/mui-utils/src/useStateWithTransitions/useStateWithTransitions.ts b/packages/mui-utils/src/useStateWithTransitions/useStateWithTransitions.ts new file mode 100644 index 00000000000000..c689b7147db024 --- /dev/null +++ b/packages/mui-utils/src/useStateWithTransitions/useStateWithTransitions.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { + runWithBatch, + blockingBatchCallback, + enqueueStateUpdate, +} from '../CompatTransitionManager'; + +type StateUpdateFn = (prevState: T) => T; +type SetStateInput = T | StateUpdateFn; +type SetStateFn = (value: SetStateInput) => void; + +/** + * Like `useState`, but for use with the compatibility version of `useTransition`. + */ +export default function useStateWithTransitions( + initialValue: T | (() => T), +): [T, SetStateFn] { + const [state, setState] = React.useState(initialValue); + const enqueueSetState = React.useCallback( + (value: SetStateInput) => { + runWithBatch(() => { + enqueueStateUpdate(() => setState(value)); + }, blockingBatchCallback); + }, + [setState], + ); + + return [state, enqueueSetState]; +} diff --git a/packages/mui-utils/src/useTransition/index.ts b/packages/mui-utils/src/useTransition/index.ts new file mode 100644 index 00000000000000..aa771431feb5ef --- /dev/null +++ b/packages/mui-utils/src/useTransition/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { default } from './useTransition'; diff --git a/packages/mui-utils/src/useTransition/useTransition.ts b/packages/mui-utils/src/useTransition/useTransition.ts new file mode 100644 index 00000000000000..342cb19f1795f4 --- /dev/null +++ b/packages/mui-utils/src/useTransition/useTransition.ts @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { + nonBlockingBatchCallback, + BatchCallback, + createPassthroughBatchCallback, + enqueueStateUpdate, + runWithBatch, +} from '../CompatTransitionManager'; + +export type TransitionFunction = () => void | PromiseLike; +export interface StartTransitionFunction { + (callback: TransitionFunction): void; +} + +interface TransitionStatePending { + status: 'PENDING'; +} +interface TransitionStateResolved { + status: 'RESOLVED'; +} +interface TransitionStateRejected { + status: 'REJECTED'; + reason: any; +} +type TransitionState = TransitionStatePending | TransitionStateResolved | TransitionStateRejected; + +/** + * Shared implementation logic for the `useTransition` hooks. + */ +function useTransitionImpl(batchCallback: BatchCallback): [boolean, StartTransitionFunction] { + const pendingPromise = React.useRef | null>(null); + const [state, setState] = React.useState({ status: 'RESOLVED' }); + + const onTransitionThen = (promise: PromiseLike | null) => { + if (pendingPromise.current === promise) { + pendingPromise.current = null; + runWithBatch(() => { + enqueueStateUpdate(() => setState({ status: 'RESOLVED' })); + }, batchCallback); + } + }; + const onTransitionCatch = (promise: PromiseLike | null, reason: any) => { + if (pendingPromise.current === promise) { + pendingPromise.current = null; + runWithBatch(() => { + enqueueStateUpdate(() => setState({ status: 'REJECTED', reason })); + }, batchCallback); + } + }; + + const startTransition = React.useCallback( + (callback: TransitionFunction) => { + enqueueStateUpdate(() => setState({ status: 'PENDING' })); + runWithBatch(() => { + try { + const res = callback(); + if (res != null && typeof res.then === 'function') { + pendingPromise.current = res; + res.then(onTransitionThen.bind(null, res), onTransitionCatch.bind(null, res)); + } else { + pendingPromise.current = null; + onTransitionThen(null); + } + } catch (error) { + pendingPromise.current = null; + onTransitionCatch(null, error); + } + }, batchCallback); + }, + [setState, batchCallback], + ); + + if (state.status === 'REJECTED') { + throw state.reason; + } else { + return [state.status === 'PENDING', startTransition]; + } +} + +function useTransition18(): [boolean, StartTransitionFunction] { + // To ease the upgrade process, we implement the same semantics in + // React 18 as React 17, i.e. only updates to `useStateWithTransitions` + // and `useReducerWithTransitions` are marked as non-blocking. + const [isPendingInternal, startTransitionInternal] = maybeReactUseTransition!(); + const batchCallback = React.useMemo( + () => createPassthroughBatchCallback(startTransitionInternal), + [startTransitionInternal], + ); + const [isPending, startTransition] = useTransitionImpl(batchCallback); + // React 18 applications might adopt suspense, so we should be pending + // as long as an async action is pending or the subsequent transition is. + return [isPending || isPendingInternal, startTransition]; +} + +function useTransition17(): [boolean, StartTransitionFunction] { + return useTransitionImpl(nonBlockingBatchCallback); +} + +// See https://github.com/mui/material-ui/issues/41190#issuecomment-2040873379 for why +const safeReact = { ...React }; +const maybeReactUseTransition: undefined | typeof React.useTransition = safeReact.useTransition; + +/** + * The `startTransition` callback in the compatibility hook works a little differently from the built-in hook. + * Even in React 18, only updates to `useStateWithTransitions` and `useReducerWithTransitions` are marked as + * non-blocking. + */ +const useTransition: () => [boolean, StartTransitionFunction] = + typeof maybeReactUseTransition === 'undefined' ? useTransition17 : useTransition18; + +export default useTransition;