Skip to content

[Fiber][Not for commit] Proof-of-concept: Promise as element type#8595

Closed
acdlite wants to merge 3 commits intofacebook:masterfrom
acdlite:promiseaselementtype
Closed

[Fiber][Not for commit] Proof-of-concept: Promise as element type#8595
acdlite wants to merge 3 commits intofacebook:masterfrom
acdlite:promiseaselementtype

Conversation

@acdlite
Copy link
Collaborator

@acdlite acdlite commented Dec 17, 2016

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):

// Stage 3 import()
const MyComponent = import('MyComponent');
// MyComponent is a promise that resolves to a normal React class/function

function ParentComponent() {
  // Use it with JSX like you normally would. It will render once the promise resolves.
  return <MyComponent prop="whatever" />;
}

(Again, this is just an experiment and only implemented in Fiber. It may never see public release.)

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.
@sebmarkbage
Copy link
Contributor

This should not behave as return null. That is already easy to do in user space.

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 17, 2016

Basically that particular priority level is blocked from committing until all promises are resolved.

Interesting...

@sebmarkbage
Copy link
Contributor

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 17, 2016

Could you implement this using a new priority field, e.g. blockedWorkPriority, that represents the priority of work in that subtree that is "blocked" by a promise? Then in the commit phase you compare the work priority to the blocked priority.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 17, 2016

^ Going to try.

@sebmarkbage
Copy link
Contributor

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.

@sebmarkbage
Copy link
Contributor

sebmarkbage commented Dec 18, 2016

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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 progressedChild is set on both trees at the same time.

So I think work is only thrown out when you render at a higher priority, but that's always the case.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

Gah after all this time I still don't understand how progressedChild works :D

@sebmarkbage
Copy link
Contributor

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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.

@sebmarkbage
Copy link
Contributor

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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 progressedChild.

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...

@sebmarkbage
Copy link
Contributor

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.

@wmertens
Copy link
Contributor

Could this also be used for data loading? That would greatly simplify server-side rendering…

@wmertens
Copy link
Contributor

I mean, you decorate your component in something that loads data, and calling render will fully resolve all promises in one go, instead of having to render twice.

This could also be used for portals like react-helmet, that can only provide their contents once the rest of the page was processed.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

Ok I think what I'm going to try is to store a blockedChild on the root so that the blocked work can be reused even when the progressed priority doesn't match.

@sebmarkbage
Copy link
Contributor

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.

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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 blockedChild. In the begin phase, we reuse the blocked child regardless of its priority. Once the child commits, we reset the blockedChild to null.

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')]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the test case that I struggled to get passing. Passes now, but I'm not sure if the solution is correct.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I think this is only really a problem for the initial mount, when there is no current tree.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amended the commit, here's a link to the same test case: e91a3fa#diff-935a1efc6cdeb4dee86edd8bd0b4ba6aR133

@acdlite
Copy link
Collaborator Author

acdlite commented Dec 18, 2016

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.

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.
@acdlite acdlite force-pushed the promiseaselementtype branch from 8a2e419 to e91a3fa Compare December 19, 2016 18:18
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.
@sebmarkbage
Copy link
Contributor

One thing that we need to think about is that this breaks defaultProps since they're normally resolved at element creation time. We just have to make sure that after the component has loaded we resolve defaultProps before continuing.

@gaearon
Copy link
Collaborator

gaearon commented Oct 4, 2017

Closing as it's not for merge.

@gaearon gaearon closed this Oct 4, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants