Skip to content

Commit 9725060

Browse files
authored
useBlockRefs: use more efficient lookup map, use uSES (#60945)
* useBlockRefs: use more efficient lookup map, use uSES * Rewrite block refs with observableMap, which moves to compose * Improve docs * Add changelog entry
1 parent 2c4ef30 commit 9725060

File tree

15 files changed

+143
-139
lines changed

15 files changed

+143
-139
lines changed

packages/block-editor/src/components/block-list/use-block-props/use-block-refs.js

+17-57
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import {
5-
useContext,
6-
useLayoutEffect,
7-
useMemo,
8-
useRef,
9-
useState,
10-
} from '@wordpress/element';
11-
import { useRefEffect } from '@wordpress/compose';
4+
import { useContext, useMemo, useRef } from '@wordpress/element';
5+
import { useRefEffect, useObservableValue } from '@wordpress/compose';
126

137
/**
148
* Internal dependencies
@@ -26,60 +20,40 @@ import { BlockRefs } from '../../provider/block-refs-provider';
2620
* @return {RefCallback} Ref callback.
2721
*/
2822
export function useBlockRefProvider( clientId ) {
29-
const { refs, callbacks } = useContext( BlockRefs );
30-
const ref = useRef();
31-
useLayoutEffect( () => {
32-
refs.set( ref, clientId );
33-
return () => {
34-
refs.delete( ref );
35-
};
36-
}, [ clientId ] );
23+
const { refsMap } = useContext( BlockRefs );
3724
return useRefEffect(
3825
( element ) => {
39-
// Update the ref in the provider.
40-
ref.current = element;
41-
// Call any update functions.
42-
callbacks.forEach( ( id, setElement ) => {
43-
if ( clientId === id ) {
44-
setElement( element );
45-
}
46-
} );
26+
refsMap.set( clientId, element );
27+
return () => refsMap.delete( clientId );
4728
},
4829
[ clientId ]
4930
);
5031
}
5132

5233
/**
53-
* Gets a ref pointing to the current block element. Continues to return a
54-
* stable ref even if the block client ID changes.
34+
* Gets a ref pointing to the current block element. Continues to return the same
35+
* stable ref object even if the `clientId` argument changes. This hook is not
36+
* reactive, i.e., it won't trigger a rerender of the calling component if the
37+
* ref value changes. For reactive use cases there is the `useBlockElement` hook.
5538
*
5639
* @param {string} clientId The client ID to get a ref for.
5740
*
5841
* @return {RefObject} A ref containing the element.
5942
*/
6043
function useBlockRef( clientId ) {
61-
const { refs } = useContext( BlockRefs );
62-
const freshClientId = useRef();
63-
freshClientId.current = clientId;
44+
const { refsMap } = useContext( BlockRefs );
45+
const latestClientId = useRef();
46+
latestClientId.current = clientId;
47+
6448
// Always return an object, even if no ref exists for a given client ID, so
6549
// that `current` works at a later point.
6650
return useMemo(
6751
() => ( {
6852
get current() {
69-
let element = null;
70-
71-
// Multiple refs may be created for a single block. Find the
72-
// first that has an element set.
73-
for ( const [ ref, id ] of refs.entries() ) {
74-
if ( id === freshClientId.current && ref.current ) {
75-
element = ref.current;
76-
}
77-
}
78-
79-
return element;
53+
return refsMap.get( latestClientId.current ) ?? null;
8054
},
8155
} ),
82-
[]
56+
[ refsMap ]
8357
);
8458
}
8559

@@ -92,22 +66,8 @@ function useBlockRef( clientId ) {
9266
* @return {Element|null} The block's wrapper element.
9367
*/
9468
function useBlockElement( clientId ) {
95-
const { callbacks } = useContext( BlockRefs );
96-
const ref = useBlockRef( clientId );
97-
const [ element, setElement ] = useState( null );
98-
99-
useLayoutEffect( () => {
100-
if ( ! clientId ) {
101-
return;
102-
}
103-
104-
callbacks.set( setElement, clientId );
105-
return () => {
106-
callbacks.delete( setElement );
107-
};
108-
}, [ clientId ] );
109-
110-
return ref.current || element;
69+
const { refsMap } = useContext( BlockRefs );
70+
return useObservableValue( refsMap, clientId ) ?? null;
11171
}
11272

11373
export { useBlockRef as __unstableUseBlockRef };

packages/block-editor/src/components/provider/block-refs-provider.js

+3-8
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,12 @@
22
* WordPress dependencies
33
*/
44
import { createContext, useMemo } from '@wordpress/element';
5+
import { observableMap } from '@wordpress/compose';
56

6-
export const BlockRefs = createContext( {
7-
refs: new Map(),
8-
callbacks: new Map(),
9-
} );
7+
export const BlockRefs = createContext( { refsMap: observableMap() } );
108

119
export function BlockRefsProvider( { children } ) {
12-
const value = useMemo(
13-
() => ( { refs: new Map(), callbacks: new Map() } ),
14-
[]
15-
);
10+
const value = useMemo( () => ( { refsMap: observableMap() } ), [] );
1611
return (
1712
<BlockRefs.Provider value={ value }>{ children }</BlockRefs.Provider>
1813
);

packages/components/src/slot-fill/bubbles-virtually/slot-fill-context.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
*/
44
import { createContext } from '@wordpress/element';
55
import warning from '@wordpress/warning';
6+
import { observableMap } from '@wordpress/compose';
7+
68
/**
79
* Internal dependencies
810
*/
911
import type { SlotFillBubblesVirtuallyContext } from '../types';
10-
import { observableMap } from './observable-map';
1112

1213
const initialContextValue: SlotFillBubblesVirtuallyContext = {
1314
slots: observableMap(),

packages/components/src/slot-fill/bubbles-virtually/slot-fill-provider.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { useMemo } from '@wordpress/element';
55
import isShallowEqual from '@wordpress/is-shallow-equal';
6+
import { observableMap } from '@wordpress/compose';
67

78
/**
89
* Internal dependencies
@@ -12,7 +13,6 @@ import type {
1213
SlotFillProviderProps,
1314
SlotFillBubblesVirtuallyContext,
1415
} from '../types';
15-
import { observableMap } from './observable-map';
1616

1717
function createSlotRegistry(): SlotFillBubblesVirtuallyContext {
1818
const slots: SlotFillBubblesVirtuallyContext[ 'slots' ] = observableMap();

packages/components/src/slot-fill/bubbles-virtually/use-slot-fills.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
* WordPress dependencies
33
*/
44
import { useContext } from '@wordpress/element';
5+
import { useObservableValue } from '@wordpress/compose';
56

67
/**
78
* Internal dependencies
89
*/
910
import SlotFillContext from './slot-fill-context';
1011
import type { SlotKey } from '../types';
11-
import { useObservableValue } from './observable-map';
1212

1313
export default function useSlotFills( name: SlotKey ) {
1414
const registry = useContext( SlotFillContext );

packages/components/src/slot-fill/bubbles-virtually/use-slot.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* WordPress dependencies
33
*/
44
import { useMemo, useContext } from '@wordpress/element';
5+
import { useObservableValue } from '@wordpress/compose';
56

67
/**
78
* Internal dependencies
@@ -13,7 +14,6 @@ import type {
1314
FillProps,
1415
SlotKey,
1516
} from '../types';
16-
import { useObservableValue } from './observable-map';
1717

1818
export default function useSlot( name: SlotKey ) {
1919
const registry = useContext( SlotFillContext );

packages/components/src/slot-fill/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import type { Component, MutableRefObject, ReactNode, RefObject } from 'react';
55

66
/**
7-
* Internal dependencies
7+
* WordPress dependencies
88
*/
9-
import type { ObservableMap } from './bubbles-virtually/observable-map';
9+
import type { ObservableMap } from '@wordpress/compose';
1010

1111
export type DistributiveOmit< T, K extends keyof any > = T extends any
1212
? Omit< T, K >

packages/compose/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Added new `observableMap` data structure and `useObservableValue` React hook ([#60945](https://github.com/WordPress/gutenberg/pull/60945)).
6+
57
## 6.33.0 (2024-04-19)
68

79
## 6.32.0 (2024-04-03)

packages/compose/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ _Returns_
129129

130130
- Higher-order component.
131131

132+
### observableMap
133+
134+
A constructor (factory) for `ObservableMap`, a map-like key/value data structure where the individual entries are observable: using the `subscribe` method, you can subscribe to updates for a particular keys. Each subscriber always observes one specific key and is not notified about any unrelated changes (for different keys) in the `ObservableMap`.
135+
136+
_Returns_
137+
138+
- `ObservableMap< K, V >`: A new instance of the `ObservableMap` type.
139+
132140
### pipe
133141

134142
Composes multiple higher-order components into a single higher-order component. Performs left-to-right function composition, where each successive invocation is supplied the return value of the previous.
@@ -442,6 +450,19 @@ _Returns_
442450

443451
- `import('react').RefCallback<TypeFromRef<TRef>>`: The merged ref callback.
444452

453+
### useObservableValue
454+
455+
React hook that lets you observe an entry in an `ObservableMap`. The hook returns the current value corresponding to the key, or `undefined` when there is no value stored. It also observes changes to the value and triggers an update of the calling component in case the value changes.
456+
457+
_Parameters_
458+
459+
- _map_ `ObservableMap< K, V >`: The `ObservableMap` to observe.
460+
- _name_ `K`: The map key to observe.
461+
462+
_Returns_
463+
464+
- `V | undefined`: The value corresponding to the map key requested.
465+
445466
### usePrevious
446467

447468
Use something's value from the previous render. Based on <https://usehooks.com/usePrevious/>.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useMemo, useSyncExternalStore } from '@wordpress/element';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import type { ObservableMap } from '../../utils/observable-map';
10+
11+
/**
12+
* React hook that lets you observe an entry in an `ObservableMap`. The hook returns the
13+
* current value corresponding to the key, or `undefined` when there is no value stored.
14+
* It also observes changes to the value and triggers an update of the calling component
15+
* in case the value changes.
16+
*
17+
* @template K The type of the keys in the map.
18+
* @template V The type of the values in the map.
19+
* @param map The `ObservableMap` to observe.
20+
* @param name The map key to observe.
21+
* @return The value corresponding to the map key requested.
22+
*/
23+
export default function useObservableValue< K, V >(
24+
map: ObservableMap< K, V >,
25+
name: K
26+
): V | undefined {
27+
const [ subscribe, getValue ] = useMemo(
28+
() => [
29+
( listener: () => void ) => map.subscribe( name, listener ),
30+
() => map.get( name ),
31+
],
32+
[ map, name ]
33+
);
34+
return useSyncExternalStore( subscribe, getValue, getValue );
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen, act } from '@testing-library/react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { observableMap } from '../../../utils/observable-map';
10+
import useObservableValue from '..';
11+
12+
describe( 'useObservableValue', () => {
13+
test( 'reacts only to the specified key', () => {
14+
const map = observableMap();
15+
map.set( 'a', 1 );
16+
17+
const MapUI = jest.fn( () => {
18+
const value = useObservableValue( map, 'a' );
19+
return <div>value is { value }</div>;
20+
} );
21+
22+
render( <MapUI /> );
23+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
24+
'value is 1'
25+
);
26+
expect( MapUI ).toHaveBeenCalledTimes( 1 );
27+
28+
act( () => {
29+
map.set( 'a', 2 );
30+
} );
31+
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
32+
'value is 2'
33+
);
34+
expect( MapUI ).toHaveBeenCalledTimes( 2 );
35+
36+
// check that setting unobserved map key doesn't trigger a render at all
37+
act( () => {
38+
map.set( 'b', 1 );
39+
} );
40+
expect( MapUI ).toHaveBeenCalledTimes( 2 );
41+
} );
42+
} );

packages/compose/src/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export * from './utils/create-higher-order-component';
44
export * from './utils/debounce';
55
// The `throttle` helper and its types.
66
export * from './utils/throttle';
7+
// The `ObservableMap` data structure
8+
export * from './utils/observable-map';
79

810
// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
911
export { default as compose } from './higher-order/compose';
@@ -46,3 +48,4 @@ export { default as useRefEffect } from './hooks/use-ref-effect';
4648
export { default as __experimentalUseDropZone } from './hooks/use-drop-zone';
4749
export { default as useFocusableIframe } from './hooks/use-focusable-iframe';
4850
export { default as __experimentalUseFixedWindowList } from './hooks/use-fixed-window-list';
51+
export { default as useObservableValue } from './hooks/use-observable-value';

packages/compose/src/index.native.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export * from './utils/create-higher-order-component';
44
export * from './utils/debounce';
55
// The `throttle` helper and its types.
66
export * from './utils/throttle';
7+
// The `ObservableMap` data structure
8+
export * from './utils/observable-map';
79

810
// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
911
export { default as compose } from './higher-order/compose';
@@ -39,3 +41,4 @@ export { default as useThrottle } from './hooks/use-throttle';
3941
export { default as useMergeRefs } from './hooks/use-merge-refs';
4042
export { default as useRefEffect } from './hooks/use-ref-effect';
4143
export { default as useNetworkConnectivity } from './hooks/use-network-connectivity';
44+
export { default as useObservableValue } from './hooks/use-observable-value';

0 commit comments

Comments
 (0)