@farcaster/snap 2.6.3 → 2.7.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.d.ts CHANGED
@@ -3,3 +3,4 @@ export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS,
3
3
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
4
  export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, type SnapAction, type SnapGetAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
6
+ export type { SnapRenderState } from "./render-state.js";
@@ -1,6 +1,7 @@
1
1
  import type { Spec } from "@json-render/core";
2
2
  import type { ReactNode } from "react";
3
3
  import type { ValidationResult } from "../validator.js";
4
+ import type { SnapRenderState } from "../render-state.js";
4
5
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
5
6
  [key: string]: JsonValue;
6
7
  };
@@ -42,7 +43,8 @@ export type SnapActionHandlers = {
42
43
  buyToken?: string;
43
44
  }) => void;
44
45
  };
45
- export declare function SnapCard({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, }: {
46
+ export type { SnapRenderState };
47
+ export declare function SnapCard({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, initialRenderState, onRenderStateChange, }: {
46
48
  snap: SnapPage;
47
49
  handlers: SnapActionHandlers;
48
50
  loading?: boolean;
@@ -58,4 +60,8 @@ export declare function SnapCard({ snap, handlers, loading, appearance, maxWidth
58
60
  plain?: boolean;
59
61
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
60
62
  loadingOverlay?: ReactNode;
63
+ /** JSON-render local state used to seed this presenter mount. */
64
+ initialRenderState?: SnapRenderState;
65
+ /** Called with the full JSON-render local state after state changes. */
66
+ onRenderStateChange?: (state: SnapRenderState) => void;
61
67
  }): import("react/jsx-runtime").JSX.Element;
@@ -4,9 +4,9 @@ import { SPEC_VERSION_2 } from "../constants.js";
4
4
  import { SnapCardV1 } from "./v1/snap-view.js";
5
5
  import { SnapCardV2 } from "./v2/snap-view.js";
6
6
  // ─── SnapCard ────────────────────────────────────────
7
- export function SnapCard({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, }) {
7
+ export function SnapCard({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, initialRenderState, onRenderStateChange, }) {
8
8
  if (snap.version === SPEC_VERSION_2) {
9
- return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay }));
9
+ return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
10
10
  }
11
- return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay }));
11
+ return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, maxWidth: maxWidth, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
12
12
  }
@@ -1,15 +1,12 @@
1
+ import { type SnapRenderState } from "../render-state.js";
1
2
  import { type ReactNode } from "react";
2
3
  import type { SnapActionHandlers, SnapPage } from "./index.js";
3
- export declare function applyStatePaths(model: Record<string, unknown>, changes: {
4
- path: string;
5
- value: unknown;
6
- }[] | Record<string, unknown> | null | undefined): void;
7
4
  export declare function SnapLoadingOverlay({ appearance, accentHex, active, }: {
8
5
  appearance: "light" | "dark";
9
6
  accentHex: string;
10
7
  active: boolean;
11
8
  }): import("react/jsx-runtime").JSX.Element;
