Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 67e4759

Browse files
authored
[Fiber] Double invoke Effects in Strict Mode during Hydration (#35961)
1 parent 23fcd7c commit 67e4759

5 files changed

Lines changed: 257 additions & 5 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let Scheduler;
2020
letSuspense;
2121
letSuspenseList;
2222
letuseSyncExternalStore;
23+
letuse;
2324
letact;
2425
letIdleEventPriority;
2526
letwaitForAll;
@@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
116117
Activity=React.Activity;
117118
Suspense=React.Suspense;
118119
useSyncExternalStore=React.useSyncExternalStore;
120+
use=React.use;
119121
if(gate(flags=>flags.enableSuspenseList)){
120122
SuspenseList=React.unstable_SuspenseList;
121123
}
@@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
256258
expect(container.textContent).toBe('HelloHello');
257259
});
258260

261+
it('replays effects when a suspended boundary hydrates in StrictMode',async()=>{
262+
constlog=[];
263+
letsuspend=false;
264+
letresolve;
265+
constpromise=newPromise(resolvePromise=>(resolve=resolvePromise));
266+
267+
functionEffectfulChild(){
268+
React.useLayoutEffect(()=>{
269+
log.push('layout mount');
270+
return()=>log.push('layout unmount');
271+
},[]);
272+
React.useEffect(()=>{
273+
log.push('effect mount');
274+
return()=>log.push('effect unmount');
275+
},[]);
276+
return'Hello';
277+
}
278+
279+
functionChild(){
280+
if(suspend){
281+
use(promise);
282+
}
283+
return<EffectfulChild/>;
284+
}
285+
286+
functionApp(){
287+
return(
288+
<Suspensefallback="Loading...">
289+
<Child/>
290+
</Suspense>
291+
);
292+
}
293+
294+
constelement=(
295+
<React.StrictMode>
296+
<App/>
297+
</React.StrictMode>
298+
);
299+
300+
suspend=false;
301+
constfinalHTML=ReactDOMServer.renderToString(element);
302+
constcontainer=document.createElement('div');
303+
container.innerHTML=finalHTML;
304+
expect(container.textContent).toBe('Hello');
305+
306+
suspend=true;
307+
ReactDOMClient.hydrateRoot(container,element);
308+
awaitwaitForAll([]);
309+
expect(log).toEqual([]);
310+
expect(container.textContent).toBe('Hello');
311+
312+
suspend=false;
313+
resolve();
314+
awaitpromise;
315+
awaitwaitForAll([]);
316+
317+
expect(container.textContent).toBe('Hello');
318+
if(__DEV__){
319+
expect(log).toEqual([
320+
'layout mount',
321+
'effect mount',
322+
'layout unmount',
323+
'effect unmount',
324+
'layout mount',
325+
'effect mount',
326+
]);
327+
}else{
328+
expect(log).toEqual(['layout mount','effect mount']);
329+
}
330+
});
331+
259332
it('falls back to client rendering boundary on mismatch',async()=>{
260333
letclient=false;
261334
letsuspend=false;

‎packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
392392
expect(element.textContent).toBe('Hi');
393393
});
394394

