Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useBlockRefs: use more efficient lookup map, use uSES #60945

Merged
merged 4 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
/**
* WordPress dependencies
*/
import {
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import { useContext, useMemo, useRef } from '@wordpress/element';
import { useRefEffect, useObservableValue } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -26,60 +20,40 @@ import { BlockRefs } from '../../provider/block-refs-provider';
* @return {RefCallback} Ref callback.
*/
export function useBlockRefProvider( clientId ) {
const { refs, callbacks } = useContext( BlockRefs );
const ref = useRef();
useLayoutEffect( () => {
refs.set( ref, clientId );
return () => {
refs.delete( ref );
};
}, [ clientId ] );
const { refsMap } = useContext( BlockRefs );
return useRefEffect(
( element ) => {
// Update the ref in the provider.
ref.current = element;
// Call any update functions.
callbacks.forEach( ( id, setElement ) => {
if ( clientId === id ) {
setElement( element );
}
} );
ellatrix marked this conversation as resolved.
Show resolved Hide resolved
refsMap.set( clientId, element );
return () => refsMap.delete( clientId );
},
[ clientId ]
);
}

/**
* Gets a ref pointing to the current block element. Continues to return a
* stable ref even if the block client ID changes.
* Gets a ref pointing to the current block element. Continues to return the same
* stable ref object even if the `clientId` argument changes. This hook is not
* reactive, i.e., it won't trigger a rerender of the calling component if the
* ref value changes. For reactive use cases there is the `useBlockElement` hook.
*
* @param {string} clientId The client ID to get a ref for.
*
* @return {RefObject} A ref containing the element.
*/
function useBlockRef( clientId ) {
const { refs } = useContext( BlockRefs );
const freshClientId = useRef();
freshClientId.current = clientId;
const { refsMap } = useContext( BlockRefs );
const latestClientId = useRef();
latestClientId.current = clientId;

// Always return an object, even if no ref exists for a given client ID, so
// that `current` works at a later point.
return useMemo(
() => ( {
get current() {
let element = null;

// Multiple refs may be created for a single block. Find the
// first that has an element set.
for ( const [ ref, id ] of refs.entries() ) {
if ( id === freshClientId.current && ref.current ) {
element = ref.current;
}
}

return element;
return refsMap.get( latestClientId.current ) ?? null;
},
} ),
[]
[ refsMap ]
);
}

Expand All @@ -92,22 +66,8 @@ function useBlockRef( clientId ) {
* @return {Element|null} The block's wrapper element.
*/
function useBlockElement( clientId ) {
const { callbacks } = useContext( BlockRefs );
const ref = useBlockRef( clientId );
const [ element, setElement ] = useState( null );

useLayoutEffect( () => {
if ( ! clientId ) {
return;
}

callbacks.set( setElement, clientId );
return () => {
callbacks.delete( setElement );
};
}, [ clientId ] );

return ref.current || element;
const { refsMap } = useContext( BlockRefs );
return useObservableValue( refsMap, clientId ) ?? null;
}

export { useBlockRef as __unstableUseBlockRef };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,12 @@
* WordPress dependencies
*/
import { createContext, useMemo } from '@wordpress/element';
import { observableMap } from '@wordpress/compose';

export const BlockRefs = createContext( {
refs: new Map(),
callbacks: new Map(),
} );
export const BlockRefs = createContext( { refsMap: observableMap() } );

export function BlockRefsProvider( { children } ) {
const value = useMemo(
() => ( { refs: new Map(), callbacks: new Map() } ),
[]
);
const value = useMemo( () => ( { refsMap: observableMap() } ), [] );
return (
<BlockRefs.Provider value={ value }>{ children }</BlockRefs.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
*/
import { createContext } from '@wordpress/element';
import warning from '@wordpress/warning';
import { observableMap } from '@wordpress/compose';

/**
* Internal dependencies
*/
import type { SlotFillBubblesVirtuallyContext } from '../types';
import { observableMap } from './observable-map';

const initialContextValue: SlotFillBubblesVirtuallyContext = {
slots: observableMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { useMemo } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { observableMap } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -12,7 +13,6 @@ import type {
SlotFillProviderProps,
SlotFillBubblesVirtuallyContext,
} from '../types';
import { observableMap } from './observable-map';

function createSlotRegistry(): SlotFillBubblesVirtuallyContext {
const slots: SlotFillBubblesVirtuallyContext[ 'slots' ] = observableMap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* WordPress dependencies
*/
import { useContext } from '@wordpress/element';
import { useObservableValue } from '@wordpress/compose';

/**
* Internal dependencies
*/
import SlotFillContext from './slot-fill-context';
import type { SlotKey } from '../types';
import { useObservableValue } from './observable-map';

export default function useSlotFills( name: SlotKey ) {
const registry = useContext( SlotFillContext );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* WordPress dependencies
*/
import { useMemo, useContext } from '@wordpress/element';
import { useObservableValue } from '@wordpress/compose';

/**
* Internal dependencies
Expand All @@ -13,7 +14,6 @@ import type {
FillProps,
SlotKey,
} from '../types';
import { useObservableValue } from './observable-map';

export default function useSlot( name: SlotKey ) {
const registry = useContext( SlotFillContext );
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/slot-fill/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
import type { Component, MutableRefObject, ReactNode, RefObject } from 'react';

/**
* Internal dependencies
* WordPress dependencies
*/
import type { ObservableMap } from './bubbles-virtually/observable-map';
import type { ObservableMap } from '@wordpress/compose';

export type DistributiveOmit< T, K extends keyof any > = T extends any
? Omit< T, K >
Expand Down
2 changes: 2 additions & 0 deletions packages/compose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added new `observableMap` data structure and `useObservableValue` React hook ([#60945](https://github.com/WordPress/gutenberg/pull/60945)).

## 6.33.0 (2024-04-19)

## 6.32.0 (2024-04-03)
Expand Down
21 changes: 21 additions & 0 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ _Returns_

- Higher-order component.

### observableMap

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`.

_Returns_

- `ObservableMap< K, V >`: A new instance of the `ObservableMap` type.

### pipe

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.
Expand Down Expand Up @@ -442,6 +450,19 @@ _Returns_

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

### useObservableValue

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.

_Parameters_

- _map_ `ObservableMap< K, V >`: The `ObservableMap` to observe.
- _name_ `K`: The map key to observe.

_Returns_

- `V | undefined`: The value corresponding to the map key requested.

### usePrevious

Use something's value from the previous render. Based on <https://usehooks.com/usePrevious/>.
Expand Down
35 changes: 35 additions & 0 deletions packages/compose/src/hooks/use-observable-value/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* WordPress dependencies
*/
import { useMemo, useSyncExternalStore } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { ObservableMap } from '../../utils/observable-map';

/**
* 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.
*
* @template K The type of the keys in the map.
* @template V The type of the values in the map.
* @param map The `ObservableMap` to observe.
* @param name The map key to observe.
* @return The value corresponding to the map key requested.
*/
export default function useObservableValue< K, V >(
map: ObservableMap< K, V >,
name: K
): V | undefined {
const [ subscribe, getValue ] = useMemo(
() => [
( listener: () => void ) => map.subscribe( name, listener ),
() => map.get( name ),
],
[ map, name ]
);
return useSyncExternalStore( subscribe, getValue, getValue );
}
42 changes: 42 additions & 0 deletions packages/compose/src/hooks/use-observable-value/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { render, screen, act } from '@testing-library/react';

/**
* Internal dependencies
*/
import { observableMap } from '../../../utils/observable-map';
import useObservableValue from '..';

describe( 'useObservableValue', () => {
test( 'reacts only to the specified key', () => {
const map = observableMap();
map.set( 'a', 1 );

const MapUI = jest.fn( () => {
const value = useObservableValue( map, 'a' );
return <div>value is { value }</div>;
} );

render( <MapUI /> );
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
'value is 1'
);
expect( MapUI ).toHaveBeenCalledTimes( 1 );

act( () => {
map.set( 'a', 2 );
} );
expect( screen.getByText( /^value is/ ) ).toHaveTextContent(
'value is 2'
);
expect( MapUI ).toHaveBeenCalledTimes( 2 );

// check that setting unobserved map key doesn't trigger a render at all
act( () => {
map.set( 'b', 1 );
} );
expect( MapUI ).toHaveBeenCalledTimes( 2 );
} );
} );
3 changes: 3 additions & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export * from './utils/create-higher-order-component';
export * from './utils/debounce';
// The `throttle` helper and its types.
export * from './utils/throttle';
// The `ObservableMap` data structure
export * from './utils/observable-map';

// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
export { default as compose } from './higher-order/compose';
Expand Down Expand Up @@ -46,3 +48,4 @@ export { default as useRefEffect } from './hooks/use-ref-effect';
export { default as __experimentalUseDropZone } from './hooks/use-drop-zone';
export { default as useFocusableIframe } from './hooks/use-focusable-iframe';
export { default as __experimentalUseFixedWindowList } from './hooks/use-fixed-window-list';
export { default as useObservableValue } from './hooks/use-observable-value';
3 changes: 3 additions & 0 deletions packages/compose/src/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export * from './utils/create-higher-order-component';
export * from './utils/debounce';
// The `throttle` helper and its types.
export * from './utils/throttle';
// The `ObservableMap` data structure
export * from './utils/observable-map';

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