From 1c94a48277f74fbf687d166cb85a5ca3fd5bcd23 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Thu, 26 Mar 2026 22:01:25 +0200 Subject: [PATCH 01/23] Fixes "Converting circular structure to JSON" exception in useControlled hook --- packages/utils/package.json | 1 + packages/utils/src/useControlled.test.tsx | 17 +++++++++++++++++ packages/utils/src/useControlled.ts | 5 +++-- pnpm-lock.yaml | 9 ++++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 8a7f999ed96..5ae8b30e05f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,6 +27,7 @@ "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", + "es-toolkit": "^1.45.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 5ace18a408d..171f45b5897 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -138,5 +138,22 @@ describe('useControlled', () => { render(); }).not.toErrorDev(); }); + + it('does not throw - Converting circular structure to JSON', () => { + function TestComponentArray() { + useControlled({ + controlled: undefined, + default: { + icon: , + }, + name: 'TestComponent', + }); + return null; + } + + expect(() => { + render(); + }).not.toErrorDev(); + }); }); }); diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 612400b6bc6..afb11485bc3 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -1,4 +1,5 @@ 'use client'; +import { isEqual } from 'es-toolkit'; // 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'; @@ -55,7 +56,7 @@ export function useControlled({ 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)) { + if (!isControlled && isEqual(defaultValue, defaultProp) === false) { console.error( [ `Base UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + @@ -63,7 +64,7 @@ export function useControlled({ ].join('\n'), ); } - }, [JSON.stringify(defaultProp)]); + }); } const setValueIfUncontrolled = React.useCallback((newValue: React.SetStateAction) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9329b8d9d95..4979fbad386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,6 +570,9 @@ importers: '@floating-ui/utils': specifier: ^0.2.11 version: 0.2.11 + es-toolkit: + specifier: ^1.45.1 + version: 1.45.1 reselect: specifier: ^5.1.1 version: 5.1.1 @@ -12546,7 +12549,7 @@ snapshots: es-toolkit: 1.45.1 eslint: 10.1.0(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-compat: 7.0.1(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) @@ -16760,7 +16763,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)): dependencies: debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) @@ -16782,7 +16785,7 @@ snapshots: optionalDependencies: eslint: 10.1.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color From 40c3f3d6245b082d2b8ca636dffdf9ef8dd9c001 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Thu, 26 Mar 2026 22:16:49 +0200 Subject: [PATCH 02/23] dedupe packages --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4979fbad386..3a0961a49a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12549,7 +12549,7 @@ snapshots: es-toolkit: 1.45.1 eslint: 10.1.0(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-compat: 7.0.1(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) @@ -16763,7 +16763,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)): dependencies: debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) @@ -16785,7 +16785,7 @@ snapshots: optionalDependencies: eslint: 10.1.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color From 607094a8a10727845a5f660c1a1cc95296c8cba6 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Thu, 26 Mar 2026 22:52:27 +0200 Subject: [PATCH 03/23] make check style consistent --- packages/utils/src/useControlled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index afb11485bc3..60e58359b13 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -56,7 +56,7 @@ export function useControlled({ React.useEffect(() => { // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is for more details. - if (!isControlled && isEqual(defaultValue, defaultProp) === false) { + if (!isControlled && !isEqual(defaultValue, defaultProp)) { console.error( [ `Base UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + From 36b488fdf61398d771cde8b2cc072d44dde86740 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 01:08:39 +0200 Subject: [PATCH 04/23] add deepEqual utility --- packages/utils/package.json | 1 - packages/utils/src/deepEquals.ts | 63 +++++++++++++++++++++++++++++ packages/utils/src/useControlled.ts | 4 +- pnpm-lock.yaml | 9 ++--- 4 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 packages/utils/src/deepEquals.ts diff --git a/packages/utils/package.json b/packages/utils/package.json index 5ae8b30e05f..8a7f999ed96 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -27,7 +27,6 @@ "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", - "es-toolkit": "^1.45.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, diff --git a/packages/utils/src/deepEquals.ts b/packages/utils/src/deepEquals.ts new file mode 100644 index 00000000000..d8f0f78a197 --- /dev/null +++ b/packages/utils/src/deepEquals.ts @@ -0,0 +1,63 @@ +// Fork of `fast-deep-equal` that only does the comparisons we need and compares +// functions + +export function deepEqual(a: any, b: any) { + if (a === b) { + return true; + } + + if (typeof a !== typeof b) { + return false; + } + + if (typeof a === 'function' && a.toString() === b.toString()) { + return true; + } + + let length: number; + let i: number; + let keys: Array; + + if (a && b && typeof a === 'object') { + if (Array.isArray(a)) { + length = a.length; + if (length !== b.length) { + return false; + } + for (i = length - 1; i >= 0; i -= 1) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + + return true; + } + + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) { + return false; + } + + for (i = length - 1; i >= 0; i -= 1) { + if (!{}.hasOwnProperty.call(b, keys[i])) { + return false; + } + } + + for (i = length - 1; i >= 0; i -= 1) { + const key = keys[i]; + if (key === '_owner' && a.$$typeof) { + continue; + } + + if (!deepEqual(a[key], b[key])) { + return false; + } + } + + return true; + } + + return Number.isNaN(a) && Number.isNaN(b); +} diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 60e58359b13..8417429003b 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -1,8 +1,8 @@ 'use client'; -import { isEqual } from 'es-toolkit'; // 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 { deepEqual } from './deepEquals'; export interface UseControlledProps { /** @@ -56,7 +56,7 @@ export function useControlled({ React.useEffect(() => { // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is for more details. - if (!isControlled && !isEqual(defaultValue, defaultProp)) { + if (!isControlled && !deepEqual(defaultValue, defaultProp)) { console.error( [ `Base UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a0961a49a7..dff6cad9cad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,9 +570,6 @@ importers: '@floating-ui/utils': specifier: ^0.2.11 version: 0.2.11 - es-toolkit: - specifier: ^1.45.1 - version: 1.45.1 reselect: specifier: ^5.1.1 version: 5.1.1 @@ -12549,7 +12546,7 @@ snapshots: es-toolkit: 1.45.1 eslint: 10.1.0(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-compat: 7.0.1(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) @@ -16763,7 +16760,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)): dependencies: debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) @@ -16785,7 +16782,7 @@ snapshots: optionalDependencies: eslint: 10.1.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color From 19efe21b89b9231c462e173de50830392e1bf9b1 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 01:09:59 +0200 Subject: [PATCH 05/23] dedupe packages --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff6cad9cad..9329b8d9d95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12546,7 +12546,7 @@ snapshots: es-toolkit: 1.45.1 eslint: 10.1.0(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@2.6.1)) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-compat: 7.0.1(eslint@10.1.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@10.1.0(jiti@2.6.1)) @@ -16760,7 +16760,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)): dependencies: debug: 4.4.3 eslint: 10.1.0(jiti@2.6.1) @@ -16782,7 +16782,7 @@ snapshots: optionalDependencies: eslint: 10.1.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@10.1.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.1.0(jiti@2.6.1)))(eslint-plugin-import@2.32.0(eslint@10.1.0(jiti@2.6.1)))(eslint@10.1.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color From 2c0d82a318a08af91df15790926fa538f31a4722 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 01:58:37 +0200 Subject: [PATCH 06/23] show error only if defaultValue changes and not equal with initial --- packages/utils/src/useControlled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 8417429003b..feeefee1f4d 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -64,7 +64,7 @@ export function useControlled({ ].join('\n'), ); } - }); + }, [defaultProp]); } const setValueIfUncontrolled = React.useCallback((newValue: React.SetStateAction) => { From 61e1f4f191759e73cc0e36e675e2beabfbb762b5 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 01:59:27 +0200 Subject: [PATCH 07/23] add tests --- packages/utils/src/useControlled.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 171f45b5897..06de5c33092 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -155,5 +155,23 @@ describe('useControlled', () => { render(); }).not.toErrorDev(); }); + + it('does not warn on rerender', () => { + 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: 0 }); + }).not.toErrorDev(); + }); }); }); From 6f7105e838f541506f69a167c770e1804e17a770 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 02:00:41 +0200 Subject: [PATCH 08/23] update test --- packages/utils/src/useControlled.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 06de5c33092..99c80da9e7a 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -156,7 +156,7 @@ describe('useControlled', () => { }).not.toErrorDev(); }); - it('does not warn on rerender', () => { + it('should warn only when defaultValue changes', () => { let setProps: (newProps: any) => void; expect(() => { @@ -169,6 +169,12 @@ describe('useControlled', () => { 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', ); + expect(() => { + setProps({ defaultValue: 2 }); + }).toErrorDev( + 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', + ); + expect(() => { setProps({ defaultValue: 0 }); }).not.toErrorDev(); From 84a1896005492330ed2f63b9b77446b5ea3c7ee8 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 02:03:56 +0200 Subject: [PATCH 09/23] fix naming --- packages/utils/src/{deepEquals.ts => deepEqual.ts} | 0 packages/utils/src/useControlled.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/utils/src/{deepEquals.ts => deepEqual.ts} (100%) diff --git a/packages/utils/src/deepEquals.ts b/packages/utils/src/deepEqual.ts similarity index 100% rename from packages/utils/src/deepEquals.ts rename to packages/utils/src/deepEqual.ts diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index feeefee1f4d..9274f736a2c 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -2,7 +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 { deepEqual } from './deepEquals'; +import { deepEqual } from './deepEqual'; export interface UseControlledProps { /** From f468417a61a7c8e600110f207db130d9eb290ef7 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 02:05:51 +0200 Subject: [PATCH 10/23] remove comment --- packages/utils/src/deepEqual.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/utils/src/deepEqual.ts b/packages/utils/src/deepEqual.ts index d8f0f78a197..2eedb71c2fd 100644 --- a/packages/utils/src/deepEqual.ts +++ b/packages/utils/src/deepEqual.ts @@ -1,6 +1,3 @@ -// Fork of `fast-deep-equal` that only does the comparisons we need and compares -// functions - export function deepEqual(a: any, b: any) { if (a === b) { return true; From f2586750e4f0cad6e69c8e3c3fe6954cfe244563 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 11:33:42 +0200 Subject: [PATCH 11/23] add test --- packages/utils/src/useControlled.test.tsx | 42 +++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 99c80da9e7a..81ff7537d9b 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; setValue: React.Dispatch>; } interface TestComponentProps { value?: number | string; - defaultValue?: number | string; + defaultValue?: number | string | object; children: (parames: TestComponentChildrenArgument) => React.ReactNode; } @@ -179,5 +179,43 @@ describe('useControlled', () => { 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] }); + }).toErrorDev( + 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', + ); + + expect(() => { + setProps({ defaultValue: items[0] }); + }).not.toErrorDev(); + }); }); }); From 5cb375573beb480227c5b0b35fea8456ccc57c7f Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 12:06:05 +0200 Subject: [PATCH 12/23] add tests --- packages/utils/src/useControlled.test.tsx | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 81ff7537d9b..805156a3a47 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -139,12 +139,31 @@ describe('useControlled', () => { }).not.toErrorDev(); }); - it('does not throw - Converting circular structure to JSON', () => { + it('does not throw when defaultValue has React elements', () => { function TestComponentArray() { useControlled({ controlled: undefined, default: { - icon: , + 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', }); @@ -217,5 +236,26 @@ describe('useControlled', () => { setProps({ defaultValue: items[0] }); }).not.toErrorDev(); }); + + it('should warn only when defaultValue has Map and changes', () => { + let setProps: (newProps: any) => void; + + const m1 = new Map().set('a', 1).set('b', 2); + const m2 = new Map().set('a', 1).set('b', 2).set('c', 3); + + expect(() => { + ({ setProps } = render({() => null})); + }).not.toErrorDev(); + + expect(() => { + setProps({ defaultValue: m2 }); + }).toErrorDev( + 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', + ); + + expect(() => { + setProps({ defaultValue: m1 }); + }).not.toErrorDev(); + }); }); }); From a33c8fd71f7b09545b90c6896c123cee7378e795 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 12:41:15 +0200 Subject: [PATCH 13/23] add tests --- packages/utils/src/useControlled.test.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 805156a3a47..5c2c574deb0 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -257,5 +257,26 @@ describe('useControlled', () => { setProps({ defaultValue: m1 }); }).not.toErrorDev(); }); + + it('should warn only when defaultValue has Set and changes', () => { + let setProps: (newProps: any) => void; + + const s1 = new Set().add('a').add('b'); + const s2 = new Set().add('a').add('b').add('c'); + + 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(); + }); }); }); From 27b4c7e1edc1dd21b0c4b3d5e1c984c26b700dd7 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 12:42:49 +0200 Subject: [PATCH 14/23] inline deepEqual --- packages/utils/src/deepEqual.ts | 60 ----------------------------- packages/utils/src/useControlled.ts | 48 ++++++++++++++++++++++- 2 files changed, 46 insertions(+), 62 deletions(-) delete mode 100644 packages/utils/src/deepEqual.ts diff --git a/packages/utils/src/deepEqual.ts b/packages/utils/src/deepEqual.ts deleted file mode 100644 index 2eedb71c2fd..00000000000 --- a/packages/utils/src/deepEqual.ts +++ /dev/null @@ -1,60 +0,0 @@ -export function deepEqual(a: any, b: any) { - if (a === b) { - return true; - } - - if (typeof a !== typeof b) { - return false; - } - - if (typeof a === 'function' && a.toString() === b.toString()) { - return true; - } - - let length: number; - let i: number; - let keys: Array; - - if (a && b && typeof a === 'object') { - if (Array.isArray(a)) { - length = a.length; - if (length !== b.length) { - return false; - } - for (i = length - 1; i >= 0; i -= 1) { - if (!deepEqual(a[i], b[i])) { - return false; - } - } - - return true; - } - - keys = Object.keys(a); - length = keys.length; - if (length !== Object.keys(b).length) { - return false; - } - - for (i = length - 1; i >= 0; i -= 1) { - if (!{}.hasOwnProperty.call(b, keys[i])) { - return false; - } - } - - for (i = length - 1; i >= 0; i -= 1) { - const key = keys[i]; - if (key === '_owner' && a.$$typeof) { - continue; - } - - if (!deepEqual(a[key], b[key])) { - return false; - } - } - - return true; - } - - return Number.isNaN(a) && Number.isNaN(b); -} diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 9274f736a2c..6831b582d2a 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -2,7 +2,8 @@ // 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 { deepEqual } from './deepEqual'; + +let deepEqual: (a: any, b: any) => boolean; export interface UseControlledProps { /** @@ -55,7 +56,6 @@ 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 && !deepEqual(defaultValue, defaultProp)) { console.error( [ @@ -75,3 +75,47 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } +if (process.env.NODE_ENV !== 'production') { + deepEqual = (a: any, b: any) => { + if (a === b) { + return true; + } + + // Handle NaN and null/primitive mismatches + if (!(a instanceof Object) || !(b instanceof Object)) { + return Number.isNaN(a) && Number.isNaN(b); + } + + if (a.constructor !== b.constructor) { + return false; + } + + // Convert Maps/Sets to Arrays to "cheat" and use the same logic + const arrA = a instanceof Set || a instanceof Map ? Array.from(a) : null; + const arrB = b instanceof Set || b instanceof Map ? Array.from(b) : null; + + // If one is a Map/Set and the other isn't, they aren't equal + if (!!arrA !== !!arrB) { + return false; + } + + if (arrA && arrB) { + if (arrA.length !== arrB.length) { + return false; + } + // Sets are unordered, so this only works for "Deep Equal" if + // the order happens to match or if you sort them (sorting is expensive). + return arrA.every((val, i) => deepEqual(val, arrB[i])); + } + + // Standard Array/Object logic + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) { + return false; + } + + return keys.every( + (key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]), + ); + }; +} From 694de85cf780aabb7ab4483f9ce4befac41ef481 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 12:43:16 +0200 Subject: [PATCH 15/23] update example --- .../select/demos/object-values/css-modules/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx index 750f90ddab8..6b4cc02f557 100644 --- a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx @@ -78,7 +78,7 @@ function CheckIcon(props: React.ComponentProps<'svg'>) { interface ShippingMethod { id: string; - name: string; + name: React.ReactNode; duration: string; price: string; } @@ -86,19 +86,19 @@ interface ShippingMethod { const shippingMethods: ShippingMethod[] = [ { id: 'standard', - name: 'Standard', + name: Standard, duration: 'Delivers in 4-6 business days', price: '$4.99', }, { id: 'express', - name: 'Express', + name: Express, duration: 'Delivers in 2-3 business days', price: '$9.99', }, { id: 'overnight', - name: 'Overnight', + name: Overnight, duration: 'Delivers next business day', price: '$19.99', }, From 05434314afd37a58c9bc6ccbf59b4f2b0c47476c Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 27 Mar 2026 13:03:06 +0200 Subject: [PATCH 16/23] add tests --- packages/utils/src/useControlled.test.tsx | 46 ++++++++++++++++++++++- packages/utils/src/useControlled.ts | 5 +++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 5c2c574deb0..c9c2e42b557 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 | object; + value: number | string | object | null; setValue: React.Dispatch>; } interface TestComponentProps { value?: number | string; - defaultValue?: number | string | object; + defaultValue?: number | string | object | null; children: (parames: TestComponentChildrenArgument) => React.ReactNode; } @@ -278,5 +278,47 @@ describe('useControlled', () => { setProps({ defaultValue: s1 }); }).not.toErrorDev(); }); + + it('should warn only when defaultValue has Date and changes', () => { + let setProps: (newProps: any) => void; + + const d1 = new Date(Date.now()); + const d2 = new Date(Date.now() - 1000); // date.toString() not returning milliseconds, so we need to make them more diverse + + expect(() => { + ({ setProps } = render({() => null})); + }).not.toErrorDev(); + + expect(() => { + setProps({ defaultValue: d2 }); + }).toErrorDev( + 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', + ); + + expect(() => { + setProps({ defaultValue: d1 }); + }).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 6831b582d2a..1a5fb30cd3c 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -90,6 +90,10 @@ if (process.env.NODE_ENV !== 'production') { return false; } + if (a instanceof Date || a instanceof RegExp) { + return a.toString() === b.toString(); + } + // Convert Maps/Sets to Arrays to "cheat" and use the same logic const arrA = a instanceof Set || a instanceof Map ? Array.from(a) : null; const arrB = b instanceof Set || b instanceof Map ? Array.from(b) : null; @@ -110,6 +114,7 @@ if (process.env.NODE_ENV !== 'production') { // Standard Array/Object logic const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) { return false; } From 218ab6e2a271e7a5b8cdcba4219369a707761502 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Thu, 2 Apr 2026 12:10:42 +0300 Subject: [PATCH 17/23] add deepEqual implementation like in floating-ui --- packages/utils/src/useControlled.test.tsx | 63 ------------- packages/utils/src/useControlled.ts | 110 ++++++++++++---------- 2 files changed, 60 insertions(+), 113 deletions(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index c9c2e42b557..32f2e76810a 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -237,69 +237,6 @@ describe('useControlled', () => { }).not.toErrorDev(); }); - it('should warn only when defaultValue has Map and changes', () => { - let setProps: (newProps: any) => void; - - const m1 = new Map().set('a', 1).set('b', 2); - const m2 = new Map().set('a', 1).set('b', 2).set('c', 3); - - expect(() => { - ({ setProps } = render({() => null})); - }).not.toErrorDev(); - - expect(() => { - setProps({ defaultValue: m2 }); - }).toErrorDev( - 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', - ); - - expect(() => { - setProps({ defaultValue: m1 }); - }).not.toErrorDev(); - }); - - it('should warn only when defaultValue has Set and changes', () => { - let setProps: (newProps: any) => void; - - const s1 = new Set().add('a').add('b'); - const s2 = new Set().add('a').add('b').add('c'); - - 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(); - }); - - it('should warn only when defaultValue has Date and changes', () => { - let setProps: (newProps: any) => void; - - const d1 = new Date(Date.now()); - const d2 = new Date(Date.now() - 1000); // date.toString() not returning milliseconds, so we need to make them more diverse - - expect(() => { - ({ setProps } = render({() => null})); - }).not.toErrorDev(); - - expect(() => { - setProps({ defaultValue: d2 }); - }).toErrorDev( - 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', - ); - - expect(() => { - setProps({ defaultValue: d1 }); - }).not.toErrorDev(); - }); - it('should not fail on null values', () => { let setProps: (newProps: any) => void; diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 1a5fb30cd3c..eb021a19316 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -3,7 +3,66 @@ /* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ import * as React from 'react'; -let deepEqual: (a: any, b: any) => boolean; +const deepEqual = (a: any, b: any) => { + if (a === b) { + return true; + } + + if (typeof a !== typeof b) { + return false; + } + + if (typeof a === 'function' && a.toString() === b.toString()) { + return true; + } + + let length: number; + let i: number; + let keys: Array; + + if (a && b && typeof a === 'object') { + if (Array.isArray(a)) { + length = a.length; + if (length !== b.length) { + return false; + } + for (i = length - 1; i >= 0; i -= 1) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + + return true; + } + + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) { + return false; + } + + for (i = length - 1; i >= 0; i -= 1) { + if (!{}.hasOwnProperty.call(b, keys[i])) { + return false; + } + } + + for (i = length - 1; i >= 0; i -= 1) { + const key = keys[i]; + if (key === '_owner' && a.$$typeof) { + continue; + } + + if (!deepEqual(a[key], b[key])) { + return false; + } + } + + return true; + } + + return Number.isNaN(a) && Number.isNaN(b); +}; export interface UseControlledProps { /** @@ -75,52 +134,3 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } -if (process.env.NODE_ENV !== 'production') { - deepEqual = (a: any, b: any) => { - if (a === b) { - return true; - } - - // Handle NaN and null/primitive mismatches - if (!(a instanceof Object) || !(b instanceof Object)) { - return Number.isNaN(a) && Number.isNaN(b); - } - - if (a.constructor !== b.constructor) { - return false; - } - - if (a instanceof Date || a instanceof RegExp) { - return a.toString() === b.toString(); - } - - // Convert Maps/Sets to Arrays to "cheat" and use the same logic - const arrA = a instanceof Set || a instanceof Map ? Array.from(a) : null; - const arrB = b instanceof Set || b instanceof Map ? Array.from(b) : null; - - // If one is a Map/Set and the other isn't, they aren't equal - if (!!arrA !== !!arrB) { - return false; - } - - if (arrA && arrB) { - if (arrA.length !== arrB.length) { - return false; - } - // Sets are unordered, so this only works for "Deep Equal" if - // the order happens to match or if you sort them (sorting is expensive). - return arrA.every((val, i) => deepEqual(val, arrB[i])); - } - - // Standard Array/Object logic - const keys = Object.keys(a); - - if (keys.length !== Object.keys(b).length) { - return false; - } - - return keys.every( - (key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]), - ); - }; -} From ed1c0140c1b98857b60544a0c98e9392af751b3a Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Fri, 3 Apr 2026 02:08:42 +0300 Subject: [PATCH 18/23] define deepEqual as function --- packages/utils/src/useControlled.ts | 122 ++++++++++++++-------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index eb021a19316..7ea13b40233 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -3,67 +3,6 @@ /* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ import * as React from 'react'; -const deepEqual = (a: any, b: any) => { - if (a === b) { - return true; - } - - if (typeof a !== typeof b) { - return false; - } - - if (typeof a === 'function' && a.toString() === b.toString()) { - return true; - } - - let length: number; - let i: number; - let keys: Array; - - if (a && b && typeof a === 'object') { - if (Array.isArray(a)) { - length = a.length; - if (length !== b.length) { - return false; - } - for (i = length - 1; i >= 0; i -= 1) { - if (!deepEqual(a[i], b[i])) { - return false; - } - } - - return true; - } - - keys = Object.keys(a); - length = keys.length; - if (length !== Object.keys(b).length) { - return false; - } - - for (i = length - 1; i >= 0; i -= 1) { - if (!{}.hasOwnProperty.call(b, keys[i])) { - return false; - } - } - - for (i = length - 1; i >= 0; i -= 1) { - const key = keys[i]; - if (key === '_owner' && a.$$typeof) { - continue; - } - - if (!deepEqual(a[key], b[key])) { - return false; - } - } - - return true; - } - - return Number.isNaN(a) && Number.isNaN(b); -}; - export interface UseControlledProps { /** * Holds the component value when it's controlled. @@ -134,3 +73,64 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } + +function deepEqual(a: any, b: any) { + if (a === b) { + return true; + } + + if (typeof a !== typeof b) { + return false; + } + + if (typeof a === 'function' && a.toString() === b.toString()) { + return true; + } + + let length: number; + let i: number; + let keys: Array; + + if (a && b && typeof a === 'object') { + if (Array.isArray(a)) { + length = a.length; + if (length !== b.length) { + return false; + } + for (i = length - 1; i >= 0; i -= 1) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + + return true; + } + + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) { + return false; + } + + for (i = length - 1; i >= 0; i -= 1) { + if (!{}.hasOwnProperty.call(b, keys[i])) { + return false; + } + } + + for (i = length - 1; i >= 0; i -= 1) { + const key = keys[i]; + if (key === '_owner' && a.$$typeof) { + continue; + } + + if (!deepEqual(a[key], b[key])) { + return false; + } + } + + return true; + } + + return Number.isNaN(a) && Number.isNaN(b); +} From 8e4623a1b93e8cc8407b7556fa5f402038739dae Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Apr 2026 18:37:08 +1000 Subject: [PATCH 19/23] [utils] Simplify useControlled dev warnings --- .../demos/object-values/css-modules/index.tsx | 8 +- packages/utils/src/useControlled.test.tsx | 8 +- packages/utils/src/useControlled.ts | 77 +++++-------------- 3 files changed, 26 insertions(+), 67 deletions(-) diff --git a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx index 6b4cc02f557..750f90ddab8 100644 --- a/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx +++ b/docs/src/app/(docs)/react/components/select/demos/object-values/css-modules/index.tsx @@ -78,7 +78,7 @@ function CheckIcon(props: React.ComponentProps<'svg'>) { interface ShippingMethod { id: string; - name: React.ReactNode; + name: string; duration: string; price: string; } @@ -86,19 +86,19 @@ interface ShippingMethod { const shippingMethods: ShippingMethod[] = [ { id: 'standard', - name: Standard, + name: 'Standard', duration: 'Delivers in 4-6 business days', price: '$4.99', }, { id: 'express', - name: Express, + name: 'Express', duration: 'Delivers in 2-3 business days', price: '$9.99', }, { id: 'overnight', - name: Overnight, + name: 'Overnight', duration: 'Delivers next business day', price: '$19.99', }, diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index 32f2e76810a..c49e34473bf 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -190,9 +190,7 @@ describe('useControlled', () => { expect(() => { setProps({ defaultValue: 2 }); - }).toErrorDev( - 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', - ); + }).not.toErrorDev(); expect(() => { setProps({ defaultValue: 0 }); @@ -228,9 +226,7 @@ describe('useControlled', () => { expect(() => { setProps({ defaultValue: items[2] }); - }).toErrorDev( - 'Base UI: A component is changing the default value state of an uncontrolled TestComponent after being initialized.', - ); + }).not.toErrorDev(); expect(() => { setProps({ defaultValue: items[0] }); diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 7ea13b40233..97e334cd360 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,10 +55,10 @@ export function useControlled({ const { current: defaultValue } = React.useRef(defaultProp); React.useEffect(() => { - if (!isControlled && !deepEqual(defaultValue, defaultProp)) { - console.error( + if (!isControlled && devmodeStringHash(defaultValue) !== devmodeStringHash(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'), ); @@ -74,63 +75,25 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } -function deepEqual(a: any, b: any) { - if (a === b) { - return true; - } - - if (typeof a !== typeof b) { - return false; - } - - if (typeof a === 'function' && a.toString() === b.toString()) { - return true; - } - - let length: number; - let i: number; - let keys: Array; - - if (a && b && typeof a === 'object') { - if (Array.isArray(a)) { - length = a.length; - if (length !== b.length) { - return false; - } - for (i = length - 1; i >= 0; i -= 1) { - if (!deepEqual(a[i], b[i])) { - return false; - } - } +function devmodeStringHash(input: unknown) { + let nextId = 0; + const seen = new WeakMap(); - return true; + return JSON.stringify(input, function replacer(key, value) { + if (key === '_owner' && this.$$typeof) { + return undefined; } - keys = Object.keys(a); - length = keys.length; - if (length !== Object.keys(b).length) { - return false; - } - - for (i = length - 1; i >= 0; i -= 1) { - if (!{}.hasOwnProperty.call(b, keys[i])) { - return false; - } - } - - for (i = length - 1; i >= 0; i -= 1) { - const key = keys[i]; - if (key === '_owner' && a.$$typeof) { - continue; + if (value !== null && typeof value === 'object') { + const id = seen.get(value); + if (id !== undefined) { + return `__object__:${id}`; } - if (!deepEqual(a[key], b[key])) { - return false; - } + seen.set(value, nextId); + nextId += 1; } - return true; - } - - return Number.isNaN(a) && Number.isNaN(b); + return value; + }); } From 720d58c4bd098b5db2114776811223d673af803e Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Apr 2026 18:53:32 +1000 Subject: [PATCH 20/23] [utils] Reset error dedupe in compiler tests --- test/setupVitest.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/setupVitest.ts b/test/setupVitest.ts index c19b098986e..8426179e9d0 100644 --- a/test/setupVitest.ts +++ b/test/setupVitest.ts @@ -3,7 +3,9 @@ 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'; +// eslint-disable-next-line import/no-relative-packages +import { reset as resetSourceError } from '../packages/utils/src/error'; declare global { // eslint-disable-next-line vars-on-top @@ -14,7 +16,8 @@ setupVitest(); afterEach(() => { vi.resetAllMocks(); - reset(); + resetBuiltError(); + resetSourceError(); }); globalThis.BASE_UI_ANIMATIONS_DISABLED = true; From 8b17c189849989d3beefaee4709b5dd54f16544e Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Apr 2026 18:58:38 +1000 Subject: [PATCH 21/23] [utils] Fix setupVitest lint for compiler tests --- test/setupVitest.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/setupVitest.ts b/test/setupVitest.ts index 8426179e9d0..239ba47ae89 100644 --- a/test/setupVitest.ts +++ b/test/setupVitest.ts @@ -4,8 +4,6 @@ import setupVitest from '@mui/internal-test-utils/setupVitest'; import '../packages/react/test/addVitestMatchers'; import '@testing-library/jest-dom/vitest'; import { reset as resetBuiltError } from '@base-ui/utils/error'; -// eslint-disable-next-line import/no-relative-packages -import { reset as resetSourceError } from '../packages/utils/src/error'; declare global { // eslint-disable-next-line vars-on-top @@ -14,9 +12,13 @@ declare global { setupVitest(); -afterEach(() => { +afterEach(async () => { vi.resetAllMocks(); resetBuiltError(); + // 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 + const { reset: resetSourceError } = await import('../packages/utils/src/error'); resetSourceError(); }); From f46290d5e3c7a8c30e4a11ab7d84636e62636e92 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Apr 2026 19:23:31 +1000 Subject: [PATCH 22/23] [utils] Load compiler error reset once --- test/setupVitest.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/setupVitest.ts b/test/setupVitest.ts index 239ba47ae89..167abf9821a 100644 --- a/test/setupVitest.ts +++ b/test/setupVitest.ts @@ -1,4 +1,4 @@ -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'; @@ -10,15 +10,20 @@ declare global { var BASE_UI_ANIMATIONS_DISABLED: boolean; } +let resetSourceError = () => {}; + setupVitest(); -afterEach(async () => { - vi.resetAllMocks(); - resetBuiltError(); +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 - const { reset: resetSourceError } = await import('../packages/utils/src/error'); + ({ reset: resetSourceError } = await import('../packages/utils/src/error')); +}); + +afterEach(() => { + vi.resetAllMocks(); + resetBuiltError(); resetSourceError(); }); From 96ebdb278a94b1aa8846f2c70933b9594226c420 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 7 Apr 2026 20:21:47 +1000 Subject: [PATCH 23/23] [utils] Harden dev warning serialization --- packages/utils/src/useControlled.test.tsx | 15 ++++++++ packages/utils/src/useControlled.ts | 43 +++++++++++++++-------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/utils/src/useControlled.test.tsx b/packages/utils/src/useControlled.test.tsx index c49e34473bf..adbf6f940d8 100644 --- a/packages/utils/src/useControlled.test.tsx +++ b/packages/utils/src/useControlled.test.tsx @@ -175,6 +175,21 @@ describe('useControlled', () => { }).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; diff --git a/packages/utils/src/useControlled.ts b/packages/utils/src/useControlled.ts index 97e334cd360..fbb361da93b 100644 --- a/packages/utils/src/useControlled.ts +++ b/packages/utils/src/useControlled.ts @@ -55,7 +55,10 @@ export function useControlled({ const { current: defaultValue } = React.useRef(defaultProp); React.useEffect(() => { - if (!isControlled && devmodeStringHash(defaultValue) !== devmodeStringHash(defaultProp)) { + if ( + !isControlled && + serializeToDevModeString(defaultValue) !== serializeToDevModeString(defaultProp) + ) { error( [ `A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + @@ -75,25 +78,35 @@ export function useControlled({ return [value as T, setValueIfUncontrolled]; } -function devmodeStringHash(input: unknown) { +function serializeToDevModeString(input: unknown) { let nextId = 0; const seen = new WeakMap(); - return JSON.stringify(input, function replacer(key, value) { - if (key === '_owner' && this.$$typeof) { - return undefined; - } + try { + const result = JSON.stringify(input, function replacer(key, value) { + if (key === '_owner' && this != null && typeof this === 'object' && '$$typeof' in this) { + return undefined; + } - if (value !== null && typeof value === 'object') { - const id = seen.get(value); - if (id !== undefined) { - return `__object__:${id}`; + if (typeof value === 'bigint') { + return `__bigint__:${value}`; } - seen.set(value, nextId); - nextId += 1; - } + if (value !== null && typeof value === 'object') { + const id = seen.get(value); + if (id !== undefined) { + return `__object__:${id}`; + } - return value; - }); + seen.set(value, nextId); + nextId += 1; + } + + return value; + }); + + return result ?? `__top__:${typeof input}`; + } catch { + return '__unserializable__'; + } }