From 3b886497fab8860749b062450e29efa640848a9c Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 10:52:11 +0900 Subject: [PATCH 1/2] fix(store): announce the removal of a connection right away Resizing a display so that its connection was dropped crashed the editor with "Got unexpected undefined" in the connection renderer. A connection was deleted synchronously, but `didUnregister` was only emitted once the surrounding transaction had ended, a few microtasks later. A config change emits `didUpdate` for the node right after dropping the connections it invalidates, which re-renders the renderer of a connection subscribed to that node. At that point the connection was gone from the store, while the list of connections, which waits for `didUnregister`, still contained it. Emit `didUnregister` for connections as soon as they are deleted. Removing a connection cascades to nothing, so no listener can observe a half-removed state. Co-Authored-By: Claude Opus 5.5 --- src/store/connection.ts | 11 ++++++++--- src/store/nodePin.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/store/connection.ts b/src/store/connection.ts index 9089ba7..99f4545 100644 --- a/src/store/connection.ts +++ b/src/store/connection.ts @@ -137,10 +137,15 @@ export class CCConnectionStore extends EventEmitter { this.emit("willUnregister", connection); this.#connections.delete(connection.id); } + // Announced as soon as the connections are gone, not when the transaction ends. + // Otherwise a listener of a change announced in between, such as a renderer + // subscribed to the node at one end, would look up a connection that is gone + // while the list of connections still has it. Removing a connection cascades + // to nothing, so its listeners see no half-removed state. + for (const connection of connections) { + this.emit("didUnregister", connection); + } }); - for (const connection of connections) { - this.emit("didUnregister", connection); - } } /** diff --git a/src/store/nodePin.test.ts b/src/store/nodePin.test.ts index d723b2c..9cc680e 100644 --- a/src/store/nodePin.test.ts +++ b/src/store/nodePin.test.ts @@ -271,6 +271,23 @@ describe("Node pin store", () => { expect(store.connections.getMany()).toHaveLength(0); }); + it("should announce a dropped connection before the config change that drops it", () => { + const { store, displayNode } = connectBroadcastToDisplay(20 * 15); + const events: string[] = []; + store.connections.on("didUnregister", () => + events.push("connection removed"), + ); + store.nodes.on("didUpdate", () => events.push("node updated")); + + // A renderer of the connection subscribed to the display re-renders on "node + // updated", so by then the list of connections must no longer contain it + store.nodes.update(displayNode.id, { + config: { resolution: { x: 4, y: 3 } }, + }); + + expect(events).toEqual(["connection removed", "node updated"]); + }); + it("should keep the connections that stay consistent across a display config change", () => { const { store, displayNode } = connectBroadcastToDisplay(20 * 15); From 4ef6636ff73847e240f561df7901c424944285f0 Mon Sep 17 00:00:00 2001 From: Rn86222 Date: Sat, 14 Nov 2026 10:52:11 +0900 Subject: [PATCH 2/2] fix(store): clear the bit width cache as soon as a node pin is removed A node pin was deleted synchronously, but the bit widths cached while it existed were only dropped when its removal was announced at the end of the transaction. Until then, the bit width of a pin derived from it, such as the output of an aggregate node whose split input pin was removed, was still reported with the removed pin counted in. Co-Authored-By: Claude Opus 5.5 --- src/store/nodePin.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/store/nodePin.ts | 4 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/store/nodePin.test.ts b/src/store/nodePin.test.ts index 9cc680e..ddf6f4c 100644 --- a/src/store/nodePin.test.ts +++ b/src/store/nodePin.test.ts @@ -148,6 +148,44 @@ describe("Node pin store", () => { ).toEqual({ isFixed: true, bitWidth: 8 }); }); + it("should drop a removed input pin of an aggregate node from its output right away", () => { + const { store, rootComponent } = createRootStore(); + const node = registerNode(store, rootComponent, intrinsics.aggregate); + const firstInputNodePin = nullthrows( + store.nodePins + .getManyByNodeId(node.id) + .find( + (pin) => pin.componentPinId === intrinsics.aggregate.inputPin.In.id, + ), + ); + store.nodePins.update(firstInputNodePin.id, { manualBitWidth: 5 }); + const secondInputNodePin = CCNodePinStore.create({ + nodeId: node.id, + componentPinId: intrinsics.aggregate.inputPin.In.id, + order: firstInputNodePin.order + 1, + manualBitWidth: 3, + }); + store.nodePins.register(secondInputNodePin); + expect( + getNodePinBitWidth( + store, + node.id, + intrinsics.aggregate.outputPin.Out.id, + ), + ).toEqual({ isFixed: true, bitWidth: 8 }); + + // The removal is announced only when the transaction ends, but the pin is gone now + store.nodePins.unregister(secondInputNodePin.id); + + expect( + getNodePinBitWidth( + store, + node.id, + intrinsics.aggregate.outputPin.Out.id, + ), + ).toEqual({ isFixed: true, bitWidth: 5 }); + }); + it("should sum up the manual bit widths of the output pins of a decompose node", () => { const { store, rootComponent } = createRootStore(); const node = registerNode(store, rootComponent, intrinsics.decompose); diff --git a/src/store/nodePin.ts b/src/store/nodePin.ts index 0b00c77..90ee596 100644 --- a/src/store/nodePin.ts +++ b/src/store/nodePin.ts @@ -130,8 +130,10 @@ export class CCNodePinStore extends EventEmitter { await this.#store.transactionManager.runInTransaction(() => { this.emit("willUnregister", nodePin); this.#nodePins.delete(nodePin.id); + // Its removal is announced only when the transaction ends, but the bit widths + // derived from it are stale from now on + this.#clearBitWidthCache(); }); - this.#clearBitWidthCache(); this.emit("didUnregister", nodePin); this.#markedAsDeleted.delete(id); }