@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.
@@ -5,14 +5,14 @@ import { SnapThemeProvider, useSnapTheme } from "../theme.js";
5
5
  import { SnapLoadingOverlay, SnapViewCoreInner, resolveAccentHex, } from "../snap-view-core.js";
6
6
  import { getSnapExpansionState } from "../expand-state.js";
7
7
  // ─── SnapViewV1 (no validation) ──────────────────────
8
- export function SnapViewV1Inner({ snap, handlers, loading = false, loadingOverlay, }) {
9
- return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }));
8
+ export function SnapViewV1Inner({ snap, handlers, loading = false, loadingOverlay, initialRenderState, onRenderStateChange, }) {
9
+ return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
10
10
  }
11
- export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", colors, loadingOverlay, }) {
12
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }) }));
11
+ export function SnapViewV1({ snap, handlers, loading = false, appearance = "dark", colors, loadingOverlay, initialRenderState, onRenderStateChange, }) {
12
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }));
13
13
  }
14
14
  // ─── SnapCardV1 (card frame with expandable clipping) ──
15
- function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, actionError, appearance, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
15
+ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, actionError, appearance, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }) {
16
16
  const { colors, mode } = useSnapTheme();
17
17
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
18
18
  const [contentHeight, setContentHeight] = useState(0);
@@ -48,7 +48,7 @@ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, action
48
48
  : currentHeight === nextHeight
49
49
  ? currentHeight
50
50
  : nextHeight);
51
- }, style: plain ? undefined : cardStyles.body, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: null }) }) }), loading
51
+ }, style: plain ? undefined : cardStyles.body, children: _jsx(SnapViewV1Inner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: null, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }) }), loading
52
52
  ? loadingOverlay === undefined
53
53
  ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
54
54
  : loadingOverlay
@@ -76,13 +76,13 @@ function SnapCardV1Inner({ snap, handlers, loading = false, borderRadius, action
76
76
  },
77
77
  ], children: actionError }))] }));
78
78
  }
79
- export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
80
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV1Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress }) }));
79
+ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }) {
80
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV1Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }));
81
81
  }
82
82
  const cardStyles = StyleSheet.create({
83
83
  frameRing: { alignSelf: "stretch" },
84
84
  card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
85
- body: { paddingHorizontal: 16, paddingVertical: 16 },
85
+ body: { paddingHorizontal: 8, paddingVertical: 8 },
86
86
  expandFloat: {
87
87
  position: "absolute",
88
88
  left: 0,
@@ -1,16 +1,18 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { type SnapNativeColors } from "../theme.js";
3
3
  import { type ValidationResult } from "@farcaster/snap";
4
- import type { SnapPage, SnapActionHandlers } from "../types.js";
5
- export declare function SnapViewV2Inner({ snap, handlers, loading, onValidationError, validationErrorFallback, loadingOverlay, }: {
4
+ import type { SnapActionHandlers, SnapPage, SnapRenderState } from "../types.js";
5
+ export declare function SnapViewV2Inner({ snap, handlers, loading, onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }: {
6
6
  snap: SnapPage;
7
7
  handlers: SnapActionHandlers;
8
8
  loading?: boolean;
9
9
  onValidationError?: (result: ValidationResult) => void;
10
10
  validationErrorFallback?: ReactNode;
11
11
  loadingOverlay?: ReactNode;
12
+ initialRenderState?: SnapRenderState;
13
+ onRenderStateChange?: (state: SnapRenderState) => void;
12
14
  }): import("react").JSX.Element;
13
- export declare function SnapViewV2({ snap, handlers, loading, appearance, colors, onValidationError, validationErrorFallback, loadingOverlay, }: {
15
+ export declare function SnapViewV2({ snap, handlers, loading, appearance, colors, onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }: {
14
16
  snap: SnapPage;
15
17
  handlers: SnapActionHandlers;
16
18
  loading?: boolean;
@@ -20,8 +22,10 @@ export declare function SnapViewV2({ snap, handlers, loading, appearance, colors
20
22
  validationErrorFallback?: ReactNode;
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
  }): import("react").JSX.Element;
24
- export declare function SnapCardV2({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }: {
28
+ export declare function SnapCardV2({ snap, handlers, loading, appearance, colors, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }: {
25
29
  snap: SnapPage;
26
30
  handlers: SnapActionHandlers;
27
31
  loading?: boolean;
@@ -41,4 +45,8 @@ export declare function SnapCardV2({ snap, handlers, loading, appearance, colors
41
45
  expandButtonLabel?: string;
42
46
  /** Called from the collapsed expand button instead of toggling internal state. */
43
47
  onExpandPress?: () => void;
48
+ /** JSON-render local state used to seed this presenter mount. */
49
+ initialRenderState?: SnapRenderState;
50
+ /** Called with the full JSON-render local state after state changes. */
51
+ onRenderStateChange?: (state: SnapRenderState) => void;
44
52
  }): import("react").JSX.Element;
@@ -25,7 +25,7 @@ const fallbackStyles = StyleSheet.create({
25
25
  },
26
26
  });
27
27
  // ─── SnapViewV2 (with validation) ────────────────────
28
- export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationError, validationErrorFallback, loadingOverlay, }) {
28
+ export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }) {
29
29
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
30
30
  const valid = validation.valid;
31
31
  const validationMessage = validation.issues[0]?.message;
@@ -45,13 +45,13 @@ export function SnapViewV2Inner({ snap, handlers, loading = false, onValidationE
45
45
  return null;
46
46
  return (_jsx(_Fragment, { children: validationErrorFallback ?? _jsx(SnapValidationFallback, { message: validationMessage }) }));
47
47
  }
48
- return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay }));
48
+ return (_jsx(SnapViewCoreInner, { snap: snap, handlers: handlers, loading: loading, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
49
49
  }
50
- export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", colors, onValidationError, validationErrorFallback, loadingOverlay, }) {
51
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: loadingOverlay }) }));
50
+ export function SnapViewV2({ snap, handlers, loading = false, appearance = "dark", colors, onValidationError, validationErrorFallback, loadingOverlay, initialRenderState, onRenderStateChange, }) {
51
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: loadingOverlay, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }));
52
52
  }
53
53
  // ─── SnapCardV2 (card frame + height limits) ─────────
54
- function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
54
+ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWarning, onValidationError, validationErrorFallback, actionError, appearance, plain, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }) {
55
55
  const { colors, mode } = useSnapTheme();
56
56
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
57
57
  const [contentHeight, setContentHeight] = useState(0);
@@ -69,7 +69,7 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
69
69
  showOverflowWarning,
70
70
  });
71
71
  const expandButtonInsideCard = typeof onExpandPress === "function";
72
- const content = (_jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null }));
72
+ const content = (_jsx(SnapViewV2Inner, { snap: snap, handlers: handlers, loading: loading, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, loadingOverlay: null, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }));
73
73
  if (plain) {
74
74
  return (_jsxs(_Fragment, { children: [_jsx(View, { style: expansion.clipped
75
75
  ? { maxHeight: expansion.maxHeight, overflow: "hidden" }
@@ -123,7 +123,7 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
123
123
  : current === nextHeight
124
124
  ? current
125
125
  : nextHeight);
126
- }, style: { paddingHorizontal: 16, paddingVertical: 16 }, children: content }), expansion.showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] })), loading
126
+ }, style: cardStyles.body, children: content }), expansion.showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (_jsxs(View, { style: { position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }, children: [_jsx(View, { style: { height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" } }), _jsx(View, { style: { position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }, children: _jsxs(Text, { style: { fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }, children: [SNAP_MAX_HEIGHT, "px"] }) }), _jsx(View, { style: { flex: 1, backgroundColor: "rgba(255,50,50,0.15)" } })] })), loading
127
127
  ? loadingOverlay === undefined
128
128
  ? _jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })
129
129
  : loadingOverlay
@@ -151,13 +151,13 @@ function SnapCardV2Inner({ snap, handlers, loading, borderRadius, showOverflowWa
151
151
  : "rgba(200,0,0,0.8)",
152
152
  }, children: actionError }))] }));