12
- export declare function SnapViewCore({ snap, handlers, loading, appearance, loadingOverlay, }: {
9
+ export declare function SnapViewCore({ snap, handlers, loading, appearance, loadingOverlay, initialRenderState, onRenderStateChange, }: {
13
10
  snap: SnapPage;
14
11
  handlers: SnapActionHandlers;
15
12
  loading?: boolean;
@@ -19,4 +16,6 @@ export declare function SnapViewCore({ snap, handlers, loading, appearance, load
19
16
  * the built-in spinner + backdrop is used. Pass `null` to render nothing.
20
17
  */
21
18
  loadingOverlay?: ReactNode;
19
+ initialRenderState?: SnapRenderState;
20
+ onRenderStateChange?: (state: SnapRenderState) => void;
22
21
  }): import("react/jsx-runtime").JSX.Element;
@@ -6,41 +6,8 @@ import { SnapPreviewAccentProvider } from "./accent-context.js";
6
6
  import { SnapVersionProvider } from "./snap-version-context.js";
7
7
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex.js";
8
8
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css.js";
9
+ import { applyStatePaths, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, markSnapEffectsPresented, } from "../render-state.js";
9
10
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
10
- // ─── Internal helpers ──────────────────────────────────
11
- export function applyStatePaths(model, changes) {
12
- if (!changes)
13
- return;
14
- const entries = Array.isArray(changes)
15
- ? changes.map((c) => [c.path, c.value])
16
- : Object.entries(changes);
17
- for (const [path, value] of entries) {
18
- const trimmed = path.startsWith("/") ? path : `/${path}`;
19
- const parts = trimmed.split("/").filter(Boolean);
20
- if (parts.length < 2)
21
- continue;
22
- const [top, ...rest] = parts;
23
- if (top === "inputs") {
24
- if (typeof model.inputs !== "object" || model.inputs === null) {
25
- model.inputs = {};
26
- }
27
- const inputs = model.inputs;
28
- if (rest.length === 1) {
29
- inputs[rest[0]] = value;
30
- }
31
- continue;
32
- }
33
- if (top === "theme") {
34
- if (typeof model.theme !== "object" || model.theme === null) {
35
- model.theme = {};
36
- }
37
- const theme = model.theme;
38
- if (rest.length === 1) {
39
- theme[rest[0]] = value;
40
- }
41
- }
42
- }
43
- }
44
11
  function withDefaultElementProps(spec) {
45
12
  if (!spec || typeof spec !== "object" || !("elements" in spec))
46
13
  return spec;
@@ -102,7 +69,7 @@ function ConfettiOverlay() {
102
69
  overflow: "hidden",
103
70
  pointerEvents: "none",
104
71
  zIndex: 20,
105
- }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (_jsx("div", { style: {
72
+ }, children: [pieces.map(({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid, }) => (_jsx("div", { style: {
106
73
  position: "absolute",
107
74
  left: `${left}%`,
108
75
  top: -20,
@@ -199,9 +166,7 @@ export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
199
166
  zIndex: 10,
200
167
  background: tint,
201
168
  backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
202
- WebkitBackdropFilter: active
203
- ? "blur(10px) saturate(1.05)"
204
- : "none",
169
+ WebkitBackdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
205
170
  opacity: active ? 1 : 0,
206
171
  pointerEvents: active ? "auto" : "none",
207
172
  transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
@@ -239,19 +204,16 @@ const PALETTE = [
239
204
  ];
240
205
  // ─── SnapViewCore ────────────────────────────────────
241
206
  // Shared rendering logic used by both v1 and v2.
242
- export function SnapViewCore({ snap, handlers, loading = false, appearance = "dark", loadingOverlay, }) {
207
+ export function SnapViewCore({ snap, handlers, loading = false, appearance = "dark", loadingOverlay, initialRenderState, onRenderStateChange, }) {
243
208
  const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
244
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
209
+ const initialState = useMemo(() => buildInitialRenderState({
210
+ specState: spec.state,
211
+ initialRenderState,
212
+ themeAccent: snap.theme?.accent,
213
+ }), [initialRenderState, spec.state, snap.theme?.accent]);
245
214
  const stateRef = useRef(initialState);
246
215
  useEffect(() => {
247
- stateRef.current = {
248
- inputs: {
249
- ...(initialState.inputs ?? {}),
250
- },
251
- theme: {
252
- ...(initialState.theme ?? {}),
253
- },
254
- };
216
+ stateRef.current = cloneSnapRenderState(initialState);
255
217
  }, [initialState]);
256
218
  useEffect(() => {
257
219
  const catalogResult = snapJsonRenderCatalog.validate(spec);
@@ -264,16 +226,49 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
264
226
  useEffect(() => {
265
227
  setPageKey((k) => k + 1);
266
228
  }, [spec]);
267
- const showConfetti = snap.effects?.includes("confetti") ?? false;
268
- const showFireworks = snap.effects?.includes("fireworks") ?? false;
269
- const [confettiKey, setConfettiKey] = useState(0);
270
- const [fireworksKey, setFireworksKey] = useState(0);
229
+ const effectSignature = snap.effects?.join("\u0000") ?? "";
230
+ const snapEffects = useMemo(() => (effectSignature ? effectSignature.split("\u0000") : []), [effectSignature]);
231
+ const showConfetti = snapEffects.includes("confetti");
232
+ const showFireworks = snapEffects.includes("fireworks");
233
+ const [effectRunKeys, setEffectRunKeys] = useState({
234
+ confetti: 0,
235
+ fireworks: 0,
236
+ });
237
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
238
+ useEffect(() => {
239
+ onRenderStateChangeRef.current = onRenderStateChange;
240
+ }, [onRenderStateChange]);
271
241
  useEffect(() => {
272
- if (showConfetti)
273
- setConfettiKey((k) => k + 1);
274
- if (showFireworks)
275
- setFireworksKey((k) => k + 1);
276
- }, [showConfetti, showFireworks, snap]);
242
+ const effectsToPresent = getUnpresentedSnapEffects(stateRef.current, snapEffects);
243
+ if (effectsToPresent.length === 0) {
244
+ setEffectRunKeys((current) => {
245
+ const next = {
246
+ confetti: showConfetti ? current.confetti : 0,
247
+ fireworks: showFireworks ? current.fireworks : 0,
248
+ };
249
+ return next.confetti === current.confetti &&
250
+ next.fireworks === current.fireworks
251
+ ? current
252
+ : next;
253
+ });
254
+ return;
255
+ }
256
+ if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
257
+ onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
258
+ }
259
+ setEffectRunKeys((current) => ({
260
+ confetti: effectsToPresent.includes("confetti")
261
+ ? current.confetti + 1
262
+ : showConfetti
263
+ ? current.confetti
264
+ : 0,
265
+ fireworks: effectsToPresent.includes("fireworks")
266
+ ? current.fireworks + 1
267
+ : showFireworks
268
+ ? current.fireworks
269
+ : 0,
270
+ }));
271
+ }, [initialState, showConfetti, showFireworks, snapEffects]);
277
272
  const accentName = snap.theme?.accent ?? "purple";
278
273
  const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
279
274
  const previewSurfaceStyle = useMemo(() => {
@@ -339,7 +334,8 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
339
334
  break;
340
335
  }
341
336
  }, [handlers]);
342
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
337
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks)), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
343
338
  applyStatePaths(stateRef.current, changes);
339
+ onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
344
340
  }, onAction: handleAction }, pageKey) }) }) })] }));
