-
Notifications
You must be signed in to change notification settings - Fork 47k
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
[Fiber] Sync mount and unmount #8634
Conversation
Can you explain what shouldDeferSyncUpdates does and the change you had to make to the internals to make this work? How are life-cycles affected? Such as if you render in a life-cycle. |
Previously we used So the only observable difference is if you nest ReactDOMFiber wraps top-level mount and unmount with |
Sorry, I still don't understand. What happens in the observable case? Does the new test cover it? What happens if you used the old behavior? |
@sebmarkbage In the observable case, updates inside Latest commit enables recursive calls to A few other tests are now failing. I'll look at these some time in the next few days. |
To answer your other question, the old behavior was to always defer sync updates if we were already performing work. This was to prevent recursion, but this new feature requires it. |
197b461
to
3080d32
Compare
This is now ready for review. One problem I discovered is whether to allow synchronous updates during the begin phase (inside My solution was to downgrade such updates to Task and print a warning. |
For legacy purposes. Only enabled in the DOM renderer. We can remove this in a future release when we enable incremental-by-default. This change is unobservable because syncUpdates actually schedules Task updates when it is called from inside another batch. The correct behavior is to recursively begin another batch of work. We will fix it in a subsequent commit.
3080d32
to
2f85a21
Compare
Cleaned up the commits a bit so it's (hopefully) easier to review. |
@@ -322,9 +324,15 @@ function renderSubtreeIntoContainer(parentComponent : ?ReactComponent<any, any, | |||
while (container.lastChild) { | |||
container.removeChild(container.lastChild); | |||
} | |||
root = container._reactRootContainer = DOMRenderer.createContainer(container); | |||
const newRoot = DOMRenderer.createContainer(container); |
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.
Given that syncUpdates
immediately executes the callback you pass it, why is the new newRoot
variable necessary?
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.
To satisfy Flow, which complains otherwise. I'm guessing because it doesn't know that syncUpdates
executes its callback synchronously.
// completeWork, rather than unwind the stack, we can just restart | ||
// from the root. Can't do that until then because without memoized | ||
// props, the nodes higher up in the tree will rerender unnecessarily. | ||
if (failedWork) { |
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.
You've already checked if (failedWork)
above. This check seems unnecessary.
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.
Good catch
performWork(TaskPriority); | ||
} | ||
// TODO: If we're not already performing work, schedule a | ||
// deferred callback. |
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.
Curious why this is a TODO instead of actual code. Is it more difficult than it seems?
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.
No it's not. Just outside the scope of this PR. It should be accompanied by tests.
ReactNoop.batchedUpdates(() => { | ||
ReactNoop.syncUpdates(() => { | ||
ReactNoop.render(<span />); | ||
expect(ReactNoop.getChildren()).toEqual([span()]); |
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.
Nit: This test could produce a false positive if either batchedUpdates
or syncUpdates
happened to defer the callback. (Which I guess is super unlikely so maybe this doesn't matter.)
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.
Not sure what you mean. All batchedUpdates
does it make it so any sync updates are batched together using Task priority. syncUpdates
does the opposite, where if you're already in a batch, it forces a synchronous flush.
The test only passes if line 347 flushes synchronously. So I'm not sure how you'd get a false positive.
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.
If batchedUpdates
didn't immediately execute the provided callback, the test would complete without any assertions (since it's not an async test) which could potentially give you a false positive if there was a failing assertion inside of the inner callback.
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.
Oh I see. Yeah this test assumes that batchedUpdates
works properly, because we have other tests that cover the behavior of batchedUpdates
.
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.
You could guard against this with:
it('can force synchronous updates with syncUpdates, even inside batchedUpdates', (done) => {
ReactNoop.batchedUpdates(() => {
ReactNoop.syncUpdates(() => {
ReactNoop.render(<span />);
expect(ReactNoop.getChildren()).toEqual([span()]);
done();
});
});
});
But yeah I agree, probably not necessary. 😄
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 have a strong opinion on it, so I'll add it to make you happy :D
@@ -345,7 +348,7 @@ module.exports = function<T, P, I, TI, C, CX>(config : HostConfig<T, P, I, TI, C | |||
firstEffect = finishedWork.firstEffect; | |||
} | |||
|
|||
prepareForCommit(); | |||
const commitInfo = prepareForCommit(); |
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'm curious what motivated this method signature change. At first I thought it might be to support nested calls to commitAllWork
(between the call to prepareForCommit
and resetAfterCommit
). But I don't think that's a use-case we support. commitAllLifeCycles
might generate more work, but commitAllHostEffects
shouldn't- right?
I'm probably overlooking something. 😁
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.
That's what I thought too but something in the DOM renderer does trigger a re-render in between prepareForCommit
and resetAfterCommit
, in one of the tests. I'll be honest and confess I don't know exactly why — something in the event system I'm guessing — but this seems like a less fragile solution than what we were doing previously (stashing values in global state), anyway.
If you revert the last commit you'll see the test that fails.
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 reverted your most recent commit (79f01b2) and ran all tests, and you're right. Looking at one of the failing tests, it seems that unmounting from a componentWillUnmount
hook eventually results in more sync priority work being scheduled.
Okay. You're right, this seems less fragile anyway. 👍
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.
Oh right, I forgot that componentWillUnmount
is called during the first pass. That explains it then.
This can only be caused by a bug in the renderer, but we should handle it gracefully anyway. Added a TODO to change capturedErrors and failedBoundaries so that they are sets of instances (stateNodes) rather than fibers, to avoid having to check the alternate. (This outside the scope of this PR.)
Currently we assume that performWork is never called recursively. Ideally that is the case. We shouldn't rely on recursion anywhere in Fiber. However, to support the use of syncUpdates as an opt-in to forced- synchronous updates, we need the ability to call performWork recursively. At the beginning of performWork, the state of the scheduler is saved; before exiting, that state is restored, so that the scheduler can continue where it left off. I've decided to use local variables to stash the previous state. We could also store them in a record and push it to a stack, but this approach has the advantage of being isolated to performWork.
Typically, a sync update is downgraded to Task priority if it's scheduled within another batch of work, e.g. within a lifecycle like componentDidMount. But syncUpdates should force sync updates to not be downgraded to Task. This will cause performWork to be called recursively.
2f85a21
to
3a25252
Compare
Stack allows this, but in Fiber it may cause an infinite loop. Instead we'll print a warning and defer the update by giving it Task priority.
The DOM renderer assumes that resetAfterCommit is called after prepareForCommit without any nested commits in between. That may not be the case now that syncUpdates forces a nested update. To address, this changes the type of prepareForCommit to return a value which is later passed to resetAfterCommit.
3a25252
to
79f01b2
Compare
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.
This PR looks good to me.
ReactBrowserEventEmitter.setEnabled(false); | ||
selectionInformation = ReactInputSelection.getSelectionInformation(); | ||
return { |
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.
FWIW this is how @spicyj wrote it originally but @sebmarkbage wanted to avoid the allocation.
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 see. I don't see a way to avoid it given that a sync update may occur inside componentWillUnmount
. Unless we disallow sync updates there.
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.
That actually might be a good idea... Interleaving commit phases could cause trouble.
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.
Since nested interleaved commits are broken anyway, can we revert this?
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.
Oops yes I meant to do that, forgot!
Ensures that
syncUpdates
resets the batching context so that sync updates are not downgraded to Task. This provides an escape hatch for code that relies on synchronous flushing of updates.Top-level mount and unmount are now wrapped in
syncUpdates
. Should we do this for ART and native as well?