facebook / react

The library for web and native user interfaces.

Home Page:https://react.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Bug: Component that throws promise caught by suspense isn't unmounted & remounted correctly.

delashum opened this issue · comments

When a component underneath a <Suspense/> throws a promise there is some strange behavior with how it unmount and mounts. I would expect that immediately when the fallback component is rendered all components underneath the suspense would be unmounted. It seems like the unmount isn't actually happening until the promise resolves and the component is rendered again (see timing in the example). In addition a function passed into a useState(fn) should be called on mount. You can see in the example as you interact and the component is unmounted and remounted due to Suspense the function in useState is never called again, despite useEffect confirming an unmount and mount are happening.

React version: 17.0.2

Steps To Reproduce

  1. Add a char to the input in the example to cause a new suspense promise to be throw.
  2. observe the timing of the logs in console.

Link to code example:

https://codesandbox.io/s/amazing-driscoll-7ufyd?file=/src/App.js

The current behavior

  1. unmount isn't called until the suspense promise is resolved and the component is rendered again.
  2. an initialize function passed to useState isn't called on subsequent mounts

The expected behavior

  1. unmount should happen immediately when the parent <Suspense/> renders the fallback and derenders it's children
  2. once the suspense promise resolves and the children component remounts the init function passed to useState should be called again

In my experience they are not "unmounted", they are "suspended", in case of "suspended", they are hidden from fiber tree but not cleaned up, and can be reconnected to fiber with its state keep alive.

I believe in React 18's concurrent mode a clean-up of all useEffect will be fired, but states, memos and refs are still kept alive.

I agree that's what appears to happen, I guess I'm arguing whether that should happen. Intuitively I would expect the components to fully unmount and remount.

That, or there should should be consistency. The scenario you described it is un-mounting according to useEffect and only suspending according to useState.

The reason I stumbled on this was because I have a library with a bug that's caused by cleanup happening in the useEffect, but the initialization in useState isn't happening when it "remounts".

commented

image

In your code, 'unmount' is in useEffect as a clean-up function which will only run after the component get cleaned up. And the argument passed useState will be used only once in the initial render.
Not very sure what you want to do with the 'unmount' state.

The reason I stumbled on this was because I have a library with a bug that's caused by cleanup happening in the useEffect, but the initialization in useState isn't happening when it "remounts".

for this part, check if you are looking for sth. below:
if(state === 'x'){ setState(state); }
or you could call setState() directly in every render.

Yes, that's probably a viable workaround, and what I will likely do for the time being (calling setState every render under a conditional).

The reason I filed this issue is because I think that mount and init state should be called together and on the same cycle. Meaning if I look at the lifecycle of a component in all situations the number of times that mount and init state get called in my examples should be the same. They are the same in all situations except when nested under a Suspense

@delashum you may be interested in these threads describing changes to Suspense in React 18:

They describe some quirks of the "old" Suspense implementation -- i have a feeling that what you're describing here is an instance of that.

commented

If you need to do something with the DOM, in React 18 you can use layout effects to handle the content being hidden/shown.

https://codesandbox.io/s/relaxed-gould-dcr5mn?file=/src/App.js

Retaining state for refetches is intentional, as well as retaining the DOM tree. It prevents user input from being lost in the refetched tree.

Another technique you might want to use in 18 is either startTransition or useDeferredValue to prevent the content from showing altogether:

https://codesandbox.io/s/pedantic-parm-fp5nwq?file=/src/App.js

commented

@gaearon Is "unmount" is appropriate word for suspended component even if cleanup function in useEffect is not called?