345
341
  }
@@ -1,14 +1,16 @@
1
1
  import { type ReactNode } from "react";
2
- import type { SnapPage, SnapActionHandlers } from "../index.js";
3
- export declare function SnapViewV1({ snap, handlers, loading, appearance, loadingOverlay, }: {
2
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../index.js";
3
+ export declare function SnapViewV1({ snap, handlers, loading, appearance, loadingOverlay, initialRenderState, onRenderStateChange, }: {
4
4
  snap: SnapPage;
5
5
  handlers: SnapActionHandlers;
6
6
  loading?: boolean;
7
7
  appearance?: "light" | "dark";
8
8
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
9
9
  loadingOverlay?: ReactNode;
10
+ initialRenderState?: SnapRenderState;
11
+ onRenderStateChange?: (state: SnapRenderState) => void;
10
12
  }): import("react/jsx-runtime").JSX.Element;
11
- export declare function SnapCardV1({ snap, handlers, loading, appearance, maxWidth, actionError, plain, loadingOverlay, }: {
13
+ export declare function SnapCardV1({ snap, handlers, loading, appearance, maxWidth, actionError, plain, loadingOverlay, initialRenderState, onRenderStateChange, }: {
12
14
  snap: SnapPage;
13
15
  handlers: SnapActionHandlers;
14
16
  loading?: boolean;
@@ -18,4 +20,8 @@ export declare function SnapCardV1({ snap, handlers, loading, appearance, maxWid
18
20
  plain?: boolean;
19
21
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
20
22
  loadingOverlay?: ReactNode;
23
+ /** JSON-render local state used to seed this presenter mount. */
24
+ initialRenderState?: SnapRenderState;
25
+ /** Called with the full JSON-render local state after state changes. */
26
+ onRenderStateChange?: (state: SnapRenderState) => void;
21
27
  }): import("react/jsx-runtime").JSX.Element;
@@ -4,10 +4,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core.js";
5
5
  import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
6
6
  const SNAP_MAX_HEIGHT = 500;
7
- export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", loadingOverlay, }) {
8
- return (_jsx(SnapViewCore, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: loadingOverlay }));
7
+ export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", loadingOverlay, initialRenderState, onRenderStateChange, }) {
8
+ return (_jsx(SnapViewCore, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
9
9
  }
10
- export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, actionError, plain = false, loadingOverlay, }) {
10
+ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, actionError, plain = false, loadingOverlay, initialRenderState, onRenderStateChange, }) {
11
11
  const isDark = appearance === "dark";
12
12
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
13
13
  const surfaceBg = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.02)";
@@ -62,7 +62,7 @@ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark
62
62
  maxHeight: SNAP_MAX_HEIGHT,
63
63
  overflow: "hidden",
64
64
  }
65
- : undefined, children: _jsx("div", { ref: contentRef, style: plain ? undefined : { padding: 16 }, children: _jsx(SnapViewV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: null }) }) }), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null] }), isExpandable ? (_jsx("button", { type: "button", "aria-expanded": isExpanded, onClick: () => setIsExpanded((value) => !value), style: {
65
+ : undefined, children: _jsx("div", { ref: contentRef, style: plain ? undefined : { padding: 16 }, children: _jsx(SnapViewV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: null, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }) }), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null] }), isExpandable ? (_jsx("button", { type: "button", "aria-expanded": isExpanded, onClick: () => setIsExpanded((value) => !value), style: {
66
66
  position: "absolute",
67
67
  bottom: 0,
68
68
  left: "50%",
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { ValidationResult } from "../../validator.js";
3
- import type { SnapPage, SnapActionHandlers } from "../index.js";
4
- export declare function SnapViewV2({ snap, handlers, loading, appearance, onValidationError, validationErrorFallback, loadingOverlay, }: {
3
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../index.js";
4
+ export declare function SnapViewV2({ snap, handlers, loading, appearance, onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }: {
5
5
  snap: SnapPage;
6
6
  handlers: SnapActionHandlers;
7
7
  loading?: boolean;
@@ -10,8 +10,10 @@ export declare function SnapViewV2({ snap, handlers, loading, appearance, onVali
10
10
  validationErrorFallback?: ReactNode;
11
11
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
12
12
  loadingOverlay?: ReactNode;
13
+ initialRenderState?: SnapRenderState;
14
+ onRenderStateChange?: (state: SnapRenderState) => void;
13
15
  }): import("react/jsx-runtime").JSX.Element | null;
14
- export declare function SnapCardV2({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, }: {
16
+ export declare function SnapCardV2({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, initialRenderState, onRenderStateChange, }: {
15
17
  snap: SnapPage;
16
18
  handlers: SnapActionHandlers;
17
19
  loading?: boolean;
@@ -24,4 +26,8 @@ export declare function SnapCardV2({ snap, handlers, loading, appearance, maxWid
24
26
  plain?: boolean;
25
27
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
26
28
  loadingOverlay?: ReactNode;
29
+ /** JSON-render local state used to seed this presenter mount. */
30
+ initialRenderState?: SnapRenderState;
31
+ /** Called with the full JSON-render local state after state changes. */
32
+ onRenderStateChange?: (state: SnapRenderState) => void;
27
33
  }): import("react/jsx-runtime").JSX.Element;
@@ -21,7 +21,7 @@ function SnapValidationFallback({ appearance, message, }) {
21
21
  }, children: _jsx("span", { children: message ? `Unable to render snap: ${message}` : "Unable to render snap" }) }));
22
22
  }
23
23
  // ─── SnapViewV2 ──────────────────────────────────────
24
- export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", onValidationError, validationErrorFallback, loadingOverlay, }) {
24
+ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }) {
25
25
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
26
26
  const valid = validation.valid;
27
27
  const validationMessage = validation.issues[0]?.message;
@@ -41,10 +41,10 @@ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark
41
41
  return null;
42
42
  return _jsx(_Fragment, { children: validationErrorFallback ?? _jsx(SnapValidationFallback, { appearance: appearance, message: validationMessage }) });
43
43
  }
44
- return (_jsx(SnapViewCore, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: loadingOverlay }));
44
+ return (_jsx(SnapViewCore, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
45
45
  }
46
46
  // ─── SnapCardV2 ──────────────────────────────────────
47
- export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, }) {
47
+ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", maxWidth = 480, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, initialRenderState, onRenderStateChange, }) {
48
48
  const isDark = appearance === "dark";
49
49
  const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
50
50
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
@@ -101,7 +101,7 @@ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark
101
101
  }),
102
102
  }, children: [_jsx("div", { style: isClipped
103
103
  ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" }
104
- : undefined, children: _jsx("div", { ref: contentRef, style: plain ? undefined : { padding: 16 }, children: _jsx(SnapViewV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null }) }) }), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, showOverflowWarning && (_jsxs("div", { style: {
104
+ : undefined, children: _jsx("div", { ref: contentRef, style: plain ? undefined : { padding: 16 }, children: _jsx(SnapViewV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }) }), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, showOverflowWarning && (_jsxs("div", { style: {
105
105
  position: "absolute",
106
106
  top: SNAP_MAX_HEIGHT,
107
107
  left: 0,
@@ -1,13 +1,13 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ValidationResult } from "@farcaster/snap";
3
3
  import type { SnapNativeColors } from "./theme.js";
4
- import type { SnapPage, SnapActionHandlers } from "./types.js";
4
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "./types.js";
5
5
  import { useSnapTheme } from "./theme.js";
6
6
  import { hexToRgba } from "./use-snap-palette.js";
7
- export type { JsonValue, SnapPage, SnapActionHandlers } from "./types.js";
7
+ export type { JsonValue, SnapActionHandlers, SnapPage, SnapRenderState, } from "./types.js";
8
8
  export { useSnapTheme, hexToRgba };
9
9
  export type { SnapNativeColors };
10
- export declare function SnapCard({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }: {
10
+ export declare function SnapCard({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }: {
11
11
  snap: SnapPage;
12
12
  handlers: SnapActionHandlers;
13
13
  loading?: boolean;
@@ -33,4 +33,8 @@ export declare function SnapCard({ snap, handlers, loading, appearance, colors,
33
33
  expandButtonLabel?: string;
34
34
  /** Called from the collapsed expand button instead of toggling internal state. */
35
35
  onExpandPress?: () => void;
36
+ /** JSON-render local state used to seed this presenter mount. */
37
+ initialRenderState?: SnapRenderState;
38
+ /** Called with the full JSON-render local state after state changes. */
39
+ onRenderStateChange?: (state: SnapRenderState) => void;
36
40
  }): import("react").JSX.Element;
@@ -7,9 +7,9 @@ import { SnapCardV2 } from "./v2/snap-view.js";
7
7
  // ─── Re-exports ───────────────────────────────────────
8
8
  export { useSnapTheme, hexToRgba };
9
9
  // ─── SnapCard (version-switching) ─────────────────────
10
- export function SnapCard({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
10
+ export function SnapCard({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }) {
11
11
  if (snap.version === SPEC_VERSION_2) {
12
- return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress }));
12
+ return (_jsx(SnapCardV2, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
13
13
  }
14
- return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress }));
14
+ return (_jsx(SnapCardV1, { snap: snap, handlers: handlers, loading: loading, appearance: appearance, colors: colors, borderRadius: borderRadius, actionError: actionError, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
15
15
  }
@@ -1,11 +1,8 @@
1
1
  import { type ReactNode } from "react";
2
+ import { type SnapRenderState } from "../render-state.js";
2
3
  import type { SnapPage, SnapActionHandlers } from "./types.js";
3
- export declare function applyStatePaths(model: Record<string, unknown>, changes: {
4
- path: string;
5
- value: unknown;
6
- }[] | Record<string, unknown> | null | undefined): void;
7
4
  export declare function resolveAccentHex(accent: string | undefined, appearance: "light" | "dark"): string;
8
- export declare function SnapViewCoreInner({ snap, handlers, loading, loadingOverlay, }: {
5
+ export declare function SnapViewCoreInner({ snap, handlers, loading, loadingOverlay, initialRenderState, onRenderStateChange, }: {
9
6
  snap: SnapPage;
10
7
  handlers: SnapActionHandlers;
11
8
  loading?: boolean;
@@ -14,6 +11,8 @@ export declare function SnapViewCoreInner({ snap, handlers, loading, loadingOver
14
11
  * the built-in ActivityIndicator overlay is used. Pass `null` to render nothing.
15
12
  */
16
13
  loadingOverlay?: ReactNode;
14
+ initialRenderState?: SnapRenderState;
15
+ onRenderStateChange?: (state: SnapRenderState) => void;
17
16
  }): import("react").JSX.Element;
18
17
  export declare function SnapLoadingOverlay({ appearance, accentHex, }: {
19
18
  appearance: "light" | "dark";
@@ -8,40 +8,7 @@ import { SnapVersionProvider } from "./snap-version-context.js";
8
8
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
9
9
  import { ActivityIndicator, StyleSheet, View } from "react-native";
10
10
  import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "@farcaster/snap";
11
- // ─── Shared helpers ──────────────────────────────────
12
- export function applyStatePaths(model, changes) {
13
- if (!changes)
14
- return;
15
- const entries = Array.isArray(changes)
16
- ? changes.map((c) => [c.path, c.value])
17
- : Object.entries(changes);
18
- for (const [path, value] of entries) {
19
- const trimmed = path.startsWith("/") ? path : `/${path}`;
20
- const parts = trimmed.split("/").filter(Boolean);
21
- if (parts.length < 2)
22
- continue;
23
- const [top, ...rest] = parts;
24
- if (top === "inputs") {
25
- if (typeof model.inputs !== "object" || model.inputs === null) {
26
- model.inputs = {};
27
- }
28
- const inputs = model.inputs;
29
- if (rest.length === 1) {
30
- inputs[rest[0]] = value;
31
- }
32
- continue;
33
- }
34
- if (top === "theme") {
35
- if (typeof model.theme !== "object" || model.theme === null) {
36
- model.theme = {};
37
- }
38
- const theme = model.theme;
39
- if (rest.length === 1) {
40
- theme[rest[0]] = value;
41
- }
42
- }
43
- }
44
- }
11
+ import { applyStatePaths, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, markSnapEffectsPresented, } from "../render-state.js";
45
12
  function withDefaultElementProps(spec) {
46
13
  if (!spec || typeof spec !== "object" || !("elements" in spec))
47
14
  return spec;
@@ -70,28 +37,18 @@ export function resolveAccentHex(accent, appearance) {
70
37
  return map[name];
71
38
  }
72
39
  // ─── Core rendering component (no validation) ────────
73
- export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOverlay, }) {
40
+ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOverlay, initialRenderState, onRenderStateChange, }) {
74
41
  const { mode } = useSnapTheme();
75
42
  const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
76
43
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
77
- const initialState = useMemo(() => ({
78
- ...(spec.state ?? {}),
79
- inputs: { ...(spec.state?.inputs ?? {}) },
80
- theme: {
81
- ...(spec.state?.theme ?? {}),
82
- ...(snap.theme ? { accent: snap.theme.accent } : {}),
83
- },
84
- }), [spec, snap.theme]);
44
+ const initialState = useMemo(() => buildInitialRenderState({
45
+ specState: spec.state,
46
+ initialRenderState,
47
+ themeAccent: snap.theme?.accent,
48
+ }), [initialRenderState, spec.state, snap.theme?.accent]);
85
49
  const stateRef = useRef(initialState);
86
50
  useEffect(() => {
87
- stateRef.current = {
88
- inputs: {
89
- ...(initialState.inputs ?? {}),
90
- },
91
- theme: {
92
- ...(initialState.theme ?? {}),
93
- },
94
- };
51
+ stateRef.current = cloneSnapRenderState(initialState);
95
52
  }, [initialState]);
96
53
  useEffect(() => {
97
54
  const catalogResult = snapJsonRenderCatalog.validate(spec);
@@ -104,16 +61,49 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
104
61
  useEffect(() => {
105
62
  setPageKey((k) => k + 1);
106
63
  }, [spec]);
107
- const showConfetti = snap.effects?.includes("confetti") ?? false;
108
- const showFireworks = snap.effects?.includes("fireworks") ?? false;
109
- const [confettiKey, setConfettiKey] = useState(0);
110
- const [fireworksKey, setFireworksKey] = useState(0);
64
+ const effectSignature = snap.effects?.join("\u0000") ?? "";
65
+ const snapEffects = useMemo(() => (effectSignature ? effectSignature.split("\u0000") : []), [effectSignature]);
66
+ const showConfetti = snapEffects.includes("confetti");
67
+ const showFireworks = snapEffects.includes("fireworks");
68
+ const [effectRunKeys, setEffectRunKeys] = useState({
69
+ confetti: 0,
70
+ fireworks: 0,
71
+ });
72
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
73
+ useEffect(() => {
74
+ onRenderStateChangeRef.current = onRenderStateChange;
75
+ }, [onRenderStateChange]);
111
76
  useEffect(() => {
112
- if (showConfetti)
113
- setConfettiKey((k) => k + 1);
114
- if (showFireworks)
115
- setFireworksKey((k) => k + 1);
116
- }, [showConfetti, showFireworks, snap]);
77
+ const effectsToPresent = getUnpresentedSnapEffects(stateRef.current, snapEffects);
78
+ if (effectsToPresent.length === 0) {
79
+ setEffectRunKeys((current) => {
80
+ const next = {
81
+ confetti: showConfetti ? current.confetti : 0,
82
+ fireworks: showFireworks ? current.fireworks : 0,
83
+ };
84
+ return next.confetti === current.confetti &&
85
+ next.fireworks === current.fireworks
86
+ ? current
87
+ : next;
88
+ });
89
+ return;
90
+ }
91
+ if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
92
+ onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
93
+ }
94
+ setEffectRunKeys((current) => ({
95
+ confetti: effectsToPresent.includes("confetti")
96
+ ? current.confetti + 1
97
+ : showConfetti
98
+ ? current.confetti
99
+ : 0,
100
+ fireworks: effectsToPresent.includes("fireworks")
101
+ ? current.fireworks + 1
102
+ : showFireworks
103
+ ? current.fireworks
104
+ : 0,
105
+ }));
106
+ }, [initialState, showConfetti, showFireworks, snapEffects]);
117
107
  const handlersRef = useRef(handlers);
118
108
  handlersRef.current = handlers;
119
109
  const handleAction = useCallback((name, params) => {
@@ -169,21 +159,16 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
169
159
  break;
170
160
  }
171
161
  }, []);
172
- return (_jsxs(View, { style: styles.container, children: [loading
173
- ? loadingOverlay === undefined
174
- ? (_jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex }))
175
- : loadingOverlay
176
- : null, _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
162
+ return (_jsxs(View, { style: styles.container, children: [loading ? (loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })) : (loadingOverlay)) : null, _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
177
163
  applyStatePaths(stateRef.current, changes);
178
- }, onAction: handleAction }, pageKey) }), showConfetti && _jsx(ConfettiOverlay, {}, confettiKey), showFireworks && _jsx(FireworksOverlay, {}, fireworksKey)] }));
164
+ onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
165
+ }, onAction: handleAction }, pageKey) }), showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks))] }));
179
166
  }
