@0xmonaco/react 1.0.50 → 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
  }