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
This commit is contained in:
Claude committed 2026-02-05 23:55:50 +00:00
1 parent 0b09e146ef
commit 8a335c2ec8
3 files changed
+88 -38

No files matched your search

+43 -15
View File
@@ -4135,6 +4135,14 @@ class Application {
: Promise.resolve(detail?.postLogin ?? null); : Promise.resolve(detail?.postLogin ?? null);
const postLoginResult = 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") { if (detail && typeof detail === "object") {
try { try {
detail.__handled = true; detail.__handled = true;
@@ -4270,8 +4278,10 @@ class Application {
return null; return null;
}); });
// Chain list loading to profile state to ensure relays are loaded first. // Start list loading as soon as relays are ready — lists don't depend on
const listStatePromise = profileStatePromise.then(() => { // profile data, only on relay URLs. This runs in parallel with profile
// fetching instead of sequentially after it.
const listStatePromise = relaysReadyPromise.then(() => {
const tasks = []; const tasks = [];
if (activePubkey && typeof this.authService.loadBlocksForPubkey === "function") { if (activePubkey && typeof this.authService.loadBlocksForPubkey === "function") {
tasks.push( tasks.push(
@@ -4328,6 +4338,7 @@ class Application {
}); });
}); });
// DMs can wait for profile since they need encryption context.
const dmStatePromise = profileStatePromise.then(() => { const dmStatePromise = profileStatePromise.then(() => {
if ( if (
activePubkey && activePubkey &&
@@ -4373,7 +4384,8 @@ class Application {
return Promise.resolve(); return Promise.resolve();
}); });
const hashtagPreferencesPromise = profileStatePromise.then(() => { // Hashtag preferences drive feed filtering — start as soon as relays are ready.
const hashtagPreferencesPromise = relaysReadyPromise.then(() => {
if ( if (
activePubkey && activePubkey &&
this.hashtagPreferences && this.hashtagPreferences &&
@@ -4387,6 +4399,9 @@ class Application {
this.capturePermissionPromptFromError( this.capturePermissionPromptFromError(
this.hashtagPreferences?.lastLoadError, 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) => { .catch((error) => {
devLogger.warn( devLogger.warn(
@@ -4399,13 +4414,9 @@ class Application {
return Promise.resolve(); return Promise.resolve();
}); });
void Promise.allSettled([ // Ensure DMs are tracked for cleanup.
profileStatePromise, void dmStatePromise;
listStatePromise,
dmStatePromise,
]);
void nwcPromise; void nwcPromise;
void hashtagPreferencesPromise;
if (activePubkey) { if (activePubkey) {
const seedBlacklist = () => { 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 { try {
this.reinitializeVideoListView({ reason: "login", postLoginResult }); this.reinitializeVideoListView({ reason: "login", postLoginResult });
} catch (error) { } catch (error) {
devLogger.warn("Failed to reinitialize video list view after login:", error); devLogger.warn("Failed to reinitialize video list view after login:", error);
} }
this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ // Wait for the critical feed-filtering lists (blocks, subscriptions,
reason: "auth-login", // hashtag preferences) to settle before rendering the video grid. This
forceMainReload: true, // 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 this.lastIdentityRefreshPromise
.catch((error) => { .catch((error) => {
devLogger.error("Failed to refresh video grids after login:", error); devLogger.error("Failed to refresh video grids after login:", error);
+13 -3
View File
@@ -623,10 +623,11 @@ export default class AuthService {
postLoginError: null, postLoginError: null,
}; };
const { detail: postLoginDetail, completionPromise } = this.applyPostLoginState({ const { detail: postLoginDetail, completionPromise, relaysReadyPromise } = this.applyPostLoginState({
deferBlocks: true, deferBlocks: true,
}); });
detail.postLogin = postLoginDetail; detail.postLogin = postLoginDetail;
detail.relaysReadyPromise = relaysReadyPromise;
const postLoginPromise = Promise.resolve(completionPromise) const postLoginPromise = Promise.resolve(completionPromise)
.then((postLogin) => { .then((postLogin) => {
@@ -694,7 +695,8 @@ export default class AuthService {
}; };
if (!activePubkey) { if (!activePubkey) {
return { detail, completionPromise: Promise.resolve(detail) }; const resolved = Promise.resolve(detail);
return { detail, completionPromise: resolved, relaysReadyPromise: resolved };
} }
const runOperation = async (operation) => { const runOperation = async (operation) => {
@@ -718,6 +720,13 @@ export default class AuthService {
const schedule = (callback) => Promise.resolve().then(() => callback()); 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 () => { const completionPromise = (async () => {
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") { if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
const relayOp = { const relayOp = {
@@ -738,6 +747,7 @@ export default class AuthService {
}; };
await runOperation(relayOp); await runOperation(relayOp);
} }
resolveRelaysReady(detail);
const concurrentOps = []; const concurrentOps = [];
@@ -775,7 +785,7 @@ export default class AuthService {
return detail; return detail;
})(); })();
return { detail, completionPromise }; return { detail, completionPromise, relaysReadyPromise };
} }
createBlocksLoadOperation( createBlocksLoadOperation(
+32 -20
View File
@@ -502,14 +502,19 @@ class SubscriptionsManager {
); );
const hadCachedSnapshot = cachedSnapshot.hasSnapshot; const hadCachedSnapshot = cachedSnapshot.hasSnapshot;
const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; const shouldForceFullFetch = !cachedSnapshot.hasSnapshot;
let events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND, // Fetch user and session actor subscription lists in parallel to reduce
pubkey: normalizedUserPubkey, // total relay wait time during login.
dTag: SUBSCRIPTION_LIST_IDENTIFIER, const fetchPromises = [
relayUrls, nostrClient.fetchListIncrementally({
since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0), kind: SUBSCRIPTION_SET_KIND,
timeoutMs: 12000, pubkey: normalizedUserPubkey,
}); dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0),
timeoutMs: 12000,
}),
];
const normalizedSessionActorPubkey = normalizeNostrPubkey( const normalizedSessionActorPubkey = normalizeNostrPubkey(
nostrClient?.sessionActor?.pubkey, nostrClient?.sessionActor?.pubkey,
@@ -525,17 +530,22 @@ class SubscriptionsManager {
), ),
); );
const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot;
const sessionEvents = await nostrClient.fetchListIncrementally({ fetchPromises.push(
kind: SUBSCRIPTION_SET_KIND, nostrClient.fetchListIncrementally({
pubkey: normalizedSessionActorPubkey, kind: SUBSCRIPTION_SET_KIND,
dTag: SUBSCRIPTION_LIST_IDENTIFIER, pubkey: normalizedSessionActorPubkey,
relayUrls, dTag: SUBSCRIPTION_LIST_IDENTIFIER,
since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0), relayUrls,
timeoutMs: 12000, since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0),
}); timeoutMs: 12000,
if (sessionEvents.length) { }),
events = events.concat(sessionEvents); );
} }
const fetchResults = await Promise.all(fetchPromises);
let events = fetchResults[0] || [];
if (shouldFetchSessionActor && fetchResults[1]?.length) {
events = events.concat(fetchResults[1]);
} }
const mergedEvents = []; const mergedEvents = [];
@@ -809,7 +819,9 @@ class SubscriptionsManager {
try { try {
// Race against a timeout so the UI doesn't hang indefinitely if relays stall. // 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([ await Promise.race([
loader, loader,
new Promise((_, reject) => new Promise((_, reject) =>