Skip to content

Commit

Permalink
Add useEvent and useObserveElementSize to @wordpress/compose (#…
Browse files Browse the repository at this point in the history
…64943)

* Simplify useResizeObserver

* Loop through all resize entries

* Add `useEvent` util.

* Add `useObserveElementSize` util.

* Simplify `useResizeObserver` by using `useEvent` and `useObserveElementSize`.

* Switch to layout effect and accept refs too.

* Prevent initial re-render in ResizeElement.

* Better error message.

* Improved example of useEvent.

* Update packages/compose/src/hooks/use-event/index.ts

Co-authored-by: Marin Atanasov <8436925+tyxla@users.noreply.github.com>

* Sync docs.

* Avoid redundant resize listener calls.

* Switch to structural check.

* Improve example.

* Fix docs.

* Make `useObserveElementSize` generic.

* New API that returns a ref.

* Make utility private for now.

* Mark legacy `useResizeObserver` as such.

* Rename `useObserveElementSize` to `useResizeObserver`.

* Add return type.

* Add signature as overload.

* Add support for legacy API.

* Move into subdirectory.

* Minor import fix.

* Fix docgen to support overloads (will pick up the first function signature).

* Replace legacy utility with the new one.

* Apply feedback.

* Clean up and document.

* Added changelog entries.

---------

Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: DaniGuardiola <daniguardiola@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
  • Loading branch information
6 people authored Sep 9, 2024
1 parent 312fa6e commit 537fb18
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 44 deletions.
9 changes: 7 additions & 2 deletions packages/compose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### New Features

