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
- Add a char to the input in the example to cause a new suspense promise to be throw.
- 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
unmount
isn't called until the suspense promise is resolved and the component is rendered again.- an initialize function passed to
useState
isn't called on subsequent mounts
The expected behavior
unmount
should happen immediately when the parent<Suspense/>
renders the fallback and derenders it's children- 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".
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 inuseState
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.
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