[profile] Refresh the profile modal on account switch (no manual reload)

Switching accounts with the profile modal open (e.g. NIP-07 -> a saved nsec) left
the PREVIOUS account's DMs, subscriptions, hashtags and lists on screen until the
user refreshed. Two problems:

1. handleAuthLogin ran its identity-reset steps unguarded, so if any one threw
   (renderSavedProfiles, a warm-list call, etc.) the steps after it were skipped —
   including handleActiveDmIdentityChanged, which is what clears the previous
   account's conversation. Result: stale DMs after a switch. Each reset step now
   runs in isolation via runIdentityResetStep, and the DM reset runs first.

2. The switch-with-modal-open refresh called populateOpenProfilePanels(), which
   only paints from current service state and omits lazily-loaded panes (messages,
   watch history). It also fired before the new identity's lists had loaded, with
   no reliable refresh afterward. Replaced with refreshOpenPanesForActiveIdentity(),
   which re-runs the ACTIVE pane through selectPane() — the same load+populate path
   as opening it fresh — so DMs reload, subscriptions/hashtags refetch, etc. It runs
   immediately, again once profile/wallet data settles, and once more from the auth
   coordinator when the new identity's blocks/subscriptions/hashtags finish loading
   (backfill for slow sync). Non-active panes stay lazy and repopulate on select.

Per-user list caches (blocks/subscriptions/hashtags are already keyed per pubkey)
mean the active pane shows cached data instantly on switch, then updates when the
new identity's fetch settles — no manual refresh.

Tests (tests/profile-modal-controller.test.mjs +3): a throwing reset step is
isolated; a throwing DM reset no longer skips the saved-profiles reset; and
refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is
open. Existing profile-modal / switch-coordinator / stored-nsec-switch suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 16:31:01 -04:00
1 parent b8ac09ef7e
commit 87af650b38
3 files changed
+196 -27

No files matched your search

