Add kind 0 profile IndexedDB cache to eliminate author loading skeletons
Profile events (kind 0) are now persisted in IndexedDB and hydrated into memory before React renders. useAuthor seeds TanStack Query with initialData from the cache so names, avatars, and metadata render instantly on repeat visits instead of showing skeletons. Cache writes happen from three sites: useAuthor (single profile fetch), useAuthors (batch fetch), and useFeed (community feed metadata prefetch). Only newer events overwrite older ones to prevent out-of-order downgrades. Staleness tiers match NIP-05: < 5 min fresh, 5 min - 7 d background refetch, > 7 d expired (skeleton until fresh data arrives).
This commit is contained in:
+28
-2
@@ -2,6 +2,8 @@ import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nos
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getProfileCached, setProfileCached } from '@/lib/profileCache';
|
||||
|
||||
/** Parse a kind-0 event into metadata + event, or return just the event on parse failure. */
|
||||
export function parseAuthorEvent(event: NostrEvent): { event: NostrEvent; metadata?: NostrMetadata } {
|
||||
try {
|
||||
@@ -12,9 +14,18 @@ export function parseAuthorEvent(event: NostrEvent): { event: NostrEvent; metada
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries older than this are not trusted at all — show a skeleton instead. */
|
||||
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
export function useAuthor(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Read cache synchronously so TanStack Query can skip the pending state.
|
||||
const cached = pubkey ? getProfileCached(pubkey) : undefined;
|
||||
|
||||
// Discard entries that are too old to trust.
|
||||
const usableCache = cached && (Date.now() - cached.lastFetched < MAX_CACHE_AGE) ? cached : undefined;
|
||||
|
||||
return useQuery<{ event?: NostrEvent; metadata?: NostrMetadata }>({
|
||||
queryKey: ['author', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -31,11 +42,26 @@ export function useAuthor(pubkey: string | undefined) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Persist to IndexedDB (fire-and-forget).
|
||||
void setProfileCached(event);
|
||||
|
||||
return parseAuthorEvent(event);
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: 1,
|
||||
|
||||
// Seed from IndexedDB cache so the first render already has data.
|
||||
// TanStack Query compares initialDataUpdatedAt against staleTime:
|
||||
// - < 5 min old → fresh, no network request
|
||||
// - 5 min – 7 d → renders cached value, background refetch
|
||||
// - > 7 d → usableCache is undefined, normal pending/skeleton
|
||||
...(usableCache
|
||||
? {
|
||||
initialData: parseAuthorEvent(usableCache.event),
|
||||
initialDataUpdatedAt: usableCache.lastFetched,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NostrEvent, type NostrMetadata } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { parseAuthorEvent } from '@/hooks/useAuthor';
|
||||
import { setProfileCached } from '@/lib/profileCache';
|
||||
|
||||
export interface AuthorData {
|
||||
pubkey: string;
|
||||
@@ -53,6 +54,8 @@ export function useAuthors(pubkeys: string[]) {
|
||||
authorMap.set(event.pubkey, { pubkey: event.pubkey, ...parsed });
|
||||
// Seed individual author cache
|
||||
queryClient.setQueryData(['author', event.pubkey], parsed);
|
||||
// Persist to IndexedDB (fire-and-forget)
|
||||
void setProfileCached(event);
|
||||
}
|
||||
|
||||
return authorMap;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseAuthorEvent } from './useAuthor';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { setProfileCached } from '@/lib/profileCache';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
@@ -127,6 +128,8 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
|
||||
if (!queryClient.getQueryData(['author', meta.pubkey])) {
|
||||
queryClient.setQueryData(['author', meta.pubkey], parseAuthorEvent(meta));
|
||||
}
|
||||
// Persist to IndexedDB (fire-and-forget)
|
||||
void setProfileCached(meta);
|
||||
}
|
||||
|
||||
// Build map of pubkey -> NIP-05 identifier
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// ============================================================================
|
||||
// Kind 0 Profile IndexedDB Cache
|
||||
//
|
||||
// Caches kind 0 profile events so repeat visits render author names, avatars,
|
||||
// and other metadata instantly instead of showing loading skeletons.
|
||||
// Each entry stores the raw NostrEvent plus a `lastFetched` timestamp so the
|
||||
// caller can decide when to re-check.
|
||||
// ============================================================================
|
||||
|
||||
const getDBName = () => {
|
||||
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'default';
|
||||
return `nostr-profile-cache-${hostname}`;
|
||||
};
|
||||
|
||||
const DB_NAME = getDBName();
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'profiles';
|
||||
|
||||
export interface ProfileCacheEntry {
|
||||
/** Hex pubkey of the profile author */
|
||||
pubkey: string;
|
||||
/** The raw kind 0 NostrEvent */
|
||||
event: NostrEvent;
|
||||
/** Unix-ms timestamp of the last successful fetch */
|
||||
lastFetched: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory mirror — hydrated once from IndexedDB so React hooks can read
|
||||
// synchronously on first render (no async waterfall).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const memoryCache = new Map<string, ProfileCacheEntry>();
|
||||
let hydrated = false;
|
||||
let hydratePromise: Promise<void> | null = null;
|
||||
|
||||
/** Ensure the in-memory mirror is populated. Safe to call many times. */
|
||||
export function hydrateProfileCache(): Promise<void> {
|
||||
if (hydrated) return Promise.resolve();
|
||||
if (hydratePromise) return hydratePromise;
|
||||
|
||||
hydratePromise = (async () => {
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
const entries: ProfileCacheEntry[] = await db.getAll(STORE_NAME);
|
||||
for (const entry of entries) {
|
||||
memoryCache.set(entry.pubkey, entry);
|
||||
}
|
||||
} catch {
|
||||
// IndexedDB unavailable (e.g. private browsing) — silently degrade.
|
||||
} finally {
|
||||
hydrated = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return hydratePromise;
|
||||
}
|
||||
|
||||
/** Read a cached profile synchronously from the in-memory mirror. */
|
||||
export function getProfileCached(pubkey: string): ProfileCacheEntry | undefined {
|
||||
return memoryCache.get(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a kind 0 profile event.
|
||||
* Only writes if the event is newer than (or equal to) what we already have,
|
||||
* so out-of-order arrivals don't downgrade the cache.
|
||||
* Updates both the in-memory mirror and IndexedDB.
|
||||
*/
|
||||
export async function setProfileCached(event: NostrEvent): Promise<void> {
|
||||
const existing = memoryCache.get(event.pubkey);
|
||||
if (existing && existing.event.created_at > event.created_at) {
|
||||
return; // Don't overwrite a newer event with an older one.
|
||||
}
|
||||
|
||||
const entry: ProfileCacheEntry = {
|
||||
pubkey: event.pubkey,
|
||||
event,
|
||||
lastFetched: Date.now(),
|
||||
};
|
||||
|
||||
memoryCache.set(event.pubkey, entry);
|
||||
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
await db.put(STORE_NAME, entry, event.pubkey);
|
||||
} catch {
|
||||
// Write failure is non-critical — the in-memory cache still works.
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a single profile entry. */
|
||||
export async function deleteProfileCached(pubkey: string): Promise<void> {
|
||||
memoryCache.delete(pubkey);
|
||||
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
await db.delete(STORE_NAME, pubkey);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the entire profile cache. */
|
||||
export async function clearProfileCache(): Promise<void> {
|
||||
memoryCache.clear();
|
||||
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
await db.clear(STORE_NAME);
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function openDatabase(): Promise<IDBPDatabase> {
|
||||
return openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
+3
-1
@@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client';
|
||||
// Import polyfills first
|
||||
import './lib/polyfills.ts';
|
||||
|
||||
// Kick off NIP-05 cache hydration early so it's ready before components render.
|
||||
// Kick off cache hydration early so data is ready before components render.
|
||||
import { hydrateNip05Cache } from '@/lib/nip05Cache';
|
||||
import { hydrateProfileCache } from '@/lib/profileCache';
|
||||
hydrateNip05Cache();
|
||||
hydrateProfileCache();
|
||||
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import App from './App.tsx';
|
||||
|
||||
Reference in New Issue
Block a user