Skip to content
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

Merged
merged 6 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
70 changes: 34 additions & 36 deletions packages/core/src/components/overlay2/overlay2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Comment on lines -198 to -208
Copy link
Contributor Author

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

Copy link
Member

Choose a reason for hiding this comment

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

I don't think it does what the comment suggests

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?

Copy link
Contributor Author

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)

// 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(
Expand Down Expand Up @@ -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);
Expand All @@ -307,6 +276,8 @@ export const Overlay2 = React.forwardRef<OverlayInstance, Overlay2Props>((props,
}
}
}, [closeOverlay, getLastOpened, handleDocumentFocus, handleDocumentMousedown, id]);
const mostRecetOverlayWillClose = React.useRef(overlayWillClose);
Copy link
Member

Choose a reason for hiding this comment

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

typo

Suggested change
const mostRecetOverlayWillClose = React.useRef(overlayWillClose);
const mostRecentOverlayWillClose = React.useRef(overlayWillClose);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah thanks! I decided "mostRecent" was weird anyways and went with overlayWillCloseRef and also co-located it with the useEffect so it's a bit more clear why we have this ref

mostRecetOverlayWillClose.current = overlayWillClose;

const prevIsOpen = usePrevious(isOpen) ?? false;
React.useEffect(() => {
Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 useEffect that calls overlayWillOpen because these event listeners were attached towards the end of that method and I believe useEffect hooks are called in order. But now I'm seeing these were also attached before setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement))); and now will be after.

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 overlayWillOpen runs.

// 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();
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 onClose from user actions, but I believe this is a function that has a stale closure around old versions of the document event listeners that may have already been removed.

Now we rely on the useEffect that adds the event listeners to clean up themselves - but continue calling this for the other overlay cleanup code. I didn't see any warnings about calling removeEventListener twice/for an already removed handler, and also didn't see any issues with this from my testing, but please flag if this seems bad.

Copy link
Member

Choose a reason for hiding this comment

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

I believe this is a function that has a stale closure around old versions of the document event listeners that may have already been removed.

Agree, overlayWillClose was not a dependency of this effect but itself had dependencies.

I didn't see any warnings about calling removeEventListener twice

Quoting the page you linked:

Calling removeEventListener() with arguments that do not identify any currently registered event listener on the EventTarget has no effect.

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();
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 overlayWillClose was previously a class method and so could never be a stale reference

};
}, []);

const handleTransitionExited = React.useCallback(
Expand Down
27 changes: 20 additions & 7 deletions packages/core/test/overlay2/overlay2Tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 onClose properly

});

it("not invoked on document mousedown when hasBackdrop=false and canOutsideClickClose=false", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down