perf(login): stop eagerly loading DM history at login

Real-env console (cold nip-07 login) showed list decryption failing en
masse: blocks/hashtags/subscriptions fetched fine (relay cap working —
every sub now fans out to exactly 8 relays) but decryption timed out for
minutes with "message channel closed", only recovering on a page refresh.

Root cause: the single-threaded nip-07 extension channel is overwhelmed
during the post-login handshake. One contributor is the eager DM load —
loadDirectMessages({ limit: 50, initialLoad: true }) ran at login and
fired a burst of decrypts that competed with the feed-driving lists
(blocks/subscriptions/hashtags) for the same channel. DMs are lower
priority (the user isn't viewing Messages at login) and already load
lazily when the Messages tab opens.

Defer the eager DM load; report dms state as "deferred". The lightweight
unread-indicator refresh still runs so the badge stays accurate. First
increment of the "cut the login burst" work (KNOWN_BUGS #0).

Test: tests/app/login-defers-dms.test.mjs (cheat-resistant — asserts
loadDirectMessages is never called at login, dms state is "deferred", and
the unread indicator still runs).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-14 23:16:40 -04:00
1 parent 55bd181867
commit 71cd04b215
2 files changed
+155 -24

No files matched your search

+12 -24
View File
@@ -618,31 +618,19 @@ export function createAuthSessionCoordinator(deps) {
return listSyncDetail;
});
// DMs can wait for profile since they need encryption context.
// DMs are intentionally NOT eagerly loaded at login. A cold DM load
// (limit: 50) is a burst of nip-07 decrypts that competes with the
// feed-driving lists (blocks/subscriptions/hashtags) for the SAME
// single-threaded extension channel during the fragile post-login
// handshake window — under load that channel drops ("message channel
// closed") and every subsequent decrypt times out until a page refresh.
// DMs are lower priority (the user isn't viewing Messages at login) and
// already load lazily when the Messages tab is opened
// (ProfileDirectMessageActions.populateProfileMessages). The lightweight
// unread indicator (refreshUnreadDmIndicator, above) still runs so the
// badge is accurate without decrypting the full conversation history.
const dmStatePromise = profileStatePromise.then(() => {
if (
activePubkey &&
this.nostrService &&
typeof this.nostrService.loadDirectMessages === "function"
) {
return this.nostrService
.loadDirectMessages({
actorPubkey: activePubkey,
limit: 50,
initialLoad: true,
})
.then(() => {
this.updateAuthLoadingState({ dms: "ready" });
})
.catch((error) => {
devLogger.warn(
"[Application] Failed to load direct messages during login:",
error,
);
this.updateAuthLoadingState({ dms: "error" });
});
}
this.updateAuthLoadingState({ dms: "idle" });
this.updateAuthLoadingState({ dms: "deferred" });
return Promise.resolve();
});
+143
View File
@@ -0,0 +1,143 @@
// Scenario (SCN-login-defers-dms):
// Given a user logs in via the auth session coordinator,
// When the post-login hydration runs (profile + feed-driving lists settle),
// Then the coordinator MUST NOT eagerly load the full direct-message history
// (a cold limit:50 DM load is a burst of nip-07 decrypts that competes with
// blocks/subscriptions/hashtags for the single-threaded extension channel
// during the fragile handshake window — see KNOWN_BUGS #0). DMs load lazily
// when the Messages tab is opened instead.
// And the DM loading state is reported as "deferred" (not "ready"/"loading"),
// And the lightweight unread indicator still runs so the badge stays accurate.
import { test } from "node:test";
import assert from "node:assert/strict";
// Minimal browser shim: handleAuthLogin reads window.location.search.
if (typeof globalThis.window === "undefined") {
globalThis.window = { location: { search: "", href: "http://localhost/" } };
}
const { createAuthSessionCoordinator } = await import(
"../../js/app/authSessionCoordinator.js"
);
const ACTIVE_PUBKEY = "a".repeat(64);
function makeCoordinator({ loadDirectMessages, refreshUnreadDmIndicator }) {
const noop = () => {};
const asyncNoop = async () => {};
const logger = { warn: noop, info: noop, log: noop, error: noop, debug: noop };
const coord = createAuthSessionCoordinator({
devLogger: logger,
userLogger: logger,
nostrClient: {
// Signer resolves so the readiness gate completes (non-extension type so
// it doesn't attempt a real permission prompt).
ensureActiveSignerForPubkey: async () => ({ type: "nsec", pubkey: ACTIVE_PUBKEY }),
ensureExtensionPermissions: async () => ({ ok: true }),
},
accessControl: { ensureReady: asyncNoop, getBlacklist: () => [] },
userBlocks: {
getBlockedPubkeys: () => [],
seedWithNpubs: asyncNoop,
},
subscriptions: { ensureLoaded: asyncNoop, lastLoadError: null },
hashtagPreferences: { load: asyncNoop, lastLoadError: null },
storageService: {},
relayManager: {},
torrentClient: {},
getHashViewName: () => "",
setHashView: noop,
DEFAULT_NIP07_PERMISSION_METHODS: [],
RELAY_UI_BATCH_DELAY_MS: 0,
sanitizeRelayList: (x) => x,
buildDmRelayListEvent: noop,
publishEventToRelays: noop,
assertAnyRelayAccepted: noop,
queueSignEvent: noop,
bootstrapTrustedSeeds: noop,
getModerationSettings: () => ({}),
getActiveProfilePubkey: () => null,
});
// Collaborators the login flow touches on `this` — stub as inert so the flow
// runs end to end without real UI/services.
coord.pubkey = ACTIVE_PUBKEY;
coord.profileController = null;
coord.commentController = null;
coord.nwcSettingsService = null;
coord.authService = {
loadBlocksForPubkey: async () => true,
};
coord.nostrService = {
loadDirectMessages,
listDirectMessageConversationSummaries: async () => [],
};
const inert = [
"applyAuthenticatedUiState",
"capturePermissionPromptFromError",
"dispatchAuthChange",
"forceRefreshAllProfiles",
"maybeShowExperimentalLoginWarning",
"reinitializeVideoListView",
"renderSavedProfiles",
"refreshAllVideoGrids",
"resetPermissionPromptState",
"resetViewLoggingState",
"updateActiveProfileUI",
"updateCachedHashtagPreferences",
"updateShareNostrAuthState",
];
for (const name of inert) {
coord[name] = noop;
}
coord.normalizeHexPubkey = (x) => x;
coord.refreshUnreadDmIndicator = refreshUnreadDmIndicator;
coord.updateAuthLoadingState = (patch) => {
coord.authLoadingState = { ...(coord.authLoadingState || {}), ...patch };
};
coord.authLoadingState = { profile: "idle", lists: "idle", dms: "idle" };
return coord;
}
test("login does not eagerly load DM history; it is deferred", async () => {
let loadDmCalls = 0;
let unreadIndicatorCalls = 0;
const coord = makeCoordinator({
loadDirectMessages: async () => {
loadDmCalls += 1;
return { ok: true };
},
refreshUnreadDmIndicator: async () => {
unreadIndicatorCalls += 1;
},
});
await coord.handleAuthLogin({
pubkey: ACTIVE_PUBKEY,
identityChanged: true,
postLogin: { profile: { name: "tester" } },
postLoginPromise: Promise.resolve({ profile: { name: "tester" } }),
});
// Let every post-login promise chain (profile -> dm/nwc, feed sync) settle.
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(
loadDmCalls,
0,
"login must NOT eagerly load DM history (it competes with feed lists for the nip-07 channel)",
);
assert.equal(
coord.authLoadingState.dms,
"deferred",
"DM loading state should be reported as deferred at login",
);
assert.ok(
unreadIndicatorCalls >= 1,
"the lightweight unread DM indicator should still run so the badge stays accurate",
);
});