fix(studio): key FX rows by node id so a mid-edit stays with its effect

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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:23:28 -07:00
co-authored by Claude Opus 5
parent 7ecc996057
commit fddd596e8f
2 changed files with 56 additions and 1 deletions
@@ -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(
<FxSection
chain={chain}
onChainChange={vi.fn()}
onChainPreview={vi.fn()}
carve={null}
onCarveChange={vi.fn()}
sourceOptions={[]}
/>,
);
});
render(chainOfNodes(a, b));
// Only the first card is open, which is the one being edited.
const openFrequency = (): HTMLInputElement =>
host.querySelector<HTMLInputElement>(".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"]');
@@ -795,7 +795,13 @@ export function FxSection({
handBuilt.map(({ node, i }) => {
return (
<FxNodeRow
key={`${node.type}-${i}`}
// Keyed by id, as the carve module's list above already is.
// On `${type}-${index}` two effects of the same type keep their
// keys through a reorder, so React reuses each row where it
// stands — and the controls hold real state (a half-typed
// number, an in-flight drag), which then lands on whichever
// effect moved into that slot.
key={node.id ?? `${node.type}-${i}`}
node={node}
index={i}
automatedTargets={automatedTargets}