@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.
- package/dist/hooks/useInstruments/useInstruments.d.ts +12 -10
- package/dist/hooks/useInstruments/useInstruments.js +16 -11
- package/dist/hooks/useUserBalances/useUserBalances.d.ts +78 -1
- package/dist/hooks/useUserBalances/useUserBalances.js +399 -44
- package/dist/hooks/useUserOrders/useUserOrders.d.ts +133 -6
- package/dist/hooks/useUserOrders/useUserOrders.js +848 -42
- package/package.json +3 -3
|
@@ -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
|
-
|
|
8
|
-
|
|
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:
|
|
12
|
-
availableBalanceRaw:
|
|
13
|
-
lockedBalance:
|
|
14
|
-
lockedBalanceRaw:
|
|
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
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
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(
|
|
245
|
+
const eventExtraTotal = decimalDifference(data.total, data.available, data.locked);
|
|
68
246
|
if (eventExtraTotal && decimalGreaterThanZero(eventExtraTotal)) {
|
|
69
|
-
return
|
|
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
|
|
251
|
+
return data.total;
|
|
74
252
|
}
|
|
75
|
-
return decimalSum(
|
|
253
|
+
return decimalSum(data.total, previousMarginTotal) ?? data.total;
|
|
76
254
|
}
|
|
77
|
-
function
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
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(
|
|
259
|
+
const eventExtraTotalRaw = decimalDifference(data.totalRaw, data.availableRaw, data.lockedRaw);
|
|
82
260
|
if (eventExtraTotalRaw && decimalGreaterThanZero(eventExtraTotalRaw)) {
|
|
83
|
-
return
|
|
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
|
|
265
|
+
return data.totalRaw;
|
|
88
266
|
}
|
|
89
|
-
return decimalSum(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
396
|
+
if (current())
|
|
397
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
115
398
|
}
|
|
116
399
|
finally {
|
|
117
|
-
|
|
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
|
|
122
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
155
|
-
needsRefresh = true;
|
|
495
|
+
// Not held yet — the re-read above is what adds it.
|
|
156
496
|
return prev;
|
|
157
497
|
});
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|