@wordpress/data 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,3 @@
1
- /**
2
- * External dependencies
3
- */
4
- import { useMemoOne } from 'use-memo-one';
5
-
6
1
  /**
7
2
  * WordPress dependencies
8
3
  */
@@ -11,11 +6,10 @@ import {
11
6
  useRef,
12
7
  useCallback,
13
8
  useMemo,
14
- useReducer,
9
+ useSyncExternalStore,
15
10
  useDebugValue,
16
11
  } from '@wordpress/element';
17
12
  import isShallowEqual from '@wordpress/is-shallow-equal';
18
- import { useIsomorphicLayoutEffect } from '@wordpress/compose';
19
13
 
20
14
  /**
21
15
  * Internal dependencies
@@ -23,7 +17,6 @@ import { useIsomorphicLayoutEffect } from '@wordpress/compose';
23
17
  import useRegistry from '../registry-provider/use-registry';
24
18
  import useAsyncMode from '../async-mode-provider/use-async-mode';
25
19
 
26
- const noop = () => {};
27
20
  const renderQueue = createQueue();
28
21
 
29
22
  /**
@@ -40,6 +33,128 @@ const renderQueue = createQueue();
40
33
  */
41
34
  /** @typedef {import('../../types').MapSelect} MapSelect */
42
35
 
36
+ function Store( registry, suspense ) {
37
+ const select = suspense ? registry.suspendSelect : registry.select;
38
+ const queueContext = {};
39
+ let lastMapSelect;
40
+ let lastMapResult;
41
+ let lastMapResultValid = false;
42
+ let lastIsAsync;
43
+ let subscribe;
44
+
45
+ const createSubscriber = ( stores ) => ( listener ) => {
46
+ // Invalidate the value right after subscription was created. React will
47
+ // call `getValue` after subscribing, to detect store updates that happened
48
+ // in the interval between the `getValue` call during render and creating
49
+ // the subscription, which is slightly delayed. We need to ensure that this
50
+ // second `getValue` call will compute a fresh value.
51
+ lastMapResultValid = false;
52
+
53
+ const onStoreChange = () => {
54
+ // Invalidate the value on store update, so that a fresh value is computed.
55
+ lastMapResultValid = false;
56
+ listener();
57
+ };
58
+
59
+ const onChange = () => {
60
+ if ( lastIsAsync ) {
61
+ renderQueue.add( queueContext, onStoreChange );
62
+ } else {
63
+ onStoreChange();
64
+ }
65
+ };
66
+
67
+ const unsubs = stores.map( ( storeName ) => {
68
+ return registry.subscribe( onChange, storeName );
69
+ } );
70
+
71
+ return () => {
72
+ // The return value of the subscribe function could be undefined if the store is a custom generic store.
73
+ for ( const unsub of unsubs ) {
74
+ unsub?.();
75
+ }
76
+ // Cancel existing store updates that were already scheduled.
77
+ renderQueue.cancel( queueContext );
78
+ };
79
+ };
80
+
81
+ return ( mapSelect, resubscribe, isAsync ) => {
82
+ const selectValue = () => mapSelect( select, registry );
83
+
84
+ function updateValue( selectFromStore ) {
85
+ // If the last value is valid, and the `mapSelect` callback hasn't changed,
86
+ // then we can safely return the cached value. The value can change only on
87
+ // store update, and in that case value will be invalidated by the listener.
88
+ if ( lastMapResultValid && mapSelect === lastMapSelect ) {
89
+ return lastMapResult;
90
+ }
91
+
92
+ const mapResult = selectFromStore();
93
+
94
+ // If the new value is shallow-equal to the old one, keep the old one so
95
+ // that we don't trigger unwanted updates that do a `===` check.
96
+ if ( ! isShallowEqual( lastMapResult, mapResult ) ) {
97
+ lastMapResult = mapResult;
98
+ }
99
+ lastMapResultValid = true;
100
+ }
101
+
102
+ function getValue() {
103
+ // Update the value in case it's been invalidated or `mapSelect` has changed.
104
+ updateValue( selectValue );
105
+ return lastMapResult;
106
+ }
107
+
108
+ // When transitioning from async to sync mode, cancel existing store updates
109
+ // that have been scheduled, and invalidate the value so that it's freshly
110
+ // computed. It might have been changed by the update we just cancelled.
111
+ if ( lastIsAsync && ! isAsync ) {
112
+ lastMapResultValid = false;
113
+ renderQueue.cancel( queueContext );
114
+ }
115
+
116
+ // Either initialize the `subscribe` function, or create a new one if `mapSelect`
117
+ // changed and has dependencies.
118
+ // Usage without dependencies, `useSelect( ( s ) => { ... } )`, will subscribe
119
+ // only once, at mount, and won't resubscibe even if `mapSelect` changes.
120
+ if ( ! subscribe || ( resubscribe && mapSelect !== lastMapSelect ) ) {
121
+ // Find out what stores the `mapSelect` callback is selecting from and
122
+ // use that list to create subscriptions to specific stores.
123
+ const listeningStores = { current: null };
124
+ updateValue( () =>
125
+ registry.__unstableMarkListeningStores(
126
+ selectValue,
127
+ listeningStores
128
+ )
129
+ );
130
+ subscribe = createSubscriber( listeningStores.current );
131
+ } else {
132
+ updateValue( selectValue );
133
+ }
134
+
135
+ lastIsAsync = isAsync;
136
+ lastMapSelect = mapSelect;
137
+
138
+ // Return a pair of functions that can be passed to `useSyncExternalStore`.
139
+ return { subscribe, getValue };
140
+ };
141
+ }
142
+
143
+ function useStaticSelect( storeName ) {
144
+ return useRegistry().select( storeName );
145
+ }
146
+
147
+ function useMappingSelect( suspense, mapSelect, deps ) {
148
+ const registry = useRegistry();
149
+ const isAsync = useAsyncMode();
150
+ const store = useMemo( () => Store( registry, suspense ), [ registry ] );
151
+ const selector = useCallback( mapSelect, deps );
152
+ const { subscribe, getValue } = store( selector, !! deps, isAsync );
153
+ const result = useSyncExternalStore( subscribe, getValue, getValue );
154
+ useDebugValue( result );
155
+ return result;
156
+ }
157
+
43
158
  /**
44
159
  * Custom react hook for retrieving props from registered selectors.
45
160
  *
@@ -105,162 +220,26 @@ const renderQueue = createQueue();
105
220
  * @return {UseSelectReturn<T>} A custom react hook.
106
221
  */
