Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1c94a48
Fixes "Converting circular structure to JSON" exception in useControl…
Profesor08 Mar 26, 2026
40c3f3d
dedupe packages
Profesor08 Mar 26, 2026
607094a
make check style consistent
Profesor08 Mar 26, 2026
36b488f
add deepEqual utility
Profesor08 Mar 26, 2026
19efe21
dedupe packages
Profesor08 Mar 26, 2026
2c0d82a
show error only if defaultValue changes and not equal with initial
Profesor08 Mar 26, 2026
61e1f4f
add tests
Profesor08 Mar 26, 2026
6f7105e
update test
Profesor08 Mar 27, 2026
84a1896
fix naming
Profesor08 Mar 27, 2026
f468417
remove comment
Profesor08 Mar 27, 2026
f258675
add test
Profesor08 Mar 27, 2026
5cb3755
add tests
Profesor08 Mar 27, 2026
a33c8fd
add tests
Profesor08 Mar 27, 2026
27b4c7e
inline deepEqual
Profesor08 Mar 27, 2026
694de85
update example
Profesor08 Mar 27, 2026
0543431
add tests
Profesor08 Mar 27, 2026
5303d9e
Merge branch 'master' into select-bug-4451
Profesor08 Mar 27, 2026
218ab6e
add deepEqual implementation like in floating-ui
Profesor08 Apr 2, 2026
ebeb5c5
Merge branch 'mui:master' into select-bug-4451
Profesor08 Apr 2, 2026
ed1c014
define deepEqual as function
Profesor08 Apr 2, 2026
d719de5
Merge branch 'mui:master' into select-bug-4451
Profesor08 Apr 7, 2026
8e4623a
[utils] Simplify useControlled dev warnings
atomiks Apr 7, 2026
720d58c
[utils] Reset error dedupe in compiler tests
atomiks Apr 7, 2026
8b17c18
[utils] Fix setupVitest lint for compiler tests
atomiks Apr 7, 2026
74ab50f
Merge branch 'master' into select-bug-4451
atomiks Apr 7, 2026
f46290d
[utils] Load compiler error reset once
atomiks Apr 7, 2026
96ebdb2
[utils] Harden dev warning serialization
atomiks Apr 7, 2026
ce8da51
Merge branch 'master' into select-bug-4451
atomiks Apr 7, 2026
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
134 changes: 132 additions & 2 deletions packages/utils/src/useControlled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { act, createRenderer } from '@mui/internal-test-utils';
import { useControlled } from './useControlled';

interface TestComponentChildrenArgument {
value: number | string;
value: number | string | object | null;
setValue: React.Dispatch<React.SetStateAction<number | string>>;
}

interface TestComponentProps {
value?: number | string;
defaultValue?: number | string;
defaultValue?: number | string | object | null;
children: (parames: TestComponentChildrenArgument) => React.ReactNode;
}

Expand Down Expand Up @@ -138,5 +138,135 @@ describe('useControlled', () => {
render(<TestComponentArray />);
}).not.toErrorDev();
});

it('does not throw when defaultValue has React elements', () => {
function TestComponentArray() {
useControlled({
controlled: undefined,
default: {
value: <span />,
},
name: 'TestComponent',
});
return null;
}

expect(() => {
render(<TestComponentArray />);
}).not.toErrorDev();
});

it('does not throw when defaultValue has function', () => {
const fn = () => 100;

function TestComponentArray() {
useControlled({
controlled: undefined,
default: {
value: fn,
},
name: 'TestComponent',
});
return null;
}

expect(() => {
render(<TestComponentArray />);
}).not.toErrorDev();
});

it('does not throw when defaultValue has bigint', () => {
function TestComponentBigInt() {
useControlled({
controlled: undefined,
default: 1n,
name: 'TestComponent',
});
return null;
}

expect(() => {
render(<TestComponentBigInt />);
}).not.toErrorDev();
});

it('should warn only when defaultValue changes', () => {
let setProps: (newProps: any) => void;

expect(() => {
({ setProps } = render(<TestComponent defaultValue={0}>{() => null}</TestComponent>));
}).not.toErrorDev();

expect(() => {
setProps({ defaultValue: 1 });
}).toErrorDev(
'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.',
);

expect(() => {
setProps({ defaultValue: 2 });
}).not.toErrorDev();

expect(() => {
setProps({ defaultValue: 0 });
}).not.toErrorDev();
});

