Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@
"react-focus-lock": "^2.13.2",
"react-inlinesvg": "^4.1.3",
"react-is": "^16.9.0",
"react-remove-scroll": "^2.6.0",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.7",
Expand Down
59 changes: 30 additions & 29 deletions packages/core/src/components/Modal/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { forwardRef, useCallback, useMemo, useRef, useState } from "react";
import cx from "classnames";
import { RemoveScroll } from "react-remove-scroll";
import FocusLock from "react-focus-lock";
import { CSSTransition } from "react-transition-group";
import { getTestId } from "../../../tests/test-ids-utils";
Expand All @@ -16,6 +15,7 @@ import { keyCodes } from "../../../constants";
import { createPortal } from "react-dom";
import usePortalTarget from "../hooks/usePortalTarget/usePortalTarget";
import useFocusEscapeTargets from "../hooks/useFocusEscapeTargets/useFocusEscapeTargets";
import useBodyScrollLock from "../hooks/useBodyScrollLock/useBodyScrollLock";
import { LayerProvider } from "@vibe/layer";

// @ts-expect-error This is a precaution to support all possible module systems (ESM/CJS)
Expand Down Expand Up @@ -111,6 +111,8 @@ const Modal = forwardRef(

const shouldAllowFocusEscape = useFocusEscapeTargets(allowFocusEscapeTo);

useBodyScrollLock(show);

Comment on lines +114 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Unlocks during exit 🐞 Bug ≡ Correctness

Modal calls useBodyScrollLock(show), so when show becomes false the hook cleanup restores
document.body styles immediately even though the modal stays mounted for the exit animation. This
makes the background scrollable while the modal/overlay is still visible during CSSTransition
exit.
Agent Prompt
### Issue description
`useBodyScrollLock(show)` unlocks scroll immediately when `show` flips to `false`, but the modal remains mounted during the exit animation (`CSSTransition` + `.containerExitActive`). This creates a window where the modal is still visible while body scroll is already re-enabled.

### Issue Context
The modal uses `CSSTransition` with `timeout.exit` and `unmountOnExit`, and exit animations are defined in SCSS. Scroll lock should remain active until the modal is actually unmounted / exit completes.

### Fix Focus Areas
- packages/core/src/components/Modal/Modal/Modal.tsx[112-196]
- packages/core/src/components/Modal/hooks/useBodyScrollLock/useBodyScrollLock.ts[10-35]

### Implementation sketch
- Introduce a local state like `isScrollLocked`.
- Set `isScrollLocked` to `true` on enter (or when mounting).
- Set `isScrollLocked` to `false` in `CSSTransition`’s `onExited` callback (after exit animation completes).
- Call `useBodyScrollLock(isScrollLocked)` instead of `useBodyScrollLock(show)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

/**
* Returning true means that the focus-lock is allowed to manage the element.
* Returning false means that the focus-lock would surrender control to the element.
Expand Down Expand Up @@ -158,34 +160,33 @@ const Modal = forwardRef(
onClick={onBackdropClick}
aria-hidden
/>
<RemoveScroll forwardProps ref={modalMergedRef}>
<div
className={cx(
styles.modal,
styles[animationType],
getStyle(styles, camelCase("size-" + size)),
{ [styles.withHeaderAction]: !!renderHeaderAction },
className
)}
id={id}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.MODAL, id)}
data-vibe={ComponentVibeId.MODAL}
role="dialog"
aria-modal
aria-labelledby={ariaLabelledby || titleId}
aria-describedby={ariaDescribedby || descriptionId}
style={{ ...style, ...anchorVars }}
tabIndex={-1}
>
{children}
<ModalTopActions
renderAction={renderHeaderAction}
theme={closeButtonTheme}
closeButtonAriaLabel={closeButtonAriaLabel}
onClose={onClose}
/>
</div>
</RemoveScroll>
<div
ref={modalMergedRef}
className={cx(
styles.modal,
styles[animationType],
getStyle(styles, camelCase("size-" + size)),
{ [styles.withHeaderAction]: !!renderHeaderAction },
className
)}
id={id}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.MODAL, id)}
data-vibe={ComponentVibeId.MODAL}
role="dialog"
aria-modal
aria-labelledby={ariaLabelledby || titleId}
aria-describedby={ariaDescribedby || descriptionId}
style={{ ...style, ...anchorVars }}
tabIndex={-1}
>
{children}
<ModalTopActions
renderAction={renderHeaderAction}
theme={closeButtonTheme}
closeButtonAriaLabel={closeButtonAriaLabel}
onClose={onClose}
/>
</div>
</div>
</FocusLockComponent>,
portalTargetElement
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect } from "react";

// Reference-counted body scroll lock so multiple stacked modals don't
// fight over `document.body` styles. The first lock captures and applies
// the styles; the last unlock restores them.
let activeLockCount = 0;
let savedBodyOverflow = "";
let savedBodyPaddingRight = "";

const useBodyScrollLock = (locked: boolean) => {
useEffect(() => {
if (!locked || typeof document === "undefined") return undefined;

if (activeLockCount === 0) {
const { body, documentElement } = document;
savedBodyOverflow = body.style.overflow;
savedBodyPaddingRight = body.style.paddingRight;

const scrollbarWidth = window.innerWidth - documentElement.clientWidth;
body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
const currentPaddingRight = parseFloat(getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
}
}

activeLockCount += 1;
return () => {
activeLockCount -= 1;
if (activeLockCount === 0) {
document.body.style.overflow = savedBodyOverflow;
document.body.style.paddingRight = savedBodyPaddingRight;
}
};
}, [locked]);
};

export default useBodyScrollLock;
Loading