[Fiber][Not for commit] Proof-of-concept: Promise as element type#8595
[Fiber][Not for commit] Proof-of-concept: Promise as element type#8595acdlite wants to merge 3 commits intofacebook:masterfrom
Conversation
If a promise is passed to React.createElement, on initial mount, React waits for the promise to resolve to a React element type. Meanwhile, it bails out and continues rendering. Once the promise resolves, it continues rendering the tree using the resolved element type. Subsequent renders are not deferred and behave just like a regular element.
|
This should not behave as Instead it should continue reconciling siblings etc but once it hits the root it should deny committing the tree. That is way we don't show any of the parent/sibling changes on the screen until everything is done. That avoids the poor user experience you get from incrementally showing results and the overhead from multiple layout passes. We need that general capability. Regardless of API. Basically that particular priority level is blocked from committing until all promises are resolved. It should also not infinitely try to rerender if no new data is available. It should only retry after at least one promise or a new state transition has happened. |
Interesting... |
|
One might ask, why would you render something that won't commit? Well because we are able to reuse progressed work and resume that work won't be for nothing. The next time we retry the render some of the work will already be done. This gives us streaming rendering with control over when it becomes visible. |
|
Could you implement this using a new priority field, e.g. |
|
^ Going to try. |
|
Hm. Maybe. Not sure we need an entire field for it though. Another question is whether we should down prioritize the whole tree and let other lower priority work commit earlier. I'm leaning towards not. I think best practice will be to only ever cause updates that need to block at Low Priority. Offscreen priority might be doable in the meantime but not critical. |
|
Edit: Did you delete your comment? Here's the answer regardless. :) That's not how it works thanks to progressedChild. If the priority level is the same as the progressed priority then we don't clone from current. We use the progressedChild instead. If we didn't do that then we'd throw out all work any time any higher priority update happened and we'd starve. |
|
Uh, meant to edit but instead accidentally deleted my previous comment... What it roughly said is that one tricky part about blocking commits is ensuring that work isn't thrown out even though the work-in-progress doesn't swap and become the current tree. But I think I was mistaken... I forgot that So I think work is only thrown out when you render at a higher priority, but that's always the case. |
|
Eh I think it's still a problem. If you block a commit and you don't swap the trees, and then render at a higher priority, all that work is lost. |
|
Gah after all this time I still don't understand how |
|
Not all. Only work that overlaps. If you bail out then that subtree gets to keep its progressed work. If there is an update to a particular component then you typically can't just reuse its work since it will have to be rebased on top of the higher priority change. The easiest way to do that is just to rerender that node. That's the heuristic I went with since it has predictable memory usage but we could explore others. |
|
In a normal bail out, though, the worst case scenario is that you can't reuse the progressed work because the priority doesn't match, so you clone from the current. Which is a re-render, but it's not a remount. The problem (I think) I'm having is that for promise components, in the worst case scenario, there is no current fiber because the commit was blocked and the trees never swapped. So it's not just a re-render, you have to recreate the entire fiber. Which leads me to believe that I need to swap the trees even when a commit is blocked. But I'm mostly just thinking out loud right now. |
|
I'm very skeptical that swapping when you're blocked is ever going to be the right solution because now you've lost the state of what is on screen which will screw up all diffing. Imagine the triangle demo. The first time the triangles mount they're not committed to current. There is a giant tree that keeps making progress in the background. The worst case is that the parent gets an update to its children because that will drop the whole tree of progressed work. Currently this scenario can happen in more cases than it should. E.g. I think that a bailout will still update its children so to keep work you need to bailout two levels above or something. There might also be bugs at the very root. So I'd start out with a simpler case such as updating something in the middle that ends up rendering new children. |
|
What if we treat this similar to error boundaries? Call it a "loading boundary." The root itself acts a default loading boundary. If an unresolved promise is rendered, it is propagated to the nearest loading boundary. When the loading boundary completes, it effectively returns null (we could add an API to specify a custom component) but the boundary itself is mounted. The progressed work is stored as the When all the promises have resolved, the loading boundary renders using its progressed child. EDIT: I thought this solved a problem but I guess it doesn't... |
|
Note that what makes this API interesting is that it can handle updates. Initial mount is pretty simple to handle even in user space. Updates is what made it hard to incorporate before. If something deep within the tree gets a state update that forces a new component to load that hasn't loaded yet, you don't want to reset the the whole tree to null. It is then In a visible but frozen state. The new thing here is that higher priority updates means that the tree is in a consistent state but not frozen. |
|
Could this also be used for data loading? That would greatly simplify server-side rendering… |
|
I mean, you decorate your component in something that loads data, and calling This could also be used for portals like react-helmet, that can only provide their contents once the rest of the page was processed. |
|
Ok I think what I'm going to try is to store a |
|
It seems to me that this is a problem with or without blocked work. Even if you just you different priorities you'all end up with the same problem. I figured that what would be needed for blocked work would just be a global flag that tells the scheduler not to commit the current work. |
|
Telling the scheduler not to commit the blocked work is simple enough. What I was (am) struggling with is how to not throw away the blocked work if a higher-priority update comes in. I just pushed one possible solution, that passes the basic the test I wrote for this. If fiber is blocked from committing, we store its child on This appears to work but it requires visiting every single node in the blocked tree. |
| }); | ||
| ReactNoop.flush(); | ||
| // We performed a high priority update, which unblocks the tree. | ||
| expect(ReactNoop.getChildren()).toEqual([span('step: 2')]); |
There was a problem hiding this comment.
This is the test case that I struggled to get passing. Passes now, but I'm not sure if the solution is correct.
There was a problem hiding this comment.
Also I think this is only really a problem for the initial mount, when there is no current tree.
There was a problem hiding this comment.
What was happening before I added blockedChild is that the blocked PromiseForFoo fiber was being thrown out, causing a new one to be mounted at a higher priority, which blocks the tree again.
There was a problem hiding this comment.
Amended the commit, here's a link to the same test case: e91a3fa#diff-935a1efc6cdeb4dee86edd8bd0b4ba6aR133
It's the same for blocked and unblocked work in the case of an interruption. But if a low pri update is blocked from committing, that work is totally thrown away unless you store it somewhere special, where in the unblocked case, the low pri tree becomes current and you can update from there. |
Pending promises block the tree from committing at that priority. If we receive a higher priority update, we should be able to commit that work without throwing out the work that was blocked.
8a2e419 to
e91a3fa
Compare
The blocking context is simply a boolean that indicates whether the current unit of work was blocked from committing. If it's true, we can continue using the progressed work regardless of priority.
|
One thing that we need to think about is that this breaks |
|
Closing as it's not for merge. |
For fun, here's a proof-of-concept based on an idea by @sebmarkbage.
If a promise is passed to
React.createElement, on initial mount, React waits for the promise to resolve to a React element type. Meanwhile, it bails out and continues rendering elsewhere. Once the promise resolves, React renders using the resolved element type. Subsequent renders are not deferred and behave just like a regular element.Such a feature could be useful alongside dynamic
import()(currently stage 3):(Again, this is just an experiment and only implemented in Fiber. It may never see public release.)