Bug
The React docs say
useEffect lets us express different kinds of side effects after a component renders.
But under suspense that doesn't seem to be true any longer. useEffect is called pre-emptively. Or at least "rendering" seems to refer to the double-buffer (?)
There are situations where side-effects rely on the element having been rendered out effectively and visually.
In my case i need to render content on the screen that's supposed to follow along the parents whereabouts.
https://codesandbox.io/s/mystifying-wilson-30u2m
useEffect (belonging to the <Dom/> component) dumps out content before the parent has rendered, which is very odd since the parent is suspended until all the other components are done loading. This is mixed mode react, two reconcilers. But that shouldn't have anything to do with it. The culprit is useEffect.
useEffect should fire after the component has effectively rendered. I think that is what most people thought it would already do.
16.12.0
Debugging deeper into how this works, it is clear to me now what happens. So essentially the component does render, but the reconciler calls hideInstance on it. When the suspense is lifted it calls unhideInstance.
The problem is that useEffect is let loose on an object that has no visual representation, which affects interop (enter state, animation, measuring, position, etc).
Can you make an example that doesn't mix two renderers? That would be a bit easier to follow.
It would also make it easier to check whether this happens in Concurrent Mode too. (It's expected that Suspense in legacy mode has some unfortunate unfixable quirks.)
Here's a plain react demo: https://codesandbox.io/s/distracted-goldstine-07bpl
You have to open codesandboxes console to see the ready logs, indicating that useEffect fires even though these components aren't ready yet.
I added react-spring for a simple fade-in on mount animation, something like this is pretty common. It falls flat inside suspense, the animation fires long before the component shows up and by the time it does show it's already at end-state.
function Suspend({ time }) {
const [props, set] = useSpring(() => ({ opacity: 0 }))
// This hook suspends the component for a while
usePromise(ms => new Promise(res => setTimeout(res, ms)), [time])
// useEffect should indicate that we are ready to roll ...
useEffect(() => {
// Component has mounted, start the animation ...
// useEffect will be called regardless if this component is ready or not, hence
// animations or other interop actions relying on refs are without foundation.
set({ opacity: 1 })
}, [])
// The view will be rendered out and silenced by the reconciler via display: none
return <animated.div style={props}>Suspense: {time}ms</animated.div>
}
<Suspense fallback={<div>Fallback</div>}>
<Suspend time={1000} />
<Suspend time={2000} />
<Suspend time={3000} />
<div>No suspense</div>
</Suspense>
Tried the dom-only demo in concurrent mode, and it seems to behave correctly there. That already is a huge relief.
edit:
works in react-reconciler as well given that it receives "2" as the 2nd arg to createContainer. https://codesandbox.io/s/romantic-leaf-zu2wo
useLayoutEffect also appears to have the same issue
This is a known issue. The problem is that legacy life-cycles and the correct behavior are in direct conflict. So we can't fix it and also preserve legacy life-cycles at the same time.
The fix is to switch to Blocking Mode or Concurrent Mode.
Most helpful comment
This is a known issue. The problem is that legacy life-cycles and the correct behavior are in direct conflict. So we can't fix it and also preserve legacy life-cycles at the same time.
The fix is to switch to Blocking Mode or Concurrent Mode.