Skip to content

Commit

Permalink
Implement getServerSnapshot in userspace shim
Browse files Browse the repository at this point in the history
If the DOM is not present, we assume that we are running in a server
environment and return the result of `getServerSnapshot`.

This heuristic doesn't work in React Native, so we'll need to provide
a separate native build (using the `.native` extension). I've left this
for a follow-up.

We can't call `getServerSnapshot` on the client, because in versions of
React before 18, there's no built-in mechanism to detect whether we're
hydrating. To avoid a server mismatch warning, users must account for
this themselves and return the correct value inside `getSnapshot`.

Note that none of this is relevant to the built-in API that is being
added in 18. This only affects the userspace shim that is provided
for backwards compatibility with versions 16 and 17.
  • Loading branch information
acdlite committed Sep 20, 2021
1 parent a5bcf88 commit c496f7c
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,58 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
expect(Scheduler).toHaveYielded(['A1']);
expect(container.textContent).toEqual('A1B1');
});

test('basic server hydration', async () => {
const store = createExternalStore('client');

const ref = React.createRef();
function App() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server',
);
useEffect(() => {
Scheduler.unstable_yieldValue('Passive effect: ' + text);
}, [text]);
return (
<div ref={ref}>
<Text text={text} />
</div>
);
}

const container = document.createElement('div');
container.innerHTML = '<div>server</div>';
const serverRenderedDiv = container.getElementsByTagName('div')[0];

if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
act(() => {
ReactDOM.hydrateRoot(container, <App />);
});
expect(Scheduler).toHaveYielded([
// First it hydrates the server rendered HTML
'server',
'Passive effect: server',
// Then in a second paint, it re-renders with the client state
'client',
'Passive effect: client',
]);
} else {
// In the userspace shim, there's no mechanism to detect whether we're
// currently hydrating, so `getServerSnapshot` is not called on the
// client. To avoid this server mismatch warning, user must account for
// this themselves and return the correct value inside `getSnapshot`.
act(() => {
expect(() => ReactDOM.hydrate(<App />, container)).toErrorDev(
'Text content did not match',
);
});
expect(Scheduler).toHaveYielded(['client', 'Passive effect: client']);
}
expect(container.textContent).toEqual('client');
expect(ref.current).toEqual(serverRenderedDiv);
});
});

// The selector implementation uses the lazy ref initialization pattern
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*
* @jest-environment node
*/

'use strict';

let useSyncExternalStore;
let React;
let ReactDOM;
let ReactDOMServer;
let Scheduler;

// This tests the userspace shim of `useSyncExternalStore` in a server-rendering
// (Node) environment
describe('useSyncExternalStore (userspace shim, server rendering)', () => {
beforeEach(() => {
jest.resetModules();

// Remove useSyncExternalStore from the React imports so that we use the
// shim instead. Also removing startTransition, since we use that to detect
// outdated 18 alphas that don't yet include useSyncExternalStore.
//
// Longer term, we'll probably test this branch using an actual build of
// React 17.
jest.mock('react', () => {
const {
// eslint-disable-next-line no-unused-vars
startTransition: _,
// eslint-disable-next-line no-unused-vars
useSyncExternalStore: __,
// eslint-disable-next-line no-unused-vars
unstable_useSyncExternalStore: ___,
...otherExports
} = jest.requireActual('react');
return otherExports;
});

React = require('react');
ReactDOM = require('react-dom');
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');

useSyncExternalStore = require('use-sync-external-store')
.useSyncExternalStore;
});

function Text({text}) {
Scheduler.unstable_yieldValue(text);
return text;
}

function createExternalStore(initialState) {
const listeners = new Set();
let currentState = initialState;
return {
set(text) {
currentState = text;
ReactDOM.unstable_batchedUpdates(() => {
listeners.forEach(listener => listener());
});
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getState() {
return currentState;
},
getSubscriberCount() {
return listeners.size;
},
};
}

test('basic server render', async () => {
const store = createExternalStore('client');

function App() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server',
);
return <Text text={text} />;
}

const html = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['server']);
expect(html).toEqual('server');
});
});
35 changes: 30 additions & 5 deletions packages/use-sync-external-store/src/useSyncExternalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import * as React from 'react';
import is from 'shared/objectIs';
import invariant from 'shared/invariant';
import {canUseDOM} from 'shared/ExecutionEnvironment';

// Intentionally not using named imports because Rollup uses dynamic
// dispatch for CommonJS interop named imports.
Expand All @@ -21,17 +23,38 @@ const {
unstable_useSyncExternalStore: builtInAPI,
} = React;

// TODO: This heuristic doesn't work in React Native. We'll need to provide a
// special build, using the `.native` extension.
const isServerEnvironment = !canUseDOM;

// Prefer the built-in API, if it exists. If it doesn't exist, then we assume
// we're in version 16 or 17, so rendering is always synchronous. The shim
// does not support concurrent rendering, only the built-in API.
export const useSyncExternalStore =
builtInAPI !== undefined
? ((builtInAPI: any): typeof useSyncExternalStore_shim)
: useSyncExternalStore_shim;
? ((builtInAPI: any): typeof useSyncExternalStore_client)
: isServerEnvironment
? useSyncExternalStore_server
: useSyncExternalStore_client;

let didWarnOld18Alpha = false;
let didWarnUncachedGetSnapshot = false;

function useSyncExternalStore_server<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
if (getServerSnapshot === undefined) {
invariant(
false,
'Missing getServerSnapshot, which is required for server-' +
'rendered content.',
);
}
return getServerSnapshot();
}

// Disclaimer: This shim breaks many of the rules of React, and only works
// because of a very particular set of implementation details and assumptions
// -- change any one of them and it will break. The most important assumption
Expand All @@ -42,10 +65,13 @@ let didWarnUncachedGetSnapshot = false;
//
// Do not assume that the clever hacks used by this hook also work in general.
// The point of this shim is to replace the need for hacks by other libraries.
function useSyncExternalStore_shim<T>(
function useSyncExternalStore_client<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
// TODO: Add a canUseDOM check and use this one on the server
// Note: The client shim does not use getServerSnapshot, because pre-18
// versions of React do not expose a way to check if we're hydrating. So
// users of the shim will need to track that themselves and return the
// correct value from `getSnapshot`.
getServerSnapshot?: () => T,
): T {
if (__DEV__) {
Expand Down Expand Up @@ -97,7 +123,6 @@ function useSyncExternalStore_shim<T>(
// Track the latest getSnapshot function with a ref. This needs to be updated
// in the layout phase so we can access it during the tearing check that
// happens on subscribe.
// TODO: Circumvent SSR warning with canUseDOM check
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
Expand Down
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,5 +395,6 @@
"404": "Invalid hook call. Hooks can only be called inside of the body of a function component.",
"405": "hydrateRoot(...): Target container is not a DOM element.",
"406": "act(...) is not supported in production builds of React.",
"407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."
"407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.",
"408": "Missing getServerSnapshot, which is required for server-rendered content."
}

0 comments on commit c496f7c

Please sign in to comment.