@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.
@@ -1,5 +1,6 @@
1
1
  export * from "./useAuth";
2
2
  export * from "./useFees";
3
+ export * from "./useInstruments";
3
4
  export * from "./useMarket";
4
5
  export * from "./useMonaco";
5
6
  export * from "./useOHLCV";
@@ -1,5 +1,6 @@
1
1
  export * from "./useAuth";
2
2
  export * from "./useFees";
3
+ export * from "./useInstruments";
3
4
  export * from "./useMarket";
4
5
  export * from "./useMonaco";
5
6
  export * from "./useOHLCV";
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export { useInstruments } from "./useInstruments";
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export { useInstruments } from "./useInstruments";
@@ -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
- * Hook for subscribing to real-time user order events via WebSocket (authenticated)
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
- * Fetches initial orders from the REST API, then subscribes to real-time updates.
6
- * Requires authentication - the user must be logged in with a session key.
7
- * User is identified on the backend from the signed session-key handshake.
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
- * @param maxOrders - Maximum number of orders to keep in state (default: 50)
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 useUserOrders(maxOrders?: number): UseUserOrdersReturn;
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 fetchOrders = useCallback(async () => {
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
- // Cursor mode (pageToken "") reads the newest rows first, which is all
25
- // this hook needs; legacy page-number mode is deprecated.
26
- const response = await sdk.trading.getPaginatedOrders({
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
- setError(err instanceof Error ? err : new Error(String(err)));
77
+ if (current())
78
+ setError(err instanceof Error ? err : new Error(String(err)));
43
79
  }
44
80
  finally {
45
- setLoading(false);
81
+ if (current())
82
+ setLoading(false);
46
83
  }
47
- }, [sdk?.trading, maxOrders]);
48
- // Manual refresh function
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 fetchOrders();
51
- }, [fetchOrders]);
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 (event.eventType === "OrderPlaced") {
84
- const newOrder = orderFromEvent(event);
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: (data.orderType || "LIMIT"),
125
- side: (data.side || "BUY"),
352
+ orderType,
353
+ side,
126
354
  price: data.price,
127
355
  quantity: data.quantity || "0",
128
356
  filledQuantity: "0",
129
357
  averageFillPrice: undefined,
130
- status: data.status,
131
- tradingMode: (data.tradingMode || "SPOT"),
132
- timeInForce: data.timeInForce,
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 when available, fall back to event-type-based mapping
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
- if ("status" in data && data.status) {
145
- newStatus = data.status;
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.47",
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.47",
20
- "@0xmonaco/types": "1.0.47"
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
- "wagmi": "^2.0.0 || ^3.0.0",
29
- "viem": "^2.45.2"
28
+ "viem": "^2.45.2",
29
+ "wagmi": "^2.0.0 || ^3.0.0"
30
30
  },
31
31
  "exports": {
32
32
  ".": {