[all components] Correct effect timing (#5337)

This commit is contained in:
atomiks authored and GitHub committed 2026-07-27 11:39:39 +10:00
1 parent 5611d30083
commit b38becd6ea
18 files changed
+753 -121

No files matched your search

@@ -54,7 +54,7 @@ export const AccordionPanel = React.forwardRef(function AccordionPanel(
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useIsoLayoutEffect(() => {
React.useEffect(() => {
if (keepMountedProp === false && hiddenUntilFound) {
warn(
'The `keepMounted={false}` prop on an `Accordion.Panel` is ignored when `hiddenUntilFound` is enabled on the panel or root, since the panel must remain mounted while closed.',
@@ -2,7 +2,6 @@
import * as React from 'react';
import { useControlled } from '@base-ui/utils/useControlled';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { warn } from '@base-ui/utils/warn';
import { BaseUIComponentProps, Orientation } from '../../internals/types';
import { CompositeList } from '../../internals/composite/list/CompositeList';
@@ -43,7 +42,7 @@ export const AccordionRoot = React.forwardRef(function AccordionRoot<Value = any
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useIsoLayoutEffect(() => {
React.useEffect(() => {
if (hiddenUntilFoundProp && keepMountedProp === false) {
warn(
'The `keepMounted={false}` prop on `Accordion.Root` is ignored when `hiddenUntilFound` is enabled, since panels must remain mounted while closed.',
@@ -34,7 +34,7 @@ export const CollapsiblePanel = React.forwardRef(function CollapsiblePanel(
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useIsoLayoutEffect(() => {
React.useEffect(() => {
if (hiddenUntilFoundProp && keepMountedProp === false) {
warn(
'The `keepMounted={false}` prop on `Collapsible.Panel` is ignored when `hiddenUntilFound` is enabled, since the panel must remain mounted while closed.',
@@ -9,9 +9,12 @@ import { Select } from '@base-ui/react/select';
import { NumberField } from '@base-ui/react/number-field';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { REASONS } from '../../internals/reasons';
import { useDialogRootContext } from './DialogRootContext';
import { DialogStore } from '../store/DialogStore';
import { DialogInteractions } from './useDialogRoot';
describe('<Dialog.Root />', () => {
const { render } = createRenderer();
@@ -34,6 +37,75 @@ describe('<Dialog.Root />', () => {
expectedPopupRole: 'dialog',
});
it('reports nested drawer counts before passive effects', async () => {
const childStore = new DialogStore(
{
open: true,
mounted: true,
modal: false,
disablePointerDismissal: false,
},
undefined,
true,
);
const parentStore = new DialogStore(undefined, undefined, false);
let mountPassiveEffectFlushed = false;
let teardownPassiveEffectFlushed = false;
let mountedBeforePassiveEffect: boolean | null = null;
let unmountedBeforePassiveEffect: boolean | null = null;
let phase: 'mount' | 'teardown' = 'mount';
Object.assign(parentStore.context, {
onNestedDialogOpen(dialogCount: number, drawerCount: number) {
if (drawerCount > 0 && mountedBeforePassiveEffect === null) {
mountedBeforePassiveEffect = !mountPassiveEffectFlushed;
}
if (
phase === 'teardown' &&
dialogCount === 0 &&
drawerCount === 0 &&
unmountedBeforePassiveEffect === null
) {
unmountedBeforePassiveEffect = !teardownPassiveEffectFlushed;
}
},
});
function PassiveEffectBoundary() {
useIsoLayoutEffect(
() => () => {
teardownPassiveEffectFlushed = false;
},
[],
);
React.useEffect(() => {
mountPassiveEffectFlushed = true;
return () => {
teardownPassiveEffectFlushed = true;
};
}, []);
return null;
}
const { unmount } = await render(
<React.Fragment>
<PassiveEffectBoundary />
<DialogInteractions store={childStore} parentContext={parentStore.context} isDrawer />
</React.Fragment>,
);
expect(mountedBeforePassiveEffect).toBe(true);
phase = 'teardown';
await act(async () => {
unmount();
});
expect(unmountedBeforePassiveEffect).toBe(true);
});
it('keeps trigger ownership when another trigger mounts while open', async () => {
function Test() {
const [showSecondTrigger, setShowSecondTrigger] = React.useState(false);
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useScrollLock } from '@base-ui/utils/useScrollLock';
import { useDismiss } from '../../floating-ui-react';
import { contains, getTarget } from '../../floating-ui-react/utils';
@@ -92,7 +93,7 @@ export function DialogInteractions({
});
// Notify parent of our open/close state using parent callbacks, if any
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (parentContext?.onNestedDialogOpen) {
if (open) {
parentContext.onNestedDialogOpen(
@@ -4,8 +4,10 @@ import { AlertDialog } from '@base-ui/react/alert-dialog';
import { Dialog } from '@base-ui/react/dialog';
import { Drawer } from '@base-ui/react/drawer';
import { SafeReact } from '@base-ui/utils/safeReact';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
import { useDrawerRootContext } from '../root/DrawerRootContext';
function setHeight(element: HTMLElement | null, getValue: () => number) {
@@ -338,6 +340,112 @@ describe('<Drawer.Popup />', () => {
},
);
it('reports nested presence and frontmost height before passive effects', async () => {
let passiveEffectFlushed = false;
let heightReportedBeforePassiveEffect: boolean | null = null;
let presenceReportedBeforePassiveEffect: boolean | null = null;
const patchedDrawerContexts = new WeakSet<object>();
function PassiveEffectBoundary({ childOpen }: { childOpen: boolean }) {
React.useEffect(() => {
if (childOpen) {
passiveEffectFlushed = true;
}
}, [childOpen]);
return null;
}
function ChildDrawerMethodProbe() {
const context = useDrawerRootContext();
if (!patchedDrawerContexts.has(context)) {
patchedDrawerContexts.add(context);
const notifyParentFrontmostHeight = context.notifyParentFrontmostHeight;
const notifyParentHasNestedDrawer = context.notifyParentHasNestedDrawer;
context.notifyParentFrontmostHeight = (height) => {
if (height > 0 && heightReportedBeforePassiveEffect === null) {
heightReportedBeforePassiveEffect = !passiveEffectFlushed;
}
notifyParentFrontmostHeight?.(height);
};
context.notifyParentHasNestedDrawer = (present) => {
if (present && presenceReportedBeforePassiveEffect === null) {
presenceReportedBeforePassiveEffect = !passiveEffectFlushed;
}
notifyParentHasNestedDrawer?.(present);
};
}
return null;
}
function PopupPhaseBoundary() {
useDialogRootContext().useState('open');
useDrawerRootContext();
passiveEffectFlushed = false;
useIsoLayoutEffect(() => {
passiveEffectFlushed = false;
});
React.useEffect(() => {
passiveEffectFlushed = true;
});
return null;
}
function TestCase({ childOpen }: { childOpen: boolean }) {
return (
<React.StrictMode>
<Drawer.Root defaultOpen modal={false}>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup data-testid="parent-popup">
<PassiveEffectBoundary childOpen={childOpen} />
<Drawer.Root open={childOpen} modal={false}>
<ChildDrawerMethodProbe />
<Drawer.Portal keepMounted>
<Drawer.Viewport>
<Drawer.Popup
initialFocus={false}
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 100,
});
}
}}
>
<PopupPhaseBoundary />
Child drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</React.StrictMode>
);
}
const { setProps } = await render(<TestCase childOpen={false} />);
await setProps({ childOpen: true });
expect(heightReportedBeforePassiveEffect).toBe(true);
expect(presenceReportedBeforePassiveEffect).toBe(true);
const parentPopup = screen.getByTestId('parent-popup');
expect(parentPopup.style.getPropertyValue('--nested-drawers')).toBe('1');
expect(parentPopup).toHaveAttribute('data-nested-drawer-open', '');
expect(parentPopup.style.getPropertyValue('--drawer-frontmost-height')).toBe('100px');
});
it('does not treat dialogs inside nested drawers as nested drawers', async () => {
await render(
<Drawer.Root open modal={false}>
@@ -267,7 +267,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
};
}, [nestedSwipeProgressStore, popupRef]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (!open) {
return undefined;
}
@@ -279,7 +279,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
};
}, [frontmostHeight, open, notifyParentFrontmostHeight]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (!notifyParentHasNestedDrawer) {
return undefined;
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import * as React from 'react';
import { Drawer } from '@base-ui/react/drawer';
import { screen } from '@mui/internal-test-utils';
import { createRenderer } from '#test-utils';
import { useDrawerProviderContext } from './DrawerProviderContext';
const manualDrawer = {};
const missingDrawer = {};
function ProviderControls() {
const context = useDrawerProviderContext();
@@ -14,10 +17,10 @@ function ProviderControls() {
return (
<React.Fragment>
<button onClick={() => context.setDrawerOpen('manual', true)}>Register open</button>
<button onClick={() => context.setDrawerOpen('manual', false)}>Register closed</button>
<button onClick={() => context.removeDrawer('manual')}>Remove registered</button>
<button onClick={() => context.removeDrawer('missing')}>Remove missing</button>
<button onClick={() => context.setDrawerOpen(manualDrawer, true)}>Register open</button>
<button onClick={() => context.setDrawerOpen(manualDrawer, false)}>Register closed</button>
<button onClick={() => context.removeDrawer(manualDrawer)}>Remove registered</button>
<button onClick={() => context.removeDrawer(missingDrawer)}>Remove missing</button>
<button
onClick={() => context.visualStateStore.set({ swipeProgress: 0.5, frontmostHeight: 120 })}
>
@@ -106,6 +109,22 @@ describe('<Drawer.Provider />', () => {
expect(background).toHaveAttribute('data-inactive', '');
});
it('does not retain closed drawer registrations', async () => {
const onRender = vi.fn();
const { user } = await render(
<React.Profiler id="provider" onRender={onRender}>
<Drawer.Provider>
<ProviderControls />
</Drawer.Provider>
</React.Profiler>,
);
onRender.mockClear();
await user.click(screen.getByRole('button', { name: 'Register closed' }));
expect(onRender).not.toHaveBeenCalled();
});
it('synchronizes and restores visual state on Drawer.Indent', async () => {
const { user, setProps } = await render(<VisualStateCase showIndent />);
const indent = screen.getByTestId('indent');
@@ -16,42 +16,30 @@ import {
export function DrawerProvider(props: DrawerProvider.Props) {
const { children } = props;
const [openById, setOpenById] = React.useState(() => new Map<string, boolean>());
const [openDrawers, setOpenDrawers] = React.useState(() => new Set<object>());
const [visualStateStore] = React.useState(createVisualStateStore);
const setDrawerOpen = useStableCallback((drawerId: string, open: boolean) => {
setOpenById((prev) => {
const prevOpen = prev.get(drawerId);
if (prevOpen === open) {
const setDrawerOpen = useStableCallback((drawer: object, open: boolean) => {
setOpenDrawers((prev) => {
if (prev.has(drawer) === open) {
return prev;
}
const next = new Map(prev);
next.set(drawerId, open);
return next;
});
});
const removeDrawer = useStableCallback((drawerId: string) => {
setOpenById((prev) => {
if (!prev.has(drawerId)) {
return prev;
}
const next = new Map(prev);
next.delete(drawerId);
return next;
});
});
const active = React.useMemo(() => {
for (const open of openById.values()) {
const next = new Set(prev);
if (open) {
return true;
next.add(drawer);
} else {
next.delete(drawer);
}
}
return false;
}, [openById]);
return next;
});
});
const removeDrawer = useStableCallback((drawer: object) => {
setDrawerOpen(drawer, false);
});
const active = openDrawers.size > 0;
const contextValue = React.useMemo(
() => ({
@@ -2,8 +2,8 @@
import * as React from 'react';
export interface DrawerProviderContext {
setDrawerOpen: (drawerId: string, open: boolean) => void;
removeDrawer: (drawerId: string) => void;
setDrawerOpen: (drawer: object, open: boolean) => void;
removeDrawer: (drawer: object) => void;
active: boolean;
visualStateStore: DrawerVisualStateStore;
}
@@ -4,6 +4,7 @@ import { Drawer } from '@base-ui/react/drawer';
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, isJSDOM, waitSingleFrame } from '#test-utils';
import { REASONS } from '../../internals/reasons';
import { useDrawerProviderContext } from '../provider/DrawerProviderContext';
import { useDrawerRootContext } from './DrawerRootContext';
const useIdMockState = vi.hoisted(() => ({ returnUndefined: false }));
@@ -1234,25 +1235,102 @@ describe('<Drawer.Root />', () => {
}
});
it('does not register an open drawer with its provider until an id is available', async () => {
useIdMockState.returnUndefined = true;
it.each([
{
label: 'a controlled drawer',
rootProps: { open: true },
strict: false,
withoutUseId: false,
},
{
label: 'a default-open drawer in Strict Mode',
rootProps: { defaultOpen: true },
strict: true,
withoutUseId: false,
},
{
label: 'the React 17 id fallback',
rootProps: { defaultOpen: true },
strict: true,
withoutUseId: true,
},
])(
'reports and unregisters $label before passive effects',
async ({ rootProps, strict, withoutUseId }) => {
let mountPassiveEffectFlushed = false;
let teardownPassiveEffectFlushed = false;
let mountedBeforePassiveEffect: boolean | null = null;
let unmountedBeforePassiveEffect: boolean | null = null;
let phase: 'mount' | 'teardown' = 'mount';
const patchedContexts = new WeakSet<object>();
try {
await render(
<Drawer.Provider>
<Drawer.IndentBackground data-testid="background" />
<Drawer.Root open>Drawer</Drawer.Root>
</Drawer.Provider>,
);
function PassiveEffectBoundary() {
React.useEffect(() => {
mountPassiveEffectFlushed = true;
return () => {
if (phase === 'teardown') {
teardownPassiveEffectFlushed = true;
}
};
}, []);
return null;
}
await act(async () => {
await waitSingleFrame();
});
expect(screen.getByTestId('background')).toHaveAttribute('data-inactive', '');
} finally {
useIdMockState.returnUndefined = false;
}
});
function ProviderMethodProbe() {
const providerContext = useDrawerProviderContext();
if (providerContext && !patchedContexts.has(providerContext)) {
patchedContexts.add(providerContext);
const { setDrawerOpen, removeDrawer } = providerContext;
providerContext.setDrawerOpen = (...args) => {
if (mountedBeforePassiveEffect === null) {
mountedBeforePassiveEffect = !mountPassiveEffectFlushed;
}
setDrawerOpen(...args);
};
providerContext.removeDrawer = (...args) => {
if (phase === 'teardown' && unmountedBeforePassiveEffect === null) {
unmountedBeforePassiveEffect = !teardownPassiveEffectFlushed;
}
removeDrawer(...args);
};
}
return null;
}
function TestCase({ showDrawer }: { showDrawer: boolean }) {
const content = (
<Drawer.Provider>
{showDrawer && <PassiveEffectBoundary />}
{showDrawer && <ProviderMethodProbe />}
{showDrawer && <Drawer.Root modal={false} {...rootProps} />}
<Drawer.IndentBackground data-testid="background" />
</Drawer.Provider>
);
return strict ? <React.StrictMode>{content}</React.StrictMode> : content;
}
useIdMockState.returnUndefined = withoutUseId;
try {
const { setProps } = await render(<TestCase showDrawer />);
expect(mountedBeforePassiveEffect).toBe(true);
expect(screen.getByTestId('background')).toHaveAttribute('data-active', '');
phase = 'teardown';
await setProps({ showDrawer: false });
expect(unmountedBeforePassiveEffect).toBe(true);
expect(screen.getByTestId('background')).toHaveAttribute('data-inactive', '');
} finally {
useIdMockState.returnUndefined = false;
}
},
);
it.skipIf(isJSDOM)('clears swipe-dismiss styles when swipe close is canceled', async () => {
const env = setupSwipeTestEnv();
+10 -14
View File
@@ -2,10 +2,10 @@
import * as React from 'react';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { useControlled } from '@base-ui/utils/useControlled';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { ownerWindow } from '@base-ui/utils/owner';
import { platform } from '@base-ui/utils/platform';
import { useId } from '@base-ui/utils/useId';
import {
DrawerRootContext,
type DrawerNestedSwipeProgressStore,
@@ -417,10 +417,10 @@ function createNestedSwipeProgressStore(): NestedSwipeProgressStore {
}
function DrawerProviderReporter() {
const drawerId = useId();
const providerContext = useDrawerProviderContext();
const store = useDialogRootContext(false);
const setDrawerOpen = providerContext?.setDrawerOpen;
const removeDrawer = providerContext?.removeDrawer;
const open = store.useState('open');
const nestedOpenDialogCount = store.useState('nestedOpenDialogCount');
@@ -428,23 +428,19 @@ function DrawerProviderReporter() {
const isTopmost = nestedOpenDialogCount === 0;
React.useEffect(() => {
if (!providerContext || drawerId == null) {
useIsoLayoutEffect(() => {
if (!removeDrawer) {
return undefined;
}
return () => {
providerContext.removeDrawer(drawerId);
removeDrawer(store);
};
}, [drawerId, providerContext]);
}, [removeDrawer, store]);
React.useEffect(() => {
if (drawerId == null) {
return;
}
providerContext?.setDrawerOpen(drawerId, open);
}, [drawerId, open, providerContext]);
useIsoLayoutEffect(() => {
setDrawerOpen?.(store, open);
}, [open, setDrawerOpen, store]);
React.useEffect(() => {
// CloseWatcher enables the Android back gesture (Chromium-only).
@@ -1,7 +1,13 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import * as React from 'react';
import { Drawer } from '@base-ui/react/drawer';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
import {
type DrawerProviderContext,
useDrawerProviderContext,
} from '../provider/DrawerProviderContext';
const useIdMockState = vi.hoisted(() => ({ returnUndefined: false }));
@@ -518,39 +524,67 @@ describe('<Drawer.SwipeArea />', () => {
});
it('cancels an active opening gesture when the swipe area becomes disabled', async () => {
let disabledPassiveEffectFlushed = false;
let providerContext: DrawerProviderContext | undefined;
const cleanupPhases: boolean[] = [];
const SwipeAreaPhaseBoundary = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<'div'>
>(function SwipeAreaPhaseBoundary(props, ref) {
useIsoLayoutEffect(() => {
disabledPassiveEffectFlushed = false;
});
React.useEffect(() => {
disabledPassiveEffectFlushed = true;
});
return <div {...props} ref={ref} />;
});
function ProviderContextCapture() {
providerContext = useDrawerProviderContext();
return null;
}
function TestCase({ disabled }: { disabled: boolean }) {
return (
<Drawer.Root>
<Drawer.SwipeArea
data-testid="swipe-area"
disabled={disabled}
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 100,
});
}
}}
/>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup
data-testid="popup"
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 100,
});
}
}}
>
Drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
<Drawer.Provider>
<ProviderContextCapture />
<Drawer.Root>
<Drawer.SwipeArea
data-testid="swipe-area"
disabled={disabled}
render={<SwipeAreaPhaseBoundary />}
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 100,
});
}
}}
/>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup
data-testid="popup"
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 100,
});
}
}}
>
Drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</Drawer.Provider>
);
}
@@ -580,11 +614,25 @@ describe('<Drawer.SwipeArea />', () => {
pointerType: 'mouse',
});
await flushMicrotasks();
expect(screen.getByTestId('popup')).toHaveAttribute('data-swiping', '');
const popup = screen.getByTestId('popup');
expect(popup).toHaveAttribute('data-swiping', '');
await setProps({ disabled: true });
expect(screen.getByTestId('popup')).not.toHaveAttribute('data-swiping');
expect(swipeArea).toHaveAttribute('data-disabled', '');
const originalSetVisualState = providerContext!.visualStateStore.set;
providerContext!.visualStateStore.set = (state) => {
if (state.swipeProgress === 0) {
cleanupPhases.push(!disabledPassiveEffectFlushed);
}
originalSetVisualState(state);
};
try {
await setProps({ disabled: true });
expect(cleanupPhases.at(-1)).toBe(true);
expect(screen.getByTestId('popup')).not.toHaveAttribute('data-swiping');
expect(swipeArea).toHaveAttribute('data-disabled', '');
} finally {
providerContext!.visualStateStore.set = originalSetVisualState;
}
});
it('does not prevent a non-cancelable pointer start', async () => {
@@ -103,6 +103,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
const dragDeltaRef = React.useRef({ x: 0, y: 0 });
const closedOffsetRef = React.useRef<number | null>(null);
const appliedSwipeStylesRef = React.useRef(false);
const swipePopupElementRef = React.useRef<HTMLElement | null>(null);
const swipeBackdropElementRef = React.useRef<HTMLElement | null>(null);
const popupTransitionRef = React.useRef<string | null>(null);
const releaseGuardCleanupRef = React.useRef<() => void>(NOOP);
@@ -228,6 +230,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementX, `${movementX}px`);
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementY, `${movementY}px`);
popupElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
swipePopupElementRef.current = popupElement;
if (popupTransitionRef.current === null) {
popupTransitionRef.current = popupElement.style.transition;
}
@@ -236,6 +239,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
const backdropElement = store.context.backdropRef.current;
if (backdropElement) {
backdropElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
swipeBackdropElementRef.current = backdropElement;
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, `${backdropProgress}`);
if (openProgress > 0 && frontmostHeight > 0) {
backdropElement.style.setProperty(DrawerPopupCssVars.height, `${frontmostHeight}px`);
@@ -253,8 +257,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
}
const clearSwipeStyles = useStableCallback(() => {
const popupElement = store.context.popupRef.current;
if (popupElement && appliedSwipeStylesRef.current) {
const popupElement = swipePopupElementRef.current;
if (popupElement) {
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementX);
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementY);
popupElement.removeAttribute(DrawerPopupDataAttributes.swiping);
@@ -265,7 +269,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
popupTransitionRef.current = null;
}
const backdropElement = store.context.backdropRef.current;
const backdropElement = swipeBackdropElementRef.current;
if (backdropElement) {
backdropElement.removeAttribute(DrawerPopupDataAttributes.swiping);
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, '0');
@@ -274,6 +278,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
providerContext?.visualStateStore.set({ swipeProgress: 0, frontmostHeight: 0 });
appliedSwipeStylesRef.current = false;
swipePopupElementRef.current = null;
swipeBackdropElementRef.current = null;
swipeAreaActiveRef.current = false;
});
@@ -386,7 +392,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
}
});
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (!enabled) {
resetSwipe();
resetDragDelta();
@@ -1,10 +1,15 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Combobox } from '@base-ui/react/combobox';
import { Drawer } from '@base-ui/react/drawer';
import { Slider } from '@base-ui/react/slider';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
import { useDrawerProviderContext } from '../provider/DrawerProviderContext';
import { useDrawerRootContext } from '../root/DrawerRootContext';
describe('<Drawer.Viewport />', () => {
beforeAll(function beforeHook() {
@@ -3635,6 +3640,319 @@ describe('<Drawer.Viewport />', () => {
}
});
it('publishes resting snap progress before descendant layout effects', async () => {
let snapPointPassiveEffectFlushed = false;
let appliedBeforePassiveEffect: boolean | null = null;
function PassiveEffectBoundary({ snapPoint }: { snapPoint: string }) {
useIsoLayoutEffect(() => {
snapPointPassiveEffectFlushed = false;
});
React.useEffect(() => {
if (snapPoint === '200px') {
snapPointPassiveEffectFlushed = true;
}
}, [snapPoint]);
return null;
}
function TestCase({ snapPoint }: { snapPoint: string }) {
return (
<Drawer.Root open modal={false} snapPoints={['100px', '200px']} snapPoint={snapPoint}>
<Drawer.Portal>
<Drawer.Backdrop data-testid="backdrop" />
<Drawer.Viewport
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 200,
});
}
}}
>
<PassiveEffectBoundary snapPoint={snapPoint} />
<Drawer.Popup
ref={(element) => {
if (element) {
Object.defineProperty(element, 'offsetHeight', {
configurable: true,
value: 200,
});
}
}}
>
Drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
const { setProps } = await render(<TestCase snapPoint="100px" />);
const backdrop = screen.getByTestId('backdrop');
const originalSetProperty = backdrop.style.setProperty.bind(backdrop.style);
const setPropertySpy = vi
.spyOn(backdrop.style, 'setProperty')
.mockImplementation((name, value, priority) => {
if (
name === '--drawer-swipe-progress' &&
value === '0' &&
appliedBeforePassiveEffect === null
) {
appliedBeforePassiveEffect = !snapPointPassiveEffectFlushed;
}
originalSetProperty(name, value, priority);
});
try {
await setProps({ snapPoint: '200px' });
expect(appliedBeforePassiveEffect).toBe(true);
expect(backdrop.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
} finally {
setPropertySpy.mockRestore();
}
});
it('clears kept-mounted swipe state before descendant layout effects on reopen', async () => {
let openPassiveEffectFlushed = false;
let resetBeforePassiveEffect: boolean | null = null;
function PassiveEffectBoundary({ open }: { open: boolean }) {
useDialogRootContext().useState('open');
useIsoLayoutEffect(() => {
openPassiveEffectFlushed = false;
});
React.useEffect(() => {
if (open) {
openPassiveEffectFlushed = true;
}
}, [open]);
return null;
}
function TestCase({ open }: { open: boolean }) {
return (
<Drawer.Root open={open} modal={false}>
<Drawer.Portal keepMounted>
<Drawer.Viewport>
<PassiveEffectBoundary open={open} />
<Drawer.Popup data-testid="popup">Drawer</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
const { setProps } = await render(<TestCase open={false} />);
const popup = screen.getByTestId('popup');
popup.style.setProperty('--drawer-swipe-movement-y', '50px');
popup.setAttribute('data-swipe-dismiss', '');
const originalToggleAttribute = popup.toggleAttribute.bind(popup);
const toggleAttributeSpy = vi
.spyOn(popup, 'toggleAttribute')
.mockImplementation((name, force) => {
if (name === 'data-swipe-dismiss' && force === false && resetBeforePassiveEffect === null) {
resetBeforePassiveEffect = !openPassiveEffectFlushed;
}
return originalToggleAttribute(name, force);
});
try {
await setProps({ open: true });
expect(resetBeforePassiveEffect).toBe(true);
expect(popup.style.getPropertyValue('--drawer-swipe-movement-y')).toBe('0px');
expect(popup).not.toHaveAttribute('data-swipe-dismiss');
} finally {
toggleAttributeSpy.mockRestore();
}
});
it('clears nested progress before descendant layout effects when the child closes', async () => {
let closePassiveEffectFlushed = false;
let clearedBeforePassiveEffect: boolean | null = null;
function NestedProgressControl() {
const { onNestedSwipeProgressChange } = useDrawerRootContext();
return <button onClick={() => onNestedSwipeProgressChange(0.5)}>Set nested progress</button>;
}
function PassiveEffectBoundary({ childOpen }: { childOpen: boolean }) {
useDialogRootContext().useState('open');
closePassiveEffectFlushed = false;
useIsoLayoutEffect(
() => () => {
closePassiveEffectFlushed = false;
},
[],
);
React.useEffect(
() => () => {
if (childOpen) {
closePassiveEffectFlushed = true;
}
},
[childOpen],
);
return null;
}
function TestCase({ childOpen }: { childOpen: boolean }) {
return (
<Drawer.Root open modal={false}>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup data-testid="parent-popup">
<Drawer.Root open={childOpen} modal={false}>
<Drawer.Portal keepMounted>
<Drawer.Viewport>
<PassiveEffectBoundary childOpen={childOpen} />
<Drawer.Popup>Child drawer</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
<NestedProgressControl />
</Drawer.Root>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
const { setProps, user } = await render(<TestCase childOpen />);
await user.click(screen.getByRole('button', { name: 'Set nested progress' }));
expect(
screen.getByTestId('parent-popup').style.getPropertyValue('--drawer-swipe-progress'),
).toBe('0.5');
const parentPopup = screen.getByTestId('parent-popup');
const originalSetProperty = parentPopup.style.setProperty.bind(parentPopup.style);
const setPropertySpy = vi
.spyOn(parentPopup.style, 'setProperty')
.mockImplementation((name, value, priority) => {
if (
name === '--drawer-swipe-progress' &&
value === '0' &&
clearedBeforePassiveEffect === null
) {
clearedBeforePassiveEffect = !closePassiveEffectFlushed;
}
originalSetProperty(name, value, priority);
});
try {
await setProps({ childOpen: false });
expect(clearedBeforePassiveEffect).toBe(true);
expect(parentPopup.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
} finally {
setPropertySpy.mockRestore();
}
});
it('clears provider and backdrop swipe state during layout teardown', async () => {
let teardownPassiveEffectFlushed = false;
let clearedBeforePassiveEffect: boolean | null = null;
function ProviderStateControl() {
const providerContext = useDrawerProviderContext();
return (
<button
onClick={() => {
providerContext?.visualStateStore.set({
swipeProgress: 0.5,
frontmostHeight: 100,
});
}}
>
Set provider state
</button>
);
}
function PassiveEffectBoundary() {
useIsoLayoutEffect(
() => () => {
teardownPassiveEffectFlushed = false;
},
[],
);
React.useEffect(
() => () => {
teardownPassiveEffectFlushed = true;
},
[],
);
return null;
}
function TestCase({ showViewport }: { showViewport: boolean }) {
return (
<Drawer.Provider>
<Drawer.Indent data-testid="indent" />
<ProviderStateControl />
<Drawer.Root open modal={false}>
<Drawer.Portal>
<Drawer.Backdrop data-testid="backdrop" />
{showViewport && (
<React.Fragment>
<PassiveEffectBoundary />
<Drawer.Viewport>
<Drawer.Popup>Drawer</Drawer.Popup>
</Drawer.Viewport>
</React.Fragment>
)}
</Drawer.Portal>
</Drawer.Root>
</Drawer.Provider>
);
}
const { setProps, user } = await render(<TestCase showViewport />);
await user.click(screen.getByRole('button', { name: 'Set provider state' }));
const indent = screen.getByTestId('indent');
const backdrop = screen.getByTestId('backdrop');
backdrop.setAttribute('data-swiping', '');
expect(indent.style.getPropertyValue('--drawer-swipe-progress')).toBe('0.5');
expect(indent.style.getPropertyValue('--drawer-height')).toBe('100px');
const originalSetProperty = indent.style.setProperty.bind(indent.style);
const setPropertySpy = vi
.spyOn(indent.style, 'setProperty')
.mockImplementation((name, value, priority) => {
if (
name === '--drawer-swipe-progress' &&
value === '0' &&
clearedBeforePassiveEffect === null
) {
clearedBeforePassiveEffect = !teardownPassiveEffectFlushed;
}
originalSetProperty(name, value, priority);
});
try {
await setProps({ showViewport: false });
expect(clearedBeforePassiveEffect).toBe(true);
expect(indent.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
expect(indent.style.getPropertyValue('--drawer-height')).toBe('');
expect(backdrop).not.toHaveAttribute('data-swiping');
} finally {
setPropertySpy.mockRestore();
}
});
it('leaves pinch-zoom touchmove to the browser', async () => {
await render(
<Drawer.Root open>
@@ -5,6 +5,7 @@ import { isElement } from '@floating-ui/utils/dom';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { ownerDocument, ownerWindow } from '@base-ui/utils/owner';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
import { DialogViewport } from '../../dialog/viewport/DialogViewport';
@@ -764,7 +765,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
virtualKeyboard,
]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (!snapPointRange || swipe.swiping) {
return;
}
@@ -783,7 +784,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
visualStateStore,
]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (!notifyParentSwipeProgressChange) {
return undefined;
}
@@ -797,7 +798,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
};
}, [notifyParentSwipeProgressChange, open]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
if (open) {
// Skip `resetSwipe` while `Drawer.SwipeArea` is driving the open: it zeroes the popup's
// `--swipe-movement-*` (via `syncDragStyles(false)`), flashing it fully open for a frame.
@@ -810,7 +811,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
}
}, [clearSwipeRelease, open, resetSwipe, swipeAreaActiveRef]);
React.useEffect(() => {
useIsoLayoutEffect(() => {
const backdropElement = backdropRef.current;
return () => {
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import { isElementDisabled } from '@base-ui/utils/isElementDisabled';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { warn } from '@base-ui/utils/warn';
import { SafeReact } from '@base-ui/utils/safeReact';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
@@ -93,7 +92,7 @@ export const MenuSubmenuTrigger = React.forwardRef(function MenuSubmenuTrigger(
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useIsoLayoutEffect(() => {
React.useEffect(() => {
const element = triggerElementRef.current;
if (element && isElementDisabled(element) && !disabled) {
const ownerStackMessage = SafeReact.captureOwnerStack?.() || '';
+1 -2
View File
@@ -1,7 +1,6 @@
'use client';
import * as React from 'react';
import { useControlled } from '@base-ui/utils/useControlled';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { error } from '@base-ui/utils/error';
import { useBaseUiId } from '../internals/useBaseUiId';
import { useRenderElement } from '../internals/useRenderElement';
@@ -52,7 +51,7 @@ export const Toggle = React.forwardRef(function Toggle<Value extends string>(
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
useIsoLayoutEffect(() => {
React.useEffect(() => {
if (groupContext && valueProp === undefined && groupContext.isValueInitialized) {
error(
'A `<Toggle>` component rendered in a `<ToggleGroup>` has no explicit `value` prop.',