Skip to content

Commit

Permalink
[utils] Use built-in hook when available for useId (mui#26489)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored Nov 23, 2021
1 parent 1dfd5a8 commit 3323b23
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 42 deletions.
5 changes: 0 additions & 5 deletions packages/mui-material/src/Autocomplete/Autocomplete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1384,11 +1384,6 @@ describe('<Autocomplete />', () => {
}).toWarnDev([
'returns duplicated headers',
!strictModeDoubleLoggingSupressed && 'returns duplicated headers',
// React 18 Strict Effects run mount effects twice which lead to a cascading update
React.version.startsWith('18') && 'returns duplicated headers',
React.version.startsWith('18') &&
!strictModeDoubleLoggingSupressed &&
'returns duplicated headers',
]);
const options = screen.getAllByRole('option').map((el) => el.textContent);
expect(options).to.have.length(7);
Expand Down
49 changes: 31 additions & 18 deletions packages/mui-material/src/RadioGroup/RadioGroup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ describe('<RadioGroup />', () => {
</RadioGroup>,
);

const radios = getAllByRole('radio');

expect(radios[0].name).to.match(/^mui-[0-9]+/);
expect(radios[1].name).to.match(/^mui-[0-9]+/);
const [arbitraryRadio, ...radios] = getAllByRole('radio');
// `name` **property** will always be a string even if the **attribute** is omitted
expect(arbitraryRadio.name).not.to.equal('');
// all input[type="radio"] have the same name
expect(new Set(radios.map((radio) => radio.name))).to.have.length(1);
});

it('should support number value', () => {
Expand Down Expand Up @@ -299,21 +300,20 @@ describe('<RadioGroup />', () => {
});

describe('useRadioGroup', () => {
const RadioGroupController = React.forwardRef((_, ref) => {
const radioGroup = useRadioGroup();
React.useImperativeHandle(ref, () => radioGroup, [radioGroup]);
return null;
});
describe('from props', () => {
const MinimalRadio = React.forwardRef(function MinimalRadio(_, ref) {
const radioGroup = useRadioGroup();
return <input {...radioGroup} ref={ref} type="radio" />;
});

const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
return (
<RadioGroup {...props}>
<RadioGroupController ref={ref} />
</RadioGroup>
);
});
const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
return (
<RadioGroup {...props}>
<MinimalRadio ref={ref} />
</RadioGroup>
);
});

describe('from props', () => {
it('should have the name prop from the instance', () => {
const radioGroupRef = React.createRef();
const { setProps } = render(<RadioGroupControlled name="group" ref={radioGroupRef} />);
Expand All @@ -338,14 +338,27 @@ describe('<RadioGroup />', () => {
const radioGroupRef = React.createRef();
const { setProps } = render(<RadioGroupControlled ref={radioGroupRef} />);

expect(radioGroupRef.current.name).to.match(/^mui-[0-9]+/);
expect(radioGroupRef.current.name).not.to.equal('');

setProps({ name: 'anotherGroup' });
expect(radioGroupRef.current).to.have.property('name', 'anotherGroup');
});
});

describe('callbacks', () => {
const RadioGroupController = React.forwardRef((_, ref) => {
const radioGroup = useRadioGroup();
React.useImperativeHandle(ref, () => radioGroup, [radioGroup]);
return null;
});

const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
return (
<RadioGroup {...props}>
<RadioGroupController ref={ref} />
</RadioGroup>
);
});
describe('onChange', () => {
it('should set the value state', () => {
const radioGroupRef = React.createRef();
Expand Down
97 changes: 79 additions & 18 deletions packages/mui-utils/src/useId.test.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,97 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { expect } from 'chai';
import { createRenderer } from 'test/utils';
import { createRenderer, screen } from 'test/utils';
import useId from './useId';

const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span>{id}</span>;
};

TestComponent.propTypes = {
id: PropTypes.string,
};

describe('useId', () => {
const { render } = createRenderer();
const { render, renderToString } = createRenderer();

it('returns the provided ID', () => {
const { getByText, setProps } = render(<TestComponent id="some-id" />);
const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span data-testid="target" id={id} />;
};
const { hydrate } = renderToString(<TestComponent id="some-id" />);
const { setProps } = hydrate();

expect(getByText('some-id')).not.to.equal(null);
expect(screen.getByTestId('target')).to.have.property('id', 'some-id');

setProps({ id: 'another-id' });
expect(getByText('another-id')).not.to.equal(null);

expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
});

it("generates an ID if one isn't provided", () => {
const { getByText, setProps } = render(<TestComponent />);
const TestComponent = ({ id: idProp }) => {
const id = useId(idProp);
return <span data-testid="target" id={id} />;
};
const { hydrate } = renderToString(<TestComponent />);
const { setProps } = hydrate();

expect(getByText(/^mui-[0-9]+$/)).not.to.equal(null);
expect(screen.getByTestId('target').id).not.to.equal('');

setProps({ id: 'another-id' });
expect(getByText('another-id')).not.to.equal(null);
expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
});

it('can be suffixed', () => {
function Widget() {
const id = useId();
const labelId = `${id}-label`;

return (
<React.Fragment>
<span data-testid="labelable" aria-labelledby={labelId} />
<span data-testid="label" id={labelId}>
Label
</span>
</React.Fragment>
);
}
render(<Widget />);

expect(screen.getByTestId('labelable')).to.have.attr(
'aria-labelledby',
screen.getByTestId('label').id,
);
});

it('can be used in in IDREF attributes', () => {
function Widget() {
const labelPartA = useId();
const labelPartB = useId();

return (
<React.Fragment>
<span data-testid="labelable" aria-labelledby={`${labelPartA} ${labelPartB}`} />
<span data-testid="labelA" id={labelPartA}>
A
</span>
<span data-testid="labelB" id={labelPartB}>
B
</span>
</React.Fragment>
);
}
render(<Widget />);

expect(screen.getByTestId('labelable')).to.have.attr(
'aria-labelledby',
`${screen.getByTestId('labelA').id} ${screen.getByTestId('labelB').id}`,
);
});

it('provides an ID on server in React 18', function test() {
if (React.useId === undefined) {
this.skip();
}
const TestComponent = () => {
const id = useId();
return <span data-testid="target" id={id} />;
};
renderToString(<TestComponent />);

expect(screen.getByTestId('target').id).not.to.equal('');
});
});
18 changes: 17 additions & 1 deletion packages/mui-utils/src/useId.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';

export default function useId(idOverride?: string): string | undefined {
function useRandomId(idOverride?: string): string | undefined {
const [defaultId, setDefaultId] = React.useState(idOverride);
const id = idOverride || defaultId;
React.useEffect(() => {
Expand All @@ -13,3 +13,19 @@ export default function useId(idOverride?: string): string | undefined {
}, [defaultId]);
return id;
}

/**
*
* @example <div id={useId()} />
* @param idOverride
* @returns {string}
*/
export default function useReactId(idOverride?: string): string | undefined {
// TODO: Remove `React as any` once `useId` is part of stable types.
if ((React as any).useId !== undefined) {
const reactId = (React as any).useId();
return idOverride ?? reactId;
}
// eslint-disable-next-line react-hooks/rules-of-hooks -- `React.useId` is invariant at runtime.
return useRandomId(idOverride);
}

0 comments on commit 3323b23

Please sign in to comment.