180
167
  export function SnapLoadingOverlay({ appearance, accentHex, }) {
181
168
  return (_jsx(View, { style: [
182
169
  styles.overlay,
183
170
  {
184
- backgroundColor: appearance === "dark"
185
- ? "rgba(0,0,0,0.1)"
186
- : "rgba(255,255,255,0.2)",
171
+ backgroundColor: appearance === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
187
172
  },
188
173
  ], children: _jsx(ActivityIndicator, { size: "large", color: accentHex }) }));
189
174
  }
@@ -1,4 +1,6 @@
1
1
  import type { Spec } from "@json-render/core";
2
+ import type { SnapRenderState } from "../render-state.js";
3
+ export type { SnapRenderState };
2
4
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
5
  [key: string]: JsonValue;
4
6
  };
@@ -1,13 +1,15 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type SnapNativeColors } from "../theme.js";
3
- import type { SnapPage, SnapActionHandlers } from "../types.js";
4
- export declare function SnapViewV1Inner({ snap, handlers, loading, loadingOverlay, }: {
3
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../types.js";
4
+ export declare function SnapViewV1Inner({ snap, handlers, loading, loadingOverlay, initialRenderState, onRenderStateChange, }: {
5
5
  snap: SnapPage;
6
6
  handlers: SnapActionHandlers;
7
7
  loading?: boolean;
8
8
  loadingOverlay?: ReactNode;
9
+ initialRenderState?: SnapRenderState;
10
+ onRenderStateChange?: (state: SnapRenderState) => void;
9
11
  }): import("react").JSX.Element;
10
- export declare function SnapViewV1({ snap, handlers, loading, appearance, colors, loadingOverlay, }: {
12
+ export declare function SnapViewV1({ snap, handlers, loading, appearance, colors, loadingOverlay, initialRenderState, onRenderStateChange, }: {
11
13
  snap: SnapPage;
12
14
  handlers: SnapActionHandlers;
13
15
  loading?: boolean;
@@ -15,8 +17,10 @@ export declare function SnapViewV1({ snap, handlers, loading, appearance, colors
15
17
  colors?: Partial<SnapNativeColors>;
16
18
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
17
19
  loadingOverlay?: ReactNode;
20
+ initialRenderState?: SnapRenderState;
21
+ onRenderStateChange?: (state: SnapRenderState) => void;
18
22
  }): import("react").JSX.Element;
19
- export declare function SnapCardV1({ snap, handlers, loading, appearance, colors, borderRadius, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }: {
23
+ export declare function SnapCardV1({ snap, handlers, loading, appearance, colors, borderRadius, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }: {
20
24
  snap: SnapPage;
21
25
  handlers: SnapActionHandlers;
22
26
  loading?: boolean;
@@ -33,4 +37,8 @@ export declare function SnapCardV1({ snap, handlers, loading, appearance, colors
33
37
  expandButtonLabel?: string;
34
38
  /** Called from the collapsed expand button instead of toggling internal state. */
35
39
  onExpandPress?: () => void;
40
+ /** JSON-render local state used to seed this presenter mount. */
41
+ initialRenderState?: SnapRenderState;
42
+ /** Called with the full JSON-render local state after state changes. */
43
+ onRenderStateChange?: (state: SnapRenderState) => void;
36
44
  }): import("react").JSX.Element;