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

Popover: remove click-outside and rewrite the lifecycle logic (try 2) #38279

Merged
merged 5 commits into from
Dec 19, 2019

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Dec 9, 2019

Second attempt at landing the Popover refactoring from #37766 that was reverted in #38229.

The problem was that the deferred (_.defer) calls to set position after component rerender (componentDidUpdate) were not cancelled when the Popover component was hidden or unmounted immediately after that rerender. I.e., before the deferred call had a chance to run. Then the deferred callback tried to access a DOM element ref that no longer existed.

I know about two practical cases where this happens:

Stats Chart: when hovering over an empty chart bar, the popover is shown on mouseenter event, then shows right under the mouse cursor, which triggers a mouseleave event and the popover is hidden. Very quickly. That's a preexisting bug that triggers the "reposition after hide" condition.

Here's a screencast that shows the "appear-under-cursor" phenomenon. The hiding event is disabled:
Screen Capture on 2019-12-06 at 09-24-28

Opening Happychat: Here, click on a "Chat with us" button dispatches a Redux action that opens Happychat, and the Inline Help popover also listens for change in the result of isHappyChat Redux selector and closes itself when Happychat appears:

onButtonClick = () => {
  dispatch( openHappychat() );
}

componentWillReceiveProps( nextProps ) {
  if ( ! this.props.isHappychatOpen && nextProps.isHappychatOpen ) {
    this.closeInlineHelp();
  }
}

Again, rerender of the InlineHelpPopover component and its unmount happen very quickly after each other.

In the case of Inline Help Popover, however, I'm not 100% sure what exactly is happening. When reasoning about the code, I don't see why crash of the deferred call should prevent opening Happychat. But I can't reproduce the Happychat-not-opening bug on the updated branch.

How I fixed it: In the Popover component, I added code that carefully cancels all outstanding deferred calls when the Popover is hidden or unmounted.

Also, in InlineHelpPopover, I moved the code that closes the popover from componentWillReceiveProps (deprecated method) to componentDidUpdate (more appropriate for firing side effects). That should be another safety belt that ensures that the lifecycles execute properly.

@jsnajdr jsnajdr added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Dec 9, 2019
@jsnajdr jsnajdr requested a review from a team as a code owner December 9, 2019 07:06
@jsnajdr jsnajdr self-assigned this Dec 9, 2019
@matticbot
Copy link
Contributor

@matticbot
Copy link
Contributor

matticbot commented Dec 9, 2019

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

App Entrypoints (~8448 bytes removed 📉 [gzipped])

name                 parsed_size           gzip_size
entry-main              -11126 B  (-0.7%)    -2787 B  (-0.7%)
entry-login             -11126 B  (-1.0%)    -2837 B  (-1.0%)
entry-gutenboarding     -11126 B  (-0.4%)    -2824 B  (-0.4%)

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Async-loaded Components (~11 bytes removed 📉 [gzipped])

name                           parsed_size           gzip_size
async-load-blocks-inline-help        -14 B  (-0.0%)      -11 B  (-0.1%)

React components that are loaded lazily, when a certain part of UI is displayed for the first time.

Legend

What is parsed and gzip size?

Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

Generated by performance advisor bot at iscalypsofastyet.com.

@blowery
Copy link
Contributor

blowery commented Dec 9, 2019

When changing the site in the help popover, the popover closes when you pick a site.

disappear

@jsnajdr
Copy link
Member Author

jsnajdr commented Dec 9, 2019

When changing the site in the help popover, the popover closes when you pick a site.

There are two popovers nested inside each other 🤔 And clicking inside the inner one means clicking outside the outer one. I'll check why the previous version handled this correctly.

Thanks for testing and catching this!

@jsnajdr
Copy link
Member Author

jsnajdr commented Dec 12, 2019

@blowery That sites dropdown inside inline help that closes the popover when it shoudn't -- that's a great catch!

First, there are no nested Popover components as I originally thought. The SitesDropdown component doesn't use Popover and React portals, but the expanded sites list is simply an absolutely positioned div.

