@wordpress/data 6.8.0 → 6.9.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { render, waitFor } from '@testing-library/react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import {
10
+ createRegistry,
11
+ createReduxStore,
12
+ useSuspenseSelect,
13
+ RegistryProvider,
14
+ } from '@wordpress/data';
15
+ import { Component, Suspense } from '@wordpress/element';
16
+
17
+ jest.useRealTimers();
18
+
19
+ function createRegistryWithStore() {
20
+ const initialState = {
21
+ prefix: 'pre-',
22
+ token: null,
23
+ data: null,
24
+ fails: true,
25
+ };
26
+
27
+ const reducer = ( state = initialState, action ) => {
28
+ switch ( action.type ) {
29
+ case 'RECEIVE_TOKEN':
30
+ return { ...state, token: action.token };
31
+ case 'RECEIVE_DATA':
32
+ return { ...state, data: action.data };
33
+ default:
34
+ return state;
35
+ }
36
+ };
37
+
38
+ const selectors = {
39
+ getPrefix: ( state ) => state.prefix,
40
+ getToken: ( state ) => state.token,
41
+ getData: ( state, token ) => {
42
+ if ( ! token ) {
43
+ throw 'missing token in selector';
44
+ }
45
+ return state.data;
46
+ },
47
+ getThatFails: ( state ) => state.fails,
48
+ };
49
+
50
+ const sleep = ( ms ) => new Promise( ( r ) => setTimeout( () => r(), ms ) );
51
+
52
+ const resolvers = {
53
+ getToken: () => async ( { dispatch } ) => {
54
+ await sleep( 10 );
55
+ dispatch( { type: 'RECEIVE_TOKEN', token: 'token' } );
56
+ },
57
+ getData: ( token ) => async ( { dispatch } ) => {
58
+ await sleep( 10 );
59
+ if ( ! token ) {
60
+ throw 'missing token in resolver';
61
+ }
62
+ dispatch( { type: 'RECEIVE_DATA', data: 'therealdata' } );
63
+ },
64
+ getThatFails: () => async () => {
65
+ await sleep( 10 );
66
+ throw 'resolution failed';
67
+ },
68
+ };
69
+
70
+ const store = createReduxStore( 'test', {
71
+ reducer,
72
+ selectors,
73
+ resolvers,
74
+ } );
75
+
76
+ const registry = createRegistry();
77
+ registry.register( store );
78
+
79
+ return { registry, store };
80
+ }
81
+
82
+ describe( 'useSuspenseSelect', () => {
83
+ it( 'renders after suspending a few times', async () => {
84
+ const { registry, store } = createRegistryWithStore();
85
+ let attempts = 0;
86
+ let renders = 0;
87
+
88
+ const UI = () => {
89
+ attempts++;
90
+ const { result } = useSuspenseSelect( ( select ) => {
91
+ const prefix = select( store ).getPrefix();
92
+ const token = select( store ).getToken();
93
+ const data = select( store ).getData( token );
94
+ return { result: prefix + data };
95
+ }, [] );
96
+ renders++;
97
+ return <div aria-label="loaded">{ result }</div>;
98
+ };
99
+
100
+ const App = () => (
101
+ <RegistryProvider value={ registry }>
102
+ <Suspense fallback="loading">
103
+ <UI />
104
+ </Suspense>
105
+ </RegistryProvider>
106
+ );
107
+
108
+ const rendered = render( <App /> );
109
+ await waitFor( () => rendered.getByLabelText( 'loaded' ) );
110
+
111
+ // Verify there were 3 attempts to render. Suspended twice because of
112
+ // `getToken` and `getData` selectors not being resolved, and then finally
113
+ // rendered after all data got loaded.
114
+ expect( attempts ).toBe( 3 );
115
+ expect( renders ).toBe( 1 );
116
+ } );
117
+
118
+ it( 'shows error when resolution fails', async () => {
119
+ const { registry, store } = createRegistryWithStore();
120
+
121
+ const UI = () => {
122
+ const { token } = useSuspenseSelect( ( select ) => {
123
+ // Call a selector whose resolution fails. The `useSuspenseSelect`
124
+ // is then supposed to throw the resolution error.
125
+ return { token: select( store ).getThatFails() };
126
+ }, [] );
127
+ return <div aria-label="loaded">{ token }</div>;
128
+ };
129
+
130
+ class Error extends Component {
131
+ state = { error: null };
132
+
133
+ static getDerivedStateFromError( error ) {
134
+ return { error };
135
+ }
136
+
137
+ render() {
138
+ if ( this.state.error ) {
139
+ return <div aria-label="error">{ this.state.error }</div>;
140
+ }
141
+ return this.props.children;
142
+ }
143
+ }
144
+
145
+ const App = () => (
146
+ <RegistryProvider value={ registry }>
147
+ <Error>
148
+ <Suspense fallback="loading">
149
+ <UI />
150
+ </Suspense>
151
+ </Error>
152
+ </RegistryProvider>
153
+ );
154
+
155
+ const rendered = render( <App /> );
156
+ const label = await waitFor( () => rendered.getByLabelText( 'error' ) );
157
+ expect( label.textContent ).toBe( 'resolution failed' );
158
+ expect( console ).toHaveErrored();
159
+ } );
160
+ } );
package/src/index.js CHANGED
@@ -19,7 +19,10 @@ export {
19
19
  RegistryConsumer,
20
20
  useRegistry,
21
21
  } from './components/registry-provider';
