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

New hook: useInstanceId #19071

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 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
7 changes: 3 additions & 4 deletions packages/components/src/checkbox-control/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/**
* WordPress dependencies
*/
import { withInstanceId } from '@wordpress/compose';
import { useInstanceId } from '@wordpress/compose';

/**
* Internal dependencies
*/
import BaseControl from '../base-control';
import Dashicon from '../dashicon';

function CheckboxControl( { label, className, heading, checked, help, instanceId, onChange, ...props } ) {
export default function CheckboxControl( { label, className, heading, checked, help, onChange, ...props } ) {
const instanceId = useInstanceId();
const id = `inspector-checkbox-control-${ instanceId }`;
const onChangeValue = ( event ) => onChange( event.target.checked );

Expand All @@ -34,5 +35,3 @@ function CheckboxControl( { label, className, heading, checked, help, instanceId
</BaseControl>
);
}

export default withInstanceId( CheckboxControl );
4 changes: 4 additions & 0 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ _Returns_

- `WPComponent`: Component class with generated display name assigned.

<a name="useInstanceId" href="#useInstanceId">#</a> **useInstanceId**

Provides a unique instance ID.

<a name="useMediaQuery" href="#useMediaQuery">#</a> **useMediaQuery**

Runs a media query and returns its value when it changes.
Expand Down
48 changes: 48 additions & 0 deletions packages/compose/src/hooks/use-instance-id/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* WordPress dependencies
*/
import { useMemo, useEffect } from '@wordpress/element';

/**
* Array to keep track of allocated ids.
*/
const allocatedIds = [];

/**
* Find an unallocated id.
*/
function findId() {
let id = allocatedIds.findIndex( ( allocated ) => ! allocated );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly less performant than just increment the id, any concern about that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not concerned as this only executes when a component mounts. Incrementing ids is simple, but it has no end. The highest number possible here is the amount of mounted components per page.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withInstanceId has counters for each component. I don't think we can do that with hooks. The counter would be per page load. So the number could potentially reach much higher than before. That's why I decided to reuse ids for unmounted components.

Copy link
Contributor

@epiqueras epiqueras Dec 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a clean up stack:

let nextId = 0;
const freedIds = [];
function findId() {
	if ( freedIds.length ) {
		return freedIds.pop();
	}
	return nextId++;
}
function freeId( id ) {
	freedIds.push( id );
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That works too. Either you have a pool of allocated ids or a pool of free ids.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point of the stack is to avoid the linear lookup.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I’ll adjust it. Thanks!


if ( id === -1 ) {
id = allocatedIds.length;
}

// Allocated the id.
allocatedIds[ id ] = true;

return id;
}

/**
* Free an allocated id.
*
* @param {number} id Id to free.
*/
function freeId( id ) {
delete allocatedIds[ id ];
}

/**
* Provides a unique instance ID.
*/
export default function useInstanceId() {
// Take advantage of useMemo to get the same id throughout the life of a
// component.
const id = useMemo( findId, [] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like this might be a use-case for useRef instead?

The returned object will persist for the full lifetime of the component.

It’s handy for keeping any mutable value around similar to how you’d use instance fields in classes.

https://reactjs.org/docs/hooks-reference.html#useref

i.e.

const id = useRef( findId() );
// ...
return id.current;

Unclear whether it would also make it okay to avoid passing id as the dependency of useEffect when used this way, if we have a stronger guarantee that the value won't change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id itself would never change. id.current would. But we should still pass id, to align with the planned linting rules.

Copy link
Member Author

@ellatrix ellatrix Dec 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s ok if the id changes. With useRef, wouldn’t findId be called on every render?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right, didn't pay attention to that part 😬

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With useRef, wouldn’t findId be called on every render?

Yes, you're right 👍 At a glance, I don't see a way around that, so maybe useMemo is the way to go.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, using useMemo for this is not recommended, because it's not always respected:

https://reactjs.org/docs/hooks-faq.html?no-cache=1#how-to-create-expensive-objects-lazily

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, but as I said before, it's fine if it's not respected. Then a now id is given and the previous id is freed up. It doesn't do any harm, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, doesn't do much unless it happens often.

It could be a problem with async mode.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's see. If need be, we can create an alternative to useMemo that never gets called again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, a useStrictMemo hook 😆

// Free up the id when the comonent unmounts. This must depend on `id` since
// useMemo is not guaranteed to return the same id throughout the life of
// the component.
useEffect( () => () => freeId( id ), [ id ] );
return id;
}
54 changes: 54 additions & 0 deletions packages/compose/src/hooks/use-instance-id/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { create, act } from 'react-test-renderer';

/**
* Internal dependencies
*/
import useInstanceId from '../';

describe( 'useInstanceId', () => {
const TestComponent = () => {
return useInstanceId();
};

it( 'should manage ids', async () => {
let test0;

await act( async () => {
test0 = create( <TestComponent /> );
} );

expect( test0.toJSON() ).toBe( '0' );

let test1;

await act( async () => {
test1 = create( <TestComponent /> );
} );

expect( test1.toJSON() ).toBe( '1' );

test0.unmount();

let test2;

await act( async () => {
test2 = create( <TestComponent /> );
} );

expect( test2.toJSON() ).toBe( '0' );

let test3;

await act( async () => {
test3 = create( <TestComponent /> );
} );

expect( test3.toJSON() ).toBe( '2' );

test1.unmount();
test2.unmount();
} );
} );
1 change: 1 addition & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export { default as withState } from './higher-order/with-state';
export { default as useMediaQuery } from './hooks/use-media-query';
export { default as useReducedMotion } from './hooks/use-reduced-motion';
export { default as useViewportMatch } from './hooks/use-viewport-match';
export { default as useInstanceId } from './hooks/use-instance-id';