Browse Source

stop waiting for the network before rendering locally-stored events

imwald
Silberengel 1 month ago
parent
commit
7bd837c581
  1. 10
      src/components/Profile/ProfileMediaFeed.tsx
  2. 23
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  3. 7
      src/constants.ts
  4. 8
      src/hooks/useFetchRelayList.tsx
  5. 8
      src/lib/private-relays.ts
  6. 89
      src/lib/relay-list-builder.ts
  7. 19
      src/providers/FeedProvider.tsx
  8. 4
      src/providers/NostrProvider/index.tsx
  9. 77
      src/services/client.service.ts

10
src/components/Profile/ProfileMediaFeed.tsx

@ -55,6 +55,16 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey @@ -55,6 +55,16 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
let cancelled = false
setRefinedAuthorRelayUrls(null)
void (async () => {
try {
const peeked = await client.peekRelayListFromStorage(pk)
if (!cancelled) {
setRefinedAuthorRelayUrls(
buildAuthorInboxOutboxRelayUrls(peeked, blockedRelays, includeAuthorLocalRelays)
)
}
} catch {
/* keep provisionalAuthorRelayUrls */
}
const authorRl = await client.fetchRelayList(pk).catch(() => ({
read: [] as string[],
write: [] as string[]

23
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -58,7 +58,8 @@ export default function SidebarCalendarWeekWidget() { @@ -58,7 +58,8 @@ export default function SidebarCalendarWeekWidget() {
const [weekOffset, setWeekOffset] = useState(0)
const [rawEvents, setRawEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(false)
/** True only until the first IndexedDB (+ session) snapshot for this week is applied — never while relay REQ runs. */
const [loading, setLoading] = useState(true)
const relayUrls = useMemo(() => {
const base = getRelayUrlsWithFavoritesFastReadAndInbox(
@ -115,16 +116,19 @@ export default function SidebarCalendarWeekWidget() { @@ -115,16 +116,19 @@ export default function SidebarCalendarWeekWidget() {
indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs),
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
])
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
if (!relayUrls.length) {
if (cancelled) return
const fromSession = client.getSessionEventsMatchingSearch(
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
const sessionSnap = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSession]))
const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap])
setRawEvents(mergedLocal)
setLoading(false)
if (!relayUrls.length) {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -189,12 +193,14 @@ export default function SidebarCalendarWeekWidget() { @@ -189,12 +193,14 @@ export default function SidebarCalendarWeekWidget() {
}
if (cancelled) return
const fromSession = client.getSessionEventsMatchingSearch(
const fromSessionAfterNet = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline]))
setRawEvents(
dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSessionAfterNet, ...localBaseline])
)
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -223,6 +229,7 @@ export default function SidebarCalendarWeekWidget() { @@ -223,6 +229,7 @@ export default function SidebarCalendarWeekWidget() {
} catch {
setRawEvents([])
}
setLoading(false)
}
} finally {
if (!cancelled) setLoading(false)

7
src/constants.ts

@ -144,6 +144,13 @@ export const EARLY_PUBLISH_SUCCESS_GRACE_MS = 1200 @@ -144,6 +144,13 @@ export const EARLY_PUBLISH_SUCCESS_GRACE_MS = 1200
*/
export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 20_000
/**
* How long {@link ClientService.fetchRelayLists} waits on the network before returning an IndexedDB + default
* merge. Kept short so users without NIP-65 (or slow relays) get {@link PROFILE_FETCH_RELAY_URLS} immediately;
* {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} stays longer for publish / prioritize paths that wrap their own races.
*/
export const FETCH_RELAY_LIST_UI_TIMEOUT_MS = 2_500
/**
* {@link ClientService.prioritizePublishUrlListWithTimeout}: must exceed {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS}
* so one full `fetchRelayLists` budget can elapse before we fall back to deduped order without inbox fetch.

8
src/hooks/useFetchRelayList.tsx

@ -22,11 +22,17 @@ export function useFetchRelayList(pubkey?: string | null) { @@ -22,11 +22,17 @@ export function useFetchRelayList(pubkey?: string | null) {
return
}
try {
// Use client.fetchRelayList which handles merging cache relays
const fromStorage = await client.peekRelayListFromStorage(pubkey)
setRelayList(fromStorage)
const relayList = await client.fetchRelayList(pubkey)
setRelayList(relayList)
} catch (err) {
logger.error('Failed to fetch relay list', { error: err, pubkey })
try {
setRelayList(await client.peekRelayListFromStorage(pubkey))
} catch {
/* keep last good state */
}
} finally {
setIsFetching(false)
}

8
src/lib/private-relays.ts

@ -8,8 +8,8 @@ import { ExtendedKind } from '@/constants' @@ -8,8 +8,8 @@ import { ExtendedKind } from '@/constants'
* @returns Promise<boolean> - true if user has at least one private relay available
*/
export async function hasPrivateRelays(pubkey: string): Promise<boolean> {
// Check for outbox relays (kind 10002)
const relayList = await client.fetchRelayList(pubkey)
// Check for outbox relays (kind 10002) — IndexedDB merge only; no network wait.
const relayList = await client.peekRelayListFromStorage(pubkey)
if (relayList.write && relayList.write.length > 0) {
return true
}
@ -35,8 +35,8 @@ export async function hasPrivateRelays(pubkey: string): Promise<boolean> { @@ -35,8 +35,8 @@ export async function hasPrivateRelays(pubkey: string): Promise<boolean> {
export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> {
const relayUrls: string[] = []
// Get outbox relays (kind 10002)
const relayList = await client.fetchRelayList(pubkey)
// Get outbox relays (kind 10002) — storage-first; cache rows below still augment.
const relayList = await client.peekRelayListFromStorage(pubkey)
if (relayList.write) {
relayUrls.push(...relayList.write)
}

89
src/lib/relay-list-builder.ts

@ -132,68 +132,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -132,68 +132,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
}
// 4. Author's outboxes (write relays) - where they publish
// 4. Author's outboxes (write relays) - where they publish (IndexedDB + defaults; no network gate)
if (authorPubkey) {
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(authorPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.debug('[RelayListBuilder] fetchRelayList timeout for author', {
author: authorPubkey.substring(0, 8)
})
resolve(null)
}, 2000)
})
const authorRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (authorRelayList) {
const authorOutboxes = [
...(authorRelayList.write || []).slice(0, 10)
]
const authorRelayList = await client.peekRelayListFromStorage(authorPubkey)
const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)]
authorOutboxes.forEach(addRelay)
const authorInboxes = [
...(authorRelayList.read || []).slice(0, 10)
]
const authorInboxes = [...(authorRelayList.read || []).slice(0, 10)]
authorInboxes.forEach(addRelay)
logger.debug('[RelayListBuilder] Added author relays', {
author: authorPubkey.substring(0, 8),
outboxes: authorOutboxes.length,
inboxes: authorInboxes.length
})
}
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch author relay list', { error })
logger.debug('[RelayListBuilder] Failed to read author relay list from storage', { error })
}
}
// 5. User's own relays (for profiles/metadata)
if (includeUserOwnRelays && userPubkey) {
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(userPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.debug('[RelayListBuilder] fetchRelayList timeout for user', {
user: userPubkey.substring(0, 8)
})
resolve(null)
}, 2000)
})
const userRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (userRelayList) {
const userRead = [
...(userRelayList.read || []).slice(0, 10)
]
const userWrite = [
...(userRelayList.write || []).slice(0, 10)
]
const userRelayList = await client.peekRelayListFromStorage(userPubkey)
const userRead = [...(userRelayList.read || []).slice(0, 10)]
const userWrite = [...(userRelayList.write || []).slice(0, 10)]
userRead.forEach(addRelay)
userWrite.forEach(addRelay)
}
// Include local relays from kind 10432
if (includeLocalRelays) {
@ -217,8 +181,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -217,8 +181,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
logger.debug('[RelayListBuilder] Added user own relays', {
read: userRelayList ? (userRelayList.read || []).length : 0,
write: userRelayList ? (userRelayList.write || []).length : 0,
read: (userRelayList.read || []).length,
write: (userRelayList.write || []).length,
local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0,
favorite: favoriteRelaysCount
})
@ -228,24 +192,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -228,24 +192,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} else if (userPubkey) {
// Even if not including user's own relays, still include user's inboxes for reading
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(userPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => {
logger.debug('[RelayListBuilder] fetchRelayList timeout for user inboxes', {
user: userPubkey.substring(0, 8)
})
resolve(null)
}, 2000)
})
const userRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (userRelayList) {
const userInboxes = [
...(userRelayList.read || []).slice(0, 10)
]
userInboxes.forEach(addRelay)
}
const userRelayList = await client.peekRelayListFromStorage(userPubkey)
;[...(userRelayList.read || []).slice(0, 10)].forEach(addRelay)
// Include local relays from kind 10432 if enabled
if (includeLocalRelays) {
@ -333,7 +281,6 @@ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { @@ -333,7 +281,6 @@ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] {
return [...out]
}
const POLL_RESULTS_RELAY_TIMEOUT_MS = 2000
const POLL_RESULTS_MAX_RELAYS = 40
const POLL_RESULTS_NIP65_READ_SLICE = 16
@ -383,20 +330,12 @@ export async function buildPollResultsReadRelayUrls(options: { @@ -383,20 +330,12 @@ export async function buildPollResultsReadRelayUrls(options: {
pushLayer(relayHintsFromEventTags(pollEvent))
pushLayer(pollRelayUrls)
const raceRelayList = (pubkey: string) => {
const p = client.fetchRelayList(pubkey)
const t = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), POLL_RESULTS_RELAY_TIMEOUT_MS)
)
return Promise.race([p, t])
}
let authorReadSlice: string[] = []
let viewerReadSlice: string[] = []
try {
const [authorRl, viewerRl] = await Promise.all([
pollEvent.pubkey ? raceRelayList(pollEvent.pubkey) : Promise.resolve(null),
viewerPubkey ? raceRelayList(viewerPubkey) : Promise.resolve(null)
pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null),
viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null)
])
if (authorRl?.read?.length) {
authorReadSlice = authorRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE)

19
src/providers/FeedProvider.tsx

@ -38,14 +38,16 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -38,14 +38,16 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}
return extra
}, [cacheRelayListEvent, httpRelayListEvent])
const [relayUrls, setRelayUrls] = useState<string[]>([])
const [isReady, setIsReady] = useState(false)
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() =>
mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], [])
)
const [isReady, setIsReady] = useState(true)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
feedType: 'relay',
id: DEFAULT_FAVORITE_RELAYS[0]
})
const feedInfoRef = useRef<TFeedInfo>(feedInfo)
const loggedWaitingForNostrInitRef = useRef(false)
const switchFeed = useCallback(async (
feedType: TFeedType,
@ -56,7 +58,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -56,7 +58,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
} = {}
) => {
logger.debug('switchFeed called:', { feedType, options })
setIsReady(false)
if (feedType === 'relay') {
const normalizedUrl = normalizeAnyRelayUrl(options.relay ?? '')
const isRelayFeedUrl =
@ -142,16 +143,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { @@ -142,16 +143,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const init = async () => {
logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length })
if (!isInitialized) {
if (!loggedWaitingForNostrInitRef.current) {
loggedWaitingForNostrInitRef.current = true
logger.info(
'[FeedProvider] Waiting for Nostr session restore before attaching feeds (home may show a loading state)'
)
}
return
}
loggedWaitingForNostrInitRef.current = false
// Wait for favoriteRelays to be initialized (should have at least default relays)
// If favoriteRelays is empty, it might not be initialized yet, so wait

4
src/providers/NostrProvider/index.tsx

@ -343,6 +343,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -343,6 +343,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} else {
setRelayList(baseRelayList)
}
} else if (!userForcedAccountNetworkHydrate) {
/** No NIP-65 / 10432 / 10243 in IDB — still set merged defaults immediately (never wait on network). */
const quick = await client.peekRelayListFromStorage(account.pubkey)
setRelayList(quick)
}
if (!userForcedAccountNetworkHydrate) {
if (storedProfileEvent) {

77
src/services/client.service.ts

@ -18,6 +18,7 @@ import { @@ -18,6 +18,7 @@ import {
PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS,
PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS,
FETCH_RELAY_LIST_UI_TIMEOUT_MS,
MULTI_RELAY_PUBLISH_ACK_CAP_MS,
RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS,
RELAY_POOL_CONNECTION_TIMEOUT_MS,
@ -3719,11 +3720,23 @@ class ClientService extends EventTarget { @@ -3719,11 +3720,23 @@ class ClientService extends EventTarget {
})
return relayList
} catch (error) {
logger.error('[FetchRelayList] Fetch failed', {
logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', {
pubkey,
error: error instanceof Error ? error.message : String(error)
})
throw error
try {
const [fallback] = await this.mergeRelayListsFromStoredOnly([pubkey])
return fallback!
} catch {
return {
write: PROFILE_FETCH_RELAY_URLS,
read: PROFILE_FETCH_RELAY_URLS,
originalRelays: [],
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
}
}
} finally {
this.relayListRequestCache.delete(cacheKey)
}
@ -3733,6 +3746,36 @@ class ClientService extends EventTarget { @@ -3733,6 +3746,36 @@ class ClientService extends EventTarget {
return requestPromise
}
/**
* Merge relay list from IndexedDB only (no network). Same rules as a timed-out {@link fetchRelayLists}:
* defaults to {@link PROFILE_FETCH_RELAY_URLS} when kind 10002 is missing.
*/
async peekRelayListFromStorage(pubkey: string): Promise<TRelayList> {
const [rl] = await this.mergeRelayListsFromStoredOnly([pubkey])
return rl!
}
private async mergeRelayListsFromStoredOnly(pubkeys: string[]): Promise<TRelayList[]> {
const storedRelayEvents = await Promise.all(
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, kinds.RelayList))
)
const storedCacheRelayEvents = await Promise.all(
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS))
)
const storedHttpRelayEvents = await Promise.all(
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.HTTP_RELAY_LIST))
)
return this.mergeRelayListsBundle(
pubkeys,
pubkeys.map(() => undefined),
pubkeys.map(() => undefined),
storedCacheRelayEvents.map((e) => e ?? undefined),
storedRelayEvents,
storedHttpRelayEvents,
storedCacheRelayEvents
)
}
/**
* Merge NIP-65 (10002), HTTP relay list (10243), and cache relays (10432) from network and/or IndexedDB.
* Network arrays may be sparse/undefined per index; stored* always filled from IDB reads.
@ -3853,6 +3896,7 @@ class ClientService extends EventTarget { @@ -3853,6 +3896,7 @@ class ClientService extends EventTarget {
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
if (pubkeys.length === 0) return []
try {
const storedRelayEvents = await Promise.all(
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
)
@ -3863,7 +3907,7 @@ class ClientService extends EventTarget { @@ -3863,7 +3907,7 @@ class ClientService extends EventTarget {
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST))
)
const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS
const budgetMs = FETCH_RELAY_LIST_UI_TIMEOUT_MS
/** True only when *every* pubkey in this batch already has kind 10002 in IDB (not just you). */
const allHaveKind10002 = pubkeys.every((_, i) => storedRelayEvents[i] != null)
@ -3964,7 +4008,13 @@ class ClientService extends EventTarget { @@ -3964,7 +4008,13 @@ class ClientService extends EventTarget {
}
const raced = await Promise.race([
hydrateRelayListsFromNetwork(),
hydrateRelayListsFromNetwork().catch((err: unknown) => {
logger.warn('[FetchRelayLists] hydrateRelayListsFromNetwork failed', {
pubkeyCount: pubkeys.length,
error: err instanceof Error ? err.message : String(err)
})
return null
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), budgetMs))
])
if (raced != null) {
@ -3980,6 +4030,7 @@ class ClientService extends EventTarget { @@ -3980,6 +4030,7 @@ class ClientService extends EventTarget {
)
}
this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents)
const now = Date.now()
if (now - fetchRelayListBudgetWarnLastMs >= FETCH_RELAY_LIST_BUDGET_WARN_MIN_INTERVAL_MS) {
fetchRelayListBudgetWarnLastMs = now
@ -3998,6 +4049,24 @@ class ClientService extends EventTarget { @@ -3998,6 +4049,24 @@ class ClientService extends EventTarget {
storedHttpRelayEvents,
storedCacheRelayEvents
)
} catch (err: unknown) {
logger.warn('[FetchRelayLists] Unexpected failure; using IndexedDB / defaults', {
pubkeyCount: pubkeys.length,
error: err instanceof Error ? err.message : String(err)
})
try {
return await this.mergeRelayListsFromStoredOnly(pubkeys)
} catch {
return pubkeys.map(() => ({
write: PROFILE_FETCH_RELAY_URLS,
read: PROFILE_FETCH_RELAY_URLS,
originalRelays: [] as TMailboxRelay[],
httpRead: [] as string[],
httpWrite: [] as string[],
httpOriginalRelays: [] as TMailboxRelay[]
}))
}
}
}
async forceUpdateRelayListEvent(pubkey: string) {

Loading…
Cancel
Save