From afc55bca4cab939c0ed23e417a352af23d99cc98 Mon Sep 17 00:00:00 2001 From: David Gallegos Date: Mon, 22 Jun 2026 08:38:52 -0300 Subject: [PATCH] bugfix(core): prevent infinite render loop for array/object control values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasControlsChanged compared a control's `value` by reference (`!==`), so any control whose value is an array or object (e.g. multiselect) was reported as changed on every render, re-registering the section each time. Since v2.5 the store's setState uses deepEqual (which no longer drops onChange handlers), each redundant re-registration became a real store mutation, and the registration effect depends on `sections` — closing the loop into "Maximum update depth exceeded". Compare control values structurally with deepEqual instead, and also deep-compare multiselect `options`. Adds regression tests for multiselect. --- .changeset/fix-multiselect-render-loop.md | 5 ++ .../hasControlChanged.spec.ts | 83 +++++++++++++++++++ .../hasControlChanged/hasControlChanged.ts | 12 ++- 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-multiselect-render-loop.md diff --git a/.changeset/fix-multiselect-render-loop.md b/.changeset/fix-multiselect-render-loop.md new file mode 100644 index 0000000..93359d3 --- /dev/null +++ b/.changeset/fix-multiselect-render-loop.md @@ -0,0 +1,5 @@ +--- +"@berenjena/react-dev-panel": patch +--- + +Fix an infinite render loop ("Maximum update depth exceeded") for controls whose `value` is an array or object, such as `multiselect`. `hasControlsChanged` now compares control values structurally instead of by reference, so a section is no longer re-registered on every render when the consumer rebuilds the value array. Multiselect `options` are now compared as well. diff --git a/src/utils/hasControlChanged/hasControlChanged.spec.ts b/src/utils/hasControlChanged/hasControlChanged.spec.ts index e2797bf..c5de863 100644 --- a/src/utils/hasControlChanged/hasControlChanged.spec.ts +++ b/src/utils/hasControlChanged/hasControlChanged.spec.ts @@ -599,6 +599,89 @@ describe("hasControlsChanged", () => { }); }); + describe("multiselect control specific properties", () => { + it("should return false when value arrays are equal but reference differs", () => { + // Given - simulates a consumer rebuilding the value array on every render + const current: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a", "b"], + options: ["a", "b", "c"], + onChange: vi.fn(), + }, + }; + + const previous: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a", "b"], + options: ["a", "b", "c"], + onChange: vi.fn(), + }, + }; + + // When + const result = hasControlsChanged(current, previous); + + // Then + expect(result).toBe(false); + }); + + it("should return true when value array contents differ", () => { + // Given + const current: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a", "b", "c"], + options: ["a", "b", "c"], + onChange: vi.fn(), + }, + }; + + const previous: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a", "b"], + options: ["a", "b", "c"], + onChange: vi.fn(), + }, + }; + + // When + const result = hasControlsChanged(current, previous); + + // Then + expect(result).toBe(true); + }); + + it("should return true when options differ", () => { + // Given + const current: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a"], + options: ["a", "b", "c"], + onChange: vi.fn(), + }, + }; + + const previous: ControlsGroup = { + flags: { + type: "multiselect", + value: ["a"], + options: ["a", "b"], + onChange: vi.fn(), + }, + }; + + // When + const result = hasControlsChanged(current, previous); + + // Then + expect(result).toBe(true); + }); + }); + describe("text control specific properties", () => { it("should return true when placeholder changes", () => { // Given diff --git a/src/utils/hasControlChanged/hasControlChanged.ts b/src/utils/hasControlChanged/hasControlChanged.ts index 0c26633..bdff976 100644 --- a/src/utils/hasControlChanged/hasControlChanged.ts +++ b/src/utils/hasControlChanged/hasControlChanged.ts @@ -27,9 +27,11 @@ export function hasControlsChanged(current: ControlsGroup, previous?: ControlsGr return true; } - // Compare value only if both controls have it + // Compare value only if both controls have it. Use a structural compare so + // controls with array/object values (e.g. multiselect) are not reported as + // changed on every render just because the consumer rebuilds the reference. if ("value" in currentControl && "value" in previousControl) { - if (currentControl.value !== previousControl.value) { + if (!deepEqual(currentControl.value, previousControl.value)) { return true; } } @@ -67,6 +69,12 @@ export function hasControlsChanged(current: ControlsGroup, previous?: ControlsGr } } + if (currentControl.type === "multiselect" && previousControl.type === "multiselect") { + if (!deepEqual(currentControl.options, previousControl.options)) { + return true; + } + } + if (currentControl.type === "text" && previousControl.type === "text") { if (currentControl.placeholder !== previousControl.placeholder) { return true;