-
Notifications
You must be signed in to change notification settings - Fork 839
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
[React 18] Add full <StrictMode> support #7007
Conversation
…date function in EuiWrappingPopover
…and EuiFlyout components
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'); |
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.
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
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.
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?
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.
That's right, Cee!
this.setThemeColor(portalNode); | ||
this.updatePortalRef(portalNode); | ||
|
||
this.setState({ |
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.
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
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.
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 😬
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.
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
- constructor (
this.portalNode
is updated with div element reference) - render (
this.portalNode
is correct, socreatePortal()
is called) - componentDidMount (nothing to do here)
❌ React 18, StrictMode enabled, development environment
- constructor (
this.portalNode
is updated with first div element reference) - constructor (
this.portalNode
is updated with second div element reference) - render (
this.portalNode
references second div element) - render (
this.portalNode
references second div element) - componentDidMount (nothing to do here)
- componentWillUnmount (
this.portalNode
is removed from DOM 🚨) - componentDidMount (nothing to do here)
After this change
✅ React 18, StrictMode enabled, development environment
- constructor (nothing to do here)
- constructor (nothing to do here)
- render (return
null
) - render (return
null
) - componentDidMount (
this.portalNode
is updated with div element reference) - componentWillUnmount (
this.portalNode
is removed from DOM) - componentDidMount (
this.portalNode
is updated again) - render (return
createPortal()
) - render (return
createPortal()
)
✅ React 18, StrictMode disabled, development environment
- constructor (nothing to do here)
- render (return
null
) - componentDidMount (
this.portalNode
is updated with div element reference) - render (return
createPortal()
)
✅ React 18, StrictMode enabled, production environment
- constructor (nothing to do here)
- render (return
null
) - componentDidMount (
this.portalNode
is updated with div element reference) - render (return
createPortal()
)
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.
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?
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.
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.
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.
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?
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.
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
.
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.
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 :)
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.
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); |
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.
anchorNode
may change when anchor
prop gets updated. Using useState
will trigger re-render for us.
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.
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?
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.
It just changes ref to state, the rest of the logic is literally the same
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.
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.
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/ |
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/ |
buildkite test this |
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/ |
'data-test-subj', | ||
'euiFlyoutCloseButton' | ||
); | ||
cy.get('[data-test-subj="euiFlyoutCloseButton"]').should('be.focused'); |
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.
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(() => { |
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 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
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.
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.
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.
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.
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.
^ Sorry forgot to clarify, this is not a change request for this PR!
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.
I updated my comment to explain it better here. I'll create an issue to write a doc on using setTimeout
/ requestAnimationFrame
:)
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.
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.
return anchorNode ? ( | ||
<EuiWrappingPopover button={anchorNode} {...popoverProps}> |
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.
This feels cleaner to me in addition to fixing a bug, so win/win all around! 🎉
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/ |
bbf4fb1
to
5cf9385
Compare
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.
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.
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/ |
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/ |
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.
Woohoo!! 🚢
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007_buildkite/ |
Preview documentation changes for this PR: https://eui.elastic.co/pr_7007/ |
💚 Build Succeeded
History
cc @tkajtoch |
Summary
Fix StrictMode and async rendering issues in
EuiPortal
,EuiWrappingPopover
andEuiTourStep
, and fix more flaky cypress tests found running on 6x CPU slowdown multiplier.QA
Ensure unit tests and cypress tests are passing.