Skip to content

Commit ff3afd3

Browse files
jsnajdrellatrixtyxla
authored
ObservableMap: optimize unsubscribe and add unit tests (#60892)
* ObservableMap: optimize unsubscribe and add unit tests * Use jest.fn to count function calls * Inline the getListeners function after TS upgrade to 5.4.5 Co-authored-by: jsnajdr <[email protected]> Co-authored-by: ellatrix <[email protected]> Co-authored-by: tyxla <[email protected]>
1 parent e33cb88 commit ff3afd3

File tree

2 files changed

+99
-15
lines changed

2 files changed

+99
-15
lines changed

packages/components/src/slot-fill/bubbles-virtually/observable-map.ts

+16-15
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export type ObservableMap< K, V > = {
1010
subscribe( name: K, listener: () => void ): () => void;
1111
};
1212

13+
/**
14+
* A key/value map where the individual entries are observable by subscribing to them
15+
* with the `subscribe` methods.
16+
*/
1317
export function observableMap< K, V >(): ObservableMap< K, V > {
1418
const map = new Map< K, V >();
1519
const listeners = new Map< K, Set< () => void > >();
@@ -24,20 +28,6 @@ export function observableMap< K, V >(): ObservableMap< K, V > {
2428
}
2529
}
2630

27-
function unsubscribe( name: K, listener: () => void ) {
28-
return () => {
29-
const list = listeners.get( name );
30-
if ( ! list ) {
31-
return;
32-
}
33-
34-
list.delete( listener );
35-
if ( list.size === 0 ) {
36-
listeners.delete( name );
37-
}
38-
};
39-
}
40-
4131
return {
4232
get( name ) {
4333
return map.get( name );
@@ -58,11 +48,22 @@ export function observableMap< K, V >(): ObservableMap< K, V > {
5848
}
5949
list.add( listener );
6050

61-
return unsubscribe( name, listener );
51+
return () => {
52+
list.delete( listener );
53+
if ( list.size === 0 ) {
54+
listeners.delete( name );
55+
}
56+
};
6257
},
6358
};
6459
}
6560

61+
/**
62+
* React hook that lets you observe an individual entry in an `ObservableMap`.
63+
*
64+
* @param map The `ObservableMap` to observe.
65+
* @param name The map key to observe.
66+
*/
6667
export function useObservableValue< K, V >(
6768
map: ObservableMap< K, V >,
6869
name: K
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen, act } from '@testing-library/react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import {
10+
observableMap,
11+
useObservableValue,
12+
} from '../bubbles-virtually/observable-map';
13+
14+
describe( 'ObservableMap', () => {
15+
test( 'should observe individual values', () => {
16+
const map = observableMap();
17+
18+
const listenerA = jest.fn();
19+
const listenerB = jest.fn();
20+
21+
const unsubA = map.subscribe( 'a', listenerA );
22+
const unsubB = map.subscribe( 'b', listenerB );
23+
24+
// check that setting `a` doesn't notify the `b` listener
25+
map.set( 'a', 1 );
26+
expect( listenerA ).toHaveBeenCalledTimes( 1 );
27+
expect( listenerB ).toHaveBeenCalledTimes( 0 );
28+
29+
// check that setting `b` doesn't notify the `a` listener
30+
map.set( 'b', 2 );
31+
expect( listenerA ).toHaveBeenCalledTimes( 1 );
32+
expect( listenerB ).toHaveBeenCalledTimes( 1 );
33+
34+
// check that `delete` triggers notifications, too
35+
map.delete( 'a' );
36+
expect( listenerA ).toHaveBeenCalledTimes( 2 );
37+
expect( listenerB ).toHaveBeenCalledTimes( 1 );
38+
39+
// check that the subscription survived the `delete`
40+
map.set( 'a', 2 );
41+
expect( listenerA ).toHaveBeenCalledTimes( 3 );
42+
expect( listenerB ).toHaveBeenCalledTimes( 1 );
43+
44+
// check that unsubscription really works
45+
unsubA();
46+
unsubB();
47+
map.set( 'a', 3 );
48+
expect( listenerA ).toHaveBeenCalledTimes( 3 );
49+
expect( listenerB ).toHaveBeenCalledTimes( 1 );
50+
} );
51+
} );
52+
53+
describe( 'useObservableValue', () => {
54+
test( 'reacts only to the specified key', () => {
55+
const map = observableMap();
56+
map.set( 'a', 1 );
57+
58+
const MapUI = jest.fn( () => {
59+
const value = useObservableValue( map, 'a' );
60+
return <div>value is { value }</div>;
61+
} );
62+
63+
render( <MapUI /> );
64+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
65+
'value is 1'
66+
);
67+
expect( MapUI ).toHaveBeenCalledTimes( 1 );
68+
69+
act( () => {
70+
map.set( 'a', 2 );
71+
} );
72+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
73+
'value is 2'
74+
);
75+
expect( MapUI ).toHaveBeenCalledTimes( 2 );
76+
77+
// check that setting unobserved map key doesn't trigger a render at all
78+
act( () => {
79+
map.set( 'b', 1 );
80+
} );
81+
expect( MapUI ).toHaveBeenCalledTimes( 2 );
82+
} );
83+
} );

0 commit comments

Comments
 (0)