Skip to content

Commit a936de5

Browse files
authored
fix(story): always deep-clone state on export to prevent frozen/proxied object errors (#87)
There was a problem where components updated the model’s state directly (e.g., via shallow assign) without notifying the store or triggering reactivity. This meant changes could bypass signals/observers, leading to inconsistent state, missed updates in the UI, and potential data corruption. All state changes should go through the store’s public API to ensure proper notifications and data integrity
1 parent a01be39 commit a936de5

File tree

7 files changed

+59
-12
lines changed

7 files changed

+59
-12
lines changed

src/components/canvas/blocks/Block.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ export class Block<T extends TBlock = TBlock, Props extends TBlockProps = TBlock
165165

166166
protected subscribe(id: TBlockId) {
167167
this.connectedState = selectBlockById<T>(this.context.graph, id);
168-
this.state = this.connectedState.$state.value;
169168
this.connectedState.setViewComponent(this);
170169
this.setState({
171170
...this.connectedState.$state.value,

src/graph.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { TBlock } from "./components/canvas/blocks/Block";
2+
import { Graph } from "./graph";
3+
4+
describe("Graph export/import and updateBlock integration", () => {
5+
function createBlock(): TBlock {
6+
return {
7+
id: "block1",
8+
is: "Block",
9+
x: 10,
10+
y: 20,
11+
width: 100,
12+
height: 50,
13+
selected: false,
14+
name: "TestBlock",
15+
anchors: [],
16+
};
17+
}
18+
19+
it("should allow export, import and updateBlock without errors (no frozen state)", (done) => {
20+
const graph1Node = document.createElement("div");
21+
const graph2Node = document.createElement("div");
22+
const block = createBlock();
23+
const graph1 = new Graph({ blocks: [block], connections: [] }, graph1Node);
24+
graph1.start();
25+
26+
setTimeout(() => {
27+
const exportedConfig = graph1.rootStore.getAsConfig();
28+
const graph2 = new Graph(exportedConfig, graph2Node);
29+
graph2.start();
30+
const updatedHeight = block.height + 10;
31+
expect(() => {
32+
graph2.api.updateBlock({ ...exportedConfig.blocks[0], height: updatedHeight });
33+
}).not.toThrow();
34+
const updatedBlock = graph2.rootStore.blocksList.$blocks.value[0];
35+
expect(updatedBlock.height).toBe(updatedHeight);
36+
done();
37+
}, 1000);
38+
});
39+
});

src/store/block/Block.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { computed, signal } from "@preact/signals-core";
22
import type { Signal } from "@preact/signals-core";
3+
import cloneDeep from "lodash/cloneDeep";
34

45
import { TAnchor } from "../../components/canvas/anchors";
56
import { Block, TBlock } from "../../components/canvas/blocks/Block";
@@ -154,7 +155,7 @@ export class BlockState<T extends TBlock = TBlock> {
154155
}
155156

156157
public asTBlock(): TBlock {
157-
return this.$state.value;
158+
return cloneDeep(this.$state.toJSON());
158159
}
159160
}
160161

src/store/connection/ConnectionList.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export class ConnectionsStore {
179179
}
180180
}
181181

182+
public toJSON() {
183+
return this.$connections.value.map((c) => c.asTConnection());
184+
}
185+
182186
public resetSelection() {
183187
this.setConnectionsSelection([], false);
184188
}

src/store/connection/ConnectionState.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { computed, signal } from "@preact/signals-core";
2+
import cloneDeep from "lodash/cloneDeep";
23

34
import { TConnectionColors } from "../../graphConfig";
45
import { ESelectionStrategy } from "../../utils/types/types";
@@ -84,7 +85,7 @@ export class ConnectionState<T extends TConnection = TConnection> {
8485
}
8586

8687
public asTConnection(): TConnection {
87-
return this.$state.value;
88+
return cloneDeep(this.$state.toJSON());
8889
}
8990

9091
public updateConnection(connection: Partial<TConnection>): void {

src/store/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { batch } from "@preact/signals-core";
2+
import cloneDeep from "lodash/cloneDeep";
23

34
import { Graph, TGraphConfig } from "../graph";
45

@@ -26,15 +27,12 @@ export class RootStore {
2627
}
2728

2829
public getAsConfig(): TGraphConfig {
29-
const blocks = this.blocksList.$blocks.value.map((block) => block.asTBlock());
30-
const connections = this.connectionsList.$connections.value.map((connection) => connection.asTConnection());
31-
32-
return {
30+
return cloneDeep({
3331
configurationName: this.configurationName,
34-
blocks,
35-
connections,
36-
settings: this.settings.asConfig,
37-
};
32+
blocks: this.blocksList.toJSON(),
33+
connections: this.connectionsList.toJSON(),
34+
settings: this.settings.toJSON(),
35+
});
3836
}
3937

4038
public reset() {

src/store/settings.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { computed, signal } from "@preact/signals-core";
2+
import cloneDeep from "lodash/cloneDeep";
23

34
import type { Block, TBlock } from "../components/canvas/blocks/Block";
45
import { BlockConnection } from "../components/canvas/connections/BlockConnection";
@@ -90,8 +91,12 @@ export class GraphEditorSettings {
9091
};
9192
});
9293

94+
public toJSON() {
95+
return cloneDeep(this.$settings.toJSON());
96+
}
97+
9398
public get asConfig(): TGraphSettingsConfig {
94-
return this.$settings.value;
99+
return this.toJSON();
95100
}
96101

97102
public reset() {

0 commit comments

Comments
 (0)