Skip to content

Commit b0d3994

Browse files
committed
ObservableMap: optimize unsubscribe and add unit tests
1 parent 058cc37 commit b0d3994

File tree

2 files changed

+111
-17
lines changed

2 files changed

+111
-17
lines changed

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

+24-17
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,18 +28,14 @@ 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-
}
31+
function getListeners( name: K ) {
32+
let list = listeners.get( name );
33+
if ( ! list ) {
34+
list = new Set();
35+
listeners.set( name, list );
36+
}
3337

34-
list.delete( listener );
35-
if ( list.size === 0 ) {
36-
listeners.delete( name );
37-
}
38-
};
38+
return list;
3939
}
4040

4141
return {
@@ -51,18 +51,25 @@ export function observableMap< K, V >(): ObservableMap< K, V > {
5151
callListeners( name );
5252
},
5353
subscribe( name, listener ) {
54-
let list = listeners.get( name );
55-
if ( ! list ) {
56-
list = new Set();
57-
listeners.set( name, list );
58-
}
54+
const list = getListeners( name );
5955
list.add( listener );
6056

61-
return unsubscribe( name, listener );
57+
return () => {
58+
list.delete( listener );
59+
if ( list.size === 0 ) {
60+
listeners.delete( name );
61+
}
62+
};
6263
},
6364
};
6465
}
6566

67+
/**
68+
* React hook that lets you observe an individual entry in an `ObservableMap`.
69+
*
70+
* @param map The `ObservableMap` to observe.
71+
* @param name The map key to observe.
72+
*/
6673
export function useObservableValue< K, V >(
6774
map: ObservableMap< K, V >,
6875
name: K
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
let notifsFromA = 0;
18+
let notifsFromB = 0;
19+
20+
const unsubA = map.subscribe( 'a', () => {
21+
notifsFromA++;
22+
} );
23+
const unsubB = map.subscribe( 'b', () => {
24+
notifsFromB++;
25+
} );
26+
27+
// check that setting `a` doesn't notify the `b` listener
28+
map.set( 'a', 1 );
29+
expect( notifsFromA ).toBe( 1 );
30+
expect( notifsFromB ).toBe( 0 );
31+
32+
// check that setting `b` doesn't notify the `a` listener
33+
map.set( 'b', 2 );
34+
expect( notifsFromA ).toBe( 1 );
35+
expect( notifsFromB ).toBe( 1 );
36+
37+
// check that `delete` triggers notifications, too
38+
map.delete( 'a' );
39+
expect( notifsFromA ).toBe( 2 );
40+
expect( notifsFromB ).toBe( 1 );
41+
42+
// check that the subscription survived the `delete`
43+
map.set( 'a', 2 );
44+
expect( notifsFromA ).toBe( 3 );
45+
expect( notifsFromB ).toBe( 1 );
46+
47+
// check that unsubscription really works.
48+
unsubA();
49+
unsubB();
50+
map.set( 'a', 3 );
51+
expect( notifsFromA ).toBe( 3 );
52+
expect( notifsFromB ).toBe( 1 );
53+
} );
54+
} );
55+
56+
describe( 'useObservableValue', () => {
57+
test( 'reacts only to the specified key', () => {
58+
const map = observableMap();
59+
map.set( 'a', 1 );
60+
61+
let renders = 0;
62+
function MapUI() {
63+
const value = useObservableValue( map, 'a' );
64+
renders++;
65+
return <div>value is { value }</div>;
66+
}
67+
68+
render( <MapUI /> );
69+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
70+
'value is 1'
71+
);
72+
expect( renders ).toBe( 1 );
73+
74+
act( () => {
75+
map.set( 'a', 2 );
76+
} );
77+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
78+
'value is 2'
79+
);
80+
expect( renders ).toBe( 2 );
81+
82+
act( () => {
83+
map.set( 'b', 1 );
84+
} );
85+
expect( renders ).toBe( 2 );
86+
} );
87+
} );

0 commit comments

Comments
 (0)