perf: speed up post-login list sync with shared decryption cache

Three performance bottlenecks were causing slow list loading after NIP-07 login:

1. Each list service (blocks, subscriptions, hashtag preferences) maintained
   its own isolated decryptionSuccessCache. When blocks decrypted with nip44_v2,
   subscriptions and hashtag preferences had no way to know — they each had to
   independently probe all schemes through the NIP-07 extension queue, tripling
   the number of extension calls during the critical login path.

2. List loading was gated on both relaysReadyPromise AND permissionPromise in
   the coordinator. Since the NIP-07 extension already grants decrypt permissions
   during loginWithExtension(), the redundant pre-grant was adding 2-5 seconds
   of unnecessary latency before any list fetching could begin.

3. The DECRYPT_RETRY_DELAY_MS was 10 seconds across all services. When a decrypt
   attempt failed transiently (extension still initializing), the retry waited
   far too long before trying again.

Changes:
- Create shared decryptionSchemeCache module used by all three list services
- Add fast-path: when shared cache knows the correct scheme, try ONLY that
  scheme before falling back to parallel Promise.any() probe
- Remove permissionPromise from the list-loading gate in authSessionCoordinator
- Reduce DECRYPT_RETRY_DELAY_MS from 10s to 3s in all three services
- Clear shared cache on logout

https://claude.ai/code/session_013QrGMnxtMTsGTPqaP35jMj
This commit is contained in:
Claude committed 2026-02-07 13:43:15 +00:00
1 parent d792570b69
commit 71c153f68d
5 files changed
+163 -44

No files matched your search

