@0xmonaco/react 1.0.51 → 1.0.53

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.
@@ -1,21 +1,199 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useMonacoSDK } from "../useMonaco";
3
+ /**
4
+ * Page size for the balance read. The endpoint caps it at 100, and asking for
5
+ * the maximum keeps a full account to one request in practice.
6
+ */
7
+ const BALANCES_PAGE_SIZE = 100;
8
+ /**
9
+ * Hard ceiling on pages walked, mirroring the server snapshot's own guard: a
10
+ * malformed `totalPages` must not drive an unbounded read loop. At the page
11
+ * size above this covers far more assets than an account can hold.
12
+ */
13
+ const MAX_BALANCE_PAGES = 20;
14
+ /**
15
+ * Read every page of the user's balances.
16
+ *
17
+ * The endpoint is paginated and defaults to 20 rows, but the WebSocket snapshot
18
+ * walks the whole set — so a single-page read left any asset past the first
19
+ * page permanently unreachable, and a snapshot naming one would ask for a
20
+ * repair read that could never satisfy it.
21
+ *
22
+ * Termination is progress-based rather than trusting `totalPages`: a page
23
+ * shorter than the one requested is the last one, whatever the count claims,
24
+ * and the ceiling bounds the loop regardless.
25
+ *
26
+ * Rows are deduplicated by token, keeping the first occurrence, the same way
27
+ * `useUserOrders` deduplicates its paginated read. Offset paging is re-derived
28
+ * against whatever the table looks like when each page runs, so a row that
29
+ * moves between two reads can be returned twice — and a duplicate here would
30
+ * become two list entries for one asset, only the first of which any later
31
+ * update would find.
32
+ */
33
+ async function readAllBalances(profile) {
34
+ const all = [];
35
+ const seen = new Set();
36
+ for (let page = 1; page <= MAX_BALANCE_PAGES; page++) {
37
+ const response = await profile.getUserBalances({ page, pageSize: BALANCES_PAGE_SIZE });
38
+ for (const row of response.balances) {
39
+ const token = row.token.toLowerCase();
40
+ if (seen.has(token))
41
+ continue;
42
+ seen.add(token);
43
+ all.push(row);
44
+ }
45
+ if (response.balances.length < BALANCES_PAGE_SIZE)
46
+ break;
47
+ if (Number.isFinite(response.totalPages) && page >= response.totalPages)
48
+ break;
49
+ }
50
+ return all;
51
+ }
3
52
  /**
4
53
  * Update an AccountBalance with data from a WebSocket balance event
5
54
  */
6
55
  export function updateBalanceFromEvent(balance, event) {
7
- const totalBalance = totalBalanceFromEvent(balance, event);
8
- const totalBalanceRaw = totalBalanceRawFromEvent(balance, event);
56
+ return updateBalanceFromData(balance, event.data);
57
+ }
58
+ function updateBalanceFromData(balance, data) {
59
+ const totalBalance = totalBalanceFromData(balance, data);
60
+ const totalBalanceRaw = totalBalanceRawFromData(balance, data);
9
61
  return {
10
62
  ...balance,
11
- availableBalance: event.data.available,
12
- availableBalanceRaw: event.data.availableRaw,
13
- lockedBalance: event.data.locked,
14
- lockedBalanceRaw: event.data.lockedRaw,
63
+ availableBalance: data.available,
64
+ availableBalanceRaw: data.availableRaw,
65
+ lockedBalance: data.locked,
66
+ lockedBalanceRaw: data.lockedRaw,
15
67
  totalBalance: totalBalance,
16
68
  totalBalanceRaw: totalBalanceRaw,
17
69
  };
18
70
  }
