From 8a9750002d6a7f487284cda8a2d1aeb98048b534 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 19:23:36 +0000 Subject: [PATCH] fix: improve subscription list loading reliability and error recovery The subscription list was failing to load correctly during login because: 1. Permission-required decryption errors silently returned without setting loaded=true or scheduling a retry, leaving subscriptions permanently stuck in a loading state. 2. Generic decryption failures (all schemes exhausted) had no retry path, unlike timeout errors which already scheduled retries. 3. The ensureLoaded timeout (30s) was too tight for the worst-case pipeline of relay fetch (12s) + decryption (20s) = 32s, causing premature timeouts that left loaded=false. 4. The loadingPromise was cleared in a finally block even when the loader was still in-flight after a timeout, allowing duplicate concurrent loads that could race against each other. Changes: - Handle permission-required errors like timeout errors: set loaded=true, schedule a retry (with allowPermissionPrompt: true), emit change event - Schedule decrypt retries for all decrypt failure paths - Increase ensureLoaded timeout from 30s to 45s - Mark loaded=true on ensureLoaded timeout so the UI can proceed - Clean up loadingPromise via loader.then() instead of finally block, preventing duplicate loads when the first is still in-flight https://claude.ai/code/session_015MhPiSWNa7dm4UaRuq5x4U --- js/subscriptions.js | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/js/subscriptions.js b/js/subscriptions.js index b5f3a441..34936b47 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -651,6 +651,21 @@ class SubscriptionsManager { !allowPermissionPrompt && decryptResult.error?.code === "subscriptions-permission-required" ) { + if (!this.loaded && !cachedSnapshot.hasSnapshot) { + this.loaded = true; + } + // Retry with permission prompt enabled so the signer / window.nostr + // path is available once the extension has finished initializing. + this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { + allowPermissionPrompt: true, + }); + if (wasBackgroundLoading) { + this.backgroundLoading = false; + this.emitter.emit("change", { + action: "background-loaded", + subscribedPubkeys: Array.from(this.subscribedPubkeys), + }); + } return; } userLogger.error( @@ -668,6 +683,9 @@ class SubscriptionsManager { userLogger.warn( "[SubscriptionsManager] Preserving cached subscriptions despite decryption failure.", ); + this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { + allowPermissionPrompt, + }); return; } if (!this.loaded) { @@ -676,6 +694,9 @@ class SubscriptionsManager { this.subsEventCreatedAt = null; this.loaded = true; } + this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { + allowPermissionPrompt, + }); return; } @@ -825,11 +846,21 @@ class SubscriptionsManager { const loader = this.loadSubscriptions(normalizedActor, options); this.loadingPromise = loader; + // Clean up loadingPromise when the actual loader completes rather than + // when the timeout fires. This prevents a second ensureLoaded() call + // from starting a duplicate load while the first is still in-flight. + const cleanup = () => { + if (this.loadingPromise === loader) { + this.loadingPromise = null; + } + }; + loader.then(cleanup, cleanup); + try { // Race against a timeout so the UI doesn't hang indefinitely if relays stall. - // 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; + // The timeout must accommodate relay fetch (~12s) + decryption (~20s timeout + // with ~5s per scheme attempt × up to 3 schemes), so we use 45s. + const timeoutMs = 45000; await Promise.race([ loader, new Promise((_, reject) => @@ -842,8 +873,11 @@ class SubscriptionsManager { devLogger.log("[SubscriptionsManager] ensureLoaded success"); } catch (error) { userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error); - } finally { - this.loadingPromise = null; + // Mark as loaded so the UI can proceed. The background loader will + // continue and emit a "change" event if it eventually succeeds. + if (!this.loaded) { + this.loaded = true; + } } }