[Fast Refresh] Find and remount wrapper edits behind lazy() (#36965)
Two bugs: - Type changes to a lazy's inner component (e.g., adding a `memo` comparison function) were missed by the refresh scan. - If a SimpleMemoComponent needs to be remounted, previously resolveRemountTypeForHotReloading would return the inner function and we would incorrectly recreate the fiber as a FunctionComponent. Instead, we can just use elementType consistently now that the beginWork functions consistently call resolveFamily on the wrapped components.
Sophie Alpert committed
Jul 8, 2026 at 09:59 UTC
5123b063a7b3cd5c42ea2be2ccebe4e68d8b12e3
3 files changed
+215
-31
packages/react-reconciler/src/ReactFiberBeginWork.js
+4
-8
@@ -132,10 +132,7 @@ import {
132
REACT_CONTEXT_TYPE,
133
} from 'shared/ReactSymbols';
134
import {setCurrentFiber} from './ReactCurrentFiber';
135
-import {
136
- resolveTypeForHotReloading,
137
- resolveRemountTypeForHotReloading,
138
-} from './ReactFiberHotReloading';
135
+import {resolveTypeForHotReloading} from './ReactFiberHotReloading';
136
137
import {
138
mountChildFibers,
@@ -4196,10 +4193,9 @@ function beginWork(
4193
if (workInProgress._debugNeedsRemount && current !== null) {
4194
// This will restart the begin phase with a new fiber.
4195
const copiedFiber = createFiberFromTypeAndProps(
4199
- resolveRemountTypeForHotReloading(
4200
- workInProgress.elementType,
4201
- workInProgress.type,
4202
- ),
4196
+ // Remount from the fiber's outermost identity; mounting resolves
4197
+ // any inner types to their latest implementations.
4198
+ resolveTypeForHotReloading(workInProgress.elementType),
4199
workInProgress.key,
4200
workInProgress.pendingProps,
4201
workInProgress._debugOwner || null,
packages/react-reconciler/src/ReactFiberHotReloading.js
+11
-23
@@ -78,29 +78,6 @@ export function resolveTypeForHotReloading(type: any): any {
78
}
79
}
80
81
-export function resolveRemountTypeForHotReloading(
82
- elementType: any,
83
- type: any,
84
-): any {
85
- if (__DEV__) {
86
- if (resolveFamily === null) {
87
- // Hot reloading is disabled.
88
- return type;
89
- }
90
- // The elementType is the fiber's public identity, so its family tracks
91
- // the latest implementation even when an edit changed the kind of the
92
- // type (e.g. memo to a plain function) and `type` still points at the
93
- // old inner implementation.
94
- const family = resolveFamily(elementType);
95
- if (family === undefined) {
96
- return type;
97
- }
98
- return family.current;
99
- } else {
100
- return type;
101
- }
102
-}
103
-
81
export function isCompatibleFamilyForHotReloading(
82
fiber: Fiber,
83
element: ReactElement,
@@ -294,6 +271,17 @@ function scheduleFibersWithFamiliesRecursively(
271
const outerFamily = resolve(outerCandidateType);
272
if (outerFamily !== undefined && staleFamilies.has(outerFamily)) {
273
needsRemount = true;
274
+ } else if (
275
+ typeof outerCandidateType === 'object' &&
276
+ outerCandidateType.$$typeof === REACT_LAZY_TYPE
277
+ ) {
278
+ const payload = outerCandidateType._payload;
279
+ if (payload._status === 1 /* Resolved; see ReactLazy */) {
280
+ const middleFamily = resolve(payload._result.default);
281
+ if (middleFamily !== undefined && staleFamilies.has(middleFamily)) {
282
+ needsRemount = true;
283
+ }
284
+ }
285
}
286
}
287
if (failedBoundaries !== null) {
packages/react-refresh/src/__tests__/ReactFresh-test.js
+200
@@ -1169,6 +1169,206 @@ describe('ReactFresh', () => {
1169
}
1170
});
1171
1172
+ it('can remount lazy(memo()) when adding a comparison function', async () => {
1173
+ if (__DEV__) {
1174
+ let resolve;
1175
+ await render(() => {
1176
+ function Hello() {
1177
+ return <p>hi memo</p>;
1178
+ }
1179
+ const Inner = React.memo(Hello);
1180
+ $RefreshReg$(Hello, 'Hello');
1181
+ $RefreshReg$(Inner, 'Inner');
1182
+
1183
+ const Outer = React.lazy(
1184
+ () =>
1185
+ new Promise(_resolve => {
1186
+ resolve = () => _resolve({default: Inner});
1187
+ }),
1188
+ );
1189
+ $RefreshReg$(Outer, 'Outer');
1190
+
1191
+ function App() {
1192
+ return (
1193
+ <React.Suspense fallback={<p>Loading</p>}>
1194
+ <Outer />
1195
+ </React.Suspense>
1196
+ );
1197
+ }
1198
+ $RefreshReg$(App, 'App');
1199
+ return App;
1200
+ });
1201
+
1202
+ expect(container.textContent).toBe('Loading');
1203
+ await act(() => {
1204
+ resolve();
1205
+ });
1206
+ expect(container.textContent).toBe('hi memo');
1207
+ const el = container.firstChild;
1208
+
1209
+ // Perform a hot update that adds a comparison function. The module
1210
+ // creating the lazy also re-runs, like when an edit propagates.
1211
+ await patch(() => {
1212
+ function Hello() {
1213
+ return <p>hi memo with compare</p>;
1214
+ }
1215
+ const Inner = React.memo(Hello, (prevProps, nextProps) => false);
1216
+ $RefreshReg$(Hello, 'Hello');
1217
+ $RefreshReg$(Inner, 'Inner');
1218
+
1219
+ const Outer = React.lazy(
1220
+ () =>
1221
+ new Promise(_resolve => {
1222
+ resolve = () => _resolve({default: Inner});
1223
+ }),
1224
+ );
1225
+ $RefreshReg$(Outer, 'Outer');
1226
+
1227
+ function App() {
1228
+ return (
1229
+ <React.Suspense fallback={<p>Loading</p>}>
1230
+ <Outer />
1231
+ </React.Suspense>
1232
+ );
1233
+ }
1234
+ $RefreshReg$(App, 'App');
1235
+ return App;
1236
+ });
1237
+
1238
+ // The shape change requires a remount. It goes through the latest
1239
+ // lazy type, which suspends until it resolves. The boundary shows
1240
+ // the fallback while the previous content stays hidden in the DOM.
1241
+ expect(container.textContent).toBe('hi memoLoading');
1242
+ await act(() => {
1243
+ resolve();
1244
+ });
1245
+ expect(container.textContent).toBe('hi memo with compare');
1246
+ expect(container.firstChild).not.toBe(el);
1247
+ }
1248
+ });
1249
+
1250
+ it('can remount lazy(memo()) when adding a comparison function without re-creating the lazy', async () => {
1251
+ if (__DEV__) {
1252
+ let resolve;
1253
+ await render(() => {
1254
+ function Hello() {
1255
+ return <p>hi memo</p>;
1256
+ }
1257
+ const Inner = React.memo(Hello);
1258
+ $RefreshReg$(Hello, 'Hello');
1259
+ $RefreshReg$(Inner, 'Inner');
1260
+
1261
+ const Outer = React.lazy(
1262
+ () =>
1263
+ new Promise(_resolve => {
1264
+ resolve = () => _resolve({default: Inner});
1265
+ }),
1266
+ );
1267
+ $RefreshReg$(Outer, 'Outer');
1268
+
1269
+ function App() {
1270
+ return (
1271
+ <React.Suspense fallback={<p>Loading</p>}>
1272
+ <Outer />
1273
+ </React.Suspense>
1274
+ );
1275
+ }
1276
+ $RefreshReg$(App, 'App');
1277
+ return App;
1278
+ });
1279
+
1280
+ expect(container.textContent).toBe('Loading');
1281
+ await act(() => {
1282
+ resolve();
1283
+ });
1284
+ expect(container.textContent).toBe('hi memo');
1285
+ const el = container.firstChild;
1286
+
1287
+ // Only the lazily loaded module re-runs this time, like when an
1288
+ // edit is contained to it (the lazy type is not re-created, so the
1289
+ // remount cannot go through it).
1290
+ await patch(() => {
1291
+ function Hello() {
1292
+ return <p>hi memo with compare</p>;
1293
+ }
1294
+ const Inner = React.memo(Hello, (prevProps, nextProps) => false);
1295
+ $RefreshReg$(Hello, 'Hello');
1296
+ $RefreshReg$(Inner, 'Inner');
1297
+ return Inner;
1298
+ });
1299
+
1300
+ // The remount goes through the old lazy, whose payload is already
1301
+ // resolved, so it doesn't suspend.
1302
+ expect(container.textContent).toBe('hi memo with compare');
1303
+ expect(container.firstChild).not.toBe(el);
1304
+ }
1305
+ });
1306
+
1307
+ it('can remount an unregistered memo wrapper without losing the wrapper', async () => {
1308
+ if (__DEV__) {
1309
+ let innerRenders = 0;
1310
+ await act(async () => {
1311
+ await render(() => {
1312
+ function Inner({label}) {
1313
+ innerRenders++;
1314
+ return <p>{label}</p>;
1315
+ }
1316
+ $RefreshReg$(Inner, 'Inner');
1317
+ $RefreshSig$(Inner, 'sig1');
1318
+ // The wrapper is deliberately not registered, like a wrapper
1319
+ // created inside a third-party HOC.
1320
+ const InnerMemo = React.memo(Inner);
1321
+
1322
+ function App() {
1323
+ const [, forceUpdate] = React.useState(0);
1324
+ return (
1325
+ <div onClick={() => forceUpdate(n => n + 1)}>
1326
+ <InnerMemo label="hi" />
1327
+ </div>
1328
+ );
1329
+ }
1330
+ $RefreshReg$(App, 'App');
1331
+ return App;
1332
+ });
1333
+ });
1334
+
1335
+ expect(container.textContent).toBe('hi');
1336
+ expect(innerRenders).toBe(1);
1337
+
1338
+ // The memo blocks re-renders with equal props.
1339
+ await act(async () => {
1340
+ container.firstChild.click();
1341
+ });
1342
+ expect(innerRenders).toBe(1);
1343
+
1344
+ // Force a remount by changing the inner function's signature.
1345
+ // Only the inner function's module re-runs; the wrapper and the
1346
+ // element referencing it are not re-created.
1347
+ await act(async () => {
1348
+ await patch(() => {
1349
+ function Inner({label}) {
1350
+ innerRenders++;
1351
+ return <p>{label}</p>;
1352
+ }
1353
+ $RefreshReg$(Inner, 'Inner');
1354
+ $RefreshSig$(Inner, 'sig2');
1355
+ return Inner;
1356
+ });
1357
+ });
1358
+ expect(innerRenders).toBe(2);
1359
+ const innerEl = container.firstChild.firstChild;
1360
+
1361
+ // The remounted fiber must still be a memo: equal props stay
1362
+ // blocked, and the fiber reconciles against the original element
1363
+ // instead of being replaced again.
1364
+ await act(async () => {
1365
+ container.firstChild.click();
1366
+ });
1367
+ expect(innerRenders).toBe(2);
1368
+ expect(container.firstChild.firstChild).toBe(innerEl);
1369
+ }
1370
+ });
1371
+
1372
it('resets state when switching between different component types', async () => {
1373
if (__DEV__) {
1374
await act(async () => {