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;