From 4ebe6bab68248ee3a0ddfa8a39712f839fc434d8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Mon, 26 Jan 2026 00:01:02 +0800 Subject: [PATCH] feat: complete implementation --- openspec/changes/add-snapshot-cache/design.md | 39 +++ .../changes/add-snapshot-cache/proposal.md | 15 ++ .../specs/production-quality/spec.md | 12 + openspec/changes/add-snapshot-cache/tasks.md | 12 + packages/chain-effect/src/index.ts | 15 +- packages/chain-effect/src/instance.ts | 243 +++++++++++++++--- .../chain-effect/src/snapshot-store.test.ts | 22 ++ packages/chain-effect/src/snapshot-store.ts | 132 ++++++++++ packages/chain-effect/src/superjson.ts | 4 + src/components/common/amount-display.tsx | 4 +- src/components/common/animated-number.tsx | 4 +- .../wallet/refraction/canvas-pool.ts | 23 ++ .../wallet/refraction/hologram-canvas.tsx | 68 ++++- .../wallet/wallet-address-portfolio-view.tsx | 11 +- .../wallet/wallet-card-carousel.tsx | 13 +- src/components/wallet/wallet-card.tsx | 21 ++ src/lib/superjson.ts | 11 + src/main.tsx | 1 + .../chain-adapter/providers/chain-provider.ts | 147 +++++++++-- .../providers/tronwallet-provider.effect.ts | 12 + src/stackflow/activities/tabs/WalletTab.tsx | 30 ++- src/stores/wallet.ts | 96 +++++-- 22 files changed, 835 insertions(+), 100 deletions(-) create mode 100644 openspec/changes/add-snapshot-cache/design.md create mode 100644 openspec/changes/add-snapshot-cache/proposal.md create mode 100644 openspec/changes/add-snapshot-cache/specs/production-quality/spec.md create mode 100644 openspec/changes/add-snapshot-cache/tasks.md create mode 100644 packages/chain-effect/src/snapshot-store.test.ts create mode 100644 packages/chain-effect/src/snapshot-store.ts create mode 100644 packages/chain-effect/src/superjson.ts create mode 100644 src/components/wallet/refraction/canvas-pool.ts create mode 100644 src/lib/superjson.ts diff --git a/openspec/changes/add-snapshot-cache/design.md b/openspec/changes/add-snapshot-cache/design.md new file mode 100644 index 000000000..a63325445 --- /dev/null +++ b/openspec/changes/add-snapshot-cache/design.md @@ -0,0 +1,39 @@ +# Design: Snapshot Cache for React useState + +## Goals +- Show cached data immediately on page load (perceived speed) without altering network/cache strategies. +- Keep the change local to the **React hook layer** (front-end concern). +- Ensure snapshot persistence across reloads via IndexedDB, with memory fallback for non-browser environments. + +## Approach +- Add snapshot support to `useState(input, options)` in the chain-effect React adapter. +- New options (non-breaking): + - `useSnapshot?: boolean` (default `true`) + - `snapshotMaxAgeMs?: number` (optional; default `Infinity`) +- Snapshot storage: + - Key format: `chain-effect:snapshot::` + - Value: `superjson.stringify({ data, timestamp })` + - Storage: **IndexedDB** when available, otherwise in-memory Map + +## Behavior +1. **Hook initialization** + - If `useSnapshot` is true, attempt to read snapshot. + - Use in-memory snapshot immediately if present (same-session fast path). + - Otherwise read IndexedDB asynchronously; if snapshot exists and not expired, set it as initial `data`. + - Continue normal subscription flow; network requests remain unchanged. + +2. **Update flow** + - On every successful update emitted by the data source, persist a snapshot. + +3. **Error handling** + - Snapshot read failures are ignored (fallback to normal behavior). + - Snapshot write failures are ignored (no impact to data flow). + +## Non-goals +- No changes to `httpFetchCached` or cache strategy semantics. +- No server-side snapshot support. + +## Risks / Mitigations +- **Stale data**: optional `snapshotMaxAgeMs` and default to immediate refresh via existing data sources. +- **Storage size**: snapshots are per key and only store latest value; no history. +- **Serialization**: use `superjson` to handle BigInt/Amount types safely. diff --git a/openspec/changes/add-snapshot-cache/proposal.md b/openspec/changes/add-snapshot-cache/proposal.md new file mode 100644 index 000000000..af160711b --- /dev/null +++ b/openspec/changes/add-snapshot-cache/proposal.md @@ -0,0 +1,15 @@ +# Change: Add snapshot cache for chain-effect React + +## Why +Current `httpFetchCached` returns a single value per call, so UI cannot immediately display cached data while a network request is in-flight. We need a front-end-only snapshot layer to show cached data as soon as it becomes available without changing existing cache strategy semantics. + +## What Changes +- Add a **snapshot cache** at the React hook layer (`useState`) to hydrate UI from the last successful data. +- Introduce a `useSnapshot` option (default `true`) to enable/disable snapshot hydration per hook call. +- Persist snapshots in a **front-end-owned storage** (IndexedDB with memory fallback) keyed by data source + input key. +- Preserve existing cache strategies (`ttl`, `network-first`, `cache-first`) and network behavior; snapshot only affects initial UI state. + +## Impact +- Affected specs: `production-quality` (performance / perceived latency) +- Affected code: `packages/chain-effect` React adapter + hook options +- Behavior change: UI can show cached data immediately before network refresh completes (no change to request policy) diff --git a/openspec/changes/add-snapshot-cache/specs/production-quality/spec.md b/openspec/changes/add-snapshot-cache/specs/production-quality/spec.md new file mode 100644 index 000000000..a846ffc8a --- /dev/null +++ b/openspec/changes/add-snapshot-cache/specs/production-quality/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Performance Targets + +The application SHALL provide immediate perceived responses by hydrating UI from recent cached snapshots while preserving existing network cache strategies. + +#### Scenario: Snapshot-first UI hydration +- **GIVEN** the user opens the app with previously cached data +- **WHEN** a data source hook initializes +- **THEN** the UI shows the cached snapshot as soon as it is available from storage +- **AND** the network request proceeds according to the configured cache strategy +- **AND** the UI updates again when fresh data arrives diff --git a/openspec/changes/add-snapshot-cache/tasks.md b/openspec/changes/add-snapshot-cache/tasks.md new file mode 100644 index 000000000..51321f433 --- /dev/null +++ b/openspec/changes/add-snapshot-cache/tasks.md @@ -0,0 +1,12 @@ +## 1. Implementation +- [x] Add snapshot storage utility (IndexedDB + memory fallback) in chain-effect React adapter +- [x] Extend `useState` options with `useSnapshot` and `snapshotMaxAgeMs` +- [x] Hydrate initial hook state from snapshot when enabled +- [x] Persist snapshot on successful updates + +## 2. Validation +- [x] Add/adjust unit tests for snapshot hydration (if feasible) +- [x] Ensure typecheck/lint pass + +## 3. Documentation +- [x] Update relevant docs or inline comments for new options diff --git a/packages/chain-effect/src/index.ts b/packages/chain-effect/src/index.ts index d609b5613..f9dab7f63 100644 --- a/packages/chain-effect/src/index.ts +++ b/packages/chain-effect/src/index.ts @@ -15,9 +15,7 @@ export { Schema } from "effect" export { isChainEffectDebugEnabled } from "./debug" // SuperJSON for serialization (handles BigInt, Amount, etc.) -import { SuperJSON } from "superjson" -export const superjson = new SuperJSON({ dedupe: true }) -export { SuperJSON } from "superjson" +export { superjson, SuperJSON } from "./superjson" // Schema definitions export * from "./schema" @@ -105,6 +103,17 @@ export { type PollMeta, } from "./poll-meta" +// Snapshot Store (IndexedDB + memory fallback) +export { + readSnapshot, + writeSnapshot, + deleteSnapshot, + readMemorySnapshot, + writeMemorySnapshot, + deleteMemorySnapshot, + type SnapshotEntry, +} from "./snapshot-store" + // Source Registry (global singleton + ref counting) export { acquireSource, diff --git a/packages/chain-effect/src/instance.ts b/packages/chain-effect/src/instance.ts index 3e9074660..7fa8c7f3f 100644 --- a/packages/chain-effect/src/instance.ts +++ b/packages/chain-effect/src/instance.ts @@ -13,6 +13,14 @@ import { Effect, Stream, Fiber } from "effect" import type { FetchError } from "./http" import { formatChainEffectError, logChainEffectDebug } from "./debug" import type { DataSource } from "./source" +import { + deleteMemorySnapshot, + deleteSnapshot, + readMemorySnapshot, + readSnapshot, + writeSnapshot, + type SnapshotEntry, +} from "./snapshot-store" type UnknownRecord = Record @@ -71,6 +79,36 @@ function debugLog(message: string, ...args: Array): v logChainEffectDebug(message, ...args) } +export interface StreamInstanceUseStateOptions { + /** 是否启用(默认 true) */ + enabled?: boolean + /** 是否使用快照(默认 true) */ + useSnapshot?: boolean + /** 快照最大有效期(毫秒,默认 Infinity) */ + snapshotMaxAgeMs?: number +} + +const SNAPSHOT_KEY_PREFIX = "chain-effect:snapshot" + +function makeSnapshotKey(name: string, inputKey: string): string { + return `${SNAPSHOT_KEY_PREFIX}:${name}:${inputKey}` +} + +function isSnapshotExpired(timestamp: number, maxAgeMs: number): boolean { + if (!Number.isFinite(maxAgeMs)) return false + return Date.now() - timestamp > maxAgeMs +} + +function getValidMemorySnapshot(key: string, maxAgeMs: number): SnapshotEntry | null { + const entry = readMemorySnapshot(key) + if (!entry) return null + if (isSnapshotExpired(entry.timestamp, maxAgeMs)) { + deleteMemorySnapshot(key) + return null + } + return entry +} + /** 兼容旧 API 的 StreamInstance 接口 */ export interface StreamInstance { readonly name: string @@ -82,7 +120,7 @@ export interface StreamInstance { ): () => void useState( input: TInput, - options?: { enabled?: boolean } + options?: StreamInstanceUseStateOptions ): { data: TOutput | undefined isLoading: boolean @@ -211,38 +249,92 @@ export function createStreamInstanceFromSource( } }, - useState(input: TInput, options?: { enabled?: boolean }) { - const [isLoading, setIsLoading] = useState(true) + useState(input: TInput, options?: StreamInstanceUseStateOptions) { + const enabled = options?.enabled !== false + const useSnapshot = options?.useSnapshot !== false + const snapshotMaxAgeMs = options?.snapshotMaxAgeMs ?? Number.POSITIVE_INFINITY + + const inputKey = useMemo(() => getInputKey(input), [input]) + const snapshotKey = useMemo(() => makeSnapshotKey(name, inputKey), [inputKey]) + const memorySnapshot = useMemo( + () => (useSnapshot ? getValidMemorySnapshot(snapshotKey, snapshotMaxAgeMs) : null), + [snapshotKey, snapshotMaxAgeMs, useSnapshot] + ) + + const [isLoading, setIsLoading] = useState(() => (enabled ? !memorySnapshot : false)) const [isFetching, setIsFetching] = useState(false) const [error, setError] = useState(undefined) - const inputKey = useMemo(() => getInputKey(input), [input]) const inputRef = useRef(input) inputRef.current = input - const enabled = options?.enabled !== false const instanceRef = useRef(this) - - // 使用 ref 存储最新值供 useSyncExternalStore 使用 - const snapshotRef = useRef(undefined) + const snapshotRef = useRef(memorySnapshot?.data) + const snapshotMetaRef = useRef<{ key: string; timestamp: number } | null>( + memorySnapshot ? { key: snapshotKey, timestamp: memorySnapshot.timestamp } : null + ) + const onStoreChangeRef = useRef<(() => void) | null>(null) + const lastSnapshotKeyRef = useRef(snapshotKey) + + if (lastSnapshotKeyRef.current !== snapshotKey) { + lastSnapshotKeyRef.current = snapshotKey + if (memorySnapshot) { + snapshotRef.current = memorySnapshot.data + snapshotMetaRef.current = { key: snapshotKey, timestamp: memorySnapshot.timestamp } + } else { + snapshotRef.current = undefined + snapshotMetaRef.current = null + } + } + + useEffect(() => { + if (!enabled || !useSnapshot) return + let active = true + + void (async () => { + const entry = await readSnapshot(snapshotKey) + if (!active || !entry) return + if (isSnapshotExpired(entry.timestamp, snapshotMaxAgeMs)) { + await deleteSnapshot(snapshotKey) + return + } + const current = snapshotMetaRef.current + if (current && current.key === snapshotKey && current.timestamp >= entry.timestamp) return + snapshotRef.current = entry.data + snapshotMetaRef.current = { key: snapshotKey, timestamp: entry.timestamp } + setIsLoading(false) + onStoreChangeRef.current?.() + })() + + return () => { + active = false + } + }, [enabled, snapshotKey, snapshotMaxAgeMs, useSnapshot]) // useSyncExternalStore 订阅函数 const subscribe = useCallback((onStoreChange: () => void) => { + onStoreChangeRef.current = onStoreChange + if (!enabled) { snapshotRef.current = undefined return () => {} } - - setIsLoading(true) + + const hasSnapshot = snapshotRef.current !== undefined + setIsLoading(!hasSnapshot) setIsFetching(true) setError(undefined) - debugLog(`${name} subscribe`, inputKey) debugLog(`${name} subscribe`, inputKey) const unsubscribe = instanceRef.current.subscribe( inputRef.current, (newData: TOutput) => { + const timestamp = Date.now() snapshotRef.current = newData + snapshotMetaRef.current = { key: snapshotKey, timestamp } + if (useSnapshot) { + void writeSnapshot({ key: snapshotKey, data: newData, timestamp }) + } setIsLoading(false) setIsFetching(false) setError(undefined) @@ -251,8 +343,11 @@ export function createStreamInstanceFromSource( } ) - return unsubscribe - }, [enabled, inputKey]) + return () => { + onStoreChangeRef.current = null + unsubscribe() + } + }, [enabled, inputKey, snapshotKey, useSnapshot]) const getSnapshot = useCallback(() => snapshotRef.current, []) @@ -266,14 +361,19 @@ export function createStreamInstanceFromSource( try { const source = await getOrCreateSource(inputRef.current) const result = await Effect.runPromise(source.refresh) + const timestamp = Date.now() snapshotRef.current = result + snapshotMetaRef.current = { key: snapshotKey, timestamp } + if (useSnapshot) { + void writeSnapshot({ key: snapshotKey, data: result, timestamp }) + } } catch (err) { setError(err instanceof Error ? err : new Error(String(err))) } finally { setIsFetching(false) setIsLoading(false) } - }, [enabled]) + }, [enabled, snapshotKey, useSnapshot]) // 处理 disabled 状态 useEffect(() => { @@ -285,6 +385,11 @@ export function createStreamInstanceFromSource( } }, [enabled]) + useEffect(() => { + if (!enabled || !useSnapshot) return + setIsLoading(!memorySnapshot) + }, [enabled, memorySnapshot, useSnapshot]) + return { data, isLoading, isFetching, error, refetch } }, @@ -376,44 +481,103 @@ export function createStreamInstance( } }, - useState(input: TInput, options?: { enabled?: boolean }) { - const [isLoading, setIsLoading] = useState(true) + useState(input: TInput, options?: StreamInstanceUseStateOptions) { + const enabled = options?.enabled !== false + const useSnapshot = options?.useSnapshot !== false + const snapshotMaxAgeMs = options?.snapshotMaxAgeMs ?? Number.POSITIVE_INFINITY + + const inputKey = useMemo(() => getInputKey(input), [input]) + const snapshotKey = useMemo(() => makeSnapshotKey(name, inputKey), [inputKey]) + const cachedEntry = useMemo(() => { + const cached = cache.get(inputKey) + if (!cached) return null + if (Date.now() - cached.timestamp >= ttl) return null + return { key: snapshotKey, data: cached.value, timestamp: cached.timestamp } + }, [inputKey, snapshotKey]) + const memorySnapshot = useMemo( + () => (useSnapshot ? getValidMemorySnapshot(snapshotKey, snapshotMaxAgeMs) : null), + [snapshotKey, snapshotMaxAgeMs, useSnapshot] + ) + const initialSnapshot = useMemo(() => { + if (!useSnapshot) return cachedEntry + if (!cachedEntry) return memorySnapshot + if (!memorySnapshot) return cachedEntry + return cachedEntry.timestamp >= memorySnapshot.timestamp ? cachedEntry : memorySnapshot + }, [cachedEntry, memorySnapshot, useSnapshot]) + + const [isLoading, setIsLoading] = useState(() => (enabled ? !initialSnapshot : false)) const [isFetching, setIsFetching] = useState(false) const [error, setError] = useState(undefined) - const inputKey = useMemo(() => getInputKey(input), [input]) const inputRef = useRef(input) inputRef.current = input - const enabled = options?.enabled !== false const instanceRef = useRef(this) - - // 使用 ref 存储最新值供 useSyncExternalStore 使用 - const snapshotRef = useRef(undefined) + const snapshotRef = useRef(initialSnapshot?.data) + const snapshotMetaRef = useRef<{ key: string; timestamp: number } | null>( + initialSnapshot ? { key: snapshotKey, timestamp: initialSnapshot.timestamp } : null + ) + const onStoreChangeRef = useRef<(() => void) | null>(null) + const lastSnapshotKeyRef = useRef(snapshotKey) + + if (lastSnapshotKeyRef.current !== snapshotKey) { + lastSnapshotKeyRef.current = snapshotKey + if (initialSnapshot) { + snapshotRef.current = initialSnapshot.data + snapshotMetaRef.current = { key: snapshotKey, timestamp: initialSnapshot.timestamp } + } else { + snapshotRef.current = undefined + snapshotMetaRef.current = null + } + } + + useEffect(() => { + if (!enabled || !useSnapshot) return + let active = true + + void (async () => { + const entry = await readSnapshot(snapshotKey) + if (!active || !entry) return + if (isSnapshotExpired(entry.timestamp, snapshotMaxAgeMs)) { + await deleteSnapshot(snapshotKey) + return + } + const current = snapshotMetaRef.current + if (current && current.key === snapshotKey && current.timestamp >= entry.timestamp) return + snapshotRef.current = entry.data + snapshotMetaRef.current = { key: snapshotKey, timestamp: entry.timestamp } + setIsLoading(false) + onStoreChangeRef.current?.() + })() + + return () => { + active = false + } + }, [enabled, snapshotKey, snapshotMaxAgeMs, useSnapshot]) // useSyncExternalStore 订阅函数 const subscribe = useCallback((onStoreChange: () => void) => { + onStoreChangeRef.current = onStoreChange + if (!enabled) { snapshotRef.current = undefined return () => {} } - // 检查缓存 - const key = inputKey - const cached = cache.get(key) - if (cached && Date.now() - cached.timestamp < ttl) { - snapshotRef.current = cached.value - setIsLoading(false) - } else { - setIsLoading(true) - } + const hasSnapshot = snapshotRef.current !== undefined + setIsLoading(!hasSnapshot) setIsFetching(true) setError(undefined) const unsubscribe = instanceRef.current.subscribe( inputRef.current, (newData: TOutput) => { + const timestamp = Date.now() snapshotRef.current = newData + snapshotMetaRef.current = { key: snapshotKey, timestamp } + if (useSnapshot) { + void writeSnapshot({ key: snapshotKey, data: newData, timestamp }) + } setIsLoading(false) setIsFetching(false) setError(undefined) @@ -421,8 +585,11 @@ export function createStreamInstance( } ) - return unsubscribe - }, [enabled, inputKey]) + return () => { + onStoreChangeRef.current = null + unsubscribe() + } + }, [enabled, snapshotKey, useSnapshot]) const getSnapshot = useCallback(() => snapshotRef.current, []) @@ -436,14 +603,19 @@ export function createStreamInstance( try { cache.delete(getInputKey(inputRef.current)) const result = await instanceRef.current.fetch(inputRef.current) + const timestamp = Date.now() snapshotRef.current = result + snapshotMetaRef.current = { key: snapshotKey, timestamp } + if (useSnapshot) { + void writeSnapshot({ key: snapshotKey, data: result, timestamp }) + } } catch (err) { setError(err instanceof Error ? err : new Error(String(err))) } finally { setIsFetching(false) setIsLoading(false) } - }, [enabled]) + }, [enabled, snapshotKey, useSnapshot]) // 处理 disabled 状态 useEffect(() => { @@ -455,6 +627,11 @@ export function createStreamInstance( } }, [enabled]) + useEffect(() => { + if (!enabled || !useSnapshot) return + setIsLoading(!initialSnapshot) + }, [enabled, initialSnapshot, useSnapshot]) + return { data, isLoading, isFetching, error, refetch } }, diff --git a/packages/chain-effect/src/snapshot-store.test.ts b/packages/chain-effect/src/snapshot-store.test.ts new file mode 100644 index 000000000..84d52761c --- /dev/null +++ b/packages/chain-effect/src/snapshot-store.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest" +import { deleteSnapshot, readSnapshot, writeSnapshot } from "./snapshot-store" + +describe("snapshot-store", () => { + it("stores and retrieves snapshots in memory when IndexedDB is unavailable", async () => { + const key = "test:snapshot:memory" + const entry = { + key, + data: { amount: 1n, symbol: "BFT" }, + timestamp: Date.now(), + } + + await writeSnapshot(entry) + + const loaded = await readSnapshot(key) + expect(loaded?.key).toBe(key) + expect(loaded?.data.amount).toBe(1n) + expect(loaded?.data.symbol).toBe("BFT") + + await deleteSnapshot(key) + }) +}) diff --git a/packages/chain-effect/src/snapshot-store.ts b/packages/chain-effect/src/snapshot-store.ts new file mode 100644 index 000000000..496205b9b --- /dev/null +++ b/packages/chain-effect/src/snapshot-store.ts @@ -0,0 +1,132 @@ +/** + * Snapshot storage (IndexedDB + memory fallback) + * + * Front-end-only persistence for hook snapshots. + */ + +import { superjson } from "./superjson" + +export interface SnapshotEntry { + key: string + data: T + timestamp: number +} + +interface SnapshotRecord { + key: string + payload: string + timestamp: number + serializerVersion: number +} + +const DB_NAME = "chain-effect-snapshots" +const DB_VERSION = 1 +const STORE_NAME = "snapshots" +const SERIALIZER_VERSION = 3 + +const memorySnapshots = new Map>() +let dbPromise: Promise | null = null + +function hasIndexedDb(): boolean { + return typeof indexedDB !== "undefined" +} + +function getDb(): Promise { + if (!hasIndexedDb()) { + return Promise.reject(new Error("IndexedDB is not available")) + } + if (dbPromise) return dbPromise + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.addEventListener("error", () => reject(request.error)) + request.addEventListener("success", () => resolve(request.result)) + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "key" }) + } + } + }) + return dbPromise +} + +export function readMemorySnapshot(key: string): SnapshotEntry | null { + const entry = memorySnapshots.get(key) + if (!entry) return null + return entry as SnapshotEntry +} + +export function writeMemorySnapshot(entry: SnapshotEntry): void { + memorySnapshots.set(entry.key, entry as SnapshotEntry) +} + +export function deleteMemorySnapshot(key: string): void { + memorySnapshots.delete(key) +} + +export async function readSnapshot(key: string): Promise | null> { + if (!hasIndexedDb()) { + return readMemorySnapshot(key) + } + + try { + const db = await getDb() + const record = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly") + const store = tx.objectStore(STORE_NAME) + const request = store.get(key) + request.addEventListener("error", () => reject(request.error)) + request.addEventListener("success", () => resolve((request.result as SnapshotRecord) ?? null)) + }) + + if (!record) return null + if (record.serializerVersion !== SERIALIZER_VERSION) { + await deleteSnapshot(key) + return null + } + const data = superjson.parse(record.payload) + return { key: record.key, data, timestamp: record.timestamp } + } catch { + return null + } +} + +export async function writeSnapshot(entry: SnapshotEntry): Promise { + writeMemorySnapshot(entry) + if (!hasIndexedDb()) return + try { + const db = await getDb() + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite") + const store = tx.objectStore(STORE_NAME) + const request = store.put({ + key: entry.key, + payload: superjson.stringify(entry.data), + timestamp: entry.timestamp, + serializerVersion: SERIALIZER_VERSION, + } satisfies SnapshotRecord) + request.addEventListener("error", () => reject(request.error)) + request.addEventListener("success", () => resolve()) + }) + } catch { + // ignore persistence errors + } +} + +export async function deleteSnapshot(key: string): Promise { + deleteMemorySnapshot(key) + if (!hasIndexedDb()) return + try { + const db = await getDb() + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite") + const store = tx.objectStore(STORE_NAME) + const request = store.delete(key) + request.addEventListener("error", () => reject(request.error)) + request.addEventListener("success", () => resolve()) + }) + } catch { + // ignore persistence errors + } +} diff --git a/packages/chain-effect/src/superjson.ts b/packages/chain-effect/src/superjson.ts new file mode 100644 index 000000000..8d2de9339 --- /dev/null +++ b/packages/chain-effect/src/superjson.ts @@ -0,0 +1,4 @@ +import { SuperJSON } from "superjson" + +export const superjson = new SuperJSON({ dedupe: true }) +export { SuperJSON } from "superjson" diff --git a/src/components/common/amount-display.tsx b/src/components/common/amount-display.tsx index 21c6abd6a..1a676ad64 100644 --- a/src/components/common/amount-display.tsx +++ b/src/components/common/amount-display.tsx @@ -239,7 +239,7 @@ export function AmountDisplay({ ); } - // 加载状态:显示 0 配合呼吸动画 + // 加载状态:显示当前值 + 呼吸动画(避免跳到 0) if (loading) { return (