From 8a335c2ec839cde9f77b14255ae03a708238c1bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 23:55:50 +0000 Subject: [PATCH] Fix slow Nostr list sync by decoupling from profile and awaiting before feed render The login-to-feed pipeline had several issues causing lists (blocks, subscriptions, hashtag preferences) to load too slowly or not reflect in the feed: 1. All list loading was chained after profileStatePromise, adding 2.5-6s of unnecessary delay. Lists only need relay URLs, not profile data. Now they chain off relaysReadyPromise instead, loading in parallel with profile fetching. 2. The video grid refreshed immediately without waiting for any lists, so the feed briefly displayed unfiltered content. Now the grid refresh waits (up to 8s) for critical feed-filtering lists to settle. 3. Hashtag preferences weren't cached into the snapshot before the feed rendered, so interests/disinterests were empty on first paint. 4. Subscription ensureLoaded had a 6s timeout that was too aggressive for relay fetch (12s) + decryption (3-12s), causing silent failures. Increased to 30s. 5. Subscription relay fetches for user and session actor ran sequentially. Now they run in parallel via Promise.all. https://claude.ai/code/session_01UKmTWb83AnQPRVQo3bKZqm --- js/app.js | 58 ++++++++++++++++++++++++++++---------- js/services/authService.js | 16 +++++++++-- js/subscriptions.js | 52 +++++++++++++++++++++------------- 3 files changed, 88 insertions(+), 38 deletions(-) diff --git a/js/app.js b/js/app.js index bd9637f2..7857b05e 100644 --- a/js/app.js +++ b/js/app.js @@ -4135,6 +4135,14 @@ class Application { : Promise.resolve(detail?.postLogin ?? null); const postLoginResult = detail?.postLogin ?? null; + // relaysReadyPromise resolves as soon as the user's relay list is loaded, + // before profile fetching completes. Lists can start loading immediately + // after relays are available — they don't depend on profile data. + const relaysReadyPromise = + detail && typeof detail.relaysReadyPromise?.then === "function" + ? detail.relaysReadyPromise + : postLoginPromise; + if (detail && typeof detail === "object") { try { detail.__handled = true; @@ -4270,8 +4278,10 @@ class Application { return null; }); - // Chain list loading to profile state to ensure relays are loaded first. - const listStatePromise = profileStatePromise.then(() => { + // Start list loading as soon as relays are ready — lists don't depend on + // profile data, only on relay URLs. This runs in parallel with profile + // fetching instead of sequentially after it. + const listStatePromise = relaysReadyPromise.then(() => { const tasks = []; if (activePubkey && typeof this.authService.loadBlocksForPubkey === "function") { tasks.push( @@ -4328,6 +4338,7 @@ class Application { }); }); + // DMs can wait for profile since they need encryption context. const dmStatePromise = profileStatePromise.then(() => { if ( activePubkey && @@ -4373,7 +4384,8 @@ class Application { return Promise.resolve(); }); - const hashtagPreferencesPromise = profileStatePromise.then(() => { + // Hashtag preferences drive feed filtering — start as soon as relays are ready. + const hashtagPreferencesPromise = relaysReadyPromise.then(() => { if ( activePubkey && this.hashtagPreferences && @@ -4387,6 +4399,9 @@ class Application { this.capturePermissionPromptFromError( this.hashtagPreferences?.lastLoadError, ); + // Ensure the cached snapshot is updated before the feed renders + // so the feed engine has access to interests/disinterests. + this.updateCachedHashtagPreferences(); }) .catch((error) => { devLogger.warn( @@ -4399,13 +4414,9 @@ class Application { return Promise.resolve(); }); - void Promise.allSettled([ - profileStatePromise, - listStatePromise, - dmStatePromise, - ]); + // Ensure DMs are tracked for cleanup. + void dmStatePromise; void nwcPromise; - void hashtagPreferencesPromise; if (activePubkey) { const seedBlacklist = () => { @@ -4438,20 +4449,37 @@ class Application { ); } }); - - // Subscriptions are loaded in list tasks to align with per-category loading states. } + // Show loading state immediately while lists are syncing. try { this.reinitializeVideoListView({ reason: "login", postLoginResult }); } catch (error) { devLogger.warn("Failed to reinitialize video list view after login:", error); } - this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ - reason: "auth-login", - forceMainReload: true, - }); + // Wait for the critical feed-filtering lists (blocks, subscriptions, + // hashtag preferences) to settle before rendering the video grid. This + // prevents the feed from briefly showing unfiltered content. We use a + // time-boxed wait so the UI isn't blocked indefinitely if decryption stalls. + const FEED_SYNC_TIMEOUT_MS = 8000; + const feedSyncPromise = Promise.race([ + Promise.allSettled([ + listStatePromise, + hashtagPreferencesPromise, + profileStatePromise, + ]), + new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)), + ]).catch(() => null); + + // Chain the grid refresh after the feed data is available. + this.lastIdentityRefreshPromise = feedSyncPromise + .then(() => + this.refreshAllVideoGrids({ + reason: "auth-login", + forceMainReload: true, + }), + ); this.lastIdentityRefreshPromise .catch((error) => { devLogger.error("Failed to refresh video grids after login:", error); diff --git a/js/services/authService.js b/js/services/authService.js index 0e0cf847..602a2ed4 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -623,10 +623,11 @@ export default class AuthService { postLoginError: null, }; - const { detail: postLoginDetail, completionPromise } = this.applyPostLoginState({ + const { detail: postLoginDetail, completionPromise, relaysReadyPromise } = this.applyPostLoginState({ deferBlocks: true, }); detail.postLogin = postLoginDetail; + detail.relaysReadyPromise = relaysReadyPromise; const postLoginPromise = Promise.resolve(completionPromise) .then((postLogin) => { @@ -694,7 +695,8 @@ export default class AuthService { }; if (!activePubkey) { - return { detail, completionPromise: Promise.resolve(detail) }; + const resolved = Promise.resolve(detail); + return { detail, completionPromise: resolved, relaysReadyPromise: resolved }; } const runOperation = async (operation) => { @@ -718,6 +720,13 @@ export default class AuthService { const schedule = (callback) => Promise.resolve().then(() => callback()); + // Expose relay readiness separately so list loading can start immediately + // after relays are available, without waiting for profile to complete. + let resolveRelaysReady; + const relaysReadyPromise = new Promise((resolve) => { + resolveRelaysReady = resolve; + }); + const completionPromise = (async () => { if (this.relayManager && typeof this.relayManager.loadRelayList === "function") { const relayOp = { @@ -738,6 +747,7 @@ export default class AuthService { }; await runOperation(relayOp); } + resolveRelaysReady(detail); const concurrentOps = []; @@ -775,7 +785,7 @@ export default class AuthService { return detail; })(); - return { detail, completionPromise }; + return { detail, completionPromise, relaysReadyPromise }; } createBlocksLoadOperation( diff --git a/js/subscriptions.js b/js/subscriptions.js index a20788e3..5a6e7c34 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -502,14 +502,19 @@ class SubscriptionsManager { ); const hadCachedSnapshot = cachedSnapshot.hasSnapshot; const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; - let events = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedUserPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0), - timeoutMs: 12000, - }); + + // Fetch user and session actor subscription lists in parallel to reduce + // total relay wait time during login. + const fetchPromises = [ + nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedUserPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0), + timeoutMs: 12000, + }), + ]; const normalizedSessionActorPubkey = normalizeNostrPubkey( nostrClient?.sessionActor?.pubkey, @@ -525,17 +530,22 @@ class SubscriptionsManager { ), ); const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; - const sessionEvents = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedSessionActorPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0), - timeoutMs: 12000, - }); - if (sessionEvents.length) { - events = events.concat(sessionEvents); - } + fetchPromises.push( + nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedSessionActorPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0), + timeoutMs: 12000, + }), + ); + } + + const fetchResults = await Promise.all(fetchPromises); + let events = fetchResults[0] || []; + if (shouldFetchSessionActor && fetchResults[1]?.length) { + events = events.concat(fetchResults[1]); } const mergedEvents = []; @@ -809,7 +819,9 @@ class SubscriptionsManager { try { // Race against a timeout so the UI doesn't hang indefinitely if relays stall. - const timeoutMs = 6000; + // The timeout must accommodate relay fetch (~12s) + decryption (~3-12s per + // scheme attempt), so we use 30s to give the full pipeline enough room. + const timeoutMs = 30000; await Promise.race([ loader, new Promise((_, reject) =>