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

[React 18] Add full <StrictMode> support #7007

Merged
merged 8 commits into from
Jul 28, 2023

Conversation

tkajtoch
Copy link
Member

Summary

Fix StrictMode and async rendering issues in EuiPortal, EuiWrappingPopover and EuiTourStep, and fix more flaky cypress tests found running on 6x CPU slowdown multiplier.

QA

Ensure unit tests and cypress tests are passing.

@tkajtoch tkajtoch requested review from cee-chen and a team July 27, 2023 22:25
@tkajtoch tkajtoch self-assigned this Jul 27, 2023
if (typeof window === 'undefined') return; // Prevent SSR errors

const { insert } = this.props;

this.portalNode = document.createElement('div');
this.portalNode.dataset.euiportal = 'true';
const portalNode = document.createElement('div');
Copy link
Member Author

Choose a reason for hiding this comment

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

Elements have to be recreated on every mount (so twice in development in <StrictMode> environment) or React will do bizarre things with them trying to nest parent element in its children

Copy link
Contributor

@cee-chen cee-chen Jul 28, 2023

Choose a reason for hiding this comment

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

Just want to make sure I'm understanding this change correctly - we moved all our instantiation logic from constructor to componentDidMount, and we found this issue in behavior due to strict mode remounting components twice in rapid succession. Does that sound right?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's right, Cee!

this.setThemeColor(portalNode);
this.updatePortalRef(portalNode);

this.setState({
Copy link
Member Author

Choose a reason for hiding this comment

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

Using state to keep portalNode reference up to date in case it changes when running in development and <StrictMode> and force re-render whenever it changes

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not totally clear to me - when and why would portalNode change that would necessitate a rerender? We're creating portalNode ourselves here so it's not like the consumers can cause a rerender?

Does the interruption happen if a remount/rerender occurs in between lines 65 and 80? TBH, everything else in this PR makes sense to me, but this state change doesn't 😬

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not that portalNode ever changes unexpectedly. We can't initialize portalNode in constructor anymore and there's no componentWillMount we could use in React 18. componentDidMount is fired after the initial render and if we just update a class property it won't trigger a rerender, we'd need to call this.forceUpdate() which is usually a red flag.


Before this change

React 18, StrictMode disabled, development environment

  1. constructor (this.portalNode is updated with div element reference)
  2. render (this.portalNode is correct, so createPortal() is called)
  3. componentDidMount (nothing to do here)

React 18, StrictMode enabled, development environment

  1. constructor (this.portalNode is updated with first div element reference)
  2. constructor (this.portalNode is updated with second div element reference)
  3. render (this.portalNode references second div element)
  4. render (this.portalNode references second div element)
  5. componentDidMount (nothing to do here)
  6. componentWillUnmount (this.portalNode is removed from DOM 🚨)
  7. componentDidMount (nothing to do here)

After this change

React 18, StrictMode enabled, development environment

  1. constructor (nothing to do here)
  2. constructor (nothing to do here)
  3. render (return null)
  4. render (return null)
  5. componentDidMount (this.portalNode is updated with div element reference)
  6. componentWillUnmount (this.portalNode is removed from DOM)
  7. componentDidMount (this.portalNode is updated again)
  8. render (return createPortal())
  9. render (return createPortal())

React 18, StrictMode disabled, development environment

  1. constructor (nothing to do here)
  2. render (return null)
  3. componentDidMount (this.portalNode is updated with div element reference)
  4. render (return createPortal())

React 18, StrictMode enabled, production environment

  1. constructor (nothing to do here)
  2. render (return null)
  3. componentDidMount (this.portalNode is updated with div element reference)
  4. render (return createPortal())

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for taking the time to lay that all out, that helps a lot.

Just to be 100% clear though, In the After this change section, after every single componentDidMount step, technically another step should be listed that says something like "rerender due to this.state.portalNode update" - correct? Because basically the component mounts and then immediately rerenders, always?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's right, even though it's probably not 100% optimal with the extra render, that's how react does things now anyway with hooks and everything.

Copy link
Contributor

@cee-chen cee-chen Jul 28, 2023

Choose a reason for hiding this comment

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

Thanks for clarifying. Can we add an in-code comment documenting this behavior and why this.setThemeColor/this.updatePortalRef only need to be called on componentDidMount and not on componentDidUpdate even though state is updated?

Copy link
Member Author

Choose a reason for hiding this comment

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

What do you mean? setThemeColor and updatePortalRef are called whenever portalNode changes and that's the only thing that can trigger component updates. State is only used to store portalNode internally so render() can then use it. They could've been inlined to componentDidMount.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ahh sorry, I think I'm following. For some reason I kept thinking it was possible for portalNode to change on prop change/update 🤦

It might be worth adding an inline code comment beforethis.setState() in any case noting that we're intentionally causing a rerender on mount. We have all that context here on this PR, but if we absolutely want to make sure it doesn't regress, an inline code comment will last longer / is easier to reference :)

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a valid point. I added a comment explaining the reasoning behind setState in componentDidMount 👍🏻

const [hasValidAnchor, setHasValidAnchor] = useState<boolean>(false);
const animationFrameId = useRef<number>();
const anchorNode = useRef<HTMLElement | null>(null);
const [anchorNode, setAnchorNode] = useState<HTMLElement | null>(null);
Copy link
Member Author

Choose a reason for hiding this comment

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

anchorNode may change when anchor prop gets updated. Using useState will trigger re-render for us.

Copy link
Contributor

@cee-chen cee-chen Jul 28, 2023

Choose a reason for hiding this comment

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

Just to check my understanding, anchor is actually changing in production whenever tour steps move around the DOM, correct? If a tour step goes from step 1 to step 2, are we changing the popover anchor/contents and reusing the same popover instance, or are we rendering 2 separate popovers?

Copy link
Member Author

Choose a reason for hiding this comment

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

It just changes ref to state, the rest of the logic is literally the same

Copy link
Contributor

@cee-chen cee-chen Jul 28, 2023

Choose a reason for hiding this comment

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

Right yeah, sorry, I was just trying to clarify my current understanding of production EuiTour behavior as it's been a while :) For what it's worth it looks like the answer is "separate popovers" / not reusing the same popover instance. So in production/reality it's unlikely (but not impossible!) that the anchor prop would update dynamically.

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/

@tkajtoch
Copy link
Member Author

buildkite test this

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/

@tkajtoch
Copy link
Member Author

tkajtoch commented Jul 28, 2023

Please ignore the failing buildkite job. Elasticmachine commit says it's building 88cf087 but it's actually checking out 6fc0fe2. Neither of them are correct

'data-test-subj',
'euiFlyoutCloseButton'
);
cy.get('[data-test-subj="euiFlyoutCloseButton"]').should('be.focused');
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for all these should('be.focused') changes - I'll definitely remember this for the future instead of using cy.focused()!

