@usearete/react 0.45.0 → 0.47.0

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/index.cjs CHANGED
@@ -380,6 +380,10 @@ function AreteProvider({ children, ...config }) {
380
380
  };
381
381
  return (React.createElement(AreteContext.Provider, { value: value }, children));
382
382
  }
383
+ /** The enclosing `AreteProvider`'s context, or `null` outside one. */
384
+ function useOptionalAreteContext() {
385
+ return React.useContext(AreteContext);
386
+ }
383
387
  function useAreteContext() {
384
388
  const context = React.useContext(AreteContext);
385
389
  if (!context) {
@@ -422,6 +426,276 @@ function useConnectionState(stack, options) {
422
426
  return state;
423
427
  }
424
428
 
429
+ const AreteSessionContext = React.createContext(null);
430
+ /**
431
+ * `ready()` rejects with this when its `AreteSessionProvider` was removed
432
+ * before the session opened (or is called after the provider is gone): no
433
+ * session will come. `code` is `provider_unmounted`.
434
+ */
435
+ class AreteSessionUnavailableError extends Error {
436
+ constructor(message = 'The AreteSessionProvider was unmounted before its session opened') {
437
+ super(message);
438
+ this.code = 'provider_unmounted';
439
+ this.name = 'AreteSessionUnavailableError';
440
+ }
441
+ }
442
+ /** Whether `error` is an {@link AreteSessionUnavailableError}. */
443
+ function isAreteSessionUnavailableError(error) {
444
+ return error instanceof AreteSessionUnavailableError;
445
+ }
446
+ function createDeferred() {
447
+ let resolvePromise;
448
+ let rejectPromise;
449
+ const promise = new Promise((resolve, reject) => {
450
+ resolvePromise = resolve;
451
+ rejectPromise = reject;
452
+ });
453
+ // A failure nobody awaited is reported through `status`, not as an
454
+ // unhandled rejection.
455
+ promise.catch(() => undefined);
456
+ const deferred = {
457
+ promise,
458
+ settled: false,
459
+ resolve(value) {
460
+ if (deferred.settled)
461
+ return;
462
+ deferred.settled = true;
463
+ resolvePromise(value);
464
+ },
465
+ reject(error) {
466
+ if (deferred.settled)
467
+ return;
468
+ deferred.settled = true;
469
+ rejectPromise(error);
470
+ },
471
+ };
472
+ return deferred;
473
+ }
474
+ function isPlainObject(value) {
475
+ if (typeof value !== 'object' || value === null)
476
+ return false;
477
+ const prototype = Object.getPrototypeOf(value);
478
+ return prototype === Object.prototype || prototype === null;
479
+ }
480
+ /**
481
+ * Equal by identity, or plain objects with the same keys whose values are
482
+ * equal to `depth` levels. Arrays, functions and class instances compare by
483
+ * identity.
484
+ */
485
+ function sameShape(left, right, depth) {
486
+ if (Object.is(left, right))
487
+ return true;
488
+ if (depth === 0 || !isPlainObject(left) || !isPlainObject(right))
489
+ return false;
490
+ const leftKeys = Object.keys(left).filter((key) => left[key] !== undefined);
491
+ const rightKeys = Object.keys(right).filter((key) => right[key] !== undefined);
492
+ return leftKeys.length === rightKeys.length
493
+ && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameShape(left[key], right[key], depth - 1));
494
+ }
495
+ /**
496
+ * `value`, or the previous one while it has the same shape: two levels of
497
+ * plain objects, so `options={{ apiUrl, auth: { tokenKind: 'api', … } }}`
498
+ * written inline does not reopen the session on every render.
499
+ */
500
+ function useSameShape(value) {
501
+ const ref = React.useRef(value);
502
+ if (!sameShape(ref.current, value, 2))
503
+ ref.current = value;
504
+ return ref.current;
505
+ }
506
+ function toError(value) {
507
+ return value instanceof Error ? value : new Error(String(value));
508
+ }
509
+ const openSession = sdk.createSession;
510
+ /**
511
+ * One Arete session (`createSession`) for everything below it, read with
512
+ * {@link useAreteSession}.
513
+ *
514
+ * ```tsx
515
+ * const SQUADS = { programs: { squads: SQUADS_V4_PROGRAM } };
516
+ * const auth = { tokenKind: 'api', tokenEndpoint: '/api/arete-token' } as const;
517
+ *
518
+ * <AreteSessionProvider definition={SQUADS} options={{ auth }} wallet={wallet}>
519
+ * <App />
520
+ * </AreteSessionProvider>;
521
+ *
522
+ * // below it
523
+ * const { session } = useAreteSession<typeof SQUADS>();
524
+ * session?.programs.squads; // typed
525
+ * ```
526
+ *
527
+ * Given `definition`, the provider opens the session when it mounts and
528
+ * closes it when it unmounts. `options.auth` and `options.fetch` default to
529
+ * the enclosing `AreteProvider`'s. When the definition or the options
530
+ * change, the old session is closed at once and a new one is opened:
531
+ * `session` is `null` until it is ready, so nothing runs against a session
532
+ * opened with credentials that no longer apply. Options are compared by
533
+ * shape two levels deep (an inline `auth` object with the same fields is the
534
+ * same auth); functions such as `getToken` or `fetch` compare by identity,
535
+ * so keep them stable. The wallet never reopens the session: changing
536
+ * `wallet` calls `session.setWallet`.
537
+ *
538
+ * Given `session`, the provider only shares it: the app opened it and closes
539
+ * it. `wallet`, when passed, is set on it the same way.
540
+ */
541
+ function AreteSessionProvider(props) {
542
+ const arete = useOptionalAreteContext();
543
+ const isExternal = props.definition === undefined;
544
+ const externalSession = isExternal ? props.session ?? null : null;
545
+ const definition = useSameShape(props.definition);
546
+ const given = (props.options ?? {});
547
+ const inheritedAuth = arete?.config.auth;
548
+ const inheritedFetch = arete?.config.fetch;
549
+ const options = useSameShape({
550
+ ...given,
551
+ ...(given.auth === undefined && inheritedAuth ? { auth: inheritedAuth } : {}),
552
+ ...(given.fetch === undefined && inheritedFetch ? { fetch: inheritedFetch } : {}),
553
+ });
554
+ const [attempt, setAttempt] = React.useState(0);
555
+ const key = React.useMemo(() => ({ definition, options, external: externalSession, attempt }), [definition, options, externalSession, attempt]);
556
+ const keyRef = React.useRef(key);
557
+ keyRef.current = key;
558
+ const walletRef = React.useRef(props.wallet);
559
+ walletRef.current = props.wallet;
560
+ const syncsWallet = !isExternal || 'wallet' in props;
561
+ const deferredsRef = React.useRef(new WeakMap());
562
+ // Every waiter that may still be pending, so unmounting can settle them.
563
+ const pendingRef = React.useRef(new Set());
564
+ const unmountedRef = React.useRef(false);
565
+ const deferredFor = React.useCallback((forKey) => {
566
+ let deferred = deferredsRef.current.get(forKey);
567
+ if (!deferred) {
568
+ deferred = createDeferred();
569
+ if (unmountedRef.current)
570
+ deferred.reject(new AreteSessionUnavailableError());
571
+ deferredsRef.current.set(forKey, deferred);
572
+ for (const pending of pendingRef.current) {
573
+ if (pending.settled)
574
+ pendingRef.current.delete(pending);
575
+ }
576
+ if (!deferred.settled)
577
+ pendingRef.current.add(deferred);
578
+ }
579
+ return deferred;
580
+ }, []);
581
+ // Removed for good, nothing will open: the waiters fail instead of
582
+ // hanging. The check waits a microtask because StrictMode (and a tree
583
+ // that is hidden and shown again) runs this cleanup and then mounts the
584
+ // same provider again, which keeps the waiters for the session it opens.
585
+ const mountedRef = React.useRef(false);
586
+ React.useEffect(() => {
587
+ mountedRef.current = true;
588
+ unmountedRef.current = false;
589
+ const waiters = pendingRef.current;
590
+ return () => {
591
+ mountedRef.current = false;
592
+ void Promise.resolve().then(() => {
593
+ if (mountedRef.current)
594
+ return;
595
+ unmountedRef.current = true;
596
+ const pending = [...waiters];
597
+ waiters.clear();
598
+ // Mounted again later (a hidden tree shown again), `ready()` waits
599
+ // for the session opened then rather than repeating this failure.
600
+ deferredsRef.current = new WeakMap();
601
+ for (const deferred of pending)
602
+ deferred.reject(new AreteSessionUnavailableError());
603
+ });
604
+ };
605
+ }, []);
606
+ const [opened, setOpened] = React.useState(null);
607
+ // A caller waiting on the previous key's session gets the next one.
608
+ const previousKeyRef = React.useRef(null);
609
+ React.useEffect(() => {
610
+ const previous = previousKeyRef.current;
611
+ previousKeyRef.current = key;
612
+ if (previous && previous !== key) {
613
+ const waiting = deferredsRef.current.get(previous);
614
+ if (waiting && !waiting.settled)
615
+ waiting.resolve(deferredFor(key).promise);
616
+ }
617
+ }, [key, deferredFor]);
618
+ React.useEffect(() => {
619
+ if (key.external)
620
+ deferredFor(key).resolve(key.external);
621
+ }, [key, deferredFor]);
622
+ React.useEffect(() => {
623
+ if (!key.definition)
624
+ return undefined;
625
+ let active = true;
626
+ let session = null;
627
+ const deferred = deferredFor(key);
628
+ const wallet = walletRef.current;
629
+ openSession(key.definition, { ...key.options, ...(wallet ? { wallet } : {}) }).then((openedSession) => {
630
+ if (!active) {
631
+ openedSession.close();
632
+ return;
633
+ }
634
+ session = openedSession;
635
+ // The wallet may have changed while the session was opening.
636
+ if (openedSession.wallet !== walletRef.current)
637
+ openedSession.setWallet(walletRef.current);
638
+ deferred.resolve(openedSession);
639
+ setOpened({ key, session: openedSession, error: null });
640
+ }, (cause) => {
641
+ if (!active)
642
+ return;
643
+ const error = toError(cause);
644
+ deferred.reject(error);
645
+ setOpened({ key, session: null, error });
646
+ });
647
+ return () => {
648
+ active = false;
649
+ session?.close();
650
+ };
651
+ }, [key, deferredFor]);
652
+ let session = null;
653
+ let error = null;
654
+ let status = 'opening';
655
+ if (isExternal) {
656
+ session = externalSession;
657
+ status = session ? 'open' : 'opening';
658
+ }
659
+ else if (opened?.key === key) {
660
+ session = opened.session;
661
+ error = opened.error;
662
+ status = error ? 'error' : 'open';
663
+ }
664
+ const { wallet } = props;
665
+ React.useEffect(() => {
666
+ if (session && syncsWallet && session.wallet !== wallet)
667
+ session.setWallet(wallet);
668
+ }, [session, syncsWallet, wallet]);
669
+ const ready = React.useCallback(() => deferredFor(keyRef.current).promise, [deferredFor]);
670
+ const retry = React.useCallback(() => {
671
+ setAttempt((current) => current + 1);
672
+ }, []);
673
+ const value = React.useMemo(() => ({ session, status, error, ready, retry }), [session, status, error, ready, retry]);
674
+ return (React.createElement(AreteSessionContext.Provider, { value: value }, props.children));
675
+ }
676
+ /**
677
+ * The nearest {@link AreteSessionProvider}'s session. Pass the definition
678
+ * (or the session type) for typed members:
679
+ *
680
+ * ```tsx
681
+ * const { session, status, ready } = useAreteSession<typeof SQUADS>();
682
+ * const squads = session?.programs.squads;
683
+ * ```
684
+ *
685
+ * Throws outside an `AreteSessionProvider`.
686
+ */
687
+ function useAreteSession() {
688
+ const value = React.useContext(AreteSessionContext);
689
+ if (!value) {
690
+ throw new Error('useAreteSession must be used within AreteSessionProvider');
691
+ }
692
+ return value;
693
+ }
694
+ /** The nearest `AreteSessionProvider`'s value, or `null` outside one. */
695
+ function useOptionalAreteSession() {
696
+ return React.useContext(AreteSessionContext);
697
+ }
698
+
425
699
  function isMarkedDefaultReconciliation(value) {
426
700
  return (typeof value === 'function'
427
701
  && value.areteDefaultReconciliation === true
@@ -1663,12 +1937,17 @@ function useQuery(client, query, snapshotEnabled, enabled, initialData) {
1663
1937
  return current;
1664
1938
  }
1665
1939
  catch (value) {
1666
- state.fallback = {
1667
- ...state.fallback,
1668
- isLoading: false,
1669
- isRefreshing: false,
1670
- error: queryError(value, 'Subscription snapshot failed'),
1671
- };
1940
+ // A lease whose client was disconnected under it (its owner closed
1941
+ // it) throws on every read: report the error once and keep returning
1942
+ // the same snapshot, so React sees a stable value.
1943
+ if (!state.fallback.error) {
1944
+ state.fallback = {
1945
+ ...state.fallback,
1946
+ isLoading: false,
1947
+ isRefreshing: false,
1948
+ error: queryError(value, 'Subscription snapshot failed'),
1949
+ };
1950
+ }
1672
1951
  return state.fallback;
1673
1952
  }
1674
1953
  // state is replaced whenever either listed identity changes.
@@ -1879,12 +2158,588 @@ function createListViewHook(viewDef, client) {
1879
2158
  return { use, useOne, refresh };
1880
2159
  }
1881
2160
 
2161
+ /** The typed failure of a {@link useOperationMutation} run. */
2162
+ class OperationMutationError extends Error {
2163
+ constructor(input) {
2164
+ super(input.message);
2165
+ this.name = 'OperationMutationError';
2166
+ this.kind = input.kind;
2167
+ this.cause = input.cause;
2168
+ this.failure = input.failure ?? null;
2169
+ this.prepared = input.prepared ?? null;
2170
+ this.completedSignatures = [...(input.completedSignatures ?? [])];
2171
+ this.signature = this.failure && 'signature' in this.failure
2172
+ ? this.failure.signature
2173
+ : undefined;
2174
+ this.programError = this.failure?.status === 'chain-failed'
2175
+ ? this.failure.programError
2176
+ : undefined;
2177
+ }
2178
+ }
2179
+ /** The `cause` chain of `error`, outermost first. */
2180
+ function causeChain(error) {
2181
+ const chain = [];
2182
+ let current = error;
2183
+ while (current !== undefined && current !== null && chain.length < 10 && !chain.includes(current)) {
2184
+ chain.push(current);
2185
+ current = typeof current === 'object' ? current.cause : undefined;
2186
+ }
2187
+ return chain;
2188
+ }
2189
+ /** Error codes wallets report a declined prompt with (EIP-1193 4001 and kin). */
2190
+ const USER_REJECTION_CODES = [4001, 'ACTION_REJECTED', 'USER_REJECTED'];
2191
+ /**
2192
+ * Whether `error` (or anything in its `cause` chain) is a wallet prompt the
2193
+ * user declined or closed. Wallets report this in a few ways: EIP-1193 code
2194
+ * 4001 (Phantom, Solflare), a `...RejectedError`, or the message.
2195
+ */
2196
+ function isWalletRejection(error) {
2197
+ return causeChain(error).some((item) => {
2198
+ if (typeof item !== 'object' || item === null)
2199
+ return false;
2200
+ const { code, name, message } = item;
2201
+ if (USER_REJECTION_CODES.includes(code))
2202
+ return true;
2203
+ if (name === 'WalletStandardSignerError')
2204
+ return code === 'user_rejected';
2205
+ if (typeof name === 'string' && /rejected/i.test(name))
2206
+ return true;
2207
+ return typeof message === 'string'
2208
+ && /user (rejected|declined|denied|cancel)|rejected the request|request (was )?(rejected|declined)/i
2209
+ .test(message);
2210
+ });
2211
+ }
2212
+ /** The `code` of a `WalletStandardSignerError` in the cause chain, if any. */
2213
+ function walletSignerErrorCode(error) {
2214
+ for (const item of causeChain(error)) {
2215
+ if (item instanceof Error
2216
+ && item.name === 'WalletStandardSignerError'
2217
+ && typeof item.code === 'string') {
2218
+ return item.code;
2219
+ }
2220
+ }
2221
+ return null;
2222
+ }
2223
+ function messageOf(error) {
2224
+ if (error instanceof Error && error.message)
2225
+ return error.message;
2226
+ if (typeof error === 'string' && error)
2227
+ return error;
2228
+ return 'The operation failed';
2229
+ }
2230
+ /**
2231
+ * Classify an execution failure. Exported so code outside the hook (an
2232
+ * imperative `session.execute`) can show the same messages.
2233
+ */
2234
+ function toOperationMutationError(error, prepared = null, completedSignatures = []) {
2235
+ if (error instanceof OperationMutationError)
2236
+ return error;
2237
+ const failure = sdk.getTransactionFailureOutcome(error);
2238
+ const completed = error instanceof sdk.OperationExecutionError
2239
+ ? error.completedReceipts.map((receipt) => receipt.signature)
2240
+ : completedSignatures;
2241
+ const make = (kind) => new OperationMutationError({
2242
+ kind,
2243
+ message: messageOf(error),
2244
+ cause: error,
2245
+ failure,
2246
+ prepared,
2247
+ completedSignatures: completed,
2248
+ });
2249
+ if (failure?.status === 'chain-failed')
2250
+ return make('chain-failed');
2251
+ if (failure?.status === 'submitted-unknown')
2252
+ return make('submitted-unknown');
2253
+ switch (walletSignerErrorCode(error)) {
2254
+ case 'wallet_returned_invalid_transaction':
2255
+ return make('wallet-modified');
2256
+ case 'sign_transaction_unsupported':
2257
+ return make('wallet-feature-unsupported');
2258
+ case 'chain_unsupported':
2259
+ return make('wallet-chain-unsupported');
2260
+ case 'transaction_version_unsupported':
2261
+ return make('wallet-version-unsupported');
2262
+ case 'user_rejected':
2263
+ return make('wallet-rejected');
2264
+ }
2265
+ if (isWalletRejection(error))
2266
+ return make('wallet-rejected');
2267
+ return make('not-submitted');
2268
+ }
2269
+ function hasActivity(wallet) {
2270
+ return typeof wallet?.onActivity === 'function';
2271
+ }
2272
+ /**
2273
+ * The wallet as one run hands it to the executor: every `signAndSend` it
2274
+ * makes carries `listener` as that send's `onActivity`, after any listener
2275
+ * the caller's send options already set.
2276
+ */
2277
+ function withSendActivity(wallet, listener) {
2278
+ const signAndSend = (instructions, options, ...rest) => {
2279
+ const own = options?.onActivity;
2280
+ const onActivity = typeof own === 'function'
2281
+ ? (activity) => {
2282
+ try {
2283
+ own(activity);
2284
+ }
2285
+ finally {
2286
+ listener(activity);
2287
+ }
2288
+ }
2289
+ : listener;
2290
+ return wallet.signAndSend(instructions, { ...(options ?? {}), onActivity }, ...rest);
2291
+ };
2292
+ return new Proxy(wallet, {
2293
+ get(target, property) {
2294
+ if (property === 'signAndSend')
2295
+ return signAndSend;
2296
+ const value = Reflect.get(target, property, target);
2297
+ return typeof value === 'function' ? value.bind(target) : value;
2298
+ },
2299
+ });
2300
+ }
2301
+ function isPreparedWithSigners(value) {
2302
+ return typeof value === 'object'
2303
+ && value !== null
2304
+ && 'operation' in value
2305
+ && !('kind' in value && 'plan' in value);
2306
+ }
2307
+ async function resolveExecutor(source) {
2308
+ if (!source)
2309
+ return null;
2310
+ if (typeof source === 'function')
2311
+ return (await source()) ?? null;
2312
+ return source;
2313
+ }
2314
+ const IDLE_STATE = {
2315
+ status: 'idle',
2316
+ prepared: null,
2317
+ result: undefined,
2318
+ error: null,
2319
+ signatures: [],
2320
+ transactionIndex: null,
2321
+ };
2322
+ /**
2323
+ * Run a prepared instruction, transaction or flow with a wallet and report
2324
+ * where it is: idle → preparing → signing → sending → confirming → done (or
2325
+ * error). Works with anything that has `execute(prepared, options)`, a
2326
+ * connected client or a session, so it needs no stack:
2327
+ *
2328
+ * ```tsx
2329
+ * const createMultisig = useOperationMutation(
2330
+ * async (input: CreateInput) => {
2331
+ * const createKey = await generateKeyPairSigner();
2332
+ * return {
2333
+ * operation: await squads.flows.arete.createMultisig.prepare({ ...input, createKey: createKey.address }),
2334
+ * signers: [createKey],
2335
+ * };
2336
+ * },
2337
+ * { executor: session, wallet },
2338
+ * );
2339
+ * // Inside an AreteSessionProvider, `executor` can be left out.
2340
+ * // createMultisig.mutate(input); createMultisig.isAwaitingWallet → "Approve in your wallet"
2341
+ * ```
2342
+ *
2343
+ * Signers returned by `prepare`, given to the hook and given to the run are
2344
+ * all passed to `execute`. A run without a wallet fails with `not-ready`
2345
+ * before `prepare` is called; set `allowExecutorWallet` to sign with the
2346
+ * executor's own wallet instead. A second run while one is in progress rejects
2347
+ * with `busy` and leaves the first alone, so a double click never opens two
2348
+ * wallet prompts. "In progress" is exactly `isBusy`: a run ends when its
2349
+ * `done` or `error` status is published, before `onSuccess`/`onError` are
2350
+ * awaited, so a button disabled by `isBusy` is never enabled while `run()`
2351
+ * would refuse it. A run started during those callbacks is a new run; the
2352
+ * earlier run's `run()` promise still settles after its callback.
2353
+ */
2354
+ function useOperationMutation(prepare, options) {
2355
+ const [state, setState] = React.useState(IDLE_STATE);
2356
+ const sessionContext = useOptionalAreteSession();
2357
+ const prepareRef = React.useRef(prepare);
2358
+ const optionsRef = React.useRef(options);
2359
+ const sessionReadyRef = React.useRef(sessionContext?.ready);
2360
+ prepareRef.current = prepare;
2361
+ optionsRef.current = options;
2362
+ sessionReadyRef.current = sessionContext?.ready;
2363
+ const generationRef = React.useRef(0);
2364
+ // The run that holds the lock, if any. A run releases only its own lock,
2365
+ // so a run that finishes its callbacks late never frees a newer run's.
2366
+ const runningRef = React.useRef(null);
2367
+ const mountedRef = React.useRef(true);
2368
+ React.useEffect(() => {
2369
+ mountedRef.current = true;
2370
+ return () => {
2371
+ mountedRef.current = false;
2372
+ generationRef.current += 1;
2373
+ };
2374
+ }, []);
2375
+ const run = React.useCallback(async (input, runOptions = {}) => {
2376
+ if (runningRef.current) {
2377
+ throw new OperationMutationError({
2378
+ kind: 'busy',
2379
+ message: 'This operation is already running',
2380
+ cause: undefined,
2381
+ });
2382
+ }
2383
+ const lock = {};
2384
+ runningRef.current = lock;
2385
+ const release = () => {
2386
+ if (runningRef.current === lock)
2387
+ runningRef.current = null;
2388
+ };
2389
+ const generation = ++generationRef.current;
2390
+ const hookOptions = optionsRef.current;
2391
+ let lastStatus = 'idle';
2392
+ let transactionIndex = null;
2393
+ const update = (patch) => {
2394
+ if (!mountedRef.current || generation !== generationRef.current)
2395
+ return;
2396
+ if (patch.transactionIndex !== undefined)
2397
+ transactionIndex = patch.transactionIndex;
2398
+ setState((current) => ({ ...current, ...patch }));
2399
+ if (patch.status && patch.status !== lastStatus) {
2400
+ lastStatus = patch.status;
2401
+ try {
2402
+ hookOptions.onStatusChange?.(patch.status, { transactionIndex });
2403
+ }
2404
+ catch {
2405
+ // Observational only.
2406
+ }
2407
+ }
2408
+ };
2409
+ let prepared = null;
2410
+ const signatures = [];
2411
+ const fail = async (error) => {
2412
+ // The run is over once `error` is published: `isBusy` turns false and
2413
+ // the hook takes the next run, even while `onError` is still pending.
2414
+ release();
2415
+ update({ status: 'error', error, prepared, signatures: [...signatures] });
2416
+ try {
2417
+ await hookOptions.onError?.(error);
2418
+ }
2419
+ catch {
2420
+ // The callback observes the failure; it does not replace it.
2421
+ }
2422
+ return error;
2423
+ };
2424
+ setState(IDLE_STATE);
2425
+ update({ status: 'preparing' });
2426
+ try {
2427
+ let executor;
2428
+ const source = hookOptions.executor !== undefined
2429
+ ? hookOptions.executor
2430
+ : sessionReadyRef.current ?? null;
2431
+ try {
2432
+ executor = await resolveExecutor(source);
2433
+ }
2434
+ catch (cause) {
2435
+ throw await fail(new OperationMutationError({
2436
+ kind: 'not-ready', message: messageOf(cause), cause,
2437
+ }));
2438
+ }
2439
+ if (!executor) {
2440
+ throw await fail(new OperationMutationError({
2441
+ kind: 'not-ready',
2442
+ message: 'Nothing to execute the operation with yet',
2443
+ cause: undefined,
2444
+ }));
2445
+ }
2446
+ // No wallet, no run: nothing is prepared, nothing is signed.
2447
+ const wallet = runOptions.wallet
2448
+ ?? hookOptions.wallet
2449
+ ?? (hookOptions.allowExecutorWallet
2450
+ ? executor.wallet
2451
+ : undefined);
2452
+ if (!wallet) {
2453
+ throw await fail(new OperationMutationError({
2454
+ kind: 'not-ready',
2455
+ message: 'Connect a wallet to run this operation',
2456
+ cause: undefined,
2457
+ }));
2458
+ }
2459
+ let extraSigners = [];
2460
+ try {
2461
+ const built = await prepareRef.current(input);
2462
+ if (isPreparedWithSigners(built)) {
2463
+ prepared = built.operation;
2464
+ extraSigners = built.signers ?? [];
2465
+ }
2466
+ else {
2467
+ prepared = built;
2468
+ }
2469
+ }
2470
+ catch (cause) {
2471
+ throw await fail(new OperationMutationError({
2472
+ kind: 'prepare-failed', message: messageOf(cause), cause,
2473
+ }));
2474
+ }
2475
+ update({ prepared });
2476
+ // A wallet that reports its activity says when its prompt opens; until
2477
+ // then the transaction is still being built (blockhash, budgets). The
2478
+ // run listens to its own sends only, and only until they settle, so
2479
+ // another run on the same wallet, or one that starts while this run's
2480
+ // callbacks are pending, cannot move this run's status.
2481
+ const reportsActivity = hasActivity(wallet);
2482
+ let listening = reportsActivity;
2483
+ const runWallet = reportsActivity
2484
+ ? withSendActivity(wallet, (activity) => {
2485
+ if (!listening)
2486
+ return;
2487
+ if (activity.stage === 'signing' || activity.stage === 'sending'
2488
+ || activity.stage === 'confirming') {
2489
+ update({ status: activity.stage });
2490
+ }
2491
+ })
2492
+ : wallet;
2493
+ const signers = [
2494
+ ...(hookOptions.signers ?? []),
2495
+ ...extraSigners,
2496
+ ...(runOptions.signers ?? []),
2497
+ ];
2498
+ const send = runOptions.send ?? hookOptions.send;
2499
+ let result;
2500
+ try {
2501
+ result = await executor.execute(prepared, {
2502
+ wallet: runWallet,
2503
+ ...(signers.length > 0 ? { signers } : {}),
2504
+ ...(send ? { send } : {}),
2505
+ onTransactionStart: (event) => {
2506
+ update({
2507
+ status: reportsActivity ? 'preparing' : 'signing',
2508
+ transactionIndex: event.transactionIndex,
2509
+ });
2510
+ },
2511
+ onTransactionSuccess: (event) => {
2512
+ signatures.push(event.receipt.signature);
2513
+ update({ signatures: [...signatures] });
2514
+ },
2515
+ });
2516
+ }
2517
+ catch (cause) {
2518
+ listening = false;
2519
+ throw await fail(toOperationMutationError(cause, prepared, signatures));
2520
+ }
2521
+ listening = false;
2522
+ // As in `fail`: `done` releases the lock, so `isBusy` and `run()` agree
2523
+ // while `onSuccess` runs. Activity listening has already stopped, so a
2524
+ // run started from (or during) the callback cannot move this one.
2525
+ release();
2526
+ update({
2527
+ status: 'done',
2528
+ result,
2529
+ signatures: [...result.signatures],
2530
+ transactionIndex: null,
2531
+ });
2532
+ try {
2533
+ await hookOptions.onSuccess?.(result, prepared);
2534
+ }
2535
+ catch {
2536
+ // The operation confirmed; a failing callback does not undo that.
2537
+ }
2538
+ return result;
2539
+ }
2540
+ finally {
2541
+ release();
2542
+ }
2543
+ }, []);
2544
+ const mutate = React.useCallback((input, runOptions) => {
2545
+ void run(input, runOptions).catch(() => undefined);
2546
+ }, [run]);
2547
+ const reset = React.useCallback(() => {
2548
+ if (runningRef.current)
2549
+ return;
2550
+ generationRef.current += 1;
2551
+ setState(IDLE_STATE);
2552
+ }, []);
2553
+ const busy = state.status === 'preparing' || state.status === 'signing'
2554
+ || state.status === 'sending' || state.status === 'confirming';
2555
+ return {
2556
+ run,
2557
+ mutate,
2558
+ reset,
2559
+ status: state.status,
2560
+ isBusy: busy,
2561
+ isAwaitingWallet: state.status === 'signing',
2562
+ prepared: state.prepared,
2563
+ artifacts: state.prepared?.artifacts,
2564
+ result: state.result,
2565
+ error: state.error,
2566
+ signatures: state.signatures,
2567
+ transactionIndex: state.transactionIndex,
2568
+ transactionCount: state.prepared ? state.prepared.plan.transactions.length : null,
2569
+ };
2570
+ }
2571
+
2572
+ function isStackClient(value) {
2573
+ return typeof value === 'object'
2574
+ && value !== null
2575
+ && typeof value.getSubscriptionRegistry === 'function'
2576
+ && typeof value.onConnectionStateChange === 'function';
2577
+ }
2578
+ function isMemberOptions(value) {
2579
+ const stack = value?.stack;
2580
+ return typeof stack === 'object' && stack !== null && 'views' in stack;
2581
+ }
2582
+ function zustandStoreOf(client) {
2583
+ return client?.store?.store ?? null;
2584
+ }
2585
+ /**
2586
+ * `useArete` for a client the caller (usually a session) connected and
2587
+ * owns. It reads the member's own connection and store: nothing connects,
2588
+ * and nothing is disconnected when the component unmounts or the member is
2589
+ * replaced. A `null` member reads as the connecting state `useArete(stack)`
2590
+ * reports before its client exists.
2591
+ */
2592
+ function useMemberArete(member, options) {
2593
+ const client = member;
2594
+ const stack = client?.stack ?? options?.stack;
2595
+ if (!stack) {
2596
+ throw new Error('useArete(member) could not read the stack definition of the member it was given. '
2597
+ + 'Pass it as useArete(member, { stack }).');
2598
+ }
2599
+ // Mutations wait for the streamed slot they landed in only when the member
2600
+ // was connected over the socket.
2601
+ const transport = client?.transport;
2602
+ const placeholderPrograms = options?.programs;
2603
+ const sessionContext = useOptionalAreteSession();
2604
+ const [connection, setConnection] = React.useState(() => ({
2605
+ client,
2606
+ state: client?.connectionState ?? 'disconnected',
2607
+ error: null,
2608
+ socketIssue: null,
2609
+ }));
2610
+ // A new member (a reopened session) never shows the previous one's state.
2611
+ const current = connection.client === client
2612
+ ? connection
2613
+ : { client, state: client?.connectionState ?? 'disconnected', error: null, socketIssue: null };
2614
+ React.useEffect(() => {
2615
+ if (!client) {
2616
+ setConnection((previous) => previous.client === null
2617
+ ? previous
2618
+ : { client: null, state: 'disconnected', error: null, socketIssue: null });
2619
+ return undefined;
2620
+ }
2621
+ const update = (next) => {
2622
+ setConnection((previous) => {
2623
+ const base = previous.client === client
2624
+ ? previous
2625
+ : { client, state: client.connectionState, error: null, socketIssue: null };
2626
+ return { ...base, ...next };
2627
+ });
2628
+ };
2629
+ update({ state: client.connectionState });
2630
+ const unsubscribeState = client.onConnectionStateChange((state, stateError) => {
2631
+ if (state === 'error') {
2632
+ update({ state, error: normalizeConnectionError(stateError, 'Arete connection entered an error state') });
2633
+ }
2634
+ else if (state === 'connected') {
2635
+ update({ state, error: null, socketIssue: null });
2636
+ }
2637
+ else {
2638
+ update({ state });
2639
+ }
2640
+ });
2641
+ const unsubscribeIssue = client.onSocketIssue((socketIssue) => {
2642
+ update({ socketIssue });
2643
+ });
2644
+ // Only listeners are removed: the member's connection is its owner's.
2645
+ return () => {
2646
+ unsubscribeIssue();
2647
+ unsubscribeState();
2648
+ };
2649
+ }, [client]);
2650
+ const views = React.useMemo(() => buildViewHooks(stack, client), [stack, client]);
2651
+ const programs = React.useMemo(() => {
2652
+ if (!client) {
2653
+ return buildDisconnectedProgramHooks(stack.programs, placeholderPrograms);
2654
+ }
2655
+ return buildProgramHookInterfaces(client.programs, client, useInstructionMutation, { defaultReconciliation: transport !== 'http' });
2656
+ }, [client, stack, transport, placeholderPrograms]);
2657
+ const connectedRead = (client?.read ?? null);
2658
+ const readArgCounts = sdk.getStackRuntimeExtensions(stack)?.readArgCounts;
2659
+ const read = React.useMemo(() => buildReadInterfaces(connectedRead, readArgCounts), [connectedRead, readArgCounts]);
2660
+ const sessionStatus = sessionContext?.status;
2661
+ const sessionError = sessionContext?.error ?? null;
2662
+ const sessionRetry = sessionContext?.retry;
2663
+ const sessionFailed = !client && sessionStatus === 'error';
2664
+ const retry = React.useCallback(async () => {
2665
+ // The member's connection belongs to its session: retrying reopens a
2666
+ // session that failed to open, and does nothing otherwise.
2667
+ if (sessionFailed)
2668
+ sessionRetry?.();
2669
+ }, [sessionFailed, sessionRetry]);
2670
+ const status = client
2671
+ ? current.error && current.state !== 'connected' ? 'error' : current.state
2672
+ : sessionFailed ? 'error' : 'connecting';
2673
+ const error = client ? current.error : sessionFailed ? sessionError : null;
2674
+ const extension = (name) => client?.[name]
2675
+ ?? stack[name];
2676
+ return {
2677
+ views,
2678
+ queries: client?.queries ?? {},
2679
+ programs,
2680
+ chain: client?.chain ?? null,
2681
+ zustandStore: zustandStoreOf(client),
2682
+ client,
2683
+ read,
2684
+ reads: read,
2685
+ addresses: extension('addresses'),
2686
+ constants: extension('constants'),
2687
+ defaults: extension('defaults'),
2688
+ math: extension('math'),
2689
+ connectionState: client ? current.state : 'disconnected',
2690
+ status,
2691
+ isConnected: Boolean(client) && current.state === 'connected',
2692
+ isLoading: !client && !sessionFailed,
2693
+ canRetry: sessionFailed,
2694
+ error,
2695
+ socketIssue: client ? current.socketIssue : null,
2696
+ retry,
2697
+ };
2698
+ }
2699
+ function buildViewHooks(stack, client) {
2700
+ const result = {};
2701
+ for (const [viewName, viewGroup] of Object.entries(stack.views)) {
2702
+ result[viewName] = {};
2703
+ if (typeof viewGroup === 'object' && viewGroup !== null) {
2704
+ for (const [subViewName, viewDef] of Object.entries(viewGroup)) {
2705
+ if (!viewDef || typeof viewDef !== 'object' || !('mode' in viewDef))
2706
+ continue;
2707
+ if (viewDef.mode === 'state') {
2708
+ result[viewName][subViewName] = createStateViewHook(viewDef, client);
2709
+ }
2710
+ else if (viewDef.mode === 'list') {
2711
+ result[viewName][subViewName] = createListViewHook(viewDef, client);
2712
+ }
2713
+ }
2714
+ }
2715
+ }
2716
+ return result;
2717
+ }
1882
2718
  function normalizeConnectionError(value, fallback) {
1883
2719
  if (value instanceof Error)
1884
2720
  return value;
1885
2721
  return new Error(typeof value === 'string' && value.length > 0 ? value : fallback);
1886
2722
  }
1887
- function useArete(stack,
2723
+ function useArete(stackOrMember,
2724
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2725
+ options
2726
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2727
+ ) {
2728
+ // A call site keeps one form for its lifetime (a stack definition, or a
2729
+ // session member that may be null while the session opens), so the hooks
2730
+ // either branch calls stay in the same order.
2731
+ if (isStackClient(stackOrMember)) {
2732
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- one form per call site
2733
+ return useMemberArete(stackOrMember, options);
2734
+ }
2735
+ if (stackOrMember == null && isMemberOptions(options)) {
2736
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- one form per call site
2737
+ return useMemberArete(null, options);
2738
+ }
2739
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- one form per call site
2740
+ return useStackArete((stackOrMember ?? undefined), options);
2741
+ }
2742
+ function useStackArete(stack,
1888
2743
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1889
2744
  options
1890
2745
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2047,25 +2902,7 @@ options
2047
2902
  retryPromiseRef.current = { lookupKey, promise: tracked };
2048
2903
  return tracked;
2049
2904
  }, [lookupKey, lookupOptions, retryClient, resolvedStack]);
2050
- const views = React.useMemo(() => {
2051
- const result = {};
2052
- for (const [viewName, viewGroup] of Object.entries(resolvedStack.views)) {
2053
- result[viewName] = {};
2054
- if (typeof viewGroup === 'object' && viewGroup !== null) {
2055
- for (const [subViewName, viewDef] of Object.entries(viewGroup)) {
2056
- if (!viewDef || typeof viewDef !== 'object' || !('mode' in viewDef))
2057
- continue;
2058
- if (viewDef.mode === 'state') {
2059
- result[viewName][subViewName] = createStateViewHook(viewDef, client);
2060
- }
2061
- else if (viewDef.mode === 'list') {
2062
- result[viewName][subViewName] = createListViewHook(viewDef, client);
2063
- }
2064
- }
2065
- }
2066
- }
2067
- return result;
2068
- }, [resolvedStack, client]);
2905
+ const views = React.useMemo(() => buildViewHooks(resolvedStack, client), [resolvedStack, client]);
2069
2906
  const programs = React.useMemo(() => {
2070
2907
  if (!client) {
2071
2908
  // Placeholder hooks so components can render (and keep hook order)
@@ -2092,7 +2929,7 @@ options
2092
2929
  queries: (client?.queries ?? {}),
2093
2930
  programs: programs,
2094
2931
  chain: client?.chain ?? null,
2095
- zustandStore: client?.store?.store ?? null,
2932
+ zustandStore: zustandStoreOf(client),
2096
2933
  client,
2097
2934
  read,
2098
2935
  reads: read,
@@ -2172,6 +3009,38 @@ function summarizeStatuses(sources) {
2172
3009
  /** @deprecated Use summarizeStatuses; it also accepts connection and read results. */
2173
3010
  const summarizeViews = summarizeStatuses;
2174
3011
 
3012
+ /**
3013
+ * A client for the Arete API's account routes, authenticated with the
3014
+ * provider's `auth` (and `fetch`). With `auth.tokenKind: 'api'`, the same
3015
+ * credential the provider mints its stack and gateway sessions with, so one
3016
+ * login drives both:
3017
+ *
3018
+ * ```tsx
3019
+ * <AreteProvider auth={{ tokenKind: 'api', tokenEndpoint: '/api/arete-token' }}>
3020
+ * …
3021
+ * </AreteProvider>
3022
+ * // in a component
3023
+ * const http = usePlatformHttpClient({ apiUrl });
3024
+ * const squads = useMemo(() => createSquadsClient({ http }), [http]);
3025
+ * ```
3026
+ *
3027
+ * The client is stable while `apiUrl`, `auth` and `fetch` keep their
3028
+ * identity, so pass a memoized (or module-level) `auth` object. Throws
3029
+ * during render when the auth carries no API credential, like
3030
+ * `createPlatformHttpClient`.
3031
+ */
3032
+ function usePlatformHttpClient(options = {}) {
3033
+ const { config } = useAreteContext();
3034
+ const auth = options.auth ?? config.auth;
3035
+ const fetchImpl = config.fetch;
3036
+ const { apiUrl } = options;
3037
+ return React.useMemo(() => sdk.createPlatformHttpClient({
3038
+ ...(apiUrl !== undefined ? { apiUrl } : {}),
3039
+ ...(auth ? { auth } : {}),
3040
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
3041
+ }), [apiUrl, auth, fetchImpl]);
3042
+ }
3043
+
2175
3044
  const chainIds = new WeakMap();
2176
3045
  let nextChainId = 1;
2177
3046
  function chainKey(chain) {
@@ -2463,6 +3332,10 @@ Object.defineProperty(exports, 'createInstructionHandler', {
2463
3332
  enumerable: true,
2464
3333
  get: function () { return sdk.createInstructionHandler; }
2465
3334
  });
3335
+ Object.defineProperty(exports, 'createPlatformHttpClient', {
3336
+ enumerable: true,
3337
+ get: function () { return sdk.createPlatformHttpClient; }
3338
+ });
2466
3339
  Object.defineProperty(exports, 'createPublicKeySeed', {
2467
3340
  enumerable: true,
2468
3341
  get: function () { return sdk.createPublicKeySeed; }
@@ -2568,20 +3441,29 @@ Object.defineProperty(exports, 'withPrograms', {
2568
3441
  get: function () { return sdk.withPrograms; }
2569
3442
  });
2570
3443
  exports.AreteProvider = AreteProvider;
3444
+ exports.AreteSessionProvider = AreteSessionProvider;
3445
+ exports.AreteSessionUnavailableError = AreteSessionUnavailableError;
2571
3446
  exports.DEFAULT_RECONCILIATION_TIMEOUT_MS = DEFAULT_RECONCILIATION_TIMEOUT_MS;
3447
+ exports.OperationMutationError = OperationMutationError;
2572
3448
  exports.ZustandAdapter = ZustandAdapter;
2573
3449
  exports.createAreteReact = createAreteReact;
2574
3450
  exports.createProcessedSlotReconciliation = createProcessedSlotReconciliation;
3451
+ exports.isAreteSessionUnavailableError = isAreteSessionUnavailableError;
3452
+ exports.isWalletRejection = isWalletRejection;
2575
3453
  exports.reconcileProcessedSlot = reconcileProcessedSlot;
2576
3454
  exports.summarizeStatuses = summarizeStatuses;
2577
3455
  exports.summarizeViews = summarizeViews;
3456
+ exports.toOperationMutationError = toOperationMutationError;
2578
3457
  exports.useAllowedSteps = useAllowedSteps;
2579
3458
  exports.useArete = useArete;
2580
3459
  exports.useAreteContext = useAreteContext;
3460
+ exports.useAreteSession = useAreteSession;
2581
3461
  exports.useAsyncRead = useAsyncRead;
2582
3462
  exports.useConnectionState = useConnectionState;
2583
3463
  exports.useInstructionMutation = useInstructionMutation;
2584
3464
  exports.useNativeBalance = useNativeBalance;
3465
+ exports.useOperationMutation = useOperationMutation;
3466
+ exports.usePlatformHttpClient = usePlatformHttpClient;
2585
3467
  exports.useProtocolEvents = useProtocolEvents;
2586
3468
  exports.useProtocolInstance = useProtocolInstance;
2587
3469
  exports.useProtocolParticipant = useProtocolParticipant;