mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
Implement getServerSnapshot in userspace shim (#22359)
* Convert useSES shim tests to use React DOM Idea is that eventually we'll run these tests against an actual build of React DOM 17 to test backwards compatibility. * Implement getServerSnapshot in userspace shim 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.
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
let useSyncExternalStore;
|
||||
let useSyncExternalStoreExtra;
|
||||
let React;
|
||||
let ReactNoop;
|
||||
let ReactDOM;
|
||||
let Scheduler;
|
||||
let act;
|
||||
let useState;
|
||||
@@ -27,11 +27,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
|
||||
// Remove the built-in API from the React exports to force the package to
|
||||
// use the shim.
|
||||
// TODO: Don't do this during a variant test run. That way these tests run
|
||||
// against both the shim and the built-in implementation.
|
||||
if (gate(flags => flags.variant)) {
|
||||
// We'll use the variant flag to represent the native implementation
|
||||
} else {
|
||||
if (!gate(flags => flags.supportsNativeUseSyncExternalStore)) {
|
||||
// and the non-variant tests for the shim.
|
||||
//
|
||||
// Remove useSyncExternalStore from the React imports so that we use the
|
||||
@@ -55,7 +51,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
}
|
||||
|
||||
React = require('react');
|
||||
ReactNoop = require('react-noop-renderer');
|
||||
ReactDOM = require('react-dom');
|
||||
Scheduler = require('scheduler');
|
||||
useState = React.useState;
|
||||
useEffect = React.useEffect;
|
||||
@@ -66,7 +62,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
// The internal act implementation doesn't batch updates by default, since
|
||||
// it's mostly used to test concurrent mode. But since these tests run
|
||||
// in both concurrent and legacy mode, I'm adding batching here.
|
||||
act = cb => internalAct(() => ReactNoop.batchedUpdates(cb));
|
||||
act = cb => internalAct(() => ReactDOM.unstable_batchedUpdates(cb));
|
||||
|
||||
useSyncExternalStore = require('use-sync-external-store')
|
||||
.useSyncExternalStore;
|
||||
@@ -79,19 +75,24 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return text;
|
||||
}
|
||||
|
||||
function createRoot(element) {
|
||||
function createRoot(container) {
|
||||
// This wrapper function exists so we can test both legacy roots and
|
||||
// concurrent roots.
|
||||
if (gate(flags => flags.variant)) {
|
||||
if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
|
||||
// The native implementation only exists in 18+, so we test using
|
||||
// concurrent mode. To test the legacy root behavior in the native
|
||||
// implementation (which is supported in the sense that it needs to have
|
||||
// the correct behavior, despite the fact that the legacy root API
|
||||
// triggers a warning in 18), write a test that uses
|
||||
// createLegacyRoot directly.
|
||||
return ReactNoop.createRoot();
|
||||
return ReactDOM.createRoot(container);
|
||||
} else {
|
||||
return ReactNoop.createLegacyRoot();
|
||||
ReactDOM.render(null, container);
|
||||
return {
|
||||
render(children) {
|
||||
ReactDOM.render(children, container);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return {
|
||||
set(text) {
|
||||
currentState = text;
|
||||
ReactNoop.batchedUpdates(() => {
|
||||
ReactDOM.unstable_batchedUpdates(() => {
|
||||
listeners.forEach(listener => listener());
|
||||
});
|
||||
},
|
||||
@@ -126,17 +127,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={text} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
await act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded(['Initial']);
|
||||
expect(root).toMatchRenderedOutput('Initial');
|
||||
expect(container.textContent).toEqual('Initial');
|
||||
|
||||
await act(() => {
|
||||
store.set('Updated');
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Updated']);
|
||||
expect(root).toMatchRenderedOutput('Updated');
|
||||
expect(container.textContent).toEqual('Updated');
|
||||
});
|
||||
|
||||
test('skips re-rendering if nothing changes', () => {
|
||||
@@ -147,11 +149,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={text} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded(['Initial']);
|
||||
expect(root).toMatchRenderedOutput('Initial');
|
||||
expect(container.textContent).toEqual('Initial');
|
||||
|
||||
// Update to the same value
|
||||
act(() => {
|
||||
@@ -159,7 +162,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Should not re-render
|
||||
expect(Scheduler).toHaveYielded([]);
|
||||
expect(root).toMatchRenderedOutput('Initial');
|
||||
expect(container.textContent).toEqual('Initial');
|
||||
});
|
||||
|
||||
test('switch to a different store', async () => {
|
||||
@@ -174,21 +177,22 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={value} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
await act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded([0]);
|
||||
expect(root).toMatchRenderedOutput('0');
|
||||
expect(container.textContent).toEqual('0');
|
||||
|
||||
await act(() => {
|
||||
storeA.set(1);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([1]);
|
||||
expect(root).toMatchRenderedOutput('1');
|
||||
expect(container.textContent).toEqual('1');
|
||||
|
||||
// Switch stores and update in the same batch
|
||||
act(() => {
|
||||
ReactNoop.flushSync(() => {
|
||||
ReactDOM.flushSync(() => {
|
||||
// This update will be disregarded
|
||||
storeA.set(2);
|
||||
setStore(storeB);
|
||||
@@ -196,7 +200,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Now reading from B instead of A
|
||||
expect(Scheduler).toHaveYielded([0]);
|
||||
expect(root).toMatchRenderedOutput('0');
|
||||
expect(container.textContent).toEqual('0');
|
||||
|
||||
// Update A
|
||||
await act(() => {
|
||||
@@ -204,14 +208,14 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Nothing happened, because we're no longer subscribed to A
|
||||
expect(Scheduler).toHaveYielded([]);
|
||||
expect(root).toMatchRenderedOutput('0');
|
||||
expect(container.textContent).toEqual('0');
|
||||
|
||||
// Update B
|
||||
await act(() => {
|
||||
storeB.set(1);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([1]);
|
||||
expect(root).toMatchRenderedOutput('1');
|
||||
expect(container.textContent).toEqual('1');
|
||||
});
|
||||
|
||||
test('selecting a specific value inside getSnapshot', async () => {
|
||||
@@ -235,11 +239,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B0']);
|
||||
expect(root).toMatchRenderedOutput('A0B0');
|
||||
expect(container.textContent).toEqual('A0B0');
|
||||
|
||||
// Update b but not a
|
||||
await act(() => {
|
||||
@@ -247,7 +252,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Only b re-renders
|
||||
expect(Scheduler).toHaveYielded(['B1']);
|
||||
expect(root).toMatchRenderedOutput('A0B1');
|
||||
expect(container.textContent).toEqual('A0B1');
|
||||
|
||||
// Update a but not b
|
||||
await act(() => {
|
||||
@@ -255,12 +260,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Only a re-renders
|
||||
expect(Scheduler).toHaveYielded(['A1']);
|
||||
expect(root).toMatchRenderedOutput('A1B1');
|
||||
expect(container.textContent).toEqual('A1B1');
|
||||
});
|
||||
|
||||
// In React 18, you can't observe in between a sync render and its
|
||||
// passive effects, so this is only relevant to legacy roots
|
||||
// @gate !variant
|
||||
// @gate !supportsNativeUseSyncExternalStore
|
||||
test(
|
||||
"compares to current state before bailing out, even when there's a " +
|
||||
'mutation in between the sync and passive effects',
|
||||
@@ -275,7 +280,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={value} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']);
|
||||
|
||||
@@ -287,7 +293,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
1,
|
||||
// Passive effect hasn't fired yet
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('1');
|
||||
expect(container.textContent).toEqual('1');
|
||||
|
||||
// Flip the store state back to the previous value.
|
||||
store.set(0);
|
||||
@@ -300,7 +306,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
0,
|
||||
]);
|
||||
// Should flip back to 0
|
||||
expect(root).toMatchRenderedOutput('0');
|
||||
expect(container.textContent).toEqual('0');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -344,10 +350,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
expect(Scheduler).toHaveYielded(['A1']);
|
||||
expect(root).toMatchRenderedOutput('A1');
|
||||
expect(container.textContent).toEqual('A1');
|
||||
|
||||
act(() => {
|
||||
// Change getSnapshot and update the store in the same batch
|
||||
@@ -360,7 +367,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
// incorrectly bailed out here instead of re-rendering.
|
||||
'B2',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('B2');
|
||||
expect(container.textContent).toEqual('B2');
|
||||
});
|
||||
|
||||
test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => {
|
||||
@@ -401,10 +408,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
expect(Scheduler).toHaveYielded(['A1']);
|
||||
expect(root).toMatchRenderedOutput('A1');
|
||||
expect(container.textContent).toEqual('A1');
|
||||
|
||||
// This will cause a layout effect, and in the layout effect we'll update
|
||||
// the store
|
||||
@@ -418,7 +426,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
'Update B in commit phase',
|
||||
// No re-render
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('A1');
|
||||
expect(container.textContent).toEqual('A1');
|
||||
});
|
||||
|
||||
test("does not bail out if the previous update hasn't finished yet", async () => {
|
||||
@@ -440,7 +448,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={value} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<>
|
||||
@@ -450,13 +459,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
),
|
||||
);
|
||||
expect(Scheduler).toHaveYielded([0, 0]);
|
||||
expect(root).toMatchRenderedOutput('00');
|
||||
expect(container.textContent).toEqual('00');
|
||||
|
||||
await act(() => {
|
||||
store.set(1);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]);
|
||||
expect(root).toMatchRenderedOutput('00');
|
||||
expect(container.textContent).toEqual('00');
|
||||
});
|
||||
|
||||
test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => {
|
||||
@@ -473,20 +482,21 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={text} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
expect(Scheduler).toHaveYielded([0]);
|
||||
|
||||
// Update the store and getSnapshot at the same time
|
||||
act(() => {
|
||||
ReactNoop.flushSync(() => {
|
||||
ReactDOM.flushSync(() => {
|
||||
setGetSnapshot(() => getSnapshotB);
|
||||
store.set({a: 1, b: 2});
|
||||
});
|
||||
});
|
||||
// It should read from B instead of A
|
||||
expect(Scheduler).toHaveYielded([2]);
|
||||
expect(root).toMatchRenderedOutput('2');
|
||||
expect(container.textContent).toEqual('2');
|
||||
});
|
||||
|
||||
test('handles errors thrown by getSnapshot', async () => {
|
||||
@@ -521,7 +531,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
}
|
||||
|
||||
const errorBoundary = React.createRef(null);
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() =>
|
||||
root.render(
|
||||
<ErrorBoundary ref={errorBoundary}>
|
||||
@@ -530,13 +541,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
),
|
||||
);
|
||||
expect(Scheduler).toHaveYielded([0]);
|
||||
expect(root).toMatchRenderedOutput('0');
|
||||
expect(container.textContent).toEqual('0');
|
||||
|
||||
// Update that throws in a getSnapshot. We can catch it with an error boundary.
|
||||
await act(() => {
|
||||
store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
|
||||
});
|
||||
if (gate(flags => flags.variant)) {
|
||||
if (gate(flags => flags.supportsNativeUseSyncExternalStore)) {
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Error in getSnapshot',
|
||||
// In a concurrent root, React renders a second time to attempt to
|
||||
@@ -546,7 +557,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
} else {
|
||||
expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
|
||||
}
|
||||
expect(root).toMatchRenderedOutput('Error in getSnapshot');
|
||||
expect(container.textContent).toEqual('Error in getSnapshot');
|
||||
});
|
||||
|
||||
test('Infinite loop if getSnapshot keeps returning new reference', () => {
|
||||
@@ -557,19 +568,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={JSON.stringify(text)} />;
|
||||
}
|
||||
|
||||
spyOnDev(console, 'error');
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
|
||||
expect(() => act(() => root.render(<App />))).toThrow(
|
||||
'Maximum update depth exceeded. This can happen when a component repeatedly ' +
|
||||
'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
|
||||
'the number of nested updates to prevent infinite loops.',
|
||||
);
|
||||
if (__DEV__) {
|
||||
expect(console.error.calls.argsFor(0)[0]).toMatch(
|
||||
'The result of getSnapshot should be cached to avoid an infinite loop',
|
||||
expect(() => {
|
||||
expect(() => act(() => root.render(<App />))).toThrow(
|
||||
'Maximum update depth exceeded. This can happen when a component repeatedly ' +
|
||||
'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
|
||||
'the number of nested updates to prevent infinite loops.',
|
||||
);
|
||||
}
|
||||
}).toErrorDev(
|
||||
'The result of getSnapshot should be cached to avoid an infinite loop',
|
||||
);
|
||||
});
|
||||
|
||||
describe('extra features implemented in user-space', () => {
|
||||
@@ -594,11 +604,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
return <Text text={'A' + a} />;
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']);
|
||||
expect(root).toMatchRenderedOutput('A0');
|
||||
expect(container.textContent).toEqual('A0');
|
||||
|
||||
// Update the store
|
||||
await act(() => {
|
||||
@@ -612,7 +623,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
// the previous result without running the selector again
|
||||
'A1',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput('A1');
|
||||
expect(container.textContent).toEqual('A1');
|
||||
});
|
||||
|
||||
// The selector implementation uses the lazy ref initialization pattern
|
||||
@@ -652,11 +663,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
act(() => root.render(<App />));
|
||||
|
||||
expect(Scheduler).toHaveYielded(['A0', 'B0']);
|
||||
expect(root).toMatchRenderedOutput('A0B0');
|
||||
expect(container.textContent).toEqual('A0B0');
|
||||
|
||||
// Update b but not a
|
||||
await act(() => {
|
||||
@@ -664,7 +676,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Only b re-renders
|
||||
expect(Scheduler).toHaveYielded(['B1']);
|
||||
expect(root).toMatchRenderedOutput('A0B1');
|
||||
expect(container.textContent).toEqual('A0B1');
|
||||
|
||||
// Update a but not b
|
||||
await act(() => {
|
||||
@@ -672,7 +684,59 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
});
|
||||
// Only a re-renders
|
||||
expect(Scheduler).toHaveYielded(['A1']);
|
||||
expect(root).toMatchRenderedOutput('A1B1');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -725,7 +789,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot();
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
await act(() => {
|
||||
root.render(<App step={0} />);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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__) {
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user