Improve prefetch safety

This commit is contained in:
Jack Kingsman
2026-02-27 17:14:20 -08:00
parent 17f6a2b8c5
commit 57e6ba534a
8 changed files with 117 additions and 24 deletions
+28 -7
View File
@@ -20,14 +20,35 @@
<script>
// Start critical data fetches before React loads — shaves ~1-2s off startup.
// React hooks consume the promises via window.__prefetch.
var j = function(r) { return r.json(); };
var fetchJsonOrThrow = function(url) {
return fetch(url).then(function(response) {
if (response.ok) {
return response.json();
}
return response.text().then(function(rawError) {
var message = rawError || response.statusText || ('HTTP ' + response.status);
try {
var parsed = JSON.parse(rawError);
if (parsed && typeof parsed.detail === 'string' && parsed.detail.length > 0) {
message = parsed.detail;
}
} catch {
// Keep raw text fallback when body is not JSON.
}
throw new Error('Prefetch failed for ' + url + ': ' + message);
});
}).catch(function(err) {
console.error(err);
throw err;
});
};
window.__prefetch = {
config: fetch('/api/radio/config').then(j),
settings: fetch('/api/settings').then(j),
channels: fetch('/api/channels').then(j),
contacts: fetch('/api/contacts?limit=1000&offset=0').then(j),
unreads: fetch('/api/read-state/unreads').then(j),
undecryptedCount: fetch('/api/packets/undecrypted/count').then(j),
config: fetchJsonOrThrow('/api/radio/config'),
settings: fetchJsonOrThrow('/api/settings'),
channels: fetchJsonOrThrow('/api/channels'),
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
};
</script>
<script type="module" src="/src/main.tsx"></script>
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -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<Contact[]> => {
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;
+2 -2
View File
@@ -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);
+7 -8
View File
@@ -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<string, number>;
@@ -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 };
+24
View File
@@ -18,9 +18,33 @@ interface PrefetchMap {
const store: PrefetchMap = (window as unknown as { __prefetch?: PrefetchMap }).__prefetch ?? {};
type PrefetchResolved<K extends keyof PrefetchMap> =
PrefetchMap[K] extends Promise<infer T> ? T : never;
/** Take a prefetched promise (consumed once, then gone). */
export function takePrefetch<K extends keyof PrefetchMap>(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<K extends keyof PrefetchMap>(
key: K,
fallback: () => Promise<PrefetchResolved<K>>
): Promise<PrefetchResolved<K>> {
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();
}
}
+49
View File
@@ -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);
});
});