perf(trust): compute the local WoT graph once per viewer

TrustBadge called useTrustScore per card, which fetched the viewer's
follow list plus up to 100 friends' follow lists for EVERY card. The
graph does not depend on the target, so useViewerGraph computes it once
per viewer (one shared React Query) and deriveLocalScore returns the same
100/90/60/0 ladder (the NIP-05 30 tier classified as unknown anyway).
TrustBadge now costs no per-card network work on top of the batched
assertion read. Behaviour preserved; logged-out users still use the house
graph. Also records the ANALYZE=1 bundle findings in AGENTS.md.
This commit is contained in:
SkateSpots 2026-10-09 18:04:16 +07:00
parent 3b06e90d15
commit 0f8c77908b
3 changed files with 105 additions and 4 deletions

View file

@ -1914,6 +1914,16 @@ Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngi
`/tmp` that got wiped (2026-10-05) — don't recreate it in `/tmp`. Needs the dev `/tmp` that got wiped (2026-10-05) — don't recreate it in `/tmp`. Needs the dev
server (`skatespots-dev.service`, `:8080`) up, else the frame shows "cannot be reached". server (`skatespots-dev.service`, `:8080`) up, else the frame shows "cannot be reached".
- `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.** - `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.**
- **Bundle analysis (2026-10-09):** `ANALYZE=1 npm run build` runs
`rollup-plugin-visualizer` (already in `vite.config.ts`) and writes machine-readable
`stats.json` — parse `nodeParts[].metaUid` → `nodeMetas[uid].id` for sizes (delete the
file after; it is not committed). Findings: the entry chunk is ~1.9 MB minified
(~584 kB gzip); its single biggest resident is **`src/i18n/en.ts` (~352 kB rendered,
~11%)**, then react-dom, zod, dexie, vaul. The heavy libs are already correctly
lazy-chunked (`html5-qrcode`/zxing, `xlsx`, `leaflet`, `html2canvas`, `jspdf`,
`coco-cashu`), so don't chase those. The 1.9 MB entry is mostly framework + the
English dictionary + shared UI; shrinking it means trimming those, not re-adding
`manualChunks` (see the TDZ warning above).
### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache) ### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache)
Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`. Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`.
@ -2552,7 +2562,10 @@ provider / your provider) and links to a public **`/trust`** explainer;
show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the
active provider + relay (`useTrustProvider`), and lets the viewer **publish a active provider + relay (`useTrustProvider`), and lets the viewer **publish a
kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex + kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex +
`wss://` relay, or clear to fall back to the community provider). Still open: a `wss://` relay, or clear to fall back to the community provider). The local
(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and
shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query.
Still open: a
mute action (no NIP-51 kind-10000 hook exists yet), counterparty alerts, and the mute action (no NIP-51 kind-10000 hook exists yet), counterparty alerts, and the
confidence-gated scoring model. Check confidence-gated scoring model. Check
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` / `TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /

View file

@ -1,4 +1,5 @@
import { useTrustScore } from './useTrustScore'; import { useCurrentUser } from './useCurrentUser';
import { useViewerGraph, deriveLocalScore } from './useViewerGraph';
import { useTrustAssertions } from './useTrustAssertions'; import { useTrustAssertions } from './useTrustAssertions';
import { bucketFromAssertion, bucketFromLocalScore, type TrustBucket } from '@/lib/trustLadder'; import { bucketFromAssertion, bucketFromLocalScore, type TrustBucket } from '@/lib/trustLadder';
import type { UserTrustAssertion } from '@/lib/nip85'; import type { UserTrustAssertion } from '@/lib/nip85';
@ -18,14 +19,20 @@ export interface TrustBucketResult {
* Resolve a viewer-facing trust bucket for one pubkey. Prefers a NIP-85 * Resolve a viewer-facing trust bucket for one pubkey. Prefers a NIP-85
* assertion (it can both verify and flag); otherwise falls back to the local * assertion (it can both verify and flag); otherwise falls back to the local
* web-of-trust ladder (verify only — it has no negative signal). * web-of-trust ladder (verify only — it has no negative signal).
*
* Both inputs are batched/shared: the assertion read is a shared per-viewer
* batch, and the local graph is computed once per viewer — so a grid of badges
* costs no per-card network work.
*/ */
export function useTrustBucket(pubkey: string | undefined): TrustBucketResult { export function useTrustBucket(pubkey: string | undefined): TrustBucketResult {
const { data: localScore, isLoading: localLoading } = useTrustScore(pubkey); const { user } = useCurrentUser();
const { data: graph, isLoading: graphLoading } = useViewerGraph();
const { data: assertions, isLoading: assertionsLoading } = useTrustAssertions( const { data: assertions, isLoading: assertionsLoading } = useTrustAssertions(
pubkey ? [pubkey] : [], pubkey ? [pubkey] : [],
); );
const assertion = pubkey ? assertions?.get(pubkey) : undefined; const assertion = pubkey ? assertions?.get(pubkey) : undefined;
const localScore = deriveLocalScore(graph, pubkey, user?.pubkey);
const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore); const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore);
const directFollow = localScore !== undefined && localScore >= 90; const directFollow = localScore !== undefined && localScore >= 90;
@ -34,6 +41,6 @@ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult {
assertion, assertion,
localScore, localScore,
directFollow, directFollow,
isLoading: localLoading || assertionsLoading, isLoading: graphLoading || assertionsLoading,
}; };
} }

