@wordpress/data
Version:
Data module for WordPress.
1,100 lines (899 loc) • 30 kB
JavaScript
/**
* External dependencies
*/
import { act, render } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { useState, useReducer } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
createRegistry,
createRegistrySelector,
RegistryProvider,
AsyncModeProvider,
} from '../../..';
import useSelect from '..';
jest.useRealTimers();
describe( 'useSelect', () => {
let registry;
beforeEach( () => {
registry = createRegistry();
} );
it( 'passes the relevant data to the component', () => {
registry.registerStore( 'testStore', {
reducer: () => ( { foo: 'bar' } ),
selectors: {
testSelector: ( state, key ) => state[ key ],
},
} );
const selectSpy = jest.fn();
const TestComponent = jest.fn( ( props ) => {
selectSpy.mockImplementation( ( select ) => ( {
results: select( 'testStore' ).testSelector( props.keyName ),
} ) );
const data = useSelect( selectSpy, [ props.keyName ] );
return <div role="status">{ data.results }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" />
</RegistryProvider>
);
// 2 times expected
// - 1 for initial mount
// - 1 for after mount before subscription set.
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'bar' );
} );
it( 'uses memoized selector if dependencies do not change', () => {
registry.registerStore( 'testStore', {
reducer: () => ( { foo: 'bar' } ),
selectors: {
testSelector: ( state, key ) => state[ key ],
},
} );
const selectSpyFoo = jest.fn( () => 'foo' );
const selectSpyBar = jest.fn( () => 'bar' );
const TestComponent = jest.fn( ( props ) => {
const mapSelect = props.change ? selectSpyFoo : selectSpyBar;
const data = useSelect( mapSelect, [ props.keyName ] );
return <div role="status">{ data }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" change={ true } />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 0 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'foo' );
// Rerender with non dependency changed.
rendered.rerender(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" change={ false } />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 0 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'foo' );
// Rerender with dependency changed.
rendered.rerender(
<RegistryProvider value={ registry }>
<TestComponent keyName="bar" change={ false } />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 3 );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'bar' );
} );
it( 'avoid calling nested listener after unmounted', async () => {
registry.registerStore( 'toggler', {
reducer: ( state = false, action ) =>
action.type === 'TOGGLE' ? ! state : state,
actions: {
toggle: () => ( { type: 'TOGGLE' } ),
},
selectors: {
get: ( state ) => state,
},
} );
const mapSelect = ( select ) => select( 'toggler' ).get();
const mapSelectChild = jest.fn( mapSelect );
function Child() {
const show = useSelect( mapSelectChild, [] );
return show ? 'yes' : 'no';
}
const mapSelectParent = jest.fn( mapSelect );
function Parent() {
const show = useSelect( mapSelectParent, [] );
return show ? <Child /> : 'none';
}
const rendered = render(
<RegistryProvider value={ registry }>
<Parent />
</RegistryProvider>
);
// Initial render renders only parent and subscribes the parent to store.
expect( rendered.getByText( 'none' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 2 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 0 );
// act() does batched updates internally, i.e., any scheduled setStates or effects
// will be executed only after the dispatch finishes. But we want to opt out of
// batched updates here. We want all the setStates to be done synchronously, as the
// store listeners are called. The async/await code is a trick to do it: do the
// dispatch in a different event loop tick, where the batched updates are no longer active.
await act( async () => {
await Promise.resolve();
registry.dispatch( 'toggler' ).toggle();
} );
// Child was rendered and subscribed to the store, as the _second_ subscription.
expect( rendered.getByText( 'yes' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 3 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
await act( async () => {
await Promise.resolve();
registry.dispatch( 'toggler' ).toggle();
} );
// Check that child was unmounted without any extra state update being performed on it.
// I.e., `mapSelectChild` was never called again, and no "state update on an unmounted
// component" warning was triggered.
expect( rendered.getByText( 'none' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 4 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
} );
describe( 'rerenders as expected with various mapSelect return types', () => {
const getComponent = ( mapSelectSpy ) => () => {
const data = useSelect( mapSelectSpy, [] );
return <div role="status" data-d={ JSON.stringify( data ) } />;
};
let TestComponent;
const mapSelectSpy = jest.fn( ( select ) =>
select( 'testStore' ).testSelector()
);
const selectorSpy = jest.fn();
beforeEach( () => {
registry.registerStore( 'testStore', {
actions: {
forceUpdate: () => ( { type: 'FORCE_UPDATE' } ),
},
reducer: ( state = {} ) => ( { ...state } ),
selectors: {
testSelector: selectorSpy,
},
} );
TestComponent = getComponent( mapSelectSpy );
} );
afterEach( () => {
selectorSpy.mockClear();
mapSelectSpy.mockClear();
} );
it.each( [
[ 'boolean', [ false, true ] ],
[ 'number', [ 10, 20 ] ],
[ 'string', [ 'bar', 'cheese' ] ],
[
'array',
[
[ 10, 20 ],
[ 10, 30 ],
],
],
[ 'object', [ { foo: 'bar' }, { foo: 'cheese' } ] ],
[ 'null', [ null, undefined ] ],
[ 'undefined', [ undefined, 42 ] ],
] )(
'renders as expected with %s return values',
( type, testValues ) => {
const [ valueA, valueB ] = testValues;
selectorSpy.mockReturnValue( valueA );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ).dataset.d ).toBe(
JSON.stringify( valueA )
);
// Update the returned value from the selector and trigger the
// subscription which should in turn trigger a re-render.
act( () => {
selectorSpy.mockReturnValue( valueB );
registry.dispatch( 'testStore' ).forceUpdate();
} );
expect( rendered.getByRole( 'status' ).dataset.d ).toBe(
JSON.stringify( valueB )
);
expect( mapSelectSpy ).toHaveBeenCalledTimes( 3 );
}
);
} );
describe( 're-calls the selector as few times as possible', () => {
const counterStore = {
actions: {
increment: () => ( { type: 'INCREMENT' } ),
},
reducer: ( state, action ) => {
if ( ! state ) {
return { counter: 0 };
}
if ( action?.type === 'INCREMENT' ) {
return { counter: state.counter + 1 };
}
return state;
},
selectors: {
getCounter: ( state ) => state.counter,
},
};
it( 'only calls the selectors it has selected', () => {
registry.registerStore( 'store-1', counterStore );
registry.registerStore( 'store-2', counterStore );
const selectCount1 = jest.fn();
const selectCount2 = jest.fn();
const TestComponent = jest.fn( () => {
const count1 = useSelect(
( select ) =>
selectCount1() || select( 'store-1' ).getCounter(),
[]
);
useSelect(
( select ) =>
selectCount2() || select( 'store-2' ).getCounter(),
[]
);
return <div role="status">{ count1 }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
expect( selectCount2 ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
act( () => {
registry.dispatch( 'store-2' ).increment();
} );
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
expect( selectCount2 ).toHaveBeenCalledTimes( 3 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
act( () => {
registry.dispatch( 'store-1' ).increment();
} );
expect( selectCount1 ).toHaveBeenCalledTimes( 3 );
expect( selectCount2 ).toHaveBeenCalledTimes( 3 );
expect( TestComponent ).toHaveBeenCalledTimes( 3 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
// Test if the unsubscribers get called correctly.
rendered.unmount();
} );
it( 'can subscribe to multiple stores at once', () => {
registry.registerStore( 'store-1', counterStore );
registry.registerStore( 'store-2', counterStore );
registry.registerStore( 'store-3', counterStore );
const selectCount1And2 = jest.fn();
const TestComponent = jest.fn( () => {
const { count1, count2 } = useSelect(
( select ) =>
selectCount1And2() || {
count1: select( 'store-1' ).getCounter(),
count2: select( 'store-2' ).getCounter(),
},
[]
);
return (
<div role="status">
{ count1 },{ count2 }
</div>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1And2 ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( '0,0' );
act( () => {
registry.dispatch( 'store-2' ).increment();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 3 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( '0,1' );
act( () => {
registry.dispatch( 'store-3' ).increment();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 3 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( '0,1' );
} );
it( 're-calls the selector when deps changed', () => {
registry.registerStore( 'store-1', counterStore );
registry.registerStore( 'store-2', counterStore );
registry.registerStore( 'store-3', counterStore );
let dep, setDep;
const selectCount1AndDep = jest.fn();
const TestComponent = jest.fn( () => {
[ dep, setDep ] = useState( 0 );
const state = useSelect(
( select ) =>
selectCount1AndDep() || {
count1: select( 'store-1' ).getCounter(),
dep,
},
[ dep ]
);
return (
<div role="status">
count:{ state.count1 },dep:{ state.dep }
</div>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count:0,dep:0'
);
act( () => {
setDep( 1 );
} );
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 4 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count:0,dep:1'
);
act( () => {
registry.dispatch( 'store-1' ).increment();
} );
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 5 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count:1,dep:1'
);
} );
it( 'handles registry selectors', () => {
const getCount1And2 = createRegistrySelector(
( select ) => ( state ) => ( {
count1: state.counter,
count2: select( 'store-2' ).getCounter(),
} )
);
registry.registerStore( 'store-1', {
...counterStore,
selectors: {
...counterStore.selectors,
getCount1And2,
},
} );
registry.registerStore( 'store-2', counterStore );
const selectCount1And2 = jest.fn();
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) =>
selectCount1And2() ||
select( 'store-1' ).getCount1And2(),
[]
);
return (
<div role="status">
count1:{ state.count1 },count2:{ state.count2 }
</div>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1And2 ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count1:0,count2:0'
);
act( () => {
registry.dispatch( 'store-2' ).increment();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 3 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count1:0,count2:1'
);
} );
it( 'handles conditional statements in selectors', () => {
registry.registerStore( 'store-1', counterStore );
registry.registerStore( 'store-2', counterStore );
const selectCount1 = jest.fn();
const selectCount2 = jest.fn();
const TestComponent = jest.fn( () => {
const [ shouldSelectCount1, toggle ] = useReducer(
( should ) => ! should,
false
);
const state = useSelect(
( select ) => {
if ( shouldSelectCount1 ) {
selectCount1();
select( 'store-1' ).getCounter();
return 'count1';
}
selectCount2();
select( 'store-2' ).getCounter();
return 'count2';
},
[ shouldSelectCount1 ]
);
return (
<>
<div role="status">{ state }</div>
<button onClick={ toggle }>Toggle</button>
</>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1 ).toHaveBeenCalledTimes( 0 );
expect( selectCount2 ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count2'
);
rendered.getByText( 'Toggle' ).click();
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
expect( selectCount2 ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count1'
);
} );
it( "handles subscriptions to the parent's stores", () => {
registry.registerStore( 'parent-store', counterStore );
const subRegistry = createRegistry( {}, registry );
subRegistry.registerStore( 'child-store', counterStore );
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) => ( {
parentCount: select( 'parent-store' ).getCounter(),
childCount: select( 'child-store' ).getCounter(),
} ),
[]
);
return (
<div role="status">
parent:{ state.parentCount },child:{ state.childCount }
</div>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<RegistryProvider value={ subRegistry }>
<TestComponent />
</RegistryProvider>
</RegistryProvider>
);
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'parent:0,child:0'
);
act( () => {
registry.dispatch( 'parent-store' ).increment();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'parent:1,child:0'
);
} );
it( 'handles non-existing stores', () => {
registry.registerStore( 'store-1', counterStore );
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) => ( {
count1: select( 'store-1' ).getCounter(),
count2: select( 'store-2' )?.getCounter() ?? 'blank',
} ),
[]
);
return (
<div role="status">
count1:{ state.count1 },count2:{ state.count2 }
</div>
);
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count1:0,count2:blank'
);
act( () => {
registry.dispatch( 'store-1' ).increment();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'count1:1,count2:blank'
);
// Test if the unsubscribers get called correctly.
rendered.unmount();
} );
it( 'handles registration of a non-existing store during rendering', () => {
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) =>
select( 'not-yet-registered-store' )?.getCounter() ??
'blank',
[]
);
return <div role="status">{ state }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'blank'
);
act( () => {
registry.registerStore(
'not-yet-registered-store',
counterStore
);
} );
// This is not ideal, but is the way it's working before and we want to prevent breaking changes.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'blank'
);
act( () => {
registry.dispatch( 'not-yet-registered-store' ).increment();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
// Test if the unsubscribers get called correctly.
rendered.unmount();
} );
it( 'handles registration of a non-existing store of sub-registry during rendering', () => {
const subRegistry = createRegistry( {}, registry );
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) =>
select(
'not-yet-registered-child-store'
)?.getCounter() ?? 'blank',
[]
);
return <div role="status">{ state }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<RegistryProvider value={ subRegistry }>
<TestComponent />
</RegistryProvider>
</RegistryProvider>
);
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'blank'
);
act( () => {
registry.registerStore(
'not-yet-registered-child-store',
counterStore
);
} );
// This is not ideal, but is the way it's working before and we want to prevent breaking changes.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent(
'blank'
);
act( () => {
registry
.dispatch( 'not-yet-registered-child-store' )
.increment();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( '1' );
// Test if the unsubscribers get called correctly.
rendered.unmount();
} );
it( 'handles custom generic stores without a unsubscribe function', () => {
const customStore = {
name: 'generic-store',
instantiate() {
let storeChanged = () => {};
let counter = 0;
const selectors = {
getCounter: () => counter,
};
const actions = {
increment: () => {
counter += 1;
storeChanged();
},
};
return {
getSelectors() {
return selectors;
},
getActions() {
return actions;
},
subscribe( listener ) {
storeChanged = listener;
},
};
},
};
registry.register( customStore );
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) => select( customStore ).getCounter(),
[]
);
return <div role="status">{ state }</div>;
} );
const rendered = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
act( () => {
registry.dispatch( customStore ).increment();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
expect( () => rendered.unmount() ).not.toThrow();
} );
} );
describe( 'async mode', () => {
function registerCounterStore( reg, initialCount = 0 ) {
reg.registerStore( 'counter', {
reducer: ( state = initialCount, action ) => {
if ( action.type === 'INCREMENT' ) {
return state + 1;
}
return state;
},
actions: {
inc: () => ( { type: 'INCREMENT' } ),
},
selectors: {
get: ( state ) => state,
},
} );
}
beforeEach( () => {
registerCounterStore( registry );
} );
it( 'renders with async mode', async () => {
const selectSpy = jest.fn( ( select ) =>
select( 'counter' ).get()
);
const TestComponent = jest.fn( () => {
const count = useSelect( selectSpy, [] );
return <div role="status">{ count }</div>;
} );
const rendered = render(
<AsyncModeProvider value={ true }>
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
// initial render + missed update catcher in subscribing effect
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
act( () => {
registry.dispatch( 'counter' ).inc();
} );
// still not called right after increment
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
expect( await rendered.findByText( 1 ) ).toBeInTheDocument();
expect( selectSpy ).toHaveBeenCalledTimes( 3 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
} );
// Tests render queue fixes done in https://github.com/WordPress/gutenberg/pull/19286
it( 'catches updates while switching from async to sync', () => {
const selectSpy = jest.fn( ( select ) =>
select( 'counter' ).get()
);
const TestComponent = jest.fn( () => {
const count = useSelect( selectSpy, [] );
return <div role="status">{ count }</div>;
} );
const App = ( { async } ) => (
<AsyncModeProvider value={ async }>
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
const rendered = render( <App async={ true } /> );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
// Schedules an async update of the component.
act( () => {
registry.dispatch( 'counter' ).inc();
} );
// Ensure the async update wasn't processed yet.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
// Switch from async mode to sync.
rendered.rerender( <App async={ false } /> );
// Ensure the async update was flushed during the rerender.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
// initial render + subscription check + rerender with isAsync=false
expect( selectSpy ).toHaveBeenCalledTimes( 3 );
// initial render + rerender with isAsync=false
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
} );
it( 'cancels scheduled updates when mapSelect function changes', async () => {
const selectA = jest.fn(
( select ) => 'a:' + select( 'counter' ).get()
);
const selectB = jest.fn(
( select ) => 'b:' + select( 'counter' ).get()
);
const TestComponent = jest.fn( ( { variant } ) => {
const count = useSelect( variant === 'a' ? selectA : selectB, [
variant,
] );
return <div role="status">{ count }</div>;
} );
const App = ( { variant } ) => (
<AsyncModeProvider value={ true }>
<RegistryProvider value={ registry }>
<TestComponent variant={ variant } />
</RegistryProvider>
</AsyncModeProvider>
);
const rendered = render( <App variant="a" /> );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'a:0' );
// Schedules an async update of the component.
act( () => {
registry.dispatch( 'counter' ).inc();
} );
// Ensure the async update wasn't processed yet.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'a:0' );
// Rerender with a prop change that causes dependency change.
rendered.rerender( <App variant="b" /> );
// Ensure the async update was flushed (cancelled) during the rerender.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 'b:1' );
// Give the async update time to run in case it wasn't cancelled
await new Promise( setImmediate );
expect( selectA ).toHaveBeenCalledTimes( 2 );
expect( selectB ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
} );
it( 'cancels scheduled updates when unmounting', async () => {
const selectSpy = jest.fn( ( select ) =>
select( 'counter' ).get()
);
const TestComponent = jest.fn( () => {
const count = useSelect( selectSpy, [] );
return <div role="status">{ count }</div>;
} );
const App = () => (
<AsyncModeProvider value={ true }>
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
const rendered = render( <App /> );
// Ensure expected state was rendered.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
// Schedules an async update of the component.
act( () => {
registry.dispatch( 'counter' ).inc();
} );
// Ensure the async update wasn't processed yet.
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
// Unmount
rendered.unmount();
// Give the async update time to run in case it wasn't cancelled
await new Promise( setImmediate );
// only the initial render, no state updates
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
} );
it( 'cancels scheduled updates when registry changes', async () => {
const registry2 = createRegistry();
registerCounterStore( registry2, 100 );
const selectSpy = jest.fn( ( select ) =>
select( 'counter' ).get()
);
const TestComponent = jest.fn( () => {
const count = useSelect( selectSpy, [] );
return <div role="status">{ count }</div>;
} );
const App = ( { reg } ) => (
<AsyncModeProvider value={ true }>
<RegistryProvider value={ reg }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
const rendered = render( <App reg={ registry } /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
act( () => {
registry.dispatch( 'counter' ).inc();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
rendered.rerender( <App reg={ registry2 } /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 100 );
// Give the async update time to run in case it wasn't cancelled
await new Promise( setImmediate );
// initial render + registry change rerender, no state updates
expect( selectSpy ).toHaveBeenCalledTimes( 4 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'usage without dependencies array', () => {
function registerStore( name, initial ) {
registry.registerStore( name, {
reducer: ( s = initial, a ) => ( a.type === 'inc' ? s + 1 : s ),
actions: { inc: () => ( { type: 'inc' } ) },
selectors: { get: ( s ) => s },
} );
}
it( 'does not memoize the callback when there are no deps', () => {
registerStore( 'store', 1 );
const Status = ( { multiple } ) => {
const count = useSelect(
( select ) => select( 'store' ).get() * multiple
);
return <div role="status">{ count }</div>;
};
const App = ( { multiple } ) => (
<RegistryProvider value={ registry }>
<Status multiple={ multiple } />
</RegistryProvider>
);
const rendered = render( <App multiple={ 1 } /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
// Check that the most recent value of `multiple` is used to render:
// the old callback wasn't memoized and there is no stale closure problem.
rendered.rerender( <App multiple={ 2 } /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 2 );
} );
it( 'subscribes only stores used by the initial callback', () => {
registerStore( 'counter-1', 1 );
registerStore( 'counter-2', 10 );
const Status = ( { store } ) => {
const count = useSelect( ( select ) => select( store ).get() );
return <div role="status">{ count }</div>;
};
const App = ( { store } ) => (
<RegistryProvider value={ registry }>
<Status store={ store } />
</RegistryProvider>
);
// initial render with counter-1
const rendered = render( <App store="counter-1" /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
// update from counter-1
act( () => {
registry.dispatch( 'counter-1' ).inc();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 2 );
// rerender with counter-2
rendered.rerender( <App store="counter-2" /> );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 10 );
// update from counter-2 is ignored because component is subcribed only to counter-1
act( () => {
registry.dispatch( 'counter-2' ).inc();
} );
expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 10 );
} );
} );
} );