react-mirrorstate 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connection-manager.d.ts +4 -1
- package/dist/connection-manager.js +38 -32
- package/dist/index.js +14 -9
- package/package.json +1 -3
|
@@ -4,11 +4,14 @@ declare class WebSocketConnectionManager {
|
|
|
4
4
|
private isConnecting;
|
|
5
5
|
private listeners;
|
|
6
6
|
private currentStates;
|
|
7
|
+
private clientId;
|
|
8
|
+
private lastSeq;
|
|
9
|
+
private queuedUpdates;
|
|
7
10
|
private getWebSocketConfig;
|
|
8
11
|
private buildWebSocketURL;
|
|
12
|
+
private cleanup;
|
|
9
13
|
connect(): Promise<void>;
|
|
10
14
|
subscribe(name: string, listener: StateListener): () => void;
|
|
11
|
-
private lastSentState;
|
|
12
15
|
private pendingUpdates;
|
|
13
16
|
updateState(name: string, state: any): void;
|
|
14
17
|
getCurrentState(name: string): any;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import debug from "debug";
|
|
2
|
-
const logger = debug("mirrorstate:ws-manager");
|
|
3
1
|
class WebSocketConnectionManager {
|
|
4
2
|
ws = null;
|
|
5
3
|
isConnecting = false;
|
|
6
4
|
listeners = new Map();
|
|
7
5
|
currentStates = new Map();
|
|
6
|
+
clientId = null;
|
|
7
|
+
lastSeq = new Map();
|
|
8
|
+
queuedUpdates = new Map();
|
|
8
9
|
async getWebSocketConfig() {
|
|
9
10
|
try {
|
|
10
11
|
const config = await import("virtual:mirrorstate/config");
|
|
@@ -22,6 +23,12 @@ class WebSocketConnectionManager {
|
|
|
22
23
|
const host = window.location.host;
|
|
23
24
|
return `${protocol}//${host}${path}`;
|
|
24
25
|
}
|
|
26
|
+
cleanup() {
|
|
27
|
+
this.isConnecting = false;
|
|
28
|
+
this.ws = null;
|
|
29
|
+
this.pendingUpdates.forEach((timeout) => clearTimeout(timeout));
|
|
30
|
+
this.pendingUpdates.clear();
|
|
31
|
+
}
|
|
25
32
|
async connect() {
|
|
26
33
|
if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) {
|
|
27
34
|
return;
|
|
@@ -33,34 +40,37 @@ class WebSocketConnectionManager {
|
|
|
33
40
|
this.isConnecting = true;
|
|
34
41
|
const config = await this.getWebSocketConfig();
|
|
35
42
|
const wsUrl = this.buildWebSocketURL(config.WS_PATH);
|
|
36
|
-
logger(`Connecting to ${wsUrl}`);
|
|
37
43
|
this.ws = new WebSocket(wsUrl);
|
|
38
44
|
this.ws.onopen = () => {
|
|
39
45
|
this.isConnecting = false;
|
|
40
|
-
logger("WebSocket connected");
|
|
41
46
|
};
|
|
42
47
|
this.ws.onclose = () => {
|
|
43
|
-
this.
|
|
44
|
-
this.ws = null;
|
|
45
|
-
logger("WebSocket closed");
|
|
48
|
+
this.cleanup();
|
|
46
49
|
};
|
|
47
50
|
this.ws.onerror = () => {
|
|
48
|
-
this.isConnecting = false;
|
|
49
|
-
this.ws = null;
|
|
50
51
|
console.error("WebSocket error");
|
|
52
|
+
this.cleanup();
|
|
51
53
|
};
|
|
52
54
|
this.ws.onmessage = (event) => {
|
|
53
55
|
try {
|
|
54
56
|
const data = JSON.parse(event.data);
|
|
55
|
-
if (data.type === "
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
if (data.type === "connected") {
|
|
58
|
+
this.clientId = data.clientId;
|
|
59
|
+
// Flush any queued updates
|
|
60
|
+
this.queuedUpdates.forEach((state, name) => {
|
|
61
|
+
this.updateState(name, state);
|
|
62
|
+
});
|
|
63
|
+
this.queuedUpdates.clear();
|
|
64
|
+
return;
|
|
59
65
|
}
|
|
60
66
|
if (data.type === "fileChange") {
|
|
61
|
-
|
|
62
|
-
this.
|
|
63
|
-
|
|
67
|
+
// Only apply updates with a higher sequence number
|
|
68
|
+
const currentSeq = this.lastSeq.get(data.name) ?? -1;
|
|
69
|
+
if (data.seq !== undefined && data.seq > currentSeq) {
|
|
70
|
+
this.lastSeq.set(data.name, data.seq);
|
|
71
|
+
this.currentStates.set(data.name, data.state);
|
|
72
|
+
this.notifyListeners(data.name, data.state);
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
catch (error) {
|
|
@@ -75,10 +85,10 @@ class WebSocketConnectionManager {
|
|
|
75
85
|
this.listeners.get(name).add(listener);
|
|
76
86
|
// Connect if not already connected (dev mode)
|
|
77
87
|
this.connect();
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
// Don't immediately notify with currentStates here - it might be stale
|
|
89
|
+
// and could revert optimistic updates. The component initializes from
|
|
90
|
+
// INITIAL_STATES, and the server will send initialState messages when
|
|
91
|
+
// the connection is established, which will update all subscribers.
|
|
82
92
|
return () => {
|
|
83
93
|
const nameListeners = this.listeners.get(name);
|
|
84
94
|
if (nameListeners) {
|
|
@@ -89,10 +99,10 @@ class WebSocketConnectionManager {
|
|
|
89
99
|
}
|
|
90
100
|
};
|
|
91
101
|
}
|
|
92
|
-
lastSentState = new Map();
|
|
93
102
|
pendingUpdates = new Map();
|
|
94
103
|
updateState(name, state) {
|
|
95
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
104
|
+
if (this.ws?.readyState !== WebSocket.OPEN || !this.clientId) {
|
|
105
|
+
this.queuedUpdates.set(name, state);
|
|
96
106
|
return;
|
|
97
107
|
}
|
|
98
108
|
// Cancel any pending update for this state name
|
|
@@ -100,22 +110,18 @@ class WebSocketConnectionManager {
|
|
|
100
110
|
if (pendingUpdate) {
|
|
101
111
|
clearTimeout(pendingUpdate);
|
|
102
112
|
}
|
|
103
|
-
// Check if this is actually a different state
|
|
104
|
-
const lastState = this.lastSentState.get(name);
|
|
105
|
-
if (lastState === state) {
|
|
106
|
-
logger(`Skipping duplicate state update for ${name}`);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
113
|
// Debounce rapid updates
|
|
110
114
|
const timeout = setTimeout(() => {
|
|
111
|
-
if (!this.ws) {
|
|
115
|
+
if (!this.ws || !this.clientId) {
|
|
112
116
|
return;
|
|
113
117
|
}
|
|
114
|
-
this.ws.send(JSON.stringify({
|
|
118
|
+
this.ws.send(JSON.stringify({
|
|
119
|
+
clientId: this.clientId,
|
|
120
|
+
name,
|
|
121
|
+
state
|
|
122
|
+
}));
|
|
115
123
|
this.currentStates.set(name, state);
|
|
116
|
-
this.lastSentState.set(name, state);
|
|
117
124
|
this.pendingUpdates.delete(name);
|
|
118
|
-
logger(`Sent state update for ${name}`);
|
|
119
125
|
}, 10);
|
|
120
126
|
this.pendingUpdates.set(name, timeout);
|
|
121
127
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,30 +3,35 @@ import { produce } from "immer";
|
|
|
3
3
|
import { connectionManager } from "./connection-manager";
|
|
4
4
|
import { INITIAL_STATES } from "virtual:mirrorstate/initial-states";
|
|
5
5
|
export function useMirrorState(name, initialValue) {
|
|
6
|
-
|
|
6
|
+
// Capture initialValue once on first render to make it stable
|
|
7
|
+
// This prevents re-renders when users pass inline objects/arrays
|
|
8
|
+
const initialValueRef = useRef(initialValue);
|
|
9
|
+
const [state, setState] = useState(() => INITIAL_STATES?.[name] ?? initialValueRef.current);
|
|
7
10
|
const hasCreatedFile = useRef(false);
|
|
8
11
|
useEffect(() => {
|
|
9
12
|
// Subscribe to state changes for this name
|
|
13
|
+
// Echo filtering happens in connection-manager using vector clocks
|
|
10
14
|
const unsubscribe = connectionManager.subscribe(name, (newState) => {
|
|
11
15
|
setState(newState);
|
|
12
16
|
});
|
|
13
17
|
// If file doesn't exist and initialValue was provided, create it
|
|
14
18
|
if (INITIAL_STATES?.[name] === undefined &&
|
|
15
|
-
|
|
19
|
+
initialValueRef.current !== undefined &&
|
|
16
20
|
!hasCreatedFile.current) {
|
|
17
21
|
hasCreatedFile.current = true;
|
|
18
|
-
connectionManager.updateState(name,
|
|
22
|
+
connectionManager.updateState(name, initialValueRef.current);
|
|
19
23
|
}
|
|
20
24
|
return () => {
|
|
21
25
|
unsubscribe();
|
|
22
26
|
};
|
|
23
|
-
}, [name
|
|
27
|
+
}, [name]);
|
|
24
28
|
const updateMirrorState = (updater) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const currentState = connectionManager.getCurrentState(name) ?? state;
|
|
30
|
+
const newState = produce(currentState, updater);
|
|
31
|
+
// Send update to connection manager (includes vector clock tracking)
|
|
32
|
+
connectionManager.updateState(name, newState);
|
|
33
|
+
// Optimistic local update for immediate UI feedback
|
|
34
|
+
setState(newState);
|
|
30
35
|
};
|
|
31
36
|
return [state, updateMirrorState];
|
|
32
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-mirrorstate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React library for bidirectional state synchronization with MirrorState",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -22,11 +22,9 @@
|
|
|
22
22
|
"directory": "packages/react-mirrorstate"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"debug": "^4.4.3",
|
|
26
25
|
"immer": "^10.1.3"
|
|
27
26
|
},
|
|
28
27
|
"devDependencies": {
|
|
29
|
-
"@types/debug": "^4.1.12",
|
|
30
28
|
"@types/react": "^19.2.2",
|
|
31
29
|
"react": "^19.2.0",
|
|
32
30
|
"typescript": "^5.9.3"
|