+20
View File
@@ -650,6 +650,26 @@ export function createAuthSessionCoordinator(deps) {
authLoadingState: this.authLoadingState, authLoadingState: this.authLoadingState,
}); });
// Now that the NEW identity's lists (blocks/subscriptions/hashtags) have
// settled, backfill the profile modal if it is open — so switching
// accounts with the modal visible refreshes the pane the user is viewing
// with the freshly-loaded data instead of leaving the previous account's
// list on screen until a manual refresh. No-op when the modal is closed.
if (
this.profileController &&
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
"function"
) {
try {
this.profileController.refreshOpenPanesForActiveIdentity();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh profile panes after lists settled:",
error,
);
}
}
// Warm the watch history in the background now that the feed-driving // Warm the watch history in the background now that the feed-driving
// lists (blocks/subscriptions/hashtags) have finished decrypting. This // lists (blocks/subscriptions/hashtags) have finished decrypting. This
// runs AFTER the required lists — never concurrently — so it does not // runs AFTER the required lists — never concurrently — so it does not
+69 -27
View File
@@ -1258,6 +1258,39 @@ export class ProfileModalController {
void this.refreshDmRelayPreferences({ force: true }); void this.refreshDmRelayPreferences({ force: true });
} }
// Run one identity-switch reset step in isolation. A failure in a single step
// must not skip the ones after it — notably the DM reset that clears the
// previous account's conversation on a switch.
runIdentityResetStep(label, fn) {
try {
fn();
} catch (error) {
userLogger.warn(
`[profileModal] Identity reset step "${label}" failed:`,
error,
);
}
}
// Refresh the profile modal for the ACTIVE identity while it is open (e.g. right
// after switching accounts). Re-runs the current pane's own lazy load+populate —
// the SAME path as opening the pane fresh — so DMs reload, subscriptions and
// hashtags refetch for the new account, blocked/relays/storage repopulate, etc.
// Non-active panes stay lazy and repopulate when the user selects them.
refreshOpenPanesForActiveIdentity() {
if (!this.isProfileModalOpen()) {
return;
}
try {
this.selectPane(this.getActivePane());
} catch (error) {
userLogger.warn(
"[profileModal] Failed to refresh the active pane for the current identity:",
error,
);
}
}
setMessagesUnreadIndicator(visible) { setMessagesUnreadIndicator(visible) {
if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) { if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) {
return; return;
@@ -5223,46 +5256,57 @@ export class ProfileModalController {
this.setActivePubkey(activePubkey); this.setActivePubkey(activePubkey);
} }
// Reset identity-scoped UI state up front, each step isolated so one throwing
// can't skip the others. Regression this guards: switching from NIP-07 to a
// saved nsec (modal open) left the PREVIOUS account's DMs, subscriptions and
// hashtags on screen because a sibling call threw before the DM reset ran.
// DM identity setup also feeds the background unread indicator + DM
// subscription, so it stays eager.
this.runIdentityResetStep("dm-identity", () =>
this.handleActiveDmIdentityChanged(activePubkey),
);
this.runIdentityResetStep("saved-profiles", () => this.renderSavedProfiles());
const walletPromise = this.hydrateActiveWalletSettings(activePubkey); const walletPromise = this.hydrateActiveWalletSettings(activePubkey);
const adminPromise = this.refreshAdminPaneState().catch((error) => { const adminPromise = this.refreshAdminPaneState().catch((error) => {
userLogger.warn("Failed to refresh admin pane after login:", error); userLogger.warn("Failed to refresh admin pane after login:", error);
}); });
this.renderSavedProfiles();
// Warm blocks + subscriptions, but go through ensureLoaded() (not the raw // Warm blocks + subscriptions, but go through ensureLoaded() (not the raw
// loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's // loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's
// login-time list loads instead of issuing a second, ungated fetch. The // login-time list loads instead of issuing a second, ungated fetch. The
// direct calls here each kicked off an independent relay round-trip, // direct calls here each kicked off an independent relay round-trip,
// doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0). // doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0).
if (activePubkey) { if (activePubkey) {
if (this.services.userBlocks?.ensureLoaded) { this.runIdentityResetStep("warm-lists", () => {
this.services.userBlocks.ensureLoaded(activePubkey).catch(noop); if (this.services.userBlocks?.ensureLoaded) {
} else if (this.services.userBlocks?.loadBlocks) { this.services.userBlocks.ensureLoaded(activePubkey).catch(noop);
this.services.userBlocks.loadBlocks(activePubkey).catch(noop); } else if (this.services.userBlocks?.loadBlocks) {
} this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
if (this.subscriptionsService?.ensureLoaded) { }
this.subscriptionsService if (this.subscriptionsService?.ensureLoaded) {
.ensureLoaded(activePubkey, { allowPermissionPrompt: false }) this.subscriptionsService
.catch(noop); .ensureLoaded(activePubkey, { allowPermissionPrompt: false })
} else if (this.subscriptionsService?.loadSubscriptions) { .catch(noop);
this.subscriptionsService } else if (this.subscriptionsService?.loadSubscriptions) {
.loadSubscriptions(activePubkey, { allowPermissionPrompt: false }) this.subscriptionsService
.catch(noop); .loadSubscriptions(activePubkey, { allowPermissionPrompt: false })
} .catch(noop);
}
});
} }
// DM identity is cheap state setup needed for the background unread
// indicator + DM subscription, so it stays eager.
this.handleActiveDmIdentityChanged(activePubkey);
// Profile-PANEL rendering is deferred: each pane lazily populates when it's // Profile-PANEL rendering is deferred: each pane lazily populates when it's
// shown (selectPane), so eagerly rendering all of them at login paints into // shown (selectPane), so eagerly rendering all of them at login paints into
// CLOSED panels — friends-list avatars, subscriptions, blocks, storage, // CLOSED panels — friends-list avatars, subscriptions, blocks, storage,
// relays — and jams the main thread right after login (the ~10-15s "profile // relays — and jams the main thread right after login (the ~10-15s "profile
// panel won't open / site frozen" report). Only do the eager pass when the // panel won't open / site frozen" report). Only refresh when the modal is
// modal is already open (e.g. switching accounts with it visible); // already open (e.g. switching accounts with it visible); otherwise
// otherwise open()/selectPane handles it on demand. // open()/selectPane handles it on demand. On a switch we re-run the ACTIVE
// pane's own load+populate (refreshOpenPanesForActiveIdentity) so the pane the
// user is viewing reloads for the new identity, then again once profile/wallet
// data settles (list panes also backfill via the auth coordinator once the new
// identity's lists finish loading).
const settleLoginData = Promise.all([ const settleLoginData = Promise.all([
walletPromise, walletPromise,
adminPromise, adminPromise,
@@ -5270,12 +5314,10 @@ export class ProfileModalController {
]); ]);
if (this.isProfileModalOpen()) { if (this.isProfileModalOpen()) {
this.populateOpenProfilePanels(); this.refreshOpenPanesForActiveIdentity();
settleLoginData settleLoginData
.then(() => { .then(() => {
if (this.isProfileModalOpen()) { this.refreshOpenPanesForActiveIdentity();
this.populateOpenProfilePanels();
}
}) })
.catch((error) => { .catch((error) => {
userLogger.warn( userLogger.warn(
+107
View File
@@ -1368,3 +1368,110 @@ test('handleDirectMessagesRelayWarning suppresses status updates when disabled',
controller.handleDirectMessagesRelayWarning(detail); controller.handleDirectMessagesRelayWarning(detail);
assert.equal(statusCalls.length, 0); assert.equal(statusCalls.length, 0);
}); });
// Account-switch refresh (nip-07 -> saved nsec, modal open): the modal must
// re-render for the NEW identity without a manual refresh, and a failure in one
// identity-reset step must not skip the others (notably the DM reset that clears
// the previous account's conversation).
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-switch-modal-refresh
// given: "profile modal controller loaded"
// when: "handleAuthLogin runs for a new identity / refreshOpenPanesForActiveIdentity is called"
// then: "DM reset still runs after a sibling throw; the active pane is re-run when open"
// observable_outcomes:
// - "runIdentityResetStep swallows a throwing step (does not propagate)"
// - "a throwing DM reset does not skip renderSavedProfiles"
// - "refreshOpenPanesForActiveIdentity re-runs selectPane(activePane) only when open"
// determinism_controls:
// - "instance method spies; no network/timers relied upon for assertions"
// anti_cheat_rationale:
// prevents: ["over-mocking internal logic", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
test('runIdentityResetStep isolates a throwing step (does not propagate)', async () => {
const controller = createController();
let ran = false;
assert.doesNotThrow(() => {
controller.runIdentityResetStep('boom', () => {
throw new Error('kaboom');
});
controller.runIdentityResetStep('ok', () => {
ran = true;
});
});
assert.equal(ran, true, 'a later reset step still runs after an earlier one throws');
});
test('handleAuthLogin: a throwing DM reset does not skip the saved-profiles reset', async (t) => {
const controller = createController();
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
// Keep the async collaborators inert so the test only exercises the reset order.
controller.hydrateActiveWalletSettings = async () => null;
controller.refreshAdminPaneState = async () => {};
controller.refreshOpenPanesForActiveIdentity = () => {};
let dmResetPubkey = 'unset';
controller.handleActiveDmIdentityChanged = (pubkey) => {
dmResetPubkey = pubkey;
throw new Error('dm reset blew up');
};
let savedProfilesRendered = 0;
controller.renderSavedProfiles = () => {
savedProfilesRendered += 1;
};
const pubkey = 'b'.repeat(64);
const result = await controller.handleAuthLogin({
pubkey,
activeProfilePubkey: pubkey,
identityChanged: true,
});
assert.equal(result, true);
assert.equal(dmResetPubkey, pubkey, 'DM reset was attempted for the new identity');
assert.equal(
savedProfilesRendered,
1,
'the saved-profiles reset still ran even though the DM reset threw',
);
});
test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is open', async (t) => {
const controller = createController();
await controller.load();
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
resetRuntimeFlags();
});
const selected = [];
controller.selectPane = (name) => {
selected.push(name);
};
controller.getActivePane = () => 'messages';
controller.isProfileModalOpen = () => false;
controller.refreshOpenPanesForActiveIdentity();
assert.deepEqual(selected, [], 'closed modal: no pane refresh');
controller.isProfileModalOpen = () => true;
controller.refreshOpenPanesForActiveIdentity();
assert.deepEqual(
selected,
['messages'],
'open modal: re-runs the active pane so it reloads for the new identity',
);
});