+11 -4
View File
@@ -9,6 +9,8 @@
* Methods use `this` which is bound to the Application instance.
*/
import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js";
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
@@ -218,10 +220,14 @@ export function createAuthSessionCoordinator(deps) {
// parallel. They have no data dependencies on each other — only the feed
// render needs all three to be settled. Loading them concurrently shaves
// 20-30 seconds off the critical login path.
const listStatePromise = Promise.all([
relaysReadyPromise,
permissionPromise,
]).then(async () => {
//
// PERF: Lists no longer wait for the permission pre-grant to complete.
// The NIP-07 extension already granted decrypt permissions during the
// login flow (ensureExtensionPermissions is called in loginWithExtension),
// so the signer adapter's decrypt functions work immediately. Waiting for
// the redundant pre-grant was adding 2-5 seconds of unnecessary latency
// before list loading could even begin.
const listStatePromise = relaysReadyPromise.then(async () => {
const parallelListTasks = [];
if (
@@ -535,6 +541,7 @@ export function createAuthSessionCoordinator(deps) {
this.resetHashtagPreferencesState();
this.resetPermissionPromptState();
clearDecryptionSchemeCache();
this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" });
await this.nwcSettingsService.onLogout({
+66
View File
@@ -0,0 +1,66 @@
// js/nostr/decryptionSchemeCache.js
/**
* Shared decryption scheme cache across all list services (blocks,
* subscriptions, hashtag preferences). When one service discovers the
* correct decryption scheme for a pubkey, the others can reuse it
* immediately instead of independently probing the NIP-07 extension.
*
* This eliminates redundant extension decrypt calls during the critical
* login path where all three services decrypt simultaneously.
*/
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/** @type {Map<string, { scheme: string, timestamp: number }>} */
const cache = new Map();
/**
* Retrieve the last successfully-used decryption scheme for a pubkey.
* Returns null if the entry is missing or expired.
*
* @param {string} pubkey - Hex pubkey to look up.
* @returns {string|null} The scheme identifier (e.g. "nip44_v2") or null.
*/
export function getLastSuccessfulScheme(pubkey) {
if (typeof pubkey !== "string" || !pubkey) {
return null;
}
const entry = cache.get(pubkey);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > DECRYPTION_CACHE_TTL_MS) {
cache.delete(pubkey);
return null;
}
return entry.scheme;
}
/**
* Record a successful decryption scheme for a pubkey so other services
* can skip scheme discovery.
*
* @param {string} pubkey - Hex pubkey.
* @param {string} scheme - The scheme that succeeded (e.g. "nip44_v2").
*/
export function setLastSuccessfulScheme(pubkey, scheme) {
if (typeof pubkey !== "string" || !pubkey) {
return;
}
if (typeof scheme !== "string" || !scheme) {
return;
}
cache.set(pubkey, { scheme, timestamp: Date.now() });
}
/**
* Clear all cached entries. Useful on logout.
*/
export function clearDecryptionSchemeCache() {
cache.clear();
}
+27 -13
View File
@@ -26,16 +26,18 @@ import {
NIP07_PRIORITY,
} from "../nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./relaySubscriptionService.js";
import {
getLastSuccessfulScheme,
setLastSuccessfulScheme,
} from "../nostr/decryptionSchemeCache.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const DEFAULT_VERSION = 1;
const DECRYPT_TIMEOUT_MS = 20000;
const DECRYPT_RETRY_DELAY_MS = 10000;
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
const decryptionSuccessCache = new Map();
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
class TinyEventEmitter {
constructor() {
@@ -1076,17 +1078,32 @@ class HashtagPreferencesService {
return { ok: false, error };
}
const cachedEntry = decryptionSuccessCache.get(userPubkey);
const cachedScheme =
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
? cachedEntry.scheme
: null;
// Use the shared cross-service cache so all list services benefit from
// the first successful decrypt during login.
const cachedScheme = getLastSuccessfulScheme(userPubkey);
const order = determineDecryptionOrder(
event,
Array.from(decryptors.keys()),
cachedScheme,
);
// PERF: Fast path — if the shared cache already knows the correct scheme
// (e.g. blocks or subscriptions decrypted first), try ONLY that scheme
// before falling back to the full parallel probe. This avoids queuing
// redundant NIP-07 extension calls.
if (cachedScheme && decryptors.has(cachedScheme)) {
try {
const plaintext = await decryptors.get(cachedScheme)(ciphertext);
if (typeof plaintext === "string") {
setLastSuccessfulScheme(userPubkey, cachedScheme);
return { ok: true, plaintext, scheme: cachedScheme };
}
} catch (_fastPathError) {
// Cached scheme failed — fall through to parallel probe.
}
}
// PERF: Try all decryption schemes in parallel via Promise.any().
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
// per scheme that previously made worst-case decryption take 45s+.
@@ -1116,10 +1133,7 @@ class HashtagPreferencesService {
if (attempts.length) {
try {
const result = await Promise.any(attempts);
decryptionSuccessCache.set(userPubkey, {
scheme: result.scheme,
timestamp: Date.now(),
});
setLastSuccessfulScheme(userPubkey, result.scheme);
return { ok: true, plaintext: result.plaintext, scheme: result.scheme };
} catch (aggregateError) {
const attemptErrors = (aggregateError.errors || []).map((err, i) => ({
+29 -13
View File
@@ -37,14 +37,19 @@ import {
NIP07_PRIORITY,
} from "./nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
import {
getLastSuccessfulScheme,
setLastSuccessfulScheme,
} from "./nostr/decryptionSchemeCache.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
const DECRYPT_TIMEOUT_MS = 20000;
const DECRYPT_RETRY_DELAY_MS = 10000;
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
const decryptionSuccessCache = new Map();
// 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;
function normalizeHexPubkey(value) {
if (typeof value !== "string") {
@@ -1097,17 +1102,31 @@ class SubscriptionsManager {
}
const availableSchemes = Array.from(decryptors.keys());
const cachedEntry = decryptionSuccessCache.get(userPubkey);
const cachedScheme =
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
? cachedEntry.scheme
: null;
// Use the shared cross-service cache so all list services benefit from
// the first successful decrypt during login.
const cachedScheme = getLastSuccessfulScheme(userPubkey);
const order = determineDecryptionOrder(
event,
availableSchemes,
cachedScheme,
);
// PERF: Fast path — if the shared cache already knows the correct scheme
// (e.g. blocks decrypted first), try ONLY that scheme before falling back
// to the full parallel probe. This avoids queuing redundant extension calls.
if (cachedScheme && decryptors.has(cachedScheme)) {
try {
const plaintext = await decryptors.get(cachedScheme)(ciphertext);
if (typeof plaintext === "string") {
setLastSuccessfulScheme(userPubkey, cachedScheme);
return { ok: true, plaintext, scheme: cachedScheme };
}
} catch (_fastPathError) {
// Cached scheme failed — fall through to parallel probe.
}
}
// PERF: Try all decryption schemes in parallel via Promise.any().
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
// per scheme that previously made worst-case decryption take 45s+.
@@ -1137,10 +1156,7 @@ class SubscriptionsManager {
if (attempts.length) {
try {
const result = await Promise.any(attempts);
decryptionSuccessCache.set(userPubkey, {
scheme: result.scheme,
timestamp: Date.now(),
});
setLastSuccessfulScheme(userPubkey, result.scheme);
return { ok: true, plaintext: result.plaintext, scheme: result.scheme };
} catch (aggregateError) {
const attemptErrors = (aggregateError.errors || []).map((err, i) => ({
+30 -14
View File
@@ -26,6 +26,10 @@ import {
NIP07_PRIORITY,
} from "./nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
import {
getLastSuccessfulScheme,
setLastSuccessfulScheme,
} from "./nostr/decryptionSchemeCache.js";
class TinyEventEmitter {
constructor() {
@@ -81,11 +85,9 @@ const FAST_BLOCKLIST_TIMEOUT_MS = 2500;
const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000;
const DECRYPT_TIMEOUT_MS = 20000;
const BACKGROUND_DECRYPT_TIMEOUT_MS = 15000;
const DECRYPT_RETRY_DELAY_MS = 10000;
// PERF: Reduced from 10s to 3s for faster recovery during login.
const DECRYPT_RETRY_DELAY_MS = 3000;
const MAX_BLOCKLIST_ENTRIES = 5000;
const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000;
const decryptionSuccessCache = new Map();
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
@@ -1159,11 +1161,9 @@ class UserBlockListManager {
permissionError.signerStatus = signerStatus;
}
const cachedEntry = decryptionSuccessCache.get(normalized);
const cachedScheme =
cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS
? cachedEntry.scheme
: null;
// Use the shared cross-service cache so all list services benefit from
// the first successful decrypt during login.
const cachedScheme = getLastSuccessfulScheme(normalized);
return {
decryptors,
@@ -1306,12 +1306,30 @@ class UserBlockListManager {
throw error;
}
// PERF: Fast path — if the shared cache already knows the correct
// scheme (e.g. another list decrypted first), try ONLY that scheme
// before falling back to the full parallel probe. This avoids
// queuing redundant NIP-07 extension calls.
let decryptedText = "";
const sharedCachedScheme = getLastSuccessfulScheme(normalized);
if (sharedCachedScheme && decryptors.has(sharedCachedScheme)) {
try {
const fastResult = await decryptors.get(sharedCachedScheme)(ev.content);
if (typeof fastResult === "string" && fastResult) {
decryptedText = fastResult;
setLastSuccessfulScheme(normalized, sharedCachedScheme);
}
} catch {
// Cached scheme failed — fall through to parallel probe.
}
}
// PERF: Try all decryption schemes in parallel via Promise.any().
// The first scheme to succeed wins, avoiding sequential 3-15s timeouts
// per scheme that previously made worst-case decryption take 45s+.
// Deduplicate schemes in the same family (nip44/nip44_v2 both call
// the same underlying signer.nip44Decrypt) to avoid redundant calls.
let decryptedText = "";
if (!decryptedText) {
try {
const getSchemeFamily = (s) =>
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
@@ -1334,14 +1352,12 @@ class UserBlockListManager {
if (attempts.length) {
const result = await Promise.any(attempts);
decryptedText = result.plaintext;
decryptionSuccessCache.set(normalized, {
scheme: result.scheme,
timestamp: Date.now(),
});
setLastSuccessfulScheme(normalized, result.scheme);
}
} catch {
// All schemes failed — decryptedText stays empty
}
}
if (!decryptedText) {
return [];