@samitouri / QOS-React-2 / commits / c18662405c

[Fiber] Correctly handle replaying when hydrating (#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: #35210

Josh Story committed Jan 13, 2026 at 12:48 UTC c18662405cc436646411647f8a8965c1c0594c3c
3 files changed +213 -1
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+166
@@ -4063,4 +4063,170 @@ describe('ReactDOMServerPartialHydration', () => {
4063 expect(span.style.display).toBe('');
4064 expect(ref.current).toBe(span);
4065 });
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 + });
4232 });
packages/react-reconciler/src/ReactFiberHydrationContext.js
+39
@@ -173,6 +173,7 @@ function enterHydrationState(fiber: Fiber): boolean {
173 didSuspendOrErrorDEV = false;
174 hydrationDiffRootDEV = null;
175 rootOrSingletonContext = true;
176 +
177 return true;
178 }
179
@@ -834,6 +835,43 @@ function resetHydrationState(): void {
835 didSuspendOrErrorDEV = false;
836 }
837
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 +
875 export function upgradeHydrationErrorsToRecoverable(): Array<
876 CapturedValue<mixed>,
877 > | null {
@@ -905,6 +943,7 @@ export {
943 reenterHydrationStateFromDehydratedActivityInstance,
944 reenterHydrationStateFromDehydratedSuspenseInstance,
945 resetHydrationState,
946 + popHydrationStateOnInterruptedWork,
947 claimHydratableSingleton,
948 tryToClaimNextHydratableInstance,
949 tryToClaimNextHydratableTextInstance,
packages/react-reconciler/src/ReactFiberWorkLoop.js
+8 -1
@@ -120,7 +120,10 @@ import {
120
121 import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
122 import {isRootDehydrated} from './ReactFiberShellHydration';
123 -import {getIsHydrating} from './ReactFiberHydrationContext';
123 +import {
124 + getIsHydrating,
125 + popHydrationStateOnInterruptedWork,
126 +} from './ReactFiberHydrationContext';
127 import {
128 NoMode,
129 ProfileMode,
@@ -3109,6 +3112,10 @@ function replayBeginWork(unitOfWork: Fiber): null | Fiber {
3112 // promises that a host component might suspend on are definitely cached
3113 // because they are controlled by us. So don't bother.
3114 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);
3119 // Fallthrough to the next branch.
3120 }
3121 default: {