| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @emails react-core |
| 8 | * |
| 9 | * @jest-environment node |
| 10 | */ |
| 11 | |
| 12 | 'use strict'; |
| 13 | |
| 14 | let React; |
| 15 | let ReactNoop; |
| 16 | let Scheduler; |
| 17 | let useSyncExternalStore; |
| 18 | let useSyncExternalStoreWithSelector; |
| 19 | let act; |
| 20 | let assertLog; |
| 21 | |
| 22 | // This tests the userspace shim of `useSyncExternalStore` in a server-rendering |
| 23 | // (Node) environment |
| 24 | describe('useSyncExternalStore (userspace shim, server rendering)', () => { |
| 25 | beforeEach(() => { |
| 26 | jest.resetModules(); |
| 27 | |
| 28 | // Remove useSyncExternalStore from the React imports so that we use the |
| 29 | // shim instead. Also removing startTransition, since we use that to detect |
| 30 | // outdated 18 alphas that don't yet include useSyncExternalStore. |
| 31 | // |
| 32 | // Longer term, we'll probably test this branch using an actual build of |
| 33 | // React 17. |
| 34 | jest.mock('react', () => { |
| 35 | const { |
| 36 | startTransition: _, |
| 37 | useSyncExternalStore: __, |
| 38 | ...otherExports |
| 39 | } = jest.requireActual('react'); |
| 40 | return otherExports; |
| 41 | }); |
| 42 | |
| 43 | jest.mock('use-sync-external-store/shim', () => |
| 44 | jest.requireActual('use-sync-external-store/shim/index.native'), |
| 45 | ); |
| 46 | |
| 47 | React = require('react'); |
| 48 | ReactNoop = require('react-noop-renderer'); |
| 49 | Scheduler = require('scheduler'); |
| 50 | act = require('internal-test-utils').act; |
| 51 | |
| 52 | const InternalTestUtils = require('internal-test-utils'); |
| 53 | assertLog = InternalTestUtils.assertLog; |
| 54 | |
| 55 | if (gate(flags => flags.source)) { |
| 56 | // The `shim/with-selector` module composes the main |
| 57 | // `use-sync-external-store` entrypoint. In the compiled artifacts, this |
| 58 | // is resolved to the `shim` implementation by our build config, but when |
| 59 | // running the tests against the source files, we need to tell Jest how to |
| 60 | // resolve it. Because this is a source module, this mock has no affect on |
| 61 | // the build tests. |
| 62 | jest.mock('use-sync-external-store/src/useSyncExternalStore', () => |
| 63 | jest.requireActual('use-sync-external-store/shim'), |
| 64 | ); |
| 65 | jest.mock('use-sync-external-store/src/isServerEnvironment', () => |
| 66 | jest.requireActual( |
| 67 | 'use-sync-external-store/src/forks/isServerEnvironment.native', |
| 68 | ), |
| 69 | ); |
| 70 | } |
| 71 | useSyncExternalStore = |
| 72 | require('use-sync-external-store/shim').useSyncExternalStore; |
| 73 | useSyncExternalStoreWithSelector = |
| 74 | require('use-sync-external-store/shim/with-selector').useSyncExternalStoreWithSelector; |
| 75 | }); |
| 76 | |
| 77 | function Text({text}) { |
| 78 | Scheduler.log(text); |
| 79 | return text; |
| 80 | } |
| 81 | |
| 82 | function createExternalStore(initialState) { |
| 83 | const listeners = new Set(); |
| 84 | let currentState = initialState; |
| 85 | return { |
| 86 | set(text) { |
| 87 | currentState = text; |
| 88 | ReactNoop.batchedUpdates(() => { |
| 89 | listeners.forEach(listener => listener()); |
| 90 | }); |
| 91 | }, |
| 92 | subscribe(listener) { |
| 93 | listeners.add(listener); |
| 94 | return () => listeners.delete(listener); |
| 95 | }, |
| 96 | getState() { |
| 97 | return currentState; |
| 98 | }, |
| 99 | getSubscriberCount() { |
| 100 | return listeners.size; |
| 101 | }, |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | it('native version', async () => { |
| 106 | const store = createExternalStore('client'); |
| 107 | |
| 108 | function App() { |
| 109 | const text = useSyncExternalStore( |
| 110 | store.subscribe, |
| 111 | store.getState, |
| 112 | () => 'server', |
| 113 | ); |
| 114 | return <Text text={text} />; |
| 115 | } |
| 116 | |
| 117 | const root = ReactNoop.createRoot(); |
| 118 | await act(() => { |
| 119 | root.render(<App />); |
| 120 | }); |
| 121 | assertLog(['client']); |
| 122 | expect(root).toMatchRenderedOutput('client'); |
| 123 | }); |
| 124 | |
| 125 | it('Using isEqual to bailout', async () => { |
| 126 | const store = createExternalStore({a: 0, b: 0}); |
| 127 | |
| 128 | function A() { |
| 129 | const {a} = useSyncExternalStoreWithSelector( |
| 130 | store.subscribe, |
| 131 | store.getState, |
| 132 | null, |
| 133 | state => ({a: state.a}), |
| 134 | (state1, state2) => state1.a === state2.a, |
| 135 | ); |
| 136 | return <Text text={'A' + a} />; |
| 137 | } |
| 138 | function B() { |
| 139 | const {b} = useSyncExternalStoreWithSelector( |
| 140 | store.subscribe, |
| 141 | store.getState, |
| 142 | null, |
| 143 | state => { |
| 144 | return {b: state.b}; |
| 145 | }, |
| 146 | (state1, state2) => state1.b === state2.b, |
| 147 | ); |
| 148 | return <Text text={'B' + b} />; |
| 149 | } |
| 150 | |
| 151 | function App() { |
| 152 | return ( |
| 153 | <> |
| 154 | <A /> |
| 155 | <B /> |
| 156 | </> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | const root = ReactNoop.createRoot(); |
| 161 | await act(() => root.render(<App />)); |
| 162 | |
| 163 | assertLog(['A0', 'B0']); |
| 164 | expect(root).toMatchRenderedOutput('A0B0'); |
| 165 | |
| 166 | // Update b but not a |
| 167 | await act(() => { |
| 168 | store.set({a: 0, b: 1}); |
| 169 | }); |
| 170 | // Only b re-renders |
| 171 | assertLog(['B1']); |
| 172 | expect(root).toMatchRenderedOutput('A0B1'); |
| 173 | |
| 174 | // Update a but not b |
| 175 | await act(() => { |
| 176 | store.set({a: 1, b: 1}); |
| 177 | }); |
| 178 | // Only a re-renders |
| 179 | assertLog(['A1']); |
| 180 | expect(root).toMatchRenderedOutput('A1B1'); |
| 181 | }); |
| 182 | }); |