Skip to content
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

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,103 @@ describe('ReactDOMServerPartialHydration', () => {
expect(span.className).toBe('hi');
});

it('regression test: an update to dehydrated content forces a restart to hydrate it first', async () => {
// Replicates a bug where React would fail to restart and hydrate when
// `disableSchedulerTimeoutBasedOnReactExpirationTime` is true.
jest.resetModuleRegistry();

ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableSuspenseServerRenderer = true;
ReactFeatureFlags.enableSuspenseCallback = true;
ReactFeatureFlags.enableFlareAPI = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

Was this intentional?

Copy link
Collaborator Author

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:

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;

ReactFeatureFlags.debugRenderPhaseSideEffects = false;
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
// NOTE: Once this feature flag is removed, the preceding test is likely
// sufficient to cover this regression case; however, it might be worth
// keeping this test around anyway as an extra precaution.
ReactFeatureFlags.disableSchedulerTimeoutBasedOnReactExpirationTime = 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;

function Text({text}) {
Scheduler.unstable_yieldValue(
text + ' [' + Scheduler.unstable_getCurrentPriorityLevel() + ']',
);
return text;
}

function Parent({text, className}) {
Scheduler.unstable_yieldValue(
'Parent [' + Scheduler.unstable_getCurrentPriorityLevel() + ']',
);
return (
<div>
<Suspense fallback="Loading...">
<Text text="Sibling" />
<span className={className}>
<Text text={text} />
</span>
</Suspense>
</div>
);
}

let finalHTML = ReactDOMServer.renderToString(
<Parent text="A" className="A" />,
);
let container = document.createElement('div');
container.innerHTML = finalHTML;
expect(Scheduler).toHaveYielded(['Parent [3]', 'Sibling [3]', 'A [3]']);

let span = container.getElementsByTagName('span')[0];

let root = ReactDOM.unstable_createRoot(container, {hydrate: true});

await act(async () => {
// The first render hydrates the shell (everything outside the Suspense
// boundary) at normal priority.
root.render(<Parent text="A" className="A" />);
expect(Scheduler).toFlushUntilNextPaint(['Parent [3]']);
expect(span.textContent).toBe('A');
expect(span.className).toBe('A');

// The second render hydrates the child (inside the Suspense boundary)
// at idle priority. Let's partially render and stop before it finishes.
expect(Scheduler).toFlushAndYieldThrough(['Sibling [5]']);

// Before the idle hydration finishes, interrupt with an update. This will
// start over at normal priority.
root.render(<Parent text="B" className="B" />);
expect(Scheduler).toFlushUntilNextPaint([
'Parent [3]',

// The Suspense boundary hasn't hydrated yet, but we need to send it new
// props. We need to finish hydrating first. So we'll interrupt the
// current render, finish hydrating, then start the update again. The
// following two entries occur at slightly higher priority. (Parent
// doesn't appear as an entry because it already hydrated.)
'Sibling [3]',
'A [3]',
]);
// Hydrating has finished
expect(span.textContent).toBe('A');
expect(span.className).toBe('A');
expect(span).toBe(container.getElementsByTagName('span')[0]);

// Now that the boundary is hydrated, we can perform the update.
expect(Scheduler).toFlushAndYield(['Parent [3]', 'Sibling [3]', 'B [3]']);
expect(span.textContent).toBe('B');
expect(span.className).toBe('B');
expect(span).toBe(container.getElementsByTagName('span')[0]);
});
});

it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
let suspend = false;
let resolve;
Expand Down
10 changes: 9 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -2046,8 +2046,16 @@ function updateDehydratedSuspenseComponent(
// at even higher pri.
let attemptHydrationAtExpirationTime = renderExpirationTime + 1;
suspenseState.retryTime = attemptHydrationAtExpirationTime;
// TODO: This happens to abort the current render and switch to a
// hydration pass, because the priority of the new task is slightly
// higher priority, which causes the work loop to cancel the current
// Scheduler task via `Scheduler.cancelCallback`. But we should probably
// model this entirely within React instead of relying on Scheduler's
// semantics for canceling in-progress tasks. I've chosen this approach
// for now since it's a fairly non-invasive change and it conceptually
// matches how other types of interuptions (e.g. due to input events)
// already work.
scheduleWork(current, attemptHydrationAtExpirationTime);
// TODO: Early abort this render.
} else {
// We have already tried to ping at a higher priority than we're rendering with
// so if we got here, we must have failed to hydrate at those levels. We must
Expand Down
3 changes: 3 additions & 0 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,9 @@ function scheduleCallbackForRoot(
// New callback has higher priority than the existing one.
const existingCallbackNode = root.callbackNode;
if (existingCallbackNode !== null) {
// If this happens during render, this task represents the currently
// running task. Canceling has the effect of interrupting the render and
// starting over at the higher priority.
cancelCallback(existingCallbackNode);
}
root.callbackExpirationTime = expirationTime;
Expand Down
28 changes: 19 additions & 9 deletions packages/scheduler/src/Scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ var currentPriorityLevel = NormalPriority;
// This is set while performing work, to prevent re-entrancy.
var isPerformingWork = false;

var didCancelCurrentTask = false;

var isHostCallbackScheduled = false;
var isHostTimeoutScheduled = false;

Expand Down Expand Up @@ -184,16 +186,20 @@ function workLoop(hasTimeRemaining, initialTime) {
markTaskRun(currentTask, currentTime);
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
if (didCancelCurrentTask) {
didCancelCurrentTask = false;
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
}
advanceTimers(currentTime);
Expand Down Expand Up @@ -389,6 +395,9 @@ function unstable_cancelCallback(task) {
// remove from the queue because you can't remove arbitrary nodes from an
// array based heap, only the first one.)
task.callback = null;
if (task === currentTask) {
didCancelCurrentTask = true;
}
}

function unstable_getCurrentPriorityLevel() {
Expand All @@ -406,6 +415,7 @@ function unstable_shouldYield() {
firstTask.callback !== null &&
firstTask.startTime <= currentTime &&
firstTask.expirationTime < currentTask.expirationTime) ||
didCancelCurrentTask ||
shouldYieldToHost()
);
}
Expand Down
20 changes: 20 additions & 0 deletions packages/scheduler/src/__tests__/Scheduler-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,26 @@ describe('Scheduler', () => {
expect(Scheduler).toFlushWithoutYielding();
});

it('cancelling the currently running task', () => {
const task = scheduleCallback(NormalPriority, () => {
Copy link
Contributor

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?

Copy link
Collaborator Author

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.

Scheduler.unstable_yieldValue('Start');
for (let i = 0; !shouldYield(); i++) {
if (i === 5) {
// Canceling the current task will cause `shouldYield` to return
// `true`. Otherwise this would infinite loop.
Scheduler.unstable_yieldValue('Cancel');
cancelCallback(task);
}
}
Scheduler.unstable_yieldValue('Finish');
// The continuation should be ignored, since the task was
// already canceled.
return () => Scheduler.unstable_yieldValue('Continuation');
});

expect(Scheduler).toFlushAndYield(['Start', 'Cancel', 'Finish']);
});

it('top-level immediate callbacks fire in a subsequent task', () => {
scheduleCallback(ImmediatePriority, () =>
Scheduler.unstable_yieldValue('A'),
Expand Down
10 changes: 10 additions & 0 deletions scripts/jest/matchers/schedulerTestMatchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ function toFlushExpired(Scheduler, expectedYields) {
});
}

function toFlushUntilNextPaint(Scheduler, expectedYields) {
assertYieldsWereCleared(Scheduler);
Scheduler.unstable_flushUntilNextPaint();
const actualYields = Scheduler.unstable_clearYields();
return captureAssertion(() => {
expect(actualYields).toEqual(expectedYields);
});
}

function toHaveYielded(Scheduler, expectedYields) {
return captureAssertion(() => {
const actualYields = Scheduler.unstable_clearYields();
Expand All @@ -78,6 +87,7 @@ module.exports = {
toFlushAndYieldThrough,
toFlushWithoutYielding,
toFlushExpired,
toFlushUntilNextPaint,
toHaveYielded,
toFlushAndThrow,
};