rename to client
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
# Cache-First Page Implementation Patterns
|
||||
|
||||
## Lessons Learned & Best Practices for client Pages
|
||||
|
||||
---
|
||||
|
||||
## The Core Problem
|
||||
|
||||
Every page in this app communicates with Nostr relays through a shared Web Worker via `init-ndk.mjs`. Relay responses are inherently slow and unreliable — they may take seconds, time out after 15s, or never arrive at all. If page initialization `await`s relay fetches, the user stares at a blank screen.
|
||||
|
||||
Meanwhile, NDK maintains an IndexedDB cache (via Dexie) that contains previously-seen events. This cache is local and fast (sub-100ms). **Pages must render from cache first, then hydrate from relays in the background.**
|
||||
|
||||
---
|
||||
|
||||
## The Three Data APIs
|
||||
|
||||
### 1. `queryCache(filters)` — Local IndexedDB only (FAST)
|
||||
- **Timeout**: 5 seconds
|
||||
- **Source**: Dexie/IndexedDB only, no network
|
||||
- **Use for**: Initial page render, instant UI population
|
||||
- **Import**: `import { queryCache } from './js/init-ndk.mjs'`
|
||||
|
||||
### 2. `ndkFetchEvents(filters)` — Relay fetch (SLOW)
|
||||
- **Timeout**: 15 seconds
|
||||
- **Source**: Nostr relays via NDK in the worker
|
||||
- **Use for**: Background hydration after cache render
|
||||
- **Import**: `import { ndkFetchEvents } from './js/init-ndk.mjs'`
|
||||
- **WARNING**: Never `await` this in the critical render path if cache data exists
|
||||
|
||||
### 3. `subscribe(filters, opts)` — Live streaming (ONGOING)
|
||||
- **No timeout**: Stays open, delivers events via `window 'ndkEvent'` events
|
||||
- **Source**: Cache first (if `cacheUsage: 'CACHE_FIRST'`), then relays
|
||||
- **Use for**: Live updates after initial render
|
||||
- **Import**: `import { subscribe } from './js/init-ndk.mjs'`
|
||||
|
||||
---
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
```
|
||||
NEVER await a relay call (ndkFetchEvents) in the critical render path
|
||||
if you can get data from cache first.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Page Loading Pattern
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Page
|
||||
participant Cache as queryCache - IndexedDB
|
||||
participant Relay as ndkFetchEvents - Relays
|
||||
participant Sub as subscribe - Live
|
||||
|
||||
Page->>Cache: queryCache filters
|
||||
Cache-->>Page: cached events - instant
|
||||
Page->>Page: render UI from cache
|
||||
|
||||
Page->>Relay: void ndkFetchEvents filters
|
||||
Note over Relay: fire-and-forget, no await
|
||||
Relay-->>Page: relay events - eventually
|
||||
Page->>Page: merge and re-render if newer
|
||||
|
||||
Page->>Sub: subscribe filters, CACHE_FIRST
|
||||
Note over Sub: stays open for live updates
|
||||
Sub-->>Page: ndkEvent window events
|
||||
Page->>Page: upsert and re-render
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Template
|
||||
|
||||
### Step 1: Import both `queryCache` and `ndkFetchEvents`
|
||||
|
||||
```javascript
|
||||
import {
|
||||
initNDKPage,
|
||||
getPubkey,
|
||||
queryCache,
|
||||
ndkFetchEvents,
|
||||
subscribe,
|
||||
// ... other imports
|
||||
} from './js/init-ndk.mjs';
|
||||
```
|
||||
|
||||
### Step 2: Cache-first data loading function
|
||||
|
||||
```javascript
|
||||
async function loadPageData() {
|
||||
const pubkey = await getPubkey();
|
||||
|
||||
// Phase 1: Cache (blocking — but fast)
|
||||
let items = [];
|
||||
try {
|
||||
const cached = await queryCache({
|
||||
kinds: [MY_KIND],
|
||||
authors: [pubkey],
|
||||
limit: 50
|
||||
});
|
||||
items = Array.isArray(cached) ? cached : [];
|
||||
renderItems(items);
|
||||
console.log('[my-page] Rendered from cache:', items.length);
|
||||
} catch (err) {
|
||||
console.warn('[my-page] Cache query failed:', err?.message);
|
||||
}
|
||||
|
||||
// Phase 2: Relay hydration (non-blocking — fire and forget)
|
||||
void ndkFetchEvents({
|
||||
kinds: [MY_KIND],
|
||||
authors: [pubkey],
|
||||
limit: 50
|
||||
}).then((relayEvents) => {
|
||||
if (Array.isArray(relayEvents) && relayEvents.length > 0) {
|
||||
// Merge with existing items, dedupe by id
|
||||
const merged = mergeAndDedupe(items, relayEvents);
|
||||
renderItems(merged);
|
||||
console.log('[my-page] Hydrated from relays:', relayEvents.length);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[my-page] Relay hydration failed:', err?.message);
|
||||
});
|
||||
|
||||
// Phase 3: Live subscription for ongoing updates
|
||||
subscribe(
|
||||
{ kinds: [MY_KIND], authors: [pubkey] },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Handle live events via ndkEvent listener
|
||||
|
||||
```javascript
|
||||
window.addEventListener('ndkEvent', async (event) => {
|
||||
const evt = event.detail;
|
||||
if (!evt || evt.kind !== MY_KIND) return;
|
||||
// Upsert into your data structure and re-render
|
||||
upsertItem(evt);
|
||||
renderItems(getAllItems());
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Awaiting relay fetch in main()
|
||||
|
||||
```javascript
|
||||
// BAD — blocks page for up to 15 seconds
|
||||
async function main() {
|
||||
const events = await ndkFetchEvents({ kinds: [1], authors: [pubkey] });
|
||||
renderFeed(events);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — render from cache, hydrate in background
|
||||
async function main() {
|
||||
const cached = await queryCache({ kinds: [1], authors: [pubkey] });
|
||||
renderFeed(cached);
|
||||
void ndkFetchEvents({ kinds: [1], authors: [pubkey] }).then(renderFeed);
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern 2: Sequential relay fetches in a loop
|
||||
|
||||
```javascript
|
||||
// BAD — each iteration blocks for up to 15 seconds
|
||||
for (const item of items) {
|
||||
const refs = await ndkFetchEvents({ ids: [item.refId] });
|
||||
item.image = extractImage(refs[0]);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — try cache first, relay in background
|
||||
for (const item of items) {
|
||||
let refs = [];
|
||||
try { refs = await queryCache({ ids: [item.refId] }); } catch (_) {}
|
||||
|
||||
const ref = refs.find(r => r.id === item.refId);
|
||||
item.image = ref ? extractImage(ref) : '';
|
||||
|
||||
if (!ref) {
|
||||
// Fire-and-forget relay lookup
|
||||
void ndkFetchEvents({ ids: [item.refId] }).then((relayRefs) => {
|
||||
const relayRef = relayRefs?.find(r => r.id === item.refId);
|
||||
if (relayRef) {
|
||||
item.image = extractImage(relayRef);
|
||||
// Re-render if needed
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern 3: Awaiting relay fetch for a single event by ID
|
||||
|
||||
```javascript
|
||||
// BAD — blocks detail page for up to 15 seconds
|
||||
if (isEventMode) {
|
||||
const events = await ndkFetchEvents({ ids: [targetEventId], limit: 1 });
|
||||
renderSingleEvent(events[0]);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — render from cache instantly, relay hydrates in background
|
||||
if (isEventMode) {
|
||||
let matched = null;
|
||||
try {
|
||||
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
|
||||
matched = Array.isArray(cached) ? cached.find(e => e?.id === targetEventId) : null;
|
||||
} catch (_) {}
|
||||
|
||||
if (matched) renderSingleEvent(matched);
|
||||
|
||||
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then(relayEvents => {
|
||||
const relayMatch = Array.isArray(relayEvents)
|
||||
? relayEvents.find(e => e?.id === targetEventId) : null;
|
||||
if (relayMatch) renderSingleEvent(relayMatch);
|
||||
}).catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern 4: Embedded content hydration using only relays
|
||||
|
||||
When a rendered post contains `nostr:nevent1...` references, the hydration code
|
||||
fetches the referenced event to display an inline preview. This is a secondary
|
||||
fetch triggered *after* the main content renders — easy to miss.
|
||||
|
||||
```javascript
|
||||
// BAD — embedded note hydration blocks on relay, shows "Failed to fetch" on timeout
|
||||
const events = await ndkFetchEventsFn(filter);
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — try cache first, fall back to relay
|
||||
let events = [];
|
||||
if (queryCacheFn) {
|
||||
try { events = await queryCacheFn(filter); } catch (_) {}
|
||||
}
|
||||
if (!events || events.length === 0) {
|
||||
events = await ndkFetchEventsFn(filter);
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern 5: Interaction data fetched only from relays
|
||||
|
||||
Social interaction queries — likes, reposts, zaps, comments — use `#e` tag
|
||||
filters. These should also be cache-first so the interaction bar populates
|
||||
instantly on page refresh.
|
||||
|
||||
```javascript
|
||||
// BAD — interaction counts blank for 15 seconds
|
||||
const allEvents = await ndkFetchEvents(filters);
|
||||
applyInteractions(allEvents);
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — show cached counts immediately, relay updates in background
|
||||
let hadCache = false;
|
||||
if (queryCacheFn) {
|
||||
try {
|
||||
const cached = await queryCacheFn(filters);
|
||||
hadCache = applyInteractions(cached, 'cache');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (hadCache) {
|
||||
void ndkFetchEvents(filters).then(relay => applyInteractions(relay, 'relays')).catch(() => {});
|
||||
} else {
|
||||
const relay = await ndkFetchEvents(filters);
|
||||
applyInteractions(relay, 'relays');
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern 6: Module loading that blocks on relays
|
||||
|
||||
```javascript
|
||||
// BAD — configureMuteList + loadMuteList blocks if relay is slow
|
||||
configureMuteList({ ndkFetchEvents, getPubkey });
|
||||
await loadMuteList(); // blocks for 15s if no cache
|
||||
|
||||
// GOOD — pass queryCache so cache loads instantly, relay hydrates in background
|
||||
configureMuteList({ ndkFetchEvents, queryCache, getPubkey });
|
||||
await loadMuteList(); // returns instantly from cache, relays fire-and-forget
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Configuration Checklist
|
||||
|
||||
When configuring shared modules like `mute-list.mjs`, always pass **both** `queryCache` and `ndkFetchEvents`:
|
||||
|
||||
```javascript
|
||||
configureMuteList({
|
||||
ndkFetchEvents,
|
||||
queryCache, // <-- REQUIRED for cache-first loading
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
nip44Encrypt: ...,
|
||||
nip44Decrypt: ...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profile Resolution
|
||||
|
||||
The `profile-cache.mjs` module already implements cache-first patterns internally. When creating a profile cache instance, pass `queryCache`:
|
||||
|
||||
```javascript
|
||||
const profileCache = createProfileCache({
|
||||
fetchCachedProfile,
|
||||
ndkFetchEvents,
|
||||
storeProfile,
|
||||
queryCache, // <-- enables cache-first profile lookups
|
||||
refreshIntervalMs: 20 * 60 * 1000
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeout Reference
|
||||
|
||||
| API | Timeout | Blocking? |
|
||||
|-----|---------|-----------|
|
||||
| `queryCache()` | 5s | Yes (but fast — local IndexedDB) |
|
||||
| `ndkFetchEvents()` | 15s | **Only if you await it** |
|
||||
| `subscribe()` | None | No (event-driven) |
|
||||
| `publishEvent()` | 15s | Yes (must await for confirmation) |
|
||||
|
||||
---
|
||||
|
||||
## Worker Request Latency Pattern (Critical for Wallet Actions)
|
||||
|
||||
Some UI actions call worker RPCs via `sendWorkerRequest()` and have strict front-end timeouts (for example, 45s for `walletSendNutzap`, 90s for `walletPayInvoice`).
|
||||
|
||||
If a worker handler does both:
|
||||
1. **critical state updates** (required for user-visible success), and
|
||||
2. **slow relay-side writes** (proof republish, history events, cleanup),
|
||||
|
||||
then awaiting both in sequence can cause front-end timeout errors even when the payment actually succeeded.
|
||||
|
||||
### Anti-Pattern 7: Responding after non-critical relay writes
|
||||
|
||||
```javascript
|
||||
// BAD — response blocked behind slow relay publishing
|
||||
await publishDirectProofs();
|
||||
await publishSpendingHistoryEvent(tx);
|
||||
port.postMessage({ type: 'response', requestId, data: successPayload });
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GOOD — respond after critical path, background the rest
|
||||
const payload = getWalletBalancePayload();
|
||||
port.postMessage({ type: 'response', requestId, data: successPayload });
|
||||
broadcast({ type: 'walletBalanceUpdated', data: payload });
|
||||
|
||||
void (async () => {
|
||||
try { await publishDirectProofs(); } catch (e) { console.warn(e); }
|
||||
try { await publishSpendingHistoryEvent(tx); } catch (e) { console.warn(e); }
|
||||
})();
|
||||
```
|
||||
|
||||
### Rule of thumb for worker handlers
|
||||
|
||||
- **Await only critical path steps** needed to determine success/failure for the request.
|
||||
- **Respond immediately** once local state is coherent.
|
||||
- **Background non-critical relay writes** with `void` async blocks and warning logs.
|
||||
- **Do not hide failures silently**: warn in logs so reconciliation/debug remains possible.
|
||||
|
||||
This is now the expected pattern for latency-sensitive wallet operations like `walletPayInvoice` and `walletSendNutzap`.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Threading Checklist
|
||||
|
||||
When a page passes dependencies to a module, and that module passes them to a
|
||||
sub-module, `queryCache` must be threaded through **every layer**. Missing it at
|
||||
any hop means the inner module falls back to relay-only fetches.
|
||||
|
||||
```
|
||||
page.html → post-interactions2.mjs → post-interactions.mjs
|
||||
queryCache ✅ queryCache ✅ queryCacheFn ✅
|
||||
```
|
||||
|
||||
**Rule**: If you add `queryCache` to a page's dependency object, grep for every
|
||||
module in the chain that destructures and forwards those deps. Each one must
|
||||
explicitly destructure and pass `queryCache` through.
|
||||
|
||||
```javascript
|
||||
// post-interactions2.mjs — MUST destructure and forward queryCache
|
||||
export function initPostCards(deps = {}) {
|
||||
const { ndkFetchEvents, queryCache, /* ... */ } = deps;
|
||||
|
||||
const interactions = initInteractions({
|
||||
ndkFetchEvents,
|
||||
queryCache, // <-- MUST be forwarded here
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// post-interactions.mjs — MUST accept and store queryCache
|
||||
let queryCacheFn = null;
|
||||
|
||||
export function initInteractions(ndkFunctions = {}) {
|
||||
const { ndkFetchEvents, queryCache, /* ... */ } = ndkFunctions;
|
||||
queryCacheFn = typeof queryCache === 'function' ? queryCache : null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Render & Decrypt Performance Lessons (from msg.html, v0.4.20–v0.4.23)
|
||||
|
||||
These lessons were learned during the messaging page optimization cycle. They
|
||||
apply to any page that does expensive async work (decryption, signing, heavy
|
||||
DOM rendering) triggered by event-driven callbacks.
|
||||
|
||||
### Lesson 1: Measure first, then optimize
|
||||
|
||||
Add lightweight `performance.now()` instrumentation to every phase of your
|
||||
page's critical path. Without timing data, you will guess wrong about where
|
||||
the bottleneck is. In `msg.html`, we added a rolling perf report system
|
||||
(`startMsgPerfReport` / `markMsgPerf` / `finalizeMsgPerfReport`) that logs
|
||||
step-by-step timing to the console and exposes `window.__msgPerfReports` for
|
||||
programmatic inspection. This immediately revealed that 63 seconds of wall
|
||||
time was spent in eager decrypt — not in cache queries or DOM rendering.
|
||||
|
||||
### Lesson 2: Expensive async render functions need single-flight guards
|
||||
|
||||
If `renderThread()` (or any expensive async render function) can be called
|
||||
from multiple event-driven paths — live subscription events, profile fetches,
|
||||
gift-wrap processing, relay hydration callbacks — concurrent invocations will
|
||||
pile up. Each one does the same expensive work (decrypt, DOM build), and all
|
||||
but the last one get thrown away.
|
||||
|
||||
**Pattern**: Wrap the render function in a single-flight coalescing guard:
|
||||
|
||||
```javascript
|
||||
let renderInFlight = null;
|
||||
let renderQueued = false;
|
||||
|
||||
async function renderThread() {
|
||||
if (renderInFlight) {
|
||||
renderQueued = true;
|
||||
return renderInFlight;
|
||||
}
|
||||
renderInFlight = (async () => {
|
||||
do {
|
||||
renderQueued = false;
|
||||
await renderThreadInner();
|
||||
} while (renderQueued);
|
||||
})().finally(() => { renderInFlight = null; });
|
||||
return renderInFlight;
|
||||
}
|
||||
```
|
||||
|
||||
This collapses N concurrent calls into at most 2 executions (current + one
|
||||
queued rerun).
|
||||
|
||||
### Lesson 3: Deduplicate in-flight async work, not just completed results
|
||||
|
||||
Caching the *result* of an async operation (e.g., `event._decodedKind4`) is
|
||||
not enough if multiple callers start the operation before the first one
|
||||
finishes. You must also deduplicate the *in-flight promise* itself.
|
||||
|
||||
**Pattern**: Use a `Map` keyed by operation identity to store the active
|
||||
promise:
|
||||
|
||||
```javascript
|
||||
const inFlightDecrypts = new Map();
|
||||
|
||||
async function decryptEvent(event, counterparty) {
|
||||
if (event._decoded) return event._decoded;
|
||||
|
||||
const key = event.id + ':' + counterparty;
|
||||
if (inFlightDecrypts.has(key)) return inFlightDecrypts.get(key);
|
||||
|
||||
const promise = doActualDecrypt(event, counterparty)
|
||||
.finally(() => inFlightDecrypts.delete(key));
|
||||
inFlightDecrypts.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
```
|
||||
|
||||
In `msg.html`, this reduced 290 decrypt calls (29 renders × 10 messages)
|
||||
down to 10.
|
||||
|
||||
### Lesson 4: Status text should reflect progress, not just phase entry
|
||||
|
||||
Showing "Decrypting recent messages… 10" and then going silent for 60 seconds
|
||||
is worse than showing nothing — it looks frozen. Update the status on every
|
||||
iteration: "Decrypting recent messages… 3/10". This gives the user confidence
|
||||
that work is happening.
|
||||
|
||||
### Lesson 5: Audit every call site that triggers re-render
|
||||
|
||||
Non-awaited render calls from places like `ensureProfileName()` and
|
||||
`processKind4Event()` can fire dozens of times during background hydration.
|
||||
Each one triggers a full render cycle. Treat render triggers like network
|
||||
calls: audit them, guard them, and suppress them during batch operations
|
||||
(e.g., using a `suppressUiRefresh` flag).
|
||||
|
||||
### Lesson 6: "Small" per-item latency explodes in serial loops
|
||||
|
||||
A 6-second decrypt call seems tolerable for one message. But 10 messages in a
|
||||
serial loop = 60 seconds. And if that loop runs 29 times concurrently due to
|
||||
a render storm, the user waits over a minute. Always consider the
|
||||
multiplicative effect of per-item costs × loop iterations × concurrent
|
||||
invocations.
|
||||
|
||||
### Lesson 7: Cache-first is necessary but not sufficient
|
||||
|
||||
Even after making `hydrateConversationHistory()` cache-first (rendering from
|
||||
cache before relay hydration), the page still stalled because the *render
|
||||
path itself* contained expensive blocking work (decrypt). Cache-first solves
|
||||
the data-fetch bottleneck; you still need to address compute bottlenecks in
|
||||
the render pipeline.
|
||||
|
||||
### Lesson 8: Keep perf tooling in production-safe form
|
||||
|
||||
Lightweight timing helpers that log to `console.groupCollapsed` and store
|
||||
results in a bounded array are cheap enough to ship. They cost nothing when
|
||||
nobody opens DevTools, and they save hours when debugging the next regression.
|
||||
|
||||
---
|
||||
|
||||
## Summary Rules
|
||||
|
||||
1. **Always import `queryCache`** alongside `ndkFetchEvents` in every page
|
||||
2. **Render from cache first** — `await queryCache()` is safe to block on (fast, local)
|
||||
3. **Never await `ndkFetchEvents` in the render path** — use `void` for fire-and-forget
|
||||
4. **Use `subscribe` with `cacheUsage: 'CACHE_FIRST'`** for live updates
|
||||
5. **Pass `queryCache` to all module configurations** (mute-list, profile-cache, etc.)
|
||||
6. **Thread `queryCache` through every dependency hop** — page → wrapper → inner module
|
||||
7. **Apply cache-first to secondary fetches** — embedded notes, interaction data, not just main content
|
||||
8. **Avoid sequential relay fetches in loops** — batch or use cache-first per item
|
||||
9. **Always handle relay timeouts gracefully** — the cache result is good enough for initial render
|
||||
10. **Guard expensive async render functions with single-flight coalescing** — prevent render storms
|
||||
11. **Deduplicate in-flight async work** — cache the promise, not just the result
|
||||
12. **Audit all re-render trigger sites** — suppress during batch operations
|
||||
13. **Show incremental progress in status text** — update per-item, not per-phase
|
||||
14. **For worker RPC handlers, respond before non-critical relay writes** — background republish/history/cleanup work with warning logs
|
||||
Reference in New Issue
Block a user