View file

@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from './useCurrentUser';
import { onlyVerified } from '@/lib/verifyEvents';
/** Same house perspective the old per-card trust ladder used for logged-out users. */
const HOUSE_GRAPH_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
const MAX_FOLLOWS = 100;
export interface ViewerGraph {
/** Pubkeys the graph owner follows. */
following: Set<string>;
/** Friends-of-friends: pubkeys followed by someone the owner follows. */
fof: Set<string>;
}
/**
* The viewer's follow graph, computed ONCE and shared by every consumer.
*
* WHY: `useTrustScore` fetched the follow list + up to 100 friends' follow lists
* PER CARD. Since the graph does not depend on the target, one query per viewer
* (deduped by React Query across every badge) replaces N per-card queries.
*/
export function useViewerGraph() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const graphOwner = user?.pubkey || HOUSE_GRAPH_PUBKEY;
return useQuery({
queryKey: ['viewer-graph', graphOwner],
staleTime: 60 * 60 * 1000, // 1h — the graph changes slowly
gcTime: 24 * 60 * 60 * 1000,
queryFn: async (c): Promise<ViewerGraph> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const followEvents = onlyVerified(await nostr.query(
[{ kinds: [3], authors: [graphOwner], limit: 1 }],
{ signal },
));
const following = new Set<string>();
for (const tag of followEvents[0]?.tags ?? []) {
if (tag[0] === 'p' && tag[1]) following.add(tag[1]);
}
const fof = new Set<string>();
if (following.size > 0) {
const fofEvents = onlyVerified(await nostr.query(
[{ kinds: [3], authors: [...following].slice(0, MAX_FOLLOWS), limit: MAX_FOLLOWS }],
{ signal },
));
for (const event of fofEvents) {
for (const [name, value] of event.tags) {
if (name === 'p' && value) fof.add(value);
}
}
}
return { following, fof };
},
});
}
/**
* The same 100 / 90 / 60 / 0 ladder the trust badges used, minus the NIP-05 tier
* (30) which classified as `unknown` anyway. `self` is the real viewer only —
* never the house graph owner.
*/
export function deriveLocalScore(
graph: ViewerGraph | undefined,
pubkey: string | undefined,
viewer: string | undefined,
): number | undefined {
if (!pubkey) return undefined;
if (viewer && pubkey === viewer) return 100;
if (!graph) return undefined;
if (graph.following.has(pubkey)) return 90;
if (graph.fof.has(pubkey)) return 60;
return 0;
}