@wordpress/data
Version:
Data module for WordPress.
1,273 lines (1,030 loc) • 35.6 kB
JavaScript
/**
* External dependencies
*/
import { act, render, fireEvent, screen } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { useLayoutEffect, useState, useReducer } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
createRegistry,
createRegistrySelector,
RegistryProvider,
AsyncModeProvider,
} from '../../..';
import useSelect from '..';
function counterStore( initialCount = 0, step = 1 ) {
return {
reducer: ( state = initialCount, action ) =>
action.type === 'INC' ? state + step : state,
actions: {
inc: () => ( { type: 'INC' } ),
},
selectors: {
get: ( state ) => state,
},
};
}
/* eslint-disable @wordpress/wp-global-usage */
describe( 'useSelect', () => {
const initialScriptDebug = globalThis.SCRIPT_DEBUG;
let registry;
beforeAll( () => {
// Do not run hook in development mode; it will call `mapSelect` an extra time.
globalThis.SCRIPT_DEBUG = false;
} );
beforeEach( () => {
registry = createRegistry();
} );
afterAll( () => {
globalThis.SCRIPT_DEBUG = initialScriptDebug;
} );
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>;
} );
render(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" />
</RegistryProvider>
);
expect( selectSpy ).toHaveBeenCalledTimes( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( screen.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 { rerender } = render(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" change />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 1 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 0 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'foo' );
// Rerender with non dependency changed.
rerender(
<RegistryProvider value={ registry }>
<TestComponent keyName="foo" change={ false } />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 1 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 0 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
// Ensure expected state was rendered.
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'foo' );
// Rerender with dependency changed.
rerender(
<RegistryProvider value={ registry }>
<TestComponent keyName="bar" change={ false } />
</RegistryProvider>
);
expect( selectSpyFoo ).toHaveBeenCalledTimes( 1 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 3 );
// Ensure expected state was rendered.
expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'bar' );
} );
it( 'does not rerender a nested component that is to be unmounted', () => {
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 );
const Child = jest.fn( () => {
const show = useSelect( mapSelectChild, [] );
return show ? 'yes' : 'no';
} );
const mapSelectParent = jest.fn( mapSelect );
const Parent = jest.fn( () => {
const show = useSelect( mapSelectParent, [] );
return show ? <Child /> : 'none';
} );
render(
<RegistryProvider value={ registry }>
<Parent />
</RegistryProvider>
);
// Initial render renders only parent and subscribes the parent to store.
expect( screen.getByText( 'none' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 1 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 0 );
expect( Parent ).toHaveBeenCalledTimes( 1 );
expect( Child ).toHaveBeenCalledTimes( 0 );
act( () => {
registry.dispatch( 'toggler' ).toggle();
} );
// Child was rendered and subscribed to the store, as the _second_ subscription.
expect( screen.getByText( 'yes' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 2 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 1 );
expect( Parent ).toHaveBeenCalledTimes( 2 );
expect( Child ).toHaveBeenCalledTimes( 1 );
act( () => {
registry.dispatch( 'toggler' ).toggle();
} );
// Check that child was unmounted without any extra state update being performed on it.
// I.e., `mapSelectChild` was called again, and state update was scheduled, we cannot
// avoid that, but the state update is never executed and doesn't do a rerender.
expect( screen.getByText( 'none' ) ).toBeInTheDocument();
expect( mapSelectParent ).toHaveBeenCalledTimes( 3 );
expect( mapSelectChild ).toHaveBeenCalledTimes( 2 );
expect( Parent ).toHaveBeenCalledTimes( 3 );
expect( Child ).toHaveBeenCalledTimes( 1 );
} );
it( 'incrementally subscribes to newly selected stores', () => {
registry.registerStore( 'store-main', counterStore() );
registry.registerStore( 'store-even', counterStore( 0, 2 ) );
registry.registerStore( 'store-odd', counterStore( 1, 2 ) );
const mapSelect = jest.fn( ( select ) => {
const first = select( 'store-main' ).get();
// select from other stores depending on whether main value is even or odd
const secondStore = first % 2 === 1 ? 'store-odd' : 'store-even';
const second = select( secondStore ).get();
return first + ':' + second;
} );
const TestComponent = jest.fn( () => {
const data = useSelect( mapSelect, [] );
return <div role="status">{ data }</div>;
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( mapSelect ).toHaveBeenCalledTimes( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0:0' );
// check that increment in store-even triggers a render
act( () => {
registry.dispatch( 'store-even' ).inc();
} );
expect( mapSelect ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0:2' );
// check that increment in store-odd doesn't trigger a render (not listening yet)
act( () => {
registry.dispatch( 'store-odd' ).inc();
} );
expect( mapSelect ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0:2' );
// check that increment in main store switches to store-odd
act( () => {
registry.dispatch( 'store-main' ).inc();
} );
expect( mapSelect ).toHaveBeenCalledTimes( 3 );
expect( TestComponent ).toHaveBeenCalledTimes( 3 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1:3' );
// check that increment in store-odd triggers a render
act( () => {
registry.dispatch( 'store-odd' ).inc();
} );
expect( mapSelect ).toHaveBeenCalledTimes( 4 );
expect( TestComponent ).toHaveBeenCalledTimes( 4 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1:5' );
// check that increment in store-even triggers a mapSelect call (still listening)
// but not a render (not used for selected value which doesn't change)
act( () => {
registry.dispatch( 'store-even' ).inc();
} );
expect( mapSelect ).toHaveBeenCalledTimes( 5 );
expect( TestComponent ).toHaveBeenCalledTimes( 4 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1:5' );
} );
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 );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
// Ensure expected state was rendered.
expect( screen.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( screen.getByRole( 'status' ).dataset.d ).toBe(
JSON.stringify( valueB )
);
expect( mapSelectSpy ).toHaveBeenCalledTimes( 2 );
}
);
} );
describe( 're-calls the selector as few times as possible', () => {
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' ).get(),
[]
);
useSelect(
( select ) => selectCount2() || select( 'store-2' ).get(),
[]
);
return <div role="status">{ count1 }</div>;
} );
const { unmount } = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1 ).toHaveBeenCalledTimes( 1 );
expect( selectCount2 ).toHaveBeenCalledTimes( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
act( () => {
registry.dispatch( 'store-2' ).inc();
} );
expect( selectCount1 ).toHaveBeenCalledTimes( 1 );
expect( selectCount2 ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
act( () => {
registry.dispatch( 'store-1' ).inc();
} );
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
expect( selectCount2 ).toHaveBeenCalledTimes( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 3 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
// Test if the unsubscribers get called correctly.
expect( () => unmount() ).not.toThrow();
} );
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' ).get(),
count2: select( 'store-2' ).get(),
},
[]
);
return (
<div role="status">
{ count1 },{ count2 }
</div>
);
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1And2 ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0,0' );
act( () => {
registry.dispatch( 'store-2' ).inc();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0,1' );
act( () => {
registry.dispatch( 'store-3' ).inc();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 2 );
expect( screen.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' ).get(),
dep,
},
[ dep ]
);
return (
<div role="status">
count:{ state.count1 },dep:{ state.dep }
</div>
);
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count:0,dep:0'
);
act( () => {
setDep( 1 );
} );
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 2 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count:0,dep:1'
);
act( () => {
registry.dispatch( 'store-1' ).inc();
} );
expect( selectCount1AndDep ).toHaveBeenCalledTimes( 3 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count:1,dep:1'
);
} );
it( 'captures state changes scheduled between render and subscription', () => {
registry.registerStore( 'store-1', counterStore() );
const selectCount1 = jest.fn( ( select ) => ( {
count1: select( 'store-1' ).get(),
} ) );
const TestComponent = jest.fn( () => {
const { count1 } = useSelect( selectCount1, [] );
// Increment the store value from 0 to 1 after render and before subscription
useLayoutEffect( () => {
if ( count1 === 0 ) {
registry.dispatch( 'store-1' ).inc();
}
}, [ count1 ] );
return <div role="status">count1:{ count1 }</div>;
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
// One select on initial render.
// There's a second selector call on the second render, but that one returns a memoized value.
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
// Initial render and second render after counter increment (which is expected to be detected).
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
// Finally rendered with the incremented counter's value.
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:1'
);
} );
it( 'captures state changes scheduled between render and effect after selector change', () => {
registry.registerStore( 'names', {
reducer: ( state = {}, action ) => {
if ( action.type === 'SET_NAME' ) {
return {
...state,
[ action.id ]: action.name,
};
}
return state;
},
actions: {
setName: ( id, name ) => ( { type: 'SET_NAME', id, name } ),
},
selectors: {
getName: ( state, id ) => state[ id ] ?? 'null',
},
} );
const renderedItems = [];
function TestComponent() {
const [ blockId, setBlockId ] = useState( 1 );
const name = useSelect(
( select ) => select( 'names' ).getName( blockId ),
[ blockId ]
);
// Change name of block 2. The store listener will still use the old selector
// for block 1, because a new one will be stored by an effect a moment later,
// but we're testing that it still won't miss the update, because one more check
// will happen in that effect.
useLayoutEffect( () => {
if ( blockId === 2 ) {
registry.dispatch( 'names' ).setName( 2, 'new2' );
}
}, [ blockId ] );
renderedItems.push( name );
return (
<button onClick={ () => setBlockId( 2 ) }>
change-block
</button>
);
}
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( renderedItems ).toEqual( [ 'null' ] );
fireEvent.click( screen.getByRole( 'button' ) );
// After click, there are two new renders:
// 1. With of block 2, after state update of `blockId` from 1 to 2
// 2. After dispatching an action to change 2's name to `new2`
expect( renderedItems ).toEqual( [ 'null', 'null', 'new2' ] );
} );
it( 'handles registry selectors', () => {
const getCount1And2 = createRegistrySelector(
( select ) => ( state ) => ( {
count1: state,
count2: select( 'store-2' ).get(),
} )
);
const store1Spec = counterStore();
Object.assign( store1Spec.selectors, { getCount1And2 } );
registry.registerStore( 'store-1', store1Spec );
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>
);
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1And2 ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:0,count2:0'
);
act( () => {
registry.dispatch( 'store-2' ).inc();
} );
expect( selectCount1And2 ).toHaveBeenCalledTimes( 2 );
expect( screen.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();
return 'count1:' + select( 'store-1' ).get();
}
selectCount2();
return 'count2:' + select( 'store-2' ).get();
},
[ shouldSelectCount1 ]
);
return (
<>
<div role="status">{ state }</div>
<button onClick={ toggle }>Open</button>
</>
);
} );
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( selectCount1 ).toHaveBeenCalledTimes( 0 );
expect( selectCount2 ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count2:0'
);
act( () => screen.getByText( 'Open' ).click() );
expect( selectCount1 ).toHaveBeenCalledTimes( 1 );
expect( selectCount2 ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:0'
);
// Verify that the component subscribed to store-1 after selected from
act( () => {
registry.dispatch( 'store-1' ).inc();
} );
expect( selectCount1 ).toHaveBeenCalledTimes( 2 );
expect( selectCount2 ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:1'
);
} );
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' ).get(),
childCount: select( 'child-store' ).get(),
} ),
[]
);
return (
<div role="status">
parent:{ state.parentCount },child:{ state.childCount }
</div>
);
} );
render(
<RegistryProvider value={ registry }>
<RegistryProvider value={ subRegistry }>
<TestComponent />
</RegistryProvider>
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'parent:0,child:0'
);
act( () => {
registry.dispatch( 'parent-store' ).inc();
} );
expect( screen.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' ).get(),
count2: select( 'store-2' )?.get() ?? 'blank',
} ),
[]
);
return (
<div role="status">
count1:{ state.count1 },count2:{ state.count2 }
</div>
);
} );
const { unmount } = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:0,count2:blank'
);
act( () => {
registry.dispatch( 'store-1' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent(
'count1:1,count2:blank'
);
// Test if the unsubscribers get called correctly.
expect( () => unmount() ).not.toThrow();
} );
it( 'handles registration of a non-existing store during rendering', () => {
const TestComponent = jest.fn( () => {
const state = useSelect(
( select ) =>
select( 'not-yet-registered-store' )?.get() ?? 'blank',
[]
);
return <div role="status">{ state }</div>;
} );
const { unmount } = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( screen.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( screen.getByRole( 'status' ) ).toHaveTextContent( 'blank' );
act( () => {
registry.dispatch( 'not-yet-registered-store' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
// Test if the unsubscribers get called correctly.
expect( () => unmount() ).not.toThrow();
} );
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' )?.get() ??
'blank',
[]
);
return <div role="status">{ state }</div>;
} );
const { unmount } = render(
<RegistryProvider value={ registry }>
<RegistryProvider value={ subRegistry }>
<TestComponent />
</RegistryProvider>
</RegistryProvider>
);
expect( screen.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( screen.getByRole( 'status' ) ).toHaveTextContent( 'blank' );
act( () => {
registry.dispatch( 'not-yet-registered-child-store' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
// Test if the unsubscribers get called correctly.
expect( () => unmount() ).not.toThrow();
} );
it( 'handles custom generic stores without a unsubscribe function', () => {
const customStore = {
name: 'generic-store',
instantiate() {
let storeChanged = () => {};
let counter = 0;
const selectors = {
get: () => counter,
};
const actions = {
inc: () => {
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 ).get(),
[]
);
return <div role="status">{ state }</div>;
} );
const { unmount } = render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
act( () => {
registry.dispatch( customStore ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
expect( () => unmount() ).not.toThrow();
} );
} );
describe( 'async mode', () => {
beforeEach( () => {
registry.registerStore( 'counter', counterStore() );
} );
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>;
} );
render(
<AsyncModeProvider value>
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
// initial render
expect( selectSpy ).toHaveBeenCalledTimes( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
// Ensure expected state was rendered.
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
act( () => {
registry.dispatch( 'counter' ).inc();
} );
// still not called right after increment
expect( selectSpy ).toHaveBeenCalledTimes( 1 );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
expect( await screen.findByText( 1 ) ).toBeInTheDocument();
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
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 { rerender } = render( <App async /> );
// Ensure expected state was rendered.
expect( screen.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( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
// Switch from async mode to sync.
rerender( <App async={ false } /> );
// Ensure the async update was flushed during the rerender.
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
// initial render + rerender with isAsync=false
expect( selectSpy ).toHaveBeenCalledTimes( 2 );
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>
<RegistryProvider value={ registry }>
<TestComponent variant={ variant } />
</RegistryProvider>
</AsyncModeProvider>
);
const { rerender } = render( <App variant="a" /> );
// Ensure expected state was rendered.
expect( screen.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( screen.getByRole( 'status' ) ).toHaveTextContent( 'a:0' );
// Rerender with a prop change that causes dependency change.
rerender( <App variant="b" /> );
// Ensure the async update was flushed (cancelled) during the rerender.
expect( screen.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( 1 );
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>
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
const { unmount } = render( <App /> );
// Ensure expected state was rendered.
expect( screen.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( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
// Unmount
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( 1 );
expect( TestComponent ).toHaveBeenCalledTimes( 1 );
} );
it( 'cancels scheduled updates when registry changes', async () => {
const registry2 = createRegistry();
registry2.registerStore( 'counter', counterStore( 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>
<RegistryProvider value={ reg }>
<TestComponent />
</RegistryProvider>
</AsyncModeProvider>
);
const { rerender } = render( <App reg={ registry } /> );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
act( () => {
registry.dispatch( 'counter' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '0' );
rerender( <App reg={ registry2 } /> );
expect( screen.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( 2 );
expect( TestComponent ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'usage without dependencies array', () => {
it( 'does not memoize the callback when there are no deps', () => {
registry.registerStore( 'store', counterStore( 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 { rerender } = render( <App multiple={ 1 } /> );
expect( screen.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.
rerender( <App multiple={ 2 } /> );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '2' );
} );
it( 'resubscribes when the set of selected stores changes', () => {
registry.registerStore( 'counter-1', counterStore( 1 ) );
registry.registerStore( 'counter-2', counterStore( 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 { rerender } = render( <App store="counter-1" /> );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '1' );
// update from counter-1
act( () => {
registry.dispatch( 'counter-1' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '2' );
// rerender with counter-2
rerender( <App store="counter-2" /> );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '10' );
// update from counter-2 is processed because component has subscribed to counter-2
act( () => {
registry.dispatch( 'counter-2' ).inc();
} );
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '11' );
} );
} );
describe( 'static store selection mode', () => {
it( 'can read the current value from store', () => {
registry.registerStore( 'testStore', counterStore() );
const record = jest.fn();
function TestComponent() {
const { get } = useSelect( 'testStore' );
return (
<button onClick={ () => record( get() ) }>record</button>
);
}
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);
fireEvent.click( screen.getByRole( 'button' ) );
expect( record ).toHaveBeenLastCalledWith( 0 );
// no need to act() as the component doesn't react to the updates
registry.dispatch( 'testStore' ).inc();
fireEvent.click( screen.getByRole( 'button' ) );
expect( record ).toHaveBeenLastCalledWith( 1 );
} );
} );
} );
/* eslint-enable @wordpress/wp-global-usage */