Skip to content

Commit 3323b23

Browse files
authored
[utils] Use built-in hook when available for useId (#26489)
1 parent 1dfd5a8 commit 3323b23

File tree

4 files changed

+127
-42
lines changed

4 files changed

+127
-42
lines changed

packages/mui-material/src/Autocomplete/Autocomplete.test.js

-5
Original file line numberDiff line numberDiff line change
@@ -1384,11 +1384,6 @@ describe('<Autocomplete />', () => {
13841384
}).toWarnDev([
13851385
'returns duplicated headers',
13861386
!strictModeDoubleLoggingSupressed && 'returns duplicated headers',
1387-
// React 18 Strict Effects run mount effects twice which lead to a cascading update
1388-
React.version.startsWith('18') && 'returns duplicated headers',
1389-
React.version.startsWith('18') &&
1390-
!strictModeDoubleLoggingSupressed &&
1391-
'returns duplicated headers',
13921387
]);
13931388
const options = screen.getAllByRole('option').map((el) => el.textContent);
13941389
expect(options).to.have.length(7);

packages/mui-material/src/RadioGroup/RadioGroup.test.js

+31-18
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,11 @@ describe('<RadioGroup />', () => {
9191
</RadioGroup>,
9292
);
9393

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

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

301302
describe('useRadioGroup', () => {
302-
const RadioGroupController = React.forwardRef((_, ref) => {
303-
const radioGroup = useRadioGroup();
304-
React.useImperativeHandle(ref, () => radioGroup, [radioGroup]);
305-
return null;
306-
});
303+
describe('from props', () => {
304+
const MinimalRadio = React.forwardRef(function MinimalRadio(_, ref) {
305+
const radioGroup = useRadioGroup();
306+
return <input {...radioGroup} ref={ref} type="radio" />;
307+
});
307308

308-
const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
309-
return (
310-
<RadioGroup {...props}>
311-
<RadioGroupController ref={ref} />
312-
</RadioGroup>
313-
);
314-
});
309+
const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
310+
return (
311+
<RadioGroup {...props}>
312+
<MinimalRadio ref={ref} />
313+
</RadioGroup>
314+
);
315+
});
315316

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

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

343343
setProps({ name: 'anotherGroup' });
344344
expect(radioGroupRef.current).to.have.property('name', 'anotherGroup');
345345
});
346346
});
347347

348348
describe('callbacks', () => {
349+
const RadioGroupController = React.forwardRef((_, ref) => {
350+
const radioGroup = useRadioGroup();
351+
React.useImperativeHandle(ref, () => radioGroup, [radioGroup]);
352+
return null;
353+
});
354+
355+
const RadioGroupControlled = React.forwardRef(function RadioGroupControlled(props, ref) {
356+
return (
357+
<RadioGroup {...props}>
358+
<RadioGroupController ref={ref} />
359+
</RadioGroup>
360+
);
361+
});
349362
describe('onChange', () => {
350363
it('should set the value state', () => {
351364
const radioGroupRef = React.createRef();

packages/mui-utils/src/useId.test.js

+79-18
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,97 @@
11
import * as React from 'react';
2-
import PropTypes from 'prop-types';
32
import { expect } from 'chai';
4-
import { createRenderer } from 'test/utils';
3+
import { createRenderer, screen } from 'test/utils';
54
import useId from './useId';
65

7-
const TestComponent = ({ id: idProp }) => {
8-
const id = useId(idProp);
9-
return <span>{id}</span>;
10-
};
11-
12-
TestComponent.propTypes = {
13-
id: PropTypes.string,
14-
};
15-
166
describe('useId', () => {
17-
const { render } = createRenderer();
7+
const { render, renderToString } = createRenderer();
188

199
it('returns the provided ID', () => {
20-
const { getByText, setProps } = render(<TestComponent id="some-id" />);
10+
const TestComponent = ({ id: idProp }) => {
11+
const id = useId(idProp);
12+
return <span data-testid="target" id={id} />;
13+
};
14+
const { hydrate } = renderToString(<TestComponent id="some-id" />);
15+
const { setProps } = hydrate();
2116

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

2419
setProps({ id: 'another-id' });
25-
expect(getByText('another-id')).not.to.equal(null);
20+
21+
expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
2622
});
2723

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

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

3334
setProps({ id: 'another-id' });
34-
expect(getByText('another-id')).not.to.equal(null);
35+
expect(screen.getByTestId('target')).to.have.property('id', 'another-id');
36+
});
37+
38+
it('can be suffixed', () => {
39+
function Widget() {
40+
const id = useId();
41+
const labelId = `${id}-label`;
42+
43+
return (
44+
<React.Fragment>
45+
<span data-testid="labelable" aria-labelledby={labelId} />
46+
<span data-testid="label" id={labelId}>
47+
Label
48+
</span>
49+
</React.Fragment>
50+
);
51+
}
52+
render(<Widget />);
53+
54+
expect(screen.getByTestId('labelable')).to.have.attr(
55+
'aria-labelledby',
56+
screen.getByTestId('label').id,
57+
);
58+
});
59+
60+
it('can be used in in IDREF attributes', () => {
61+
function Widget() {
62+
const labelPartA = useId();
63+
const labelPartB = useId();
64+
65+
return (
66+
<React.Fragment>
67+
<span data-testid="labelable" aria-labelledby={`${labelPartA} ${labelPartB}`} />
68+
<span data-testid="labelA" id={labelPartA}>
69+
A
70+
</span>
71+
<span data-testid="labelB" id={labelPartB}>
72+
B
73+
</span>
74+
</React.Fragment>
75+
);
76+
}
77+
render(<Widget />);
78+
79+
expect(screen.getByTestId('labelable')).to.have.attr(
80+
'aria-labelledby',
81+
`${screen.getByTestId('labelA').id} ${screen.getByTestId('labelB').id}`,
82+
);
83+
});
84+
85+
it('provides an ID on server in React 18', function test() {
86+
if (React.useId === undefined) {
87+
this.skip();
88+
}
89+
const TestComponent = () => {
90+
const id = useId();
91+
return <span data-testid="target" id={id} />;
92+
};
93+
renderToString(<TestComponent />);
94+
95+
expect(screen.getByTestId('target').id).not.to.equal('');
3596
});
3697
});

packages/mui-utils/src/useId.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22

3-
export default function useId(idOverride?: string): string | undefined {
3+
function useRandomId(idOverride?: string): string | undefined {
44
const [defaultId, setDefaultId] = React.useState(idOverride);
55
const id = idOverride || defaultId;
66
React.useEffect(() => {
@@ -13,3 +13,19 @@ export default function useId(idOverride?: string): string | undefined {
1313
}, [defaultId]);
1414
return id;
1515
}
16+
17+
/**
18+
*
19+
* @example <div id={useId()} />
20+
* @param idOverride
21+
* @returns {string}
22+
*/
23+
export default function useReactId(idOverride?: string): string | undefined {
24+
// TODO: Remove `React as any` once `useId` is part of stable types.
25+
if ((React as any).useId !== undefined) {
26+
const reactId = (React as any).useId();
27+
return idOverride ?? reactId;
28+
}
29+
// eslint-disable-next-line react-hooks/rules-of-hooks -- `React.useId` is invariant at runtime.
30+
return useRandomId(idOverride);
31+
}

0 commit comments

Comments
 (0)