@wordpress/data 6.7.0 → 6.10.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.
@@ -118,6 +118,71 @@ describe( 'useSelect', () => {
118
118
  expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'bar' );
119
119
  } );
120
120
 
121
+ it( 'avoid calling nested listener after unmounted', async () => {
122
+ registry.registerStore( 'toggler', {
123
+ reducer: ( state = false, action ) =>
124
+ action.type === 'TOGGLE' ? ! state : state,
125
+ actions: {
126
+ toggle: () => ( { type: 'TOGGLE' } ),
127
+ },
128
+ selectors: {
129
+ get: ( state ) => state,
130
+ },
131
+ } );
132
+
133
+ const mapSelect = ( select ) => select( 'toggler' ).get();
134
+
135
+ const mapSelectChild = jest.fn( mapSelect );
136
+ function Child() {
137
+ const show = useSelect( mapSelectChild, [] );
138
+ return show ? 'yes' : 'no';
139
+ }
140
+
141
+ const mapSelectParent = jest.fn( mapSelect );
142
+ function Parent() {
143
+ const show = useSelect( mapSelectParent, [] );
144
+ return show ? <Child /> : 'none';
145
+ }
146
+
147
+ const rendered = render(
148
+ <RegistryProvider value={ registry }>
149
+ <Parent />
150
+ </RegistryProvider>
151
+ );
152
+
153
+ // Initial render renders only parent and subscribes the parent to store.
154
+ expect( rendered.getByText( 'none' ) ).toBeInTheDocument();
155
+ expect( mapSelectParent ).toHaveBeenCalledTimes( 2 );
156
+ expect( mapSelectChild ).toHaveBeenCalledTimes( 0 );
157
+
158
+ // act() does batched updates internally, i.e., any scheduled setStates or effects
159
+ // will be executed only after the dispatch finishes. But we want to opt out of
160
+ // batched updates here. We want all the setStates to be done synchronously, as the
161
+ // store listeners are called. The async/await code is a trick to do it: do the
162
+ // dispatch in a different event loop tick, where the batched updates are no longer active.
163
+ await act( async () => {
164
+ await Promise.resolve();
165
+ registry.dispatch( 'toggler' ).toggle();
166
+ } );
167
+
168
+ // Child was rendered and subscribed to the store, as the _second_ subscription.
169
+ expect( rendered.getByText( 'yes' ) ).toBeInTheDocument();
170
+ expect( mapSelectParent ).toHaveBeenCalledTimes( 3 );
171
+ expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
172
+
173
+ await act( async () => {
174
+ await Promise.resolve();
175
+ registry.dispatch( 'toggler' ).toggle();
176
+ } );
177
+
178
+ // Check that child was unmounted without any extra state update being performed on it.
179
+ // I.e., `mapSelectChild` was never called again, and no "state update on an unmounted
180
+ // component" warning was triggered.
181
+ expect( rendered.getByText( 'none' ) ).toBeInTheDocument();
182
+ expect( mapSelectParent ).toHaveBeenCalledTimes( 4 );
183
+ expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
184
+ } );
185
+
121
186
  describe( 'rerenders as expected with various mapSelect return types', () => {
122
187
  const getComponent = ( mapSelectSpy ) => () => {
123
188
  const data = useSelect( mapSelectSpy, [] );
@@ -819,10 +884,10 @@ describe( 'useSelect', () => {
819
884
  // Ensure the async update was flushed during the rerender.
820
885
  expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
821
886
 
822
- // initial render + subscription check + flushed store update
887
+ // initial render + subscription check + rerender with isAsync=false
823
888
  expect( selectSpy ).toHaveBeenCalledTimes( 3 );
824
- // initial render + rerender with isAsync=false + store state update
825
- expect( TestComponent ).toHaveBeenCalledTimes( 3 );
889
+ // initial render + rerender with isAsync=false
890
+ expect( TestComponent ).toHaveBeenCalledTimes( 2 );
826
891
  } );
827
892
 
828
893
  it( 'cancels scheduled updates when mapSelect function changes', async () => {
@@ -960,4 +1025,75 @@ describe( 'useSelect', () => {
960
1025
  expect( TestComponent ).toHaveBeenCalledTimes( 2 );
961
1026
  } );
962
1027
  } );
1028
+
1029
+ describe( 'usage without dependencies array', () => {
1030
+ function registerStore( name, initial ) {
1031
+ registry.registerStore( name, {
1032
+ reducer: ( s = initial, a ) => ( a.type === 'inc' ? s + 1 : s ),
1033
+ actions: { inc: () => ( { type: 'inc' } ) },
1034
+ selectors: { get: ( s ) => s },
1035
+ } );
1036
+ }
1037
+
1038
+ it( 'does not memoize the callback when there are no deps', () => {
1039
+ registerStore( 'store', 1 );
1040
+
1041
+ const Status = ( { multiple } ) => {
1042
+ const count = useSelect(
1043
+ ( select ) => select( 'store' ).get() * multiple
1044
+ );
1045
+ return <div role="status">{ count }</div>;
1046
+ };
1047
+
1048
+ const App = ( { multiple } ) => (
1049
+ <RegistryProvider value={ registry }>
1050
+ <Status multiple={ multiple } />
1051
+ </RegistryProvider>
1052
+ );
1053
+
1054
+ const rendered = render( <App multiple={ 1 } /> );
1055
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
1056
+
1057
+ // Check that the most recent value of `multiple` is used to render:
1058
+ // the old callback wasn't memoized and there is no stale closure problem.
1059
+ rendered.rerender( <App multiple={ 2 } /> );
1060
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 2 );
1061
+ } );
1062
+
1063
+ it( 'subscribes only stores used by the initial callback', () => {
1064
+ registerStore( 'counter-1', 1 );
1065
+ registerStore( 'counter-2', 10 );
1066
+
1067
+ const Status = ( { store } ) => {
1068
+ const count = useSelect( ( select ) => select( store ).get() );
1069
+ return <div role="status">{ count }</div>;
1070
+ };
1071
+
1072
+ const App = ( { store } ) => (
1073
+ <RegistryProvider value={ registry }>
1074
+ <Status store={ store } />
1075
+ </RegistryProvider>
1076
+ );
1077
+
1078
+ // initial render with counter-1
1079
+ const rendered = render( <App store="counter-1" /> );
1080
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
1081
+
1082
+ // update from counter-1
1083
+ act( () => {
1084
+ registry.dispatch( 'counter-1' ).inc();
1085
+ } );
1086
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 2 );
1087
+
1088
+ // rerender with counter-2
1089
+ rendered.rerender( <App store="counter-2" /> );
1090
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 10 );
1091
+
1092
+ // update from counter-2 is ignored because component is subcribed only to counter-1
1093
+ act( () => {
1094
+ registry.dispatch( 'counter-2' ).inc();
1095
+ } );
1096
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 10 );
1097
+ } );
1098
+ } );
963
1099
  } );
@@ -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.