Skip to content

Commit ba2fe90

Browse files
mghdiasbruno
authored andcommitted
[fixed] respect closeTimeoutMS during unmount
Fixes issue #17: "When the modal is unmounted, it will abruptly close, not waiting for any animations to finish." The bug was caused by the Modal component unmounting the portal immediately in `componentWillUnmount` regardless of whether the portal is currently closing or would animate to close. Now when a Modal has a non-zero `closeTimeoutMS`, the Modal inspects the portal state before closing. If the portal is open or closing, but not closed, it waits to unmount the portal. If the portal is open and not already closing, the Modal calls closeWithTimeout() to trigger the close. Adds test to ensure portal DOM persists after Modal is unmounted when the Modal has a `closeTimeoutMS` set. Updates existing tests to properly unmount test Modal instances.
1 parent ebec638 commit ba2fe90

File tree

3 files changed

+88
-26
lines changed

3 files changed

+88
-26
lines changed

lib/components/Modal.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ export default class Modal extends Component {
105105
ariaAppHider.show(this.props.appElement);
106106
}
107107

108+
const state = this.portal.state;
109+
const now = Date.now();
110+
const closesAt = state.isOpen && this.props.closeTimeoutMS
111+
&& (state.closesAt
112+
|| now + this.props.closeTimeoutMS);
113+
114+
if (closesAt) {
115+
if (!state.beforeClose) {
116+
this.portal.closeWithTimeout();
117+
}
118+
119+
setTimeout(this.removePortal.bind(this), closesAt - now);
120+
} else {
121+
this.removePortal();
122+
}
123+
}
124+
125+
removePortal () {
108126
ReactDOM.unmountComponentAtNode(this.node);
109127
const parent = getParentElement(this.props.parentSelector);
110128
parent.removeChild(this.node);

lib/components/ModalPortal.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,9 @@ export default class ModalPortal extends Component {
132132
}
133133

134134
closeWithTimeout () {
135-
this.setState({ beforeClose: true }, () => {
136-
this.closeTimer = setTimeout(this.closeWithoutTimeout, this.props.closeTimeoutMS);
135+
const closesAt = Date.now() + this.props.closeTimeoutMS;
136+
this.setState({ beforeClose: true, closesAt }, () => {
137+
this.closeTimer = setTimeout(this.closeWithoutTimeout, this.state.closesAt - Date.now());
137138
});
138139
}
139140

@@ -142,7 +143,8 @@ export default class ModalPortal extends Component {
142143
beforeClose: false,
143144
isOpen: false,
144145
afterOpen: false,
145-
}, () => this.afterClose());
146+
closesAt: null
147+
}, this.afterClose);
146148
}
147149

148150
handleKeyDown = (event) => {
@@ -182,7 +184,7 @@ export default class ModalPortal extends Component {
182184
}
183185

184186
shouldBeClosed () {
185-
return !this.props.isOpen && !this.state.beforeClose;
187+
return !this.state.isOpen && !this.state.beforeClose;
186188
}
187189

188190
contentHasFocus () {

specs/Modal.spec.js

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ const Simulate = TestUtils.Simulate;
1818

1919

2020
describe('Modal', () => {
21+
afterEach('check if test cleaned up rendered modals', () => {
22+
const overlay = document.querySelectorAll('.ReactModal__Overlay');
23+
const content = document.querySelectorAll('.ReactModal__Content');
24+
expect(overlay.length).toBe(0);
25+
expect(content.length).toBe(0);
26+
});
27+
2128
it('scopes tab navigation to the modal');
2229
it('focuses the last focused element when tabbing in from browser chrome');
2330

@@ -115,6 +122,7 @@ describe('Modal', () => {
115122
const modal = renderModal({
116123
isOpen: true,
117124
onRequestClose () {
125+
unmountModal();
118126
unmountModal();
119127
done();
120128
}
@@ -175,6 +183,7 @@ describe('Modal', () => {
175183
preventDefault () { tabPrevented = true; }
176184
});
177185
expect(tabPrevented).toEqual(true);
186+
unmountModal();
178187
});
179188

180189
it('supports portalClassName', () => {
@@ -204,26 +213,31 @@ describe('Modal', () => {
204213
it('overrides the default styles when a custom overlayClassName is used', () => {
205214
const modal = renderModal({ isOpen: true, overlayClassName: 'myOverlayClass' });
206215
expect(modal.portal.overlay.style.backgroundColor).toEqual('');
216+
unmountModal();
207217
});
208218

209219
it('supports adding style to the modal contents', () => {
210220
const modal = renderModal({ isOpen: true, style: { content: { width: '20px' } } });
211221
expect(modal.portal.content.style.width).toEqual('20px');
222+
unmountModal();
212223
});
213224

214225
it('supports overriding style on the modal contents', () => {
215226
const modal = renderModal({ isOpen: true, style: { content: { position: 'static' } } });
216227
expect(modal.portal.content.style.position).toEqual('static');
228+
unmountModal();
217229
});
218230

219231
it('supports adding style on the modal overlay', () => {
220232
const modal = renderModal({ isOpen: true, style: { overlay: { width: '75px' } } });
221233
expect(modal.portal.overlay.style.width).toEqual('75px');
234+
unmountModal();
222235
});
223236

224237
it('supports overriding style on the modal overlay', () => {
225238
const modal = renderModal({ isOpen: true, style: { overlay: { position: 'static' } } });
226239
expect(modal.portal.overlay.style.position).toEqual('static');
240+
unmountModal();
227241
});
228242

229243
it('supports overriding the default styles', () => {
@@ -234,14 +248,17 @@ describe('Modal', () => {
234248
const modal = renderModal({ isOpen: true });
235249
expect(modal.portal.content.style.position).toEqual(newStyle);
236250
Modal.defaultStyles.content.position = previousStyle;
251+
unmountModal();
237252
});
238253

239254
it('adds class to body when open', () => {
240255
renderModal({ isOpen: false });
241256
expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(false);
257+
unmountModal();
242258

243259
renderModal({ isOpen: true });
244260
expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(true);
261+
unmountModal();
245262

246263
renderModal({ isOpen: false });
247264
expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(false);
@@ -323,6 +340,7 @@ describe('Modal', () => {
323340
expect(event).toBeTruthy();
324341
expect(event.constructor).toBeTruthy();
325342
expect(event.key).toEqual('FakeKeyToTestLater');
343+
unmountModal();
326344
});
327345

328346
describe('should close on overlay click', () => {
@@ -455,26 +473,50 @@ describe('Modal', () => {
455473
});
456474
});
457475

458-
// it('adds --before-close for animations', function() {
459-
// var node = document.createElement('div');
460-
461-
// var component = ReactDOM.render(React.createElement(Modal, {
462-
// isOpen: true,
463-
// ariaHideApp: false,
464-
// closeTimeoutMS: 50,
465-
// }), node);
466-
467-
// component = ReactDOM.render(React.createElement(Modal, {
468-
// isOpen: false,
469-
// ariaHideApp: false,
470-
// closeTimeoutMS: 50,
471-
// }), node);
472-
473-
// It can't find these nodes, I didn't spend much time on this
474-
// var overlay = document.querySelector('.ReactModal__Overlay');
475-
// var content = document.querySelector('.ReactModal__Content');
476-
// ok(overlay.className.match(/ReactModal__Overlay--before-close/));
477-
// ok(content.className.match(/ReactModal__Content--before-close/));
478-
// unmountModal();
479-
// });
476+
it('adds --before-close for animations', () => {
477+
const closeTimeoutMS = 50;
478+
const modal = renderModal({
479+
isOpen: true,
480+
closeTimeoutMS
481+
});
482+
483+
modal.portal.closeWithTimeout();
484+
485+
const overlay = TestUtils.findRenderedDOMComponentWithClass(modal.portal, 'ReactModal__Overlay');
486+
const content = TestUtils.findRenderedDOMComponentWithClass(modal.portal, 'ReactModal__Content');
487+
488+
expect(/ReactModal__Overlay--before-close/.test(overlay.className)).toBe(true);
489+
expect(/ReactModal__Content--before-close/.test(content.className)).toBe(true);
490+
491+
modal.portal.closeWithoutTimeout();
492+
unmountModal();
493+
});
494+
495+
it('keeps the modal in the DOM until closeTimeoutMS elapses', (done) => {
496+
const closeTimeoutMS = 50;
497+
498+
renderModal({
499+
isOpen: true,
500+
closeTimeoutMS
501+
});
502+
503+
unmountModal();
504+
505+
const checkDOM = (expectMounted) => {
506+
const overlay = document.querySelectorAll('.ReactModal__Overlay');
507+
const content = document.querySelectorAll('.ReactModal__Content');
508+
const numNodes = expectMounted ? 1 : 0;
509+
expect(overlay.length).toBe(numNodes);
510+
expect(content.length).toBe(numNodes);
511+
};
512+
513+
// content is still mounted after modal is gone
514+
checkDOM(true);
515+
516+
setTimeout(() => {
517+
// content is unmounted after specified timeout
518+
checkDOM(false);
519+
done();
520+
}, closeTimeoutMS);
521+
});
480522
});

0 commit comments

Comments
 (0)