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);
+ });
+});