Skip to content

Commit c186624

Browse files
authored
[Fiber] Correctly handle replaying when hydrating (facebook#35494)
When hydrating if something suspends and then resolves in a microtask it is possible that React will resume the render without fully unwinding work in progress. This can cause hydration cursors to be offset and lead to hydration errors. This change adds a restore step when replaying HostComponent to ensure the hydration cursor is in the appropriate position when replaying. fixes: facebook#35210
1 parent 583e200 commit c186624

3 files changed

Lines changed: 213 additions & 1 deletion

File tree

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4063,4 +4063,170 @@ describe('ReactDOMServerPartialHydration', () => {
40634063
expect(span.style.display).toBe('');
40644064
expect(ref.current).toBe(span);
40654065
});
4066+
4067+
// Regression for https://github.com/facebook/react/issues/35210 and other issues where lazy elements created in flight
4068+
// caused hydration issues b/c the replay pathway did not correctly reset the hydration cursor
4069+
it('Can hydrate even when lazy content resumes immediately inside a HostComponent', async () => {
4070+
let resolve;
4071+
const promise = new Promise(r => {
4072+
resolve = () => r({default: 'value'});
4073+
});
4074+
4075+
const lazyContent = React.lazy(() => {
4076+
Scheduler.log('Lazy initializer called');
4077+
return promise;
4078+
});
4079+
4080+
function App() {
4081+
return <label>{lazyContent}</label>;
4082+
}
4083+
4084+
// Server-rendered HTML
4085+
const container = document.createElement('div');
4086+
container.innerHTML = '<label>value</label>';
4087+
4088+
const hydrationErrors = [];
4089+
4090+
React.startTransition(() => {
4091+
ReactDOMClient.hydrateRoot(container, <App />, {
4092+
onRecoverableError(error) {
4093+
console.log('[DEBUG] hydration error:', error.message);
4094+
hydrationErrors.push(error.message);
4095+
},
4096+
});
4097+
});
4098+
4099+
await waitFor(['Lazy initializer called']);
4100+
resolve();
4101+
await waitForAll([]);
4102+
4103+
// Without the fix, hydration cursor is wrong and causes mismatch
4104+
expect(hydrationErrors).toEqual([]);
4105+
expect(container.innerHTML).toEqual('<label>value</label>');
4106+
});
4107+
4108+
it('Can hydrate even when lazy content resumes immediately inside a HostSingleton', async () => {
4109+
let resolve;
4110+
const promise = new Promise(r => {
4111+
resolve = () => r({default: <div>value</div>});
4112+
});
4113+
4114+
const lazyContent = React.lazy(() => {
4115+
Scheduler.log('Lazy initializer called');
4116+
return promise;
4117+
});
4118+
4119+
function App() {
4120+
return (
4121+
<html>
4122+
<body>{lazyContent}</body>
4123+
</html>
4124+
);
4125+
}
4126+
4127+
// Server-rendered HTML
4128+
document.body.innerHTML = '<div>value</div>';
4129+
4130+
const hydrationErrors = [];
4131+
4132+
React.startTransition(() => {
4133+
ReactDOMClient.hydrateRoot(document, <App />, {
4134+
onRecoverableError(error) {
4135+
console.log('[DEBUG] hydration error:', error.message);
4136+
hydrationErrors.push(error.message);
4137+
},
4138+
});
4139+
});
4140+
4141+
await waitFor(['Lazy initializer called']);
4142+
resolve();
4143+
await waitForAll([]);
4144+
4145+
expect(hydrationErrors).toEqual([]);
4146+
expect(document.documentElement.outerHTML).toEqual(
4147+
'<html><head></head><body><div>value</div></body></html>',
4148+
);
4149+
});
4150+
4151+
it('Can hydrate even when lazy content resumes immediately inside a Suspense', async () => {
4152+
let resolve;
4153+
const promise = new Promise(r => {
4154+
resolve = () => r({default: 'value'});
4155+
});
4156+
4157+
const lazyContent = React.lazy(() => {
4158+
Scheduler.log('Lazy initializer called');
4159+
return promise;
4160+
});
4161+
4162+
function App() {
4163+
return <Suspense>{lazyContent}</Suspense>;
4164+
}
4165+
4166+
// Server-rendered HTML
4167+
const container = document.createElement('div');
4168+
container.innerHTML = '<!--$-->value<!--/$-->';
4169+
4170+
const hydrationErrors = [];
4171+
4172+
let root;
4173+
React.startTransition(() => {
4174+
root = ReactDOMClient.hydrateRoot(container, <App />, {
4175+
onRecoverableError(error) {
4176+
console.log('[DEBUG] hydration error:', error.message);
4177+
hydrationErrors.push(error.message);
4178+
},
4179+
});
4180+
});
4181+
4182+
await waitFor(['Lazy initializer called']);
4183+
resolve();
4184+
await waitForAll([]);
4185+
4186+
expect(hydrationErrors).toEqual([]);
4187+
expect(container.innerHTML).toEqual('<!--$-->value<!--/$-->');
4188+
root.unmount();
4189+
expect(container.innerHTML).toEqual('<!--$--><!--/$-->');
4190+
});
4191+
4192+
it('Can hydrate even when lazy content resumes immediately inside an Activity', async () => {
4193+
let resolve;
4194+
const promise = new Promise(r => {
4195+
resolve = () => r({default: 'value'});
4196+
});
4197+
4198+
const lazyContent = React.lazy(() => {
4199+
Scheduler.log('Lazy initializer called');
4200+
return promise;
4201+
});
4202+
4203+
function App() {
4204+
return <Activity mode="visible">{lazyContent}</Activity>;
4205+
}
4206+
4207+
// Server-rendered HTML
4208+
const container = document.createElement('div');
4209+
container.innerHTML = '<!--&-->value<!--/&-->';
4210+
4211+
const hydrationErrors = [];
4212+
4213+
let root;
4214+
React.startTransition(() => {
4215+
root = ReactDOMClient.hydrateRoot(container, <App />, {
4216+
onRecoverableError(error) {
4217+
console.log('[DEBUG] hydration error:', error.message);
4218+
hydrationErrors.push(error.message);
4219+
},
4220+
});
4221+
});
4222+
4223+
await waitFor(['Lazy initializer called']);
4224+
resolve();
4225+
await waitForAll([]);
4226+
4227+
expect(hydrationErrors).toEqual([]);
4228+
expect(container.innerHTML).toEqual('<!--&-->value<!--/&-->');
4229+
root.unmount();
4230+
expect(container.innerHTML).toEqual('<!--&--><!--/&-->');
4231+
});
40664232
});

