@0xmonaco/react 1.0.47 → 1.0.50-develop.9c1b239
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/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useInstruments/index.d.ts +2 -0
- package/dist/hooks/useInstruments/index.js +2 -0
- package/dist/hooks/useInstruments/types.d.ts +34 -0
- package/dist/hooks/useInstruments/types.js +0 -0
- package/dist/hooks/useInstruments/useInstruments.d.ts +27 -0
- package/dist/hooks/useInstruments/useInstruments.js +94 -0
- package/dist/hooks/useUserOrders/useUserOrders.d.ts +26 -6
- package/dist/hooks/useUserOrders/useUserOrders.js +284 -39
- package/package.json +5 -5
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { InstrumentEventData } from "@0xmonaco/types";
|
|
2
|
+
/**
|
|
3
|
+
* Return type for the useInstruments hook
|
|
4
|
+
*/
|
|
5
|
+
export interface UseInstrumentsReturn {
|
|
6
|
+
/**
|
|
7
|
+
* Current configuration of every market seen on the channel, ordered by
|
|
8
|
+
* symbol and then trading pair id. Delisted markets stay in the list carrying
|
|
9
|
+
* `isActive: false` — filter on `isActive` for the tradable set.
|
|
10
|
+
*/
|
|
11
|
+
instruments: InstrumentEventData[];
|
|
12
|
+
/** The same entries keyed by trading pair UUID, for direct lookup */
|
|
13
|
+
instrumentsByPair: Record<string, InstrumentEventData>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a subscription to the instrument channel is currently registered.
|
|
16
|
+
* This is not a connection indicator: the client accepts subscriptions while
|
|
17
|
+
* the socket is down and sends them on the next open, so `true` means
|
|
18
|
+
* "registered", not "receiving".
|
|
19
|
+
*/
|
|
20
|
+
subscribed: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* A rejected subscription request. The core helper validates its arguments
|
|
23
|
+
* synchronously — passing an empty `tradingPairId` is the reachable case — so
|
|
24
|
+
* treat this as a bad-usage signal.
|
|
25
|
+
*
|
|
26
|
+
* It is deliberately NOT a transport signal: the client registers handlers
|
|
27
|
+
* regardless of socket state, and logs malformed-frame failures internally,
|
|
28
|
+
* so connection loss and unparseable frames never appear here. Drive
|
|
29
|
+
* connection UI from the client's own status/resync callbacks instead.
|
|
30
|
+
*/
|
|
31
|
+
error: Error | null;
|
|
32
|
+
/** Clear the current error state */
|
|
33
|
+
clearError: () => void;
|
|
34
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { UseInstrumentsReturn } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Hook for subscribing to real-time instrument configuration via WebSocket (public)
|
|
4
|
+
*
|
|
5
|
+
* On subscribe the channel delivers a snapshot of the current active markets
|
|
6
|
+
* (one `listing` frame each), then live listing, delisting, halt/unhalt and
|
|
7
|
+
* config-change frames. Every frame carries a market's FULL current
|
|
8
|
+
* configuration rather than a delta, so state is kept as a terminal-state
|
|
9
|
+
* entry per trading pair: each event replaces that market's entry outright.
|
|
10
|
+
*
|
|
11
|
+
* A delisted market keeps its entry with `isActive: false` rather than
|
|
12
|
+
* disappearing, so a consumer can distinguish "no longer tradable" from "never
|
|
13
|
+
* seen"; read `instruments.filter((instrument) => instrument.isActive)` for the
|
|
14
|
+
* tradable set.
|
|
15
|
+
*
|
|
16
|
+
* Caveat on reconnect: the server's post-reconnect snapshot covers the markets
|
|
17
|
+
* active at that moment, and the core helper delivers it as ordinary per-market
|
|
18
|
+
* events. A market delisted while the socket was down is simply absent from
|
|
19
|
+
* that snapshot, so its entry here keeps its pre-outage state until the market
|
|
20
|
+
* next emits. Closing that needs a snapshot-boundary signal the channel's
|
|
21
|
+
* handler contract does not currently carry.
|
|
22
|
+
*
|
|
23
|
+
* @param tradingPairId - Optional trading pair UUID to filter on; omit to
|
|
24
|
+
* receive every market. An empty string is rejected by the core helper and is
|
|
25
|
+
* surfaced through `error`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useInstruments(tradingPairId?: string): UseInstrumentsReturn;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useMonacoSDK } from "../useMonaco";
|
|
3
|
+
const IDLE = {
|
|
4
|
+
source: undefined,
|
|
5
|
+
tradingPairId: undefined,
|
|
6
|
+
byPair: {},
|
|
7
|
+
subscribed: false,
|
|
8
|
+
error: null,
|
|
9
|
+
};
|
|
10
|
+
/** Shared empty map, so an out-of-scope render keeps a stable identity. */
|
|
11
|
+
const NO_INSTRUMENTS = {};
|
|
12
|
+
/**
|
|
13
|
+
* Hook for subscribing to real-time instrument configuration via WebSocket (public)
|
|
14
|
+
*
|
|
15
|
+
* On subscribe the channel delivers a snapshot of the current active markets
|
|
16
|
+
* (one `listing` frame each), then live listing, delisting, halt/unhalt and
|
|
17
|
+
* config-change frames. Every frame carries a market's FULL current
|
|
18
|
+
* configuration rather than a delta, so state is kept as a terminal-state
|
|
19
|
+
* entry per trading pair: each event replaces that market's entry outright.
|
|
20
|
+
*
|
|
21
|
+
* A delisted market keeps its entry with `isActive: false` rather than
|
|
22
|
+
* disappearing, so a consumer can distinguish "no longer tradable" from "never
|
|
23
|
+
* seen"; read `instruments.filter((instrument) => instrument.isActive)` for the
|
|
24
|
+
* tradable set.
|
|
25
|
+
*
|
|
26
|
+
* Caveat on reconnect: the server's post-reconnect snapshot covers the markets
|
|
27
|
+
* active at that moment, and the core helper delivers it as ordinary per-market
|
|
28
|
+
* events. A market delisted while the socket was down is simply absent from
|
|
29
|
+
* that snapshot, so its entry here keeps its pre-outage state until the market
|
|
30
|
+
* next emits. Closing that needs a snapshot-boundary signal the channel's
|
|
31
|
+
* handler contract does not currently carry.
|
|
32
|
+
*
|
|
33
|
+
* @param tradingPairId - Optional trading pair UUID to filter on; omit to
|
|
34
|
+
* receive every market. An empty string is rejected by the core helper and is
|
|
35
|
+
* surfaced through `error`.
|
|
36
|
+
*/
|
|
37
|
+
export function useInstruments(tradingPairId) {
|
|
38
|
+
const { sdk } = useMonacoSDK();
|
|
39
|
+
const ws = sdk?.ws;
|
|
40
|
+
const [subscription, setSubscription] = useState(IDLE);
|
|
41
|
+
// Entries only count while they belong to the subscription the caller is
|
|
42
|
+
// asking for right now; the effect below realigns them a tick later.
|
|
43
|
+
const inScope = subscription.source === ws && subscription.tradingPairId === tradingPairId;
|
|
44
|
+
const clearError = useCallback(() => {
|
|
45
|
+
setSubscription((prev) => (prev.error === null ? prev : { ...prev, error: null }));
|
|
46
|
+
}, []);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!ws) {
|
|
49
|
+
setSubscription({ ...IDLE, tradingPairId });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Only a frame that belongs to *this* subscription may touch state — a
|
|
53
|
+
// late arrival from a torn-down one must not resurrect old markets.
|
|
54
|
+
const belongsToThisSubscription = (prev) => prev.source === ws && prev.tradingPairId === tradingPairId;
|
|
55
|
+
// Claim the scope before subscribing so the first frame, whenever it lands,
|
|
56
|
+
// already has somewhere in-scope to go.
|
|
57
|
+
setSubscription({ source: ws, tradingPairId, byPair: {}, subscribed: false, error: null });
|
|
58
|
+
// `instruments` throws synchronously on a missing handler or an empty pair
|
|
59
|
+
// id — surface that through `error` instead of letting it escape the effect.
|
|
60
|
+
let unsubscribe;
|
|
61
|
+
try {
|
|
62
|
+
unsubscribe = ws.instruments((event) => {
|
|
63
|
+
setSubscription((prev) => (belongsToThisSubscription(prev) ? { ...prev, byPair: { ...prev.byPair, [event.tradingPairId]: event.data } } : prev));
|
|
64
|
+
}, tradingPairId);
|
|
65
|
+
setSubscription((prev) => (belongsToThisSubscription(prev) ? { ...prev, subscribed: true } : prev));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
69
|
+
setSubscription((prev) => (belongsToThisSubscription(prev) ? { ...prev, subscribed: false, error } : prev));
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
unsubscribe?.();
|
|
73
|
+
setSubscription((prev) => (belongsToThisSubscription(prev) ? { ...prev, subscribed: false } : prev));
|
|
74
|
+
};
|
|
75
|
+
}, [ws, tradingPairId]);
|
|
76
|
+
const instrumentsByPair = inScope ? subscription.byPair : NO_INSTRUMENTS;
|
|
77
|
+
// Sorted with plain codepoint comparison rather than `localeCompare`, so the
|
|
78
|
+
// order does not shift with the runtime locale, and tie-broken on the pair id
|
|
79
|
+
// because a spot and a perp market can carry the same symbol.
|
|
80
|
+
const instruments = useMemo(() => Object.values(instrumentsByPair).sort((left, right) => {
|
|
81
|
+
if (left.symbol !== right.symbol)
|
|
82
|
+
return left.symbol < right.symbol ? -1 : 1;
|
|
83
|
+
if (left.tradingPairId === right.tradingPairId)
|
|
84
|
+
return 0;
|
|
85
|
+
return left.tradingPairId < right.tradingPairId ? -1 : 1;
|
|
86
|
+
}), [instrumentsByPair]);
|
|
87
|
+
return {
|
|
88
|
+
instruments,
|
|
89
|
+
instrumentsByPair,
|
|
90
|
+
subscribed: inScope && subscription.subscribed,
|
|
91
|
+
error: inScope ? subscription.error : null,
|
|
92
|
+
clearError,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
|
+
import type { Order, OrderEvent } from "@0xmonaco/types";
|
|
1
2
|
import type { UseUserOrdersReturn } from "./types";
|
|
3
|
+
export declare function useUserOrders(maxOrders?: number): UseUserOrdersReturn;
|
|
2
4
|
/**
|
|
3
|
-
*
|
|
5
|
+
* Reconcile a REST read against live state, last-writer-wins by `updatedAt`.
|
|
6
|
+
*
|
|
7
|
+
* The resync runs with the subscription open, so `fetched` may already be stale
|
|
8
|
+
* by the time it lands. Three cases:
|
|
4
9
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
13
|
+
* - Live only: keep it. Either it was placed over the socket after the read
|
|
14
|
+
* started, or it is an older row the page no longer reaches — the two are
|
|
15
|
+
* indistinguishable here, which is why the cap is applied by age below
|
|
16
|
+
* rather than by which side a row came from.
|
|
17
|
+
* - REST only: take it. This is the order the resync exists to recover.
|
|
8
18
|
*
|
|
9
|
-
*
|
|
19
|
+
* The union is then sorted newest-first by `createdAt` and truncated to
|
|
20
|
+
* `limit`, so the row dropped at the cap is the genuinely oldest one. A missing
|
|
21
|
+
* or unparseable `createdAt` sorts last rather than to the top.
|
|
10
22
|
*/
|
|
11
|
-
export declare function
|
|
23
|
+
export declare function mergeOrders(current: Order[], fetched: Order[], limit: number): Order[];
|
|
24
|
+
/**
|
|
25
|
+
* Create an Order object from an OrderPlaced event
|
|
26
|
+
*/
|
|
27
|
+
export declare function orderFromEvent(event: OrderEvent): Order | null;
|
|
28
|
+
/**
|
|
29
|
+
* Update an existing Order with data from a WebSocket event
|
|
30
|
+
*/
|
|
31
|
+
export declare function updateOrderFromEvent(order: Order, event: OrderEvent): Order;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useMonacoSDK } from "../useMonaco";
|
|
3
3
|
/**
|
|
4
4
|
* Hook for subscribing to real-time user order events via WebSocket (authenticated)
|
|
@@ -9,6 +9,8 @@ import { useMonacoSDK } from "../useMonaco";
|
|
|
9
9
|
*
|
|
10
10
|
* @param maxOrders - Maximum number of orders to keep in state (default: 50)
|
|
11
11
|
*/
|
|
12
|
+
/** Debounce for the REST re-read after an undescribable event, in milliseconds. */
|
|
13
|
+
const RESYNC_DEBOUNCE_MS = 1000;
|
|
12
14
|
export function useUserOrders(maxOrders = 50) {
|
|
13
15
|
const { sdk } = useMonacoSDK();
|
|
14
16
|
const [orders, setOrders] = useState([]);
|
|
@@ -16,42 +18,131 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
16
18
|
const [error, setError] = useState(null);
|
|
17
19
|
const [subscribed, setSubscribed] = useState(false);
|
|
18
20
|
const clearError = useCallback(() => setError(null), []);
|
|
19
|
-
const
|
|
21
|
+
const resyncTimer = useRef(null);
|
|
22
|
+
/**
|
|
23
|
+
* Bumped by effect cleanup. Every REST read — the initial load and the resync
|
|
24
|
+
* alike — captures this when it starts and drops its result if it no longer
|
|
25
|
+
* matches, so a response still in flight when the SDK changed or the
|
|
26
|
+
* component unmounted cannot write rows belonging to the previous client.
|
|
27
|
+
*/
|
|
28
|
+
const readGeneration = useRef(0);
|
|
29
|
+
/** Read the newest page of orders. Shared by the initial load and the resync. */
|
|
30
|
+
const readOrders = useCallback(async () => {
|
|
31
|
+
if (!sdk?.trading)
|
|
32
|
+
return null;
|
|
33
|
+
// Cursor mode (pageToken "") reads the newest rows first, which is all
|
|
34
|
+
// this hook needs; legacy page-number mode is deprecated.
|
|
35
|
+
const response = await sdk.trading.getPaginatedOrders({
|
|
36
|
+
pageSize: maxOrders,
|
|
37
|
+
pageToken: "",
|
|
38
|
+
});
|
|
39
|
+
// Deduplicate the paginated orders by id, keeping the first occurrence.
|
|
40
|
+
const seenIds = new Set();
|
|
41
|
+
const dedupedOrders = [];
|
|
42
|
+
for (const order of response.orders) {
|
|
43
|
+
if (!seenIds.has(order.id)) {
|
|
44
|
+
seenIds.add(order.id);
|
|
45
|
+
dedupedOrders.push(order);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return dedupedOrders.slice(0, maxOrders);
|
|
49
|
+
}, [sdk?.trading, maxOrders]);
|
|
50
|
+
/**
|
|
51
|
+
* Read the list and apply it, either owning it outright or reconciling.
|
|
52
|
+
*
|
|
53
|
+
* `replace` is only correct BEFORE the subscription opens: the initial load
|
|
54
|
+
* runs first by design, so nothing newer can exist to lose. Every other
|
|
55
|
+
* caller runs with the socket live and must `reconcile`, or an event arriving
|
|
56
|
+
* while the request is in flight is overwritten by an older snapshot.
|
|
57
|
+
*/
|
|
58
|
+
const load = useCallback(async (mode) => {
|
|
20
59
|
if (!sdk?.trading)
|
|
21
60
|
return;
|
|
61
|
+
const generation = readGeneration.current;
|
|
62
|
+
// A read belonging to a previous client must not write ANY of this state:
|
|
63
|
+
// not the rows, not the error, and not the loading flag. Clearing
|
|
64
|
+
// `loading` from a stale request would mark the hook loaded while the new
|
|
65
|
+
// client's read is still running — showing an empty list as though it
|
|
66
|
+
// were the answer — and a stale rejection would leave `error` set after
|
|
67
|
+
// the new load had already succeeded.
|
|
68
|
+
const current = () => generation === readGeneration.current;
|
|
22
69
|
setLoading(true);
|
|
23
70
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
pageSize: maxOrders,
|
|
28
|
-
pageToken: "",
|
|
29
|
-
});
|
|
30
|
-
// Deduplicate the paginated orders by id, keeping the first occurrence.
|
|
31
|
-
const seenIds = new Set();
|
|
32
|
-
const dedupedOrders = [];
|
|
33
|
-
for (const order of response.orders) {
|
|
34
|
-
if (!seenIds.has(order.id)) {
|
|
35
|
-
seenIds.add(order.id);
|
|
36
|
-
dedupedOrders.push(order);
|
|
37
|
-
}
|
|
71
|
+
const rows = await readOrders();
|
|
72
|
+
if (rows && current()) {
|
|
73
|
+
setOrders((prev) => (mode === "replace" ? rows : mergeOrders(prev, rows, maxOrders)));
|
|
38
74
|
}
|
|
39
|
-
setOrders(dedupedOrders.slice(0, maxOrders));
|
|
40
75
|
}
|
|
41
76
|
catch (err) {
|
|
42
|
-
|
|
77
|
+
if (current())
|
|
78
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
43
79
|
}
|
|
44
80
|
finally {
|
|
45
|
-
|
|
81
|
+
if (current())
|
|
82
|
+
setLoading(false);
|
|
46
83
|
}
|
|
47
|
-
}, [sdk?.trading, maxOrders]);
|
|
48
|
-
|
|
84
|
+
}, [sdk?.trading, readOrders, maxOrders]);
|
|
85
|
+
const fetchOrders = useCallback(() => load("replace"), [load]);
|
|
86
|
+
/**
|
|
87
|
+
* Manual refresh. Reconciles rather than replaces: unlike the initial load
|
|
88
|
+
* this is public and can be called at any time, including with the
|
|
89
|
+
* subscription open, so a fill arriving mid-request must not be rolled back
|
|
90
|
+
* by the older snapshot the request returns.
|
|
91
|
+
*/
|
|
49
92
|
const refresh = useCallback(async () => {
|
|
50
|
-
await
|
|
51
|
-
}, [
|
|
93
|
+
await load("reconcile");
|
|
94
|
+
}, [load]);
|
|
95
|
+
/**
|
|
96
|
+
* Re-read the list from REST after an event this SDK version cannot describe.
|
|
97
|
+
*
|
|
98
|
+
* `orderFromEvent` declines an `OrderPlaced` whose `orderType`, `side` or
|
|
99
|
+
* `tradingMode` is a value this version does not know, rather than
|
|
100
|
+
* relabelling it. Without this the order would then be missing from the list
|
|
101
|
+
* indefinitely — it exists on the server, and no later event adds it, because
|
|
102
|
+
* every subsequent event for it takes the "update an existing order" path and
|
|
103
|
+
* finds nothing to update.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately NOT `fetchOrders`. This runs while the subscription is live,
|
|
106
|
+
* so unlike the initial load it cannot own the list: it must not raise
|
|
107
|
+
* `loading` (consumers read that as the initial load and would flash a
|
|
108
|
+
* spinner), and it must not replace the list wholesale (an event landing
|
|
109
|
+
* while the request is in flight would be overwritten by an older snapshot —
|
|
110
|
+
* the very race the initial load avoids by subscribing only after it
|
|
111
|
+
* completes). It merges instead, last-writer-wins by `updatedAt`, the same
|
|
112
|
+
* reconciliation the SDK documents for `onResync`.
|
|
113
|
+
*
|
|
114
|
+
* Debounced and coalesced: a deploy that adds an order type produces a burst
|
|
115
|
+
* of declines, and one REST read repairs the whole burst. A failure is
|
|
116
|
+
* swallowed rather than surfaced through `error`, which describes the load.
|
|
117
|
+
*/
|
|
118
|
+
const scheduleResync = useCallback(() => {
|
|
119
|
+
if (resyncTimer.current)
|
|
120
|
+
return;
|
|
121
|
+
const generation = readGeneration.current;
|
|
122
|
+
resyncTimer.current = setTimeout(() => {
|
|
123
|
+
resyncTimer.current = null;
|
|
124
|
+
void readOrders()
|
|
125
|
+
.then((rows) => {
|
|
126
|
+
// Clearing the timer cannot cancel a read that already started, so
|
|
127
|
+
// the generation is what invalidates it.
|
|
128
|
+
if (rows && generation === readGeneration.current) {
|
|
129
|
+
setOrders((prev) => mergeOrders(prev, rows, maxOrders));
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
.catch(() => {
|
|
133
|
+
// A repair that fails leaves the list exactly as it was.
|
|
134
|
+
});
|
|
135
|
+
}, RESYNC_DEBOUNCE_MS);
|
|
136
|
+
}, [readOrders, maxOrders]);
|
|
52
137
|
useEffect(() => {
|
|
53
138
|
if (!sdk?.ws || !sdk?.trading) {
|
|
54
139
|
setSubscribed(false);
|
|
140
|
+
// The previous effect's cleanup has already bumped `readGeneration`, so
|
|
141
|
+
// any read still in flight will now decline to write — including its
|
|
142
|
+
// `setLoading(false)`. With no client left to start a replacement read,
|
|
143
|
+
// nothing would ever clear the flag, and the hook would report loading
|
|
144
|
+
// forever. Clear it here instead.
|
|
145
|
+
setLoading(false);
|
|
55
146
|
return;
|
|
56
147
|
}
|
|
57
148
|
setOrders([]);
|
|
@@ -60,12 +151,27 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
60
151
|
const limit = Number.isFinite(maxOrders) ? maxOrders : 50;
|
|
61
152
|
// Fetch initial orders via REST API, then subscribe to WebSocket updates
|
|
62
153
|
let unsubscribe;
|
|
154
|
+
// Cleanup cannot cancel a fetch already in flight. Without this, a slow
|
|
155
|
+
// initial read could resolve after the SDK changed or the component
|
|
156
|
+
// unmounted and then install a subscription on the OLD client — after
|
|
157
|
+
// cleanup had already run with `unsubscribe` still unset, so that handler
|
|
158
|
+
// would never be removed.
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
const generation = readGeneration.current;
|
|
63
161
|
fetchOrders()
|
|
64
162
|
.then(() => {
|
|
163
|
+
if (cancelled || generation !== readGeneration.current)
|
|
164
|
+
return;
|
|
65
165
|
// Subscribe to WebSocket order updates after initial data is loaded
|
|
66
166
|
// This prevents race conditions where WS events could be overwritten by REST response
|
|
67
167
|
try {
|
|
68
168
|
unsubscribe = sdk.ws.userOrders((event) => {
|
|
169
|
+
// Built OUTSIDE the state updater: an updater must stay pure, and
|
|
170
|
+
// React may invoke it more than once. `null` here means the event
|
|
171
|
+
// is an `OrderPlaced` this version cannot describe.
|
|
172
|
+
const placed = event.eventType === "OrderPlaced" ? orderFromEvent(event) : null;
|
|
173
|
+
if (event.eventType === "OrderPlaced" && !placed)
|
|
174
|
+
scheduleResync();
|
|
69
175
|
setOrders((prev) => {
|
|
70
176
|
const orderId = event.orderId;
|
|
71
177
|
// Check if this order already exists
|
|
@@ -80,11 +186,8 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
80
186
|
return newOrders;
|
|
81
187
|
}
|
|
82
188
|
// New order - add to the beginning if it's an OrderPlaced event
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
if (newOrder) {
|
|
86
|
-
return [newOrder, ...prev].slice(0, limit);
|
|
87
|
-
}
|
|
189
|
+
if (placed) {
|
|
190
|
+
return [placed, ...prev].slice(0, limit);
|
|
88
191
|
}
|
|
89
192
|
return prev;
|
|
90
193
|
});
|
|
@@ -102,34 +205,172 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
102
205
|
setLoading(false);
|
|
103
206
|
});
|
|
104
207
|
return () => {
|
|
208
|
+
cancelled = true;
|
|
105
209
|
unsubscribe?.();
|
|
210
|
+
if (resyncTimer.current) {
|
|
211
|
+
clearTimeout(resyncTimer.current);
|
|
212
|
+
resyncTimer.current = null;
|
|
213
|
+
}
|
|
214
|
+
// Invalidates any read already in flight; see `readGeneration`.
|
|
215
|
+
readGeneration.current += 1;
|
|
106
216
|
setSubscribed(false);
|
|
107
217
|
};
|
|
108
|
-
}, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders]);
|
|
218
|
+
}, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders, scheduleResync]);
|
|
109
219
|
return { orders, loading, subscribed, error, clearError, refresh };
|
|
110
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Runtime membership test for the closed `OrderStatus` union.
|
|
223
|
+
*
|
|
224
|
+
* `@0xmonaco/core` never validates the order-event `status` against a value
|
|
225
|
+
* list — it hands the wire string through, so an unfamiliar value cannot drop
|
|
226
|
+
* the whole frame (0XM-2627/0XM-2628). The REST `Order` model this hook builds
|
|
227
|
+
* has a closed `OrderStatus`, so an unrecognized value has to be recognized as
|
|
228
|
+
* such here rather than cast in, where it would corrupt the model for every
|
|
229
|
+
* consumer that branches on `order.status`.
|
|
230
|
+
*
|
|
231
|
+
* A `Record<OrderStatus, true>` rather than an array: adding a variant to
|
|
232
|
+
* `OrderStatus` is a compile error here until it is listed, so this cannot
|
|
233
|
+
* fall behind the union it mirrors.
|
|
234
|
+
*/
|
|
235
|
+
const ORDER_STATUSES = {
|
|
236
|
+
SUBMITTED: true,
|
|
237
|
+
PARTIALLY_FILLED: true,
|
|
238
|
+
FILLED: true,
|
|
239
|
+
SETTLED_ON_CHAIN: true,
|
|
240
|
+
SETTLED: true,
|
|
241
|
+
CANCELLED: true,
|
|
242
|
+
REJECTED: true,
|
|
243
|
+
EXPIRED: true,
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The other closed unions this hook converts into the REST `Order`. Same
|
|
247
|
+
* contract as `ORDER_STATUSES`: every one of these arrives on the same
|
|
248
|
+
* unvalidated WebSocket payload, so an unrecognized value in any of them would
|
|
249
|
+
* corrupt the model just as an unknown status would. `Record<T, true>` again,
|
|
250
|
+
* so adding a variant to any union is a compile error here until it is listed.
|
|
251
|
+
*/
|
|
252
|
+
const ORDER_TYPES = { LIMIT: true, MARKET: true };
|
|
253
|
+
const ORDER_SIDES = { BUY: true, SELL: true };
|
|
254
|
+
const TRADING_MODES = { SPOT: true, MARGIN: true };
|
|
255
|
+
const TIMES_IN_FORCE = { GTC: true, IOC: true, FOK: true, GTD: true };
|
|
256
|
+
/**
|
|
257
|
+
* Narrow a widened WebSocket value back to its closed union, or `undefined`.
|
|
258
|
+
*
|
|
259
|
+
* `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a wire value of
|
|
260
|
+
* "toString" / "constructor" / "valueOf" would test true and be cast into the
|
|
261
|
+
* closed union — the corruption these helpers exist to prevent.
|
|
262
|
+
*/
|
|
263
|
+
function asKnown(known, value) {
|
|
264
|
+
return typeof value === "string" && Object.hasOwn(known, value) ? value : undefined;
|
|
265
|
+
}
|
|
266
|
+
function asOrderStatus(value) {
|
|
267
|
+
return asKnown(ORDER_STATUSES, value);
|
|
268
|
+
}
|
|
269
|
+
/** Milliseconds since epoch for an order's `updatedAt`, or `NaN` if unparseable. */
|
|
270
|
+
function updatedAtMs(order) {
|
|
271
|
+
return Date.parse(order.updatedAt ?? "");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Sort key for the list's newest-first order. An unparseable or absent
|
|
275
|
+
* `createdAt` sorts oldest rather than jumping to the top.
|
|
276
|
+
*/
|
|
277
|
+
function createdAtMs(order) {
|
|
278
|
+
const parsed = Date.parse(order.createdAt ?? "");
|
|
279
|
+
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Reconcile a REST read against live state, last-writer-wins by `updatedAt`.
|
|
283
|
+
*
|
|
284
|
+
* The resync runs with the subscription open, so `fetched` may already be stale
|
|
285
|
+
* by the time it lands. Three cases:
|
|
286
|
+
*
|
|
287
|
+
* - In both: keep whichever `updatedAt` is later, so a fill that arrived over
|
|
288
|
+
* the socket mid-request is not rolled back by an older REST row. Ties keep
|
|
289
|
+
* the live row, which cannot be older than the response.
|
|
290
|
+
* - Live only: keep it. Either it was placed over the socket after the read
|
|
291
|
+
* started, or it is an older row the page no longer reaches — the two are
|
|
292
|
+
* indistinguishable here, which is why the cap is applied by age below
|
|
293
|
+
* rather than by which side a row came from.
|
|
294
|
+
* - REST only: take it. This is the order the resync exists to recover.
|
|
295
|
+
*
|
|
296
|
+
* The union is then sorted newest-first by `createdAt` and truncated to
|
|
297
|
+
* `limit`, so the row dropped at the cap is the genuinely oldest one. A missing
|
|
298
|
+
* or unparseable `createdAt` sorts last rather than to the top.
|
|
299
|
+
*/
|
|
300
|
+
export function mergeOrders(current, fetched, limit) {
|
|
301
|
+
const fetchedIds = new Set(fetched.map((order) => order.id));
|
|
302
|
+
const currentById = new Map(current.map((order) => [order.id, order]));
|
|
303
|
+
const reconciled = fetched.map((incoming) => {
|
|
304
|
+
const live = currentById.get(incoming.id);
|
|
305
|
+
if (!live)
|
|
306
|
+
return incoming;
|
|
307
|
+
const liveAt = updatedAtMs(live);
|
|
308
|
+
const incomingAt = updatedAtMs(incoming);
|
|
309
|
+
// An unparseable timestamp on either side is not evidence of staleness, so
|
|
310
|
+
// prefer the live row rather than silently rolling it back.
|
|
311
|
+
if (Number.isNaN(liveAt) || Number.isNaN(incomingAt))
|
|
312
|
+
return live;
|
|
313
|
+
return liveAt >= incomingAt ? live : incoming;
|
|
314
|
+
});
|
|
315
|
+
const liveOnly = current.filter((order) => !fetchedIds.has(order.id));
|
|
316
|
+
// Cap by RECENCY, not by side. Prepending every live-only row and slicing
|
|
317
|
+
// evicted the tail of the REST page, and a live-only row is not always the
|
|
318
|
+
// newer one: once the list is at `limit`, a row REST no longer returns is the
|
|
319
|
+
// OLDEST held, so the recovered order this resync exists to insert was
|
|
320
|
+
// precisely the row being dropped — at `limit` 1 it never landed at all.
|
|
321
|
+
// Sorting newest-first keeps a genuinely new socket row ahead of the page and
|
|
322
|
+
// lets a genuinely old one fall off, which is what the cap means.
|
|
323
|
+
return [...liveOnly, ...reconciled].sort((a, b) => createdAtMs(b) - createdAtMs(a)).slice(0, limit);
|
|
324
|
+
}
|
|
111
325
|
/**
|
|
112
326
|
* Create an Order object from an OrderPlaced event
|
|
113
327
|
*/
|
|
114
|
-
function orderFromEvent(event) {
|
|
328
|
+
export function orderFromEvent(event) {
|
|
115
329
|
if (event.eventType !== "OrderPlaced")
|
|
116
330
|
return null;
|
|
117
331
|
const { data } = event;
|
|
118
332
|
const tradingPairId = data.tradingPairId || data.tradingPair;
|
|
119
333
|
if (!tradingPairId)
|
|
120
334
|
return null;
|
|
335
|
+
// The closed REST unions this event must supply for the order to be
|
|
336
|
+
// describable at all. `@0xmonaco/core` passes an unrecognized value straight
|
|
337
|
+
// through during version skew, and there is no honest default for any of
|
|
338
|
+
// these: defaulting a future `STOP` to `LIMIT`, or an unknown side to `BUY`,
|
|
339
|
+
// renders an order as something it is not — the worst outcome in a trading
|
|
340
|
+
// UI, and worse than not rendering it. So the order is declined here the same
|
|
341
|
+
// way one with no trading pair is. Declining alone would leave the order
|
|
342
|
+
// missing indefinitely, so `useUserOrders` schedules a debounced REST re-read
|
|
343
|
+
// when this happens and picks the order up from there.
|
|
344
|
+
const orderType = asKnown(ORDER_TYPES, data.orderType);
|
|
345
|
+
const side = asKnown(ORDER_SIDES, data.side);
|
|
346
|
+
const tradingMode = asKnown(TRADING_MODES, data.tradingMode);
|
|
347
|
+
if (!orderType || !side || !tradingMode)
|
|
348
|
+
return null;
|
|
121
349
|
return {
|
|
122
350
|
id: event.orderId,
|
|
123
351
|
tradingPairId: tradingPairId,
|
|
124
|
-
orderType
|
|
125
|
-
side
|
|
352
|
+
orderType,
|
|
353
|
+
side,
|
|
126
354
|
price: data.price,
|
|
127
355
|
quantity: data.quantity || "0",
|
|
128
356
|
filledQuantity: "0",
|
|
129
357
|
averageFillPrice: undefined,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
358
|
+
// Not an invented value: an `OrderPlaced` event IS a submitted order by
|
|
359
|
+
// construction, whatever status string it carries.
|
|
360
|
+
status: asOrderStatus(data.status) ?? "SUBMITTED",
|
|
361
|
+
tradingMode,
|
|
362
|
+
// Optional on the model, so an unrecognized value is simply absent —
|
|
363
|
+
// there is no honest default to invent for it.
|
|
364
|
+
timeInForce: asKnown(TIMES_IN_FORCE, data.timeInForce),
|
|
365
|
+
// Read from the ACKNOWLEDGEMENT only, and then left alone:
|
|
366
|
+
// `updateOrderFromEvent` spreads the existing order and never rewrites this
|
|
367
|
+
// field, so the value learned here survives every later event. That is
|
|
368
|
+
// load-bearing rather than incidental — the maker fill does not carry
|
|
369
|
+
// `postOnly`, and a post-only order rests and therefore fills as a maker,
|
|
370
|
+
// so re-deriving the field per event would clear it on the one event a
|
|
371
|
+
// post-only order most often produces and disagree with REST. Only a real
|
|
372
|
+
// boolean is taken: anything else is not evidence the order was post-only.
|
|
373
|
+
postOnly: typeof data.postOnly === "boolean" ? data.postOnly : undefined,
|
|
133
374
|
createdAt: event.timestamp,
|
|
134
375
|
updatedAt: event.timestamp,
|
|
135
376
|
};
|
|
@@ -137,12 +378,16 @@ function orderFromEvent(event) {
|
|
|
137
378
|
/**
|
|
138
379
|
* Update an existing Order with data from a WebSocket event
|
|
139
380
|
*/
|
|
140
|
-
function updateOrderFromEvent(order, event) {
|
|
381
|
+
export function updateOrderFromEvent(order, event) {
|
|
141
382
|
const data = event.data;
|
|
142
|
-
// Prefer status from event data
|
|
383
|
+
// Prefer a RECOGNIZED status from the event data, else fall back to the
|
|
384
|
+
// event-type mapping. An unrecognized status falls through rather than being
|
|
385
|
+
// cast in: the event type still says what happened, which is a better answer
|
|
386
|
+
// for the UI than a string no consumer can branch on.
|
|
143
387
|
let newStatus = order.status;
|
|
144
|
-
|
|
145
|
-
|
|
388
|
+
const reportedStatus = "status" in data ? asOrderStatus(data.status) : undefined;
|
|
389
|
+
if (reportedStatus) {
|
|
390
|
+
newStatus = reportedStatus;
|
|
146
391
|
}
|
|
147
392
|
else {
|
|
148
393
|
switch (event.eventType) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmonaco/react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.50-develop.9c1b239",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"homepage": "https://docs.0xmonaco.com/sdk/typescript",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"lint": "biome lint ."
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@0xmonaco/core": "1.0.
|
|
20
|
-
"@0xmonaco/types": "1.0.
|
|
19
|
+
"@0xmonaco/core": "1.0.50-develop.9c1b239",
|
|
20
|
+
"@0xmonaco/types": "1.0.50-develop.9c1b239"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/react": "^19.1.12",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
28
|
+
"viem": "^2.45.2",
|
|
29
|
+
"wagmi": "^2.0.0 || ^3.0.0"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": {
|