22
- export { default as useSelect } from './components/use-select';
22
+ export {
23
+ default as useSelect,
24
+ useSuspenseSelect,
25
+ } from './components/use-select';
23
26
  export { useDispatch } from './components/use-dispatch';
24
27
  export { AsyncModeProvider } from './components/async-mode-provider';
25
28
  export { createRegistry } from './registry';
@@ -115,6 +118,18 @@ export const select = defaultRegistry.select;
115
118
  */
116
119
  export const resolveSelect = defaultRegistry.resolveSelect;
117
120
 
121
+ /**
122
+ * Given the name of a registered store, returns an object containing the store's
123
+ * selectors pre-bound to state so that you only need to supply additional arguments,
124
+ * and modified so that they throw promises in case the selector is not resolved yet.
125
+ *
126
+ * @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
127
+ * or the store descriptor.
128
+ *
129
+ * @return {Object} Object containing the store's suspense-wrapped selectors.
130
+ */
131
+ export const suspendSelect = defaultRegistry.suspendSelect;
132
+
118
133
  /**
119
134
  * Given the name of a registered store, returns an object of the store's action creators.
120
135
  * Calling an action creator will cause it to be dispatched, updating the state value accordingly.
@@ -162,10 +162,12 @@ export default function createReduxStore( key, options ) {
162
162
  }
163
163
 
164
164
  const resolveSelectors = mapResolveSelectors( selectors, store );
165
+ const suspendSelectors = mapSuspendSelectors( selectors, store );
165
166
 
166
167
  const getSelectors = () => selectors;
167
168
  const getActions = () => actions;
168
169
  const getResolveSelectors = () => resolveSelectors;
170
+ const getSuspendSelectors = () => suspendSelectors;
169
171
 
170
172
  // We have some modules monkey-patching the store object
171
173
  // It's wrong to do so but until we refactor all of our effects to controls
@@ -200,6 +202,7 @@ export default function createReduxStore( key, options ) {
200
202
  resolvers,
201
203
  getSelectors,
202
204
  getResolveSelectors,
205
+ getSuspendSelectors,
203
206
  getActions,
204
207
  subscribe,
205
208
  };
@@ -376,6 +379,46 @@ function mapResolveSelectors( selectors, store ) {
376
379
  } );
377
380
  }
378
381
 
382
+ /**
383
+ * Maps selectors to functions that throw a suspense promise if not yet resolved.
384
+ *
385
+ * @param {Object} selectors Selectors to map.
386
+ * @param {Object} store The redux store the selectors select from.
387
+ *
388
+ * @return {Object} Selectors mapped to their suspense functions.
389
+ */
390
+ function mapSuspendSelectors( selectors, store ) {
391
+ return mapValues( selectors, ( selector, selectorName ) => {
392
+ // Selector without a resolver doesn't have any extra suspense behavior.
393
+ if ( ! selector.hasResolver ) {
394
+ return selector;
395
+ }
396
+
397
+ return ( ...args ) => {
398
+ const result = selector.apply( null, args );
399
+
400
+ if ( selectors.hasFinishedResolution( selectorName, args ) ) {
401
+ if ( selectors.hasResolutionFailed( selectorName, args ) ) {
402
+ throw selectors.getResolutionError( selectorName, args );
403
+ }
404
+
405
+ return result;
406
+ }
407
+
408
+ throw new Promise( ( resolve ) => {
409
+ const unsubscribe = store.subscribe( () => {
410
+ if (
411
+ selectors.hasFinishedResolution( selectorName, args )
412
+ ) {
413
+ resolve();
414
+ unsubscribe();
415
+ }
416
+ } );
417
+ } );
418
+ };
419
+ } );
420
+ }
421
+
379
422
  /**
380
423
  * Returns resolvers with matched selectors for a given namespace.
381
424
  * Resolvers are side effects invoked once per argument set of a given selector call,
package/src/registry.js CHANGED
@@ -93,14 +93,16 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
93
93
  return store.getSelectors();
94
94
  }
95
95
 
96
- return parent && parent.select( storeName );
96
+ return parent?.select( storeName );
97
97
  }
98
98
 
99
99
  function __unstableMarkListeningStores( callback, ref ) {
100
100
  listeningStores.clear();
101
- const result = callback.call( this );
102
- ref.current = Array.from( listeningStores );
103
- return result;
101
+ try {
102
+ return callback.call( this );
103
+ } finally {
104
+ ref.current = Array.from( listeningStores );
105
+ }
104
106
  }
105
107
 
106
108
  /**
@@ -127,6 +129,29 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
127
129
  return parent && parent.resolveSelect( storeName );
128
130
  }
129
131
 
132
+ /**
133
+ * Given the name of a registered store, returns an object containing the store's
134
+ * selectors pre-bound to state so that you only need to supply additional arguments,
135
+ * and modified so that they throw promises in case the selector is not resolved yet.
136
+ *
137
+ * @param {string|StoreDescriptor} storeNameOrDescriptor Unique namespace identifier for the store
138
+ * or the store descriptor.
139
+ *
140
+ * @return {Object} Object containing the store's suspense-wrapped selectors.
141
+ */
142
+ function suspendSelect( storeNameOrDescriptor ) {
143
+ const storeName = isObject( storeNameOrDescriptor )
144
+ ? storeNameOrDescriptor.name
145
+ : storeNameOrDescriptor;
146
+ listeningStores.add( storeName );
147
+ const store = stores[ storeName ];
148
+ if ( store ) {
149
+ return store.getSuspendSelectors();
150
+ }
151
+
152
+ return parent && parent.suspendSelect( storeName );
153
+ }
154
+
130
155
  /**
131
156
  * Returns the available actions for a part of the state.
132
157
  *
@@ -276,6 +301,7 @@ export function createRegistry( storeConfigs = {}, parent = null ) {
276
301
  subscribe,
277
302
  select,
278
303
  resolveSelect,
304
+ suspendSelect,
279
305
  dispatch,
280
306
  use,
281
307
  register,
package/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
5
  "declarationDir": "build-types",
6
- "noUnusedParameters": false,
6
+ "noUnusedParameters": false
7
7
  },
8
8
  "references": [
9
9
  { "path": "../compose" },
@@ -17,6 +17,6 @@
17
17
  "src/redux-store/metadata/**/*",
18
18
  "src/promise-middleware.js",
19
19
  "src/utils",
20
- "src/*.ts"
20
+ "src/*.ts"
21
21
  ]
22
22
  }