Skip to content

Commit a861ca4

Browse files
fix: infinite re-render on reoccuring ids (#7657)
* fix: infinite re-render on reoccuring ids * fix: useId effect cleanup * fix: react 19 compatibility * fix: typo * fix: jest anything matcher * fix: match optional anything * fix: react 16 fc & assertion * chore: remove nvmrc upgrade --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 9d70953 commit a861ca4

File tree

3 files changed

+66
-27
lines changed

3 files changed

+66
-27
lines changed

packages/@react-aria/utils/src/mergeProps.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
3434
* @param args - Multiple sets of props to merge together.
3535
*/
3636
export function mergeProps<T extends PropsArg[]>(...args: T): UnionToIntersection<TupleTypes<T>> {
37-
// Start with a base clone of the first argument. This is a lot faster than starting
37+
// Start with a base clone of the last argument. This is a lot faster than starting
3838
// with an empty object and adding properties as we go.
39-
let result: Props = {...args[0]};
40-
for (let i = 1; i < args.length; i++) {
39+
let result: Props = {...args[args.length - 1]};
40+
for (let i = args.length - 2; i >= 0; i--) {
4141
let props = args[i];
4242
for (let key in props) {
4343
let a = result[key];
@@ -53,20 +53,20 @@ export function mergeProps<T extends PropsArg[]>(...args: T): UnionToIntersectio
5353
key.charCodeAt(2) >= /* 'A' */ 65 &&
5454
key.charCodeAt(2) <= /* 'Z' */ 90
5555
) {
56-
result[key] = chain(a, b);
56+
result[key] = chain(b, a);
5757

5858
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
5959
} else if (
6060
(key === 'className' || key === 'UNSAFE_className') &&
6161
typeof a === 'string' &&
6262
typeof b === 'string'
6363
) {
64-
result[key] = clsx(a, b);
64+
result[key] = clsx(b, a);
6565
} else if (key === 'id' && a && b) {
66-
result.id = mergeIds(a, b);
66+
result.id = mergeIds(b, a);
6767
// Override others
68-
} else {
69-
result[key] = b !== undefined ? b : a;
68+
} else if (a === undefined) {
69+
result[key] = b;
7070
}
7171
}
7272
}

packages/@react-aria/utils/src/useId.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,15 @@ export function useId(defaultId?: string): string {
5454
};
5555
}, [res]);
5656

57-
// This cannot cause an infinite loop because the ref is updated first.
57+
// This cannot cause an infinite loop because the ref is always cleaned up.
5858
// eslint-disable-next-line
5959
useEffect(() => {
6060
let newId = nextId.current;
61-
if (newId) {
62-
nextId.current = null;
63-
setValue(newId);
64-
}
61+
if (newId) { setValue(newId); }
62+
63+
return () => {
64+
if (newId) { nextId.current = null; }
65+
};
6566
});
6667

6768
return res;

packages/@react-aria/utils/test/mergeProps.test.js packages/@react-aria/utils/test/mergeProps.test.jsx

+52-14
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@
1111
*/
1212

1313
import clsx from 'clsx';
14-
import {mergeIds} from '../src/useId';
15-
import {mergeProps} from '../';
16-
14+
import { mergeIds, useId } from '../src/useId';
15+
import { mergeProps } from '../src/mergeProps';
16+
import { render } from '@react-spectrum/test-utils-internal';
1717

1818
describe('mergeProps', function () {
1919
it('handles one argument', function () {
20-
let onClick = () => {};
20+
let onClick = () => { };
2121
let className = 'primary';
2222
let id = 'test_id';
23-
let mergedProps = mergeProps({onClick, className, id});
23+
let mergedProps = mergeProps({ onClick, className, id });
2424
expect(mergedProps.onClick).toBe(onClick);
2525
expect(mergedProps.className).toBe(className);
2626
expect(mergedProps.id).toBe(id);
@@ -32,9 +32,9 @@ describe('mergeProps', function () {
3232
let message2 = 'click2';
3333
let message3 = 'click3';
3434
let mergedProps = mergeProps(
35-
{onClick: () => mockFn(message1)},
36-
{onClick: () => mockFn(message2)},
37-
{onClick: () => mockFn(message3)}
35+
{ onClick: () => mockFn(message1) },
36+
{ onClick: () => mockFn(message2) },
37+
{ onClick: () => mockFn(message3) }
3838
);
3939
mergedProps.onClick();
4040
expect(mockFn).toHaveBeenNthCalledWith(1, message1);
@@ -51,14 +51,15 @@ describe('mergeProps', function () {
5151
let focus = 'focus';
5252
let margin = 2;
5353
const mergedProps = mergeProps(
54-
{onClick: () => mockFn(click1)},
55-
{onHover: () => mockFn(hover), styles: {margin}},
56-
{onClick: () => mockFn(click2), onFocus: () => mockFn(focus)}
54+
{ onClick: () => mockFn(click1) },
55+
{ onHover: () => mockFn(hover), styles: { margin } },
56+
{ onClick: () => mockFn(click2), onFocus: () => mockFn(focus) }
5757
);
58-
5958
mergedProps.onClick();
59+
let callOrder = mockFn.mock.invocationCallOrder;
6060
expect(mockFn).toHaveBeenNthCalledWith(1, click1);
6161
expect(mockFn).toHaveBeenNthCalledWith(2, click2);
62+
expect(callOrder[0]).toBeLessThan(callOrder[1]);
6263
mergedProps.onFocus();
6364
expect(mockFn).toHaveBeenNthCalledWith(3, focus);
6465
mergedProps.onHover();
@@ -71,7 +72,7 @@ describe('mergeProps', function () {
7172
let className1 = 'primary';
7273
let className2 = 'hover';
7374
let className3 = 'focus';
74-
let mergedProps = mergeProps({className: className1}, {className: className2}, {className: className3});
75+
let mergedProps = mergeProps({ className: className1 }, { className: className2 }, { className: className3 });
7576
let mergedClassNames = clsx(className1, className2, className3);
7677
expect(mergedProps.className).toBe(mergedClassNames);
7778
});
@@ -80,8 +81,45 @@ describe('mergeProps', function () {
8081
let id1 = 'id1';
8182
let id2 = 'id2';
8283
let id3 = 'id3';
83-
let mergedProps = mergeProps({id: id1}, {id: id2}, {id: id3});
84+
let mergedProps = mergeProps({ id: id1 }, { id: id2 }, { id: id3 });
8485
let mergedIds = mergeIds(mergeIds(id1, id2), id3);
8586
expect(mergedProps.id).toBe(mergedIds);
8687
});
88+
89+
it('combines ids with aria ids', function () {
90+
let Spy = jest.fn((props) => <div {...props} />);
91+
92+
const Component = () => {
93+
let id1 = 'id1';
94+
let id2 = useId('id2');
95+
96+
mergeProps({ id: id1 }, { id: id2 });
97+
98+
return <Spy id={id2} />
99+
};
100+
101+
render(<Component />);
102+
103+
// We use stringMatching to support optional refs in React 19.
104+
expect(Spy).toHaveBeenCalledWith({ id: 'id2' }, expect.not.stringMatching(/\A(?!x)x/));
105+
expect(Spy).toHaveBeenLastCalledWith({ id: 'id1' }, expect.not.stringMatching(/\A(?!x)x/));
106+
});
107+
108+
it('combines reoccuring ids', function () {
109+
const Component = () => {
110+
let id1 = useId('id1');
111+
let id2 = useId('id2');
112+
113+
return <div {...mergeProps({ id: id1 }, { id: id2 }, { id: id1 })} />;
114+
};
115+
116+
expect(() => render(<Component />)).not.toThrow();
117+
});
118+
119+
it('overrides other props', function () {
120+
let id1 = 'id1';
121+
let id2 = 'id2';
122+
let mergedProps = mergeProps({ data: id1 }, { data: id2 });
123+
expect(mergedProps.data).toBe(id2);
124+
});
87125
});

0 commit comments

Comments
 (0)