mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Improve prefetch safety
This commit is contained in:
+28
-7
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user