395+
it('replays effects when hydrating a StrictMode subtree',async()=>{
396+
constlog=[];
397+
functionChild(){
398+
React.useLayoutEffect(()=>{
399+
log.push('layout mount');
400+
return()=>log.push('layout unmount');
401+
},[]);
402+
React.useEffect(()=>{
403+
log.push('effect mount');
404+
return()=>log.push('effect unmount');
405+
},[]);
406+
return<span>Hello</span>;
407+
}
408+
409+
functionApp(){
410+
return(
411+
<div>
412+
<Child/>
413+
</div>
414+
);
415+
}
416+
417+
constmarkup=(
418+
<React.StrictMode>
419+
<App/>
420+
</React.StrictMode>
421+
);
422+
423+
constelement=document.createElement('div');
424+
element.innerHTML=ReactDOMServer.renderToString(markup);
425+
expect(element.textContent).toBe('Hello');
426+
427+
awaitact(()=>{
428+
ReactDOMClient.hydrateRoot(element,markup);
429+
});
430+
431+
if(__DEV__){
432+
expect(log).toEqual([
433+
'layout mount',
434+
'effect mount',
435+
'layout unmount',
436+
'effect unmount',
437+
'layout mount',
438+
'effect mount',
439+
]);
440+
}else{
441+
expect(log).toEqual(['layout mount','effect mount']);
442+
}
443+
});
444+
395445
it('should be able to render and hydrate forwardRef components',async()=>{
396446
constFunctionComponent=({label, forwardedRef})=>(
397447
<divref={forwardedRef}>{label}</div>

‎packages/react-reconciler/src/ReactFiberBeginWork.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
NoFlags,
8989
PerformedWork,
9090
Placement,
91+
PlacementDEV,
9192
Hydrating,
9293
Callback,
9394
ContentReset,
@@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
10801081
// Conceptually this is similar to Placement in that a new subtree is
10811082
// inserted into the React tree here. It just happens to not need DOM
10821083
// mutations because it already exists.
1083-
primaryChildFragment.flags|=Hydrating;
1084+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1085+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
10841086
returnprimaryChildFragment;
10851087
}
10861088
}else{
@@ -1899,7 +1901,8 @@ function updateHostRoot(
18991901
// Conceptually this is similar to Placement in that a new subtree is
19001902
// inserted into the React tree here. It just happens to not need DOM
19011903
// mutations because it already exists.
1902-
node.flags=(node.flags&~Placement)|Hydrating;
1904+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
1905+
node.flags=(node.flags&~Placement)|Hydrating|PlacementDEV;
19031906
node=node.sibling;
19041907
}
19051908
}
@@ -3104,7 +3107,8 @@ function updateDehydratedSuspenseComponent(
31043107
// Conceptually this is similar to Placement in that a new subtree is
31053108
// inserted into the React tree here. It just happens to not need DOM
31063109
// mutations because it already exists.
3107-
primaryChildFragment.flags|=Hydrating;
3110+
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
3111+
primaryChildFragment.flags|=Hydrating|PlacementDEV;
31083112
returnprimaryChildFragment;
31093113
}
31103114
}else{

‎packages/react-reconciler/src/ReactFiberWorkLoop.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,9 +5312,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
53125312
if(fiber.memoizedState===null){
53135313
// Only consider Offscreen that is visible.
53145314
// TODO (Offscreen) Handle manual mode.
5315-
if(isInStrictMode&&fiber.flags&Visibility){
5316-
// Double invoke effects on Offscreen's subtree only
5315+
if(isInStrictMode&&fiber.flags&(Visibility|PlacementDEV)){
5316+
// Double invoke effects on Offscreen's subtree
53175317
// if it is visible and its visibility has changed.
5318+
// However, we also need to consider newly hydrated Offscreen because their
5319+
// visibility flags might not have changed.
53185320
runWithFiberInDEV(fiber,doubleInvokeEffectsOnFiber,root,fiber);
53195321
}elseif(fiber.subtreeFlags&PlacementDEV){
53205322
// Something in the subtree could have been suspended.

‎packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
230230
'Child mount',
231231
]);
232232
});
233+
234+
// @gate __DEV__
235+
it('should double invoke effects on newly inserted children while Activity becomes visible',async()=>{
236+
functionParent({children}){
237+
log.push('Parent rendered');
238+
React.useEffect(()=>{
239+
log.push('Parent mount');
240+
return()=>{
241+
log.push('Parent unmount');
242+
};
243+
});
244+
245+
return<div>{children}</div>;
246+
}
247+
248+
functionChild({name}){
249+
log.push(`Child ${name} rendered`);
250+
React.useEffect(()=>{
251+
log.push(`Child ${name} mount`);
252+
return()=>{
253+
log.push(`Child ${name} unmount`);
254+
};
255+
});
256+
257+
returnnull;
258+
}
259+
260+
awaitact(()=>{
261+
ReactNoop.render(
262+
<React.StrictMode>
263+
<Activitymode="hidden">
264+
<Parent/>
265+
</Activity>
266+
</React.StrictMode>,
267+
);
268+
});
269+
270+
expect(log).toEqual(['Parent rendered','Parent rendered']);
271+
272+
log.length=0;
273+
awaitact(()=>{
274+
ReactNoop.render(
275+
<React.StrictMode>
276+
<Activitymode="visible">
277+
<Parent>
278+
<Childname="one"/>
279+
</Parent>
280+
</Activity>
281+
</React.StrictMode>,
282+
);
283+
});
284+
285+
expect(log).toEqual([
286+
'Parent rendered',
287+
'Parent rendered',
288+
'Child one rendered',
289+
'Child one rendered',
290+
'Child one mount',
291+
'Parent mount',
292+
// StrictMode double invocation
293+
'Parent unmount',
294+
'Child one unmount',
295+
'Child one mount',
296+
'Parent mount',
297+
]);
298+
299+
log.length=0;
300+
awaitact(()=>{
301+
ReactNoop.render(
302+
<React.StrictMode>
303+
<Activitymode="visible">
304+
<Parent>
305+
<Childname="one"/>
306+
</Parent>
307+
</Activity>
308+
</React.StrictMode>,
309+
);
310+
});
311+
312+
expect(log).toEqual([
313+
'Parent rendered',
314+
'Parent rendered',
315+
'Child one rendered',
316+
'Child one rendered',
317+
// single Effect invocation. No double invocation on update.
318+
'Child one unmount',
319+
'Parent unmount',
320+
'Child one mount',
321+
'Parent mount',
322+
]);
323+
324+
log.length=0;
325+
awaitact(()=>{
326+
ReactNoop.render(
327+
<React.StrictMode>
328+
<Activitymode="visible">
329+
<Parent>
330+
<Childname="one"/>
331+
<Childname="two"/>
332+
</Parent>
333+
</Activity>
334+
</React.StrictMode>,
335+
);
336+
});
337+
338+
expect(log).toEqual([
339+
'Parent rendered',
340+
'Parent rendered',
341+
'Child one rendered',
342+
'Child one rendered',
343+
'Child two rendered',
344+
'Child two rendered',
345+
// single Effect invocation for existing Components.
346+
'Child one unmount',
347+
'Parent unmount',
348+
'Child one mount',
349+
'Child two mount',
350+
'Parent mount',
351+
// Double Effect invocation for new Component "two"
352+
'Child two unmount',
353+
'Child two mount',
354+
]);
355+
});
233356
});

0 commit comments

Comments
 (0)