home: fan out single-relay queries to fix load waterfall

The home page serialized its first paint behind relay.ditto.pub:
useCampaignLists queried that one relay via nostr.relay(DITTO_RELAY)
(awaited, up to an 8s timeout) and every hero campaign was gated on
its result, so a slow ditto.pub stalled the whole page. Connection
sharing made it worse — pooled queries multiplexed onto the same
stalled socket.

Switch the home-critical moderation/list/discovery hooks from
single-relay nostr.relay(DITTO_RELAY) calls to the parallel pooled
nostr.query() fan-out:

- useCampaignLists: authors:[curator] filter enforces trust; relay
  pin was unnecessary and headed the waterfall.
- useCampaignModeration: authors:[moderators] filter enforces trust.
- useFeaturedOrganizations: per-author filters enforce curation.
- useDiscoverCommunities: global discovery — fan-out broadens coverage.

useDashboardCounts stays pinned: NIP-45 COUNT is a single-relay
primitive and isn't mergeable across relays, and it's off the home
critical path.

Regression-of: 3d825aef
This commit is contained in:
Alex Gleason
2026-06-01 23:16:45 +02:00
parent a948725245
commit bc80dba826
4 changed files with 32 additions and 20 deletions
+16 -9
View File
@@ -10,7 +10,6 @@ import {
foldCampaignLists,
} from '@/lib/campaignLists';
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
import { DITTO_RELAY } from '@/lib/appRelays';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -34,7 +33,20 @@ interface UseCampaignListsResult {
* follow-pack member is trusted to sign approve / hide labels.
*
* Because the curator is a hardcoded constant, this query depends on no
* other query — it fires on first paint with no waterfall.
* other query — it fires on first paint.
*
* **Relay fan-out.** This used to query `relay.ditto.pub` directly (a
* single-relay `nostr.relay(...)` call) to avoid a fast empty EOSE from a
* less-populated relay racing the surface to "no lists." But this query
* sits at the *head* of the home-page waterfall — every hero campaign is
* gated on its result (see `CampaignsPage`/`useCampaigns`) — so a slow
* `relay.ditto.pub` stalled the entire first paint. We now fan out to the
* whole read pool via `nostr.query`. The `authors: [LIST_CURATOR_PUBKEY]`
* filter is what enforces the trust model; correctness no longer depends
* on hitting one specific relay, and the curated relay is still in the
* fan-out so its events are found. The pool accumulates events across
* relays until first EOSE (+ the pool's eoseTimeout), so a late event from
* the curated relay still folds in on the next tick.
*
* Lists *and* the index are pulled in a single filter via
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
@@ -46,12 +58,7 @@ export function useCampaignLists() {
const query = useQuery<UseCampaignListsResult>({
queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY],
queryFn: async ({ signal }) => {
// Query the canonical app relay directly. The same reasoning as
// `useCampaignModerators` applies: a fast empty EOSE from a
// less-populated relay should not race the moderation surface to
// "no lists" while the curated relay still holds them.
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
const events = await nostr.query(
[
{
kinds: [CAMPAIGN_LIST_KIND],
@@ -60,7 +67,7 @@ export function useCampaignLists() {
limit: 500,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
{ signal },
);
return foldCampaignLists(events);
},
+7 -4
View File
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import { DITTO_RELAY } from '@/lib/appRelays';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import {
AGORA_MODERATION_NAMESPACE,
@@ -62,8 +61,12 @@ export function useCampaignModeration() {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const relay = nostr.relay(DITTO_RELAY);
const events = await relay.query(
// Fan out to the whole read pool rather than pinning a single relay.
// The `authors: moderators` filter enforces the trust model, so
// querying more relays only improves coverage — and it keeps this
// moderation surface off the single-relay critical path that was
// serializing the home page behind relay.ditto.pub.
const events = await nostr.query(
[
{
kinds: [LABEL_KIND],
@@ -76,7 +79,7 @@ export function useCampaignModeration() {
limit: 2000,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
{ signal },
);
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
},
+4 -3
View File
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { DITTO_RELAY } from '@/lib/appRelays';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -38,13 +37,15 @@ interface UseDiscoverCommunitiesOptions {
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
const { limit = 24, enabled = true } = options;
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
return useQuery<ParsedCommunity[]>({
queryKey: ['discover-communities', limit],
enabled,
queryFn: async ({ signal }) => {
const events = await relay.query(
// Global discovery (no `authors:` filter), so fan out to the whole
// read pool: more relays means broader community coverage, and it
// keeps Discover off the single-relay critical path.
const events = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
{ signal },
);
+5 -4
View File
@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { DITTO_RELAY } from '@/lib/appRelays';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
@@ -50,7 +49,6 @@ function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
*/
export function useFeaturedOrganizations() {
const { nostr } = useNostr();
const relay = nostr.relay(DITTO_RELAY);
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
// Derive the curated coord set: featured minus hidden, sorted by the
@@ -102,8 +100,11 @@ export function useFeaturedOrganizations() {
}),
);
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await relay.query(filters, { signal: combinedSignal });
// Fan out to the whole read pool. Each filter pins `authors:`, so the
// curation is still enforced by the moderation labels (the `featured`
// labels are themselves moderator-authored) — querying more relays only
// improves coverage and keeps this off the single-relay critical path.
const events = await nostr.query(filters, { signal });
// Latest-wins dedupe of addressable revisions, then index by coord so
// we can return them in the moderator-controlled `featuredOrder`.