From fddd596e8f10ec9b2ec47652d3a2b95da42573d1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:23:28 -0700 Subject: [PATCH] fix(studio): key FX rows by node id so a mid-edit stays with its effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rack's rows were keyed `${type}-${index}`. Two effects of the SAME type keep those keys through a reorder — position 0 is `peaking-0` before and after — so React reused each row where it stood rather than moving it with its node. The controls hold real state (a number field mid-edit is held as text, a drag holds a local value), and that state stayed at the position: moving an effect handed its half-typed value to whichever effect took its slot. Different types happened to be safe, because the type is in the key. Same types were not, and a rack with two EQ bands is the common case. Now keyed by `node.id`, which the carve module's own list above already does, falling back to the old form for a node minted without one. Falsified by restoring the index key: the slot keeps showing 123 after the effect carrying it moved away. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/propertyPanelFxSection.test.tsx | 49 +++++++++++++++++++ .../editor/propertyPanelFxSection.tsx | 8 ++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx index 345d3574d9..c6a35307f7 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx @@ -163,6 +163,55 @@ describe("FxSection chain", () => { expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "peaking"]); }); + it("keeps a half-typed value with its own effect across a reorder", () => { + // Rows used to be keyed `${type}-${index}`, so two effects of the same type + // kept their keys through a reorder and React reused each row where it + // stood. The controls hold real state — a number field mid-edit is held as + // text — so the buffer stayed at the position and landed on whichever + // effect moved into it. + const peaking = (id: string, frequency: number) => ({ + type: "peaking", + id, + enabled: true, + params: { ...defaultAudioFxParams("peaking"), frequency }, + }); + const a = peaking("pa", 400); + const b = peaking("pb", 1600); + const chainOfNodes = (...nodes: unknown[]): HfAudioFxChain => + ({ version: 1, nodes }) as HfAudioFxChain; + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (chain: HfAudioFxChain) => + act(() => { + root.render( + , + ); + }); + + render(chainOfNodes(a, b)); + // Only the first card is open, which is the one being edited. + const openFrequency = (): HTMLInputElement => + host.querySelector(".hf-fx-node .hf-fx-number")!; + + expect(openFrequency().value).toBe("400"); + typeInto(openFrequency(), "123"); + expect(openFrequency().value).toBe("123"); + + // The author moves that effect down; the other one takes the open slot. + render(chainOfNodes(b, a)); + + expect(openFrequency().value).toBe("1600"); + }); + it("cannot move the ends past themselves", () => { const { host } = mount({ chain: chainOf("peaking", "reverb") }); const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]'); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index ab696119af..33fda26891 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -795,7 +795,13 @@ export function FxSection({ handBuilt.map(({ node, i }) => { return (