Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let Scheduler;
let Suspense;
let SuspenseList;
let useSyncExternalStore;
let use;
let act;
let IdleEventPriority;
let waitForAll;
Expand Down Expand Up @@ -116,6 +117,7 @@ describe('ReactDOMServerPartialHydration', () => {
Activity = React.Activity;
Suspense = React.Suspense;
useSyncExternalStore = React.useSyncExternalStore;
use = React.use;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
Expand Down Expand Up @@ -256,6 +258,77 @@ describe('ReactDOMServerPartialHydration', () => {
expect(container.textContent).toBe('HelloHello');
});

it('replays effects when a suspended boundary hydrates in StrictMode', async () => {
const log = [];
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));

function EffectfulChild() {
React.useLayoutEffect(() => {
log.push('layout mount');
return () => log.push('layout unmount');
}, []);
React.useEffect(() => {
log.push('effect mount');
return () => log.push('effect unmount');
}, []);
return 'Hello';
}

function Child() {
if (suspend) {
use(promise);
}
return <EffectfulChild />;
}

function App() {
return (
<Suspense fallback="Loading...">
<Child />
</Suspense>
);
}

const element = (
<React.StrictMode>
<App />
</React.StrictMode>
);

suspend = false;
const finalHTML = ReactDOMServer.renderToString(element);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('Hello');

suspend = true;
ReactDOMClient.hydrateRoot(container, element);
await waitForAll([]);
expect(log).toEqual([]);
expect(container.textContent).toBe('Hello');

suspend = false;
resolve();
await promise;
await waitForAll([]);

expect(container.textContent).toBe('Hello');
if (__DEV__) {
expect(log).toEqual([
'layout mount',
'effect mount',
'layout unmount',
'effect unmount',
'layout mount',
'effect mount',
]);
} else {
expect(log).toEqual(['layout mount', 'effect mount']);
}
});

it('falls back to client rendering boundary on mismatch', async () => {
let client = false;
let suspend = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,56 @@ describe('ReactDOMServerHydration', () => {
expect(element.textContent).toBe('Hi');
});

it('replays effects when hydrating a StrictMode subtree', async () => {
const log = [];
function Child() {
React.useLayoutEffect(() => {
log.push('layout mount');
return () => log.push('layout unmount');
}, []);
React.useEffect(() => {
log.push('effect mount');
return () => log.push('effect unmount');
}, []);
return <span>Hello</span>;
}

function App() {
return (
<div>
<Child />
</div>
);
}

const markup = (
<React.StrictMode>
<App />
</React.StrictMode>
);

const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(markup);
expect(element.textContent).toBe('Hello');

await act(() => {
ReactDOMClient.hydrateRoot(element, markup);
});

if (__DEV__) {
expect(log).toEqual([
'layout mount',
'effect mount',
'layout unmount',
'effect unmount',
'layout mount',
'effect mount',
]);
} else {
expect(log).toEqual(['layout mount', 'effect mount']);
}
});

it('should be able to render and hydrate forwardRef components', async () => {
const FunctionComponent = ({label, forwardedRef}) => (
<div ref={forwardedRef}>{label}</div>
Expand Down
10 changes: 7 additions & 3 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
NoFlags,
PerformedWork,
Placement,
PlacementDEV,
Hydrating,
Callback,
ContentReset,
Expand Down Expand Up @@ -1080,7 +1081,8 @@ function updateDehydratedActivityComponent(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
primaryChildFragment.flags |= Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}
} else {
Expand Down Expand Up @@ -1896,7 +1898,8 @@ function updateHostRoot(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
node.flags = (node.flags & ~Placement) | Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
node.flags = (node.flags & ~Placement) | Hydrating | PlacementDEV;
node = node.sibling;
}
}
Expand Down Expand Up @@ -3101,7 +3104,8 @@ function updateDehydratedSuspenseComponent(
// Conceptually this is similar to Placement in that a new subtree is
// inserted into the React tree here. It just happens to not need DOM
// mutations because it already exists.
primaryChildFragment.flags |= Hydrating;
// We should still treat it as a newly inserted Fiber to double invoke Strict Effects.
primaryChildFragment.flags |= Hydrating | PlacementDEV;
return primaryChildFragment;
}
} else {
Expand Down
6 changes: 4 additions & 2 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -5255,9 +5255,11 @@ function doubleInvokeEffectsInDEVIfNecessary(
if (fiber.memoizedState === null) {
// Only consider Offscreen that is visible.
// TODO (Offscreen) Handle manual mode.
if (isInStrictMode && fiber.flags & Visibility) {
// Double invoke effects on Offscreen's subtree only
if (isInStrictMode && fiber.flags & (Visibility | PlacementDEV)) {
// Double invoke effects on Offscreen's subtree
// if it is visible and its visibility has changed.
// However, we also need to consider newly hydrated Offscreen because their
// visibility flags might not have changed.
runWithFiberInDEV(fiber, doubleInvokeEffectsOnFiber, root, fiber);
} else if (fiber.subtreeFlags & PlacementDEV) {
// Something in the subtree could have been suspended.
Expand Down
123 changes: 123 additions & 0 deletions packages/react-reconciler/src/__tests__/ActivityStrictMode-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,127 @@ describe('Activity StrictMode', () => {
'Child mount',
]);
});

// @gate __DEV__
it('should double invoke effects on newly inserted children while Activity becomes visible', async () => {
function Parent({children}) {
log.push('Parent rendered');
React.useEffect(() => {
log.push('Parent mount');
return () => {
log.push('Parent unmount');
};
});

return <div>{children}</div>;
}

function Child({name}) {
log.push(`Child ${name} rendered`);
React.useEffect(() => {
log.push(`Child ${name} mount`);
return () => {
log.push(`Child ${name} unmount`);
};
});

return null;
}

await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="hidden">
<Parent />
</Activity>
</React.StrictMode>,
);
});

expect(log).toEqual(['Parent rendered', 'Parent rendered']);

log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
</Parent>
</Activity>
</React.StrictMode>,
);
});

expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
'Child one mount',
'Parent mount',
// StrictMode double invocation
'Parent unmount',
'Child one unmount',
'Child one mount',
'Parent mount',
]);

log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
</Parent>
</Activity>
</React.StrictMode>,
);
});

expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
// single Effect invocation. No double invocation on update.
'Child one unmount',
'Parent unmount',
'Child one mount',
'Parent mount',
]);

log.length = 0;
await act(() => {
ReactNoop.render(
<React.StrictMode>
<Activity mode="visible">
<Parent>
<Child name="one" />
<Child name="two" />
</Parent>
</Activity>
</React.StrictMode>,
);
});

expect(log).toEqual([
'Parent rendered',
'Parent rendered',
'Child one rendered',
'Child one rendered',
'Child two rendered',
'Child two rendered',
// single Effect invocation for existing Components.
'Child one unmount',
'Parent unmount',
'Child one mount',
'Child two mount',
'Parent mount',
// Double Effect invocation for new Component "two"
'Child two unmount',
'Child two mount',
]);
});
});
Loading