diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx
index 0008d06..ab70368 100644
--- a/app/app/create/create-form.tsx
+++ b/app/app/create/create-form.tsx
@@ -389,6 +389,13 @@ export function CreateForm() {
setRecipientInput(draft.recipient);
},
true,
+ // Issue #676: surface draft-save failures instead of silently
+ // discarding them.
+ () =>
+ toast.warning("Your draft isn't being saved", {
+ description:
+ "Storage is full or unavailable — your progress won't be restored if you leave this page.",
+ }),
);
// Check for existing draft on first mount
@@ -888,7 +895,19 @@ export function CreateForm() {
)}
- {/* Issue #186: USD equivalent + per-second rate */}
+ {/* Issue #186: USD equivalent + per-second rate.
+ Issue #675: `priceLoading` used to be nested inside this
+ block, but by the time `usdEquivalent` is truthy the price
+ has already resolved (loading is always false here) — so
+ it could never actually render. Show a distinct loading
+ indicator whenever a price is being fetched and the user
+ has entered an amount, separately from the "here's the
+ USD value" case below. */}
+ {priceLoading && tokenAmountNum > 0 && !usdInputMode && (
+
+ Fetching price…
+
+ )}
{usdEquivalent && !usdInputMode && (
{copy.amountSection.usdEquivalent(usdEquivalent)}
@@ -910,6 +929,15 @@ export function CreateForm() {
)}
)}
+
+ )}
+ {/* Issue #675: distinct from the loading state above — the
+ price fetch has finished but no price is available for
+ this token (not XLM/USDC/EURC, or the fetch failed with no
+ cached fallback). */}
+ {!priceLoading && !supportsUsd && tokenAmountNum > 0 && !usdInputMode && (
+
+ Price unavailable for {selectedToken.symbol}
{priceLoading && (
{copy.amountSection.fetchingPrice}
diff --git a/components/streams/stream-card.tsx b/components/streams/stream-card.tsx
index 1541cf1..8fe5641 100644
--- a/components/streams/stream-card.tsx
+++ b/components/streams/stream-card.tsx
@@ -62,7 +62,7 @@ function StreamCardInner({
const interval = getInterval(stream)
const now = useNow(interval)
const { address } = useWallet()
- const { usdPrice } = useTokenPrice(stream.token.symbol)
+ const { usdPrice, loading: priceLoading } = useTokenPrice(stream.token.symbol)
const [showUsd] = useShowUsd()
const isCancelling = useIsStreamCancelling(stream.id)
const { isBlocked, hideStream, unhideStream, blockSender, unblockSender } = useHiddenStreams()
@@ -239,8 +239,15 @@ function StreamCardInner({
className="text-lg font-semibold"
maxFractionDigits={2}
/>
- {usdValue !== null && (
- {formatUsd(usdValue)}
+ {/* Issue #675: loading and unavailable previously rendered
+ identically (nothing) — show a distinct skeleton while the
+ price is still being fetched. */}
+ {showUsd && usdValue === null && priceLoading ? (
+
+ ) : (
+ usdValue !== null && (
+ {formatUsd(usdValue)}
+ )
)}
diff --git a/components/webhooks/webhook-settings.tsx b/components/webhooks/webhook-settings.tsx
index 8ff2c18..bf53c89 100644
--- a/components/webhooks/webhook-settings.tsx
+++ b/components/webhooks/webhook-settings.tsx
@@ -19,7 +19,14 @@ const ALL_EVENTS: { value: WebhookEventType; label: string }[] = [
]
export function WebhookSettings() {
- const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks()
+ // Issue #677: surface webhook-config save failures instead of letting
+ // localStorage.setItem throw uncaught / fail silently.
+ const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks(
+ () =>
+ toast.warning("Webhook settings aren't being saved", {
+ description: 'Storage is full or unavailable — your changes may not persist.',
+ }),
+ )
const [url, setUrl] = useState('')
const [urlError, setUrlError] = useState('')
diff --git a/hooks/use-form-draft.ts b/hooks/use-form-draft.ts
index aaac4c6..4b157e5 100644
--- a/hooks/use-form-draft.ts
+++ b/hooks/use-form-draft.ts
@@ -14,9 +14,16 @@ export function useFormDraft(
value: T,
onChange: (draft: T) => void,
enabled = true,
+ // Issue #676: draft-save failures (quota exceeded, private browsing) used
+ // to be silently discarded — the caller can surface this however fits
+ // (toast, inline notice, ...). Only fires once per failure streak, not on
+ // every debounced save, so it stays a lightweight one-time warning rather
+ // than spamming the user.
+ onSaveError?: () => void,
) {
const debounceRef = useRef | null>(null)
const isRestoringRef = useRef(false)
+ const hasWarnedRef = useRef(false)
const storageKey = `flowstar_draft_${key}`
@@ -25,11 +32,18 @@ export function useFormDraft(
try {
const entry: DraftEntry = { data, savedAt: Date.now() }
localStorage.setItem(storageKey, JSON.stringify(entry))
+ hasWarnedRef.current = false
} catch {
- // storage quota exceeded or unavailable — silently skip
+ // storage quota exceeded or unavailable — the draft itself is still
+ // silently skipped (nothing else we can do), but the user is now
+ // told their progress isn't being saved.
+ if (!hasWarnedRef.current) {
+ hasWarnedRef.current = true
+ onSaveError?.()
+ }
}
},
- [storageKey],
+ [storageKey, onSaveError],
)
const discard = useCallback(() => {
diff --git a/hooks/use-webhooks.ts b/hooks/use-webhooks.ts
index 8df9388..27ba50e 100644
--- a/hooks/use-webhooks.ts
+++ b/hooks/use-webhooks.ts
@@ -1,6 +1,6 @@
'use client'
-import { useState, useCallback, useEffect } from 'react'
+import { useState, useCallback, useEffect, useRef } from 'react'
export type WebhookEventType =
| 'stream.created'
@@ -45,8 +45,18 @@ function loadWebhooks(): WebhookConfig[] {
}
}
-function saveWebhooks(hooks: WebhookConfig[]) {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks))
+// Issue #677: these used to call localStorage.setItem unguarded, which can
+// throw (quota exceeded, private browsing) and crash the settings UI
+// interaction that triggered it. Now wrapped in try/catch, matching
+// use-form-draft.ts's already-guarded pattern, and reports success so
+// callers can surface a warning instead of silently losing the write.
+function saveWebhooks(hooks: WebhookConfig[]): boolean {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks))
+ return true
+ } catch {
+ return false
+ }
}
function loadHistory(): WebhookDelivery[] {
@@ -58,8 +68,13 @@ function loadHistory(): WebhookDelivery[] {
}
}
-function saveHistory(history: WebhookDelivery[]) {
- localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)))
+function saveHistory(history: WebhookDelivery[]): boolean {
+ try {
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)))
+ return true
+ } catch {
+ return false
+ }
}
async function deliverWithRetry(
@@ -85,9 +100,27 @@ async function deliverWithRetry(
return { statusCode: null, success: false }
}
-export function useWebhooks() {
+export function useWebhooks(onSaveError?: () => void) {
const [webhooks, setWebhooks] = useState([])
const [history, setHistory] = useState([])
+ const hasWarnedRef = useRef(false)
+
+ // Issue #677: only warn once per failure streak, resetting as soon as a
+ // save succeeds again — a lightweight one-time notice, not a toast per
+ // keystroke/action while storage stays unavailable.
+ const reportSaveResult = useCallback(
+ (ok: boolean) => {
+ if (ok) {
+ hasWarnedRef.current = false
+ return
+ }
+ if (!hasWarnedRef.current) {
+ hasWarnedRef.current = true
+ onSaveError?.()
+ }
+ },
+ [onSaveError],
+ )
useEffect(() => {
setWebhooks(loadWebhooks())
@@ -104,26 +137,26 @@ export function useWebhooks() {
}
setWebhooks((prev) => {
const next = [...prev, hook]
- saveWebhooks(next)
+ reportSaveResult(saveWebhooks(next))
return next
})
- }, [])
+ }, [reportSaveResult])
const removeWebhook = useCallback((id: string) => {
setWebhooks((prev) => {
const next = prev.filter((h) => h.id !== id)
- saveWebhooks(next)
+ reportSaveResult(saveWebhooks(next))
return next
})
- }, [])
+ }, [reportSaveResult])
const toggleWebhook = useCallback((id: string) => {
setWebhooks((prev) => {
const next = prev.map((h) => (h.id === id ? { ...h, enabled: !h.enabled } : h))
- saveWebhooks(next)
+ reportSaveResult(saveWebhooks(next))
return next
})
- }, [])
+ }, [reportSaveResult])
const fireEvent = useCallback(
async (eventType: WebhookEventType, data: WebhookPayload) => {
@@ -140,12 +173,12 @@ export function useWebhooks() {
}
setHistory((prev) => {
const next = [delivery, ...prev]
- saveHistory(next)
+ reportSaveResult(saveHistory(next))
return next
})
}
},
- [webhooks]
+ [webhooks, reportSaveResult]
)
const testWebhook = useCallback(async (id: string): Promise => {