107
222
  export default function useSelect( mapSelect, deps ) {
108
- const hasMappingFunction = 'function' === typeof mapSelect;
109
-
110
- // If we're recalling a store by its name or by
111
- // its descriptor then we won't be caching the
112
- // calls to `mapSelect` because we won't be calling it.
113
- if ( ! hasMappingFunction ) {
114
- deps = [];
115
- }
116
-
117
- // Because of the "rule of hooks" we have to call `useCallback`
118
- // on every invocation whether or not we have a real function
119
- // for `mapSelect`. we'll create this intermediate variable to
120
- // fulfill that need and then reference it with our "real"
121
- // `_mapSelect` if we can.
122
- const callbackMapper = useCallback(
123
- hasMappingFunction ? mapSelect : noop,
124
- deps
125
- );
126
- const _mapSelect = hasMappingFunction ? callbackMapper : null;
127
-
128
- const registry = useRegistry();
129
- const isAsync = useAsyncMode();
130
-
131
- const latestRegistry = useRef( registry );
132
- const latestMapSelect = useRef();
133
- const latestIsAsync = useRef( isAsync );
134
- const latestMapOutput = useRef();
135
- const latestMapOutputError = useRef();
136
-
137
- // Keep track of the stores being selected in the _mapSelect function,
138
- // and only subscribe to those stores later.
139
- const listeningStores = useRef( [] );
140
- const wrapSelect = useCallback(
141
- ( callback ) =>
142
- registry.__unstableMarkListeningStores(
143
- () => callback( registry.select, registry ),
144
- listeningStores
145
- ),
146
- [ registry ]
147
- );
148
-
149
- // Generate a "flag" for used in the effect dependency array.
150
- // It's different than just using `mapSelect` since deps could be undefined,
151
- // in that case, we would still want to memoize it.
152
- const depsChangedFlag = useMemo( () => ( {} ), deps || [] );
153
-
154
- let mapOutput;
155
-
156
- let selectorRan = false;
157
- if ( _mapSelect ) {
158
- mapOutput = latestMapOutput.current;
159
- const hasReplacedRegistry = latestRegistry.current !== registry;
160
- const hasReplacedMapSelect = latestMapSelect.current !== _mapSelect;
161
- const hasLeftAsyncMode = latestIsAsync.current && ! isAsync;
162
- const lastMapSelectFailed = !! latestMapOutputError.current;
163
-
164
- if (
165
- hasReplacedRegistry ||
166
- hasReplacedMapSelect ||
167
- hasLeftAsyncMode ||
168
- lastMapSelectFailed
169
- ) {
170
- try {
171
- mapOutput = wrapSelect( _mapSelect );
172
- selectorRan = true;
173
- } catch ( error ) {
174
- let errorMessage = `An error occurred while running 'mapSelect': ${ error.message }`;
175
-
176
- if ( latestMapOutputError.current ) {
177
- errorMessage += `\nThe error may be correlated with this previous error:\n`;
178
- errorMessage += `${ latestMapOutputError.current.stack }\n\n`;
179
- errorMessage += 'Original stack trace:';
180
- }
181
-
182
- // eslint-disable-next-line no-console
183
- console.error( errorMessage );
184
- }
185
- }
186
- }
187
-
188
- useIsomorphicLayoutEffect( () => {
189
- if ( ! hasMappingFunction ) {
190
- return;
191
- }
192
-
193
- latestRegistry.current = registry;
194
- latestMapSelect.current = _mapSelect;
195
- latestIsAsync.current = isAsync;
196
- if ( selectorRan ) {
197
- latestMapOutput.current = mapOutput;
198
- }
199
- latestMapOutputError.current = undefined;
200
- } );
201
-
202
- // React can sometimes clear the `useMemo` cache.
203
- // We use the cache-stable `useMemoOne` to avoid
204
- // losing queues.
205
- const queueContext = useMemoOne( () => ( { queue: true } ), [ registry ] );
206
- const [ , forceRender ] = useReducer( ( s ) => s + 1, 0 );
207
- const isMounted = useRef( false );
208
-
209
- useIsomorphicLayoutEffect( () => {
210
- if ( ! hasMappingFunction ) {
211
- return;
212
- }
213
-
214
- const onStoreChange = () => {
215
- try {
216
- const newMapOutput = wrapSelect( latestMapSelect.current );
217
-
218
- if ( isShallowEqual( latestMapOutput.current, newMapOutput ) ) {
219
- return;
220
- }
221
- latestMapOutput.current = newMapOutput;
222
- } catch ( error ) {
223
- latestMapOutputError.current = error;
224
- }
225
- forceRender();
226
- };
227
-
228
- const onChange = () => {
229
- if ( ! isMounted.current ) {
230
- return;
231
- }
232
-
233
- if ( latestIsAsync.current ) {
234
- renderQueue.add( queueContext, onStoreChange );
235
- } else {
236
- onStoreChange();
237
- }
238
- };
239
-
240
- // Catch any possible state changes during mount before the subscription
241
- // could be set.
242
- onStoreChange();
243
-
244
- const unsubscribers = listeningStores.current.map( ( storeName ) =>
245
- registry.subscribe( onChange, storeName )
223
+ // On initial call, on mount, determine the mode of this `useSelect` call
224
+ // and then never allow it to change on subsequent updates.
225
+ const staticSelectMode = typeof mapSelect !== 'function';
226
+ const staticSelectModeRef = useRef( staticSelectMode );
227
+
228
+ if ( staticSelectMode !== staticSelectModeRef.current ) {
229
+ const prevMode = staticSelectModeRef.current ? 'static' : 'mapping';
230
+ const nextMode = staticSelectMode ? 'static' : 'mapping';
231
+ throw new Error(
232
+ `Switching useSelect from ${ prevMode } to ${ nextMode } is not allowed`
246
233
  );
234
+ }
247
235
 
248
- isMounted.current = true;
249
-
250
- return () => {
251
- // The return value of the subscribe function could be undefined if the store is a custom generic store.
252
- unsubscribers.forEach( ( unsubscribe ) => unsubscribe?.() );
253
- renderQueue.cancel( queueContext );
254
- isMounted.current = false;
255
- };
256
- // If you're tempted to eliminate the spread dependencies below don't do it!
257
- // We're passing these in from the calling function and want to make sure we're
258
- // examining every individual value inside the `deps` array.
259
- }, [ registry, wrapSelect, hasMappingFunction, depsChangedFlag ] );
260
-
261
- useDebugValue( mapOutput );
262
-
263
- return hasMappingFunction ? mapOutput : registry.select( mapSelect );
236
+ /* eslint-disable react-hooks/rules-of-hooks */
237
+ // `staticSelectMode` is not allowed to change during the hook instance's,
238
+ // lifetime, so the rules of hooks are not really violated.
239
+ return staticSelectMode
240
+ ? useStaticSelect( mapSelect )
241
+ : useMappingSelect( false, mapSelect, deps );
242
+ /* eslint-enable react-hooks/rules-of-hooks */
264
243
  }
265
244
 
266
245
  /**
@@ -279,117 +258,5 @@ export default function useSelect( mapSelect, deps ) {
279
258
  * @return {Object} Data object returned by the `mapSelect` function.
280
259
  */
281
260
  export function useSuspenseSelect( mapSelect, deps ) {
282
- const _mapSelect = useCallback( mapSelect, deps );
283
-
284
- const registry = useRegistry();
285
- const isAsync = useAsyncMode();
286
-
287
- const latestRegistry = useRef( registry );
288
- const latestMapSelect = useRef();
289
- const latestIsAsync = useRef( isAsync );
290
- const latestMapOutput = useRef();
291
- const latestMapOutputError = useRef();
292
-
293
- // Keep track of the stores being selected in the `mapSelect` function,
294
- // and only subscribe to those stores later.
295
- const listeningStores = useRef( [] );
296
- const wrapSelect = useCallback(
297
- ( callback ) =>
298
- registry.__unstableMarkListeningStores(
299
- () => callback( registry.suspendSelect, registry ),
300
- listeningStores
301
- ),
302
- [ registry ]
303
- );
304
-
305
- // Generate a "flag" for used in the effect dependency array.
306
- // It's different than just using `mapSelect` since deps could be undefined,
307
- // in that case, we would still want to memoize it.
308
- const depsChangedFlag = useMemo( () => ( {} ), deps || [] );
309
-
310
- let mapOutput = latestMapOutput.current;
311
- let mapOutputError = latestMapOutputError.current;
312
-
313
- const hasReplacedRegistry = latestRegistry.current !== registry;
314
- const hasReplacedMapSelect = latestMapSelect.current !== _mapSelect;
315
- const hasLeftAsyncMode = latestIsAsync.current && ! isAsync;
316
-
317
- let selectorRan = false;
318
- if ( hasReplacedRegistry || hasReplacedMapSelect || hasLeftAsyncMode ) {
319
- try {
320
- mapOutput = wrapSelect( _mapSelect );
321
- selectorRan = true;
322
- } catch ( error ) {
323
- mapOutputError = error;
324
- }
325
- }
326
-
327
- useIsomorphicLayoutEffect( () => {
328
- latestRegistry.current = registry;
329
- latestMapSelect.current = _mapSelect;
330
- latestIsAsync.current = isAsync;
331
- if ( selectorRan ) {
332
- latestMapOutput.current = mapOutput;
333
- }
334
- latestMapOutputError.current = mapOutputError;
335
- } );
336
-
337
- // React can sometimes clear the `useMemo` cache.
338
- // We use the cache-stable `useMemoOne` to avoid
339
- // losing queues.
340
- const queueContext = useMemoOne( () => ( { queue: true } ), [ registry ] );
341
- const [ , forceRender ] = useReducer( ( s ) => s + 1, 0 );
342
- const isMounted = useRef( false );
343
-
344
- useIsomorphicLayoutEffect( () => {
345
- const onStoreChange = () => {
346
- try {
347
- const newMapOutput = wrapSelect( latestMapSelect.current );
348
-
349
- if ( isShallowEqual( latestMapOutput.current, newMapOutput ) ) {
350
- return;
351
- }
352
- latestMapOutput.current = newMapOutput;
353
- } catch ( error ) {
354
- latestMapOutputError.current = error;
355
- }
356
-
357
- forceRender();
358
- };
359
-
360
- const onChange = () => {
361
- if ( ! isMounted.current ) {
362
- return;
363
- }
364
-
365
- if ( latestIsAsync.current ) {
366
- renderQueue.add( queueContext, onStoreChange );
367
- } else {
368
- onStoreChange();
369
- }
370
- };
371
-
372
- // catch any possible state changes during mount before the subscription
373
- // could be set.
374
- onStoreChange();
375
-
376
- const unsubscribers = listeningStores.current.map( ( storeName ) =>
377
- registry.subscribe( onChange, storeName )
378
- );
379
-
380
- isMounted.current = true;
381
-
382
- return () => {
383
- // The return value of the subscribe function could be undefined if the store is a custom generic store.
384
- unsubscribers.forEach( ( unsubscribe ) => unsubscribe?.() );
385
- renderQueue.cancel( queueContext );
386
- isMounted.current = false;
387
- };
388
- }, [ registry, wrapSelect, depsChangedFlag ] );
389
-
390
- if ( mapOutputError ) {
391
- throw mapOutputError;
392
- }
393
-
394
- return mapOutput;
261
+ return useMappingSelect( true, mapSelect, deps );
395
262
  }
@@ -50,9 +50,9 @@ describe( 'useSelect', () => {
50
50
  </RegistryProvider>
51
51
  );
52
52
 
53
- // 2 times expected
53
+ // 2 selectSpy calls expected
54
54
  // - 1 for initial mount
55
- // - 1 for after mount before subscription set.
55
+ // - 1 for the subscription effect checking if value has changed
56
56
  expect( selectSpy ).toHaveBeenCalledTimes( 2 );
57
57
  expect( TestComponent ).toHaveBeenCalledTimes( 1 );
58
58
 
@@ -118,8 +118,7 @@ describe( 'useSelect', () => {
118
118
  expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'bar' );
119
119
  } );
120
120
 
121
- // TODO: this might be impossible to pull off in React 18 without `useSyncExternalStore`
122
- it.skip( 'avoid calling nested listener after unmounted', async () => {
121
+ it( 'does not rerender a nested component that is to be unmounted', () => {
123
122
  registry.registerStore( 'toggler', {
124
123
  reducer: ( state = false, action ) =>
125
124
  action.type === 'TOGGLE' ? ! state : state,
@@ -134,16 +133,16 @@ describe( 'useSelect', () => {
134
133
  const mapSelect = ( select ) => select( 'toggler' ).get();
135
134
 
136
135
  const mapSelectChild = jest.fn( mapSelect );
137
- function Child() {
136
+ const Child = jest.fn( () => {
138
137
  const show = useSelect( mapSelectChild, [] );
139
138
  return show ? 'yes' : 'no';
140
- }
139
+ } );
141
140
 
142
141
  const mapSelectParent = jest.fn( mapSelect );
143
- function Parent() {
142
+ const Parent = jest.fn( () => {
144
143
  const show = useSelect( mapSelectParent, [] );
145
144
  return show ? <Child /> : 'none';
146
- }
145
+ } );
147
146
 
148
147
  render(
149
148
  <RegistryProvider value={ registry }>
@@ -155,14 +154,10 @@ describe( 'useSelect', () => {
155
154
  expect( screen.getByText( 'none' ) ).toBeInTheDocument();
156
155
  expect( mapSelectParent ).toHaveBeenCalledTimes( 2 );
157
156
  expect( mapSelectChild ).toHaveBeenCalledTimes( 0 );
157
+ expect( Parent ).toHaveBeenCalledTimes( 1 );
158
+ expect( Child ).toHaveBeenCalledTimes( 0 );
158
159
 
159
- // act() does batched updates internally, i.e., any scheduled setStates or effects
160
- // will be executed only after the dispatch finishes. But we want to opt out of
161
- // batched updates here. We want all the setStates to be done synchronously, as the
162
- // store listeners are called. The async/await code is a trick to do it: do the
163
- // dispatch in a different event loop tick, where the batched updates are no longer active.
164
- await act( async () => {
165
- await Promise.resolve();
160
+ act( () => {
166
161
  registry.dispatch( 'toggler' ).toggle();
167
162
  } );
168
163
 
@@ -170,18 +165,21 @@ describe( 'useSelect', () => {
170
165
  expect( screen.getByText( 'yes' ) ).toBeInTheDocument();
171
166
  expect( mapSelectParent ).toHaveBeenCalledTimes( 3 );
172
167
  expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
168
+ expect( Parent ).toHaveBeenCalledTimes( 2 );
169
+ expect( Child ).toHaveBeenCalledTimes( 1 );
173
170
 
174
- await act( async () => {
175
- await Promise.resolve();
171
+ act( () => {
176
172
  registry.dispatch( 'toggler' ).toggle();
177
173
  } );
178
174
 
179
175
  // Check that child was unmounted without any extra state update being performed on it.
180
- // I.e., `mapSelectChild` was never called again, and no "state update on an unmounted
181
- // component" warning was triggered.
176
+ // I.e., `mapSelectChild` was called again, and state update was scheduled, we cannot
177
+ // avoid that, but the state update is never executed and doesn't do a rerender.
182
178
  expect( screen.getByText( 'none' ) ).toBeInTheDocument();
183
179
  expect( mapSelectParent ).toHaveBeenCalledTimes( 4 );
184
- expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
180
+ expect( mapSelectChild ).toHaveBeenCalledTimes( 3 );
181
+ expect( Parent ).toHaveBeenCalledTimes( 3 );
182
+ expect( Child ).toHaveBeenCalledTimes( 1 );
185
183
  } );
186
184
 
187
185
  describe( 'rerenders as expected with various mapSelect return types', () => {
@@ -162,4 +162,76 @@ describe( 'useSuspenseSelect', () => {
162
162
  expect( label ).toHaveTextContent( 'resolution failed' );
163
163
  expect( console ).toHaveErrored();
164
164
  } );
165
+
166
+ it( 'independent resolutions do not cause unrelated rerenders', async () => {
167
+ const store = createReduxStore( 'test', {
168
+ reducer: ( state = {}, action ) => {
169
+ switch ( action.type ) {
170
+ case 'RECEIVE':
171
+ return { ...state, [ action.endpoint ]: action.data };
172
+ default:
173
+ return state;
174
+ }
175
+ },
176
+ selectors: {
177
+ getData: ( state, endpoint ) => state[ endpoint ],
178
+ },
179
+ resolvers: {
180
+ getData:
181
+ ( endpoint ) =>
182
+ async ( { dispatch } ) => {
183
+ const delay = endpoint === 'slow' ? 30 : 10;
184
+ await new Promise( ( r ) =>
185
+ setTimeout( () => r(), delay )
186
+ );
187
+ dispatch( {
188
+ type: 'RECEIVE',
189
+ endpoint,
190
+ data: endpoint,
191
+ } );
192
+ },
193
+ },
194
+ } );
195
+
196
+ const registry = createRegistry();
197
+ registry.register( store );
198
+
199
+ const FastUI = jest.fn( () => {
200
+ const data = useSuspenseSelect(
201
+ ( select ) => select( store ).getData( 'fast' ),
202
+ []
203
+ );
204
+ return <div aria-label="fast loaded">{ data }</div>;
205
+ } );
206
+
207
+ const SlowUI = jest.fn( () => {
208
+ const data = useSuspenseSelect(
209
+ ( select ) => select( store ).getData( 'slow' ),
210
+ []
211
+ );
212
+ return <div aria-label="slow loaded">{ data }</div>;
213
+ } );
214
+
215
+ const App = () => (
216
+ <RegistryProvider value={ registry }>
217
+ <Suspense fallback="fast loading">
218
+ <FastUI />
219
+ </Suspense>
220
+ <Suspense fallback="slow loading">
221
+ <SlowUI />
222
+ </Suspense>
223
+ </RegistryProvider>
224
+ );
225
+
226
+ render( <App /> );
227
+
228
+ const fastLabel = await screen.findByLabelText( 'fast loaded' );
229
+ expect( fastLabel ).toHaveTextContent( 'fast' );
230
+
231
+ const slowLabel = await screen.findByLabelText( 'slow loaded' );
232
+ expect( slowLabel ).toHaveTextContent( 'slow' );
233
+
234
+ expect( FastUI ).toHaveBeenCalledTimes( 2 );
235
+ expect( SlowUI ).toHaveBeenCalledTimes( 2 );
236
+ } );
165
237
  } );