it('should warn only when defaultValue has functions/components and changes', () => {
let setProps: (newProps: any) => void;

const items = [
{
item: <span />,
},
{
item: () => 100,
},
{
item: <div />,
},
];

expect(() => {
({ setProps } = render(
<TestComponent defaultValue={items[0]}>{() => null}</TestComponent>,
));
}).not.toErrorDev();

expect(() => {
setProps({ defaultValue: items[1] });
}).toErrorDev(
'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.',
);

expect(() => {
setProps({ defaultValue: items[2] });
}).not.toErrorDev();

expect(() => {
setProps({ defaultValue: items[0] });
}).not.toErrorDev();
});

it('should not fail on null values', () => {
let setProps: (newProps: any) => void;

const s1 = null;
const s2 = undefined;

expect(() => {
({ setProps } = render(<TestComponent defaultValue={s1}>{() => null}</TestComponent>));
}).not.toErrorDev();

expect(() => {
setProps({ defaultValue: s2 });
}).toErrorDev(
'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.',
);

expect(() => {
setProps({ defaultValue: s1 });
}).not.toErrorDev();
});
});
});
50 changes: 43 additions & 7 deletions packages/utils/src/useControlled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler -- process.env never changes, dependency arrays are intentionally ignored
/* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */
import * as React from 'react';
import { error } from './error';

export interface UseControlledProps<T = unknown> {
/**
Expand Down Expand Up @@ -36,9 +37,9 @@ export function useControlled<T = unknown>({
if (process.env.NODE_ENV !== 'production') {
React.useEffect(() => {
if (isControlled !== (controlled !== undefined)) {
console.error(
error(
[
`Base UI: A component is changing the ${
`A component is changing the ${
isControlled ? '' : 'un'
}controlled ${state} state of ${name} to be ${isControlled ? 'un' : ''}controlled.`,
'Elements should not switch from uncontrolled to controlled (or vice versa).',
Expand All @@ -54,16 +55,18 @@ export function useControlled<T = unknown>({
const { current: defaultValue } = React.useRef(defaultProp);

React.useEffect(() => {
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is for more details.
if (!isControlled && JSON.stringify(defaultValue) !== JSON.stringify(defaultProp)) {
console.error(
if (
!isControlled &&
serializeToDevModeString(defaultValue) !== serializeToDevModeString(defaultProp)
) {
error(
[
`Base UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` +
`A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` +
`To suppress this warning opt to use a controlled ${name}.`,
].join('\n'),
);
}
}, [JSON.stringify(defaultProp)]);
}, [defaultProp]);
}

const setValueIfUncontrolled = React.useCallback((newValue: React.SetStateAction<T>) => {
Expand All @@ -74,3 +77,36 @@ export function useControlled<T = unknown>({

return [value as T, setValueIfUncontrolled];
}

function serializeToDevModeString(input: unknown) {
let nextId = 0;
const seen = new WeakMap<object, number>();

try {
const result = JSON.stringify(input, function replacer(key, value) {
if (key === '_owner' && this != null && typeof this === 'object' && '$$typeof' in this) {
return undefined;
}

if (typeof value === 'bigint') {
return `__bigint__:${value}`;
}

if (value !== null && typeof value === 'object') {
const id = seen.get(value);
if (id !== undefined) {
return `__object__:${id}`;
}

seen.set(value, nextId);
nextId += 1;
}

return value;
});

return result ?? `__top__:${typeof input}`;
} catch {
return '__unserializable__';
}
}
16 changes: 13 additions & 3 deletions test/setupVitest.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { vi } from 'vitest';
import { beforeAll, vi } from 'vitest';
import setupVitest from '@mui/internal-test-utils/setupVitest';
// eslint-disable-next-line import/no-relative-packages
import '../packages/react/test/addVitestMatchers';
import '@testing-library/jest-dom/vitest';
import { reset } from '@base-ui/utils/error';
import { reset as resetBuiltError } from '@base-ui/utils/error';

declare global {
// eslint-disable-next-line vars-on-top
var BASE_UI_ANIMATIONS_DISABLED: boolean;
}

let resetSourceError = () => {};

setupVitest();

beforeAll(async () => {
// In compiler tests with workspace aliases disabled, the source module and package entry
// can be loaded as separate instances, so reset the source copy too.
// eslint-disable-next-line import/no-relative-packages
({ reset: resetSourceError } = await import('../packages/utils/src/error'));
});

afterEach(() => {
vi.resetAllMocks();
reset();
resetBuiltError();
resetSourceError();
});

globalThis.BASE_UI_ANIMATIONS_DISABLED = true;
Expand Down
Loading