@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.
@@ -20,47 +20,24 @@ import {
20
20
  PALETTE_DARK_HEX,
21
21
  type PaletteColor,
22
22
  } from "@farcaster/snap";
23
+ import {
24
+ applyStatePaths,
25
+ buildInitialRenderState,
26
+ cloneSnapRenderState,
27
+ getUnpresentedSnapEffects,
28
+ markSnapEffectsPresented,
29
+ type SnapRenderState,
30
+ } from "../render-state";
23
31
  import type { SnapPage, SnapActionHandlers, JsonValue } from "./types";
24
32
 
25
- // ─── Shared helpers ──────────────────────────────────
33
+ function asRecord(value: unknown): Record<string, unknown> {
34
+ return value && typeof value === "object"
35
+ ? (value as Record<string, unknown>)
36
+ : {};
37
+ }
26
38
 
27
- export function applyStatePaths(
28
- model: Record<string, unknown>,
29
- changes:
30
- | { path: string; value: unknown }[]
31
- | Record<string, unknown>
32
- | null
33
- | undefined,
34
- ): void {
35
- if (!changes) return;
36
- const entries = Array.isArray(changes)
37
- ? changes.map((c) => [c.path, c.value] as const)
38
- : Object.entries(changes);
39
- for (const [path, value] of entries) {
40
- const trimmed = path.startsWith("/") ? path : `/${path}`;
41
- const parts = trimmed.split("/").filter(Boolean);
42
- if (parts.length < 2) continue;
43
- const [top, ...rest] = parts;
44
- if (top === "inputs") {
45
- if (typeof model.inputs !== "object" || model.inputs === null) {
46
- model.inputs = {};
47
- }
48
- const inputs = model.inputs as Record<string, unknown>;
49
- if (rest.length === 1) {
50
- inputs[rest[0]!] = value;
51
- }
52
- continue;
53
- }
54
- if (top === "theme") {
55
- if (typeof model.theme !== "object" || model.theme === null) {
56
- model.theme = {};
57
- }
58
- const theme = model.theme as Record<string, unknown>;
59
- if (rest.length === 1) {
60
- theme[rest[0]!] = value;
61
- }
62
- }
63
- }
39
+ function optionalString(value: unknown): string | undefined {
40
+ return value ? String(value) : undefined;
64
41
  }
65
42
 
66
43
  function withDefaultElementProps(spec: Spec): Spec {
@@ -106,6 +83,8 @@ export function SnapViewCoreInner({
106
83
  handlers,
107
84
  loading = false,
108
85
  loadingOverlay,
86
+ initialRenderState,
87
+ onRenderStateChange,
109
88
  }: {
110
89
  snap: SnapPage;
111
90
  handlers: SnapActionHandlers;
@@ -115,6 +94,8 @@ export function SnapViewCoreInner({
115
94
  * the built-in ActivityIndicator overlay is used. Pass `null` to render nothing.
116
95
  */
117
96
  loadingOverlay?: ReactNode;
97
+ initialRenderState?: SnapRenderState;
98
+ onRenderStateChange?: (state: SnapRenderState) => void;
118
99
  }) {
119
100
  const { mode } = useSnapTheme();
120
101
  const spec = useMemo(
@@ -124,28 +105,19 @@ export function SnapViewCoreInner({
124
105
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
125
106
 
126
107
  const initialState = useMemo(
127
- () => ({
128
- ...(spec.state ?? {}),
129
- inputs: { ...((spec.state?.inputs ?? {}) as Record<string, unknown>) },
130
- theme: {
131
- ...((spec.state?.theme ?? {}) as Record<string, unknown>),
132
- ...(snap.theme ? { accent: snap.theme.accent } : {}),
133
- },
134
- }),
135
- [spec, snap.theme],
108
+ () =>
109
+ buildInitialRenderState({
110
+ specState: spec.state,
111
+ initialRenderState,
112
+ themeAccent: snap.theme?.accent,
113
+ }),
114
+ [initialRenderState, spec.state, snap.theme?.accent],
136
115
  );
137
116
 
138
117
  const stateRef = useRef<Record<string, unknown>>(initialState);
139
118
 
140
119
  useEffect(() => {
141
- stateRef.current = {
142
- inputs: {
143
- ...((initialState.inputs ?? {}) as Record<string, unknown>),
144
- },
145
- theme: {
146
- ...((initialState.theme ?? {}) as Record<string, unknown>),
147
- },
148
- };
120
+ stateRef.current = cloneSnapRenderState(initialState);
149
121
  }, [initialState]);
150
122
 
151
123
  useEffect(() => {
@@ -161,14 +133,58 @@ export function SnapViewCoreInner({
161
133
  setPageKey((k) => k + 1);
162
134
  }, [spec]);
163
135
 
164
- const showConfetti = snap.effects?.includes("confetti") ?? false;
165
- const showFireworks = snap.effects?.includes("fireworks") ?? false;
166
- const [confettiKey, setConfettiKey] = useState(0);
167
- const [fireworksKey, setFireworksKey] = useState(0);
136
+ const effectSignature = snap.effects?.join("\u0000") ?? "";
137
+ const snapEffects = useMemo(
138
+ () => (effectSignature ? effectSignature.split("\u0000") : []),
139
+ [effectSignature],
140
+ );
141
+ const showConfetti = snapEffects.includes("confetti");
142
+ const showFireworks = snapEffects.includes("fireworks");
143
+ const [effectRunKeys, setEffectRunKeys] = useState({
144
+ confetti: 0,
145
+ fireworks: 0,
146
+ });
147
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
168
148
  useEffect(() => {
169
- if (showConfetti) setConfettiKey((k) => k + 1);
170
- if (showFireworks) setFireworksKey((k) => k + 1);
171
- }, [showConfetti, showFireworks, snap]);
149
+ onRenderStateChangeRef.current = onRenderStateChange;
150
+ }, [onRenderStateChange]);
151
+ useEffect(() => {
152
+ const effectsToPresent = getUnpresentedSnapEffects(
153
+ stateRef.current,
154
+ snapEffects,
155
+ );
156
+
157
+ if (effectsToPresent.length === 0) {
158
+ setEffectRunKeys((current) => {
159
+ const next = {
160
+ confetti: showConfetti ? current.confetti : 0,
161
+ fireworks: showFireworks ? current.fireworks : 0,
162
+ };
163
+ return next.confetti === current.confetti &&
164
+ next.fireworks === current.fireworks
165
+ ? current
166
+ : next;
167
+ });
168
+ return;
169
+ }
170
+
171
+ if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
172
+ onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
173
+ }
174
+
175
+ setEffectRunKeys((current) => ({
176
+ confetti: effectsToPresent.includes("confetti")
177
+ ? current.confetti + 1
178
+ : showConfetti
179
+ ? current.confetti
180
+ : 0,
181
+ fireworks: effectsToPresent.includes("fireworks")
182
+ ? current.fireworks + 1
183
+ : showFireworks
184
+ ? current.fireworks
185
+ : 0,
186
+ }));
187
+ }, [initialState, showConfetti, showFireworks, snapEffects]);
172
188
 
173
189
  const handlersRef = useRef(handlers);
174
190
  handlersRef.current = handlers;
@@ -222,6 +238,39 @@ export function SnapViewCoreInner({
222
238
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
223
239
  });
224
240
  break;
241
+ case "send_transaction":
242
+ h.send_transaction?.({
243
+ chainId: String(p.chainId ?? ""),
244
+ to: String(p.to ?? ""),
245
+ data: optionalString(p.data),
246
+ value: optionalString(p.value),
247
+ gas: optionalString(p.gas),
248
+ gasPrice: optionalString(p.gasPrice),
249
+ maxFeePerGas: optionalString(p.maxFeePerGas),
250
+ maxPriorityFeePerGas: optionalString(p.maxPriorityFeePerGas),
251
+ });
252
+ break;
253
+ case "send_calls":
254
+ h.send_calls?.({
255
+ version: p.version === "1.0" ? "1.0" : undefined,
256
+ chainId: String(p.chainId ?? ""),
257
+ atomicRequired:
258
+ typeof p.atomicRequired === "boolean"
259
+ ? p.atomicRequired
260
+ : undefined,
261
+ id: optionalString(p.id),
262
+ calls: Array.isArray(p.calls)
263
+ ? p.calls.map((call) => {
264
+ const c = asRecord(call);
265
+ return {
266
+ to: optionalString(c.to),
267
+ data: optionalString(c.data),
268
+ value: optionalString(c.value),
269
+ };
270
+ })
271
+ : [],
272
+ });
273
+ break;
225
274
  default:
226
275
  break;
227
276
  }
@@ -229,16 +278,13 @@ export function SnapViewCoreInner({
229
278
 
230
279
  return (
231
280
  <View style={styles.container}>
232
- {loading
233
- ? loadingOverlay === undefined
234
- ? (
235
- <SnapLoadingOverlay
236
- appearance={mode}
237
- accentHex={accentHex}
238
- />
239
- )
240
- : loadingOverlay
241
- : null}
281
+ {loading ? (
282
+ loadingOverlay === undefined ? (
283
+ <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
284
+ ) : (
285
+ loadingOverlay
286
+ )
287
+ ) : null}
242
288
  <SnapVersionProvider value={snap.version === "2.0" ? "2.0" : "1.0"}>
243
289
  <SnapCatalogView
244
290
  key={pageKey}
@@ -247,12 +293,17 @@ export function SnapViewCoreInner({
247
293
  loading={false}
248
294
  onStateChange={(changes) => {
249
295
  applyStatePaths(stateRef.current, changes);
296
+ onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
250
297
  }}
251
298
  onAction={handleAction}
252
299
  />
253
300
  </SnapVersionProvider>
254
- {showConfetti && <ConfettiOverlay key={confettiKey} />}
255
- {showFireworks && <FireworksOverlay key={fireworksKey} />}
301
+ {showConfetti && effectRunKeys.confetti > 0 && (
302
+ <ConfettiOverlay key={effectRunKeys.confetti} />
303
+ )}
304
+ {showFireworks && effectRunKeys.fireworks > 0 && (
305
+ <FireworksOverlay key={effectRunKeys.fireworks} />
306
+ )}
256
307
  </View>
257
308
  );
258
309
  }
@@ -270,9 +321,7 @@ export function SnapLoadingOverlay({
270
321
  styles.overlay,
271
322
  {
272
323
  backgroundColor:
273
- appearance === "dark"
274
- ? "rgba(0,0,0,0.1)"
275
- : "rgba(255,255,255,0.2)",
324
+ appearance === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
276
325
  },
277
326
  ]}
278
327
  >
@@ -1,4 +1,7 @@
1
1
  import type { Spec } from "@json-render/core";
2
+ import type { SnapRenderState } from "../render-state";
3
+
4
+ export type { SnapRenderState };
2
5
 
3
6
  export type JsonValue =
4
7
  | string
@@ -15,6 +18,29 @@ export type SnapPage = {
15
18
  ui: Spec;
16
19
  };
17
20
 
21
+ export type SnapSendTransactionParams = {
22
+ chainId: string;
23
+ to: string;
24
+ data?: string;
25
+ value?: string;
26
+ gas?: string;
27
+ gasPrice?: string;
28
+ maxFeePerGas?: string;
29
+ maxPriorityFeePerGas?: string;
30
+ };
31
+
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
+
18
44
  export type SnapActionHandlers = {
19
45
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
20
46
  open_url: (target: string) => void;
@@ -35,4 +61,6 @@ export type SnapActionHandlers = {
35
61
  recipientAddress?: string;
36
62
  }) => void;
37
63
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
64
+ send_transaction?: (params: SnapSendTransactionParams) => void;
65
+ send_calls?: (params: SnapSendCallsParams) => void;
38
66
  };
@@ -6,7 +6,7 @@ import {
6
6
  SnapViewCoreInner,
7
7
  resolveAccentHex,
8
8
  } from "../snap-view-core";
9
- import type { SnapPage, SnapActionHandlers } from "../types";
9
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../types";
10
10
  import { getSnapExpansionState } from "../expand-state";
11
11
 
12
12
  // ─── SnapViewV1 (no validation) ──────────────────────
@@ -16,11 +16,15 @@ export function SnapViewV1Inner({
16
16
  handlers,
17
17
  loading = false,
18
18
  loadingOverlay,
19
+ initialRenderState,
20
+ onRenderStateChange,
19
21
  }: {
20
22
  snap: SnapPage;
21
23
  handlers: SnapActionHandlers;
22
24
  loading?: boolean;
23
25
  loadingOverlay?: ReactNode;
26
+ initialRenderState?: SnapRenderState;
27
+ onRenderStateChange?: (state: SnapRenderState) => void;
24
28
  }) {
25
29
  return (
26
30
  <SnapViewCoreInner
@@ -28,6 +32,8 @@ export function SnapViewV1Inner({
28
32
  handlers={handlers}
29
33
  loading={loading}
30
34
  loadingOverlay={loadingOverlay}
35
+ initialRenderState={initialRenderState}
36
+ onRenderStateChange={onRenderStateChange}
31
37
  />
32
38
  );
33
39
  }
@@ -39,6 +45,8 @@ export function SnapViewV1({
39
45
  appearance = "dark",
40
46
  colors,
41
47
  loadingOverlay,
48
+ initialRenderState,
49
+ onRenderStateChange,
42
50
  }: {
43
51
  snap: SnapPage;
44
52
  handlers: SnapActionHandlers;
@@ -47,6 +55,8 @@ export function SnapViewV1({
47
55
  colors?: Partial<SnapNativeColors>;
48
56
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
49
57
  loadingOverlay?: ReactNode;
58
+ initialRenderState?: SnapRenderState;
59
+ onRenderStateChange?: (state: SnapRenderState) => void;
50
60
  }) {
51
61
  return (
52
62
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -55,6 +65,8 @@ export function SnapViewV1({
55
65
  handlers={handlers}
56
66
  loading={loading}
57
67
  loadingOverlay={loadingOverlay}
68
+ initialRenderState={initialRenderState}
69
+ onRenderStateChange={onRenderStateChange}
58
70
  />
59
71
  </SnapThemeProvider>
60
72
  );
@@ -74,6 +86,8 @@ function SnapCardV1Inner({
74
86
  forceExpanded,
75
87
  expandButtonLabel,
76
88
  onExpandPress,
89
+ initialRenderState,
90
+ onRenderStateChange,
77
91
  }: {
78
92
  snap: SnapPage;
79
93
  handlers: SnapActionHandlers;
@@ -86,6 +100,8 @@ function SnapCardV1Inner({
86
100
  forceExpanded?: boolean;
87
101
  expandButtonLabel?: string;
88
102
  onExpandPress?: () => void;
103
+ initialRenderState?: SnapRenderState;
104
+ onRenderStateChange?: (state: SnapRenderState) => void;
89
105
  }) {
90
106
  const { colors, mode } = useSnapTheme();
91
107
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
@@ -148,6 +164,8 @@ function SnapCardV1Inner({
148
164
  handlers={handlers}
149
165
  loading={loading}
150
166
  loadingOverlay={null}
167
+ initialRenderState={initialRenderState}
168
+ onRenderStateChange={onRenderStateChange}
151
169
  />
152
170
  </View>
153
171
  </View>
@@ -223,6 +241,8 @@ export function SnapCardV1({
223
241
  forceExpanded,
224
242
  expandButtonLabel,
225
243
  onExpandPress,
244
+ initialRenderState,
245
+ onRenderStateChange,
226
246
  }: {
227
247
  snap: SnapPage;
228
248
  handlers: SnapActionHandlers;
@@ -240,6 +260,10 @@ export function SnapCardV1({
240
260
  expandButtonLabel?: string;
241
261
  /** Called from the collapsed expand button instead of toggling internal state. */
242
262
  onExpandPress?: () => void;
263
+ /** JSON-render local state used to seed this presenter mount. */
264
+ initialRenderState?: SnapRenderState;
265
+ /** Called with the full JSON-render local state after state changes. */
266
+ onRenderStateChange?: (state: SnapRenderState) => void;
243
267
  }) {
244
268
  return (
245
269
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -255,6 +279,8 @@ export function SnapCardV1({
255
279
  forceExpanded={forceExpanded}
256
280
  expandButtonLabel={expandButtonLabel}
257
281
  onExpandPress={onExpandPress}
282
+ initialRenderState={initialRenderState}
283
+ onRenderStateChange={onRenderStateChange}
258
284
  />
259
285
  </SnapThemeProvider>
260
286
  );
@@ -11,7 +11,7 @@ import {
11
11
  validateSnapResponse,
12
12
  type ValidationResult,
13
13
  } from "@farcaster/snap";
14
- import type { SnapPage, SnapActionHandlers } from "../types";
14
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../types";
15
15
  import { getSnapExpansionState, SNAP_MAX_HEIGHT } from "../expand-state";
16
16
 
17
17
  // ─── Constants ───────────────────────────────────────
@@ -53,6 +53,8 @@ export function SnapViewV2Inner({
53
53
  onValidationError,
54
54
  validationErrorFallback,
55
55
  loadingOverlay,
56
+ initialRenderState,
57
+ onRenderStateChange,
56
58
  }: {
57
59
  snap: SnapPage;
58
60
  handlers: SnapActionHandlers;
@@ -60,6 +62,8 @@ export function SnapViewV2Inner({
60
62
  onValidationError?: (result: ValidationResult) => void;
61
63
  validationErrorFallback?: ReactNode;
62
64
  loadingOverlay?: ReactNode;
65
+ initialRenderState?: SnapRenderState;
66
+ onRenderStateChange?: (state: SnapRenderState) => void;
63
67
  }) {
64
68
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
65
69
  const valid = validation.valid;
@@ -89,6 +93,8 @@ export function SnapViewV2Inner({
89
93
  handlers={handlers}
90
94
  loading={loading}
91
95
  loadingOverlay={loadingOverlay}
96
+ initialRenderState={initialRenderState}
97
+ onRenderStateChange={onRenderStateChange}
92
98
  />
93
99
  );
94
100
  }
@@ -102,6 +108,8 @@ export function SnapViewV2({
102
108
  onValidationError,
103
109
  validationErrorFallback,
104
110
  loadingOverlay,
111
+ initialRenderState,
112
+ onRenderStateChange,
105
113
  }: {
106
114
  snap: SnapPage;
107
115
  handlers: SnapActionHandlers;
@@ -112,6 +120,8 @@ export function SnapViewV2({
112
120
  validationErrorFallback?: ReactNode;
113
121
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
114
122
  loadingOverlay?: ReactNode;
123
+ initialRenderState?: SnapRenderState;
124
+ onRenderStateChange?: (state: SnapRenderState) => void;
115
125
  }) {
116
126
  return (
117
127
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -122,6 +132,8 @@ export function SnapViewV2({
122
132
  onValidationError={onValidationError}
123
133
  validationErrorFallback={validationErrorFallback}
124
134
  loadingOverlay={loadingOverlay}
135
+ initialRenderState={initialRenderState}
136
+ onRenderStateChange={onRenderStateChange}
125
137
  />
126
138
  </SnapThemeProvider>
127
139
  );
@@ -144,6 +156,8 @@ function SnapCardV2Inner({
144
156
  forceExpanded,
145
157
  expandButtonLabel,
146
158
  onExpandPress,
159
+ initialRenderState,
160
+ onRenderStateChange,
147
161
  }: {
148
162
  snap: SnapPage;
149
163
  handlers: SnapActionHandlers;
@@ -159,6 +173,8 @@ function SnapCardV2Inner({
159
173
  forceExpanded?: boolean;
160
174
  expandButtonLabel?: string;
161
175
  onExpandPress?: () => void;
176
+ initialRenderState?: SnapRenderState;
177
+ onRenderStateChange?: (state: SnapRenderState) => void;
162
178
  }) {
163
179
  const { colors, mode } = useSnapTheme();
164
180
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
@@ -188,6 +204,8 @@ function SnapCardV2Inner({
188
204
  onValidationError={onValidationError}
189
205
  validationErrorFallback={validationErrorFallback}
190
206
  loadingOverlay={null}
207
+ initialRenderState={initialRenderState}
208
+ onRenderStateChange={onRenderStateChange}
191
209
  />
192
210
  );
193
211
 
@@ -375,6 +393,8 @@ export function SnapCardV2({
375
393
  forceExpanded,
376
394
  expandButtonLabel,
377
395
  onExpandPress,
396
+ initialRenderState,
397
+ onRenderStateChange,
378
398
  }: {
379
399
  snap: SnapPage;
380
400
  handlers: SnapActionHandlers;
@@ -395,6 +415,10 @@ export function SnapCardV2({
395
415
  expandButtonLabel?: string;
396
416
  /** Called from the collapsed expand button instead of toggling internal state. */
397
417
  onExpandPress?: () => void;
418
+ /** JSON-render local state used to seed this presenter mount. */
419
+ initialRenderState?: SnapRenderState;
420
+ /** Called with the full JSON-render local state after state changes. */
421
+ onRenderStateChange?: (state: SnapRenderState) => void;
398
422
  }) {
399
423
  return (
400
424
  <SnapThemeProvider appearance={appearance} colors={colors}>
@@ -413,6 +437,8 @@ export function SnapCardV2({
413
437
  forceExpanded={forceExpanded}
414
438
  expandButtonLabel={expandButtonLabel}
415
439
  onExpandPress={onExpandPress}
440
+ initialRenderState={initialRenderState}
441
+ onRenderStateChange={onRenderStateChange}
416
442
  />
417
443
  </SnapThemeProvider>
418
444
  );