-
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 #12279
Suspense #12279
Conversation
Awesome! But are you gonna fit this into 16.3? |
We'll see, the release has been slipping for a few weeks because some things turned out harder than we thought. Maybe 16.3 will be a bigger one than we thought. |
function waitForTimeout(root, ms, suspendedTime) { | ||
setTimeout(() => { | ||
retryOnPromiseResolution(root, suspendedTime); | ||
}, ms); |
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.
I bumped into a funny case where I accidentally clobbered the timeout queue with a bunch of setTimeout
s that reschedule themselves, and the fallback never showed up for this reason. Was very tricky to debug.
false, | ||
'An update was suspended for longer than the timeout, but no fallback ' + | ||
'UI was provided.', | ||
); |
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.
Can we provide a component stack trace here?
@@ -894,10 +964,20 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>( | |||
} else { | |||
// The root did not complete. | |||
invariant( | |||
false, | |||
!nextRenderIsExpired, | |||
'Expired work should have completed. This error is likely caused ' + |
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.
FYI, I got here with 16.4.0-alpha.3174632
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.
Yeah that’s the bug @ryanflorence found. Should be fixed now, haven’t pushed a new canary yet.
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.
The more I think of it, the more I’m convinced that having the <Loading />
component wrap both the “from” screen and the “to” screen is not just a convenience but active bad pattern.
Because you start out using it by hoisting it up to a shared parent and it just works. Then you realize that you need two different loading states on the same screen depending on what the target is and you have no way of breaking this up.
It is also really impossible to have a loading state when you’re navigating to a completely different part of the app.
I wonder if we’re modeling the loading state wrong. It seems to me that it is more likely to be close to the setState that triggered the transition.
Can we split out the Timeout into a separate PR that we can land while we figure out the Loading one? |
f215727
to
7166ce6
Compare
@sebmarkbage Removed the soft expiration stuff. I'll open a separate PR for that. This one is Timeout-only. |
It already makes sense to me !!! |
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.
Let's see if we can do this without state and update queues on the component level.
I think retrying can happen on the whole root level for the timed out work.
For calling .then
we can just do that eagerly and retry for each of them.
return bailoutOnAlreadyFinishedWork(current, workInProgress); | ||
} | ||
|
||
if ((workInProgress.effectTag & DidCapture) !== NoEffect) { |
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.
Ignoring legacy context you’re going to check this twice. Use a temp variable here.
renderIsExpired, | ||
remainingTimeMs, | ||
renderStartTime, | ||
renderExpirationTime, |
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.
These extra args are all unused.
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.
Yeah sorry. Soft expiration vestigia.
Please tell me what suspense and what will all make sense ?? |
@ohyeahgotit If you're curious, you can watch Dan's talk this Thursday at JSConf Iceland. |
packages/shared/ReactTypeOfWork.js
Outdated
@@ -37,3 +37,4 @@ export const Fragment = 10; | |||
export const Mode = 11; | |||
export const ContextConsumer = 12; | |||
export const ContextProvider = 13; | |||
export const TimeoutComponent = 15; |
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.
should be 14
here for consistence and also added to TypeOfWork
above?
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.
well played @acdlite 👏 |
For anyone just following this on Github, you can see a roundup of links to the talk and implementation comments here: https://news.ycombinator.com/item?id=16492973 And the money image: |
I am a very big fan of this API, I think the whole idea behind it is superb, kudo's to everyone involved! 👍 One thing I was wondering about though: it seems that the deferred setState is the only way to use the suspense feature. Am I correct in assuming that? Because that would mean that a update triggered per prop would not work, which would be bad news for Redux-heavy applications. Effectively that would limit the scope of to within presentational components: everything 'above' a connected component in the tree couldn't use this. |
Not quite. You can use it with regular setState but it will have a very small expiration time. So it’ll be like if you set the Placeholder timeout to a low value, and will switch to loading state very quickly.
We don’t even have a real API for doing deferred updates yet so this is a premature conclusion. The real problem with Redux is it only has a concept of one current state. But with suspense you have two. React can handle this but Redux currently doesn’t. This is a much bigger problem that needs to be solved. We have some ideas on how we can make suspense work with Redux but it’ll take some time and likely some API changes. The issue you pointed out is just a small part of the problem. |
Quick question: if my render calls 3 fetchers, each of which doesn't have the requested value cached, how long would it take at least to render my component when each fetcher takes 500ms to get the data? From what I can tell they are executed in sequence, not parallel, so it would take at least 1500ms (just for the fetching). |
Yes, calling multiple fetchers from one component when they don’t strictly depend on one another can be considered a bad practice. You can solve this either by putting them in sibling components (they are parallel) or by using a combinator similar to Promise.all. We might provide one in the final API. Edit: Nope, I’m wrong #12279 (comment) |
@gaearon No that is fine practice. Using a Promise.all might in fact be bad practice since it doesn’t let individual values share the cache. The solution is to use preload.
Where preload should ideally be hoisted even further up if possible. |
Another problem with Promise.all is that people tend to think that it’s good to “batch” work. That often leads to a situation where everything is hoisted to a top Promise.all and you can’t do any work until all dependencies are satisfied. That leads to worse parallelization of CPU work. The preload technique avoids that problem but does require adding code to two places. However, tooling should be able to provide good hints. |
Things to address in follow-up PRs: Immediately...
After that...
At this point I think we'll have an MVP. Then... Future
|
bucketSizeMs / UNIT_SIZE, | ||
return ( | ||
MAGIC_NUMBER_OFFSET + | ||
round( |
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.
Why this change? What happens if you move this to a follow up?
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.
When I made the change to infer the start time from the expiration time, all the numbers in the tests were off by 250ms (the bucket size) because we were rounding up. Since in practice it doesn’t really matter if we round up or down when placing updates into buckets, I adjusted it here rather than change all the tests and introduce more noise to the diff.
|
||
import {enableSuspense} from 'shared/ReactFeatureFlags'; | ||
|
||
// TODO: Offscreen updates |
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.
What is this? It doesn't seem to be on your follow up list.
@@ -28,6 +28,13 @@ export type FiberRoot = { | |||
pendingChildren: any, | |||
// The currently active root fiber. This is the mutable root of the tree. | |||
current: Fiber, | |||
|
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.
Comments plz. What are these for?
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.
Ok I’ll add comments in the morning
} | ||
} | ||
|
||
export function flushPendingPriorityLevel( |
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.
Flush means something else in the scheduler context. This is more like update something based on something else flushing. This is not the flush.
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.
Huh this is called after a commit, so “flush” seemed appropriate. Suggestions?
if (current !== null && current.memoizedState === true) { | ||
// A parent Timeout already committed in a placeholder state. We | ||
// need to handle this promise immediately. In other words, we | ||
// should never suspend inside a tree that already expired. |
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.
So this is the thing you're going to fix in the follow up?
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.
Yeah, this is the first bullet
thenable.then(onResolveOrReject, onResolveOrReject); | ||
return; | ||
} else { | ||
// No time remaining. Need to fallback to palceholder. |
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.
palceholder -> placeholder
@@ -28,6 +28,13 @@ export type FiberRoot = { | |||
pendingChildren: any, | |||
// The currently active root fiber. This is the mutable root of the tree. | |||
current: Fiber, | |||
|
|||
earliestPendingTime: ExpirationTime, | |||
latestPendingTime: ExpirationTime, |
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.
I don't get why we need to keep track of these two pending times.
Why isn't earliestPendingTime just root.current.expirationTime
and latestPendingTime just earliestSuspendedTime - 1
? Effectively.
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.
The pending times are levels we haven’t tried yet. If we try them and they suspend, they are cleared. In other words, need to distinguish between “incomplete” and “incomplete and suspended.”
Adds Timeout component. If a promise is thrown from inside a Timeout component, React will suspend the in-progress render from committing. When the promise resolves, React will retry. If the render is suspended for longer than the maximum threshold, the Timeout switches to a placeholder state. The timeout threshold is defined as the minimum of: - The expiration time of the current render - The `ms` prop given to each Timeout component in the ancestor path of the thrown promise.
Co-authored-by: Andrew Clark <acdlite@fb.com>
React should resume rendering regardless of whether it resolves or rejects.
Async is not required for Suspense, but strict mode is.
Some of this was added with "soft expiration" in mind, but now with our revised model for how soft expiration will work, this isn't necessary. It would be nice to remove more of this, but I think the list itself is inherent because we need a way to track the start times, for <Timeout ms={ms} />.
It already worked this way in practice.
Instead of waiting for commit phase.
We can replicate almost all the functionality by tracking just five separate levels: the highest/lowest priority pending levels, the highest/lowest priority suspended levels, and the lowest pinged level. We lose a bit of granularity, in that if there are multiple levels of pending updates, only the first and last ones are known. But in practice this likely isn't a big deal. These heuristics are almost entirely isolated to a single module and can be adjusted later, without API changes, if necessary. Non-IO-bound work is not affected at all.
Idk why I thought this was neccessary
This means you have to account for the start time approximation heuristic when writing Suspense tests, but that's going to be true regardless. When updating the tests, I also made a fix related to offscreen priority. We should never timeout inside a hidden tree.
I don't think |
TODO:
Cache invalidationWill work more on simple-cache-provider in a follow upPromise.race
<div hidden>
trees is taken into account. I would expect it to be ignored. Preloading and prerendering shouldn’t affect visual indicator, and shouldn’t prevent an already suspended tree from committing when all Promises from non-hidden resolve. [Andrew: I think this is fixed]Update (April 13)
Rebased on top of update queue changes. Now depends on #12600.
Update (April 23)
#12600 was merged.