@farcaster/snap 2.6.4 → 2.7.1

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.
@@ -4,6 +4,7 @@ import type { Spec } from "@json-render/core";
4
4
  import type { ReactNode } from "react";
5
5
  import type { ValidationResult } from "../validator.js";
6
6
  import { SPEC_VERSION_2 } from "../constants";
7
+ import type { SnapRenderState } from "../render-state";
7
8
  import { SnapCardV1 } from "./v1/snap-view";
8
9
  import { SnapCardV2 } from "./v2/snap-view";
9
10
 
@@ -24,6 +25,29 @@ export type SnapPage = {
24
25
  ui: Spec;
25
26
  };
26
27
 
28
+ export type SnapSendTransactionParams = {
29
+ chainId: string;
30
+ to: string;
31
+ data?: string;
32
+ value?: string;
33
+ gas?: string;
34
+ gasPrice?: string;
35
+ maxFeePerGas?: string;
36
+ maxPriorityFeePerGas?: string;
37
+ };
38
+
39
+ export type SnapSendCallsParams = {
40
+ version?: "1.0";
41
+ chainId: string;
42
+ atomicRequired?: boolean;
43
+ id?: string;
44
+ calls: Array<{
45
+ to?: string;
46
+ data?: string;
47
+ value?: string;
48
+ }>;
49
+ };
50
+
27
51
  export type SnapActionHandlers = {
28
52
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
29
53
  open_url: (target: string) => void;
@@ -44,8 +68,12 @@ export type SnapActionHandlers = {
44
68
  recipientAddress?: string;
45
69
  }) => void;
46
70
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
71
+ send_transaction?: (params: SnapSendTransactionParams) => void;
72
+ send_calls?: (params: SnapSendCallsParams) => void;
47
73
  };
48
74
 
75
+ export type { SnapRenderState };
76
+
49
77
  // ─── SnapCard ────────────────────────────────────────
50
78
 
51
79
  export function SnapCard({
@@ -60,6 +88,8 @@ export function SnapCard({
60
88
  actionError,
61
89
  plain = false,
62
90
  loadingOverlay,
91
+ initialRenderState,
92
+ onRenderStateChange,
63
93
  }: {
64
94
  snap: SnapPage;
65
95
  handlers: SnapActionHandlers;
@@ -76,6 +106,10 @@ export function SnapCard({
76
106
  plain?: boolean;
77
107
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
78
108
  loadingOverlay?: ReactNode;
109
+ /** JSON-render local state used to seed this presenter mount. */
110
+ initialRenderState?: SnapRenderState;
111
+ /** Called with the full JSON-render local state after state changes. */
112
+ onRenderStateChange?: (state: SnapRenderState) => void;
79
113
  }) {
80
114
  if (snap.version === SPEC_VERSION_2) {
81
115
  return (
@@ -91,6 +125,8 @@ export function SnapCard({
91
125
  actionError={actionError}
92
126
  plain={plain}
93
127
  loadingOverlay={loadingOverlay}
128
+ initialRenderState={initialRenderState}
129
+ onRenderStateChange={onRenderStateChange}
94
130
  />
95
131
  );
96
132
  }
@@ -105,6 +141,8 @@ export function SnapCard({
105
141
  actionError={actionError}
106
142
  plain={plain}
107
143
  loadingOverlay={loadingOverlay}
144
+ initialRenderState={initialRenderState}
145
+ onRenderStateChange={onRenderStateChange}
108
146
  />
109
147
  );
110
148
  }
@@ -7,6 +7,14 @@ import { SnapPreviewAccentProvider } from "./accent-context";
7
7
  import { SnapVersionProvider } from "./snap-version-context";
8
8
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
9
9
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
10
+ import {
11
+ applyStatePaths,
12
+ buildInitialRenderState,
13
+ cloneSnapRenderState,
14
+ getUnpresentedSnapEffects,
15
+ markSnapEffectsPresented,
16
+ type SnapRenderState,
17
+ } from "../render-state";
10
18
  import {
11
19
  type CSSProperties,
12
20
  type ReactNode,
@@ -18,45 +26,14 @@ import {
18
26
  } from "react";
19
27
  import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
20
28
 
21
- // ─── Internal helpers ──────────────────────────────────
29
+ function asRecord(value: unknown): Record<string, unknown> {
30
+ return value && typeof value === "object"
31
+ ? (value as Record<string, unknown>)
32
+ : {};
33
+ }
22
34
 
23
- export function applyStatePaths(
24
- model: Record<string, unknown>,
25
- changes:
26
- | { path: string; value: unknown }[]
27
- | Record<string, unknown>
28
- | null
29
- | undefined,
30
- ): void {
31
- if (!changes) return;
32
- const entries = Array.isArray(changes)
33
- ? changes.map((c) => [c.path, c.value] as const)
34
- : Object.entries(changes);
35
- for (const [path, value] of entries) {
36
- const trimmed = path.startsWith("/") ? path : `/${path}`;
37
- const parts = trimmed.split("/").filter(Boolean);
38
- if (parts.length < 2) continue;
39
- const [top, ...rest] = parts;
40
- if (top === "inputs") {
41
- if (typeof model.inputs !== "object" || model.inputs === null) {
42
- model.inputs = {};
43
- }
44
- const inputs = model.inputs as Record<string, unknown>;
45
- if (rest.length === 1) {
46
- inputs[rest[0]!] = value;
47
- }
48
- continue;
49
- }
50
- if (top === "theme") {
51
- if (typeof model.theme !== "object" || model.theme === null) {
52
- model.theme = {};
53
- }
54
- const theme = model.theme as Record<string, unknown>;
55
- if (rest.length === 1) {
56
- theme[rest[0]!] = value;
57
- }
58
- }
59
- }
35
+ function optionalString(value: unknown): string | undefined {
36
+ return value ? String(value) : undefined;
60
37
  }
61
38
 
62
39
  function withDefaultElementProps(spec: Spec): Spec {
@@ -137,7 +114,18 @@ function ConfettiOverlay() {
137
114
  }}
138
115
  >
139
116
  {pieces.map(
140
- ({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (
117
+ ({
118
+ id,
119
+ left,
120
+ delay,
121
+ duration,
122
+ color,
123
+ size,
124
+ rotation,
125
+ isCircle,
126
+ driftX,
127
+ driftMid,
128
+ }) => (
141
129
  <div
142
130
  key={id}
143
131
  style={
@@ -179,8 +167,7 @@ function FireworksOverlay() {
179
167
  y: 10 + Math.random() * 50,
180
168
  delay: b * 0.5 + Math.random() * 0.2,
181
169
  particles: Array.from({ length: 24 }, (_, p) => {
182
- const angle =
183
- (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
170
+ const angle = (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
184
171
  const dist = 55 + Math.random() * 60;
185
172
  return {
186
173
  id: p,
@@ -288,9 +275,7 @@ export function SnapLoadingOverlay({
288
275
  zIndex: 10,
289
276
  background: tint,
290
277
  backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
291
- WebkitBackdropFilter: active
292
- ? "blur(10px) saturate(1.05)"
293
- : "none",
278
+ WebkitBackdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
294
279
  opacity: active ? 1 : 0,
295
280
  pointerEvents: active ? "auto" : "none",
296
281
  transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
@@ -349,6 +334,8 @@ export function SnapViewCore({
349
334
  loading = false,
350
335
  appearance = "dark",
351
336
  loadingOverlay,
337
+ initialRenderState,
338
+ onRenderStateChange,
352
339
  }: {
353
340
  snap: SnapPage;
354
341
  handlers: SnapActionHandlers;
@@ -359,21 +346,24 @@ export function SnapViewCore({
359
346
  * the built-in spinner + backdrop is used. Pass `null` to render nothing.
360
347
  */
361
348
  loadingOverlay?: ReactNode;
349
+ initialRenderState?: SnapRenderState;
350
+ onRenderStateChange?: (state: SnapRenderState) => void;
362
351
  }) {
363
352
  const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
364
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
353
+ const initialState = useMemo(
354
+ () =>
355
+ buildInitialRenderState({
356
+ specState: spec.state,
357
+ initialRenderState,
358
+ themeAccent: snap.theme?.accent,
359
+ }),
360
+ [initialRenderState, spec.state, snap.theme?.accent],
361
+ );
365
362
 
366
363
  const stateRef = useRef<Record<string, unknown>>(initialState);
367
364
 
368
365
  useEffect(() => {
369
- stateRef.current = {
370
- inputs: {
371
- ...((initialState.inputs ?? {}) as Record<string, unknown>),
372
- },
373
- theme: {
374
- ...((initialState.theme ?? {}) as Record<string, unknown>),
375
- },
376
- };
366
+ stateRef.current = cloneSnapRenderState(initialState);
377
367
  }, [initialState]);
378
368
 
379
369
  useEffect(() => {
@@ -389,14 +379,58 @@ export function SnapViewCore({
389
379
  setPageKey((k) => k + 1);
390
380
  }, [spec]);
391
381
 
392
- const showConfetti = snap.effects?.includes("confetti") ?? false;
393
- const showFireworks = snap.effects?.includes("fireworks") ?? false;
394
- const [confettiKey, setConfettiKey] = useState(0);
395
- const [fireworksKey, setFireworksKey] = useState(0);
382
+ const effectSignature = snap.effects?.join("\u0000") ?? "";
383
+ const snapEffects = useMemo(
384
+ () => (effectSignature ? effectSignature.split("\u0000") : []),
385
+ [effectSignature],
386
+ );
387
+ const showConfetti = snapEffects.includes("confetti");
388
+ const showFireworks = snapEffects.includes("fireworks");
389
+ const [effectRunKeys, setEffectRunKeys] = useState({
390
+ confetti: 0,
391
+ fireworks: 0,
392
+ });
393
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
394
+ useEffect(() => {
395
+ onRenderStateChangeRef.current = onRenderStateChange;
396
+ }, [onRenderStateChange]);
396
397
  useEffect(() => {
397
- if (showConfetti) setConfettiKey((k) => k + 1);
398
- if (showFireworks) setFireworksKey((k) => k + 1);
399
- }, [showConfetti, showFireworks, snap]);
398
+ const effectsToPresent = getUnpresentedSnapEffects(
399
+ stateRef.current,
400
+ snapEffects,
401
+ );
402
+
403
+ if (effectsToPresent.length === 0) {
404
+ setEffectRunKeys((current) => {
405
+ const next = {
406
+ confetti: showConfetti ? current.confetti : 0,
407
+ fireworks: showFireworks ? current.fireworks : 0,
408
+ };
409
+ return next.confetti === current.confetti &&
410
+ next.fireworks === current.fireworks
411
+ ? current
412
+ : next;
413
+ });
414
+ return;
415
+ }
416
+
417
+ if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
418
+ onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
419
+ }
420
+
421
+ setEffectRunKeys((current) => ({
422
+ confetti: effectsToPresent.includes("confetti")
423
+ ? current.confetti + 1
424
+ : showConfetti
425
+ ? current.confetti
426
+ : 0,
427
+ fireworks: effectsToPresent.includes("fireworks")
428
+ ? current.fireworks + 1
429
+ : showFireworks
430
+ ? current.fireworks
431
+ : 0,
432
+ }));
433
+ }, [initialState, showConfetti, showFireworks, snapEffects]);
400
434
 
401
435
  const accentName = snap.theme?.accent ?? "purple";
402
436
 
@@ -469,6 +503,39 @@ export function SnapViewCore({
469
503
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
470
504
  });
471
505
  break;
506
+ case "send_transaction":
507
+ handlers.send_transaction?.({
508
+ chainId: String(p.chainId ?? ""),
509
+ to: String(p.to ?? ""),
510
+ data: optionalString(p.data),
511
+ value: optionalString(p.value),
512
+ gas: optionalString(p.gas),
513
+ gasPrice: optionalString(p.gasPrice),
514
+ maxFeePerGas: optionalString(p.maxFeePerGas),
515
+ maxPriorityFeePerGas: optionalString(p.maxPriorityFeePerGas),
516
+ });
517
+ break;
518
+ case "send_calls":
519
+ handlers.send_calls?.({
520
+ version: p.version === "1.0" ? "1.0" : undefined,
521
+ chainId: String(p.chainId ?? ""),
522
+ atomicRequired:
523
+ typeof p.atomicRequired === "boolean"
524
+ ? p.atomicRequired
525
+ : undefined,
526
+ id: optionalString(p.id),
527
+ calls: Array.isArray(p.calls)
528
+ ? p.calls.map((call) => {
529
+ const c = asRecord(call);
530
+ return {
531
+ to: optionalString(c.to),
532
+ data: optionalString(c.data),
533
+ value: optionalString(c.value),
534
+ };
535
+ })
536
+ : [],
537
+ });
538
+ break;
472
539
  default:
473
540
  break;
474
541
  }
@@ -478,8 +545,12 @@ export function SnapViewCore({
478
545
 
479
546
  return (
480
547
  <div style={{ position: "relative", width: "100%" }}>
481
- {showConfetti && <ConfettiOverlay key={confettiKey} />}
482
- {showFireworks && <FireworksOverlay key={fireworksKey} />}
548
+ {showConfetti && effectRunKeys.confetti > 0 && (
549
+ <ConfettiOverlay key={effectRunKeys.confetti} />
550
+ )}
551
+ {showFireworks && effectRunKeys.fireworks > 0 && (
552
+ <FireworksOverlay key={effectRunKeys.fireworks} />
553
+ )}
483
554
  {loadingOverlay === undefined ? (
484
555
  <SnapLoadingOverlay
485
556
  appearance={appearance}
@@ -503,6 +574,7 @@ export function SnapViewCore({
503
574
  loading={false}
504
575
  onStateChange={(changes) => {
505
576
  applyStatePaths(stateRef.current, changes);
577
+ onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
506
578
  }}
507
579
  onAction={handleAction}
508
580
  />
@@ -3,7 +3,7 @@
3
3
  import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core";
5
5
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
6
- import type { SnapPage, SnapActionHandlers } from "../index";
6
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../index";
7
7
 
8
8
  const SNAP_MAX_HEIGHT = 500;
9
9
 
@@ -13,6 +13,8 @@ export function SnapViewV1({
13
13
  loading = false,
14
14
  appearance = "dark",
15
15
  loadingOverlay,
16
+ initialRenderState,
17
+ onRenderStateChange,
16
18
  }: {
17
19
  snap: SnapPage;
18
20
  handlers: SnapActionHandlers;
@@ -20,6 +22,8 @@ export function SnapViewV1({
20
22
  appearance?: "light" | "dark";
21
23
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
22
24
  loadingOverlay?: ReactNode;
25
+ initialRenderState?: SnapRenderState;
26
+ onRenderStateChange?: (state: SnapRenderState) => void;
23
27
  }) {
24
28
  return (
25
29
  <SnapViewCore
@@ -28,6 +32,8 @@ export function SnapViewV1({
28
32
  loading={loading}
29
33
  appearance={appearance}
30
34
  loadingOverlay={loadingOverlay}
35
+ initialRenderState={initialRenderState}
36
+ onRenderStateChange={onRenderStateChange}
31
37
  />
32
38
  );
33
39
  }
@@ -41,6 +47,8 @@ export function SnapCardV1({
41
47
  actionError,
42
48
  plain = false,
43
49
  loadingOverlay,
50
+ initialRenderState,
51
+ onRenderStateChange,
44
52
  }: {
45
53
  snap: SnapPage;
46
54
  handlers: SnapActionHandlers;
@@ -51,6 +59,10 @@ export function SnapCardV1({
51
59
  plain?: boolean;
52
60
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
53
61
  loadingOverlay?: ReactNode;
62
+ /** JSON-render local state used to seed this presenter mount. */
63
+ initialRenderState?: SnapRenderState;
64
+ /** Called with the full JSON-render local state after state changes. */
65
+ onRenderStateChange?: (state: SnapRenderState) => void;
54
66
  }) {
55
67
  const isDark = appearance === "dark";
56
68
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
@@ -135,6 +147,8 @@ export function SnapCardV1({
135
147
  loading={loading}
136
148
  appearance={appearance}
137
149
  loadingOverlay={null}
150
+ initialRenderState={initialRenderState}
151
+ onRenderStateChange={onRenderStateChange}
138
152
  />
139
153
  </div>
140
154
  </div>
@@ -5,7 +5,7 @@ import { validateSnapResponse } from "../../validator.js";
5
5
  import type { ValidationResult } from "../../validator.js";
6
6
  import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core";
7
7
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
8
- import type { SnapPage, SnapActionHandlers } from "../index";
8
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../index";
9
9
 
10
10
  const SNAP_MAX_HEIGHT = 500;
11
11
  const SNAP_WARNING_HEIGHT = 700;
@@ -48,6 +48,8 @@ export function SnapViewV2({
48
48
  onValidationError,
49
49
  validationErrorFallback,
50
50
  loadingOverlay,
51
+ initialRenderState,
52
+ onRenderStateChange,
51
53
  }: {
52
54
  snap: SnapPage;
53
55
  handlers: SnapActionHandlers;
@@ -57,6 +59,8 @@ export function SnapViewV2({
57
59
  validationErrorFallback?: ReactNode;
58
60
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
59
61
  loadingOverlay?: ReactNode;
62
+ initialRenderState?: SnapRenderState;
63
+ onRenderStateChange?: (state: SnapRenderState) => void;
60
64
  }) {
61
65
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
62
66
  const valid = validation.valid;
@@ -85,6 +89,8 @@ export function SnapViewV2({
85
89
  loading={loading}
86
90
  appearance={appearance}
87
91
  loadingOverlay={loadingOverlay}
92
+ initialRenderState={initialRenderState}
93
+ onRenderStateChange={onRenderStateChange}
88
94
  />
89
95
  );
90
96
  }
@@ -103,6 +109,8 @@ export function SnapCardV2({
103
109
  actionError,
104
110
  plain = false,
105
111
  loadingOverlay,
112
+ initialRenderState,
113
+ onRenderStateChange,
106
114
  }: {
107
115
  snap: SnapPage;
108
116
  handlers: SnapActionHandlers;
@@ -116,6 +124,10 @@ export function SnapCardV2({
116
124
  plain?: boolean;
117
125
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
118
126
  loadingOverlay?: ReactNode;
127
+ /** JSON-render local state used to seed this presenter mount. */
128
+ initialRenderState?: SnapRenderState;
129
+ /** Called with the full JSON-render local state after state changes. */
130
+ onRenderStateChange?: (state: SnapRenderState) => void;
119
131
  }) {
120
132
  const isDark = appearance === "dark";
121
133
  const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
@@ -208,6 +220,8 @@ export function SnapCardV2({
208
220
  onValidationError={onValidationError}
209
221
  validationErrorFallback={validationErrorFallback}
210
222
  loadingOverlay={null}
223
+ initialRenderState={initialRenderState}
224
+ onRenderStateChange={onRenderStateChange}
211
225
  />
212
226
  </div>
213
227
  </div>
@@ -2,7 +2,12 @@ import type { ReactNode } from "react";
2
2
  import type { ValidationResult } from "@farcaster/snap";
3
3
  import { SPEC_VERSION_2 } from "@farcaster/snap";
4
4
  import type { SnapNativeColors } from "./theme";
5
- import type { JsonValue, SnapPage, SnapActionHandlers } from "./types";
5
+ import type {
6
+ JsonValue,
7
+ SnapActionHandlers,
8
+ SnapPage,
9
+ SnapRenderState,
10
+ } from "./types";
6
11
  import { useSnapTheme } from "./theme";
7
12
  import { hexToRgba } from "./use-snap-palette";
8
13
  import { SnapCardV1 } from "./v1/snap-view";
@@ -10,7 +15,12 @@ import { SnapCardV2 } from "./v2/snap-view";
10
15
 
11
16
  // ─── Public types ──────────────────────────────────────
12
17
 
13
- export type { JsonValue, SnapPage, SnapActionHandlers } from "./types";
18
+ export type {
19
+ JsonValue,
20
+ SnapActionHandlers,
21
+ SnapPage,
22
+ SnapRenderState,
23
+ } from "./types";
14
24
 
15
25
  // ─── Re-exports ───────────────────────────────────────
16
26
 
@@ -35,6 +45,8 @@ export function SnapCard({
35
45
  forceExpanded,
36
46
  expandButtonLabel,
37
47
  onExpandPress,
48
+ initialRenderState,
49
+ onRenderStateChange,
38
50
  }: {
39
51
  snap: SnapPage;
40
52
  handlers: SnapActionHandlers;
@@ -61,6 +73,10 @@ export function SnapCard({
61
73
  expandButtonLabel?: string;
62
74
  /** Called from the collapsed expand button instead of toggling internal state. */
63
75
  onExpandPress?: () => void;
76
+ /** JSON-render local state used to seed this presenter mount. */
77
+ initialRenderState?: SnapRenderState;
78
+ /** Called with the full JSON-render local state after state changes. */
79
+ onRenderStateChange?: (state: SnapRenderState) => void;
64
80
  }) {
65
81
  if (snap.version === SPEC_VERSION_2) {
66
82
  return (
@@ -80,6 +96,8 @@ export function SnapCard({
80
96
  forceExpanded={forceExpanded}
81
97
  expandButtonLabel={expandButtonLabel}
82
98
  onExpandPress={onExpandPress}
99
+ initialRenderState={initialRenderState}
100
+ onRenderStateChange={onRenderStateChange}
83
101
  />
84
102
  );
85
103
  }
@@ -98,6 +116,8 @@ export function SnapCard({
98
116
  forceExpanded={forceExpanded}
99
117
  expandButtonLabel={expandButtonLabel}
100
118
  onExpandPress={onExpandPress}
119
+ initialRenderState={initialRenderState}
120
+ onRenderStateChange={onRenderStateChange}
101
121
  />
102
122
  );
103
123
  }