[Fiber] setState #7344

Merged
merged 15 commits into from Sep 13, 2016

Projects

None yet

5 participants

@acdlite
Member
acdlite commented Jul 24, 2016 edited

workInProgress (yay puns)

Implements setState for Fiber. Updates are scheduled by setting a work priority on the fiber and bubbling it to the root. Because the instance does not know which tree is current at any given time, the update is scheduled on both fiber alternates.

Need to add more unit tests to cover edge cases.

A few questions I have:

  • How to deal with the circular dependency between ReactFiberScheduler and ReactFiberBeginWork. I'm using a closure to lazily access the scheduler but seems like there may be a better way.
  • How to queue update callbacks. The only way I could think to do it was to add another field to Fiber, so I've punted on this for now.
  • How to access the fiber from the updater. I decided to use a private field on the instance. Could also create a new updater for each instance and close over its fiber, but that seems wasteful.
  • Should I put setState related tests in their own module? I think I should but not sure.

(I discussed with @sebmarkbage before working on this PR.)

Based on #7448. Compare view: sebmarkbage@fiberstarvation...acdlite:fibersetstate

@ghost ghost added the CLA Signed label Jul 24, 2016
@acdlite acdlite and 1 other commented on an outdated diff Jul 24, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
+ scheduleLowPriWork(root, priorityLevel);
+ return;
+ }
+ if (!fiber.return) {
+ throw new Error('No root!');
+ }
+ fiber = fiber.return;
+ }
+ }
+
+ // Class component state updater
+ const updater = {
+ enqueueSetState(instance, partialState) {
+ const fiber = instance._fiber;
+
+ const prevState = fiber.pendingState || fiber.memoizedState;
@acdlite
acdlite Jul 24, 2016 Member

Pretty sure using fiber.memoizedState here is a bug because we don't know if fiber is current. But, I don't know how to get the current state. It would be nice if we could rely on instance.state but that won't work in the case of an aborted update.

@acdlite
acdlite Jul 24, 2016 Member

Will have the same problem with getting the current props for the updater form of setState((state, props) => newState).

@sebmarkbage
sebmarkbage Jul 24, 2016 Member

Yea, that's why this will need to become a queue of state transitions for those cases.

You may also need to be able to schedule different priority level state transitions on the same component. In that case the queue can be reordered. We can wait on that though. Not sure it is worth the trouble.

Can pendingState just use the partial state and that way you can do the merge at the time you apply it?

@sebmarkbage
Member

We'll figure something out with the dependency later. One technique is to just put everything in the same file and then try to break it apart again into reasonable pieces.

I figured that the simple pendingState could an object, and then if you use setState((state, props) => ...); or the callback model or it would turn into an array or linked list.

To access the fiber from the updater, you can use a field. That's what we do now. However, you could switch the implementation to use ReactInstanceMap which the current implementation does so we can share that code. Just move it to: src/renderers/shared/shared/ to indicate that we would like to share it between both implementations.

@sebmarkbage
Member

As for the unit tests, we can keep them in Incremental for now. In the near future we'll just run this against the existing unit tests for setState. We'll evolve the tests you're writing now into tests that specifically tests the new incremental effects of setState.

@acdlite
Member
acdlite commented Jul 24, 2016

Ah keeping a queue of pending states makes sense. A linked list sounds like the way to go, but how to disambiguate between a state object and a linked list? $$typeof?

@sebmarkbage
Member

Always wrapping it in a linked list node might be fine. Should be short lived and not a very hot path.

On Jul 24, 2016, at 4:44 PM, Andrew Clark notifications@github.com wrote:

Ah keeping a queue of pending states makes sense. A linked list sounds like the way to go, but how to disambiguate between a state object and a linked list? $$typeof?

โ€”
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or mute the thread.

@acdlite
Member
acdlite commented Jul 25, 2016

Changed the type of pendingState to be a queue.

Regarding typing, I'm guessing eventually the type of the props and state will be inferred from the element? For now I've used any because that's what props uses, but I know that's bad.

Also not sure about naming. pendingState is confusing because it's a queue of partial states, whereas pendingProps is a single props object.

@acdlite
Member
acdlite commented Jul 25, 2016

Maybe rename pendingState to stateQueue?

@sebmarkbage
Member

Sure. Wouldn't worry too much about it yet until all the pieces are there and have proven themselves.

It's quite possible that props will become a queue too if we want to solve the problem of props not being consistent when an event happens.

On Jul 24, 2016, at 11:04 PM, Andrew Clark notifications@github.com wrote:

Maybe rename pendingState to stateQueue?

โ€”
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or mute the thread.

@acdlite acdlite and 1 other commented on an outdated diff Jul 25, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
+ if (pendingState === null) {
+ pendingState = nextPendingStateNode;
+ } else {
+ if (pendingState.tail === null) {
+ pendingState.next = nextPendingStateNode;
+ } else {
+ pendingState.tail.next = nextPendingStateNode;
+ }
+ pendingState.tail = nextPendingStateNode;
+ }
+
+ // Must schedule an update on both alternates, because we don't know tree
+ // is current.
+ scheduleUpdate(fiber, pendingState, LowPriority);
+ if (fiber.alternate) {
+ scheduleUpdate(fiber.alternate, pendingState, LowPriority);
@acdlite
acdlite Jul 25, 2016 Member

Rather than bubble up the priority both trees, could we bubble once and assign to the alternate at each level? I think so...

@sebmarkbage
sebmarkbage Jul 25, 2016 Member

Yea, that's the only correct way to do it, because .return is not guaranteed to point to two different nodes even for two children.

These are not two entirely parallel trees. They can overlap. Two parent fibers can point to the same exact child fiber when the child gets reused.

@acdlite
acdlite Jul 25, 2016 Member

Hmm, I'm confused. I see your point about how the trees aren't parallel, but given that, isn't bubbling up both trees the only way? The first part of your comment seems to contradict the second.

@sebmarkbage
sebmarkbage Jul 25, 2016 Member

The parent path is guaranteed to be the same conceptual component path. However, you can't rely on any particular node being the current one along the path.

So this is safe:

while (node) {
  addState(node);
  addState(node.alternate);
  node = node.return;
}

But this is not safe, because it is not guaranteed to cover every node:

node = start;
while (node) {
  addState(node);
  node = node.return;
}
node = start.alternate;
while (node) {
  addState(node);
  node = node.return;
}

An example when this happens:

// Assume the cycles are resolved
a1 = { child: b1, return: null, alternate: a2 };
a2 = { child: b2, return: null, alternate: a1 };
b1 = { child: c1, return: a1, alternate: b2 };
b2 = { child: c1, return: a2, alternate: b1 };
c1 = { child: null, return: b1, alternate: c2 };
c2 = { child: null, return: b1, alternate: c1 }; // Note that the return is the same b1.

Since two versions of a fiber can point to the same child, it is not possible for a parent pointer alone to point to every parent since there can be multiple.

Conceptually this pointer is used for two purposes. A pointer to the parent "Instance" which I've merged into the Fiber. See the type definition.

However, it is also used as a temporary pointer for knowing which fiber to step back through after processing.

If I wasn't so unnecessarily clever about allocations the structure would like this:

type Instance = { parent: ?Instance };
type Fiber = { inst: Instance, return: ?Fiber };

and you would just walk the parent pointer. However, I'm being clever to see how far we can get but it adds some complexity.

@acdlite
acdlite Jul 25, 2016 Member

Ooooh, okay. I think I get it now! Thanks for the detailed explanation (and for being patient as I try to keep up, haha).

@sebmarkbage sebmarkbage and 1 other commented on an outdated diff Jul 27, 2016
src/renderers/shared/fiber/ReactFiberStateQueue.js
return prevState;
}
let state = Object.assign({}, prevState);
do {
- const partialState = typeof queue.partialState === 'function' ?
- queue.partialState(state, props) :
- queue.partialState;
+ const partialState = typeof node.partialState === 'function' ?
+ node.partialState(state, props) :
@sebmarkbage
sebmarkbage Jul 27, 2016 Member

This will pass the node to user code, leaking this info. Pick off the function before invoking it so that this will be undefined (or global depending on strict mode).

const stateUpdater = node.partialState;
const partialState = stateUpdater(state, props);
@acdlite
acdlite Jul 27, 2016 Member

Good catch

@ghost ghost added the CLA Signed label Aug 1, 2016
@ghost ghost added the CLA Signed label Aug 2, 2016
@acdlite
Member
acdlite commented Aug 2, 2016

A few things left to do.

We need a separate field for the priority of a fiber's pending props and state, distinct from the existing field pendingWorkPriority, which represents the highest priority of the entire subtree. Otherwise, there's no way to schedule work deep in the tree without overriding the priority of every ancestor. I got this working locally but I'm holding off until @sebmarkbage fixes some priority-related bugs on his branch.

We also need more tests, specifically around preempted updates. In order to do this, we need to implement high priority updates. @sebmarkbage's idea is an API like ReactNoop.performHighPriWork(fn) where all updates called within that scope have high priority by default.

@ghost ghost added the CLA Signed label Aug 2, 2016
@sebmarkbage sebmarkbage was assigned by zpao Aug 3, 2016
@ghost ghost added the CLA Signed label Aug 3, 2016
@acdlite acdlite commented on an outdated diff Aug 5, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
@@ -132,6 +134,29 @@ module.exports = function<T, P, I, C>(config : HostConfig<T, P, I, C>, getSchedu
createUpdateQueue(partialState);
scheduleUpdate(fiber, updateQueue, LowPriority);
},
+ enqueueCallback(instance, callback) {
+ const fiber = ReactInstanceMap.get(instance);
+ let updateQueue = fiber.updateQueue ?
+ fiber.updateQueue :
+ createUpdateQueue(null);
+ addCallbackToQueue(updateQueue, callback);
+
+ if (fiber.callbackList) {
+ // If this fiber was preempted and already has callbacks queued up,
+ // we need to make sure they are part of the new update queue
+ updateQueue = concatQueues(fiber.callbackList, updateQueue);
@acdlite
acdlite Aug 5, 2016 edited Member

Oops, this isn't right because this also adds the previous state updates to the beginning of the queue! This causes the updates to be applied twice, because the updates were already used to compute the previous memoized state. I believe this is unobservable unless there are side-effects inside state updaters (which there shouldn't be), but it's wasteful regardless.

Should instead combine the callbacks into a single callback, add to an empty node, and prepend it to the update queue.

@acdlite
Member
acdlite commented Aug 6, 2016

@sebmarkbage Rebased

@ghost ghost added the CLA Signed label Aug 6, 2016
@sebmarkbage sebmarkbage and 1 other commented on an outdated diff Aug 6, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
@@ -266,7 +266,8 @@ module.exports = function<T, P, I, C>(config : HostConfig<T, P, I, C>) {
// TODO: This passes the current node and reads the priority level and
// pending props from that. We want it to read our priority level and
// pending props from the work in progress. Needs restructuring.
- return findNextUnitOfWorkAtPriority(workInProgress, priorityLevel);
+ var work = findNextUnitOfWorkAtPriority(workInProgress, priorityLevel);
+ return work;
@sebmarkbage
sebmarkbage Aug 6, 2016 edited Member

This change was reverted but still in your commit history due to the rebase not being able to ignore. You can exclude it when doing your rebase.

@acdlite
acdlite Aug 6, 2016 Member

Oops. I think it's fixed now?

@ghost ghost added the CLA Signed label Aug 6, 2016
@sebmarkbage sebmarkbage and 1 other commented on an outdated diff Aug 6, 2016
src/renderers/shared/fiber/ReactFiberScheduler.js
@@ -126,6 +136,10 @@ module.exports = function<T, P, I, C>(config : HostConfig<T, P, I, C>) {
// The work is now done. We don't need this anymore. This flags
// to the system not to redo any work here.
workInProgress.pendingProps = null;
+ workInProgress.updateQueue = null;
+ if (current) {
+ current.updateQueue = null;
@sebmarkbage
sebmarkbage Aug 6, 2016 Member

This looks scary. This whole work in progress tree can still be aborted so it is not safe to reset this yet. Wouldn't that drop work if that happens?

@acdlite
acdlite Aug 6, 2016 edited Member

Is the fix as simple as not resetting the updateQueue on current here? I think so...

@sebmarkbage sebmarkbage and 1 other commented on an outdated diff Aug 6, 2016
src/renderers/shared/fiber/ReactFiberCompleteWork.js
@@ -132,6 +130,17 @@ module.exports = function<T, P, I, C>(config : HostConfig<T, P, I, C>) {
return null;
case ClassComponent:
transferOutput(workInProgress.child, workInProgress);
+ // Don't use the state queue to compute the memoized state. We already
+ // merged it and assigned it to the instance. Transfer it from there.
+ const state = workInProgress.stateNode.state;
+ workInProgress.memoizedState = state;
+ // Transfer update queue to callbackList field so callbacks can be
+ // called during commit phase.
+ workInProgress.callbackList = workInProgress.updateQueue;
+ if (current) {
+ current.callbackList = workInProgress.callbackList;
@sebmarkbage
sebmarkbage Aug 6, 2016 Member

Same issue here. We should be able to drop the whole work in progress tree without any side-effects being retained.

@acdlite
acdlite Aug 6, 2016 edited Member

Hmm I think I see what you mean. So the only place we should ever reset the queue / callback list is after the changes are committed?

EDIT: Meant this to be a reply to the other comment above.

@acdlite
acdlite Aug 6, 2016 Member

Ah alright. I'll need to rethink what to do here then: https://github.com/acdlite/react/blob/04c9a399e871d5ffe7323f11132b51185bc32e6e/src/renderers/shared/fiber/ReactFiberBeginWork.js#L158-L174

Does the overall strategy of callback list seem sound or should I go back to the drawing board?

@acdlite
Member
acdlite commented Aug 6, 2016

@sebmarkbage Pushed some changes that I believe address your comments. I think I understand the relationship between the current tree and work in progress tree better now.

@acdlite acdlite commented on an outdated diff Aug 6, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
+ const updateQueue = fiber.updateQueue || createUpdateQueue(null);
+ updateQueue.isForced = true;
+ scheduleUpdate(fiber, updateQueue, LowPriority);
+ },
+ enqueueCallback(instance, callback) {
+ const fiber = ReactInstanceMap.get(instance);
+ let updateQueue = fiber.updateQueue ?
+ fiber.updateQueue :
+ createUpdateQueue(null);
+ addCallbackToQueue(updateQueue, callback);
+
+ let preemptedFiber;
+ if (preemptedFiber = fiber.callbackList || (fiber.alternate && fiber.alternate.callbackList)) {
+ // If the workInProgress was preempted and already has callbacks queued
+ // up, we need to make sure they are part of the new update queue.
+ const { callbackList } = preemptedFiber;
@acdlite
acdlite Aug 6, 2016 Member

Actually... I don't think any of this is necessary at all, right? If an update is preempted the callbacks won't be lost because they're still on the current tree's update queue?

@acdlite
acdlite Aug 6, 2016 edited Member

I'm having trouble reasoning about this since it's not yet possible to test :D

@ghost ghost added the CLA Signed label Aug 7, 2016
@ghost ghost added the CLA Signed label Aug 7, 2016
@sebmarkbage sebmarkbage and 1 other commented on an outdated diff Aug 7, 2016
src/renderers/shared/fiber/ReactFiberCommitWork.js
@@ -31,6 +32,10 @@ module.exports = function<T, P, I, C>(config : HostConfig<T, P, I, C>) {
function commitWork(finishedWork : Fiber) : void {
switch (finishedWork.tag) {
case ClassComponent: {
+ if (finishedWork.callbackList) {
+ callCallbacks(finishedWork.callbackList);
+ finishedWork.callbackList = null;
@sebmarkbage
sebmarkbage Aug 7, 2016 edited Member

I tend to prefer extract such lists off the object, into a variable, and reset it to null on the object before calling it. Makes it easier to reason about reentry that happen inside of the callbacks, such as new callbacks being scheduled onto this list or errors being thrown.

@acdlite
acdlite Aug 7, 2016 Member

Good call

@acdlite
Member
acdlite commented Aug 9, 2016

@sebmarkbage I rebased against your fiberstarvation branch. I think you're right to worry about sharing the update queue between both trees โ€” after rebasing, there was a bug where updates weren't being cleared from the current tree. I solved by clearing the queue during the commit phase but I'm unsure if it's safe to do so. Added a test to ensure that setState works inside a callback.

@sebmarkbage
Member

I rebased this on top of my stuff for you. However, now it is failing tests. Not sure why yet.

https://github.com/sebmarkbage/react/tree/fibersetstate

@ghost ghost added the CLA Signed label Sep 3, 2016
@sebmarkbage sebmarkbage commented on an outdated diff Sep 6, 2016
src/renderers/shared/fiber/ReactFiberBeginWork.js
}
- if (!workInProgress.childInProgress &&
- workInProgress.pendingProps === workInProgress.memoizedProps) {
+ if (workInProgress.progressedPriority === priorityLevel) {
+ // If we have progressed work on this priority level already, we can
+ // proceed this that as the child.
+ workInProgress.child = workInProgress.progressedChild;
+ }
+
+ if (workInProgress.pendingProps === null || (
+ workInProgress.memoizedProps !== null &&
+ workInProgress.pendingProps === workInProgress.memoizedProps &&
+ workInProgress.updateQueue === null
@sebmarkbage
sebmarkbage Sep 6, 2016 Member

I screwed this up in the rebase. If pendingProps === null the updateQueue also needs to be === null. I'll fix it.

@sebmarkbage
Member

We know that there might still be issues with some minor details of this and we'll need more tests, but this is a great start. I'll accept it pending that the base PR #7636 lands.

@coveralls

Coverage Status

Coverage increased (+0.005%) to 87.196% when pulling 3e92354 on acdlite:fibersetstate into 6a525fd on facebook:master.

@ghost ghost added the CLA Signed label Sep 6, 2016
@coveralls

Coverage Status

Coverage increased (+0.005%) to 87.196% when pulling 319e755 on acdlite:fibersetstate into 6a525fd on facebook:master.

acdlite and others added some commits Jul 24, 2016
@acdlite @sebmarkbage acdlite setState for Fiber
Updates are scheduled by setting a work priority on the fiber and bubbling it to
the root. Because the instance does not know which tree is current at any given
time, the update is scheduled on both fiber alternates.

Need to add more unit tests to cover edge cases.
94d2317
@acdlite @sebmarkbage acdlite Use queue for pendingState
Changes the type of pendingState to be a linked list of partial
state objects.
8b200c5
@acdlite @sebmarkbage acdlite Updater form of setState
Add support for setState((state, props) => newState).

Rename pendingState to stateQueue.
25d199a
@acdlite @sebmarkbage acdlite Clean up
Rather than bubble up both trees, bubble up once and assign to the
alternate at each level.

Extract logic for adding to the queue to the StateQueue module.
909cacc
@acdlite @sebmarkbage acdlite Fix stateQueue typing 691e053
@acdlite @sebmarkbage acdlite Clean up
Use a union type for the head of StateQueue.
d218158
@acdlite @sebmarkbage acdlite Use ReactInstanceMap
Move ReactInstanceMap to src/renderers/shared/shared to indicate that
this logic is shared across implementations.
97dac74
@acdlite @sebmarkbage acdlite Ensure that setState update function's context is undefined f7dab22
@acdlite @sebmarkbage acdlite Rename stateQueue -> updateQueue
Also cleans up some types.
c6db7f7
@acdlite @sebmarkbage acdlite Update callbacks
Callbacks are stored on the same queue as updates. They care called
during the commit phase, after the updates have been flushed.

Because the update queue is cleared during the completion phase (before
commit), a new field has been added to fiber called callbackList. The
queue is transferred from updateQueue to callbackList during completion.
During commit, the list is reset.

Need a test to confirm that callbacks are not lost if an update is
preempted.
1d49237
@acdlite @sebmarkbage acdlite replaceState
Adds a field to UpdateQueue that indicates whether an update should
replace the previous state completely.
d8c24cf
@acdlite @sebmarkbage acdlite forceUpdate
Adds a field to the update queue that causes shouldComponentUpdate to
be skipped.
f514662
@acdlite @sebmarkbage acdlite Don't mutate current tree before work is committed.
We should be able to abort an update without any side-effects to the
current tree. This fixes a few cases where that was broken.

The callback list should only ever be set on the workInProgress.
There's no reason to add it to the current tree because they're not
needed after they are called during the commit phase.

Also found a bug where the memoizedProps were set to null in the
case of an update, because the pendingProps were null. Fixed by
transfering the props from the instance, like we were already doing
with state.

Added a test to ensure that setState can be called inside a
callback.
0ca1cea
@sebmarkbage sebmarkbage Check for truthiness of alternate
This is unfortunate since we agreed on using the `null | Fiber`
convention instead of `?Fiber` but haven't upgraded yet and this
is the pattern I've been using everywhere else so far.
f3a2dc2
@sebmarkbage sebmarkbage Log the updateQueue in dumpTree
This also buffers all rows into a single console.log call.
This is because jest nows adds the line number of each console.log
call which becomes quite noisy for these trees.
27d1210
@ghost ghost added the CLA Signed label Sep 13, 2016
@sebmarkbage
Member

Ninja merging this. Will fix travis if errors are caused. #workedonmymachine

@sebmarkbage sebmarkbage merged commit 3e54b28 into facebook:master Sep 13, 2016

1 check was pending

continuous-integration/travis-ci/pr The Travis CI build is in progress
Details
@zpao zpao modified the milestone: 15-next Sep 19, 2016
@zpao zpao modified the milestone: 15-next, 15.4.0 Oct 4, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment