-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix Overlay2 storing stale onClose callback #6874
Changes from 5 commits
f07eacf
f49bee2
2f8be21
0228043
e08e9e5
faf9128
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -195,17 +195,6 @@ export const Overlay2 = React.forwardRef<OverlayInstance, Overlay2Props>((props, | |||||
[getThisOverlayAndDescendants, id, onClose], | ||||||
); | ||||||
|
||||||
// Important: clean up old document-level event listeners if their memoized values change (this is rare, but | ||||||
// may happen, for example, if a user forgets to use `React.useCallback` in the `props.onClose` value). | ||||||
// Otherwise, we will lose the reference to those values and create a memory leak since we won't be able | ||||||
// to successfully detach them inside overlayWillClose. | ||||||
React.useEffect(() => { | ||||||
document.removeEventListener("mousedown", handleDocumentMousedown); | ||||||
}, [handleDocumentMousedown]); | ||||||
React.useEffect(() => { | ||||||
document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); | ||||||
}, [handleDocumentFocus]); | ||||||
|
||||||
// send this instance's imperative handle to the the forwarded ref as well as our local ref | ||||||
const ref = React.useMemo(() => mergeRefs(forwardedRef, instance), [forwardedRef]); | ||||||
React.useImperativeHandle( | ||||||
|
@@ -264,28 +253,8 @@ export const Overlay2 = React.forwardRef<OverlayInstance, Overlay2Props>((props, | |||||
bringFocusInsideOverlay(); | ||||||
} | ||||||
|
||||||
if (enforceFocus) { | ||||||
// Focus events do not bubble, but setting useCapture allows us to listen in and execute | ||||||
// our handler before all others | ||||||
document.addEventListener("focus", handleDocumentFocus, /* useCapture */ true); | ||||||
} | ||||||
|
||||||
if (canOutsideClickClose && !hasBackdrop) { | ||||||
document.addEventListener("mousedown", handleDocumentMousedown); | ||||||
} | ||||||
|
||||||
setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement))); | ||||||
}, [ | ||||||
autoFocus, | ||||||
bringFocusInsideOverlay, | ||||||
canOutsideClickClose, | ||||||
enforceFocus, | ||||||
getLastOpened, | ||||||
handleDocumentMousedown, | ||||||
handleDocumentFocus, | ||||||
hasBackdrop, | ||||||
openOverlay, | ||||||
]); | ||||||
}, [autoFocus, bringFocusInsideOverlay, getLastOpened, openOverlay]); | ||||||
|
||||||
const overlayWillClose = React.useCallback(() => { | ||||||
document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); | ||||||
|
@@ -307,6 +276,8 @@ export const Overlay2 = React.forwardRef<OverlayInstance, Overlay2Props>((props, | |||||
} | ||||||
} | ||||||
}, [closeOverlay, getLastOpened, handleDocumentFocus, handleDocumentMousedown, id]); | ||||||
const mostRecetOverlayWillClose = React.useRef(overlayWillClose); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. typo
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah thanks! I decided "mostRecent" was weird anyways and went with |
||||||
mostRecetOverlayWillClose.current = overlayWillClose; | ||||||
|
||||||
const prevIsOpen = usePrevious(isOpen) ?? false; | ||||||
React.useEffect(() => { | ||||||
|
@@ -325,14 +296,41 @@ export const Overlay2 = React.forwardRef<OverlayInstance, Overlay2Props>((props, | |||||
} | ||||||
}, [isOpen, overlayWillOpen, overlayWillClose, prevIsOpen]); | ||||||
|
||||||
// run once on unmount | ||||||
// Important: clean up old document-level event listeners if their memoized values change (this is rare, but | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if this order matters - I moved these below the I don't expect that to be an issue, but also don't see any issues if these get attached before the other code in |
||||||
// may happen, for example, if a user forgets to use `React.useCallback` in the `props.onClose` value). | ||||||
// Otherwise, we will lose the reference to those values and create a memory leak since we won't be able | ||||||
// to successfully detach them inside overlayWillClose. | ||||||
React.useEffect(() => { | ||||||
if (!isOpen || !(canOutsideClickClose && !hasBackdrop)) { | ||||||
return; | ||||||
} | ||||||
|
||||||
document.addEventListener("mousedown", handleDocumentMousedown); | ||||||
|
||||||
return () => { | ||||||
// we need to run cleanup code to remove some event handlers from the overlay element | ||||||
overlayWillClose(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't able to find a test case that can assert this because we only call Now we rely on the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Agree, overlayWillClose was not a dependency of this effect but itself had dependencies.
Quoting the page you linked:
This should be fine. |
||||||
document.removeEventListener("mousedown", handleDocumentMousedown); | ||||||
}; | ||||||
}, [handleDocumentMousedown, isOpen, canOutsideClickClose, hasBackdrop]); | ||||||
React.useEffect(() => { | ||||||
if (!isOpen || !enforceFocus) { | ||||||
return; | ||||||
} | ||||||
|
||||||
// Focus events do not bubble, but setting useCapture allows us to listen in and execute | ||||||
// our handler before all others | ||||||
document.addEventListener("focus", handleDocumentFocus, /* useCapture */ true); | ||||||
|
||||||
return () => { | ||||||
document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true); | ||||||
}; | ||||||
}, [handleDocumentFocus, enforceFocus, isOpen]); | ||||||
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||||||
React.useEffect(() => { | ||||||
// run cleanup code once on unmount, ensuring we call the most recent overlayWillClose callback | ||||||
// by storing in a ref and keeping up to date | ||||||
return () => { | ||||||
mostRecetOverlayWillClose.current(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to #6753 (review) this diff brings this closer to the original behavior, as |
||||||
}; | ||||||
}, []); | ||||||
|
||||||
const handleTransitionExited = React.useCallback( | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -184,8 +184,13 @@ describe("<Overlay2>", () => { | |
{createOverlayContents()} | ||
</OverlayWrapper>, | ||
); | ||
|
||
// ensure onClose can be updated | ||
const onClose2 = spy(); | ||
overlay.setProps({ onClose: onClose2 }); | ||
overlay.find(BACKDROP_SELECTOR).simulate("mousedown"); | ||
assert.isTrue(onClose.calledOnce); | ||
assert.isTrue(onClose.notCalled); | ||
assert.isTrue(onClose2.calledOnce); | ||
}); | ||
|
||
it("not invoked on backdrop mousedown when canOutsideClickClose=false", () => { | ||
|
@@ -201,15 +206,18 @@ describe("<Overlay2>", () => { | |
|
||
it("invoked on document mousedown when hasBackdrop=false", () => { | ||
const onClose = spy(); | ||
// mounting cuz we need document events + lifecycle | ||
mountWrapper( | ||
<OverlayWrapper hasBackdrop={false} isOpen={true} onClose={onClose} usePortal={false}> | ||
const overlay = mountWrapper( | ||
<OverlayWrapper isOpen={true} onClose={onClose} usePortal={false} hasBackdrop={false}> | ||
{createOverlayContents()} | ||
</OverlayWrapper>, | ||
); | ||
|
||
// ensure onClose can be updated | ||
const onClose2 = spy(); | ||
overlay.setProps({ onClose: onClose2 }); | ||
dispatchMouseEvent(document.documentElement, "mousedown"); | ||
assert.isTrue(onClose.calledOnce); | ||
assert.isTrue(onClose.notCalled); | ||
assert.isTrue(onClose2.calledOnce); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this test case was previously failing - the other methods of closing handled updating |
||
}); | ||
|
||
it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => { | ||
|
@@ -249,13 +257,18 @@ describe("<Overlay2>", () => { | |
|
||
it("invoked on escape key", () => { | ||
const onClose = spy(); | ||
mountWrapper( | ||
const overlay = mountWrapper( | ||
<OverlayWrapper isOpen={true} onClose={onClose} usePortal={false}> | ||
{createOverlayContents()} | ||
</OverlayWrapper>, | ||
); | ||
|
||
// ensure onClose can be updated | ||
const onClose2 = spy(); | ||
overlay.setProps({ onClose: onClose2 }); | ||
wrapper.simulate("keydown", { key: "Escape" }); | ||
assert.isTrue(onClose.calledOnce); | ||
assert.isTrue(onClose.notCalled); | ||
assert.isTrue(onClose2.calledOnce); | ||
}); | ||
|
||
it("not invoked on escape key when canEscapeKeyClose=false", () => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
linking diff that added: https://github.com/palantir/blueprint/pull/6681/files#diff-50ceef90072c82bdf126a28d4f90f718d8a384f60d4ac90e7b1a9d2c4f4e57d4R227-R232
I think this had the right idea but I don't think it does what the comment suggests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean because it's doing this directly in the effect vs. in a cleanup function returned from the effect? Or did you find something else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea, since it's directly in the effect it's reference the version of the handler that was just defined and never had a chance to be attached (or worse was just intentionally attached, but I didn't find a case of this happening)