Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix uSES hydration in strict mode #26791

Merged
merged 7 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
94 changes: 94 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let JSDOM;
let Stream;
let Scheduler;
let React;
let ReactDOM;
let ReactDOMClient;
let ReactDOMFizzServer;
let Suspense;
Expand Down Expand Up @@ -73,6 +74,7 @@ describe('ReactDOMFizzServer', () => {

Scheduler = require('scheduler');
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Expand Down Expand Up @@ -2507,6 +2509,98 @@ describe('ReactDOMFizzServer', () => {
},
);

it('can hydrate uSES in StrictMode with different client and server snapshot (sync)', async () => {
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
function getServerSnapshot() {
return 'Nay!';
}

function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);

return value;
}

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

await act(async () => {
const {pipe} = renderToPipeableStream(element);
pipe(writable);
});

assertLog(['Nay!']);
expect(getVisibleChildren(container)).toEqual('Nay!');

await clientAct(() => {
ReactDOM.flushSync(() => {
ReactDOMClient.hydrateRoot(container, element);
});
});

expect(getVisibleChildren(container)).toEqual('Yay!');
assertLog(['Nay!', 'Yay!']);
});

it('can hydrate uSES in StrictMode with different client and server snapshot (concurrent)', async () => {
function subscribe() {
return () => {};
}
function getClientSnapshot() {
return 'Yay!';
}
function getServerSnapshot() {
return 'Nay!';
}

function App() {
const value = useSyncExternalStore(
subscribe,
getClientSnapshot,
getServerSnapshot,
);
Scheduler.log(value);

return value;
}

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

await act(async () => {
const {pipe} = renderToPipeableStream(element);
pipe(writable);
});

assertLog(['Nay!']);
expect(getVisibleChildren(container)).toEqual('Nay!');

await clientAct(() => {
React.startTransition(() => {
ReactDOMClient.hydrateRoot(container, element);
});
});

expect(getVisibleChildren(container)).toEqual('Yay!');
assertLog(['Nay!', 'Yay!']);
});

it(
'errors during hydration force a client render at the nearest Suspense ' +
'boundary, and during the client render it recovers',
Expand Down
37 changes: 24 additions & 13 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1776,8 +1776,6 @@ function mountSyncExternalStore<T>(
// clean-up function, and we track the deps correctly, we can call pushEffect
// directly, without storing any additional state. For the same reason, we
// don't need to set a static flag, either.
// TODO: We can move this to the passive phase once we add a pre-commit
// consistency check. See the next comment.
fiber.flags |= PassiveEffect;
pushEffect(
HookHasEffect | HookPassive,
Expand All @@ -1799,15 +1797,28 @@ function updateSyncExternalStore<T>(
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
const nextSnapshot = getSnapshot();
if (__DEV__) {
if (!didWarnUncachedGetSnapshot) {
const cachedSnapshot = getSnapshot();
if (!is(nextSnapshot, cachedSnapshot)) {
console.error(
'The result of getSnapshot should be cached to avoid an infinite loop',
);
didWarnUncachedGetSnapshot = true;
let nextSnapshot;
const isHydrating = getIsHydrating();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was a regression when I made it so that the "rerender" dispatcher is used during the strict mode double render. Originally the idea was to keep the getIsHydrating out of the update path because it's always false during an update.

Right now there is no special "rerender" implementation of useSES, but if we add one we can keep this extra check out of the common path.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am skeptical that the double implementations are good for perf (we should benchmark it sometime) but I can change it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean because of the code size? Execution wise I don't see why it would be slower

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code size and because the function dispatch call is more dynamic so I imagine it can’t inline a jump.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, where do we stop? Should we have a fourth implementation for the rerender-on-initial-mount case instead of combining it with the rerender-on-update one like we do today? Right now updateEffect has a branch for currentHook === null.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I was just thinking the rerender implementation would check currentHook and call the mount or update ones as appropriate. I agree it's not worth duplicating the entire update implementation.

Like I did here for rerenderOptimisticHook:

if (currentHook !== null) {
// This is an update. Process the update queue.
return updateOptimistic(passthrough, reducer);
}

The main reason we added the separate phases was to keep the mount path fast, not so much the update one. But subjectively I also prefer it for code organization reasons because the logic does end up being significantly different in a lot of the hooks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately though my original comment was only a factoring nit. I'd probably structure this differently if I were doing it myself, but I liked how you had it before more than this way, so you could just revert it back to that and if I really want to I can submit a follow up PR later.

The important thing is the fix itself, and the regression test you added.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to change it to what you just described! That way makes sense to me. Wasn’t sure if you approved of the fix originally.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm actually not sure the best way to reuse the mount code on rerender because we need it to call updateWorkInProgressHook including for the interior mountEffect. Gonna land it with the unified update+rerender code like I had before but would be happy to review a refactor if you have one in mind.

if (isHydrating) {
// Needed for strict mode double render
if (getServerSnapshot === undefined) {
throw new Error(
'Missing getServerSnapshot, which is required for ' +
'server-rendered content. Will revert to client rendering.',
);
}
nextSnapshot = getServerSnapshot();
} else {
nextSnapshot = getSnapshot();
if (__DEV__) {
if (!didWarnUncachedGetSnapshot) {
const cachedSnapshot = getSnapshot();
if (!is(nextSnapshot, cachedSnapshot)) {
console.error(
'The result of getSnapshot should be cached to avoid an infinite loop',
);
didWarnUncachedGetSnapshot = true;
}
}
}
}
Expand All @@ -1830,7 +1841,7 @@ function updateSyncExternalStore<T>(
if (
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the susbcribe function changed. We can save some memory by
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
Expand All @@ -1854,7 +1865,7 @@ function updateSyncExternalStore<T>(
);
}

if (!includesBlockingLane(root, renderLanes)) {
if (!isHydrating && !includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what this is so @acdlite is probably best suited for review. Though this
change makes sense to me since we're also not pushing a consistency check when mounting a sync external store during hydration.

}
}
Expand Down