perf(profile): defer profile-panel rendering until the modal opens

Post-login the site froze for ~10-15s and the profile panel wouldn't open.
Cause: profileModalController.handleAuthLogin eagerly rendered EVERY profile
panel at login (twice) — friends list + per-contact avatar fetches,
subscriptions, blocks, relays, wallet, hashtags, storage — into a CLOSED
modal. With a responsive signer (nos2x) making decryption instant, that all
fired in one main-thread burst right after login. The block- and
subscription-"change" listeners and the moderation "contacts" listener also
re-rendered their panels unconditionally as that data loaded, compounding it.

selectPane() already lazily populates each pane when it's shown, so the eager
pass was redundant. Changes:
- handleAuthLogin: only run the full panel population when the modal is
  already open (e.g. account switch with it visible); otherwise rely on
  open()/selectPane. Kept the cheap DM-identity setup eager.
- Gate the userBlocks "change", subscriptions "change", and moderation
  "contacts" re-renders on isProfileModalOpen() so they don't paint into a
  closed modal during the post-login data burst.
- Add a "friends" case to selectPane() (the only pane without a lazy-populate
  trigger) so it still populates on open now that login doesn't.
- Add isProfileModalOpen() + populateOpenProfilePanels() helpers.

All panes remain covered (account is static; admin refreshes independently).
Full build + unit suite green. KNOWN_BUGS #0 (post-login freeze).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-16 16:06:26 -04:00
1 parent 4f7ef08935
commit a1a8f6cda6
1 file changed
+82 -19
+82 -19
View File
@@ -269,7 +269,13 @@ export class ProfileModalController {
this.unsubscribeModerationContacts = this.moderationService.on(
"contacts",
() => {
// Only re-render the friends list if the modal is open; otherwise
// selectPane("friends") repopulates it on demand. Avoids rendering
// (and avatar-fetching) the whole contact list into a closed panel
// during the post-login contacts burst.
if (this.isProfileModalOpen()) {
void this.populateFriendsList();
}
updateTrustStats();
},
);
@@ -542,7 +548,12 @@ export class ProfileModalController {
typeof this.services.userBlocks.on === "function"
) {
this.services.userBlocks.on("change", () => {
// Re-render the blocked pane only when the modal is open; selectPane
// repopulates it on demand. Avoids rendering into a closed panel during
// the post-login block-list load.
if (this.isProfileModalOpen()) {
this.populateBlockedList();
}
});
}
}
@@ -1207,6 +1218,37 @@ export class ProfileModalController {
// Whether the profile modal is currently visible. Used to avoid rendering
// profile-panel content into a CLOSED modal (e.g. the eager population that
// jammed the main thread right after login — KNOWN_BUGS #0 post-login freeze).
isProfileModalOpen() {
const root =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
return (
!!root &&
!root.classList.contains("hidden") &&
root.getAttribute("aria-hidden") !== "true"
);
}
// Render every profile panel at once. Only worth doing when the modal is
// actually open — otherwise rely on selectPane() to lazily populate each pane
// as it's shown.
populateOpenProfilePanels() {
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.relayController.populateProfileRelays();
this.walletController.refreshWalletPaneState();
this.hashtagController.populateHashtagPreferences();
void this.storageController.populateStoragePane();
void this.refreshDmRelayPreferences({ force: true });
}
setMessagesUnreadIndicator(visible) {
if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) {
return;
@@ -3251,6 +3293,10 @@ export class ProfileModalController {
this.services.userBlocks.loadBlocks(activeHex).catch(noop);
}
this.populateBlockedList();
} else if (target === "friends") {
// The friends pane has no other lazy-populate trigger now that login no
// longer eagerly renders it, so populate it on selection.
void this.populateFriendsList();
} else if (target === "safety") {
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
@@ -3501,7 +3547,13 @@ export class ProfileModalController {
this.subscriptionsBackgroundLoading = false;
}
// Re-render the subscriptions pane only when the modal is open (status
// bookkeeping above stays unconditional); selectPane repopulates it on
// demand, so we don't render into a closed panel during the post-login
// subscription-list load.
if (this.isProfileModalOpen()) {
void this.populateSubscriptionsList();
}
this.refreshSubscriptionsBackgroundStatus();
}
@@ -5179,28 +5231,30 @@ export class ProfileModalController {
}
}
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.relayController.populateProfileRelays();
this.walletController.refreshWalletPaneState();
this.hashtagController.populateHashtagPreferences();
void this.storageController.populateStoragePane();
// DM identity is cheap state setup needed for the background unread
// indicator + DM subscription, so it stays eager.
this.handleActiveDmIdentityChanged(activePubkey);
void this.refreshDmRelayPreferences({ force: true });
// Ensure critical state is settled if possible, but don't block initial rendering
Promise.all([walletPromise, adminPromise, postLoginPromise])
// Profile-PANEL rendering is deferred: each pane lazily populates when it's
// shown (selectPane), so eagerly rendering all of them at login paints into
// CLOSED panels — friends-list avatars, subscriptions, blocks, storage,
// 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
// modal is already open (e.g. switching accounts with it visible);
// otherwise open()/selectPane handles it on demand.
const settleLoginData = Promise.all([
walletPromise,
adminPromise,
postLoginPromise,
]);
if (this.isProfileModalOpen()) {
this.populateOpenProfilePanels();
settleLoginData
.then(() => {
// Re-run population to ensure any late-arriving data is reflected
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.relayController.populateProfileRelays();
this.walletController.refreshWalletPaneState();
this.hashtagController.populateHashtagPreferences();
void this.storageController.populateStoragePane();
void this.refreshDmRelayPreferences({ force: true });
if (this.isProfileModalOpen()) {
this.populateOpenProfilePanels();
}
})
.catch((error) => {
userLogger.warn(
@@ -5208,6 +5262,15 @@ export class ProfileModalController {
error,
);
});
} else {
// Keep the collaborators' promises observed even when we skip rendering.
settleLoginData.catch((error) => {
userLogger.warn(
"[profileModal] Failed to settle login data:",
error,
);
});
}
return true;
}