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: avoid double-render #68

Merged
merged 1 commit into from
Sep 11, 2020
Merged

fix: avoid double-render #68

merged 1 commit into from
Sep 11, 2020

Conversation

kentcdodds
Copy link
Collaborator

What: fix: avoid double-render

Why: Closes #66 and Closes #67

How:

  • Remove the setState call in componentDidCatch
  • Ensure that the cDU only resets if the component is updated after the first update with an error.

Checklist:

  • Documentation
  • Tests
  • Ready to be merged

BREAKING CHANGE: This removes the componentStack in the props given to the FallbackComponent and fallbackRender

BREAKING CHANGE: This removes the `componentStack` in the props given to the `FallbackComponent` and `fallbackRender`
@codecov-commenter
Copy link

Codecov Report

Merging #68 into master will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##            master       #68   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            1         1           
  Lines           36        40    +4     
  Branches        11        11           
=========================================
+ Hits            36        40    +4     
Impacted Files Coverage Δ
src/index.js 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 463c62e...13f0ffd. Read the comment docs.

Copy link
Owner

@bvaughn bvaughn left a comment

Choose a reason for hiding this comment

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

Thanks for tackling this and removing the second render 😁

this.setState(initialState)
}

componentDidCatch(error, info) {
this.props.onError?.(error, info?.componentStack)
this.setState({info})
Copy link
Owner

Choose a reason for hiding this comment

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

Nice

// error to be thrown.
// So we make sure that we don't check the resetKeys on the first call
// of cDU after the error is set
if (error !== null && !this.updatedWithError) {
Copy link
Owner

Choose a reason for hiding this comment

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

I don't think I fully understand why the resetKeys feature is helpful, or what the purpose of onResetKeysChange does– but the way you're using the updatedWithError instance attribute looks safe (since it's only modified during the commit phase or during an imperative reset "action".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, it's actually quite helpful. Here's an example of it in action: https://react-hooks.netlify.app/6 (It's the Extra Credit 8, which you can play with here: https://react-hooks.netlify.app/isolated/final/06.extra-8.js)

Basically, if there's a property that you anticipate resulting in an error to be thrown, then you can put it in the resetKeys array and after an error has been thrown, if any values in the array changes then it will be reset automatically.

Here's a gif that demonstrates what this does. With the reset keys, the error boundary is automatically reset when I change the pokemon name:

Screen Recording 2020-09-11 at 9 15 39 AM

Copy link
Owner

Choose a reason for hiding this comment

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

Thanks for elaborating. The concept seems a bit odd to me, but not necessarily in a bad way. It could just be because it's a different way of thinking about using the boundary than would have occurred to me.

I guess it's roughly similar to using an id-based key or something to completely reset a subtree between view changes.

Anyway 😄 it doesn't look like it would cause problems. (Instance properties can sources of subtle side effects but I think this one looks fine.)

this.props.onResetKeysChange?.(prevProps.resetKeys, resetKeys)
this.setState(initialState)
this.reset()
Copy link
Owner

Choose a reason for hiding this comment

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

Shouldn't this be calling resetErrorBoundary? Otherwise you won't call props.onReset.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

props.onReset will be called only for when this.resetErrorBoundary is called (presumably the end-user clicked a button you rendered in your fallback that triggered this). props.onResetKeysChange will be called only for when the reset happened due to a change in the resetKeys. It's possible these will both be set to the same function, but I think having the distinction could be helpful which is why they're different functions.

I'm open to your feedback on this though. Great opportunity to break things since we'll be pushing a major version bump.

Copy link
Owner

Choose a reason for hiding this comment

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

Hm. I see what you mean, but I would have assumed (if I were using this component) that onReset was called any time the boundary reset (for any reason) and onResetKeysChange was called any time the reset keys changed and those things are decoupled (because one can happen without the other).

How do you actually observe when a reset happens because of a key change? (Using onResetKeysChange won't be sufficient to tell you this, will it)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

How do you actually observe

Is "you" referring to the developer user or library author? 😅

For the developer, they'll know because onResetKeysChange will be called. The lib author knows because we do this check in cDU to compare the previous resetKeys with the next ones.

Copy link
Owner

Choose a reason for hiding this comment

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

I was referring to the library user.

And, right. I forgot that we only check and call onResetKeysChange if the error !== null (because intuitively, I still think keys changing is separate from resetting).

Anyway, maybe this isn't a valuable conversation to continue. It just seemed a little unintuitive to me, but I'm only one person.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's quite possible this particular feature is a bit niche. But it's definitely solved real problems for me so that's why I think it's worth the added complexity 😅

Thanks for your feedback @bvaughn! Always appreciate your time :)

Copy link
Collaborator Author

@kentcdodds kentcdodds left a 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 review this and provide feedback.

// error to be thrown.
// So we make sure that we don't check the resetKeys on the first call
// of cDU after the error is set
if (error !== null && !this.updatedWithError) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, it's actually quite helpful. Here's an example of it in action: https://react-hooks.netlify.app/6 (It's the Extra Credit 8, which you can play with here: https://react-hooks.netlify.app/isolated/final/06.extra-8.js)

Basically, if there's a property that you anticipate resulting in an error to be thrown, then you can put it in the resetKeys array and after an error has been thrown, if any values in the array changes then it will be reset automatically.

Here's a gif that demonstrates what this does. With the reset keys, the error boundary is automatically reset when I change the pokemon name:

Screen Recording 2020-09-11 at 9 15 39 AM

this.props.onResetKeysChange?.(prevProps.resetKeys, resetKeys)
this.setState(initialState)
this.reset()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

props.onReset will be called only for when this.resetErrorBoundary is called (presumably the end-user clicked a button you rendered in your fallback that triggered this). props.onResetKeysChange will be called only for when the reset happened due to a change in the resetKeys. It's possible these will both be set to the same function, but I think having the distinction could be helpful which is why they're different functions.

I'm open to your feedback on this though. Great opportunity to break things since we'll be pushing a major version bump.

@kentcdodds kentcdodds merged commit b2f82ce into master Sep 11, 2020
@kentcdodds kentcdodds deleted the pr/avoid-double-render branch September 11, 2020 17:07
@kentcdodds
Copy link
Collaborator Author

🎉 This PR is included in version 3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@ranisalt
Copy link

Great, now how do I get the component stack? It was essential for me.

@kentcdodds
Copy link
Collaborator Author

Use the old version or use the onError handler. Or you could write your own error boundary.

@bvaughn
Copy link
Owner

bvaughn commented Sep 19, 2020

@ranisalt This component is very small. If you have a use case it doesn't solve- fork it.

@ranisalt
Copy link

I see. A combination of onError and useState solves this. Thanks 👍

jeysal pushed a commit to yldio/asap-hub that referenced this pull request Oct 21, 2020
I noticed that when using `react-error-boundary@2`
(without bvaughn/react-error-boundary#68),
and fixing my oversights from the error boundary PR,
they work properly with the intended `resetKeys` prop instead of `key`,
and we thus avoid the tree unmount issue that `key` introduced.
jeysal pushed a commit to yldio/asap-hub that referenced this pull request Oct 21, 2020
I noticed that when using `react-error-boundary@2`
(without bvaughn/react-error-boundary#68),
and fixing my oversights from the error boundary PR,
they work properly with the intended `resetKeys` prop instead of `key`,
and we thus avoid the tree unmount issue that `key` introduced.
@karlhorky
Copy link

@ranisalt how exactly did you do this? Is it similar to the code below from this gist?

export default function MyApp({ Component, pageProps }: { Component: any; pageProps: any }) {
  // ReactErrorBoundary doesn't pass in the component stack trace.
  // Capture that ourselves to pass down via render props
  const [errorInfo, setErrorInfo] = useState<React.ErrorInfo | null>(null);

  return (
    <React.Fragment>
      <ErrorBoundary
        onError={(error, info) => {
          if (process.env.NODE_ENV === 'production') {
            uploadErrorDetails(error, info);
          }
          setErrorInfo(info);
        }}
        fallbackRender={fallbackProps => {
          return <AppErrorFallback {...fallbackProps} errorInfo={errorInfo} />;
        }}
      >
        <Component {...pageProps} />;
      </ErrorBoundary>
    </React.Fragment>
  );
}

@karlhorky
Copy link

karlhorky commented Jul 4, 2021

@bvaughn @kentcdodds would you be open to having an example like this in the readme for those who want the component stack trace?

Maybe with the caveat that it would cause a double render?

@kentcdodds
Copy link
Collaborator Author

That's fine with me. Most of the time the component stack trace is unnecessary, so make a note about that as well 👍

@king-of-poppk
Copy link

king-of-poppk commented Aug 9, 2023

@ranisalt how exactly did you do this? Is it similar to the code below from this gist?

export default function MyApp({ Component, pageProps }: { Component: any; pageProps: any }) {
  // ReactErrorBoundary doesn't pass in the component stack trace.
  // Capture that ourselves to pass down via render props
  const [errorInfo, setErrorInfo] = useState<React.ErrorInfo | null>(null);

  return (
    <React.Fragment>
      <ErrorBoundary
        onError={(error, info) => {
          if (process.env.NODE_ENV === 'production') {
            uploadErrorDetails(error, info);
          }
          setErrorInfo(info);
        }}
        fallbackRender={fallbackProps => {
          return <AppErrorFallback {...fallbackProps} errorInfo={errorInfo} />;
        }}
      >
        <Component {...pageProps} />;
      </ErrorBoundary>
    </React.Fragment>
  );
}

This commits twice on error right? Once with the current error and the previous info (or null the first time) and once with the current error and info.

@bvaughn, @kentcdodds Will this potentially render a transient invalid state?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update state with getDerivedStateFromError rather than componentDidCatch
6 participants