-
Notifications
You must be signed in to change notification settings - Fork 46.9k
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
[Suspense] Change Suspending and Restarting Heuristics #15769
Conversation
ReactDOM: size: 0.0%, gzip: -0.1% Details of bundled changes.Comparing: 3b23022...2e1c5ea react-dom
react-art
react-test-renderer
react-native-renderer
react-reconciler
Generated by 🚫 dangerJS |
This value is going to be used to avoid committing too many fallback states in quick succession. It doesn't really matter where in the tree that happened. This means that we now don't really need the concept of SuspenseState other than has a flag. It could be made cheaper/simpler.
This now eagerly commits non-delayed suspended trees, unless they're only retries in which case they're throttled to 500ms.
If we get a ping on this level but have not yet suspended, we might still suspend later. In that case we should still restart.
We should add this to throwException so we get these markers earlier. I've had to rewrite tests that test restarting to account for the delayed restarting heuristic. Ideally, we should also be able to restart from within throwException if we're already ready to restart. Right now we wait until the next yield.
They're not testing the exact states of the suspense boundaries, only the result. I keep assertions that they're not already resolved early.
I also added a blank initial render to ensuree that we cover the suspended case.
Mostly this just means render the Suspense boundary first so that it becomes an update instead of initial mount.
84b320b
to
e176a6b
Compare
Which meaning of the word “suspend” do you use here? “Not flush placeholder immediately”? |
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.
New heuristics make sense, though maybe you could paste this PR's description somewhere in ReactFiberWorkLoop? The inline comments throughout are very helpful but individually they paint an incomplete picture. A person who lacks context probably won't know where to look for them.
Meta note, I like that we're able to change all these heuristics but none of them affect the terminal output of a sequence of events. Feels like we've got most of the right levers and knobs in place, and now we're focused on fiddling with their settings.
@gaearon Yeah that's what "suspend" means in this context: commit the fallbacks after a |
This summary is still very dense and a bit hard to understand for me. Let me try to rephrase it and you'll correct me where I misunderstood?
If we've decided not to commit the fallback yet and wait more, we should also handle updates and retry. This is because that might avoid needing to commit fallback altogether — or unlock a level deeper when it has to commit. Indeed a chance of unlocking more is the whole point of waiting. Otherwise we'd be wasting time and delaying a loading state that we already have.
We shouldn't couple restarting opportunity too closely to whether we're suspended or not. Otherwise the result is too unpredictable because we might miss the window.
Not sure what this is about. Before what?
There might be too many new updates that might unlock new levels. If we always restart, we'll never manage to complete. We gotta stop somewhere.
I guess this is about the case where an update led to no "suspensey" changes?
This is about top-level render? I think it's saying that there's no JND on first render because the thing is blank. So we fill it in right away and commit fallbacks if that's all we have. But maybe this isn't just about top-level. Is it about first time Suspense is in a tree? Are we saying we want to flush the initial fallback early — as long as nothing else prevents us from doing it (such as an intention to delay or maybe only having a "shy" fallback available)? I'm not sure.
If committing a state change would lead to a new fallback (that wasn't shown before) being committed, we wait for JND (or longer if opted in via config). While we're waiting, more updates would lead to retries since that's our only chance to actually show more by the time we have to commit.
If unlocking a level reveals another fallback, we make sure they don't flash too often. It extends past normal JND because the end result is not ideal (?)
I don't get this. How does throttling let us get to completed state faster? Or is it just "always restarting" in this mode that let us complete sooner?
If one of batched updates causes a "navigation" transition, don't worry about showing new loading states ASAP yet. But if there's no "navigations" then we should paint the intentional loading states early. In absence of both of these special cases, get on the throttling unlocking train. (How does that relate to JND again?) |
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.
README.md
Now that Suspense retries have their own dedicated set of lanes (facebook#19287), we can determine if a render includes only retries by checking if its lanes are a subset of the retry lanes. Previously we inferred this by checking `workInProgressRootLatestProcessedEventTime`. If it's not set, that implies that no updates were processed in the current render, which implies it must be a Suspense retry. The eventual plan is to get rid of `workInProgressRootLatestProcessedEventTime` and instead track event times on the root; this change is one the steps toward that goal. The relevant tests were originally added in facebook#15769.
Now that Suspense retries have their own dedicated set of lanes (#19287), we can determine if a render includes only retries by checking if its lanes are a subset of the retry lanes. Previously we inferred this by checking `workInProgressRootLatestProcessedEventTime`. If it's not set, that implies that no updates were processed in the current render, which implies it must be a Suspense retry. The eventual plan is to get rid of `workInProgressRootLatestProcessedEventTime` and instead track event times on the root; this change is one the steps toward that goal. The relevant tests were originally added in #15769.
This introduces a new principle for when we restart a render instead of continuing to completion. The principle is that if we're going to suspend when we complete a root, then we should also restart if we get an update or ping that might unsuspend it, and vice versa. The only reason to suspend is because you think you might want to restart before committing. However, it doesn't make sense to restart only while in the period we're suspended. That's just a race condition depending on how long it took to render. We should also be able to restart before that.
However, restarting too aggressively is also not good because it starves out any intermediate loading state. So this PR uses a set of heuristics.
Heuristics
If nothing threw a Promise or all the same fallbacks are already showing, then don't suspend/restart.
If this is an initial render of a new tree of Suspense boundaries and those trigger a fallback, then don't suspend/restart. We want to ensure that we can show the initial loading state as quickly as possible.
If we hit a "Delayed" case, such as when we'd switch from content back into a fallback, then we should always suspend/restart. SuspenseConfig applies to this case. If none is defined, JND is used instead.
If we're already showing a fallback and it gets "retried", allowing us to show another level, but there's still an inner boundary that would show a fallback, then we suspend/restart for 500ms since the last time we showed a fallback anywhere in the tree. This effectively throttles progressive loading into a consistent train of commits. This also gives us an opportunity to restart to get to the completed state slightly earlier.
If there's ambiguity due to batching it's resolved in preference of: "delayed", "initial render", "retry". We want to ensure that a "busy" state doesn't get force committed. We want to ensure that new initial loading states can commit as soon as possible.
More info about the mechanisms in the individual commits.
Tests
This PR needed to adjust a lot of tests.
Most of them is because initial render no longer suspends in the traditional way. I fixed this mostly by first rendering an empty Suspense boundary and then updating it. This transition works mostly the traditional way.
Another quirk is that restarting doesn't happen until we know that this render is going to suspend. Currently, we only know that once we complete the Suspense boundary. Pings can happen before or after this. If we never yield after completing it, we don't restart until we get to the root. I left a comment to fix that. In the meantime I had to rewrite the tests to continue render past the Suspense boundary before pausing so that it can restart after that.