-
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
[Hydration Bugfix] Updates to dehydrated content when disableSchedulerTimeoutBasedOnReactExpirationTime
is enabled
#16614
Conversation
Adds a test case for receving an update to dehydrated content when the `disableSchedulerTimeoutBasedOnReactExpirationTime` flag is enabled.
Canceling an already running task is currently a no-op. It does not cause the task to yield execution, and if the task does yield with a continuation, then the continuation will run. This change addresses both parts: canceling the current task causes `shouldYield` to return `true`, and if the canceled task returns a continuation, the continuation is ignored. This fixes the regression test introduced in the preceding commit. There's likely a better way to model a restart of an in-progress render, but this approach is conceptually similar to how a regular high priority interruption already works.
3b40cc0
to
83102b6
Compare
Details of bundled changes.Comparing: 4ef2696...83102b6 react-art
react-dom
react-test-renderer
react-reconciler
react-native-renderer
Generated by 🚫 dangerJS |
ReactFeatureFlags = require('shared/ReactFeatureFlags'); | ||
ReactFeatureFlags.enableSuspenseServerRenderer = true; | ||
ReactFeatureFlags.enableSuspenseCallback = true; | ||
ReactFeatureFlags.enableFlareAPI = true; |
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.
Was this intentional?
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.
Probably not necessary, I just copy pasted from the top of the file for consistency:
react/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
Lines 23 to 36 in 83102b6
jest.resetModuleRegistry(); | |
ReactFeatureFlags = require('shared/ReactFeatureFlags'); | |
ReactFeatureFlags.enableSuspenseServerRenderer = true; | |
ReactFeatureFlags.enableSuspenseCallback = true; | |
ReactFeatureFlags.enableFlareAPI = true; | |
React = require('react'); | |
ReactDOM = require('react-dom'); | |
act = require('react-dom/test-utils').act; | |
ReactDOMServer = require('react-dom/server'); | |
Scheduler = require('scheduler'); | |
Suspense = React.Suspense; | |
SuspenseList = React.unstable_SuspenseList; |
@@ -288,6 +288,26 @@ describe('Scheduler', () => { | |||
expect(Scheduler).toFlushWithoutYielding(); | |||
}); | |||
|
|||
it('cancelling the currently running task', () => { | |||
const task = scheduleCallback(NormalPriority, () => { |
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.
Are there any other priorities we need to test cancelling on other than normal? Should there be a test to confirm that discrete priority events do not get cancelled?
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 priority shouldn't be relevant, but I'll add the same test at user-blocking priority as an extra precaution.
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 understand the implementation here and understand what you're doing here and it looks fine for a short-term solution, as I agree – depending on the scheduler's semantics for canceling and yielding is a bit leaky.
I also added some comments in regards to some things though that you might want to respond to.
Superseded by #16771 |
The Bug
When server rendered content that hasn't finished hydrating yet ("dehydrated" content) receives an update (via props or context), React has a mechanism to force the content to hydrate before applying the update. It does this by increasing the priority of the hydration task from Idle to a level slightly higher than the current render. React will abort the current render, perform the hydration, then try the update again on top of the now-fully-hydrated content.
There are unit tests that cover this case. The bug starts happening when
disableSchedulerTimeoutBasedOnReactExpirationTime
is enabled. It turns out that the mechanism to interrupt the current rendering task depends on the hydration task having a slightly earlier timeout, because Scheduler tasks are sorted by their timeouts. When the hydration task is higher priority, it causesshouldYield
to flip to true, forcing the render to yield execution and allowing the hydration task to start. (This is similar to how input events can interrupt normal priority renders.)disableSchedulerTimeoutBasedOnReactExpirationTime
breaks this mechanism, because when it is enabled, the timeout given to Scheduler is no longer based on React's internal expiration times. Effectively, all rendering tasks within the same priority category are first-in-first-out. So, the hydration task comes after the original task in the Scheduler queue, and thereforeshouldYield
will keep returningfalse
, and the original task will run to completion. (See #16284 for more information ondisableSchedulerTimeoutBasedOnReactExpirationTime
.)The first commit in this PR adds a regression test for this case.
The Fix
There are several potential fixes. The one I've chosen is not ideal in the long term, but it's lower risk compared to the complete solution, which will likely require some refactoring of how rendering tasks are scheduled.
The work loop already has some logic to cancel a rendering task in favor of a higher priority one, using
Scheduler.cancelCallback
. It does this by comparing the React expiration times of each task, so it doesn't depend on the ordering of tasks in Scheduler. This works when the high priority task is received during an input event.However,
Scheduler.cancelCallback
is currently a no-op when given an already-running task. It does not cause the task to stop execution, and if the task does yield with a continuation, then the continuation will run. Which means it won't work if React is already inside the render phase. (Note the distinction between "inside the render phase" versus "in an event that fires in between two chunks of a time sliced task.")The fix in the second commit addresses both parts: canceling the current task causes
shouldYield
to returntrue
, and if the canceled task returns a continuation, the continuation is ignored.This is sufficient to fix the regression.
Alternative Fixes
A proper fix would be to model interruptions of in-progress renders in such a way that it does not depend on Scheduler's semantics for canceling and yielding. However, because of the inherent risk involved in changing how rendering tasks are scheduled, I would prefer to land this smaller fix first before attempting a refactor.
(There's already a planned mini-refactor of the work loop, e.g. to optimize how pings and restarts are modeled. We can fold this into that larger change.)