After selecting a site inside the dropdown, there are two click listeners that are fired:

  1. There is a clickout handler on the InlineHelpPopover, bound to document.body, that checks if the event.target is inside the Popover's DOM element and closes the popover if it isn't. popoverEl.contains( event.target ) is used to check that.
  2. There is a React click handler on the Site component, handling the actual business logic of selecting a site, and it also closes the SitesDropdown, removing the DOM element you just clicked on. Note also that because of React synthetic event system, the DOM handler for this click is also on document.body.

In the original Popover version, the above two handlers ran exactly in that order. First the clickout handler, then the React click handler. That's because the clickout handler was registered first. It happened in the click-outside module -- see here how the handler is attached during static module initialization.

But with the new Popover, I removed the clickout-outside package and instead register the clickout handler during Popover mount. Then the two document.body click handlers run in a reversed order.

First, the site selection is handled and the event.target element gets removed from DOM (SitesDropdown closes)

Second, the clickout handler runs and the popoverEl.contains( event.target ) condition is suddenly false. The event.target is no longer in the DOM tree! Click outside the InlineHelpPopover is detected and the popover is closed.

I fixed this bug in 36deb02 by running the clickout listener in the capture phase -- before any other events (that run in the bubble phase). The right order is established again.

Please test and review 🙂

@jsnajdr
Copy link
Member Author

jsnajdr commented Dec 17, 2019

Today I fixed one more subtle bug that I discovered when testing @sgomes' Publicize changes in #37078. There are two nested popovers there:

Screenshot 2019-12-17 at 13 19 29

And the render code looks somewhat like this:

const [ showOuterPopover, setOuterPopover ] = useState( false );
const [ showInnerPopover, setInnerPopover ] = useState( false );
const anchorRef = useRef();

return (
  <>
    { showOuterPopover && <Popover>
        <OuterPopoverContent
          anchorRef={ anchorRef }
          toggleInnerPopover={ setInnerPopover }
        />
      </Popover> }
    <Popover isVisible={ showInnerPopover } context={ anchorRef.current } >
      <InnerPopoverContent />
    </Popover>
  </>
);

The OuterPopoverContent sets the anchorRef to a DOM element inside itself to anchor the inner popover to, and also controls whether the inner popover is displayed.

However, when mounting this UI, the inner popover is mounted first, because its render is not conditional, but rather uses the isVisible prop. And because its portal and the div container is rendered even for isVisible false, the div for the inner popover is in the document.body before the div for the outer popover. And therefore has a lower natural z-index and is shown behind it 👎

I refactored the Popover in such a way that the portal (i.e., the RootChild component) is rendered only after isVisible becomes true. That removes the difference between conditional and unconditional render and the popover that shows last is the last one in DOM, and also on top of the z-index order.

Second attempt at landing the `Popover` refactoring from #37766 that was reverted in #38229.
…omponent

The `Popover` component uses `_.defer` to delay certain tasks, like reposition on
rerender or focus after show, to the next event loop ticks.

However, in certain edge cases (that happen often enough) the `Popover` can be hidden
or unmounted before these tasks have a chance to run. In such case, they try to work
with a DOM element that no longer exist and can crash.

This patch carefully tracks these deferred tasks and cancels them when the `Popover`
is being hidden or unmounted.
…g chat

The Inline Help Popover automatically closes when Happychat opens. This patch
moves the code that does the detection and closing from the legacy `componentWillReceiveProps`
to `componentDidUpdate`, which is more appropriate for firing side effects.

Accompanies the fix for `Popover` update scheduling.
The code that syncs the `isVisible` prop and `show` state, possibly delayed by
`showDelay` timeout, is extracted to the outer component, simplifying the inner
implementation a lot (removes approx 50 lines of code).

Also ensures that the `RootChild` is rendered (and its `div` element appended to DOM) only
after the `Popover` becomes visible. That ensures that when the last of several nested
`Popover`s is shown, it's also the last DOM element in the DOM tree, and therefore has
higher natural z-index and is displayed over the earlier one.
Copy link
Contributor

@blowery blowery left a comment

Choose a reason for hiding this comment

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

LGTM! Stats, the contact us popover, and the post scheduling popover all work as expected.

@jsnajdr jsnajdr merged commit 494cef1 into master Dec 19, 2019
@jsnajdr jsnajdr deleted the update/popover-try-2 branch December 19, 2019 07:15
@matticbot matticbot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Dec 19, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants