@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.
@@ -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(initialOrderId);
117
- const authRef = useRef(initialAuth);
118
- const requestCounterRef = useRef(0);
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
- const lastReceivedRef = useRef(0);
122
- const isReconnectingRef = useRef(false);
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
- onComplete?.();
228
+ setCompletedTimestamp((prev) => prev ? Math.min(prev, timestamp) : timestamp);
229
+ onCompleteRef.current?.();
163
230
  }
164
231
  return currentProgresses;
165
232
  });
166
- }, [onComplete]);
167
- // Submit JSON-RPC request
168
- const submitRequest = useCallback((method, params, handler) => {
169
- const ws = wsRef.current;
170
- if (!ws || ws.readyState !== WebSocket.OPEN) {
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 = requestCounterRef.current++;
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
- responseHandlersRef.current.set(requestId, handler);
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 using JSON-RPC protocol (matching Svelte implementation)
365
+ // Connect to WebSocket uses module-level singleton cache
188
366
  const connect = useCallback((orderId, auth) => {
189
- orderIdRef.current = orderId;
190
- authRef.current = auth;
191
- setIsLoading(true);
192
- setError(null);
193
- isReconnectingRef.current = false;
194
- // Clear any existing reconnection timeout
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
- // Close existing connection
200
- if (wsRef.current) {
201
- wsRef.current.close();
202
- wsRef.current = null;
203
- }
204
- // Clear response handlers
205
- responseHandlersRef.current.clear();
206
- requestCounterRef.current = 0;
207
- // Reset reconnection attempts and delay for new connection
208
- reconnectAttemptsRef.current = 0;
209
- reconnectDelayRef.current = 1000;
210
- // Get WebSocket URL (matching Svelte implementation)
211
- // Use env var if available, otherwise use client baseUrl, otherwise construct from location
212
- // IMPORTANT: URL should be just /websocket with NO path or query params
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
- catch {
228
- // If URL parsing fails, try simple string replacement
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
- else if (typeof window !== 'undefined') {
233
- // Construct from current location
234
- wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/websocket`;
235
- }
236
- else {
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
- lastReceivedRef.current = Date.now();
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
- }, (error, result) => {
271
- if (error) {
272
- const err = new Error(error.message || 'Connection 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
- onError?.(err);
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
- setOrderStatus(status);
281
- if (status.deposit) {
282
- setDeposit({
283
- amount: status.deposit.amount,
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
- responseHandlersRef.current.set(connectRequestId, (statusError, statusResult) => {
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
- onError?.(err);
503
+ onErrorRef.current?.(err);
504
+ return;
505
+ }
506
+ if (isFullOrderStatus(statusResult)) {
507
+ applyFullOrderStatusToState(statusResult, conn);
320
508
  return;
321
509
  }
322
- // Handle status update
323
- const update = statusResult;
324
- handleStatusUpdate(update);
510
+ handleStatusUpdate(statusResult);
325
511
  });
326
512
  }
327
- onStatusUpdate?.(status);
513
+ onStatusUpdateRef.current?.(status);
328
514
  });
329
515
  };
330
516
  ws.onmessage = (event) => {
331
- lastReceivedRef.current = Date.now();
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
- // Get handler(s) for this request ID (matching Svelte pattern)
339
- // Status updates come as multiple responses to the same request ID
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 = (event) => {
352
- const err = new Error('WebSocket connection error');
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
- ws.onclose = async () => {
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
- responseHandlersRef.current.clear();
361
- // Automatic reconnection with retry limit and exponential backoff
362
- // Only reconnect if we still have valid orderId/auth and we're not already reconnecting
363
- // Also check that the orderId/auth matches what we're supposed to be tracking
364
- if (orderIdRef.current &&
365
- authRef.current &&
366
- !isReconnectingRef.current &&
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
- reconnectAttemptsRef.current = 0;
378
- reconnectDelayRef.current = 1000;
379
- }, 30000);
380
- return;
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 error = err instanceof Error ? err : new Error('Failed to connect to order tracking');
412
- setError(error);
568
+ const connectError = err instanceof Error ? err : new Error('Failed to connect to order tracking');
569
+ setError(connectError);
413
570
  setIsLoading(false);
414
- onError?.(error);
571
+ onErrorRef.current?.(connectError);
415
572
  }
416
- }, [submitRequest, updateOutput, onStatusUpdate, onError]);
417
- // Handle status updates
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 reconnection timeout
576
+ // Clear any pending reconnect timeout
465
577
  if (reconnectTimeoutRef.current) {
466
578
  clearTimeout(reconnectTimeoutRef.current);
467
579
  reconnectTimeoutRef.current = null;
468
580
  }
469
- isReconnectingRef.current = true; // Prevent reconnection
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
- if (wsRef.current) {
473
- wsRef.current.close();
474
- wsRef.current = null;
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 if orderId and auth are provided
605
+ // Auto-connect when orderId/auth are provided
606
+ // connect and disconnect have stable identities (all deps are stable)
480
607
  useEffect(() => {
481
- // Only connect if orderId/auth changed or if not yet connected
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
- // Only disconnect if we're actually disconnecting (orderId/auth cleared)
496
- if (!initialOrderId || !initialAuth) {
497
- lastConnectedOrderIdRef.current = undefined;
498
- lastConnectedAuthRef.current = undefined;
499
- disconnect();
500
- }
612
+ disconnect();
501
613
  };
502
- // Only depend on initialOrderId, initialAuth, and client
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,