153
153
  }
154
- export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, }) {
155
- return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress }) }));
154
+ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark", colors, borderRadius = 16, showOverflowWarning = false, onValidationError, validationErrorFallback, actionError, plain = false, loadingOverlay, forceExpanded, expandButtonLabel, onExpandPress, initialRenderState, onRenderStateChange, }) {
155
+ return (_jsx(SnapThemeProvider, { appearance: appearance, colors: colors, children: _jsx(SnapCardV2Inner, { snap: snap, handlers: handlers, loading: loading, borderRadius: borderRadius, showOverflowWarning: showOverflowWarning, onValidationError: onValidationError, validationErrorFallback: validationErrorFallback, actionError: actionError, appearance: appearance, plain: plain, loadingOverlay: loadingOverlay, forceExpanded: forceExpanded, expandButtonLabel: expandButtonLabel, onExpandPress: onExpandPress, initialRenderState: initialRenderState, onRenderStateChange: onRenderStateChange }) }));
156
156
  }
157
157
  const cardStyles = StyleSheet.create({
158
158
  frameRing: { alignSelf: "stretch" },
159
159
  card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
160
- body: { paddingHorizontal: 16, paddingVertical: 16 },
160
+ body: { paddingHorizontal: 8, paddingVertical: 8 },
161
161
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
162
162
  expandFloat: {
163
163
  position: "absolute",
@@ -0,0 +1,14 @@
1
+ export type SnapRenderState = Record<string, unknown>;
2
+ export type SnapRenderStateChanges = {
3
+ path: string;
4
+ value: unknown;
5
+ }[] | Record<string, unknown> | null | undefined;
6
+ export declare function cloneSnapRenderState<T>(value: T): T;
7
+ export declare function applyStatePaths(model: Record<string, unknown>, changes: SnapRenderStateChanges): void;
8
+ export declare function getUnpresentedSnapEffects(model: SnapRenderState, effects: readonly string[] | undefined): string[];
9
+ export declare function markSnapEffectsPresented(model: SnapRenderState, effects: readonly string[] | undefined): boolean;
10
+ export declare function buildInitialRenderState({ specState, initialRenderState, themeAccent, }: {
11
+ specState: unknown;
12
+ initialRenderState?: SnapRenderState;
13
+ themeAccent?: string;
14
+ }): SnapRenderState;
@@ -0,0 +1,116 @@
1
+ const SNAP_RENDER_STATE_META_KEY = "__snapRender";
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ function normalizeEffects(effects) {
6
+ if (!effects)
7
+ return [];
8
+ return Array.from(new Set(effects.filter((effect) => typeof effect === "string" && effect.length > 0)));
9
+ }
10
+ function getRenderStateMeta(model) {
11
+ const meta = model[SNAP_RENDER_STATE_META_KEY];
12
+ return isRecord(meta) ? meta : undefined;
13
+ }
14
+ function getPresentedSnapEffects(model) {
15
+ const presentedEffects = getRenderStateMeta(model)?.presentedEffects;
16
+ if (!Array.isArray(presentedEffects))
17
+ return new Set();
18
+ return new Set(presentedEffects.filter((effect) => typeof effect === "string" && effect.length > 0));
19
+ }
20
+ export function cloneSnapRenderState(value) {
21
+ if (Array.isArray(value)) {
22
+ return value.map((item) => cloneSnapRenderState(item));
23
+ }
24
+ if (isRecord(value)) {
25
+ const next = {};
26
+ for (const [key, nestedValue] of Object.entries(value)) {
27
+ next[key] = cloneSnapRenderState(nestedValue);
28
+ }
29
+ return next;
30
+ }
31
+ return value;
32
+ }
33
+ function mergeRenderState(base, override) {
34
+ if (!override)
35
+ return cloneSnapRenderState(base);
36
+ const next = cloneSnapRenderState(base);
37
+ for (const [key, value] of Object.entries(override)) {
38
+ const existing = next[key];
39
+ next[key] =
40
+ isRecord(existing) && isRecord(value)
41
+ ? mergeRenderState(existing, value)
42
+ : cloneSnapRenderState(value);
43
+ }
44
+ return next;
45
+ }
46
+ function normalizeStatePath(path) {
47
+ const trimmed = path.startsWith("/") ? path.slice(1) : path;
48
+ return trimmed.split("/").filter(Boolean);
49
+ }
50
+ function setStateValue(model, parts, value) {
51
+ let cursor = model;
52
+ for (let index = 0; index < parts.length; index += 1) {
53
+ const part = parts[index];
54
+ if (index === parts.length - 1) {
55
+ cursor[part] = cloneSnapRenderState(value);
56
+ return;
57
+ }
58
+ const next = cursor[part];
59
+ if (!isRecord(next)) {
60
+ cursor[part] = {};
61
+ }
62
+ cursor = cursor[part];
63
+ }
64
+ }
65
+ export function applyStatePaths(model, changes) {
66
+ if (!changes)
67
+ return;
68
+ const entries = Array.isArray(changes)
69
+ ? changes.map((change) => [change.path, change.value])
70
+ : Object.entries(changes);
71
+ for (const [path, value] of entries) {
72
+ const parts = normalizeStatePath(path);
73
+ if (parts.length === 0)
74
+ continue;
75
+ setStateValue(model, parts, value);
76
+ }
77
+ }
78
+ export function getUnpresentedSnapEffects(model, effects) {
79
+ const presentedEffects = getPresentedSnapEffects(model);
80
+ return normalizeEffects(effects).filter((effect) => !presentedEffects.has(effect));
81
+ }
82
+ export function markSnapEffectsPresented(model, effects) {
83
+ const nextEffects = normalizeEffects(effects);
84
+ if (nextEffects.length === 0)
85
+ return false;
86
+ const presentedEffects = getPresentedSnapEffects(model);
87
+ let changed = false;
88
+ for (const effect of nextEffects) {
89
+ if (!presentedEffects.has(effect)) {
90
+ presentedEffects.add(effect);
91
+ changed = true;
92
+ }
93
+ }
94
+ if (!changed)
95
+ return false;
96
+ let meta = getRenderStateMeta(model);
97
+ if (!meta) {
98
+ meta = {};
99
+ model[SNAP_RENDER_STATE_META_KEY] = meta;
100
+ }
101
+ meta.presentedEffects = Array.from(presentedEffects);
102
+ return true;
103
+ }
104
+ export function buildInitialRenderState({ specState, initialRenderState, themeAccent, }) {
105
+ const authoredState = isRecord(specState) ? specState : {};
106
+ const restoredState = initialRenderState
107
+ ? mergeRenderState(authoredState, initialRenderState)
108
+ : cloneSnapRenderState(authoredState);
109
+ if (!isRecord(restoredState.inputs)) {
110
+ restoredState.inputs = {};
111
+ }
112
+ const theme = isRecord(restoredState.theme) ? restoredState.theme : {};
113
+ restoredState.theme =
114
+ themeAccent === undefined ? theme : { ...theme, accent: themeAccent };
115
+ return restoredState;
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farcaster/snap",
3
- "version": "2.6.3",
3
+ "version": "2.7.0",
4
4
  "description": "Farcaster Snaps 🫰",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -47,3 +47,4 @@ export {
47
47
  type SnapGetPayload,
48
48
  } from "./schemas";
49
49
  export { validateSnapResponse, type ValidationResult } from "./validator";
50
+ export type { SnapRenderState } from "./render-state";
@@ -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
 
@@ -46,6 +47,8 @@ export type SnapActionHandlers = {
46
47
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
47
48
  };
48
49
 
50
+ export type { SnapRenderState };
51
+
49
52
  // ─── SnapCard ────────────────────────────────────────
50
53
 
51
54
  export function SnapCard({
@@ -60,6 +63,8 @@ export function SnapCard({
60
63
  actionError,
61
64
  plain = false,
62
65
  loadingOverlay,
66
+ initialRenderState,
67
+ onRenderStateChange,
63
68
  }: {
64
69
  snap: SnapPage;
65
70
  handlers: SnapActionHandlers;
@@ -76,6 +81,10 @@ export function SnapCard({
76
81
  plain?: boolean;
77
82
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
78
83
  loadingOverlay?: ReactNode;
84
+ /** JSON-render local state used to seed this presenter mount. */
85
+ initialRenderState?: SnapRenderState;
86
+ /** Called with the full JSON-render local state after state changes. */
87
+ onRenderStateChange?: (state: SnapRenderState) => void;
79
88
  }) {
80
89
  if (snap.version === SPEC_VERSION_2) {
81
90
  return (
@@ -91,6 +100,8 @@ export function SnapCard({
91
100
  actionError={actionError}
92
101
  plain={plain}
93
102
  loadingOverlay={loadingOverlay}
103
+ initialRenderState={initialRenderState}
104
+ onRenderStateChange={onRenderStateChange}
94
105
  />
95
106
  );
96
107
  }
@@ -105,6 +116,8 @@ export function SnapCard({
105
116
  actionError={actionError}
106
117
  plain={plain}
107
118
  loadingOverlay={loadingOverlay}
119
+ initialRenderState={initialRenderState}
120
+ onRenderStateChange={onRenderStateChange}
108
121
  />
109
122
  );
110
123
  }
@@ -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,47 +26,6 @@ import {
18
26
  } from "react";
19
27
  import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
20
28
 
21
- // ─── Internal helpers ──────────────────────────────────
22
-
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
- }
60
- }
61
-
62
29
  function withDefaultElementProps(spec: Spec): Spec {
63
30
  if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
64
31
  const elements = spec.elements as unknown as Record<
@@ -137,7 +104,18 @@ function ConfettiOverlay() {
137
104
  }}
138
105
  >
139
106
  {pieces.map(
140
- ({ id, left, delay, duration, color, size, rotation, isCircle, driftX, driftMid }) => (
107
+ ({
108
+ id,
109
+ left,
110
+ delay,
111
+ duration,
112
+ color,
113
+ size,
114
+ rotation,
115
+ isCircle,
116
+ driftX,
117
+ driftMid,
118
+ }) => (
141
119
  <div
142
120
  key={id}
143
121
  style={
@@ -179,8 +157,7 @@ function FireworksOverlay() {
179
157
  y: 10 + Math.random() * 50,
180
158
  delay: b * 0.5 + Math.random() * 0.2,
181
159
  particles: Array.from({ length: 24 }, (_, p) => {
182
- const angle =
183
- (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
160
+ const angle = (p / 24) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
184
161
  const dist = 55 + Math.random() * 60;
185
162
  return {
186
163
  id: p,
@@ -288,9 +265,7 @@ export function SnapLoadingOverlay({
288
265
  zIndex: 10,
289
266
  background: tint,
290
267
  backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
291
- WebkitBackdropFilter: active
292
- ? "blur(10px) saturate(1.05)"
293
- : "none",
268
+ WebkitBackdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
294
269
  opacity: active ? 1 : 0,
295
270
  pointerEvents: active ? "auto" : "none",
296
271
  transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
@@ -349,6 +324,8 @@ export function SnapViewCore({
349
324
  loading = false,
350
325
  appearance = "dark",
351
326
  loadingOverlay,
327
+ initialRenderState,
328
+ onRenderStateChange,
352
329
  }: {
353
330
  snap: SnapPage;
354
331
  handlers: SnapActionHandlers;
@@ -359,21 +336,24 @@ export function SnapViewCore({
359
336
  * the built-in spinner + backdrop is used. Pass `null` to render nothing.
360
337
  */
361
338
  loadingOverlay?: ReactNode;
339
+ initialRenderState?: SnapRenderState;
340
+ onRenderStateChange?: (state: SnapRenderState) => void;
362
341
  }) {
363
342
  const spec = useMemo(() => withDefaultElementProps(snap.ui), [snap.ui]);
364
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
343
+ const initialState = useMemo(
344
+ () =>
345
+ buildInitialRenderState({
346
+ specState: spec.state,
347
+ initialRenderState,
348
+ themeAccent: snap.theme?.accent,
349
+ }),
350
+ [initialRenderState, spec.state, snap.theme?.accent],
351
+ );
365
352
 
366
353
  const stateRef = useRef<Record<string, unknown>>(initialState);
367
354
 
368
355
  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
- };
356
+ stateRef.current = cloneSnapRenderState(initialState);
377
357
  }, [initialState]);
378
358
 
379
359
  useEffect(() => {
@@ -389,14 +369,58 @@ export function SnapViewCore({
389
369
  setPageKey((k) => k + 1);
390
370
  }, [spec]);
391
371
 
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);
372
+ const effectSignature = snap.effects?.join("\u0000") ?? "";
373
+ const snapEffects = useMemo(
374
+ () => (effectSignature ? effectSignature.split("\u0000") : []),
375
+ [effectSignature],
376
+ );
377
+ const showConfetti = snapEffects.includes("confetti");
378
+ const showFireworks = snapEffects.includes("fireworks");
379
+ const [effectRunKeys, setEffectRunKeys] = useState({
380
+ confetti: 0,
381
+ fireworks: 0,
382
+ });
383
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
396
384
  useEffect(() => {
397
- if (showConfetti) setConfettiKey((k) => k + 1);
398
- if (showFireworks) setFireworksKey((k) => k + 1);
399
- }, [showConfetti, showFireworks, snap]);
385
+ onRenderStateChangeRef.current = onRenderStateChange;
386
+ }, [onRenderStateChange]);
387
+ useEffect(() => {
388
+ const effectsToPresent = getUnpresentedSnapEffects(
389
+ stateRef.current,
390
+ snapEffects,
391
+ );
392
+
393
+ if (effectsToPresent.length === 0) {
394
+ setEffectRunKeys((current) => {
395
+ const next = {
396
+ confetti: showConfetti ? current.confetti : 0,
397
+ fireworks: showFireworks ? current.fireworks : 0,
398
+ };
399
+ return next.confetti === current.confetti &&
400
+ next.fireworks === current.fireworks
401
+ ? current
402
+ : next;
403
+ });
404
+ return;
405
+ }
406
+
407
+ if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
408
+ onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
409
+ }
410
+
411
+ setEffectRunKeys((current) => ({
412
+ confetti: effectsToPresent.includes("confetti")
413
+ ? current.confetti + 1
414
+ : showConfetti
415
+ ? current.confetti
416
+ : 0,
417
+ fireworks: effectsToPresent.includes("fireworks")
418
+ ? current.fireworks + 1
419
+ : showFireworks
420
+ ? current.fireworks
421
+ : 0,
422
+ }));
423
+ }, [initialState, showConfetti, showFireworks, snapEffects]);
400
424
 
401
425
  const accentName = snap.theme?.accent ?? "purple";
402
426
 
@@ -478,8 +502,12 @@ export function SnapViewCore({
478
502
 
479
503
  return (
480
504
  <div style={{ position: "relative", width: "100%" }}>
481
- {showConfetti && <ConfettiOverlay key={confettiKey} />}
482
- {showFireworks && <FireworksOverlay key={fireworksKey} />}
505
+ {showConfetti && effectRunKeys.confetti > 0 && (
506
+ <ConfettiOverlay key={effectRunKeys.confetti} />
507
+ )}
508
+ {showFireworks && effectRunKeys.fireworks > 0 && (
509
+ <FireworksOverlay key={effectRunKeys.fireworks} />
510
+ )}
483
511
  {loadingOverlay === undefined ? (
484
512
  <SnapLoadingOverlay
485
513
  appearance={appearance}
@@ -503,6 +531,7 @@ export function SnapViewCore({
503
531
  loading={false}
504
532
  onStateChange={(changes) => {
505
533
  applyStatePaths(stateRef.current, changes);
534
+ onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
506
535
  }}
507
536
  onAction={handleAction}
508
537
  />
@@ -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>