From 124c89435a6e8a17e48ae3a943fe8bc6e424e0cb Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Fri, 17 Jul 2026 15:32:53 -0400 Subject: [PATCH 1/9] Remove explicit usage of findDOMNode I removed the container prop from Modal, but we don't seem to use that ourselves so it's probably fine to do. It was marked as private anyway. --- src/CarouselItem.js | 12 ++++++-- src/Dropdown.js | 71 ++++++++++++++++++++++----------------------- src/DropdownMenu.js | 6 ++-- src/Modal.js | 12 ++------ src/Nav.js | 10 +++++-- test/index.js | 5 ---- 6 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/CarouselItem.js b/src/CarouselItem.js index 4c7d0eeda1..e4a11abbf8 100644 --- a/src/CarouselItem.js +++ b/src/CarouselItem.js @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import ReactDOM from 'react-dom'; import transition from 'dom-helpers/transition'; const propTypes = { @@ -29,6 +28,7 @@ class CarouselItem extends React.Component { direction: null }; + this.containerRef = React.createRef(); this.isUnmounted = false; } @@ -43,7 +43,7 @@ class CarouselItem extends React.Component { const prevActive = prevProps.active; if (!active && prevActive) { - transition.end(ReactDOM.findDOMNode(this), this.handleAnimateOutEnd); + transition.end(this.containerRef.current, this.handleAnimateOutEnd); } if (active !== prevActive) { @@ -99,7 +99,13 @@ class CarouselItem extends React.Component { classes[this.state.direction] = true; } - return
; + return ( +
+ ); } } diff --git a/src/Dropdown.js b/src/Dropdown.js index 1fd68d3c4a..55adaa3666 100644 --- a/src/Dropdown.js +++ b/src/Dropdown.js @@ -3,7 +3,6 @@ import activeElement from 'dom-helpers/activeElement'; import contains from 'dom-helpers/query/contains'; import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; -import ReactDOM from 'react-dom'; import all from 'prop-types-extra/lib/all'; import elementType from 'prop-types-extra/lib/elementType'; import isRequiredForA11y from 'prop-types-extra/lib/isRequiredForA11y'; @@ -123,6 +122,7 @@ class Dropdown extends React.Component { this.handleClose = this.handleClose.bind(this); this._focusInDropdown = false; + this.containerRef = React.createRef(); this.lastOpenEventType = null; } @@ -133,7 +133,7 @@ class Dropdown extends React.Component { UNSAFE_componentWillUpdate(nextProps) { if (!nextProps.open && this.props.open) { this._focusInDropdown = contains( - ReactDOM.findDOMNode(this.menu), + this.containerRef.current.querySelector('[role=menu]'), activeElement(document) ); } @@ -158,7 +158,9 @@ class Dropdown extends React.Component { } focus() { - const toggle = ReactDOM.findDOMNode(this.toggle); + const toggle = this.containerRef.current.querySelector( + '[role=button][aria-haspopup]' + ); if (toggle && toggle.focus) { toggle.focus(); @@ -241,7 +243,9 @@ class Dropdown extends React.Component { this.menu = c; }; - ref = createChainedFunction(child.ref, ref); + if (child.props && child.props.ref) { + ref = createChainedFunction(child.props.ref, ref); + } return cloneElement(child, { ...props, @@ -259,15 +263,8 @@ class Dropdown extends React.Component { } renderToggle(child, props) { - let ref = c => { - this.toggle = c; - }; - - ref = createChainedFunction(child.ref, ref); - return cloneElement(child, { ...props, - ref, bsClass: prefix(props, 'toggle'), onClick: createChainedFunction(child.props.onClick, this.handleClick), onKeyDown: createChainedFunction( @@ -311,31 +308,33 @@ class Dropdown extends React.Component { // underlying component, to allow it to render size and style variants. return ( - - {ValidComponentChildren.map(children, child => { - switch (child.props.bsRole) { - case TOGGLE_ROLE: - return this.renderToggle(child, { - id, - disabled, - open, - role, - bsClass - }); - case MENU_ROLE: - return this.renderMenu(child, { - id, - open, - pullRight, - bsClass, - onSelect, - rootCloseEvent - }); - default: - return child; - } - })} - +
+ + {ValidComponentChildren.map(children, child => { + switch (child.props.bsRole) { + case TOGGLE_ROLE: + return this.renderToggle(child, { + id, + disabled, + open, + role, + bsClass + }); + case MENU_ROLE: + return this.renderMenu(child, { + id, + open, + pullRight, + bsClass, + onSelect, + rootCloseEvent + }); + default: + return child; + } + })} + +
); } } diff --git a/src/DropdownMenu.js b/src/DropdownMenu.js index 8d4d9fad87..91f6baea23 100644 --- a/src/DropdownMenu.js +++ b/src/DropdownMenu.js @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import ReactDOM from 'react-dom'; import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper'; import { @@ -33,10 +32,12 @@ class DropdownMenu extends React.Component { this.handleRootClose = this.handleRootClose.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + + this.menuRef = React.createRef(); } getFocusableMenuItems() { - const node = ReactDOM.findDOMNode(this); + const node = this.menuRef.current; if (!node) { return []; } @@ -119,6 +120,7 @@ class DropdownMenu extends React.Component { event={rootCloseEvent} >
    document.documentElement.clientHeight; diff --git a/src/Nav.js b/src/Nav.js index 9b20a2476e..439a4172c6 100644 --- a/src/Nav.js +++ b/src/Nav.js @@ -1,7 +1,6 @@ import classNames from 'classnames'; import React, { cloneElement, useContext } from 'react'; import PropTypes from 'prop-types'; -import ReactDOM from 'react-dom'; import all from 'prop-types-extra/lib/all'; import warning from 'warning'; @@ -98,6 +97,12 @@ const defaultProps = { }; class Nav extends React.Component { + constructor(props) { + super(props); + + this.containerRef = React.createRef(); + } + componentDidUpdate() { if (!this._needsRefocus) { return; @@ -115,7 +120,7 @@ class Nav extends React.Component { const childrenArray = ValidComponentChildren.toArray(children); const activeChildIndex = childrenArray.indexOf(activeChild); - const childNodes = ReactDOM.findDOMNode(this).children; + const childNodes = this.containerRef.current.children; const activeNode = childNodes && childNodes[activeChildIndex]; if (!activeNode || !activeNode.firstChild) { @@ -309,6 +314,7 @@ class Nav extends React.Component { return (
      { return; } - if (msg.includes('findDOMNode')) { - // @hmhealey This is removed in React 19 - return; - } - console.error.threw = true; throw new Error(msg); }); From 74632d022a494dc64754b47c3e298324202a62f4 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Fri, 17 Jul 2026 18:41:17 -0400 Subject: [PATCH 2/9] Update react-overlays and react-transition-group --- package.json | 4 +- patches/react-overlays+0.9.3.patch | 35 -------- patches/react-overlays+5.2.1.patch | 52 +++++++++++ src/Collapse.js | 41 ++++++--- src/DropdownMenu.js | 16 +++- src/Fade.js | 17 +++- src/Modal.js | 30 +++++-- src/Overlay.js | 17 +++- src/OverlayTrigger.js | 8 +- test/DropdownSpec.js | 1 - test/ModalSpec.js | 37 ++++---- test/OverlayTriggerSpec.js | 17 ++-- yarn.lock | 137 +++++++++++++++++++---------- 13 files changed, 276 insertions(+), 136 deletions(-) delete mode 100644 patches/react-overlays+0.9.3.patch create mode 100644 patches/react-overlays+5.2.1.patch diff --git a/package.json b/package.json index 5f7b46c629..f723625a41 100644 --- a/package.json +++ b/package.json @@ -124,9 +124,9 @@ "keycode": "^2.2.0", "prop-types": "^15.6.1", "prop-types-extra": "^1.0.1", - "react-overlays": "^0.9.3", + "react-overlays": "^5.2.0", "react-prop-types": "^0.4.0", - "react-transition-group": "^2.0.0", + "react-transition-group": "^4.4.0", "uncontrollable": "^5.0.0", "warning": "^3.0.0" }, diff --git a/patches/react-overlays+0.9.3.patch b/patches/react-overlays+0.9.3.patch deleted file mode 100644 index 5539968862..0000000000 --- a/patches/react-overlays+0.9.3.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/node_modules/react-overlays/lib/Modal.js b/node_modules/react-overlays/lib/Modal.js -index 9fbba3b..c876341 100644 ---- a/node_modules/react-overlays/lib/Modal.js -+++ b/node_modules/react-overlays/lib/Modal.js -@@ -576,7 +576,7 @@ var _initialiseProps = function _initialiseProps() { - }; - - this.handleDocumentKeyDown = function (e) { -- if (_this2.props.keyboard && e.keyCode === 27 && _this2.isTopModal()) { -+ if (_this2.props.keyboard && (e.keyCode === 27 || e.key === 'Escape') && _this2.isTopModal()) { - if (_this2.props.onEscapeKeyDown) { - _this2.props.onEscapeKeyDown(e); - } -@@ -586,7 +586,7 @@ var _initialiseProps = function _initialiseProps() { - }; - - this.handleDocumentKeyUp = function (e) { -- if (_this2.props.keyboard && e.keyCode === 27 && _this2.isTopModal()) { -+ if (_this2.props.keyboard && (e.keyCode === 27 || e.key === 'Escape') && _this2.isTopModal()) { - if (_this2.props.onEscapeKeyUp) { - _this2.props.onEscapeKeyUp(e); - } -diff --git a/node_modules/react-overlays/lib/RootCloseWrapper.js b/node_modules/react-overlays/lib/RootCloseWrapper.js -index 683e1cf..c561092 100644 ---- a/node_modules/react-overlays/lib/RootCloseWrapper.js -+++ b/node_modules/react-overlays/lib/RootCloseWrapper.js -@@ -115,7 +115,7 @@ var RootCloseWrapper = function (_React$Component) { - return; - } - -- if (e.keyCode === escapeKeyCode && _this.props.onRootClose) { -+ if ((e.keyCode === escapeKeyCode || e.key === 'Escape') && _this.props.onRootClose) { - _this.props.onRootClose(e); - } - }; diff --git a/patches/react-overlays+5.2.1.patch b/patches/react-overlays+5.2.1.patch new file mode 100644 index 0000000000..3789e904b2 --- /dev/null +++ b/patches/react-overlays+5.2.1.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/react-overlays/cjs/Modal.js b/node_modules/react-overlays/cjs/Modal.js +index 2247ad0..71c9f4a 100644 +--- a/node_modules/react-overlays/cjs/Modal.js ++++ b/node_modules/react-overlays/cjs/Modal.js +@@ -215,7 +215,7 @@ var Modal = /*#__PURE__*/(0, _react.forwardRef)(function (_ref, ref) { + } + }); + var handleDocumentKeyDown = (0, _useEventCallback["default"])(function (e) { +- if (keyboard && e.keyCode === 27 && modal.isTopModal()) { ++ if (keyboard && (e.keyCode === 27 || e.key === 'Escape') && modal.isTopModal()) { + onEscapeKeyDown == null ? void 0 : onEscapeKeyDown(e); + + if (!e.defaultPrevented) { +diff --git a/node_modules/react-overlays/cjs/useRootClose.js b/node_modules/react-overlays/cjs/useRootClose.js +index 3fc40a7..cc16674 100644 +--- a/node_modules/react-overlays/cjs/useRootClose.js ++++ b/node_modules/react-overlays/cjs/useRootClose.js +@@ -66,7 +66,7 @@ function useRootClose(ref, onRootClose, _temp) { + } + }); + var handleKeyUp = (0, _useEventCallback["default"])(function (e) { +- if (e.keyCode === escapeKeyCode) { ++ if (e.keyCode === escapeKeyCode || e.key === 'Escape') { + onClose(e); + } + }); +diff --git a/node_modules/react-overlays/esm/Modal.js b/node_modules/react-overlays/esm/Modal.js +index 42f79e4..a12ebac 100644 +--- a/node_modules/react-overlays/esm/Modal.js ++++ b/node_modules/react-overlays/esm/Modal.js +@@ -193,7 +193,7 @@ var Modal = /*#__PURE__*/forwardRef(function (_ref, ref) { + } + }); + var handleDocumentKeyDown = useEventCallback(function (e) { +- if (keyboard && e.keyCode === 27 && modal.isTopModal()) { ++ if (keyboard && (e.keyCode === 27 || e.key === 'Escape') && modal.isTopModal()) { + onEscapeKeyDown == null ? void 0 : onEscapeKeyDown(e); + + if (!e.defaultPrevented) { +diff --git a/node_modules/react-overlays/esm/useRootClose.js b/node_modules/react-overlays/esm/useRootClose.js +index 8c7882c..30d0d56 100644 +--- a/node_modules/react-overlays/esm/useRootClose.js ++++ b/node_modules/react-overlays/esm/useRootClose.js +@@ -53,7 +53,7 @@ function useRootClose(ref, onRootClose, _temp) { + } + }); + var handleKeyUp = useEventCallback(function (e) { +- if (e.keyCode === escapeKeyCode) { ++ if (e.keyCode === escapeKeyCode || e.key === 'Escape') { + onClose(e); + } + }); diff --git a/src/Collapse.js b/src/Collapse.js index 6d181aeec0..cf0d8002b4 100644 --- a/src/Collapse.js +++ b/src/Collapse.js @@ -134,6 +134,12 @@ const defaultProps = { }; class Collapse extends React.Component { + constructor(props) { + super(props); + + this.childRef = React.createRef(); + } + getDimension() { return typeof this.props.dimension === 'function' ? this.props.dimension() @@ -146,31 +152,34 @@ class Collapse extends React.Component { } /* -- Expanding -- */ - handleEnter = elem => { - elem.style[this.getDimension()] = '0'; + handleEnter = () => { + this.childRef.current.style[this.getDimension()] = '0'; }; - handleEntering = elem => { + handleEntering = () => { const dimension = this.getDimension(); - elem.style[dimension] = this._getScrollDimensionValue(elem, dimension); + this.childRef.current.style[dimension] = this._getScrollDimensionValue( + this.childRef.current, + dimension + ); }; - handleEntered = elem => { - elem.style[this.getDimension()] = null; + handleEntered = () => { + this.childRef.current.style[this.getDimension()] = null; }; /* -- Collapsing -- */ - handleExit = elem => { + handleExit = () => { const dimension = this.getDimension(); - elem.style[dimension] = `${this.props.getDimensionValue( + this.childRef.current.style[dimension] = `${this.props.getDimensionValue( dimension, - elem + this.childRef.current )}px`; - triggerBrowserReflow(elem); + triggerBrowserReflow(this.childRef.current); }; - handleExiting = elem => { - elem.style[this.getDimension()] = '0'; + handleExiting = () => { + this.childRef.current.style[this.getDimension()] = '0'; }; render() { @@ -197,10 +206,17 @@ class Collapse extends React.Component { const handleExit = createChainedFunction(this.handleExit, onExit); const handleExiting = createChainedFunction(this.handleExiting, onExiting); + let ref = c => { + this.childRef.current = c; + }; + + ref = createChainedFunction(children.props.ref, ref); + return ( React.cloneElement(children, { ...innerProps, + ref, className: classNames( className, children.props.className, diff --git a/src/DropdownMenu.js b/src/DropdownMenu.js index 91f6baea23..7beaeba0c6 100644 --- a/src/DropdownMenu.js +++ b/src/DropdownMenu.js @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper'; +import { useRootClose } from 'react-overlays'; import { bsClass, @@ -118,6 +118,7 @@ class DropdownMenu extends React.Component { disabled={!open} onRootClose={this.handleRootClose} event={rootCloseEvent} + menuRef={this.menuRef} >
        { + this.childRef.current = c; + }; + + ref = createChainedFunction(children.props.ref, ref); + return ( - + {(status, innerProps) => React.cloneElement(children, { ...innerProps, + ref, className: classNames( 'fade', className, diff --git a/src/Modal.js b/src/Modal.js index 73b34f28c0..9431ef8751 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -5,8 +5,8 @@ import canUseDOM from 'dom-helpers/util/inDOM'; import getScrollbarSize from 'dom-helpers/util/scrollbarSize'; import React from 'react'; import PropTypes from 'prop-types'; -import BaseModal from 'react-overlays/lib/Modal'; -import isOverflowing from 'react-overlays/lib/utils/isOverflowing'; +import isOverflowing from 'react-overlays/isOverflowing'; +import BaseModal from 'react-overlays/Modal'; import elementType from 'prop-types-extra/lib/elementType'; import Fade from './Fade'; @@ -118,7 +118,16 @@ const propTypes = { }; const defaultProps = { - ...BaseModal.defaultProps, + show: false, + backdrop: true, + keyboard: true, + autoFocus: true, + enforceFocus: true, + restoreFocus: true, + onHide: function onHide() {}, + renderBackdrop: function renderBackdrop(props) { + return
        ; + }, animation: true, dialogComponentClass: ModalDialog }; @@ -194,7 +203,7 @@ class Modal extends React.Component { return; } - const dialogNode = this._modal.getDialogElement(); + const dialogNode = this._modal.dialog; const dialogHeight = dialogNode.scrollHeight; const document = ownerDocument(dialogNode); @@ -245,10 +254,15 @@ class Modal extends React.Component { transition={animation ? DialogTransition : undefined} backdrop={backdrop} backdropTransition={animation ? BackdropTransition : undefined} - backdropClassName={classNames( - prefix(props, 'backdrop'), - backdropClassName, - inClassName + renderBackdrop={backdropProps => ( +
        )} onEntering={createChainedFunction(onEntering, this.handleEntering)} onExited={createChainedFunction(onExited, this.handleExited)} diff --git a/src/Overlay.js b/src/Overlay.js index bd35b36389..42a69120fe 100644 --- a/src/Overlay.js +++ b/src/Overlay.js @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; -import BaseOverlay from 'react-overlays/lib/Overlay'; +import BaseOverlay from 'react-overlays/Overlay'; import elementType from 'prop-types-extra/lib/elementType'; import Fade from './Fade'; @@ -88,8 +88,19 @@ class Overlay extends React.Component { } return ( - - {child} + + {({ props: overlayProps /* , arrowProps, placement */ }) => ( + // TODO do I need to do something with these other props? + // return cloneElement(children, { + // ref: overlayProps.ref, + // className: transition ? children.props.className : classNames(children.props.className, 'in'), + // placement, + // style: {...children.props.style, ...overlayProps.style}, + // }) +
        + {child} +
        + )}
        ); } diff --git a/src/OverlayTrigger.js b/src/OverlayTrigger.js index 3adda67446..d45a25a833 100644 --- a/src/OverlayTrigger.js +++ b/src/OverlayTrigger.js @@ -115,6 +115,8 @@ class OverlayTrigger extends React.Component { this.state = { show: props.defaultOverlayShown }; + + this.triggerRef = React.createRef(); } componentWillUnmount() { @@ -207,7 +209,7 @@ class OverlayTrigger extends React.Component { {...props} show={this.state.show} onHide={this.handleHide} - target={this} + target={this.triggerRef} > {overlay} @@ -292,7 +294,9 @@ class OverlayTrigger extends React.Component { return ( <> - {cloneElement(child, triggerProps)} +
        + {cloneElement(child, triggerProps)} +
        {ReactDOM.createPortal(this.makeOverlay(overlay, props), document.body)} ); diff --git a/test/DropdownSpec.js b/test/DropdownSpec.js index 314e937bdc..cc05ffaa74 100644 --- a/test/DropdownSpec.js +++ b/test/DropdownSpec.js @@ -408,7 +408,6 @@ describe('', () => { outerRef.dropdown.menu.should.exist; outerRef.toggle.should.exist; - outerRef.dropdown.toggle.should.exist; }); describe('focusable state', () => { diff --git a/test/ModalSpec.js b/test/ModalSpec.js index ba3948f4c3..23559e4901 100644 --- a/test/ModalSpec.js +++ b/test/ModalSpec.js @@ -20,7 +20,11 @@ describe('', () => { ); - assert.ok(instance._modal.getDialogElement().querySelector('strong')); + assert.ok( + instance._modal.dialog + .querySelector('[role=dialog]') + .querySelector('strong') + ); }); it('Should close the modal when the modal dialog is clicked', done => { @@ -35,7 +39,7 @@ describe('', () => { ); - const dialog = instance._modal.getDialogElement(); + const dialog = instance._modal.dialog.querySelector('[role=dialog]'); userEvent.click(dialog); }); @@ -54,7 +58,7 @@ describe('', () => { ); - const dialog = instance._modal.getDialogElement(); + const dialog = instance._modal.dialog.querySelector('[role=dialog]'); userEvent.click(dialog); @@ -74,15 +78,13 @@ describe('', () => { ); - const button = instance._modal - .getDialogElement() - .getElementsByClassName('close')[0]; + const button = instance._modal.dialog.getElementsByClassName('close')[0]; userEvent.click(button); }); it('Should close the modal when the escape key is pressed with keyboard=true', async () => { - const handleHide = sinon.spy(); + const handleHide = sinon.spy(() => {}); render( @@ -125,7 +127,7 @@ describe('', () => { ); - const dialog = instance._modal.getDialogElement(); + const dialog = instance._modal.dialog.querySelector('[role=dialog]'); assert.ok(dialog.className.match(/\bmymodal\b/)); }); @@ -144,7 +146,7 @@ describe('', () => { ); - const modal = instance._modal.getDialogElement(); + const modal = instance._modal.dialog.querySelector('[role=dialog]'); assert.ok(modal.className.match(/\bmymodal\b/)); assert.ok(modal.children[0].className.match(/\bmymodal-dialog\b/)); @@ -180,9 +182,9 @@ describe('', () => { ); - const dialog = instance._modal - .getDialogElement() - .getElementsByClassName('modal-dialog')[0]; + const dialog = instance._modal.dialog.getElementsByClassName( + 'modal-dialog' + )[0]; assert.ok(dialog.className.match(/\bmodal-sm\b/)); }); @@ -201,7 +203,7 @@ describe('', () => { ); - const dialog = instance._modal.getDialogElement(); + const dialog = instance._modal.dialog.querySelector('[role=dialog]'); assert.ok(dialog.style.top === '1000px'); }); @@ -220,9 +222,7 @@ describe('', () => { ); - const dialog = instance._modal - .getDialogElement() - .querySelector('.modal-dialog'); + const dialog = instance._modal.dialog.querySelector('.modal-dialog'); assert.ok(dialog.className.match(/\btestCss\b/)); }); @@ -246,7 +246,10 @@ describe('', () => { ); - assert.equal(instance._modal.getDialogElement().className, 'custom-dialog'); + assert.equal( + instance._modal.dialog.firstElementChild.className, + 'custom-dialog' + ); }); it('Should pass transition callbacks to Transition', done => { diff --git a/test/OverlayTriggerSpec.js b/test/OverlayTriggerSpec.js index 3ae8166cbc..c8eba6d4c8 100644 --- a/test/OverlayTriggerSpec.js +++ b/test/OverlayTriggerSpec.js @@ -1,6 +1,5 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import PropTypes from 'prop-types'; import React from 'react'; import OverlayTrigger from '../src/OverlayTrigger'; @@ -140,9 +139,8 @@ describe('', () => { }); it('Should forward requested context', async () => { - const contextTypes = { - key: PropTypes.string - }; + const TestContext = React.createContext(undefined); + TestContext.displayName = 'TestContext'; const contextSpy = sinon.spy(); @@ -153,18 +151,19 @@ describe('', () => { } } - ContextReader.contextTypes = contextTypes; + ContextReader.contextType = TestContext; class ContextHolder extends React.Component { render() { return ( - }> - - + + }> + + + ); } } - ContextHolder.childContextTypes = contextTypes; render(); const overlayTrigger = screen.getByText('button'); diff --git a/yarn.lock b/yarn.lock index 80ea5abf38..3f1524ae8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -796,18 +796,16 @@ core-js "^2.5.7" regenerator-runtime "^0.12.0" -"@babel/runtime@^7.1.2": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== - dependencies: - regenerator-runtime "^0.13.4" - "@babel/runtime@^7.12.5": version "7.28.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473" integrity sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA== +"@babel/runtime@^7.13.8", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + "@babel/template@7.0.0-beta.51": version "7.0.0-beta.51" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.51.tgz#9602a40aebcf357ae9677e2532ef5fc810f5fbff" @@ -944,6 +942,18 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@popperjs/core@^2.11.6": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + +"@restart/hooks@^0.4.7": + version "0.4.16" + resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.16.tgz#95ae8ac1cc7e2bd4fed5e39800ff85604c6d59fb" + integrity sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w== + dependencies: + dequal "^2.0.3" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -1030,6 +1040,18 @@ dependencies: undici-types "~7.10.0" +"@types/react@>=16.9.11": + version "19.2.17" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" + integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== + dependencies: + csstype "^3.2.2" + +"@types/warning@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.4.tgz#ebc0c83180dc83994d902bbd51ab0af8a445b1f9" + integrity sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg== + "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" @@ -2180,6 +2202,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +csstype@^3.0.2, csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + custom-event@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" @@ -2373,16 +2400,17 @@ dom-accessibility-api@^0.5.9: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== -dom-helpers@^3.2.0, dom-helpers@^3.2.1, dom-helpers@^3.3.1: +dom-helpers@^3.2.0: version "3.3.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6" -dom-helpers@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" - integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== +dom-helpers@^5.0.1, dom-helpers@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== dependencies: - "@babel/runtime" "^7.1.2" + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" dom-serialize@^2.2.1: version "2.2.1" @@ -5191,13 +5219,22 @@ prop-types-extra@^1.0.1: react-is "^16.3.2" warning "^3.0.0" -prop-types@^15.5.10, prop-types@^15.6.1, prop-types@^15.6.2: +prop-types@^15.6.1, prop-types@^15.6.2: version "15.6.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" dependencies: loose-envify "^1.3.1" object-assign "^4.1.1" +prop-types@^15.7.2: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + pseudomap@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" @@ -5316,6 +5353,11 @@ react-dom@^18.3.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-is@^16.3.2: version "16.4.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.2.tgz#84891b56c2b6d9efdee577cc83501dfc5ecead88" @@ -5329,17 +5371,19 @@ react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" -react-overlays@^0.9.3: - version "0.9.3" - resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.3.tgz#5bac8c1e9e7e057a125181dee2d784864dd62902" - integrity sha512-u2T7nOLnK+Hrntho4p0Nxh+BsJl0bl4Xuwj/Y0a56xywLMetgAfyjnDVrudLXsNcKGaspoC+t3C1V80W9QQTdQ== - dependencies: - classnames "^2.2.5" - dom-helpers "^3.2.1" - prop-types "^15.5.10" - prop-types-extra "^1.0.1" - react-transition-group "^2.2.1" - warning "^3.0.0" +react-overlays@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.2.1.tgz#49dc007321adb6784e1f212403f0fb37a74ab86b" + integrity sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA== + dependencies: + "@babel/runtime" "^7.13.8" + "@popperjs/core" "^2.11.6" + "@restart/hooks" "^0.4.7" + "@types/warning" "^3.0.0" + dom-helpers "^5.2.0" + prop-types "^15.7.2" + uncontrollable "^7.2.1" + warning "^4.0.3" react-prop-types@^0.4.0: version "0.4.0" @@ -5364,24 +5408,15 @@ react-test-renderer@^18.3.1: react-shallow-renderer "^16.15.0" scheduler "^0.23.2" -react-transition-group@^2.0.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.4.0.tgz#1d9391fabfd82e016f26fabd1eec329dbd922b5a" +react-transition-group@^4.4.0: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== dependencies: - dom-helpers "^3.3.1" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-lifecycles-compat "^3.0.4" - -react-transition-group@^2.2.1: - version "2.9.0" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" - integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== - dependencies: - dom-helpers "^3.4.0" + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" loose-envify "^1.4.0" prop-types "^15.6.2" - react-lifecycles-compat "^3.0.4" react@^18.3.1: version "18.3.1" @@ -5462,11 +5497,6 @@ regenerator-runtime@^0.12.0: version "0.12.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" -regenerator-runtime@^0.13.4: - version "0.13.9" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" - integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== - regenerator-transform@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" @@ -6402,6 +6432,16 @@ uncontrollable@^5.0.0: dependencies: invariant "^2.2.4" +uncontrollable@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.2.1.tgz#1fa70ba0c57a14d5f78905d533cf63916dc75738" + integrity sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ== + dependencies: + "@babel/runtime" "^7.6.3" + "@types/react" ">=16.9.11" + invariant "^2.2.4" + react-lifecycles-compat "^3.0.4" + undici-types@~7.10.0: version "7.10.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350" @@ -6553,6 +6593,13 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" +warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^2.4.1: version "2.4.4" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" From d7d9d47560b767e911aad3912afca9ec6ff1a6af Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 28 Jul 2026 15:46:23 -0400 Subject: [PATCH 3/9] Re-add backdropStyle prop to Modal This used to come from react-overlays, but it's not part of the newer version. --- src/Modal.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Modal.js b/src/Modal.js index 9431ef8751..677b7790fc 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -37,6 +37,11 @@ const propTypes = { */ backdropClassName: PropTypes.string, + /** + * Add optional styles to .modal-backdrop + */ + backdropStyle: PropTypes.object, + /** * Close the modal when escape key is pressed */ @@ -229,6 +234,7 @@ class Modal extends React.Component { const { backdrop, backdropClassName, + backdropStyle, animation, show, dialogComponentClass: Dialog, @@ -262,6 +268,10 @@ class Modal extends React.Component { backdropClassName, inClassName )} + style={{ + ...backdropProps.style, + ...backdropStyle + }} /> )} onEntering={createChainedFunction(onEntering, this.handleEntering)} From 57ccf6964dcdf62a7ca4a50aaf3e45ae5b8d5306 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 28 Jul 2026 15:47:52 -0400 Subject: [PATCH 4/9] Cleanup unused props in Overlay --- src/Overlay.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Overlay.js b/src/Overlay.js index 42a69120fe..fbfa2520ad 100644 --- a/src/Overlay.js +++ b/src/Overlay.js @@ -89,14 +89,7 @@ class Overlay extends React.Component { return ( - {({ props: overlayProps /* , arrowProps, placement */ }) => ( - // TODO do I need to do something with these other props? - // return cloneElement(children, { - // ref: overlayProps.ref, - // className: transition ? children.props.className : classNames(children.props.className, 'in'), - // placement, - // style: {...children.props.style, ...overlayProps.style}, - // }) + {({ props: overlayProps }) => (
        {child}
        From 4f818a48f8516b5881ba9f50418adb79d7f464cc Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 28 Jul 2026 16:03:53 -0400 Subject: [PATCH 5/9] Have Modal use renderDialog properly --- src/Modal.js | 28 +++++++++++++++++----------- src/ModalDialog.js | 40 +++++++++++++++++++++++++--------------- test/ModalSpec.js | 27 ++++++++++----------------- 3 files changed, 52 insertions(+), 43 deletions(-) diff --git a/src/Modal.js b/src/Modal.js index 677b7790fc..3835bad0f8 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -274,19 +274,25 @@ class Modal extends React.Component { }} /> )} + renderDialog={renderProps => ( + + {children} + + )} onEntering={createChainedFunction(onEntering, this.handleEntering)} onExited={createChainedFunction(onExited, this.handleExited)} - > - - {children} - - + /> ); } diff --git a/src/ModalDialog.js b/src/ModalDialog.js index 34b0b5a5a3..c8306ebe16 100644 --- a/src/ModalDialog.js +++ b/src/ModalDialog.js @@ -2,15 +2,11 @@ import classNames from 'classnames'; import React from 'react'; import PropTypes from 'prop-types'; -import { - bsClass, - bsSizes, - getClassSet, - prefix, - splitBsProps -} from './utils/bootstrapUtils'; +import { getClassSet, prefix, splitBsProps } from './utils/bootstrapUtils'; import { Size } from './utils/StyleConfig'; +const SIZES = [Size.LARGE, Size.SMALL]; + const propTypes = { /** * A css class to apply to the Modal dialog DOM node. @@ -20,20 +16,31 @@ const propTypes = { /** * A method to run for the mousedown event on the dialog. */ - handleDialogMouseDown: PropTypes.func + handleDialogMouseDown: PropTypes.func, + + bsClass: PropTypes.string, + bsSize: PropTypes.oneOf(SIZES) }; -class ModalDialog extends React.Component { - render() { - const { +const ModalDialog = React.forwardRef( + ( + { dialogClassName, className, style, children, handleDialogMouseDown, + bsClass = 'modal', + bsSize, ...props - } = this.props; - const [bsProps, elementProps] = splitBsProps(props); + }, + ref + ) => { + const [bsProps, elementProps] = splitBsProps({ + ...props, + bsClass, + bsSize + }); const bsClassName = prefix(bsProps); @@ -47,6 +54,7 @@ class ModalDialog extends React.Component { return (
        ); } -} +); +ModalDialog.displayName = 'ModalDialog'; ModalDialog.propTypes = propTypes; +ModalDialog.SIZES = SIZES; -export default bsClass('modal', bsSizes([Size.LARGE, Size.SMALL], ModalDialog)); +export default ModalDialog; diff --git a/test/ModalSpec.js b/test/ModalSpec.js index 23559e4901..920c851851 100644 --- a/test/ModalSpec.js +++ b/test/ModalSpec.js @@ -20,11 +20,7 @@ describe('', () => { ); - assert.ok( - instance._modal.dialog - .querySelector('[role=dialog]') - .querySelector('strong') - ); + assert.ok(instance._modal.dialog.querySelector('strong')); }); it('Should close the modal when the modal dialog is clicked', done => { @@ -39,7 +35,7 @@ describe('', () => { ); - const dialog = instance._modal.dialog.querySelector('[role=dialog]'); + const dialog = instance._modal.dialog; userEvent.click(dialog); }); @@ -58,7 +54,7 @@ describe('', () => { ); - const dialog = instance._modal.dialog.querySelector('[role=dialog]'); + const dialog = instance._modal.dialog; userEvent.click(dialog); @@ -127,7 +123,7 @@ describe('', () => { ); - const dialog = instance._modal.dialog.querySelector('[role=dialog]'); + const dialog = instance._modal.dialog; assert.ok(dialog.className.match(/\bmymodal\b/)); }); @@ -146,7 +142,7 @@ describe('', () => { ); - const modal = instance._modal.dialog.querySelector('[role=dialog]'); + const modal = instance._modal.dialog; assert.ok(modal.className.match(/\bmymodal\b/)); assert.ok(modal.children[0].className.match(/\bmymodal-dialog\b/)); @@ -203,7 +199,7 @@ describe('', () => { ); - const dialog = instance._modal.dialog.querySelector('[role=dialog]'); + const dialog = instance._modal.dialog; assert.ok(dialog.style.top === '1000px'); }); @@ -230,9 +226,9 @@ describe('', () => { it('Should use dialogComponentClass', () => { const noOp = () => {}; - function CustomDialog() { - return
        ; - } + const CustomDialog = React.forwardRef((props, ref) => ( +
        + )); let instance; render( @@ -246,10 +242,7 @@ describe('', () => { ); - assert.equal( - instance._modal.dialog.firstElementChild.className, - 'custom-dialog' - ); + assert.equal(instance._modal.dialog.className, 'custom-dialog'); }); it('Should pass transition callbacks to Transition', done => { From 3e0731de434dab15f2d0eef481faee5f5047e5fa Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Wed, 29 Jul 2026 11:38:56 -0400 Subject: [PATCH 6/9] Forward refs through Fade component and change ref merging to be backwards compatible --- src/Collapse.js | 7 ++--- src/Dropdown.js | 14 +++++----- src/Fade.js | 59 +++++++++++++++++++++--------------------- src/Modal.js | 16 +++++++----- src/utils/mergeRefs.js | 41 +++++++++++++++++++++++++++++ test/FadeSpec.js | 17 ++++++------ 6 files changed, 98 insertions(+), 56 deletions(-) create mode 100644 src/utils/mergeRefs.js diff --git a/src/Collapse.js b/src/Collapse.js index cf0d8002b4..affcc4530b 100644 --- a/src/Collapse.js +++ b/src/Collapse.js @@ -11,6 +11,7 @@ import Transition, { import capitalize from './utils/capitalize'; import createChainedFunction from './utils/createChainedFunction'; +import { getElementRef, makeMergedRef } from './utils/mergeRefs'; const MARGINS = { height: ['marginTop', 'marginBottom'], @@ -206,11 +207,7 @@ class Collapse extends React.Component { const handleExit = createChainedFunction(this.handleExit, onExit); const handleExiting = createChainedFunction(this.handleExiting, onExiting); - let ref = c => { - this.childRef.current = c; - }; - - ref = createChainedFunction(children.props.ref, ref); + const ref = makeMergedRef([this.childRef, getElementRef(children)]); return ( { - this.menu = c; - }; - - if (child.props && child.props.ref) { - ref = createChainedFunction(child.props.ref, ref); - } + const ref = makeMergedRef([ + el => { + this.menu = el; + }, + getElementRef(child) + ]); return cloneElement(child, { ...props, diff --git a/src/Fade.js b/src/Fade.js index 26b17ac66b..b64fc118fc 100644 --- a/src/Fade.js +++ b/src/Fade.js @@ -5,8 +5,7 @@ import Transition, { ENTERED, ENTERING } from 'react-transition-group/Transition'; - -import createChainedFunction from './utils/createChainedFunction'; +import { getElementRef, useMergedRef } from './utils/mergeRefs'; const propTypes = { /** @@ -63,41 +62,43 @@ const propTypes = { onExited: PropTypes.func }; -const defaultProps = { - in: false, - timeout: 300, - mountOnEnter: false, - unmountOnExit: false, - appear: false -}; - const fadeStyles = { [ENTERING]: 'in', [ENTERED]: 'in' }; -class Fade extends React.Component { - constructor(props) { - super(props); - - this.childRef = React.createRef(); - } - - render() { - const { className, children, ...props } = this.props; - - let ref = c => { - this.childRef.current = c; - }; - - ref = createChainedFunction(children.props.ref, ref); +const Fade = React.forwardRef( + ( + { + className, + children, + in: inProp = false, + timeout = 300, + mountOnEnter = false, + unmountOnExit = false, + appear = false, + ...props + }, + ref + ) => { + const childRef = React.useRef(null); + + const setChildRef = useMergedRef([childRef, getElementRef(children), ref]); return ( - + {(status, innerProps) => React.cloneElement(children, { ...innerProps, - ref, + ref: setChildRef, className: classNames( 'fade', className, @@ -109,9 +110,9 @@ class Fade extends React.Component { ); } -} +); +Fade.displayName = 'Fade'; Fade.propTypes = propTypes; -Fade.defaultProps = defaultProps; export default Fade; diff --git a/src/Modal.js b/src/Modal.js index 3835bad0f8..38cf2bbb22 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -138,13 +138,15 @@ const defaultProps = { }; /* eslint-disable no-use-before-define, react/no-multi-comp */ -function DialogTransition(props) { - return ; -} - -function BackdropTransition(props) { - return ; -} +const DialogTransition = React.forwardRef((props, ref) => ( + +)); +DialogTransition.displayName = 'DialogTransition'; + +const BackdropTransition = React.forwardRef((props, ref) => ( + +)); +BackdropTransition.displayName = 'BackdropTransition'; /* eslint-enable no-use-before-define */ diff --git a/src/utils/mergeRefs.js b/src/utils/mergeRefs.js new file mode 100644 index 0000000000..0497882706 --- /dev/null +++ b/src/utils/mergeRefs.js @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; + +const hasRefProp = parseInt(React.version, 10) >= 19; + +/** + * Given any number of React ref callbacks or ref objects, returns a new ref callback + * that sets all of the provided refs. This result isn't memoized, so useMergedRef + * should be preferred. + */ +export function makeMergedRef(refs) { + return el => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(el); + } else if (ref != null) { + ref.current = el; + } + }); + }; +} + +/** + * Given any number of React ref callbacks or ref objects, returns a new ref callback + * that sets all of the provided refs. + */ +export function useMergedRef(refs) { + return useCallback(makeMergedRef(refs), refs); +} + +/** + * Given a React element, returns the ref of that object in a way that's compatible + * with different React versions. + */ +export function getElementRef(reactElement) { + if (!reactElement) { + return null; + } + + // Accessing reactElement.ref prints a warning in React 19 and will likely be removed eventually + return hasRefProp ? reactElement.props.ref : reactElement.ref; +} diff --git a/test/FadeSpec.js b/test/FadeSpec.js index 8f7e1f87da..6a50c9d74b 100644 --- a/test/FadeSpec.js +++ b/test/FadeSpec.js @@ -21,21 +21,22 @@ describe('Fade', () => { }); it('Should default to hidden', () => { - let instance; - render( - (instance = element)}>Panel content - ); + render(Panel content); - assert.ok(instance.fade.props.in === false); + assert.equal(screen.getByText('Panel content').className, 'fade'); }); - it('Should always have the "fade" class', () => { + it('Should forward its ref to the child element', () => { let instance; render( (instance = element)}>Panel content ); - assert.ok(instance.fade.props.in === false); + assert.equal(instance.fade, screen.getByText('Panel content')); + }); + + it('Should always have the "fade" class', () => { + render(Panel content); assert.equal(screen.getByText('Panel content').className, 'fade'); }); @@ -51,7 +52,7 @@ describe('Fade', () => { done(); } - assert.ok(instance.fade.props.in === false); + assert.equal(screen.getByText('Panel content').className, 'fade'); act(() => { instance.setState({ in: true, onEntering }); From e80c73246dcdb3f2cb476e05085237a56b27c734 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 6 Aug 2026 09:26:34 -0400 Subject: [PATCH 7/9] Address feedback 1 - Fix TabPane transition - Fix callbacks to transitions to pass element as first parameter - Remove dialogComponentClass prop from Modal --- src/Collapse.js | 23 ++++++++++++++++++----- src/Fade.js | 23 +++++++++++++++++++++-- src/Modal.js | 16 +++------------- src/TabPane.js | 4 ++++ src/utils/withRef.js | 6 ++++++ test/ModalSpec.js | 22 ---------------------- 6 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 src/utils/withRef.js diff --git a/src/Collapse.js b/src/Collapse.js index affcc4530b..3fe186808e 100644 --- a/src/Collapse.js +++ b/src/Collapse.js @@ -12,6 +12,7 @@ import Transition, { import capitalize from './utils/capitalize'; import createChainedFunction from './utils/createChainedFunction'; import { getElementRef, makeMergedRef } from './utils/mergeRefs'; +import withRef from './utils/withRef'; const MARGINS = { height: ['marginTop', 'marginBottom'], @@ -198,14 +199,26 @@ class Collapse extends React.Component { delete props.dimension; delete props.getDimensionValue; - const handleEnter = createChainedFunction(this.handleEnter, onEnter); + const handleEnter = createChainedFunction( + this.handleEnter, + withRef(onEnter, this.childRef) + ); const handleEntering = createChainedFunction( this.handleEntering, - onEntering + withRef(onEntering, this.childRef) + ); + const handleEntered = createChainedFunction( + this.handleEntered, + withRef(onEntered, this.childRef) + ); + const handleExit = createChainedFunction( + this.handleExit, + withRef(onExit, this.childRef) + ); + const handleExiting = createChainedFunction( + this.handleExiting, + withRef(onExiting, this.childRef) ); - const handleEntered = createChainedFunction(this.handleEntered, onEntered); - const handleExit = createChainedFunction(this.handleExit, onExit); - const handleExiting = createChainedFunction(this.handleExiting, onExiting); const ref = makeMergedRef([this.childRef, getElementRef(children)]); diff --git a/src/Fade.js b/src/Fade.js index b64fc118fc..f75195477a 100644 --- a/src/Fade.js +++ b/src/Fade.js @@ -1,11 +1,12 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; import Transition, { ENTERED, ENTERING } from 'react-transition-group/Transition'; import { getElementRef, useMergedRef } from './utils/mergeRefs'; +import withRef from './utils/withRef'; const propTypes = { /** @@ -77,17 +78,35 @@ const Fade = React.forwardRef( mountOnEnter = false, unmountOnExit = false, appear = false, + onEnter, + onEntering, + onEntered, + onExit, + onExiting, + onExited, ...props }, ref ) => { - const childRef = React.useRef(null); + const childRef = useRef(null); const setChildRef = useMergedRef([childRef, getElementRef(children), ref]); + // Transition doesn't pass the node as the first parameter of these callbacks when nodeRef is used, + // so we add that ourselves to keep the API for Fade consistent + const callbacks = { + onEnter: useMemo(() => withRef(onEnter, childRef), [onEnter]), + onEntering: useMemo(() => withRef(onEntering, childRef), [onEntering]), + onEntered: useMemo(() => withRef(onEntered, childRef), [onEntered]), + onExit: useMemo(() => withRef(onExit, childRef), [onExit]), + onExiting: useMemo(() => withRef(onExiting, childRef), [onExiting]), + onExited: useMemo(() => withRef(onExited, childRef), [onExited]) + }; + return ( ; }, - animation: true, - dialogComponentClass: ModalDialog + animation: true }; /* eslint-disable no-use-before-define, react/no-multi-comp */ @@ -239,7 +230,6 @@ class Modal extends React.Component { backdropStyle, animation, show, - dialogComponentClass: Dialog, className, style, children, // Just in case this get added to BaseModal propTypes. @@ -277,7 +267,7 @@ class Modal extends React.Component { /> )} renderDialog={renderProps => ( - {children} - + )} onEntering={createChainedFunction(onEntering, this.handleEntering)} onExited={createChainedFunction(onExited, this.handleExited)} diff --git a/src/TabPane.js b/src/TabPane.js index 08895bdb2b..14334790cf 100644 --- a/src/TabPane.js +++ b/src/TabPane.js @@ -91,6 +91,8 @@ class TabPane extends React.Component { this.handleExited = this.handleExited.bind(this); this.in = false; + + this.paneRef = React.createRef(); } componentDidMount() { @@ -220,6 +222,7 @@ class TabPane extends React.Component { const pane = (
        {pane} diff --git a/src/utils/withRef.js b/src/utils/withRef.js new file mode 100644 index 0000000000..2871a09b54 --- /dev/null +++ b/src/utils/withRef.js @@ -0,0 +1,6 @@ +/** + * Given a function, returns that function with the first parameter set to the referenced element. + */ +export default function withRef(callback, ref) { + return callback ? (...args) => callback(ref.current, ...args) : undefined; +} diff --git a/test/ModalSpec.js b/test/ModalSpec.js index 920c851851..d004a798e0 100644 --- a/test/ModalSpec.js +++ b/test/ModalSpec.js @@ -223,28 +223,6 @@ describe('', () => { assert.ok(dialog.className.match(/\btestCss\b/)); }); - it('Should use dialogComponentClass', () => { - const noOp = () => {}; - - const CustomDialog = React.forwardRef((props, ref) => ( -
        - )); - - let instance; - render( - (instance = element)} - show - dialogComponentClass={CustomDialog} - onHide={noOp} - > - Message - - ); - - assert.equal(instance._modal.dialog.className, 'custom-dialog'); - }); - it('Should pass transition callbacks to Transition', done => { let count = 0; const increment = () => { From f05e02cebc1ea4aee63b4f69eb8c28a5a93b4a2a Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 6 Aug 2026 10:01:17 -0400 Subject: [PATCH 8/9] Address feedback: Fix positioning of Overlay, Tooltip, and Popover (breaking change) Breaking change: overlay prop of OverlayTrigger and child of Overlay must forward ref to a DOM node. It will cause tooltips to either not appear or not be correctly positioned in affected plugins. The only one of our plugins that should be affected is mattermost-plugin-legal-hold. To fix it, its Tooltip must be updated to forward a ref to the inner RBTooltip, and OverlayTrigger must similarly pass a ref to the child of OverlayWrapper. --- package.json | 1 + src/Overlay.js | 39 +++++++++++++++++++++++++++++++------ src/Popover.js | 30 ++++++++++++++-------------- src/Tooltip.js | 36 +++++++++++++++++++--------------- src/utils/bootstrapUtils.js | 6 +++++- test/OverlayTriggerSpec.js | 37 ++++++++++++++++++++++------------- 6 files changed, 98 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index f723625a41..b7e3e20f55 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "keycode": "^2.2.0", "prop-types": "^15.6.1", "prop-types-extra": "^1.0.1", + "react-is": "^18.3.1", "react-overlays": "^5.2.0", "react-prop-types": "^0.4.0", "react-transition-group": "^4.4.0", diff --git a/src/Overlay.js b/src/Overlay.js index fbfa2520ad..208a977550 100644 --- a/src/Overlay.js +++ b/src/Overlay.js @@ -9,6 +9,11 @@ import Fade from './Fade'; const propTypes = { ...BaseOverlay.propTypes, + /** + * The element that's rendered in the overlay + */ + children: PropTypes.node, + /** * Set the visibility of the Overlay */ @@ -88,12 +93,34 @@ class Overlay extends React.Component { } return ( - - {({ props: overlayProps }) => ( -
        - {child} -
        - )} + + {({ arrowProps, placement, props: overlayProps }) => { + const positionTop = + overlayProps && overlayProps.style && overlayProps.style.top; + const positionLeft = + overlayProps && overlayProps.style && overlayProps.style.left; + + // Passing the positionX and arrowOffsetX props is redundant with the style props, + // but it matches how react-overlays used to behave. + return React.cloneElement(child, { + ...overlayProps, + placement, + positionTop, + positionLeft, + arrowOffsetTop: + arrowProps && arrowProps.style && arrowProps.style.top, + arrowOffsetLeft: + arrowProps && arrowProps.style && arrowProps.style.left, + arrowRef: arrowProps.ref, + arrowStyle: arrowProps.style, + style: { + ...child.props.style, + ...overlayProps.style, + left: positionLeft, + top: positionTop + } + }); + }} ); } diff --git a/src/Popover.js b/src/Popover.js index 8b78ceef3c..b69772ccd8 100644 --- a/src/Popover.js +++ b/src/Popover.js @@ -49,25 +49,24 @@ const propTypes = { title: PropTypes.node }; -const defaultProps = { - placement: 'right' -}; - -class Popover extends React.Component { - render() { - const { - placement, +const Popover = React.forwardRef( + ( + { + placement = 'right', positionTop, positionLeft, arrowOffsetTop, arrowOffsetLeft, + arrowRef, + arrowStyle, title, className, style, children, ...props - } = this.props; - + }, + ref + ) => { const [bsProps, elementProps] = splitBsProps(props); const classes = { @@ -82,19 +81,21 @@ class Popover extends React.Component { ...style }; - const arrowStyle = { + const combinedArrowStyle = { top: arrowOffsetTop, - left: arrowOffsetLeft + left: arrowOffsetLeft, + ...arrowStyle }; return (
        -
        +
        {title &&

        {title}

        } @@ -102,9 +103,8 @@ class Popover extends React.Component {
        ); } -} +); Popover.propTypes = propTypes; -Popover.defaultProps = defaultProps; export default bsClass('popover', Popover); diff --git a/src/Tooltip.js b/src/Tooltip.js index 5a9a7cdaa4..aa07909d9f 100644 --- a/src/Tooltip.js +++ b/src/Tooltip.js @@ -44,24 +44,23 @@ const propTypes = { arrowOffsetLeft: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) }; -const defaultProps = { - placement: 'right' -}; - -class Tooltip extends React.Component { - render() { - const { - placement, +const Tooltip = React.forwardRef( + ( + { + placement = 'right', positionTop, positionLeft, arrowOffsetTop, arrowOffsetLeft, + arrowRef, + arrowStyle, className, style, children, ...props - } = this.props; - + }, + ref + ) => { const [bsProps, elementProps] = splitBsProps(props); const classes = { @@ -75,27 +74,32 @@ class Tooltip extends React.Component { ...style }; - const arrowStyle = { + const combinedArrowStyle = { top: arrowOffsetTop, - left: arrowOffsetLeft + left: arrowOffsetLeft, + ...arrowStyle }; return (
        -
        +
        {children}
        ); } -} - +); +Tooltip.displayName = 'Tooltip'; Tooltip.propTypes = propTypes; -Tooltip.defaultProps = defaultProps; export default bsClass('tooltip', Tooltip); diff --git a/src/utils/bootstrapUtils.js b/src/utils/bootstrapUtils.js index 5735d17a21..da59292407 100644 --- a/src/utils/bootstrapUtils.js +++ b/src/utils/bootstrapUtils.js @@ -1,6 +1,7 @@ // TODO: The publicly exposed parts of this should be in lib/BootstrapUtils. import invariant from 'invariant'; +import { isValidElementType } from 'react-is'; import PropTypes from 'prop-types'; import { SIZE_MAP } from './StyleConfig'; @@ -8,9 +9,12 @@ import { SIZE_MAP } from './StyleConfig'; function curry(fn) { return (...args) => { let last = args[args.length - 1]; - if (typeof last === 'function') { + if (typeof last !== 'string' && isValidElementType(last)) { + // Supports calling curry(...args, Component) return fn(...args); } + + // Supports calling curry(...args)(Component) return Component => fn(...args, Component); }; } diff --git a/test/OverlayTriggerSpec.js b/test/OverlayTriggerSpec.js index c8eba6d4c8..23c8232114 100644 --- a/test/OverlayTriggerSpec.js +++ b/test/OverlayTriggerSpec.js @@ -1,6 +1,6 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; +import React, { useContext, useEffect } from 'react'; import OverlayTrigger from '../src/OverlayTrigger'; import Popover from '../src/Popover'; @@ -8,9 +8,11 @@ import Tooltip from '../src/Tooltip'; describe('', () => { // Swallow extra props. - const Div = ({ className, children }) => ( -
        {children}
        - ); + const Div = React.forwardRef(({ className, children }, ref) => ( +
        + {children} +
        + )); it('Should create OverlayTrigger element', () => { render( @@ -144,14 +146,19 @@ describe('', () => { const contextSpy = sinon.spy(); - class ContextReader extends React.Component { - render() { - contextSpy(this.context.key); - return
        ; - } - } + const ContextReader = React.forwardRef((props, ref) => { + const context = useContext(TestContext); + const contextValue = context.key; + useEffect( + () => { + contextSpy(contextValue); + }, + [contextValue] + ); - ContextReader.contextType = TestContext; + return
        ; + }); + ContextReader.displayName = 'ContextReader'; class ContextHolder extends React.Component { render() { @@ -289,7 +296,7 @@ describe('', () => { } return ( -
        +
        original @@ -298,9 +305,13 @@ describe('', () => { } } + const WrappedReplacedOverlay = React.forwardRef((props, ref) => ( + + )); + render( } + overlay={} trigger="click" rootClose > From 14451263976fca5612337d71b677b20ff86dc4c2 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 6 Aug 2026 13:03:16 -0400 Subject: [PATCH 9/9] Address feedback: Change OverlayTrigger element to display inline-block This fixes my previous attempt because the div didn't have a box to get the dimensions for by giving it dimensions. I'm not sure if this will work 100% because the child element can be either inline or block, but this seems to work based on some brief testing. To fully match the old behaviour, we'd attach the ref to the cloned child, but that would be a breaking change to a few plugins. --- src/OverlayTrigger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OverlayTrigger.js b/src/OverlayTrigger.js index d45a25a833..ba40324d0c 100644 --- a/src/OverlayTrigger.js +++ b/src/OverlayTrigger.js @@ -294,7 +294,7 @@ class OverlayTrigger extends React.Component { return ( <> -
        +
        {cloneElement(child, triggerProps)}
        {ReactDOM.createPortal(this.makeOverlay(overlay, props), document.body)}