@silentswap/react 0.0.77 → 0.0.79
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/contexts/OrdersContext.d.ts +7 -0
- package/dist/contexts/OrdersContext.js +11 -0
- package/dist/contexts/SwapFormEstimatesContext.d.ts +16 -0
- package/dist/contexts/SwapFormEstimatesContext.js +445 -0
- package/dist/hooks/silent/useOrderTracking.d.ts +9 -2
- package/dist/hooks/silent/useOrderTracking.js +378 -269
- package/dist/hooks/usePlatformHealth.js +4 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/package.json +3 -3
|
@@ -98,10 +98,73 @@ export function getProgressFromStage(stage) {
|
|
|
98
98
|
return 0;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
const wsCache = new Map();
|
|
102
|
+
const pendingCleanups = new Map();
|
|
103
|
+
const STATUS_UPDATE_TYPES = ['deposit', 'stage', 'transaction', 'error'];
|
|
104
|
+
function getWsCacheKey(orderId, auth) {
|
|
105
|
+
return `${orderId}|${auth}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Type guard: result is a full order status snapshot (priority, outputs, optional metadata)
|
|
109
|
+
* rather than a discriminated StatusUpdate (type + data). Server may push full status
|
|
110
|
+
* with no `type` field.
|
|
111
|
+
*/
|
|
112
|
+
function isFullOrderStatus(result) {
|
|
113
|
+
if (typeof result !== 'object' || result === null)
|
|
114
|
+
return false;
|
|
115
|
+
const r = result;
|
|
116
|
+
const hasOutputs = Array.isArray(r.outputs) && r.outputs.length > 0;
|
|
117
|
+
const hasType = typeof r.type === 'string' && STATUS_UPDATE_TYPES.includes(r.type);
|
|
118
|
+
return hasOutputs && !hasType;
|
|
119
|
+
}
|
|
120
|
+
/** True if status has at least one output at FINALIZED */
|
|
121
|
+
function hasFinalizedOutput(status) {
|
|
122
|
+
return status.outputs?.some((o) => o.stage === OutputStage.FINALIZED) ?? false;
|
|
123
|
+
}
|
|
124
|
+
// Build WebSocket URL from client config (pure function, no React deps)
|
|
125
|
+
function buildWsUrl(client) {
|
|
126
|
+
let wsUrl;
|
|
127
|
+
if (typeof window !== 'undefined' && window.__WEBSOCKET_URL__) {
|
|
128
|
+
wsUrl = window.__WEBSOCKET_URL__;
|
|
129
|
+
}
|
|
130
|
+
else if (client) {
|
|
131
|
+
const baseUrl = client.baseUrl;
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(baseUrl);
|
|
134
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
135
|
+
wsUrl = `${protocol}//${url.host}/websocket`;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
wsUrl = baseUrl.replace(/^https?:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') + '/websocket';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (typeof window !== 'undefined') {
|
|
142
|
+
wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/websocket`;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
wsUrl = 'ws://localhost/websocket';
|
|
146
|
+
}
|
|
147
|
+
// Ensure URL is exactly: wss://domain/websocket (no extra path/query)
|
|
148
|
+
try {
|
|
149
|
+
const url = new URL(wsUrl);
|
|
150
|
+
const protocol = url.protocol === 'https:' || url.protocol === 'wss:' ? 'wss:' : 'ws:';
|
|
151
|
+
wsUrl = `${protocol}//${url.host}/websocket`;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
wsUrl = wsUrl.split('?')[0].split('#')[0].replace(/\/+$/, '') + '/websocket';
|
|
155
|
+
}
|
|
156
|
+
return wsUrl;
|
|
157
|
+
}
|
|
158
|
+
// ─── Hook ───────────────────────────────────────────────────────────────────
|
|
101
159
|
/**
|
|
102
160
|
* React hook for tracking SilentSwap orders via WebSocket
|
|
161
|
+
*
|
|
162
|
+
* Uses a module-level singleton: one WebSocket per order, reused across
|
|
163
|
+
* React lifecycle events (Strict Mode, re-renders, effect re-runs).
|
|
164
|
+
* Reconnection matches Svelte ws_order_subscribe: immediate reconnect,
|
|
165
|
+
* 5s delay if idle >90s, no exponential backoff.
|
|
103
166
|
*/
|
|
104
|
-
export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth: initialAuth, onStatusUpdate, onError, onComplete, } = {}) {
|
|
167
|
+
export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth: initialAuth, onStatusUpdate, onError, onComplete, fetchAssetPrice, } = {}) {
|
|
105
168
|
const [isConnected, setIsConnected] = useState(false);
|
|
106
169
|
const [isLoading, setIsLoading] = useState(false);
|
|
107
170
|
const [error, setError] = useState(null);
|
|
@@ -112,21 +175,25 @@ export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth:
|
|
|
112
175
|
const [statusTexts, setStatusTexts] = useState(['Connecting']);
|
|
113
176
|
const [completedTimestamp, setCompletedTimestamp] = useState(null);
|
|
114
177
|
const [isComplete, setIsComplete] = useState(false);
|
|
178
|
+
// Store callback props in refs — read from handlers without causing re-creation
|
|
179
|
+
const onStatusUpdateRef = useRef(onStatusUpdate);
|
|
180
|
+
const onErrorRef = useRef(onError);
|
|
181
|
+
const onCompleteRef = useRef(onComplete);
|
|
182
|
+
const fetchAssetPriceRef = useRef(fetchAssetPrice);
|
|
183
|
+
const clientRef = useRef(client);
|
|
184
|
+
onStatusUpdateRef.current = onStatusUpdate;
|
|
185
|
+
onErrorRef.current = onError;
|
|
186
|
+
onCompleteRef.current = onComplete;
|
|
187
|
+
fetchAssetPriceRef.current = fetchAssetPrice;
|
|
188
|
+
clientRef.current = client;
|
|
189
|
+
// Per-instance refs (point to the shared cached connection)
|
|
115
190
|
const wsRef = useRef(null);
|
|
116
|
-
const orderIdRef = useRef(
|
|
117
|
-
const authRef = useRef(
|
|
118
|
-
const
|
|
119
|
-
const responseHandlersRef = useRef(new Map());
|
|
191
|
+
const orderIdRef = useRef(undefined);
|
|
192
|
+
const authRef = useRef(undefined);
|
|
193
|
+
const cachedConnRef = useRef(null);
|
|
120
194
|
const reconnectTimeoutRef = useRef(null);
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const reconnectAttemptsRef = useRef(0);
|
|
124
|
-
const maxReconnectAttempts = 5; // Maximum number of reconnection attempts
|
|
125
|
-
const reconnectDelayRef = useRef(1000); // Initial delay in ms, will increase exponentially
|
|
126
|
-
// Track the last connected orderId/auth to prevent unnecessary reconnections
|
|
127
|
-
// These are used in both the auto-connect useEffect and the onclose handler
|
|
128
|
-
const lastConnectedOrderIdRef = useRef(undefined);
|
|
129
|
-
const lastConnectedAuthRef = useRef(undefined);
|
|
195
|
+
/** Latest order status (for same order) so we never overwrite finalized with stale "Verifying deposit" */
|
|
196
|
+
const orderStatusRef = useRef(null);
|
|
130
197
|
// Update output state when status changes
|
|
131
198
|
const updateOutput = useCallback((index, stage, timestamp = Date.now(), asset) => {
|
|
132
199
|
const progress = getProgressFromStage(stage);
|
|
@@ -149,29 +216,140 @@ export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth:
|
|
|
149
216
|
stage,
|
|
150
217
|
timestamp,
|
|
151
218
|
...(asset ? { asset } : {}),
|
|
152
|
-
// Preserve recipient if it exists
|
|
153
219
|
...(prev[index]?.recipient ? { recipient: prev[index].recipient } : {}),
|
|
154
220
|
};
|
|
155
221
|
return newOutputs;
|
|
156
222
|
});
|
|
157
223
|
// Check if all outputs are complete
|
|
224
|
+
// Use Math.min for earliest completion time (matching Svelte)
|
|
158
225
|
setProgresses((currentProgresses) => {
|
|
159
226
|
if (currentProgresses.every((p) => (p ?? 0) >= 1)) {
|
|
160
227
|
setIsComplete(true);
|
|
161
|
-
setCompletedTimestamp(timestamp);
|
|
162
|
-
|
|
228
|
+
setCompletedTimestamp((prev) => prev ? Math.min(prev, timestamp) : timestamp);
|
|
229
|
+
onCompleteRef.current?.();
|
|
163
230
|
}
|
|
164
231
|
return currentProgresses;
|
|
165
232
|
});
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
233
|
+
// Fetch USD price if missing (matching Svelte: k_asset?.priceUsd())
|
|
234
|
+
if (asset && asset.priceUsd === undefined && fetchAssetPriceRef.current) {
|
|
235
|
+
fetchAssetPriceRef.current(asset.caip19).then((price) => {
|
|
236
|
+
if (typeof price === 'number' && price > 0) {
|
|
237
|
+
setOutputs((prev) => {
|
|
238
|
+
const newOutputs = [...prev];
|
|
239
|
+
if (newOutputs[index]?.asset) {
|
|
240
|
+
newOutputs[index] = {
|
|
241
|
+
...newOutputs[index],
|
|
242
|
+
asset: { ...newOutputs[index].asset, priceUsd: price },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return newOutputs;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}).catch(() => { });
|
|
249
|
+
}
|
|
250
|
+
}, []);
|
|
251
|
+
// Apply full OrderStatus to state (and cache). Used for initial connect response and server pushes with no `type`.
|
|
252
|
+
// Never overwrite a more advanced state (e.g. FINALIZED) with a stale one (e.g. "Verifying deposit") when messages arrive out of order.
|
|
253
|
+
const applyFullOrderStatusToState = useCallback((status, cacheConn) => {
|
|
254
|
+
const current = orderStatusRef.current;
|
|
255
|
+
const currentFinalized = current && hasFinalizedOutput(current);
|
|
256
|
+
const incomingFinalized = hasFinalizedOutput(status);
|
|
257
|
+
if (currentFinalized && !incomingFinalized) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
orderStatusRef.current = status;
|
|
261
|
+
if (cacheConn) {
|
|
262
|
+
cacheConn.initialStatus = status;
|
|
263
|
+
}
|
|
264
|
+
setOrderStatus(status);
|
|
265
|
+
if (status.deposit) {
|
|
266
|
+
setDeposit({
|
|
267
|
+
amount: status.deposit.amount,
|
|
268
|
+
timestamp: status.deposit.timestamp,
|
|
269
|
+
duration: status.deposit.duration,
|
|
270
|
+
orderId: status.deposit.orderId,
|
|
271
|
+
tx: status.deposit.tx,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const numOutputs = status.outputs?.length ?? 1;
|
|
275
|
+
setProgresses(new Array(numOutputs).fill(undefined));
|
|
276
|
+
setStatusTexts(new Array(numOutputs).fill('Connecting'));
|
|
277
|
+
status.outputs?.forEach((output, index) => {
|
|
278
|
+
const outputAsset = output.output ?? (output.asset ? {
|
|
279
|
+
caip19: output.asset,
|
|
280
|
+
amount: output.value || '0',
|
|
281
|
+
decimals: 0,
|
|
282
|
+
} : undefined);
|
|
283
|
+
setOutputs((prev) => {
|
|
284
|
+
const newOutputs = [...prev];
|
|
285
|
+
newOutputs[index] = {
|
|
286
|
+
index,
|
|
287
|
+
stage: output.stage,
|
|
288
|
+
timestamp: output.timestamp,
|
|
289
|
+
recipient: output.recipient,
|
|
290
|
+
asset: outputAsset,
|
|
291
|
+
txs: output.txs,
|
|
292
|
+
};
|
|
293
|
+
return newOutputs;
|
|
294
|
+
});
|
|
295
|
+
updateOutput(index, output.stage, output.timestamp, outputAsset);
|
|
296
|
+
});
|
|
297
|
+
onStatusUpdateRef.current?.(status);
|
|
298
|
+
}, [updateOutput]);
|
|
299
|
+
// Handle status updates from WebSocket — also caches in the connection for future subscribers
|
|
300
|
+
const handleStatusUpdate = useCallback((update) => {
|
|
301
|
+
// Cache the update so new subscribers get it immediately
|
|
302
|
+
cachedConnRef.current?.statusUpdates.push(update);
|
|
303
|
+
switch (update.type) {
|
|
304
|
+
case 'deposit': {
|
|
305
|
+
setDeposit({
|
|
306
|
+
amount: update.data.amount,
|
|
307
|
+
timestamp: update.data.timestamp,
|
|
308
|
+
duration: update.data.duration,
|
|
309
|
+
orderId: update.data.orderId,
|
|
310
|
+
tx: update.data.tx,
|
|
311
|
+
});
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case 'stage': {
|
|
315
|
+
updateOutput(update.data.index, update.data.stage, update.data.timestamp, update.data.asset);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case 'transaction': {
|
|
319
|
+
setOutputs((prev) => {
|
|
320
|
+
const newOutputs = [...prev];
|
|
321
|
+
if (newOutputs[update.data.index]) {
|
|
322
|
+
const existingTxs = newOutputs[update.data.index].txs || {};
|
|
323
|
+
newOutputs[update.data.index] = {
|
|
324
|
+
...newOutputs[update.data.index],
|
|
325
|
+
txs: {
|
|
326
|
+
...existingTxs,
|
|
327
|
+
[update.data.kind]: {
|
|
328
|
+
txId: update.data.txId,
|
|
329
|
+
chain: update.data.chain,
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return newOutputs;
|
|
335
|
+
});
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case 'error': {
|
|
339
|
+
const err = new Error(update.data.message || 'Order tracking error');
|
|
340
|
+
setError(err);
|
|
341
|
+
onErrorRef.current?.(err);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}, [updateOutput]);
|
|
346
|
+
// Submit JSON-RPC request on the cached connection
|
|
347
|
+
const submitRequest = useCallback((conn, method, params, handler) => {
|
|
348
|
+
if (conn.ws.readyState !== WebSocket.OPEN) {
|
|
171
349
|
console.warn('WebSocket not connected, cannot send request');
|
|
172
350
|
return -1;
|
|
173
351
|
}
|
|
174
|
-
const requestId =
|
|
352
|
+
const requestId = conn.requestCounter++;
|
|
175
353
|
const request = {
|
|
176
354
|
jsonrpc: '2.0',
|
|
177
355
|
id: requestId,
|
|
@@ -179,168 +357,172 @@ export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth:
|
|
|
179
357
|
params,
|
|
180
358
|
};
|
|
181
359
|
if (handler) {
|
|
182
|
-
|
|
360
|
+
conn.responseHandlers.set(requestId, handler);
|
|
183
361
|
}
|
|
184
|
-
ws.send(JSON.stringify(request));
|
|
362
|
+
conn.ws.send(JSON.stringify(request));
|
|
185
363
|
return requestId;
|
|
186
364
|
}, []);
|
|
187
|
-
// Connect to WebSocket
|
|
365
|
+
// Connect to WebSocket — uses module-level singleton cache
|
|
188
366
|
const connect = useCallback((orderId, auth) => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
367
|
+
const key = getWsCacheKey(orderId, auth);
|
|
368
|
+
// Cancel any pending cleanup for this order (React Strict Mode recovery)
|
|
369
|
+
const pendingCleanup = pendingCleanups.get(key);
|
|
370
|
+
if (pendingCleanup) {
|
|
371
|
+
clearTimeout(pendingCleanup);
|
|
372
|
+
pendingCleanups.delete(key);
|
|
373
|
+
}
|
|
374
|
+
// Clear any pending reconnect timeout
|
|
195
375
|
if (reconnectTimeoutRef.current) {
|
|
196
376
|
clearTimeout(reconnectTimeoutRef.current);
|
|
197
377
|
reconnectTimeoutRef.current = null;
|
|
198
378
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// Auth is sent via JSON-RPC connect method, not in URL
|
|
214
|
-
let wsUrl;
|
|
215
|
-
if (typeof window !== 'undefined' && window.__WEBSOCKET_URL__) {
|
|
216
|
-
// WebSocket URL from window (for runtime override)
|
|
217
|
-
wsUrl = window.__WEBSOCKET_URL__;
|
|
218
|
-
}
|
|
219
|
-
else if (client) {
|
|
220
|
-
// Use client baseUrl (convert http/https to ws/wss)
|
|
221
|
-
const baseUrl = client.baseUrl;
|
|
222
|
-
try {
|
|
223
|
-
const url = new URL(baseUrl);
|
|
224
|
-
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
225
|
-
wsUrl = `${protocol}//${url.host}/websocket`;
|
|
379
|
+
orderIdRef.current = orderId;
|
|
380
|
+
authRef.current = auth;
|
|
381
|
+
// ── Singleton: reuse existing active connection for this order ──
|
|
382
|
+
const cached = wsCache.get(key);
|
|
383
|
+
if (cached && (cached.ws.readyState === WebSocket.OPEN || cached.ws.readyState === WebSocket.CONNECTING)) {
|
|
384
|
+
// Close any WS for a different order
|
|
385
|
+
if (wsRef.current && wsRef.current !== cached.ws) {
|
|
386
|
+
wsRef.current.close();
|
|
387
|
+
}
|
|
388
|
+
wsRef.current = cached.ws;
|
|
389
|
+
cachedConnRef.current = cached;
|
|
390
|
+
if (cached.ws.readyState === WebSocket.OPEN) {
|
|
391
|
+
setIsConnected(true);
|
|
392
|
+
setIsLoading(false);
|
|
226
393
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
wsUrl = baseUrl.replace(/^https?:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') + '/websocket';
|
|
394
|
+
else {
|
|
395
|
+
setIsLoading(true);
|
|
230
396
|
}
|
|
397
|
+
// Hydrate React state from cached data so new subscribers skip skeletons
|
|
398
|
+
if (cached.initialStatus) {
|
|
399
|
+
const status = cached.initialStatus;
|
|
400
|
+
orderStatusRef.current = status;
|
|
401
|
+
setOrderStatus(status);
|
|
402
|
+
if (status.deposit) {
|
|
403
|
+
setDeposit({
|
|
404
|
+
amount: status.deposit.amount,
|
|
405
|
+
timestamp: status.deposit.timestamp,
|
|
406
|
+
duration: status.deposit.duration,
|
|
407
|
+
orderId: status.deposit.orderId,
|
|
408
|
+
tx: status.deposit.tx,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const numOutputs = status.outputs?.length ?? 1;
|
|
412
|
+
setProgresses(new Array(numOutputs).fill(undefined));
|
|
413
|
+
setStatusTexts(new Array(numOutputs).fill('Connecting'));
|
|
414
|
+
status.outputs?.forEach((output, index) => {
|
|
415
|
+
// Use resolved output.output if available, otherwise construct from top-level fields
|
|
416
|
+
// At early stages (INIT), output.output is undefined but asset/value are present
|
|
417
|
+
const outputAsset = output.output ?? (output.asset ? {
|
|
418
|
+
caip19: output.asset,
|
|
419
|
+
amount: output.value || '0',
|
|
420
|
+
decimals: 0,
|
|
421
|
+
} : undefined);
|
|
422
|
+
setOutputs((prev) => {
|
|
423
|
+
const newOutputs = [...prev];
|
|
424
|
+
newOutputs[index] = {
|
|
425
|
+
index,
|
|
426
|
+
stage: output.stage,
|
|
427
|
+
timestamp: output.timestamp,
|
|
428
|
+
recipient: output.recipient,
|
|
429
|
+
asset: outputAsset,
|
|
430
|
+
txs: output.txs,
|
|
431
|
+
};
|
|
432
|
+
return newOutputs;
|
|
433
|
+
});
|
|
434
|
+
updateOutput(index, output.stage, output.timestamp, outputAsset);
|
|
435
|
+
});
|
|
436
|
+
// Replay any status updates that arrived after the initial connect
|
|
437
|
+
for (const update of cached.statusUpdates) {
|
|
438
|
+
handleStatusUpdate(update);
|
|
439
|
+
}
|
|
440
|
+
onStatusUpdateRef.current?.(status);
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
231
443
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
wsUrl = 'ws://localhost/websocket';
|
|
238
|
-
}
|
|
239
|
-
// Ensure URL doesn't have any path or query params (just /websocket)
|
|
240
|
-
// This is critical: the WebSocket URL must be exactly: wss://domain/websocket (no params)
|
|
241
|
-
try {
|
|
242
|
-
const url = new URL(wsUrl);
|
|
243
|
-
// Force protocol to ws/wss (remove any existing path/query)
|
|
244
|
-
const protocol = url.protocol === 'https:' || url.protocol === 'wss:' ? 'wss:' : 'ws:';
|
|
245
|
-
// Reconstruct URL with only host and /websocket path
|
|
246
|
-
wsUrl = `${protocol}//${url.host}/websocket`;
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
// If URL parsing fails, try to clean it manually
|
|
250
|
-
// Remove any query params, hash, or extra paths
|
|
251
|
-
wsUrl = wsUrl.split('?')[0].split('#')[0].replace(/\/+$/, '') + '/websocket';
|
|
444
|
+
// ── No active connection — create new WebSocket ──
|
|
445
|
+
// Close any existing WS for a different order
|
|
446
|
+
if (wsRef.current) {
|
|
447
|
+
wsRef.current.close();
|
|
448
|
+
wsRef.current = null;
|
|
252
449
|
}
|
|
450
|
+
// Remove stale cache entry
|
|
451
|
+
wsCache.delete(key);
|
|
452
|
+
// So first response for this order is not skipped (ref may still hold previous order's finalized state)
|
|
453
|
+
orderStatusRef.current = null;
|
|
454
|
+
setIsLoading(true);
|
|
455
|
+
setError(null);
|
|
456
|
+
const wsUrl = buildWsUrl(clientRef.current);
|
|
253
457
|
try {
|
|
254
458
|
const ws = new WebSocket(wsUrl);
|
|
459
|
+
// Create cached connection entry
|
|
460
|
+
const conn = {
|
|
461
|
+
ws,
|
|
462
|
+
orderId,
|
|
463
|
+
auth,
|
|
464
|
+
requestCounter: 0,
|
|
465
|
+
responseHandlers: new Map(),
|
|
466
|
+
lastReceived: 0,
|
|
467
|
+
reconnectTimeout: null,
|
|
468
|
+
initialStatus: null,
|
|
469
|
+
statusUpdates: [],
|
|
470
|
+
};
|
|
471
|
+
wsCache.set(key, conn);
|
|
255
472
|
wsRef.current = ws;
|
|
473
|
+
cachedConnRef.current = conn;
|
|
256
474
|
ws.onopen = () => {
|
|
257
|
-
|
|
475
|
+
conn.lastReceived = Date.now();
|
|
258
476
|
setIsConnected(true);
|
|
259
477
|
setIsLoading(false);
|
|
260
|
-
isReconnectingRef.current = false;
|
|
261
|
-
// Reset reconnection attempts on successful connection
|
|
262
|
-
reconnectAttemptsRef.current = 0;
|
|
263
|
-
reconnectDelayRef.current = 1000;
|
|
264
478
|
// Send connect JSON-RPC request (matching Svelte)
|
|
265
|
-
const connectRequestId = submitRequest('connect', {
|
|
479
|
+
const connectRequestId = submitRequest(conn, 'connect', {
|
|
266
480
|
auth: {
|
|
267
481
|
orderId,
|
|
268
482
|
viewingAuth: auth,
|
|
269
483
|
},
|
|
270
|
-
}, (
|
|
271
|
-
if (
|
|
272
|
-
const err = new Error(
|
|
484
|
+
}, (rpcError, result) => {
|
|
485
|
+
if (rpcError) {
|
|
486
|
+
const err = new Error(rpcError.message || 'Connection error');
|
|
273
487
|
setError(err);
|
|
274
488
|
setIsLoading(false);
|
|
275
|
-
|
|
489
|
+
onErrorRef.current?.(err);
|
|
276
490
|
return;
|
|
277
491
|
}
|
|
278
|
-
// Handle connected response
|
|
492
|
+
// Handle connected response — cache for future subscribers
|
|
279
493
|
const status = result;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
timestamp: status.deposit.timestamp,
|
|
285
|
-
duration: status.deposit.duration,
|
|
286
|
-
orderId: status.deposit.orderId,
|
|
287
|
-
tx: status.deposit.tx,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
// Initialize outputs
|
|
291
|
-
const numOutputs = status.outputs?.length ?? 1;
|
|
292
|
-
setProgresses(new Array(numOutputs).fill(undefined));
|
|
293
|
-
setStatusTexts(new Array(numOutputs).fill('Connecting'));
|
|
294
|
-
// Process initial output statuses
|
|
295
|
-
status.outputs?.forEach((output, index) => {
|
|
296
|
-
// Set output with recipient information (matching Svelte)
|
|
297
|
-
setOutputs((prev) => {
|
|
298
|
-
const newOutputs = [...prev];
|
|
299
|
-
newOutputs[index] = {
|
|
300
|
-
index,
|
|
301
|
-
stage: output.stage,
|
|
302
|
-
timestamp: output.timestamp,
|
|
303
|
-
recipient: output.recipient,
|
|
304
|
-
asset: output.output,
|
|
305
|
-
txs: output.txs,
|
|
306
|
-
};
|
|
307
|
-
return newOutputs;
|
|
308
|
-
});
|
|
309
|
-
// Update progress and status
|
|
310
|
-
updateOutput(index, output.stage, output.timestamp, output.output);
|
|
311
|
-
});
|
|
312
|
-
// Register status handler for subsequent updates (matching Svelte pattern)
|
|
313
|
-
// Status updates come as responses to the same request ID
|
|
494
|
+
conn.statusUpdates = [];
|
|
495
|
+
applyFullOrderStatusToState(status, conn);
|
|
496
|
+
// Register status handler for subsequent updates (matching Svelte pattern).
|
|
497
|
+
// Server may push full status (priority, outputs, metadata) with no `type` — treat as OrderStatus.
|
|
314
498
|
if (connectRequestId >= 0) {
|
|
315
|
-
|
|
499
|
+
conn.responseHandlers.set(connectRequestId, (statusError, statusResult) => {
|
|
316
500
|
if (statusError) {
|
|
317
501
|
const err = new Error(statusError.message || 'Status error');
|
|
318
502
|
setError(err);
|
|
319
|
-
|
|
503
|
+
onErrorRef.current?.(err);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (isFullOrderStatus(statusResult)) {
|
|
507
|
+
applyFullOrderStatusToState(statusResult, conn);
|
|
320
508
|
return;
|
|
321
509
|
}
|
|
322
|
-
|
|
323
|
-
const update = statusResult;
|
|
324
|
-
handleStatusUpdate(update);
|
|
510
|
+
handleStatusUpdate(statusResult);
|
|
325
511
|
});
|
|
326
512
|
}
|
|
327
|
-
|
|
513
|
+
onStatusUpdateRef.current?.(status);
|
|
328
514
|
});
|
|
329
515
|
};
|
|
330
516
|
ws.onmessage = (event) => {
|
|
331
|
-
|
|
517
|
+
conn.lastReceived = Date.now();
|
|
332
518
|
try {
|
|
333
519
|
const response = JSON.parse(event.data);
|
|
334
|
-
// Validate JSON-RPC response
|
|
335
520
|
if (response.jsonrpc !== '2.0' || typeof response.id !== 'number') {
|
|
336
521
|
return;
|
|
337
522
|
}
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
// So we DON'T delete the handler - it stays registered for subsequent updates
|
|
341
|
-
const handler = responseHandlersRef.current.get(response.id);
|
|
523
|
+
// Status updates reuse the same request ID — don't delete handler
|
|
524
|
+
const handler = conn.responseHandlers.get(response.id);
|
|
342
525
|
if (handler) {
|
|
343
|
-
// Call handler but DON'T delete it - status updates reuse the same request ID
|
|
344
526
|
handler(response.error, response.result);
|
|
345
527
|
}
|
|
346
528
|
}
|
|
@@ -348,161 +530,88 @@ export function useOrderTracking({ client, orderId: initialOrderId, viewingAuth:
|
|
|
348
530
|
console.warn('Failed to parse WebSocket message:', parseError);
|
|
349
531
|
}
|
|
350
532
|
};
|
|
351
|
-
ws.onerror = (
|
|
352
|
-
|
|
353
|
-
setError(err);
|
|
533
|
+
ws.onerror = () => {
|
|
534
|
+
setError(new Error('WebSocket connection error'));
|
|
354
535
|
setIsLoading(false);
|
|
355
|
-
// Don't call onError here to prevent infinite error logging
|
|
356
|
-
// The error will be handled by onclose reconnection logic
|
|
357
536
|
};
|
|
358
|
-
|
|
537
|
+
// Reconnection on close (matching Svelte ws_order_subscribe):
|
|
538
|
+
// Always reconnect if still tracking, 5s delay if idle >90s
|
|
539
|
+
ws.onclose = () => {
|
|
540
|
+
// Remove from cache (stale)
|
|
541
|
+
if (wsCache.get(key)?.ws === ws) {
|
|
542
|
+
wsCache.delete(key);
|
|
543
|
+
}
|
|
544
|
+
// If this WS was replaced by a new one, don't reconnect
|
|
545
|
+
if (wsRef.current !== ws)
|
|
546
|
+
return;
|
|
359
547
|
setIsConnected(false);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
if (orderIdRef.current &&
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
orderIdRef.current === lastConnectedOrderIdRef.current &&
|
|
368
|
-
authRef.current === lastConnectedAuthRef.current) {
|
|
369
|
-
// Check if we've exceeded max reconnection attempts
|
|
370
|
-
if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
|
371
|
-
const err = new Error(`WebSocket connection failed after ${maxReconnectAttempts} attempts`);
|
|
372
|
-
setError(err);
|
|
373
|
-
setIsLoading(false);
|
|
374
|
-
onError?.(err);
|
|
375
|
-
// Reset attempts after a longer delay (30 seconds) to allow retry later
|
|
548
|
+
conn.responseHandlers.clear();
|
|
549
|
+
conn.initialStatus = null;
|
|
550
|
+
conn.statusUpdates = [];
|
|
551
|
+
// Reconnect if still tracking this order (matching Svelte)
|
|
552
|
+
if (orderIdRef.current === orderId && authRef.current === auth) {
|
|
553
|
+
const idle = Date.now() - conn.lastReceived > 90_000;
|
|
554
|
+
if (idle) {
|
|
376
555
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
556
|
+
if (orderIdRef.current === orderId && authRef.current === auth) {
|
|
557
|
+
connect(orderId, auth);
|
|
558
|
+
}
|
|
559
|
+
}, 5000);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
connect(orderId, auth);
|
|
381
563
|
}
|
|
382
|
-
isReconnectingRef.current = true;
|
|
383
|
-
reconnectAttemptsRef.current += 1;
|
|
384
|
-
// Exponential backoff: delay increases with each attempt (1s, 2s, 4s, 8s, 16s)
|
|
385
|
-
const currentDelay = reconnectDelayRef.current;
|
|
386
|
-
reconnectDelayRef.current = Math.min(currentDelay * 2, 30000); // Cap at 30 seconds
|
|
387
|
-
// If last received was a while ago, add extra delay
|
|
388
|
-
const timeSinceLastReceived = Date.now() - lastReceivedRef.current;
|
|
389
|
-
const extraDelay = timeSinceLastReceived > 90000 ? 5000 : 0;
|
|
390
|
-
const totalDelay = currentDelay + extraDelay;
|
|
391
|
-
reconnectTimeoutRef.current = setTimeout(() => {
|
|
392
|
-
// Double-check that orderId/auth still match before reconnecting
|
|
393
|
-
if (orderIdRef.current &&
|
|
394
|
-
authRef.current &&
|
|
395
|
-
orderIdRef.current === lastConnectedOrderIdRef.current &&
|
|
396
|
-
authRef.current === lastConnectedAuthRef.current) {
|
|
397
|
-
isReconnectingRef.current = false; // Reset before connecting
|
|
398
|
-
connect(orderIdRef.current, authRef.current);
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
// Order changed, don't reconnect
|
|
402
|
-
isReconnectingRef.current = false;
|
|
403
|
-
reconnectAttemptsRef.current = 0;
|
|
404
|
-
reconnectDelayRef.current = 1000;
|
|
405
|
-
}
|
|
406
|
-
}, totalDelay);
|
|
407
564
|
}
|
|
408
565
|
};
|
|
409
566
|
}
|
|
410
567
|
catch (err) {
|
|
411
|
-
const
|
|
412
|
-
setError(
|
|
568
|
+
const connectError = err instanceof Error ? err : new Error('Failed to connect to order tracking');
|
|
569
|
+
setError(connectError);
|
|
413
570
|
setIsLoading(false);
|
|
414
|
-
|
|
571
|
+
onErrorRef.current?.(connectError);
|
|
415
572
|
}
|
|
416
|
-
}, [submitRequest, updateOutput,
|
|
417
|
-
//
|
|
418
|
-
const handleStatusUpdate = useCallback((update) => {
|
|
419
|
-
switch (update.type) {
|
|
420
|
-
case 'deposit': {
|
|
421
|
-
setDeposit({
|
|
422
|
-
amount: update.data.amount,
|
|
423
|
-
timestamp: update.data.timestamp,
|
|
424
|
-
duration: update.data.duration,
|
|
425
|
-
orderId: update.data.orderId,
|
|
426
|
-
tx: update.data.tx,
|
|
427
|
-
});
|
|
428
|
-
break;
|
|
429
|
-
}
|
|
430
|
-
case 'stage': {
|
|
431
|
-
updateOutput(update.data.index, update.data.stage, update.data.timestamp, update.data.asset);
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
case 'transaction': {
|
|
435
|
-
setOutputs((prev) => {
|
|
436
|
-
const newOutputs = [...prev];
|
|
437
|
-
if (newOutputs[update.data.index]) {
|
|
438
|
-
const existingTxs = newOutputs[update.data.index].txs || {};
|
|
439
|
-
newOutputs[update.data.index] = {
|
|
440
|
-
...newOutputs[update.data.index],
|
|
441
|
-
txs: {
|
|
442
|
-
...existingTxs,
|
|
443
|
-
[update.data.kind]: {
|
|
444
|
-
txId: update.data.txId,
|
|
445
|
-
chain: update.data.chain,
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
return newOutputs;
|
|
451
|
-
});
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
case 'error': {
|
|
455
|
-
const err = new Error(update.data.message || 'Order tracking error');
|
|
456
|
-
setError(err);
|
|
457
|
-
onError?.(err);
|
|
458
|
-
break;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}, [updateOutput, onError]);
|
|
462
|
-
// Disconnect from WebSocket
|
|
573
|
+
}, [submitRequest, updateOutput, handleStatusUpdate, applyFullOrderStatusToState]);
|
|
574
|
+
// Disconnect — schedules delayed cleanup so WS survives React Strict Mode re-mount
|
|
463
575
|
const disconnect = useCallback(() => {
|
|
464
|
-
// Clear
|
|
576
|
+
// Clear any pending reconnect timeout
|
|
465
577
|
if (reconnectTimeoutRef.current) {
|
|
466
578
|
clearTimeout(reconnectTimeoutRef.current);
|
|
467
579
|
reconnectTimeoutRef.current = null;
|
|
468
580
|
}
|
|
469
|
-
|
|
581
|
+
const orderId = orderIdRef.current;
|
|
582
|
+
const auth = authRef.current;
|
|
583
|
+
// Clear tracked order so onclose won't reconnect
|
|
470
584
|
orderIdRef.current = undefined;
|
|
471
585
|
authRef.current = undefined;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
586
|
+
orderStatusRef.current = null;
|
|
587
|
+
const ws = wsRef.current;
|
|
588
|
+
wsRef.current = null;
|
|
589
|
+
cachedConnRef.current = null;
|
|
590
|
+
// Schedule delayed cleanup — if connect() is called quickly (Strict Mode),
|
|
591
|
+
// it will cancel this timeout and reuse the cached WS
|
|
592
|
+
if (ws && orderId && auth) {
|
|
593
|
+
const key = getWsCacheKey(orderId, auth);
|
|
594
|
+
pendingCleanups.set(key, setTimeout(() => {
|
|
595
|
+
pendingCleanups.delete(key);
|
|
596
|
+
// Only close if still in cache (wasn't reclaimed by a new connect)
|
|
597
|
+
if (wsCache.get(key)?.ws === ws) {
|
|
598
|
+
wsCache.delete(key);
|
|
599
|
+
ws.close();
|
|
600
|
+
}
|
|
601
|
+
}, 500));
|
|
475
602
|
}
|
|
476
|
-
responseHandlersRef.current.clear();
|
|
477
603
|
setIsConnected(false);
|
|
478
604
|
}, []);
|
|
479
|
-
// Auto-connect
|
|
605
|
+
// Auto-connect when orderId/auth are provided
|
|
606
|
+
// connect and disconnect have stable identities (all deps are stable)
|
|
480
607
|
useEffect(() => {
|
|
481
|
-
|
|
482
|
-
// Also check if we're already connected to the same orderId/auth to prevent reconnection
|
|
483
|
-
const shouldConnect = initialOrderId &&
|
|
484
|
-
initialAuth &&
|
|
485
|
-
client &&
|
|
486
|
-
(lastConnectedOrderIdRef.current !== initialOrderId || lastConnectedAuthRef.current !== initialAuth) &&
|
|
487
|
-
!isReconnectingRef.current &&
|
|
488
|
-
(!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED);
|
|
489
|
-
if (shouldConnect) {
|
|
490
|
-
lastConnectedOrderIdRef.current = initialOrderId;
|
|
491
|
-
lastConnectedAuthRef.current = initialAuth;
|
|
608
|
+
if (initialOrderId && initialAuth && clientRef.current) {
|
|
492
609
|
connect(initialOrderId, initialAuth);
|
|
493
610
|
}
|
|
494
611
|
return () => {
|
|
495
|
-
|
|
496
|
-
if (!initialOrderId || !initialAuth) {
|
|
497
|
-
lastConnectedOrderIdRef.current = undefined;
|
|
498
|
-
lastConnectedAuthRef.current = undefined;
|
|
499
|
-
disconnect();
|
|
500
|
-
}
|
|
612
|
+
disconnect();
|
|
501
613
|
};
|
|
502
|
-
|
|
503
|
-
// Don't include connect/disconnect to prevent infinite loops
|
|
504
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
505
|
-
}, [initialOrderId, initialAuth, client]);
|
|
614
|
+
}, [initialOrderId, initialAuth, connect, disconnect]);
|
|
506
615
|
return {
|
|
507
616
|
// State
|
|
508
617
|
isConnected,
|