perf(renderer): lazy render member hover cards
This commit is contained in:
parent
d59865f300
commit
2c13516d9f
2 changed files with 198 additions and 114 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
|
|
@ -59,9 +59,7 @@ interface MemberHoverCardProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Wraps children in a HoverCard that shows member info on hover.
|
||||
* Reads member data from the team snapshot + resolved member selectors.
|
||||
* Falls back to a simple wrapper when member data is unavailable.
|
||||
* Wraps children in a HoverCard that mounts detailed member data only while open.
|
||||
*/
|
||||
export const MemberHoverCard = memo(function MemberHoverCard({
|
||||
name,
|
||||
|
|
@ -70,10 +68,39 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
onOpenTask,
|
||||
children,
|
||||
}: MemberHoverCardProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<HoverCard open={open} onOpenChange={setOpen} openDelay={300} closeDelay={200}>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
{open ? (
|
||||
<MemberHoverCardContent
|
||||
name={name}
|
||||
color={color}
|
||||
teamName={teamName}
|
||||
onOpenTask={onOpenTask}
|
||||
/>
|
||||
) : null}
|
||||
</HoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
interface MemberHoverCardContentProps {
|
||||
name: string;
|
||||
color?: string;
|
||||
teamName?: string;
|
||||
onOpenTask?: (task: TeamTaskWithKanban) => void;
|
||||
}
|
||||
|
||||
const MemberHoverCardContent = ({
|
||||
name,
|
||||
color,
|
||||
teamName,
|
||||
onOpenTask,
|
||||
}: MemberHoverCardContentProps): React.JSX.Element | null => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const { isLight } = useTheme();
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||
const effectiveTeamName = useStore((s) => teamName ?? s.selectedTeamName);
|
||||
const {
|
||||
member,
|
||||
teamMembers,
|
||||
|
|
@ -120,10 +147,10 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
}))
|
||||
);
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
const avatarMap = buildMemberAvatarMap(teamMembers);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
|
||||
if (!member) {
|
||||
return <>{children}</>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
||||
|
|
@ -238,8 +265,6 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
: null;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={300} closeDelay={200}>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" sideOffset={8}>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Header: avatar + name + presence */}
|
||||
|
|
@ -344,6 +369,5 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -68,8 +69,25 @@ const storeState = {
|
|||
openMemberProfile: vi.fn(),
|
||||
};
|
||||
|
||||
const hoverCardMockState = vi.hoisted(() => ({
|
||||
autoOpen: true,
|
||||
wideSelectorCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
useStore: (selector: (state: typeof storeState) => unknown) => {
|
||||
const selected = selector(storeState);
|
||||
if (
|
||||
selected &&
|
||||
typeof selected === 'object' &&
|
||||
'member' in selected &&
|
||||
'teamMembers' in selected &&
|
||||
'runtimeEntry' in selected
|
||||
) {
|
||||
hoverCardMockState.wideSelectorCalls += 1;
|
||||
}
|
||||
return selected;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||
|
|
@ -100,8 +118,22 @@ vi.mock('@renderer/components/ui/badge', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/hover-card', () => ({
|
||||
HoverCard: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
HoverCard: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (hoverCardMockState.autoOpen && open !== true) {
|
||||
onOpenChange?.(true);
|
||||
}
|
||||
}, [onOpenChange, open]);
|
||||
return React.createElement('div', { 'data-hover-card-open': open ? 'true' : 'false' }, children);
|
||||
},
|
||||
HoverCardTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
HoverCardContent: ({ children }: { children: React.ReactNode }) =>
|
||||
|
|
@ -155,6 +187,34 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
|
||||
storeState.teamAgentRuntimeByTeam = {};
|
||||
storeState.openMemberProfile.mockReset();
|
||||
hoverCardMockState.autoOpen = true;
|
||||
hoverCardMockState.wideSelectorCalls = 0;
|
||||
});
|
||||
|
||||
it('does not run detailed store selectors while closed', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
hoverCardMockState.autoOpen = false;
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberHoverCard, {
|
||||
name: 'alice',
|
||||
children: React.createElement('button', { type: 'button' }, 'alice'),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('alice');
|
||||
expect(hoverCardMockState.wideSelectorCalls).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows starting from the team spawn snapshot even when provisioning is no longer active', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue