diff --git a/frontend/index.html b/frontend/index.html index 03c440a..92bb1ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,14 +20,35 @@ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b27ef1..469f49b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ import { Suspense, } from 'react'; import { api } from './api'; -import { takePrefetch } from './prefetch'; +import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useUnreadCounts, @@ -319,7 +319,7 @@ export function App() { fetchUndecryptedCount(); // Fetch contacts and channels via REST (parallel, faster than WS serial push) - (takePrefetch('channels') ?? api.getChannels()).then(setChannels).catch(console.error); + takePrefetchOrFetch('channels', api.getChannels).then(setChannels).catch(console.error); fetchAllContacts() .then((data) => { setContacts(data); diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 89b4fed..7969934 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { api } from '../api'; -import { takePrefetch } from '../prefetch'; +import { takePrefetchOrFetch } from '../prefetch'; import { toast } from '../components/ui/sonner'; import { initLastMessageTimes, @@ -27,7 +27,7 @@ export function useAppSettings() { const fetchAppSettings = useCallback(async () => { try { - const data = await (takePrefetch('settings') ?? api.getSettings()); + const data = await takePrefetchOrFetch('settings', api.getSettings); setAppSettings(data); initLastMessageTimes(data.last_message_times ?? {}); } catch (err) { diff --git a/frontend/src/hooks/useContactsAndChannels.ts b/frontend/src/hooks/useContactsAndChannels.ts index f147d32..ac84f66 100644 --- a/frontend/src/hooks/useContactsAndChannels.ts +++ b/frontend/src/hooks/useContactsAndChannels.ts @@ -1,6 +1,6 @@ import { useState, useCallback, type MutableRefObject } from 'react'; import { api } from '../api'; -import { takePrefetch } from '../prefetch'; +import { takePrefetchOrFetch } from '../prefetch'; import { toast } from '../components/ui/sonner'; import * as messageCache from '../messageCache'; import { getContactDisplayName } from '../utils/pubkey'; @@ -26,7 +26,7 @@ export function useContactsAndChannels({ const fetchUndecryptedCountInternal = useCallback(async () => { try { - const data = await (takePrefetch('undecryptedCount') ?? api.getUndecryptedPacketCount()); + const data = await takePrefetchOrFetch('undecryptedCount', api.getUndecryptedPacketCount); setUndecryptedCount(data.count); } catch (err) { console.error('Failed to fetch undecrypted count:', err); @@ -36,7 +36,7 @@ export function useContactsAndChannels({ // Fetch all contacts, paginating if >1000 const fetchAllContacts = useCallback(async (): Promise => { const pageSize = 1000; - const first = await (takePrefetch('contacts') ?? api.getContacts(pageSize, 0)); + const first = await takePrefetchOrFetch('contacts', () => api.getContacts(pageSize, 0)); if (first.length < pageSize) return first; let all = [...first]; let offset = pageSize; diff --git a/frontend/src/hooks/useRadioControl.ts b/frontend/src/hooks/useRadioControl.ts index c63fc19..265402a 100644 --- a/frontend/src/hooks/useRadioControl.ts +++ b/frontend/src/hooks/useRadioControl.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { api } from '../api'; -import { takePrefetch } from '../prefetch'; +import { takePrefetchOrFetch } from '../prefetch'; import { toast } from '../components/ui/sonner'; import type { HealthStatus, RadioConfig, RadioConfigUpdate } from '../types'; @@ -20,7 +20,7 @@ export function useRadioControl() { const fetchConfig = useCallback(async () => { try { - const data = await (takePrefetch('config') ?? api.getRadioConfig()); + const data = await takePrefetchOrFetch('config', api.getRadioConfig); setConfig(data); } catch (err) { console.error('Failed to fetch config:', err); diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 34eee7a..2f9f8a2 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -7,7 +7,7 @@ import { type ConversationTimes, } from '../utils/conversationState'; import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types'; -import { takePrefetch } from '../prefetch'; +import { takePrefetchOrFetch } from '../prefetch'; interface UseUnreadCountsResult { unreadCounts: Record; @@ -59,13 +59,12 @@ export function useUnreadCounts( const contactsLen = contacts.length; const prevLens = useRef({ channels: 0, contacts: 0 }); useEffect(() => { - const prefetched = takePrefetch('unreads'); - if (prefetched) { - prefetched.then(applyUnreads).catch(() => fetchUnreads()); - } else { - fetchUnreads(); - } - }, [fetchUnreads, applyUnreads]); + takePrefetchOrFetch('unreads', api.getUnreads) + .then(applyUnreads) + .catch((err) => { + console.error('Failed to fetch unreads:', err); + }); + }, [applyUnreads]); useEffect(() => { const prev = prevLens.current; prevLens.current = { channels: channelsLen, contacts: contactsLen }; diff --git a/frontend/src/prefetch.ts b/frontend/src/prefetch.ts index d437852..3bb55d2 100644 --- a/frontend/src/prefetch.ts +++ b/frontend/src/prefetch.ts @@ -18,9 +18,33 @@ interface PrefetchMap { const store: PrefetchMap = (window as unknown as { __prefetch?: PrefetchMap }).__prefetch ?? {}; +type PrefetchResolved = + PrefetchMap[K] extends Promise ? T : never; + /** Take a prefetched promise (consumed once, then gone). */ export function takePrefetch(key: K): PrefetchMap[K] { const p = store[key]; delete store[key]; return p; } + +/** + * Use prefetched data when available. If prefetch failed or was absent, run + * the provided fallback fetcher. + */ +export async function takePrefetchOrFetch( + key: K, + fallback: () => Promise> +): Promise> { + const prefetched = takePrefetch(key); + if (!prefetched) { + return fallback(); + } + + try { + return await prefetched; + } catch (err) { + console.warn(`Prefetch for "${String(key)}" failed, falling back to live fetch.`, err); + return fallback(); + } +} diff --git a/frontend/src/test/prefetch.test.ts b/frontend/src/test/prefetch.test.ts new file mode 100644 index 0000000..7a3a321 --- /dev/null +++ b/frontend/src/test/prefetch.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +interface PrefetchWindow extends Window { + __prefetch?: unknown; +} + +function setPrefetchStore(store: unknown) { + (window as PrefetchWindow).__prefetch = store; +} + +describe('takePrefetchOrFetch', () => { + beforeEach(() => { + vi.resetModules(); + delete (window as PrefetchWindow).__prefetch; + vi.restoreAllMocks(); + }); + + it('uses prefetched data once, then falls back', async () => { + setPrefetchStore({ + undecryptedCount: Promise.resolve({ count: 7 }), + }); + + const { takePrefetchOrFetch } = await import('../prefetch'); + const fallback = vi.fn().mockResolvedValue({ count: 9 }); + + await expect(takePrefetchOrFetch('undecryptedCount', fallback)).resolves.toEqual({ count: 7 }); + expect(fallback).not.toHaveBeenCalled(); + + await expect(takePrefetchOrFetch('undecryptedCount', fallback)).resolves.toEqual({ count: 9 }); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('falls back when prefetched promise rejects', async () => { + const prefetchedFailure = Promise.reject(new Error('prefetch failed')); + // Avoid unhandled rejection noise while the helper awaits the same promise. + prefetchedFailure.catch(() => undefined); + setPrefetchStore({ + undecryptedCount: prefetchedFailure, + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { takePrefetchOrFetch } = await import('../prefetch'); + const fallback = vi.fn().mockResolvedValue({ count: 11 }); + + await expect(takePrefetchOrFetch('undecryptedCount', fallback)).resolves.toEqual({ count: 11 }); + expect(fallback).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +});