Speed up Nostr login and fix list sync, storage unlock, and feed registration

- Remove redundant double signer readiness gate evaluation that added 5-10s
  to the login path (authSessionCoordinator)
- Reduce decryption timeouts (10s→6s per scheme, 3s→1.5s retry delay,
  8s→5s NIP-07 no-prompt, 45s→25s overall ensureLoaded) for faster scheme
  fallback during list sync
- Fix storage tab unlock: remove overly strict capabilities check that
  blocked lazy-injecting NIP-07 extensions, and add auto-unlock attempt
  when profile modal opens with an active signer
- Fix subscription/hashtag decryption: always resolve signer for background
  refreshes (was previously gated behind allowPermissionPrompt), and always
  check window.nostr as fallback decryptor regardless of prompt mode
- Fix capabilities check in subscription decryption: use method existence
  instead of dynamic capabilities getter that returns false during lazy
  extension injection
- Wrap Promise.any decryptor calls in Promise.resolve().then() to catch
  synchronous throws from NIP-07 adapter methods when extension modules
  haven't loaded yet
- Make watch-history feed registration race-safe with pre-check and
  try/catch for concurrent registration attempts
- Improve NIP-07 extension detection with DOMContentLoaded listener for
  faster detection of extensions that inject during page load

https://claude.ai/code/session_0174XTB5ADJ9cMBSucqL9Wer
This commit is contained in:
Claude committed 2026-02-09 00:18:43 +00:00
1 parent dc196b8e43
commit 29215d9b5e
6 files changed
+173 -93

No files matched your search

+13 -20
View File
@@ -288,22 +288,12 @@ export function createAuthSessionCoordinator(deps) {
? Promise.resolve()
.then(async () => {
const signerGateStart = now();
let outcome = await evaluateSignerReadinessGate(activePubkey);
if (!outcome.ready) {
outcome = {
...outcome,
recoveryAttempted: true,
initialStatus: outcome.status,
};
const recoveryOutcome = await evaluateSignerReadinessGate(
activePubkey,
);
outcome = {
...recoveryOutcome,
recoveryAttempted: true,
initialStatus: outcome.initialStatus,
};
}
// PERF: Evaluate signer readiness once. The previous double-gate
// evaluation added 5-10s to the login path by repeating the same
// extension wait + permission request. A single evaluation is
// sufficient because ensureActiveSignerForPubkey already waits
// for extension injection internally.
const outcome = await evaluateSignerReadinessGate(activePubkey);
const durationMs = Math.max(
0,
@@ -311,7 +301,7 @@ export function createAuthSessionCoordinator(deps) {
);
const finalOutcome = {
...outcome,
recoveryAttempted: Boolean(outcome.recoveryAttempted),
recoveryAttempted: false,
durationMs,
};
userLogger.info("[signer-ready]", {
@@ -323,7 +313,7 @@ export function createAuthSessionCoordinator(deps) {
if (!finalOutcome.ready && finalOutcome.error) {
devLogger.warn(
"[Application] Signer readiness gate not ready after bounded recovery:",
"[Application] Signer readiness gate not ready:",
finalOutcome.error,
);
}
@@ -336,7 +326,7 @@ export function createAuthSessionCoordinator(deps) {
ready: false,
status: classifySignerGateError(error),
error,
recoveryAttempted: true,
recoveryAttempted: false,
durationMs: 0,
};
})
@@ -712,7 +702,10 @@ export function createAuthSessionCoordinator(deps) {
// 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 = 12000;
// PERF: Reduced from 12s to 8s — list decryption timeouts have been
// lowered so settled results arrive faster. 8s prevents the feed from
// being blocked while still giving most lists time to decrypt.
const FEED_SYNC_TIMEOUT_MS = 8000;
const feedSyncPromise = Promise.race([
Promise.allSettled([
listStatePromise,
+22 -1
View File
@@ -665,6 +665,27 @@ export function registerWatchHistoryFeed(engine, options = {}) {
return null;
}
// Check if already registered to avoid "already registered" throw from
// the engine when bootstrap and lazy registration race.
if (typeof engine.getFeedDefinition === "function") {
const existing = engine.getFeedDefinition("watch-history");
if (existing) {
return existing;
}
}
const definition = createWatchHistoryFeedDefinition(options);
return engine.registerFeed("watch-history", definition);
try {
return engine.registerFeed("watch-history", definition);
} catch (error) {
// Handle race condition where another call registered between our
// check and the registration attempt.
if (typeof engine.getFeedDefinition === "function") {
const raceWinner = engine.getFeedDefinition("watch-history");
if (raceWinner) {
return raceWinner;
}
}
throw error;
}
}
+32 -4
View File
@@ -402,14 +402,42 @@ export function waitForNip07Extension(timeoutMs = 5000) {
}
const start = Date.now();
let resolved = false;
const finish = (ext) => {
if (resolved) return;
resolved = true;
clearInterval(interval);
resolve(ext);
};
const fail = () => {
if (resolved) return;
resolved = true;
clearInterval(interval);
reject(new Error("Nostr extension not found within timeout."));
};
// Poll every 50ms for window.nostr injection.
const interval = setInterval(() => {
if (window.nostr) {
clearInterval(interval);
resolve(window.nostr);
finish(window.nostr);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error("Nostr extension not found within timeout."));
fail();
}
}, 50);
// Additionally listen for the DOMContentLoaded event — many extensions
// inject window.nostr during this phase, and detecting it via the event
// can be faster than waiting for the next poll tick.
if (typeof document !== "undefined" && document.readyState === "loading") {
const onReady = () => {
document.removeEventListener("DOMContentLoaded", onReady);
if (window.nostr) {
finish(window.nostr);
}
};
document.addEventListener("DOMContentLoaded", onReady);
}
});
}
+29 -20
View File
@@ -36,13 +36,14 @@ const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
const DEFAULT_VERSION = 1;
// PERF: Reduced from 20s to 10s — the signer is now guaranteed to be ready
// before list loading starts (authSessionCoordinator waits for the permission
// pre-grant), so decryption should succeed quickly. 10s is a generous fallback
// for slow relay responses while still failing fast enough to retry promptly.
const DECRYPT_TIMEOUT_MS = 10000;
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
// PERF: Reduced from 10s to 6s — the signer is guaranteed ready before list
// loading starts (authSessionCoordinator waits for the permission pre-grant),
// so decryption should succeed within 2-3s. 6s is generous enough for slow
// relays while still failing fast for scheme fallback.
const DECRYPT_TIMEOUT_MS = 6000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. Shorter delay speeds up the login path.
const DECRYPT_RETRY_DELAY_MS = 1500;
class TinyEventEmitter {
constructor() {
@@ -984,10 +985,13 @@ class HashtagPreferencesService {
(signerReadinessGate.status === "permission-denied" ||
signerReadinessGate.status === "extension-unavailable");
let signer = getActiveSigner();
// FIX: Always attempt to resolve the signer, not only when
// allowPermissionPrompt is true. ensureActiveSignerForPubkey does not
// prompt the user — it only waits for an already-injected extension.
// Without this, background refreshes can never decrypt.
if (
!signer &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
allowPermissionPrompt
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
@@ -1044,11 +1048,11 @@ class HashtagPreferencesService {
sources.set(scheme, source);
};
// PERF: Previous 5s no-prompt timeout was too aggressive — extensions that
// have already granted permission still need time to decrypt. 8s gives a
// realistic window while still failing faster than the full 15s interactive
// timeout.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
// gate guarantees the extension is ready before decryption starts, so
// decrypt calls should complete within 1-2s. 5s accommodates slow
// extensions while still failing fast for scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
@@ -1207,12 +1211,17 @@ class HashtagPreferencesService {
const decryptFn = decryptors.get(scheme);
if (!decryptFn) return null;
attemptSchemes.push(scheme);
return decryptFn(ciphertext).then((plaintext) => {
if (typeof plaintext !== "string") {
throw new Error("Decryption returned non-string payload.");
}
return { plaintext, scheme };
});
// FIX: Wrap in Promise.resolve().then() to catch synchronous throws
// from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws
// immediately if the extension hasn't injected its nip44 module yet).
return Promise.resolve()
.then(() => decryptFn(ciphertext))
.then((plaintext) => {
if (typeof plaintext !== "string") {
throw new Error("Decryption returned non-string payload.");
}
return { plaintext, scheme };
});
})
.filter(Boolean);
+53 -40
View File
@@ -44,16 +44,16 @@ import {
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
// PERF: Reduced from 20s to 10s — the signer is now guaranteed to be ready
// before list loading starts (authSessionCoordinator waits for the permission
// pre-grant), so decryption should succeed quickly. 10s is a generous fallback
// for slow relay responses while still failing fast enough to retry promptly.
const DECRYPT_TIMEOUT_MS = 10000;
// PERF: Reduced from 10s to 3s — extensions that already granted permission
// should recover quickly. The longer delay was causing unnecessary wait time
// during the critical login path when the first decrypt attempt fails due to
// the extension still initializing.
const DECRYPT_RETRY_DELAY_MS = 3000;
// PERF: Reduced from 10s to 6s — the signer is guaranteed ready before list
// loading starts (authSessionCoordinator waits for the permission pre-grant),
// so decryption should succeed within 2-3s. 6s is generous enough for slow
// relays while still failing fast enough for the scheme fallback to try the
// next decryption method without stalling the login path.
const DECRYPT_TIMEOUT_MS = 6000;
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
// should recover near-instantly. The shorter delay prevents unnecessary wait
// time during the critical login path when the first decrypt attempt fails.
const DECRYPT_RETRY_DELAY_MS = 1500;
function normalizeHexPubkey(value) {
if (typeof value !== "string") {
@@ -909,9 +909,10 @@ class SubscriptionsManager {
try {
// Race against a timeout so the UI doesn't hang indefinitely if relays stall.
// 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;
// The timeout accommodates relay fetch (~12s) + decryption (~6s timeout per
// scheme × up to 3 schemes = ~18s) = ~30s. Reduced from 45s to prevent
// blocking the login path for nearly a minute.
const timeoutMs = 25000;
await Promise.race([
loader,
new Promise((_, reject) =>
@@ -1005,28 +1006,33 @@ class SubscriptionsManager {
};
let signer = getActiveSigner();
// FIX: Always attempt to resolve the signer if one isn't available,
// regardless of allowPermissionPrompt. The previous guard meant that
// background refreshes (allowPermissionPrompt=false) could never decrypt
// because they skipped signer resolution entirely. ensureActiveSignerForPubkey
// does not itself prompt the user — it only waits for an already-injected
// extension or returns the existing signer.
if (
allowPermissionPrompt &&
(!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"))
(!signer || !signerHasRequiredDecryptors(signer)) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerCapabilities = signer?.capabilities;
const signerHasNip04 =
typeof signer?.nip04Decrypt === "function" &&
(!signerCapabilities || signerCapabilities.nip04 !== false);
const signerHasNip44 =
typeof signer?.nip44Decrypt === "function" &&
(!signerCapabilities || signerCapabilities.nip44 !== false);
// FIX: Check method existence only, not capabilities. NIP-07 adapters
// always expose decrypt methods that check extension state at call time.
// The capabilities getter dynamically queries window.nostr which may not
// have injected nip04/nip44 modules yet (lazy injection), causing false
// negatives that block decryption even when methods will work at call time.
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const nostrApi = allowPermissionPrompt
? typeof window !== "undefined"
? window?.nostr
: null
: null;
// FIX: Always check window.nostr for decrypt methods as a fallback,
// not only when allowPermissionPrompt is true. The extension may have
// already granted permissions from a previous session, and checking
// its methods doesn't trigger any user-visible prompts.
const nostrApi =
typeof window !== "undefined" ? window?.nostr : null;
const windowHasNip04 = typeof nostrApi?.nip04?.decrypt === "function";
const windowHasNip44 =
(nostrApi?.nip44 && typeof nostrApi.nip44.decrypt === "function") ||
@@ -1079,11 +1085,11 @@ class SubscriptionsManager {
decryptors.set(scheme, handler);
};
// PERF: Previous 5s no-prompt timeout was too aggressive — extensions that
// have already granted permission still need time to decrypt. 8s gives a
// realistic window while still failing faster than the full 15s interactive
// timeout.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
// gate now guarantees the extension is ready before decryption starts, so
// decrypt calls should complete within 1-2s. 5s accommodates slow
// extensions while still failing fast enough for scheme fallback.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
const signerDecryptOptions = {
priority: NIP07_PRIORITY.NORMAL,
timeoutMs: nip07DecryptTimeoutMs,
@@ -1219,12 +1225,19 @@ class SubscriptionsManager {
const decryptFn = decryptors.get(scheme);
if (!decryptFn) return null;
attemptSchemes.push(scheme);
return decryptFn(ciphertext).then((plaintext) => {
if (typeof plaintext !== "string") {
throw new Error("Decryption returned a non-string payload.");
}
return { plaintext, scheme };
});
// FIX: Wrap in Promise.resolve().then() to catch synchronous throws
// from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws
// immediately if the extension hasn't injected its nip44 module yet).
// Without this, a sync throw breaks the .map() chain and prevents
// Promise.any() from trying the remaining schemes.
return Promise.resolve()
.then(() => decryptFn(ciphertext))
.then((plaintext) => {
if (typeof plaintext !== "string") {
throw new Error("Decryption returned a non-string payload.");
}
return { plaintext, scheme };
});
})
.filter(Boolean);
+24 -8
View File
@@ -9982,7 +9982,23 @@ export class ProfileModalController {
return;
}
const isUnlocked = storageService && storageService.masterKeys.has(pubkey);
let isUnlocked = storageService && storageService.masterKeys.has(pubkey);
// Auto-unlock: If not already unlocked and a signer is available, attempt
// a silent unlock in the background. This eliminates the manual "Unlock"
// click for users whose extension has already granted permissions.
if (!isUnlocked && storageService && !this.storageUnlockFailure) {
const signer = getActiveSigner();
if (signer && (typeof signer.nip44Decrypt === "function" || typeof signer.nip04Decrypt === "function")) {
try {
await storageService.unlock(pubkey, { signer });
isUnlocked = storageService.masterKeys.has(pubkey);
} catch (autoUnlockError) {
// Silent failure — user can still manually unlock.
devLogger.log("[ProfileModal] Auto-unlock storage skipped:", autoUnlockError?.message || autoUnlockError);
}
}
}
if (this.storageStatusText) {
if (isUnlocked) {
@@ -10238,14 +10254,14 @@ export class ProfileModalController {
}
}
const caps = signer?.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: null;
const hasNip44Decrypt =
caps?.nip44 !== false && typeof signer?.nip44Decrypt === "function";
// FIX: Check for method existence only, not capabilities. NIP-07 adapters
// always expose nip04Decrypt/nip44Decrypt methods that perform their own
// existence checks at call time. The capabilities getter dynamically queries
// the extension which may not have injected its NIP-44/04 modules yet
// (lazy injection), causing a false-negative that blocks the unlock flow.
const hasNip44Decrypt = typeof signer?.nip44Decrypt === "function";
const hasNip04Decrypt =
caps?.nip04 !== false &&
(typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function");
typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function";
if (!hasNip44Decrypt && !hasNip04Decrypt) {
const missingDecryptError = new Error(