@wordpress/compose
Version:
WordPress higher-order components (HOCs).
704 lines (551 loc) • 19.4 kB
JavaScript
/**
* External dependencies
*/
import { render, screen, fireEvent } from '@testing-library/react';
/**
* WordPress dependencies
*/
import { useCallback, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import useMergeRefs from '../';
describe( 'useMergeRefs', () => {
// Setup
// =====
//
// A component with two merged ref callbacks. The second has a dependency,
// the first does not. We expect the one with the dependency to be called
// with null in the old function and the node in the new function. We don't
// expect the first ref callback to be called **unless** the node changes.
// There's a prop controlling the tag name, which can be used to trigger a
// node change. In that case we expect both ref callbacks to be called with
// null in the old function and the **new** node in the new function.
//
// The history of all functions is recorded. Note that a new function is
// created on every render, which will all be tracked. Some functions are
// never expected to be called on subsequent renders if no callback
// dependency updates!
function renderCallback( args ) {
renderCallback.history.push( args );
}
renderCallback.history = [];
function MergedRefs( {
count,
tagName: TagName = 'ul',
disable1,
disable2,
unused,
} ) {
function refCallback1( value ) {
refCallback1.history.push( value );
}
refCallback1.history = [];
function refCallback2( value ) {
refCallback2.history.push( value );
}
refCallback2.history = [];
renderCallback( [ refCallback1.history, refCallback2.history ] );
const ref1 = useCallback( refCallback1, [] );
const ref2 = useCallback( refCallback2, [ count ] );
const mergedRefs = useMergeRefs( [
! disable1 && ref1,
! disable2 && ref2,
] );
if ( unused ) {
return <TagName ref={ ref1 } />;
}
return <TagName ref={ mergedRefs } />;
}
afterEach( () => {
// Reset all history.
renderCallback.history = [];
} );
it( 'should work', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: both initial callback functions should be called with node.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs /> );
// Render 2: the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
[ [], [] ],
] );
unmount();
// Unmount: the initial callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
] );
} );
it( 'should work for node change', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// After a render with the original element and a second render with the
// new element, expect the initial callback functions to be called with
// the original element, then null, then the new element.
// Again, the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement ],
],
[ [], [] ],
] );
unmount();
// Unmount: the initial callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
] );
} );
it( 'should work with dependencies', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs count={ 2 } /> );
// After a second render with a dependency change, expect the initial
// callback function to be called with null and the new callback
// function to be called with the original node. Note that for callback
// one no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, null ] ],
[ [], [ originalElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [ originalElement, null ] ],
] );
} );
it( 'should simultaneously update node and dependencies', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs count={ 2 } tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// Both the node changes and the dependencies update for the second
// callback, so expect the old callback function to be called with null
// and the new callback function to be called with the **new** node.
// For the first callback, we expect the initial function to be called
// with null and then the new node since no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null ],
],
[ [], [ newElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null ],
],
[ [], [ newElement, null ] ],
] );
} );
it( 'should work for dependency change after node change', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// After a render with the original element and a second render with the
// new element, expect the initial callback functions to be called with
// the original element, then null, then the new element.
// Again, the new callback functions should not be called! There has
// been no dependency change.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement ],
],
[ [], [] ],
] );
rerender( <MergedRefs tagName="button" count={ 1 } /> );
// After a third render with a dependency change, expect the initial
// callback function to be called with null and the new callback
// function to be called with the new element. Note that for callback
// one no dependencies have changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
[ [], [ newElement ] ],
] );
unmount();
// Unmount: current callback functions should be called with null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null, newElement, null ],
[ originalElement, null, newElement, null ],
],
[ [], [] ],
[ [], [ newElement, null ] ],
] );
} );
it( 'should allow disabling a ref', () => {
const { rerender, unmount } = render( <MergedRefs disable1 /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: ref 1 should be disabled.
expect( renderCallback.history ).toEqual( [
[ [], [ originalElement ] ],
] );
rerender( <MergedRefs disable2 /> );
// Render 2: ref 1 should be enabled and receive the ref. Note that the
// callback hasn't changed, so the original callback function will be
// called. Ref 2 should be disabled, so called with null.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, null ] ],
[ [], [] ],
] );
rerender( <MergedRefs disable1 count={ 1 } /> );
// Render 3: ref 1 should again be disabled. Ref 2 to should receive a
// ref with the new callback function because the count has been
// changed.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
[ [], [ originalElement ] ],
] );
unmount();
// Unmount: current callback functions should receive null.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [] ],
[ [], [ originalElement, null ] ],
] );
} );
it( 'should allow the hook being unused', () => {
const { rerender } = render( <MergedRefs unused /> );
const originalElement = screen.getByRole( 'list' );
// Render 1: ref 1 should updated, ref 2 should not.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [] ],
] );
rerender( <MergedRefs /> );
// Render 2: ref 2 should be updated as well.
expect( renderCallback.history ).toEqual( [
[ [ originalElement, null, originalElement ], [ originalElement ] ],
[ [], [] ],
] );
rerender( <MergedRefs unused /> );
// Render 3: ref 2 should be updated with null
expect( renderCallback.history ).toEqual( [
[
[
originalElement,
null,
originalElement,
null,
originalElement,
],
[ originalElement, null ],
],
[ [], [] ],
[ [], [] ],
] );
} );
it( 'should work for dependency change after attachment outside a render of the owner', () => {
// The merged ref is created by a parent, but the element is mounted
// by a child in a commit of its own (e.g. the editor iframe portals
// the canvas body once the iframe document is available). The parent
// does not render in that commit, so the hook must still detect and
// apply a later ref change on the parent's next render.
function Child( { mergedRefs } ) {
const [ attached, setAttached ] = useState( false );
return (
<>
<button onClick={ () => setAttached( true ) } />
{ attached && <ul ref={ mergedRefs } /> }
</>
);
}
function Parent( { count } ) {
function refCallback1( value ) {
refCallback1.history.push( value );
}
refCallback1.history = [];
function refCallback2( value ) {
refCallback2.history.push( value );
}
refCallback2.history = [];
renderCallback( [ refCallback1.history, refCallback2.history ] );
const ref1 = useCallback( refCallback1, [] );
const ref2 = useCallback( refCallback2, [ count ] );
return <Child mergedRefs={ useMergeRefs( [ ref1, ref2 ] ) } />;
}
const { rerender, unmount } = render( <Parent count={ 1 } /> );
// Mount the element in a child-only commit: the parent does not
// render.
fireEvent.click( screen.getByRole( 'button' ) );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <Parent count={ 2 } /> );
// The dependency change must still swap the second ref: the initial
// function called with null, the new function with the attached node.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, null ] ],
[ [], [ originalElement ] ],
] );
unmount();
expect( renderCallback.history ).toEqual( [
[
[ originalElement, null ],
[ originalElement, null ],
],
[ [], [ originalElement, null ] ],
] );
} );
} );
describe( 'useMergeRefs with cleanup-returning ref callbacks', () => {
// Setup
// =====
//
// Same structure as the suite above, but the ref callbacks return a
// cleanup function. React 19 (and `useMergeRefs`) invokes the cleanup at
// teardown instead of calling the callback with `null`. The cleanup pushes
// the string `'cleanup'` into the same history array, so each call site's
// full lifecycle is captured as `[ node, 'cleanup', newNode, 'cleanup' ]`.
// The strict invariant is that `null` never appears in the history of a
// cleanup-returning ref.
function renderCallback( args ) {
renderCallback.history.push( args );
}
renderCallback.history = [];
function MergedRefs( {
count,
tagName: TagName = 'ul',
disable1,
disable2,
} ) {
function refCallback1( value ) {
refCallback1.history.push( value );
return () => {
refCallback1.history.push( 'cleanup' );
};
}
refCallback1.history = [];
function refCallback2( value ) {
refCallback2.history.push( value );
return () => {
refCallback2.history.push( 'cleanup' );
};
}
refCallback2.history = [];
renderCallback( [ refCallback1.history, refCallback2.history ] );
const ref1 = useCallback( refCallback1, [] );
const ref2 = useCallback( refCallback2, [ count ] );
const mergedRefs = useMergeRefs( [
! disable1 && ref1,
! disable2 && ref2,
] );
return <TagName ref={ mergedRefs } />;
}
afterEach( () => {
renderCallback.history = [];
} );
it( 'should invoke cleanup on unmount instead of calling ref with null', () => {
const { unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
unmount();
// Cleanups run; refs are NOT called with `null`.
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup' ],
[ originalElement, 'cleanup' ],
],
] );
} );
it( 'should invoke cleanup on node change instead of calling ref with null', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// Node change triggers the outer callback first with `null` (which
// runs the cleanups) and then with the new node (which sets up new
// cleanups).
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup', newElement ],
[ originalElement, 'cleanup', newElement ],
],
[ [], [] ],
] );
unmount();
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup', newElement, 'cleanup' ],
[ originalElement, 'cleanup', newElement, 'cleanup' ],
],
[ [], [] ],
] );
} );
it( 'should invoke cleanup on dependency change instead of calling ref with null', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement ] ],
] );
rerender( <MergedRefs count={ 2 } /> );
// Only ref2's deps changed. Its previous cleanup runs and the new ref
// callback is called with the still-attached node. ref1 is untouched.
expect( renderCallback.history ).toEqual( [
[ [ originalElement ], [ originalElement, 'cleanup' ] ],
[ [], [ originalElement ] ],
] );
unmount();
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup' ],
[ originalElement, 'cleanup' ],
],
[ [], [ originalElement, 'cleanup' ] ],
] );
} );
it( 'should invoke cleanup on simultaneous node and dependency change', () => {
const { rerender, unmount } = render( <MergedRefs count={ 1 } /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs count={ 2 } tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// Outer callback's null/new-node pair runs both cleanups and sets
// up the new ones (ref1 reused, ref2 has a new identity).
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup', newElement ],
[ originalElement, 'cleanup' ],
],
[ [], [ newElement ] ],
] );
unmount();
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup', newElement, 'cleanup' ],
[ originalElement, 'cleanup' ],
],
[ [], [ newElement, 'cleanup' ] ],
] );
} );
it( 'should invoke cleanup when a ref becomes disabled', () => {
const { rerender, unmount } = render( <MergedRefs /> );
const originalElement = screen.getByRole( 'list' );
rerender( <MergedRefs disable1 /> );
// ref1 is disabled: its stored cleanup must fire. ref2 is unchanged.
expect( renderCallback.history ).toEqual( [
[ [ originalElement, 'cleanup' ], [ originalElement ] ],
[ [], [] ],
] );
unmount();
expect( renderCallback.history ).toEqual( [
[
[ originalElement, 'cleanup' ],
[ originalElement, 'cleanup' ],
],
[ [], [] ],
] );
} );
it( 'should support mixing a cleanup-returning ref with a void-returning ref', () => {
function MergedRefsMixed() {
function refCleanup( value ) {
refCleanup.history.push( value );
return () => {
refCleanup.history.push( 'cleanup' );
};
}
refCleanup.history = [];
function refVoid( value ) {
refVoid.history.push( value );
}
refVoid.history = [];
renderCallback( [ refCleanup.history, refVoid.history ] );
const r1 = useCallback( refCleanup, [] );
const r2 = useCallback( refVoid, [] );
const merged = useMergeRefs( [ r1, r2 ] );
return <ul ref={ merged } />;
}
const { unmount } = render( <MergedRefsMixed /> );
const el = screen.getByRole( 'list' );
expect( renderCallback.history ).toEqual( [ [ [ el ], [ el ] ] ] );
unmount();
// Cleanup ref sees the cleanup; void ref still sees null. Independent.
expect( renderCallback.history ).toEqual( [
[
[ el, 'cleanup' ],
[ el, null ],
],
] );
} );
it( 'should support mixing an object ref with a cleanup-returning ref', () => {
const objectRef = { current: null };
function MergedRefsMixed( { tagName: TagName = 'ul' } ) {
function refCleanup( value ) {
refCleanup.history.push( value );
return () => {
refCleanup.history.push( 'cleanup' );
};
}
refCleanup.history = [];
renderCallback( [ refCleanup.history ] );
const r = useCallback( refCleanup, [] );
const merged = useMergeRefs( [ objectRef, r ] );
return <TagName ref={ merged } />;
}
const { rerender, unmount } = render( <MergedRefsMixed /> );
const originalElement = screen.getByRole( 'list' );
// Object ref's .current is set; cleanup ref is called with the node.
expect( objectRef.current ).toBe( originalElement );
expect( renderCallback.history ).toEqual( [ [ [ originalElement ] ] ] );
rerender( <MergedRefsMixed tagName="button" /> );
const newElement = screen.getByRole( 'button' );
// Node change: object ref retargets, callback ref's cleanup fires
// and the callback is re-invoked with the new node.
expect( objectRef.current ).toBe( newElement );
expect( renderCallback.history ).toEqual( [
[ [ originalElement, 'cleanup', newElement ] ],
[ [] ],
] );
unmount();
// Object ref nulled via the fallback assignRef path; callback ref
// sees its cleanup, never null.
expect( objectRef.current ).toBe( null );
expect( renderCallback.history ).toEqual( [
[ [ originalElement, 'cleanup', newElement, 'cleanup' ] ],
[ [] ],
] );
} );
} );