packages/react-reconciler/src/ReactFiberHydrationContext.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ function enterHydrationState(fiber: Fiber): boolean {
173173
didSuspendOrErrorDEV = false;
174174
hydrationDiffRootDEV = null;
175175
rootOrSingletonContext = true;
176+
176177
return true;
177178
}
178179

@@ -834,6 +835,43 @@ function resetHydrationState(): void {
834835
didSuspendOrErrorDEV = false;
835836
}
836837

838+
// Restore the hydration cursor when unwinding a HostComponent that already
839+
// claimed a DOM node. This is a fork of popHydrationState that does all the
840+
// same validity checks but restores the cursor to this fiber's DOM node
841+
// instead of advancing past it. It also does NOT clear unhydrated tail nodes
842+
// or throw on mismatches since we're unwinding, not completing.
843+
//
844+
// This is needed when replaySuspendedUnitOfWork calls unwindInterruptedWork
845+
// before re-running beginWork on the same fiber, or when throwAndUnwindWorkLoop
846+
// calls unwindWork on ancestor fibers.
847+
function popHydrationStateOnInterruptedWork(fiber: Fiber): void {
848+
if (!supportsHydration) {
849+
return;
850+
}
851+
if (fiber !== hydrationParentFiber) {
852+
// We're deeper than the current hydration context, inside an inserted
853+
// tree. Don't touch the cursor.
854+
return;
855+
}
856+
if (!isHydrating) {
857+
// If we're not currently hydrating but we're in a hydration context, then
858+
// we were an insertion and now need to pop up to reenter hydration of our
859+
// siblings. Same as popHydrationState.
860+
popToNextHostParent(fiber);
861+
isHydrating = true;
862+
return;
863+
}
864+
865+
// We're in a valid hydration context. Restore the cursor to this fiber's
866+
// DOM node so that when beginWork re-runs, it can claim the same node.
867+
// Unlike popHydrationState, we do NOT check for unhydrated tail nodes
868+
// or advance the cursor - we're restoring, not completing.
869+
popToNextHostParent(fiber);
870+
if (fiber.tag === HostComponent && fiber.stateNode != null) {
871+
nextHydratableInstance = fiber.stateNode;
872+
}
873+
}
874+
837875
export function upgradeHydrationErrorsToRecoverable(): Array<
838876
CapturedValue<mixed>,
839877
> | null {
@@ -905,6 +943,7 @@ export {
905943
reenterHydrationStateFromDehydratedActivityInstance,
906944
reenterHydrationStateFromDehydratedSuspenseInstance,
907945
resetHydrationState,
946+
popHydrationStateOnInterruptedWork,
908947
claimHydratableSingleton,
909948
tryToClaimNextHydratableInstance,
910949
tryToClaimNextHydratableTextInstance,

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ import {
120120

121121
import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
122122
import {isRootDehydrated} from './ReactFiberShellHydration';
123-
import {getIsHydrating} from './ReactFiberHydrationContext';
123+
import {
124+
getIsHydrating,
125+
popHydrationStateOnInterruptedWork,
126+
} from './ReactFiberHydrationContext';
124127
import {
125128
NoMode,
126129
ProfileMode,
@@ -3109,6 +3112,10 @@ function replayBeginWork(unitOfWork: Fiber): null | Fiber {
31093112
// promises that a host component might suspend on are definitely cached
31103113
// because they are controlled by us. So don't bother.
31113114
resetHooksOnUnwind(unitOfWork);
3115+
// We are about to retry this host component and need to ensure the hydration
3116+
// state is appropriate for hydrating this unit. Other fiber types hydrate differently
3117+
// and aren't reliant on the cursor positioning so this function is only for HostComponent
3118+
popHydrationStateOnInterruptedWork(unitOfWork);
31123119
// Fallthrough to the next branch.
31133120
}
31143121
default: {

0 commit comments

Comments
 (0)