fix(profile): hoist the activity hook above the early return
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s

The activity selector sat below `if (!isOpen || !user) return null`. `user`
arrives asynchronously, so the hook ran on some renders and not others; React
counts hooks per render and tore the tree down with error #310 as soon as a
profile finished loading.

Move it above the guard and let the selector tolerate a null user. Typecheck
and the suite both passed with the bug in place — TypeScript cannot see hook
order and nothing renders this modal across the null-to-loaded transition.
This commit is contained in:
2026-08-31 12:12:23 -03:00
parent d80de49768
commit 63afd2fc89
@@ -144,19 +144,27 @@ export function UserProfileModal() {
return () => document.removeEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey);
}, [isOpen, closeModal]); }, [isOpen, closeModal]);
// Keyed by home id, matching every other activity consumer (ActivityPanel,
// MemberSidebar), so federated users resolve to the same record.
//
// Must sit ABOVE the early return: `user` is null on the first render and
// arrives asynchronously, so a hook below it runs on some renders and not
// others — React counts hooks per render and aborts the tree (#310).
// The `?? []` stays OUTSIDE the selector; building it inside would hand
// zustand a fresh array reference every render and spin.
const activityList = useActivityStore((s) =>
user ? s.userActivities.get(user.homeUserId ?? user.id) : undefined,
);
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
const activities = activityList ?? [];
const { baseName, domain } = parseFederatedUsername(user.username); const { baseName, domain } = parseFederatedUsername(user.username);
const displayName = user.displayName ?? baseName; const displayName = user.displayName ?? baseName;
// Banner — use correct API client for remote users // Banner — use correct API client for remote users
const profileApi = getApiForOrigin(userOrigin); const profileApi = getApiForOrigin(userOrigin);
// Keyed by home id, matching every other activity consumer (ActivityPanel,
// MemberSidebar), so federated users resolve to the same record. The `?? []`
// stays OUTSIDE the selector: building it inside would hand zustand a fresh
// array reference every render and spin.
const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id));
const activities = activityList ?? [];
const bannerSrc = user.banner const bannerSrc = user.banner
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner)) ? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))