diff --git a/packages/data/src/components/use-select/index.js b/packages/data/src/components/use-select/index.js
index cc204f19ef47b1..eb7c1a58cd710f 100644
--- a/packages/data/src/components/use-select/index.js
+++ b/packages/data/src/components/use-select/index.js
@@ -118,6 +118,7 @@ export default function useSelect( mapSelect, deps ) {
const queueContext = useMemoOne( () => ( { queue: true } ), [ registry ] );
const [ , forceRender ] = useReducer( ( s ) => s + 1, 0 );
+ const latestRegistry = useRef( registry );
const latestMapSelect = useRef();
const latestIsAsync = useRef( isAsync );
const latestMapOutput = useRef();
@@ -145,10 +146,15 @@ export default function useSelect( mapSelect, deps ) {
if ( _mapSelect ) {
mapOutput = latestMapOutput.current;
+ const hasReplacedRegistry = latestRegistry.current !== registry;
const hasReplacedMapSelect = latestMapSelect.current !== _mapSelect;
const lastMapSelectFailed = !! latestMapOutputError.current;
- if ( hasReplacedMapSelect || lastMapSelectFailed ) {
+ if (
+ hasReplacedRegistry ||
+ hasReplacedMapSelect ||
+ lastMapSelectFailed
+ ) {
try {
mapOutput = wrapSelect( _mapSelect );
} catch ( error ) {
@@ -171,6 +177,7 @@ export default function useSelect( mapSelect, deps ) {
return;
}
+ latestRegistry.current = registry;
latestMapSelect.current = _mapSelect;
latestMapOutput.current = mapOutput;
latestMapOutputError.current = undefined;
@@ -219,7 +226,7 @@ export default function useSelect( mapSelect, deps ) {
// Catch any possible state changes during mount before the subscription
// could be set.
- onChange();
+ onStoreChange();
const unsubscribers = listeningStores.current.map( ( storeName ) =>
registry.__unstableSubscribeStore( storeName, onChange )
diff --git a/packages/data/src/components/use-select/test/index.js b/packages/data/src/components/use-select/test/index.js
index ae0dac2c0f3c7f..bdacb4488e4f68 100644
--- a/packages/data/src/components/use-select/test/index.js
+++ b/packages/data/src/components/use-select/test/index.js
@@ -11,10 +11,15 @@ import { useState, useReducer } from '@wordpress/element';
/**
* Internal dependencies
*/
-import { createRegistry } from '../../../registry';
-import { createRegistrySelector } from '../../../factory';
-import { RegistryProvider } from '../../registry-provider';
-import useSelect from '../index';
+import {
+ createRegistry,
+ createRegistrySelector,
+ RegistryProvider,
+ AsyncModeProvider,
+} from '../../..';
+import useSelect from '..';
+
+jest.useRealTimers();
describe( 'useSelect', () => {
let registry;
@@ -714,4 +719,245 @@ describe( 'useSelect', () => {
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
{ count }
;
+ } );
+
+ const rendered = render(
+
+
+
+
+
+ );
+
+ // 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 { count }
;
+ } );
+
+ const App = ( { async } ) => (
+
+
+
+
+
+ );
+
+ const rendered = render( );
+
+ // 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( );
+
+ // Ensure the async update was flushed during the rerender.
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 1 );
+
+ // initial render + subscription check + flushed store update
+ expect( selectSpy ).toHaveBeenCalledTimes( 3 );
+ // initial render + rerender with isAsync=false + store state update
+ expect( TestComponent ).toHaveBeenCalledTimes( 3 );
+ } );
+
+ 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 { count }
;
+ } );
+
+ const App = ( { variant } ) => (
+
+
+
+
+
+ );
+
+ const rendered = render( );
+
+ // 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( );
+
+ // 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 { count }
;
+ } );
+
+ const App = () => (
+
+
+
+
+
+ );
+
+ const rendered = render( );
+
+ // 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 { count }
;
+ } );
+
+ const App = ( { reg } ) => (
+
+
+
+
+
+ );
+
+ const rendered = render( );
+
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
+
+ act( () => {
+ registry.dispatch( 'counter' ).inc();
+ } );
+
+ expect( rendered.getByRole( 'status' ) ).toHaveTextContent( 0 );
+
+ rendered.rerender( );
+
+ 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 );
+ } );
+ } );
} );