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;