71
+ /**
72
+ * Apply a subscribe-time balances snapshot to the current list.
73
+ *
74
+ * The snapshot is the user's COMPLETE balance set, which is what makes this a
75
+ * different operation from applying a live event rather than a loop over one:
76
+ *
77
+ * - A held asset the snapshot omits has no balance left, so it is zeroed rather
78
+ * than left showing a figure that is no longer true. The row itself stays —
79
+ * a portfolio needs to render "0 WSEI", not to lose the line.
80
+ * - The snapshot's totals are taken VERBATIM. A snapshot row is built from the
81
+ * same accounts-service read that produced the REST row, so its `total`
82
+ * already is the REST `totalBalance` with margin collateral included —
83
+ * unlike a live event, whose `total` can describe the spot balance alone and
84
+ * therefore needs {@link updateBalanceFromEvent}'s margin reconciliation.
85
+ * Running a snapshot row through that would add the derived margin component
86
+ * a second time.
87
+ * - A row for an asset the list does not hold cannot be completed here: the
88
+ * wire payload carries no `assetId`, `decimals` or wrapped-native flag. That
89
+ * case is reported by {@link snapshotHasUnknownAsset} rather than from here,
90
+ * so the caller can decide to re-read WITHOUT doing it inside a state
91
+ * updater — React invokes an updater during render, and may invoke it more
92
+ * than once, so a flag assigned inside one is not readable afterwards.
93
+ *
94
+ * Pure, so the caller can use it inside a state updater.
95
+ */
96
+ export function applyBalanceSnapshot(current, items) {
97
+ const byToken = new Map(items.map((item) => [item.tokenAddress.toLowerCase(), item]));
98
+ const balances = current.map((held) => {
99
+ const item = byToken.get(held.token.toLowerCase());
100
+ if (!item) {
101
+ return {
102
+ ...held,
103
+ availableBalance: "0",
104
+ lockedBalance: "0",
105
+ totalBalance: "0",
106
+ availableBalanceRaw: "0",
107
+ lockedBalanceRaw: "0",
108
+ totalBalanceRaw: "0",
109
+ };
110
+ }
111
+ return {
112
+ ...held,
113
+ availableBalance: item.available,
114
+ lockedBalance: item.locked,
115
+ totalBalance: item.total,
116
+ availableBalanceRaw: item.availableRaw,
117
+ lockedBalanceRaw: item.lockedRaw,
118
+ totalBalanceRaw: item.totalRaw,
119
+ };
120
+ });
121
+ return { balances, hasUnknownAsset: snapshotHasUnknownAsset(current, items) };
122
+ }
123
+ /**
124
+ * Whether the snapshot names an asset the current list does not hold.
125
+ *
126
+ * Split out of {@link applyBalanceSnapshot} so a caller can ask the question
127
+ * before entering a state updater: the answer decides whether to fire a REST
128
+ * re-read, and a side effect must not depend on an updater having run.
129
+ */
130
+ export function snapshotHasUnknownAsset(current, items) {
131
+ const heldTokens = new Set(current.map((held) => held.token.toLowerCase()));
132
+ return items.some((item) => !heldTokens.has(item.tokenAddress.toLowerCase()));
133
+ }
134
+ /**
135
+ * Merge a background REST read into the current list without disturbing it.
136
+ *
137
+ * The repair this serves has one job: learn about an asset the list does not
138
+ * hold yet, because the WebSocket payload carries no `assetId`, `decimals` or
139
+ * wrapped-native flag and so cannot introduce one on its own.
140
+ *
141
+ * It must not do more than that. The subscription stays live while the request
142
+ * is in flight, so an event or snapshot can land in between — and replacing the
143
+ * list with the response would roll those newer values back, including the
144
+ * authoritative snapshot figures that triggered the read in the first place.
145
+ * Rows already held therefore keep their live values untouched, and only
146
+ * genuinely new assets are appended.
147
+ *
148
+ * The initial load and the public `refresh()` still replace: the first runs
149
+ * before the subscription opens, so nothing newer can exist to lose, and the
150
+ * second is a deliberate "give me the server's answer" action.
151
+ */
152
+ export function reconcileBalances(current, fetched) {
153
+ const held = new Set(current.map((row) => row.token.toLowerCase()));
154
+ const added = fetched.filter((row) => !held.has(row.token.toLowerCase()));
155
+ return added.length === 0 ? current : [...current, ...added];
156
+ }
157
+ /**
158
+ * Overlay buffered wire state onto rows a REST read has just supplied metadata
159
+ * for.
160
+ *
161
+ * A frame naming a token the list does not hold yet is dropped by the event
162
+ * handler — there is no row to apply it to — and the repair read that follows
163
+ * may have queried the server BEFORE that frame. Appending its result would
164
+ * then leave the token at a value the stream has already moved past, with no
165
+ * later frame to correct it because every subsequent frame finds a row and
166
+ * takes the update path. Buffering the newest wire state for an unheld token
167
+ * and laying it over the row once metadata arrives is what closes that.
168
+ *
169
+ * Only entries newer than the read are applied. One buffered BEFORE the read
170
+ * started is already reflected in its result, and re-applying it would roll the
171
+ * row back to that older value.
172
+ */
173
+ export function applyBufferedBalances(rows, buffered, readSeq) {
174
+ if (buffered.size === 0)
175
+ return rows;
176
+ return rows.map((row) => {
177
+ const entry = buffered.get(row.token.toLowerCase());
178
+ if (!entry || entry.seq <= readSeq)
179
+ return row;
180
+ if (entry.source === "event") {
181
+ // A live non-margin frame can carry spot total only. Replaying it as a
182
+ // snapshot would replace the REST row's margin component instead of
183
+ // preserving it through the ordinary event reconciliation.
184
+ return updateBalanceFromData(row, entry.data);
185
+ }
186
+ return {
187
+ ...row,
188
+ availableBalance: entry.data.available,
189
+ lockedBalance: entry.data.locked,
190
+ totalBalance: entry.data.total,
191
+ availableBalanceRaw: entry.data.availableRaw,
192
+ lockedBalanceRaw: entry.data.lockedRaw,
193
+ totalBalanceRaw: entry.data.totalRaw,
194
+ };
195
+ });
196
+ }
19
197
  function parseDecimal(value) {
20
198
  const match = value.trim().match(/^(-?)(\d+)(?:\.(\d+))?$/);
21
199
  if (!match)
@@ -60,33 +238,33 @@ function decimalGreaterThanZero(value) {
60
238
  const parsed = parseDecimal(value);
61
239
  return parsed ? parsed.value > 0n : false;
62
240
  }
63
- function totalBalanceFromEvent(balance, event) {
64
- if (event.data.reason === "margin_transfer_in" || event.data.reason === "margin_transfer_out") {
65
- return event.data.total;
241
+ function totalBalanceFromData(balance, data) {
242
+ if (data.reason === "margin_transfer_in" || data.reason === "margin_transfer_out") {
243
+ return data.total;
66
244
  }
67
- const eventExtraTotal = decimalDifference(event.data.total, event.data.available, event.data.locked);
245
+ const eventExtraTotal = decimalDifference(data.total, data.available, data.locked);
68
246
  if (eventExtraTotal && decimalGreaterThanZero(eventExtraTotal)) {
69
- return event.data.total;
247
+ return data.total;
70
248
  }
71
249
  const previousMarginTotal = decimalDifference(balance.totalBalance, balance.availableBalance, balance.lockedBalance);
72
250
  if (!previousMarginTotal || !decimalGreaterThanZero(previousMarginTotal)) {
73
- return event.data.total;
251
+ return data.total;
74
252
  }
75
- return decimalSum(event.data.total, previousMarginTotal) ?? event.data.total;
253
+ return decimalSum(data.total, previousMarginTotal) ?? data.total;
76
254
  }
77
- function totalBalanceRawFromEvent(balance, event) {
78
- if (event.data.reason === "margin_transfer_in" || event.data.reason === "margin_transfer_out") {
79
- return event.data.totalRaw;
255
+ function totalBalanceRawFromData(balance, data) {
256
+ if (data.reason === "margin_transfer_in" || data.reason === "margin_transfer_out") {
257
+ return data.totalRaw;
80
258
  }
81
- const eventExtraTotalRaw = decimalDifference(event.data.totalRaw, event.data.availableRaw, event.data.lockedRaw);
259
+ const eventExtraTotalRaw = decimalDifference(data.totalRaw, data.availableRaw, data.lockedRaw);
82
260
  if (eventExtraTotalRaw && decimalGreaterThanZero(eventExtraTotalRaw)) {
83
- return event.data.totalRaw;
261
+ return data.totalRaw;
84
262
  }
85
263
  const previousMarginTotalRaw = decimalDifference(balance.totalBalanceRaw, balance.availableBalanceRaw, balance.lockedBalanceRaw);
86
264
  if (!previousMarginTotalRaw || !decimalGreaterThanZero(previousMarginTotalRaw)) {
87
- return event.data.totalRaw;
265
+ return data.totalRaw;
88
266
  }
89
- return decimalSum(event.data.totalRaw, previousMarginTotalRaw) ?? event.data.totalRaw;
267
+ return decimalSum(data.totalRaw, previousMarginTotalRaw) ?? data.totalRaw;
90
268
  }
91
269
  /**
92
270
  * Hook for subscribing to real-time user balance updates via WebSocket (authenticated)
@@ -98,39 +276,193 @@ function totalBalanceRawFromEvent(balance, event) {
98
276
  export function useUserBalances() {
99
277
  const { sdk } = useMonacoSDK();
100
278
  const [balances, setBalances] = useState([]);
279
+ /**
280
+ * The latest applied balances, readable synchronously from a WebSocket
281
+ * callback — which closes over the `balances` of the render that installed
282
+ * it, and so cannot see anything applied since.
283
+ *
284
+ * Written by {@link applyBalances} at the moment a list is applied, not from
285
+ * inside a state updater, so it never lags the state it mirrors.
286
+ */
287
+ const balancesRef = useRef([]);
288
+ /**
289
+ * Bumped by effect cleanup. Every REST read captures this when it starts and
290
+ * declines to write anything if it no longer matches, so a response still in
291
+ * flight when the SDK changed cannot land in the new client's state — which
292
+ * on an account switch would mean showing, or appending, the previous user's
293
+ * balances. Mirrors `useUserOrders`' `readGeneration`.
294
+ */
295
+ const readGeneration = useRef(0);
296
+ /**
297
+ * Whether a background repair read is already in flight.
298
+ *
299
+ * A burst of frames naming a token the list does not hold yet would otherwise
300
+ * start one request per frame: `balancesRef` cannot learn the token until the
301
+ * first response lands, so every frame in the burst sees it as unknown.
302
+ */
303
+ const repairInFlight = useRef(false);
304
+ /**
305
+ * Whether a repair was asked for while one was already in flight.
306
+ *
307
+ * Coalescing alone would LOSE those updates rather than merely skip requests.
308
+ * A frame for a still-unknown token is also dropped by the event handler,
309
+ * because there is no row to apply it to — so if the in-flight read had
310
+ * already queried the server before that frame, it appends a value that is
311
+ * already stale and nothing later repairs it. This trailing flag runs exactly
312
+ * one more read after the first settles, which necessarily observes state
313
+ * newer than every frame that set it.
314
+ */
315
+ const repairDirty = useRef(false);
316
+ /**
317
+ * Newest wire state for tokens the list does not hold yet, with a monotonic
318
+ * sequence so a read can tell which entries postdate it. Cleared per token
319
+ * once a read has supplied that token's metadata; entries for tokens a read
320
+ * did not return stay buffered for the next one.
321
+ */
322
+ const bufferedUnheld = useRef(new Map());
323
+ const bufferSeq = useRef(0);
101
324
  const [loading, setLoading] = useState(false);
102
325
  const [error, setError] = useState(null);
103
326
  const [subscribed, setSubscribed] = useState(false);
104
327
  const clearError = useCallback(() => setError(null), []);
105
- const fetchBalances = useCallback(async () => {
328
+ /** Remember a frame for a token the list cannot represent yet. */
329
+ const bufferUnheld = useCallback((items, source) => {
330
+ const held = new Set(balancesRef.current.map((row) => row.token.toLowerCase()));
331
+ for (const item of items) {
332
+ const token = item.tokenAddress.toLowerCase();
333
+ if (held.has(token))
334
+ continue;
335
+ bufferSeq.current += 1;
336
+ bufferedUnheld.current.set(token, { data: item, seq: bufferSeq.current, source });
337
+ }
338
+ }, []);
339
+ /**
340
+ * Apply a new list, reducing against the ref rather than through a functional
341
+ * state updater.
342
+ *
343
+ * The ref is the reducing source of truth and `setBalances` only mirrors it
344
+ * for rendering. That is what removes every dependence on updater timing:
345
+ * React invokes a functional updater during render and may invoke it more
346
+ * than once, so neither a side effect nor a value another statement needs can
347
+ * live inside one. Reducing here instead is also correct under a burst of
348
+ * WebSocket frames, where consecutive plain `setBalances(value)` calls would
349
+ * each have closed over a stale list.
350
+ */
351
+ const applyBalances = useCallback((next) => {
352
+ const applied = typeof next === "function" ? next(balancesRef.current) : next;
353
+ balancesRef.current = applied;
354
+ setBalances(applied);
355
+ }, []);
356
+ /**
357
+ * Read the balance list and apply it.
358
+ *
359
+ * `replace` is only correct while nothing newer can exist to lose — the
360
+ * initial load, which runs before the subscription opens, and the public
361
+ * `refresh()`. A read that runs with the socket live must `reconcile`, or an
362
+ * event arriving while the request is in flight is overwritten by the older
363
+ * response.
364
+ */
365
+ const loadBalances = useCallback(async (mode) => {
106
366
  if (!sdk?.profile)
107
367
  return;
108
- setLoading(true);
368
+ const generation = readGeneration.current;
369
+ // Captured where the read starts: only frames buffered AFTER this point
370
+ // are newer than what the response will carry.
371
+ const readSeq = bufferSeq.current;
372
+ // A read belonging to a previous client must not write ANY of this state
373
+ // — not the rows, not the error, not the loading flag. Clearing `loading`
374
+ // from a stale request would mark the hook loaded while the new client's
375
+ // read is still running, and a stale rejection would leave `error` set
376
+ // after the new load had already succeeded.
377
+ const current = () => generation === readGeneration.current;
378
+ // `loading` describes the INITIAL load, which consumers render a spinner
379
+ // from. A background repair runs with the list already on screen, so it
380
+ // must not flash it.
381
+ if (mode === "replace")
382
+ setLoading(true);
109
383
  try {
110
- const response = await sdk.profile.getUserBalances();
111
- setBalances(response.balances);
384
+ const fetched = await readAllBalances(sdk.profile);
385
+ if (current()) {
386
+ // Lay any frame that arrived while the read was in flight over the
387
+ // rows it just supplied metadata for, then forget those tokens: they
388
+ // are held now, so every later frame reaches them the ordinary way.
389
+ const repaired = applyBufferedBalances(fetched, bufferedUnheld.current, readSeq);
390
+ for (const row of fetched)
391
+ bufferedUnheld.current.delete(row.token.toLowerCase());
392
+ applyBalances(mode === "replace" ? repaired : (prev) => reconcileBalances(prev, repaired));
393
+ }
112
394
  }
113
395
  catch (err) {
114
- setError(err instanceof Error ? err : new Error(String(err)));
396
+ if (current())
397
+ setError(err instanceof Error ? err : new Error(String(err)));
115
398
  }
116
399
  finally {
117
- setLoading(false);
400
+ if (mode === "replace" && current())
401
+ setLoading(false);
118
402
  }
119
- }, [sdk?.profile]);
403
+ }, [sdk?.profile, applyBalances]);
404
+ const fetchBalances = useCallback(() => loadBalances("replace"), [loadBalances]);
120
405
  const refresh = useCallback(async () => {
121
- await fetchBalances();
122
- }, [fetchBalances]);
406
+ await loadBalances("replace");
407
+ }, [loadBalances]);
123
408
  useEffect(() => {
124
409
  if (!sdk?.ws || !sdk?.profile) {
125
410
  setSubscribed(false);
411
+ // The previous effect's cleanup has already bumped `readGeneration`, so a
412
+ // read still in flight will now decline to write — including its own
413
+ // `setLoading(false)`. With no client left to start a replacement read,
414
+ // nothing else would ever clear the flag and the hook would report
415
+ // loading forever. Clear it here instead.
416
+ setLoading(false);
126
417
  return;
127
418
  }
128
- setBalances([]);
419
+ applyBalances([]);
129
420
  setError(null);
130
421
  setLoading(true);
131
422
  // Fetch initial balances via REST API, then subscribe to WebSocket updates
132
423
  let unsubscribe;
133
424
  let cancelled = false;
425
+ /**
426
+ * Re-read the list to pick up an asset the stream cannot introduce on its
427
+ * own. Reconciles rather than replaces — this runs with the subscription
428
+ * live, so the response is already potentially stale by the time it lands
429
+ * — and coalesces, so a burst of frames for an unheld token costs one read.
430
+ */
431
+ const refetch = () => {
432
+ // Already reading: remember that something asked again, so the trailing
433
+ // read below picks up whatever those frames carried.
434
+ if (repairInFlight.current) {
435
+ repairDirty.current = true;
436
+ return;
437
+ }
438
+ // The coordination refs are shared across effect runs, so a repair
439
+ // started here must only touch them while it is still the current one. A
440
+ // read from a previous SDK settling late would otherwise clear
441
+ // `repairInFlight` out from under the NEW client's in-flight repair, and
442
+ // every frame after that would launch its own concurrent REST read.
443
+ // Cleanup resets both refs, so a generation that loses the race simply
444
+ // does nothing rather than leaving the flag stuck.
445
+ const generation = readGeneration.current;
446
+ repairInFlight.current = true;
447
+ repairDirty.current = false;
448
+ loadBalances("reconcile")
449
+ .catch((err) => {
450
+ if (!cancelled) {
451
+ setError(err instanceof Error ? err : new Error(String(err)));
452
+ }
453
+ })
454
+ .finally(() => {
455
+ if (generation !== readGeneration.current)
456
+ return;
457
+ repairInFlight.current = false;
458
+ // The dirty flag is cleared by the read this starts, not here:
459
+ // `refetch` resets it the moment it begins, which is the point at
460
+ // which every request made so far is subsumed. Clearing it in both
461
+ // places would read as two rules for one thing.
462
+ if (repairDirty.current && !cancelled)
463
+ refetch();
464
+ });
465
+ };
134
466
  fetchBalances()
135
467
  .then(() => {
136
468
  // Bail out if effect was cleaned up before fetch resolved
@@ -139,30 +471,48 @@ export function useUserBalances() {
139
471
  // Subscribe to WebSocket balance updates after initial data is loaded
140
472
  try {
141
473
  unsubscribe = sdk.ws.balances((event) => {
142
- let needsRefresh = false;
143
- setBalances((prev) => {
474
+ // Decided off the ref before the updater, for the same reason the
475
+ // snapshot path is: a token the list does not hold yet cannot be
476
+ // built from the event alone, and the re-read that repairs it must
477
+ // not depend on when React invokes an updater.
478
+ const held = balancesRef.current.some((row) => row.token.toLowerCase() === event.data.tokenAddress.toLowerCase());
479
+ if (!held && !cancelled) {
480
+ // The updater below cannot apply this frame — there is no row
481
+ // for it — so hold it until the read supplies one.
482
+ bufferUnheld([event.data], "event");
483
+ refetch();
484
+ }
485
+ applyBalances((prev) => {
144
486
  // Find by token address and update (case-insensitive for addresses)
145
487
  const existingIndex = prev.findIndex((b) => b.token.toLowerCase() === event.data.tokenAddress.toLowerCase());
146
488
  const existingBalance = prev[existingIndex];
147
489
  if (existingIndex >= 0 && existingBalance) {
148
- // Update existing balance
149
490
  const updatedBalance = updateBalanceFromEvent(existingBalance, event);
150
491
  const newBalances = [...prev];
151
492
  newBalances[existingIndex] = updatedBalance;
152
493
  return newBalances;
153
494
  }
154
- // New token - flag for refresh outside the updater
155
- needsRefresh = true;
495
+ // Not held yet — the re-read above is what adds it.
156
496
  return prev;
157
497
  });
158
- // Refresh outside the state updater to keep it pure
159
- if (needsRefresh && !cancelled) {
160
- fetchBalances().catch((err) => {
161
- if (!cancelled) {
162
- setError(err instanceof Error ? err : new Error(String(err)));
163
- }
164
- });
498
+ },
499
+ // The subscribe-time snapshot carries the whole balance set, so it
500
+ // repairs the list without a REST round trip — which is what makes
501
+ // a reconnect cheap: the resubscribe brings a fresh snapshot, and
502
+ // balances that moved during the outage are corrected from it.
503
+ (items) => {
504
+ // Decided BEFORE the updater runs, off the ref rather than off an
505
+ // updater's `prev`: an asset the list does not hold yet cannot be
506
+ // completed from the wire payload alone (it carries no assetId or
507
+ // decimals), so it needs a re-read — and a side effect must not
508
+ // depend on when React chooses to invoke an updater.
509
+ if (snapshotHasUnknownAsset(balancesRef.current, items) && !cancelled) {
510
+ // Held before the read is issued, so a frame the read predates
511
+ // is not lost when its row finally appears.
512
+ bufferUnheld(items, "snapshot");
513
+ refetch();
165
514
  }
515
+ applyBalances((prev) => applyBalanceSnapshot(prev, items).balances);
166
516
  });
167
517
  setSubscribed(true);
168
518
  }
@@ -180,8 +530,13 @@ export function useUserBalances() {
180
530
  return () => {
181
531
  cancelled = true;
182
532
  unsubscribe?.();
533
+ // Invalidates any read already in flight; see `readGeneration`.
534
+ readGeneration.current += 1;
535
+ repairInFlight.current = false;
536
+ repairDirty.current = false;
537
+ bufferedUnheld.current = new Map();
183
538
  setSubscribed(false);
184
539
  };
185
- }, [sdk?.ws, sdk?.profile, fetchBalances]);
540
+ }, [sdk?.ws, sdk?.profile, fetchBalances, loadBalances, applyBalances, bufferUnheld]);
186
541
  return { balances, loading, subscribed, error, clearError, refresh };
187
542
  }
@@ -1,15 +1,122 @@
1
- import type { Order, OrderEvent } from "@0xmonaco/types";
1
+ import type { Order, OrderEvent, OrderSnapshotItem, OrderStatus } from "@0xmonaco/types";
2
2
  import type { UseUserOrdersReturn } from "./types";
3
3
  export declare function useUserOrders(maxOrders?: number): UseUserOrdersReturn;
4
4
  /**
5
- * Reconcile a REST read against live state, last-writer-wins by `updatedAt`.
5
+ * Map one `orders` snapshot row onto the REST `Order` model, or `null` when
6
+ * this SDK version cannot describe it.
7
+ *
8
+ * Same decline contract as {@link orderFromEvent}, for the same reason: the
9
+ * wire values are handed through unvalidated, and relabelling a future order
10
+ * type or side renders an order as something it is not. `status` joins the
11
+ * declining set here, unlike on an `OrderPlaced` — that event IS a submitted
12
+ * order whatever string it carries, whereas a snapshot row's status is the
13
+ * order's actual resting state and has no honest substitute.
14
+ *
15
+ * The row's optionals arrive as explicit `null` (Rust `Option`s serialized
16
+ * without `skip_serializing_if`), so they are normalized to absent: the REST
17
+ * model declares them `?:`, and leaking `null` past it would have consumers
18
+ * branching on a value the model says cannot occur.
19
+ */
20
+ export declare function orderFromSnapshotItem(item: OrderSnapshotItem): Order | null;
21
+ /**
22
+ * Apply a subscribe-time `orders` snapshot to the current list.
23
+ *
24
+ * The snapshot carries RESTING orders only, so — unlike the balances one — it
25
+ * is not the whole list this hook shows. Replacing wholesale would erase every
26
+ * filled or cancelled order the user just placed, so it reconciles through
27
+ * the same version-first ordering as {@link mergeOrders}, with its cap applied
28
+ * by age.
29
+ *
30
+ * It IS complete for the persisted resting set, so absence identifies rows that
31
+ * need resolution — but it is not itself a terminal-state tombstone. On a fast
32
+ * reconnect persistence may lag a newer resting event retained from the prior
33
+ * connection, while an order that really filled or was cancelled during the
34
+ * outage is absent for the same reason: it no longer rests. The pure merge
35
+ * therefore keeps omitted resting rows and reports their ids so the caller can
36
+ * issue the targeted `getOrder` required by the shared order contract. That
37
+ * point read is cache-first and returns the state/version that can be ranked.
38
+ * Terminal rows are kept exactly as before: the snapshot never carried them,
39
+ * so their absence says nothing.
40
+ *
41
+ * A snapshot row is ranked against the order already held on `version`. The
42
+ * snapshot wins when its version is equal or higher, because it is the final
43
+ * state for that sequencer step; a lower version loses. A versioned local row
44
+ * also wins over an unversioned snapshot: the latter cannot be ranked and may
45
+ * be persisted state lagging the previous connection's live stream. When both
46
+ * sides are unversioned, the pre-counter fallback keeps the snapshot's original
47
+ * precedence instead of comparing incompatible timestamps; that also permits a
48
+ * validly-null `updatedAt`.
49
+ *
50
+ * A snapshot row is LAYERED over the order it already knows rather than
51
+ * replacing it. `OrderSnapshotItem` is not a whole `Order`: it carries no
52
+ * `postOnly`, `expirationDate`, `positionSide`, `terminalReason` or fee
53
+ * aggregates. Substituting a winning snapshot row would otherwise erase every
54
+ * one of those — including `postOnly`, where absence is never evidence of
55
+ * `false`. Spreading the row over the existing order lets the snapshot own
56
+ * exactly the fields it describes and leaves the rest standing.
57
+ *
58
+ * The fee aggregates are the one part that can then go stale rather than
59
+ * missing: they accumulate with fills, and a fill this connection never saw
60
+ * moves them. So a snapshot row that reports MORE filled quantity than the row
61
+ * it supersedes schedules the debounced REST re-read, which is the only source
62
+ * for them. A snapshot that changes nothing about the fill state schedules
63
+ * nothing, so a quiet reconnect still costs no request.
64
+ *
65
+ * `hasUndescribableRow` reports rows this version declined, `hasOfflineFill`
66
+ * rows whose fills this connection never saw, and `missingRestingOrderIds`
67
+ * names snapshot omissions that need point reads. The first two let the caller
68
+ * schedule the debounced list re-read; the last must not use a list page because
69
+ * an old order can fall outside it.
70
+ *
71
+ * Pure, and called with the current list read from a ref rather than from
72
+ * inside a state updater — an updater must stay free of side effects, and its
73
+ * signals exist precisely to drive those reads.
74
+ */
75
+ export declare function applyOrderSnapshot(current: Order[], items: OrderSnapshotItem[], limit: number): {
76
+ orders: Order[];
77
+ hasUndescribableRow: boolean;
78
+ hasOfflineFill: boolean;
79
+ missingRestingOrderIds: string[];
80
+ };
81
+ /**
82
+ * The statuses the subscribe-time snapshot treats as resting.
83
+ *
84
+ * Mirrors the server's own `SNAPSHOT_RESTING_ORDER_STATUSES` — an order in one
85
+ * of these is still working on the book, so the snapshot carries it and its
86
+ * absence therefore means it stopped resting. Every other status is terminal
87
+ * or post-trade, never carried, so absence says nothing about it.
88
+ *
89
+ * The two sets must not drift: a status the server started treating as resting
90
+ * but this one does not would put its orders back in the stale-forever case
91
+ * this drop exists to fix, and one this set claims but the server does not
92
+ * would delete rows on an absence that means nothing. Exported so
93
+ * `useUserOrders.test.ts` can pin it against the Rust constant directly.
94
+ *
95
+ * A `Record<..., true>` over a subset of `OrderStatus`, read through
96
+ * `Object.hasOwn`, for the same reasons as {@link asKnown}: no prototype walk,
97
+ * and the union's own membership is checked by the compiler.
98
+ */
99
+ export declare const SNAPSHOT_RESTING_STATUSES: Partial<Record<OrderStatus, true>>;
100
+ /**
101
+ * Map snapshot rows onto the REST model, reporting whether any was declined.
102
+ *
103
+ * Split out of {@link applyOrderSnapshot} because it needs no view of the
104
+ * current list, which makes the mapping — and the decline rule that governs it
105
+ * — testable on its own.
106
+ */
107
+ export declare function snapshotOrderRows(items: OrderSnapshotItem[]): {
108
+ rows: Order[];
109
+ hasUndescribableRow: boolean;
110
+ };
111
+ /**
112
+ * Reconcile a REST read against live state, ranking on `version` first.
6
113
  *
7
114
  * The resync runs with the subscription open, so `fetched` may already be stale
8
115
  * by the time it lands. Three cases:
9
116
  *
10
- * - In both: keep whichever `updatedAt` is later, so a fill that arrived over
11
- * the socket mid-request is not rolled back by an older REST row. Ties keep
12
- * the live row, which cannot be older than the response.
117
+ * - In both: when either row has `version`, the versioned side wins; when both
118
+ * do, the incoming REST row wins on `>=` because it is that step's final
119
+ * state. Only when neither has a version does `updatedAt` arbitrate.
13
120
  * - Live only: keep it. Either it was placed over the socket after the read
14
121
  * started, or it is an older row the page no longer reaches — the two are
15
122
  * indistinguishable here, which is why the cap is applied by age below