@farcaster/snap 2.6.4 → 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 +1 -0
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +3 -3
- package/dist/react/snap-view-core.d.ts +4 -5
- package/dist/react/snap-view-core.js +54 -58
- package/dist/react/v1/snap-view.d.ts +9 -3
- package/dist/react/v1/snap-view.js +4 -4
- package/dist/react/v2/snap-view.d.ts +9 -3
- package/dist/react/v2/snap-view.js +4 -4
- package/dist/react-native/index.d.ts +7 -3
- package/dist/react-native/index.js +3 -3
- package/dist/react-native/snap-view-core.d.ts +4 -5
- package/dist/react-native/snap-view-core.js +54 -69
- package/dist/react-native/types.d.ts +2 -0
- package/dist/react-native/v1/snap-view.d.ts +12 -4
- package/dist/react-native/v1/snap-view.js +8 -8
- package/dist/react-native/v2/snap-view.d.ts +12 -4
- package/dist/react-native/v2/snap-view.js +8 -8
- package/dist/render-state.d.ts +14 -0
- package/dist/render-state.js +116 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/react/index.tsx +13 -0
- package/src/react/snap-view-core.tsx +94 -65
- package/src/react/v1/snap-view.tsx +15 -1
- package/src/react/v2/snap-view.tsx +15 -1
- package/src/react-native/index.tsx +22 -2
- package/src/react-native/snap-view-core.tsx +86 -80
- package/src/react-native/types.ts +3 -0
- package/src/react-native/v1/snap-view.tsx +27 -1
- package/src/react-native/v2/snap-view.tsx +27 -1
- package/src/render-state.ts +184 -0
|
@@ -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,8 +76,8 @@ 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" },
|
|
@@ -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,
|
|
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" }
|
|
@@ -151,8 +151,8 @@ 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" },
|
|
@@ -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
package/src/index.ts
CHANGED
package/src/react/index.tsx
CHANGED
|
@@ -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
|
-
({
|
|
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(
|
|
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
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 &&
|
|
482
|
-
|
|
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,
|
|
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>
|