animationFrameId.current = window.requestAnimationFrame(() => {
anchorNode.current = findElementBySelectorOrRef(anchor);
setHasValidAnchor(anchorNode.current ? true : false);
timeout = window.setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you mind leaving a code comment over why we chose setTimeout here over requestAnimationFrame? In the past Chandler would leave review feedback / have a preference to using requestAnimationFrame over setTimeout - it would be nice to document a conscious decision to use one or the other whenever we do

Copy link
Member Author

Choose a reason for hiding this comment

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

I added it here.

It is strongly recommended to use requestAnimationFrame whenever you execute a series of updates that cause layout repainting. In this case though we just wait for next event loop tick to try find anchor element in the DOM and the only difference between these two is that requestAnimationFrame may wait longer before executing as it's dependent on repainting speed (usually 60Hz / 16.6ms). This doesn't affect the chance of having a race-condition scenario in our code as it's not guaranteed for components to be rendered within a single frame / repaint.

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha. That's probably be too much info to list in a single code comment but I'd really love it if we documented this in an internal wiki doc instead. I know there's multiple places in EUI where we alternate between requestAnimationFrame and setTimeout and it's honestly never clear why we made that decision. I think we need a singular place that recommends between one or the other, and we can link to that doc on a per-usage basis if necessary.

Copy link
Contributor

Choose a reason for hiding this comment

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

^ Sorry forgot to clarify, this is not a change request for this PR!

Copy link
Member Author

Choose a reason for hiding this comment

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

I updated my comment to explain it better here. I'll create an issue to write a doc on using setTimeout / requestAnimationFrame :)

Copy link
Contributor

Choose a reason for hiding this comment

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

You're awesome. Thanks Tomasz! Really appreciate us taking the extra time and effort to help our future selves (or future... dev descendants? 😅) not have to re-trip over some of the things we've had to figure out.

Comment on lines +331 to +332
return anchorNode ? (
<EuiWrappingPopover button={anchorNode} {...popoverProps}>
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels cleaner to me in addition to fixing a bug, so win/win all around! 🎉

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/

@tkajtoch tkajtoch force-pushed the fix/strict-mode-support branch from bbf4fb1 to 5cf9385 Compare July 28, 2023 18:30
Copy link
Contributor

@cee-chen cee-chen left a comment

Choose a reason for hiding this comment

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

My only remaining request is to document (in a code comment) the intentional decision to rerender after mount. That being said, that's already pretty thoroughly documented in this PR, so if you'd rather skip or wordsmith the comment that's totally up to you.

src/components/portal/portal.tsx Show resolved Hide resolved
@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/

Copy link
Contributor

@cee-chen cee-chen left a comment

Choose a reason for hiding this comment

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

Woohoo!! 🚢

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/

@elasticmachine
Copy link
Collaborator

💚 Build Succeeded

History

cc @tkajtoch

@tkajtoch tkajtoch merged commit a2df03d into elastic:feature/react-18 Jul 28, 2023
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.

4 participants