diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 5ace18a408d..adbf6f940d8 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -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>; } interface TestComponentProps { value?: number | string; - defaultValue?: number | string; + defaultValue?: number | string | object | null; children: (parames: TestComponentChildrenArgument) => React.ReactNode; } @@ -138,5 +138,135 @@ describe('useControlled', () => { render(); }).not.toErrorDev(); }); + + it('does not throw when defaultValue has React elements', () => { + function TestComponentArray() { + useControlled({ + controlled: undefined, + default: { + value: , + }, + name: 'TestComponent', + }); + return null; + } + + expect(() => { + render(); + }).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(); + }).not.toErrorDev(); + }); + + it('does not throw when defaultValue has bigint', () => { + function TestComponentBigInt() { + useControlled({ + controlled: undefined, + default: 1n, + name: 'TestComponent', + }); + return null; + } + + expect(() => { + render(); + }).not.toErrorDev(); + }); + + it('should warn only when defaultValue changes', () => { + let setProps: (newProps: any) => void; + + expect(() => { + ({ setProps } = render({() => null})); + }).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: , + }, + { + item: () => 100, + }, + { + item:
, + }, + ]; + + expect(() => { + ({ setProps } = render( + {() => null}, + )); + }).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({() => null})); + }).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(); + }); }); }); diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 612400b6bc6..fbb361da93b 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -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 { /** @@ -36,9 +37,9 @@ export function useControlled({ 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).', @@ -54,16 +55,18 @@ export function useControlled({ 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) => { @@ -74,3 +77,36 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } + +function serializeToDevModeString(input: unknown) { + let nextId = 0; + const seen = new WeakMap(); + + 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__'; + } +} diff --git a/test/setupVitest.ts b/test/setupVitest.ts index c19b098986e..167abf9821a 100644 --- a/test/setupVitest.ts +++ b/test/setupVitest.ts @@ -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;