@farcaster/snap 2.8.0 → 2.10.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.
Files changed (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/react/catalog-renderer.d.ts +5 -5
  4. package/dist/react/catalog-renderer.js +16 -4
  5. package/dist/react/components/action-button.js +23 -5
  6. package/dist/react/index.d.ts +2 -13
  7. package/dist/react/snap-view-core.js +90 -45
  8. package/dist/react/v1/snap-view.js +1 -1
  9. package/dist/react/v2/snap-view.js +1 -1
  10. package/dist/react-native/components/snap-action-button.js +6 -1
  11. package/dist/react-native/snap-view-core.js +77 -44
  12. package/dist/react-native/types.d.ts +2 -13
  13. package/dist/render-state.d.ts +9 -0
  14. package/dist/render-state.js +27 -0
  15. package/dist/schemas.d.ts +123 -3
  16. package/dist/schemas.js +53 -2
  17. package/dist/server/parseRequest.js +19 -3
  18. package/dist/ui/button.d.ts +1 -0
  19. package/dist/ui/button.js +1 -0
  20. package/dist/ui/catalog.d.ts +13 -14
  21. package/dist/ui/catalog.js +15 -22
  22. package/package.json +1 -1
  23. package/src/index.ts +7 -0
  24. package/src/react/catalog-renderer.tsx +57 -3
  25. package/src/react/components/action-button.tsx +32 -3
  26. package/src/react/index.tsx +4 -14
  27. package/src/react/snap-view-core.tsx +144 -48
  28. package/src/react/v1/snap-view.tsx +1 -0
  29. package/src/react/v2/snap-view.tsx +1 -0
  30. package/src/react-native/components/snap-action-button.tsx +6 -1
  31. package/src/react-native/snap-view-core.tsx +114 -48
  32. package/src/react-native/types.ts +4 -14
  33. package/src/render-state.ts +46 -0
  34. package/src/schemas.ts +73 -2
  35. package/src/server/parseRequest.ts +37 -6
  36. package/src/ui/button.ts +1 -0
  37. package/src/ui/catalog.ts +16 -25
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { Spec } from "@json-render/core";
4
+ import { createStateStore } from "@json-render/react";
4
5
  import { snapJsonRenderCatalog } from "../ui/index.js";
5
6
  import { SnapCatalogView } from "./catalog-renderer";
6
7
  import { SnapPreviewAccentProvider } from "./accent-context";
@@ -8,10 +9,11 @@ import { SnapVersionProvider } from "./snap-version-context";
8
9
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
9
10
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
10
11
  import {
11
- applyStatePaths,
12
+ buildActionActivityStateChanges,
12
13
  buildInitialRenderState,
13
14
  cloneSnapRenderState,
14
15
  getUnpresentedSnapEffects,
16
+ hasPendingSnapAction,
15
17
  markSnapEffectsPresented,
16
18
  type SnapRenderState,
17
19
  } from "../render-state";
@@ -36,6 +38,12 @@ function optionalString(value: unknown): string | undefined {
36
38
  return value ? String(value) : undefined;
37
39
  }
38
40
 
41
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
42
+ return value && typeof value === "object" && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : undefined;
45
+ }
46
+
39
47
  function withDefaultElementProps(spec: Spec): Spec {
40
48
  if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
41
49
  const elements = spec.elements as unknown as Record<
@@ -266,6 +274,8 @@ export function SnapLoadingOverlay({
266
274
 
267
275
  return (
268
276
  <div
277
+ data-snap-loading-overlay
278
+ data-snap-loading-active={active ? "true" : "false"}
269
279
  style={{
270
280
  position: "absolute",
271
281
  inset: 0,
@@ -299,6 +309,20 @@ export function SnapLoadingOverlay({
299
309
  }}
300
310
  />
301
311
  <style>{`
312
+ [data-snap-view-root]:has([data-snap-action-pending-active="true"])
313
+ [data-snap-loading-overlay] {
314
+ opacity: 1 !important;
315
+ pointer-events: auto !important;
316
+ backdrop-filter: blur(10px) saturate(1.05) !important;
317
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
318
+ }
319
+ [data-snap-card-surface]:has([data-snap-action-pending-active="true"])
320
+ > [data-snap-loading-overlay] {
321
+ opacity: 1 !important;
322
+ pointer-events: auto !important;
323
+ backdrop-filter: blur(10px) saturate(1.05) !important;
324
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
325
+ }
302
326
  @keyframes snapViewSpin {
303
327
  to { transform: rotate(360deg); }
304
328
  }
@@ -314,6 +338,22 @@ export function SnapLoadingOverlay({
314
338
  );
315
339
  }
316
340
 
341
+ function SnapPendingActionOverlay({
342
+ appearance,
343
+ accentHex,
344
+ }: {
345
+ appearance: "light" | "dark";
346
+ accentHex: string;
347
+ }) {
348
+ return (
349
+ <SnapLoadingOverlay
350
+ appearance={appearance}
351
+ accentHex={accentHex}
352
+ active={false}
353
+ />
354
+ );
355
+ }
356
+
317
357
  const PALETTE = [
318
358
  "gray",
319
359
  "blue",
@@ -360,12 +400,31 @@ export function SnapViewCore({
360
400
  [initialRenderState, spec.state, snap.theme?.accent],
361
401
  );
362
402
 
403
+ const stateStore = useMemo(() => createStateStore(initialState), [
404
+ initialState,
405
+ ]);
363
406
  const stateRef = useRef<Record<string, unknown>>(initialState);
407
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
408
+ const pendingActionCountRef = useRef(0);
364
409
 
365
410
  useEffect(() => {
366
411
  stateRef.current = cloneSnapRenderState(initialState);
367
412
  }, [initialState]);
368
413
 
414
+ useEffect(() => {
415
+ onRenderStateChangeRef.current = onRenderStateChange;
416
+ }, [onRenderStateChange]);
417
+
418
+ useEffect(
419
+ () =>
420
+ stateStore.subscribe(() => {
421
+ const snapshot = cloneSnapRenderState(stateStore.getSnapshot());
422
+ stateRef.current = snapshot;
423
+ onRenderStateChangeRef.current?.(snapshot);
424
+ }),
425
+ [stateStore],
426
+ );
427
+
369
428
  useEffect(() => {
370
429
  const catalogResult = snapJsonRenderCatalog.validate(spec);
371
430
  if (!catalogResult.success) {
@@ -390,10 +449,6 @@ export function SnapViewCore({
390
449
  confetti: 0,
391
450
  fireworks: 0,
392
451
  });
393
- const onRenderStateChangeRef = useRef(onRenderStateChange);
394
- useEffect(() => {
395
- onRenderStateChangeRef.current = onRenderStateChange;
396
- }, [onRenderStateChange]);
397
452
  useEffect(() => {
398
453
  const effectsToPresent = getUnpresentedSnapEffects(
399
454
  stateRef.current,
@@ -415,7 +470,10 @@ export function SnapViewCore({
415
470
  }
416
471
 
417
472
  if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
418
- onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
473
+ const meta = recordValue(stateRef.current.__snapRender);
474
+ stateStore.update({
475
+ "/__snapRender/presentedEffects": meta?.presentedEffects ?? [],
476
+ });
419
477
  }
420
478
 
421
479
  setEffectRunKeys((current) => ({
@@ -430,7 +488,7 @@ export function SnapViewCore({
430
488
  ? current.fireworks
431
489
  : 0,
432
490
  }));
433
- }, [initialState, showConfetti, showFireworks, snapEffects]);
491
+ }, [initialState, showConfetti, showFireworks, snapEffects, stateStore]);
434
492
 
435
493
  const accentName = snap.theme?.accent ?? "purple";
436
494
 
@@ -449,6 +507,40 @@ export function SnapViewCore({
449
507
  } as CSSProperties;
450
508
  }, [accentName, appearance]);
451
509
 
510
+ const applyActionActivityState = useCallback(
511
+ (name: unknown, params: Record<string, unknown>, pending: boolean) => {
512
+ stateStore.update(
513
+ Object.fromEntries(
514
+ buildActionActivityStateChanges({
515
+ actionName: name,
516
+ params,
517
+ pending,
518
+ }).map(({ path, value }) => [path, value]),
519
+ ),
520
+ );
521
+ },
522
+ [stateStore],
523
+ );
524
+
525
+ const setActionPending = useCallback(
526
+ (name: unknown, params: Record<string, unknown>) => {
527
+ pendingActionCountRef.current += 1;
528
+ applyActionActivityState(name, params, true);
529
+ },
530
+ [applyActionActivityState],
531
+ );
532
+
533
+ const setActionSettled = useCallback(
534
+ (name: unknown, params: Record<string, unknown>) => {
535
+ pendingActionCountRef.current = Math.max(
536
+ 0,
537
+ pendingActionCountRef.current - 1,
538
+ );
539
+ applyActionActivityState(name, params, false);
540
+ },
541
+ [applyActionActivityState],
542
+ );
543
+
452
544
  const handleAction = useCallback(
453
545
  (name: unknown, params: unknown) => {
454
546
  const inputs = (stateRef.current.inputs ?? {}) as Record<
@@ -456,30 +548,35 @@ export function SnapViewCore({
456
548
  JsonValue
457
549
  >;
458
550
  const p = (params ?? {}) as Record<string, unknown>;
551
+ let result: unknown;
552
+ setActionPending(name, p);
553
+
459
554
  switch (name) {
460
555
  case "submit":
461
- handlers.submit(String(p.target ?? ""), inputs);
556
+ result = handlers.submit(String(p.target ?? ""), inputs);
462
557
  break;
463
558
  case "open_url":
464
- handlers.open_url(String(p.target ?? ""));
559
+ result = handlers.open_url(String(p.target ?? ""));
465
560
  break;
466
561
  case "open_snap":
467
- handlers.open_snap(String(p.target ?? ""));
562
+ result = handlers.open_snap(String(p.target ?? ""));
468
563
  break;
469
564
  case "open_mini_app":
470
- handlers.open_mini_app(String(p.target ?? ""));
565
+ result = handlers.open_mini_app(String(p.target ?? ""));
471
566
  break;
472
567
  case "view_cast":
473
- handlers.view_cast({ hash: String(p.hash ?? "") });
568
+ result = handlers.view_cast({ hash: String(p.hash ?? "") });
474
569
  break;
475
570
  case "view_profile":
476
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
571
+ result = handlers.view_profile({ fid: Number(p.fid ?? 0) });
477
572
  break;
478
573
  case "view_channel":
479
- handlers.view_channel({ channelKey: String(p.channelKey ?? "") });
574
+ result = handlers.view_channel({
575
+ channelKey: String(p.channelKey ?? ""),
576
+ });
480
577
  break;
481
578
  case "compose_cast":
482
- handlers.compose_cast({
579
+ result = handlers.compose_cast({
483
580
  text: p.text ? String(p.text) : undefined,
484
581
  channelKey: p.channelKey ? String(p.channelKey) : undefined,
485
582
  embeds: Array.isArray(p.embeds)
@@ -488,10 +585,10 @@ export function SnapViewCore({
488
585
  });
489
586
  break;
490
587
  case "view_token":
491
- handlers.view_token({ token: String(p.token ?? "") });
588
+ result = handlers.view_token({ token: String(p.token ?? "") });
492
589
  break;
493
590
  case "send_token":
494
- handlers.send_token({
591
+ result = handlers.send_token({
495
592
  token: String(p.token ?? ""),
496
593
  amount: p.amount ? String(p.amount) : undefined,
497
594
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
@@ -501,13 +598,13 @@ export function SnapViewCore({
501
598
  });
502
599
  break;
503
600
  case "swap_token":
504
- handlers.swap_token({
601
+ result = handlers.swap_token({
505
602
  sellToken: p.sellToken ? String(p.sellToken) : undefined,
506
603
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
507
604
  });
508
605
  break;
509
606
  case "send_transaction":
510
- handlers.send_transaction?.({
607
+ result = handlers.send_transaction?.({
511
608
  chainId: String(p.chainId ?? ""),
512
609
  to: String(p.to ?? ""),
513
610
  data: optionalString(p.data),
@@ -518,36 +615,32 @@ export function SnapViewCore({
518
615
  maxPriorityFeePerGas: optionalString(p.maxPriorityFeePerGas),
519
616
  });
520
617
  break;
521
- case "send_calls":
522
- handlers.send_calls?.({
523
- version: p.version === "1.0" ? "1.0" : undefined,
524
- chainId: String(p.chainId ?? ""),
525
- atomicRequired:
526
- typeof p.atomicRequired === "boolean"
527
- ? p.atomicRequired
528
- : undefined,
529
- id: optionalString(p.id),
530
- calls: Array.isArray(p.calls)
531
- ? p.calls.map((call) => {
532
- const c = asRecord(call);
533
- return {
534
- to: optionalString(c.to),
535
- data: optionalString(c.data),
536
- value: optionalString(c.value),
537
- };
538
- })
539
- : [],
540
- });
541
- break;
542
618
  default:
543
619
  break;
544
620
  }
621
+
622
+ if (result instanceof Promise) {
623
+ void result.finally(() => {
624
+ setActionSettled(name, p);
625
+ }).catch(() => {});
626
+ } else {
627
+ setActionSettled(name, p);
628
+ }
629
+ return result;
545
630
  },
546
- [handlers],
631
+ [handlers, setActionPending, setActionSettled],
547
632
  );
548
633
 
549
634
  return (
550
- <div style={{ position: "relative", width: "100%" }}>
635
+ <div
636
+ data-snap-view-root
637
+ style={{ position: "relative", width: "100%" }}
638
+ onClickCapture={(event) => {
639
+ if (!hasPendingSnapAction(stateRef.current)) return;
640
+ event.preventDefault();
641
+ event.stopPropagation();
642
+ }}
643
+ >
551
644
  {showConfetti && effectRunKeys.confetti > 0 && (
552
645
  <ConfettiOverlay key={effectRunKeys.confetti} />
553
646
  )}
@@ -573,14 +666,17 @@ export function SnapViewCore({
573
666
  <SnapCatalogView
574
667
  key={pageKey}
575
668
  spec={spec}
576
- state={initialState}
669
+ store={stateStore}
577
670
  loading={false}
578
- onStateChange={(changes) => {
579
- applyStatePaths(stateRef.current, changes);
580
- onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
581
- }}
582
671
  onAction={handleAction}
583
- />
672
+ >
673
+ {loadingOverlay === undefined ? (
674
+ <SnapPendingActionOverlay
675
+ appearance={appearance}
676
+ accentHex={accentHex}
677
+ />
678
+ ) : null}
679
+ </SnapCatalogView>
584
680
  </SnapVersionProvider>
585
681
  </SnapPreviewAccentProvider>
586
682
  </div>
@@ -120,6 +120,7 @@ export function SnapCardV1({
120
120
  }}
121
121
  >
122
122
  <div
123
+ data-snap-card-surface
123
124
  style={{
124
125
  position: "relative",
125
126
  overflow: "hidden",
@@ -193,6 +193,7 @@ export function SnapCardV2({
193
193
  }}
194
194
  >
195
195
  <div
196
+ data-snap-card-surface
196
197
  style={{
197
198
  position: "relative",
198
199
  maxHeight: containerMaxHeight,
@@ -34,6 +34,7 @@ export function SnapActionButton({
34
34
  const label = String(props.label ?? "Action");
35
35
  const variant = String(props.variant ?? "secondary");
36
36
  const isPrimary = variant === "primary";
37
+ const disabled = props.disabled === true;
37
38
  const iconName = props.icon ? String(props.icon) : undefined;
38
39
 
39
40
  const textColor = isPrimary ? "#fff" : colors.text;
@@ -53,10 +54,13 @@ export function SnapActionButton({
53
54
  isPrimary ? styles.btnDefault : styles.btnOther,
54
55
  isPrimary
55
56
  ? { backgroundColor: pressed ? accentHex + "DD" : accentHex }
56
- : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
57
+ : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
57
58
  pressed && styles.pressed,
59
+ disabled && styles.disabled,
58
60
  ]}
61
+ disabled={disabled}
59
62
  onPress={() => {
63
+ if (disabled) return;
60
64
  if (runPaginatorAction(stateStore, paginatorAction)) return;
61
65
  void (async () => {
62
66
  try {
@@ -114,4 +118,5 @@ const styles = StyleSheet.create({
114
118
  paddingVertical: 6,
115
119
  },
116
120
  pressed: { opacity: 0.88 },
121
+ disabled: { opacity: 0.62 },
117
122
  });
@@ -1,4 +1,5 @@
1
1
  import type { Spec } from "@json-render/core";
2
+ import { createStateStore } from "@json-render/react-native";
2
3
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
4
  import { SnapCatalogView } from "./catalog-renderer";
4
5
  import { ConfettiOverlay } from "./confetti-overlay";
@@ -21,10 +22,11 @@ import {
21
22
  type PaletteColor,
22
23
  } from "@farcaster/snap";
23
24
  import {
24
- applyStatePaths,
25
+ buildActionActivityStateChanges,
25
26
  buildInitialRenderState,
26
27
  cloneSnapRenderState,
27
28
  getUnpresentedSnapEffects,
29
+ hasPendingSnapAction,
28
30
  markSnapEffectsPresented,
29
31
  type SnapRenderState,
30
32
  } from "../render-state";
@@ -40,6 +42,12 @@ function optionalString(value: unknown): string | undefined {
40
42
  return value ? String(value) : undefined;
41
43
  }
42
44
 
45
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
46
+ return value && typeof value === "object" && !Array.isArray(value)
47
+ ? (value as Record<string, unknown>)
48
+ : undefined;
49
+ }
50
+
43
51
  function withDefaultElementProps(spec: Spec): Spec {
44
52
  if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
45
53
  const elements = spec.elements as unknown as Record<
@@ -114,12 +122,34 @@ export function SnapViewCoreInner({
114
122
  [initialRenderState, spec.state, snap.theme?.accent],
115
123
  );
116
124
 
125
+ const stateStore = useMemo(() => createStateStore(initialState), [
126
+ initialState,
127
+ ]);
117
128
  const stateRef = useRef<Record<string, unknown>>(initialState);
129
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
130
+ const pendingActionCountRef = useRef(0);
131
+ const [hasPendingAction, setHasPendingAction] = useState(false);
132
+ const [actionActivityVersion, setActionActivityVersion] = useState(0);
118
133
 
119
134
  useEffect(() => {
120
135
  stateRef.current = cloneSnapRenderState(initialState);
121
136
  }, [initialState]);
122
137
 
138
+ useEffect(() => {
139
+ onRenderStateChangeRef.current = onRenderStateChange;
140
+ }, [onRenderStateChange]);
141
+
142
+ useEffect(
143
+ () =>
144
+ stateStore.subscribe(() => {
145
+ const snapshot = cloneSnapRenderState(stateStore.getSnapshot());
146
+ stateRef.current = snapshot;
147
+ setHasPendingAction(hasPendingSnapAction(snapshot));
148
+ onRenderStateChangeRef.current?.(snapshot);
149
+ }),
150
+ [stateStore],
151
+ );
152
+
123
153
  useEffect(() => {
124
154
  const catalogResult = snapJsonRenderCatalog.validate(spec);
125
155
  if (!catalogResult.success) {
@@ -144,10 +174,6 @@ export function SnapViewCoreInner({
144
174
  confetti: 0,
145
175
  fireworks: 0,
146
176
  });
147
- const onRenderStateChangeRef = useRef(onRenderStateChange);
148
- useEffect(() => {
149
- onRenderStateChangeRef.current = onRenderStateChange;
150
- }, [onRenderStateChange]);
151
177
  useEffect(() => {
152
178
  const effectsToPresent = getUnpresentedSnapEffects(
153
179
  stateRef.current,
@@ -169,7 +195,10 @@ export function SnapViewCoreInner({
169
195
  }
170
196
 
171
197
  if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
172
- onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
198
+ const meta = recordValue(stateRef.current.__snapRender);
199
+ stateStore.update({
200
+ "/__snapRender/presentedEffects": meta?.presentedEffects ?? [],
201
+ });
173
202
  }
174
203
 
175
204
  setEffectRunKeys((current) => ({
@@ -184,49 +213,92 @@ export function SnapViewCoreInner({
184
213
  ? current.fireworks
185
214
  : 0,
186
215
  }));
187
- }, [initialState, showConfetti, showFireworks, snapEffects]);
216
+ }, [initialState, showConfetti, showFireworks, snapEffects, stateStore]);
188
217
 
189
218
  const handlersRef = useRef(handlers);
190
219
  handlersRef.current = handlers;
191
220
 
221
+ const applyActionActivityState = useCallback(
222
+ (name: unknown, params: Record<string, unknown>, pending: boolean) => {
223
+ stateStore.update(
224
+ Object.fromEntries(
225
+ buildActionActivityStateChanges({
226
+ actionName: name,
227
+ params,
228
+ pending,
229
+ }).map(({ path, value }) => [path, value]),
230
+ ),
231
+ );
232
+ },
233
+ [stateStore],
234
+ );
235
+
236
+ const setActionPending = useCallback(
237
+ (name: unknown, params: Record<string, unknown>) => {
238
+ pendingActionCountRef.current += 1;
239
+ setHasPendingAction(true);
240
+ setActionActivityVersion((version) => version + 1);
241
+ applyActionActivityState(name, params, true);
242
+ },
243
+ [applyActionActivityState],
244
+ );
245
+
246
+ const setActionSettled = useCallback(
247
+ (name: unknown, params: Record<string, unknown>) => {
248
+ pendingActionCountRef.current = Math.max(
249
+ 0,
250
+ pendingActionCountRef.current - 1,
251
+ );
252
+ applyActionActivityState(name, params, false);
253
+ if (pendingActionCountRef.current === 0) {
254
+ setHasPendingAction(false);
255
+ }
256
+ setActionActivityVersion((version) => version + 1);
257
+ },
258
+ [applyActionActivityState],
259
+ );
260
+
192
261
  const handleAction = useCallback((name: unknown, params: unknown) => {
193
262
  const inputs = (stateRef.current.inputs ?? {}) as Record<string, JsonValue>;
194
263
  const p = (params ?? {}) as Record<string, unknown>;
195
264
  const h = handlersRef.current;
265
+ let result: unknown;
266
+ setActionPending(name, p);
267
+
196
268
  switch (name) {
197
269
  case "submit":
198
- h.submit(String(p.target ?? ""), inputs);
270
+ result = h.submit(String(p.target ?? ""), inputs);
199
271
  break;
200
272
  case "open_url":
201
- h.open_url(String(p.target ?? ""));
273
+ result = h.open_url(String(p.target ?? ""));
202
274
  break;
203
275
  case "open_snap":
204
- h.open_snap(String(p.target ?? ""));
276
+ result = h.open_snap(String(p.target ?? ""));
205
277
  break;
206
278
  case "open_mini_app":
207
- h.open_mini_app(String(p.target ?? ""));
279
+ result = h.open_mini_app(String(p.target ?? ""));
208
280
  break;
209
281
  case "view_cast":
210
- h.view_cast({ hash: String(p.hash ?? "") });
282
+ result = h.view_cast({ hash: String(p.hash ?? "") });
211
283
  break;
212
284
  case "view_profile":
213
- h.view_profile({ fid: Number(p.fid ?? 0) });
285
+ result = h.view_profile({ fid: Number(p.fid ?? 0) });
214
286
  break;
215
287
  case "view_channel":
216
- h.view_channel({ channelKey: String(p.channelKey ?? "") });
288
+ result = h.view_channel({ channelKey: String(p.channelKey ?? "") });
217
289
  break;
218
290
  case "compose_cast":
219
- h.compose_cast({
291
+ result = h.compose_cast({
220
292
  text: p.text ? String(p.text) : undefined,
221
293
  channelKey: p.channelKey ? String(p.channelKey) : undefined,
222
294
  embeds: Array.isArray(p.embeds) ? (p.embeds as string[]) : undefined,
223
295
  });
224
296
  break;
225
297
  case "view_token":
226
- h.view_token({ token: String(p.token ?? "") });
298
+ result = h.view_token({ token: String(p.token ?? "") });
227
299
  break;
228
300
  case "send_token":
229
- h.send_token({
301
+ result = h.send_token({
230
302
  token: String(p.token ?? ""),
231
303
  amount: p.amount ? String(p.amount) : undefined,
232
304
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
@@ -236,13 +308,13 @@ export function SnapViewCoreInner({
236
308
  });
237
309
  break;
238
310
  case "swap_token":
239
- h.swap_token({
311
+ result = h.swap_token({
240
312
  sellToken: p.sellToken ? String(p.sellToken) : undefined,
241
313
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
242
314
  });
243
315
  break;
244
316
  case "send_transaction":
245
- h.send_transaction?.({
317
+ result = h.send_transaction?.({
246
318
  chainId: String(p.chainId ?? ""),
247
319
  to: String(p.to ?? ""),
248
320
  data: optionalString(p.data),
@@ -253,35 +325,33 @@ export function SnapViewCoreInner({
253
325
  maxPriorityFeePerGas: optionalString(p.maxPriorityFeePerGas),
254
326
  });
255
327
  break;
256
- case "send_calls":
257
- h.send_calls?.({
258
- version: p.version === "1.0" ? "1.0" : undefined,
259
- chainId: String(p.chainId ?? ""),
260
- atomicRequired:
261
- typeof p.atomicRequired === "boolean"
262
- ? p.atomicRequired
263
- : undefined,
264
- id: optionalString(p.id),
265
- calls: Array.isArray(p.calls)
266
- ? p.calls.map((call) => {
267
- const c = asRecord(call);
268
- return {
269
- to: optionalString(c.to),
270
- data: optionalString(c.data),
271
- value: optionalString(c.value),
272
- };
273
- })
274
- : [],
275
- });
276
- break;
277
328
  default:
278
329
  break;
279
330
  }
280
- }, []);
331
+
332
+ if (result instanceof Promise) {
333
+ void result.finally(() => {
334
+ setActionSettled(name, p);
335
+ }).catch(() => {});
336
+ } else {
337
+ setActionSettled(name, p);
338
+ }
339
+ return result;
340
+ }, [setActionPending, setActionSettled]);
341
+
342
+ const showLoadingOverlay =
343
+ loading ||
344
+ hasPendingAction ||
345
+ (actionActivityVersion >= 0 && pendingActionCountRef.current > 0);
281
346
 
282
347
  return (
283
- <View style={styles.container}>
284
- {loading ? (
348
+ <View
349
+ style={styles.container}
350
+ onStartShouldSetResponderCapture={() =>
351
+ hasPendingSnapAction(stateRef.current)
352
+ }
353
+ >
354
+ {showLoadingOverlay ? (
285
355
  loadingOverlay === undefined ? (
286
356
  <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
287
357
  ) : (
@@ -292,12 +362,8 @@ export function SnapViewCoreInner({
292
362
  <SnapCatalogView
293
363
  key={pageKey}
294
364
  spec={spec}
295
- state={initialState}
365
+ store={stateStore}
296
366
  loading={false}
297
- onStateChange={(changes) => {
298
- applyStatePaths(stateRef.current, changes);
299
- onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
300
- }}
301
367
  onAction={handleAction}
302
368
  />
303
369
  </SnapVersionProvider>
@@ -1,5 +1,6 @@
1
1
  import type { Spec } from "@json-render/core";
2
2
  import type { SnapRenderState } from "../render-state";
3
+ import type { SnapTransactionResult } from "../schemas";
3
4
 
4
5
  export type { SnapRenderState };
5
6
 
@@ -29,18 +30,6 @@ export type SnapSendTransactionParams = {
29
30
  maxPriorityFeePerGas?: string;
30
31
  };
31
32
 
32
- export type SnapSendCallsParams = {
33
- version?: "1.0";
34
- chainId: string;
35
- atomicRequired?: boolean;
36
- id?: string;
37
- calls: Array<{
38
- to?: string;
39
- data?: string;
40
- value?: string;
41
- }>;
42
- };
43
-
44
33
  export type SnapActionHandlers = {
45
34
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
46
35
  open_url: (target: string) => void;
@@ -62,6 +51,7 @@ export type SnapActionHandlers = {
62
51
  recipientAddress?: string;
63
52
  }) => void;
64
53
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
65
- send_transaction?: (params: SnapSendTransactionParams) => void;
66
- send_calls?: (params: SnapSendCallsParams) => void;
54
+ send_transaction?: (
55
+ params: SnapSendTransactionParams,
56
+ ) => void | Promise<void | SnapTransactionResult>;
67
57
  };