Skip to content

Commit 1caed2e

Browse files
committed
Distinguish price loading/unavailable, fix analytics 0-flash, surface silent save failures
- #675: audited all 5 consumers of use-token-price.ts. Fixed two real gaps — stream-card.tsx rendered loading and unavailable identically (both showed nothing); now shows a skeleton pulse while loading. create-form.tsx's "Fetching price…" indicator was dead code (nested inside a block that can only render once the price has already resolved, so `priceLoading` was always false there) — moved it out so it's actually reachable, and added a distinct "Price unavailable" message for the resolved-but-null case. dashboard-stats.tsx already handled this correctly; create-confirmation.tsx and the stream detail page use the price only for an XLM-denominated fee estimate that never renders a USD value, so there's nothing to distinguish there. - #674: analytics/page.tsx called useStreams({ enablePolling: false }) but never destructured `loading`, so the four stat cards flashed "0" before real data resolved. Destructured it and render a skeleton pulse per card while loading. (hooks/use-streams.ts had the same pre-existing corruption fixed elsewhere this session — two competing implementations merged together, duplicate declarations, two conflicting return statements — reconciled into one, since it directly blocked `loading` from working at all here.) - #676: use-form-draft.ts's save() silently discarded quota/ unavailable errors. Added an optional onSaveError callback (fires once per failure streak, resets on the next successful save) and wired create-form.tsx to show a toast warning. - #677: use-webhooks.ts's saveWebhooks/saveHistory called localStorage.setItem unguarded — wrapped both in try/catch (returning a success flag) matching use-form-draft.ts's pattern, and added the same onSaveError callback convention, wired to a toast in webhook-settings.tsx. Closes #674 Closes #675 Closes #676 Closes #677
1 parent 0041b03 commit 1caed2e

7 files changed

Lines changed: 181 additions & 84 deletions

File tree

app/app/analytics/page.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
210210
}
211211

212212
export default function AnalyticsPage() {
213-
const { all } = useStreams({ enablePolling: false })
213+
// Issue #674: `loading` was never destructured, so the four stat cards
214+
// below rendered misleading "0" values before real data resolved instead
215+
// of a loading state.
216+
const { all, loading } = useStreams({ enablePolling: false })
214217
const { network } = useNetwork()
215218
const [range, setRange] = useState('30d')
216219
const [mounted, setMounted] = useState(false)
@@ -260,9 +263,13 @@ export default function AnalyticsPage() {
260263
<CardHeader className="pb-2">
261264
<CardDescription>Total volume streamed</CardDescription>
262265
<CardTitle className="text-2xl font-semibold">
263-
{snapshot.totalVolume > 0n
264-
? formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7)
265-
: '0'}
266+
{loading ? (
267+
<span className="inline-block h-7 w-24 animate-pulse rounded bg-muted" />
268+
) : snapshot.totalVolume > 0n ? (
269+
formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7)
270+
) : (
271+
'0'
272+
)}
266273
</CardTitle>
267274
</CardHeader>
268275
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -272,7 +279,13 @@ export default function AnalyticsPage() {
272279
<Card>
273280
<CardHeader className="pb-2">
274281
<CardDescription>Active streams</CardDescription>
275-
<CardTitle className="text-2xl font-semibold">{snapshot.activeCount}</CardTitle>
282+
<CardTitle className="text-2xl font-semibold">
283+
{loading ? (
284+
<span className="inline-block h-7 w-12 animate-pulse rounded bg-muted" />
285+
) : (
286+
snapshot.activeCount
287+
)}
288+
</CardTitle>
276289
</CardHeader>
277290
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
278291
<TrendingUp className="size-4" /> Currently streaming now
@@ -281,7 +294,13 @@ export default function AnalyticsPage() {
281294
<Card>
282295
<CardHeader className="pb-2">
283296
<CardDescription>Total streams created</CardDescription>
284-
<CardTitle className="text-2xl font-semibold">{snapshot.totalStreams}</CardTitle>
297+
<CardTitle className="text-2xl font-semibold">
298+
{loading ? (
299+
<span className="inline-block h-7 w-12 animate-pulse rounded bg-muted" />
300+
) : (
301+
snapshot.totalStreams
302+
)}
303+
</CardTitle>
285304
</CardHeader>
286305
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
287306
<BarChart3 className="size-4" /> All-time stream count
@@ -291,7 +310,11 @@ export default function AnalyticsPage() {
291310
<CardHeader className="pb-2">
292311
<CardDescription>Average duration</CardDescription>
293312
<CardTitle className="text-2xl font-semibold">
294-
{snapshot.averageDurationDays.toFixed(1)}d
313+
{loading ? (
314+
<span className="inline-block h-7 w-14 animate-pulse rounded bg-muted" />
315+
) : (
316+
`${snapshot.averageDurationDays.toFixed(1)}d`
317+
)}
295318
</CardTitle>
296319
</CardHeader>
297320
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">

app/app/create/create-form.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,13 @@ export function CreateForm() {
371371
setRecipientInput(draft.recipient);
372372
},
373373
true,
374+
// Issue #676: surface draft-save failures instead of silently
375+
// discarding them.
376+
() =>
377+
toast.warning("Your draft isn't being saved", {
378+
description:
379+
"Storage is full or unavailable — your progress won't be restored if you leave this page.",
380+
}),
374381
);
375382

376383
// Check for existing draft on first mount
@@ -841,7 +848,19 @@ export function CreateForm() {
841848
)}
842849
</div>
843850

844-
{/* Issue #186: USD equivalent + per-second rate */}
851+
{/* Issue #186: USD equivalent + per-second rate.
852+
Issue #675: `priceLoading` used to be nested inside this
853+
block, but by the time `usdEquivalent` is truthy the price
854+
has already resolved (loading is always false here) — so
855+
it could never actually render. Show a distinct loading
856+
indicator whenever a price is being fetched and the user
857+
has entered an amount, separately from the "here's the
858+
USD value" case below. */}
859+
{priceLoading && tokenAmountNum > 0 && !usdInputMode && (
860+
<p className="text-xs text-muted-foreground opacity-60">
861+
Fetching price…
862+
</p>
863+
)}
845864
{usdEquivalent && !usdInputMode && (
846865
<p className="text-xs text-muted-foreground">
847866
≈ ${usdEquivalent} USD
@@ -860,9 +879,15 @@ export function CreateForm() {
860879
{selectedToken.symbol}/sec (≈ ${amountPerSecondUsd}/sec)
861880
</span>
862881
)}
863-
{priceLoading && (
864-
<span className="ml-1 opacity-60">Fetching price…</span>
865-
)}
882+
</p>
883+
)}
884+
{/* Issue #675: distinct from the loading state above — the
885+
price fetch has finished but no price is available for
886+
this token (not XLM/USDC/EURC, or the fetch failed with no
887+
cached fallback). */}
888+
{!priceLoading && !supportsUsd && tokenAmountNum > 0 && !usdInputMode && (
889+
<p className="text-xs text-muted-foreground">
890+
Price unavailable for {selectedToken.symbol}
866891
</p>
867892
)}
868893
{usdInputMode && form.amount && (

components/streams/stream-card.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function StreamCardInner({
6262
const interval = getInterval(stream)
6363
const now = useNow(interval)
6464
const { address } = useWallet()
65-
const { usdPrice } = useTokenPrice(stream.token.symbol)
65+
const { usdPrice, loading: priceLoading } = useTokenPrice(stream.token.symbol)
6666
const [showUsd] = useShowUsd()
6767
const isCancelling = useIsStreamCancelling(stream.id)
6868
const { isBlocked, hideStream, unhideStream, blockSender, unblockSender } = useHiddenStreams()
@@ -239,8 +239,15 @@ function StreamCardInner({
239239
className="text-lg font-semibold"
240240
maxFractionDigits={2}
241241
/>
242-
{usdValue !== null && (
243-
<p className="text-xs text-muted-foreground">{formatUsd(usdValue)}</p>
242+
{/* Issue #675: loading and unavailable previously rendered
243+
identically (nothing) — show a distinct skeleton while the
244+
price is still being fetched. */}
245+
{showUsd && usdValue === null && priceLoading ? (
246+
<div className="mt-0.5 h-3 w-12 animate-pulse rounded bg-muted" aria-label="Loading price" />
247+
) : (
248+
usdValue !== null && (
249+
<p className="text-xs text-muted-foreground">{formatUsd(usdValue)}</p>
250+
)
244251
)}
245252
</div>
246253
<div className="text-right">

components/webhooks/webhook-settings.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ const ALL_EVENTS: { value: WebhookEventType; label: string }[] = [
1919
]
2020

2121
export function WebhookSettings() {
22-
const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks()
22+
// Issue #677: surface webhook-config save failures instead of letting
23+
// localStorage.setItem throw uncaught / fail silently.
24+
const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks(
25+
() =>
26+
toast.warning("Webhook settings aren't being saved", {
27+
description: 'Storage is full or unavailable — your changes may not persist.',
28+
}),
29+
)
2330

2431
const [url, setUrl] = useState('')
2532
const [urlError, setUrlError] = useState('')

hooks/use-form-draft.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ export function useFormDraft<T>(
1414
value: T,
1515
onChange: (draft: T) => void,
1616
enabled = true,
17+
// Issue #676: draft-save failures (quota exceeded, private browsing) used
18+
// to be silently discarded — the caller can surface this however fits
19+
// (toast, inline notice, ...). Only fires once per failure streak, not on
20+
// every debounced save, so it stays a lightweight one-time warning rather
21+
// than spamming the user.
22+
onSaveError?: () => void,
1723
) {
1824
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1925
const isRestoringRef = useRef(false)
26+
const hasWarnedRef = useRef(false)
2027

2128
const storageKey = `flowstar_draft_${key}`
2229

@@ -25,11 +32,18 @@ export function useFormDraft<T>(
2532
try {
2633
const entry: DraftEntry<T> = { data, savedAt: Date.now() }
2734
localStorage.setItem(storageKey, JSON.stringify(entry))
35+
hasWarnedRef.current = false
2836
} catch {
29-
// storage quota exceeded or unavailable — silently skip
37+
// storage quota exceeded or unavailable — the draft itself is still
38+
// silently skipped (nothing else we can do), but the user is now
39+
// told their progress isn't being saved.
40+
if (!hasWarnedRef.current) {
41+
hasWarnedRef.current = true
42+
onSaveError?.()
43+
}
3044
}
3145
},
32-
[storageKey],
46+
[storageKey, onSaveError],
3347
)
3448

3549
const discard = useCallback(() => {

hooks/use-streams.ts

Lines changed: 41 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,13 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
5858
const [streams, setStreams] = useState<StreamData[]>([])
5959
const [loading, setLoading] = useState(false)
6060
const [isRefreshingAfterHidden, setIsRefreshingAfterHidden] = useState(false)
61+
// True while `streams` is serving the offline cache instead of a live fetch.
62+
const [stale, setStale] = useState(false)
63+
const [lastUpdated, setLastUpdated] = useState<number | null>(null)
6164

6265
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
6366
// Tracks whether the polling interval is currently running.
6467
const pollingActiveRef = useRef(false)
65-
66-
// True while `streams` is serving the offline cache instead of a live fetch.
67-
const [stale, setStale] = useState(false)
68-
const [lastUpdated, setLastUpdated] = useState<number | null>(null)
69-
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
7068
// Monotonically increasing request ID — any response whose ID doesn't
7169
// match the current value is from a stale request and is discarded.
7270
const requestIdRef = useRef(0)
@@ -96,19 +94,47 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
9694

9795
if (!address) {
9896
setStreams([])
97+
setStale(false)
98+
setLastUpdated(null)
9999
if (req === requestIdRef.current) setLoading(false)
100100
return
101101
}
102+
103+
// Offline (or the request will fail shortly): serve cached stream data
104+
// immediately with a stale indicator instead of an empty dashboard
105+
// (issue #150).
106+
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
107+
const cached = readCachedStreams(network, address)
108+
if (cached) {
109+
setStreams(cached.streams)
110+
setStale(true)
111+
setLastUpdated(cached.fetchedAt)
112+
}
113+
setLoading(false)
114+
return
115+
}
116+
102117
setLoading(true)
103118
try {
104119
const data = await fetchStreamsForAddress(network, address)
105120
// Discard if a newer request has already started.
106121
if (req !== requestIdRef.current) return
107122
setStreams(data)
123+
setStale(false)
124+
setLastUpdated(Date.now())
125+
writeCachedStreams(network, address, data)
108126
} catch (e) {
109127
if (req !== requestIdRef.current) return
110128
// Suppress errors from intentionally aborted requests.
111129
if (e instanceof DOMException && e.name === 'AbortError') return
130+
// Network-level failure (e.g. connectivity dropped mid-request) —
131+
// fall back to whatever we last cached rather than showing nothing.
132+
const cached = readCachedStreams(network, address)
133+
if (cached) {
134+
setStreams(cached.streams)
135+
setStale(true)
136+
setLastUpdated(cached.fetchedAt)
137+
}
112138
captureError(e, { operation: 'use-streams:fetch' })
113139
} finally {
114140
if (req === requestIdRef.current) setLoading(false)
@@ -137,52 +163,6 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
137163
if (pollIntervalRef.current) {
138164
clearInterval(pollIntervalRef.current)
139165
pollIntervalRef.current = null
140-
if (!address) {
141-
setStreams([])
142-
setStale(false)
143-
setLastUpdated(null)
144-
if (req === requestIdRef.current) setLoading(false)
145-
return
146-
}
147-
148-
// Offline (or the request will fail shortly): serve cached stream data
149-
// immediately with a stale indicator instead of an empty dashboard
150-
// (issue #150).
151-
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
152-
const cached = readCachedStreams(network, address)
153-
if (cached) {
154-
setStreams(cached.streams)
155-
setStale(true)
156-
setLastUpdated(cached.fetchedAt)
157-
}
158-
setLoading(false)
159-
return
160-
}
161-
162-
setLoading(true)
163-
try {
164-
const data = await fetchStreamsForAddress(network, address)
165-
// Discard if a newer request has already started.
166-
if (req !== requestIdRef.current) return
167-
setStreams(data)
168-
setStale(false)
169-
setLastUpdated(Date.now())
170-
writeCachedStreams(network, address, data)
171-
} catch (e) {
172-
if (req !== requestIdRef.current) return
173-
// Suppress errors from intentionally aborted requests.
174-
if (e instanceof DOMException && e.name === 'AbortError') return
175-
// Network-level failure (e.g. connectivity dropped mid-request) —
176-
// fall back to whatever we last cached rather than showing nothing.
177-
const cached = readCachedStreams(network, address)
178-
if (cached) {
179-
setStreams(cached.streams)
180-
setStale(true)
181-
setLastUpdated(cached.fetchedAt)
182-
}
183-
captureError(e, { operation: 'use-streams:fetch' })
184-
} finally {
185-
if (req === requestIdRef.current) setLoading(false)
186166
}
187167
pollingActiveRef.current = false
188168
}, [])
@@ -256,8 +236,16 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
256236
const sent = streams.filter((s) => s.sender === address)
257237
const received = streams.filter((s) => s.recipient === address)
258238

259-
return { all: streams, sent, received, loading, isRefreshingAfterHidden, refetch: fetch }
260-
return { all: streams, sent, received, loading, refetch: fetch, stale, lastUpdated }
239+
return {
240+
all: streams,
241+
sent,
242+
received,
243+
loading,
244+
isRefreshingAfterHidden,
245+
refetch: fetch,
246+
stale,
247+
lastUpdated,
248+
}
261249
}
262250

263251
export function useStream(id: string): {

0 commit comments

Comments
 (0)