- `useEvent`: a new utility that creates a stable callback function that has access to the latest state and can be used within event handlers and effect callbacks ([#64943](https://github.com/WordPress/gutenberg/pull/64943)).
- `useResizeObserver`: new and improved version of the utility (legacy API is still supported) ([#64943](https://github.com/WordPress/gutenberg/pull/64943)).

## 7.7.0 (2024-09-05)

## 7.6.0 (2024-08-21)
Expand Down Expand Up @@ -205,8 +210,8 @@

### Breaking Changes

- Drop support for Internet Explorer 11 ([#31110](https://github.com/WordPress/gutenberg/pull/31110)). Learn more at https://make.wordpress.org/core/2021/04/22/ie-11-support-phase-out-plan/.
- Increase the minimum Node.js version to v12 matching Long Term Support releases ([#31270](https://github.com/WordPress/gutenberg/pull/31270)). Learn more at https://nodejs.org/en/about/releases/.
- Drop support for Internet Explorer 11 ([#31110](https://github.com/WordPress/gutenberg/pull/31110)). Learn more at <https://make.wordpress.org/core/2021/04/22/ie-11-support-phase-out-plan/>.
- Increase the minimum Node.js version to v12 matching Long Term Support releases ([#31270](https://github.com/WordPress/gutenberg/pull/31270)). Learn more at <https://nodejs.org/en/about/releases/>.

## 3.25.0 (2021-03-17)

Expand Down
52 changes: 41 additions & 11 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,29 @@ _Returns_

- `import('react').RefCallback<HTMLElement>`: Element Ref.

### useEvent

Creates a stable callback function that has access to the latest state and can be used within event handlers and effect callbacks. Throws when used in the render phase.

_Usage_

```tsx
function Component( props ) {
const onClick = useEvent( props.onClick );
useEffect( () => {
onClick();
// Won't trigger the effect again when props.onClick is updated.
}, [ onClick ] );
// Won't re-render Button when props.onClick is updated (if `Button` is
// wrapped in `React.memo`).
return <Button onClick={ onClick } />;
}
```

_Parameters_

- _callback_ `T`: The callback function to wrap.

### useFocusableIframe

Dispatches a bubbling focus event when the iframe receives focus. Use `onFocus` as usual on the iframe or a parent element.
Expand Down Expand Up @@ -500,23 +523,30 @@ _Returns_

### useResizeObserver

Hook which allows to listen to the resize event of any target element when it changes size. \_Note: `useResizeObserver` will report `null` sizes until after first render.
Sets up a [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API) for an HTML or SVG element.

Pass the returned setter as a callback ref to the React element you want to observe, or use it in layout effects for advanced use cases.

_Usage_

```js
const App = () => {
const [ resizeListener, sizes ] = useResizeObserver();
```tsx
const setElement = useResizeObserver(
( resizeObserverEntries ) => console.log( resizeObserverEntries ),
{ box: 'border-box' }
);
<div ref={ setElement } />;

return (
<div>
{ resizeListener }
Your content here
</div>
);
};
// The setter can be used in other ways, for example:
useLayoutEffect( () => {
setElement( document.querySelector( `data-element-id="${ elementId }"` ) );
}, [ elementId ] );
```

_Parameters_

- _callback_ `ResizeObserverCallback`: The `ResizeObserver` callback - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver#callback).
- _options_ `ResizeObserverOptions`: Options passed to `ResizeObserver.observe` when called - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#options). Changes will be ignored.

### useStateWithHistory

useState with undo/redo history.
Expand Down
51 changes: 51 additions & 0 deletions packages/compose/src/hooks/use-event/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* WordPress dependencies
*/
import { useRef, useInsertionEffect, useCallback } from '@wordpress/element';

/**
* Any function.
*/
export type AnyFunction = ( ...args: any ) => any;

/**
* Creates a stable callback function that has access to the latest state and
* can be used within event handlers and effect callbacks. Throws when used in
* the render phase.
*
* @param callback The callback function to wrap.
*
* @example
*
* ```tsx
* function Component( props ) {
* const onClick = useEvent( props.onClick );
* useEffect( () => {
* onClick();
* // Won't trigger the effect again when props.onClick is updated.
* }, [ onClick ] );
* // Won't re-render Button when props.onClick is updated (if `Button` is
* // wrapped in `React.memo`).
* return <Button onClick={ onClick } />;
* }
* ```
*/
export default function useEvent< T extends AnyFunction >(
/**
* The callback function to wrap.
*/
callback?: T
) {
const ref = useRef< AnyFunction | undefined >( () => {
throw new Error(
'Callbacks created with `useEvent` cannot be called during rendering.'
);
} );
useInsertionEffect( () => {
ref.current = callback;
} );
return useCallback< AnyFunction >(
( ...args ) => ref.current?.( ...args ),
[]
) as T;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ import type { ReactElement } from 'react';
/**
* WordPress dependencies
*/
import {
useCallback,
useLayoutEffect,
useRef,
useState,
} from '@wordpress/element';
import { useCallback, useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useResizeObserver from '../index';

type ObservedSize = {
export type ObservedSize = {
width: number | null;
height: number | null;
};
Expand Down Expand Up @@ -84,28 +83,10 @@ type ResizeElementProps = {
};

function ResizeElement( { onResize }: ResizeElementProps ) {
const resizeElementRef = useRef< HTMLDivElement >( null );
const resizeCallbackRef = useRef( onResize );

useLayoutEffect( () => {
resizeCallbackRef.current = onResize;
}, [ onResize ] );

useLayoutEffect( () => {
const resizeElement = resizeElementRef.current as HTMLDivElement;
const resizeObserver = new ResizeObserver( ( entries ) => {
for ( const entry of entries ) {
const newSize = extractSize( entry );
resizeCallbackRef.current( newSize );
}
} );

resizeObserver.observe( resizeElement );

return () => {
resizeObserver.unobserve( resizeElement );
};
}, [] );
const resizeElementRef = useResizeObserver( ( entries ) => {
const newSize = extractSize( entries.at( -1 )! ); // Entries are never empty.
onResize( newSize );
} );

return (
<div
Expand Down Expand Up @@ -141,7 +122,10 @@ const NULL_SIZE: ObservedSize = { width: null, height: null };
* };
* ```
*/
export default function useResizeObserver(): [ ReactElement, ObservedSize ] {
export default function useLegacyResizeObserver(): [
ReactElement,
ObservedSize,
] {
const [ size, setSize ] = useState( NULL_SIZE );

// Using a ref to track the previous width / height to avoid unnecessary renders.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { View } from 'react-native';
/**
* Internal dependencies
*/
import useResizeObserver from '../';
import useResizeObserver from '..';

const TestComponent = ( { onLayout } ) => {
const [ resizeObserver, sizes ] = useResizeObserver();
Expand Down
119 changes: 119 additions & 0 deletions packages/compose/src/hooks/use-resize-observer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import useEvent from '../use-event';
import type { ObservedSize } from './_legacy';
import _useLegacyResizeObserver from './_legacy';
/**
* External dependencies
*/
import type { ReactElement } from 'react';

// This is the current implementation of `useResizeObserver`.
//
// The legacy implementation is still supported for backwards compatibility.
// This is achieved by overloading the exported function with both signatures,
// and detecting which API is being used at runtime.
function _useResizeObserver< T extends HTMLElement >(
callback: ResizeObserverCallback,
resizeObserverOptions: ResizeObserverOptions = {}
): ( element?: T | null ) => void {
const callbackEvent = useEvent( callback );

const observedElementRef = useRef< T | null >();
const resizeObserverRef = useRef< ResizeObserver >();
return useEvent( ( element?: T | null ) => {
if ( element === observedElementRef.current ) {
return;
}
observedElementRef.current = element;

// Set up `ResizeObserver`.
resizeObserverRef.current ??= new ResizeObserver( callbackEvent );
const { current: resizeObserver } = resizeObserverRef;

// Unobserve previous element.
if ( observedElementRef.current ) {
resizeObserver.unobserve( observedElementRef.current );
}

// Observe new element.
if ( element ) {
resizeObserver.observe( element, resizeObserverOptions );
}
} );
}

/**
* Sets up a [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API)
* for an HTML or SVG element.
*
* Pass the returned setter as a callback ref to the React element you want
* to observe, or use it in layout effects for advanced use cases.
*
* @example
*
* ```tsx
* const setElement = useResizeObserver(
* ( resizeObserverEntries ) => console.log( resizeObserverEntries ),
* { box: 'border-box' }
* );
* <div ref={ setElement } />;
*
* // The setter can be used in other ways, for example:
* useLayoutEffect( () => {
* setElement( document.querySelector( `data-element-id="${ elementId }"` ) );
* }, [ elementId ] );
* ```
*
* @param callback The `ResizeObserver` callback - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver#callback).
* @param options Options passed to `ResizeObserver.observe` when called - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#options). Changes will be ignored.
*/
export default function useResizeObserver< T extends Element >(
/**
* The `ResizeObserver` callback - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver#callback).
*/
callback: ResizeObserverCallback,
/**
* Options passed to `ResizeObserver.observe` when called - [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#options). Changes will be ignored.
*/
options?: ResizeObserverOptions
): ( element?: T | null ) => void;

/**
* **This is a legacy API and should not be used.**
*
* @deprecated Use the other `useResizeObserver` API instead: `const ref = useResizeObserver( ( entries ) => { ... } )`.
*
* Hook which allows to listen to the resize event of any target element when it changes size.
* _Note: `useResizeObserver` will report `null` sizes until after first render.
*
* @example
*
* ```js
* const App = () => {
* const [ resizeListener, sizes ] = useResizeObserver();
*
* return (
* <div>
* { resizeListener }
* Your content here
* </div>
* );
* };
* ```
*/
export default function useResizeObserver(): [ ReactElement, ObservedSize ];

export default function useResizeObserver< T extends HTMLElement >(
callback?: ResizeObserverCallback,
options: ResizeObserverOptions = {}
): ( ( element?: T | null ) => void ) | [ ReactElement, ObservedSize ] {
return callback
? _useResizeObserver( callback, options )
: _useLegacyResizeObserver();
}
1 change: 1 addition & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { default as useCopyOnClick } from './hooks/use-copy-on-click';
export { default as useCopyToClipboard } from './hooks/use-copy-to-clipboard';
export { default as __experimentalUseDialog } from './hooks/use-dialog';
export { default as useDisabled } from './hooks/use-disabled';
export { default as useEvent } from './hooks/use-event';
export { default as __experimentalUseDragging } from './hooks/use-dragging';
export { default as useFocusOnMount } from './hooks/use-focus-on-mount';
export { default as __experimentalUseFocusOutside } from './hooks/use-focus-outside';
Expand Down
4 changes: 4 additions & 0 deletions packages/docgen/lib/get-type-annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ function getTypeAnnotation( typeAnnotation ) {
* TODO: Remove the special-casing here once we're able to infer the types from TypeScript itself.
*/
function unwrapWrappedSelectors( token ) {
if ( babelTypes.isTSDeclareFunction( token ) ) {
return token;
}

if ( babelTypes.isFunctionDeclaration( token ) ) {
return token;
}
Expand Down

0 comments on commit 537fb18

Please sign in to comment.