fix(app): keep avatar stack entries keyed by user across re-renders (#1908)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:43:37 +02:00
1 parent 5eb7822691
commit 97eb84fd46
1 file changed
+78 -74
@@ -39,6 +39,11 @@ export interface AvatarStackProps {
onUserContextMenu?: (event: React.MouseEvent<HTMLElement>, user: User, index: number) => void; onUserContextMenu?: (event: React.MouseEvent<HTMLElement>, user: User, index: number) => void;
} }
interface AvatarStackEntry {
key: string;
node: React.ReactNode;
}
export const AvatarStack: React.FC<AvatarStackProps> = observer( export const AvatarStack: React.FC<AvatarStackProps> = observer(
({ ({
children, children,
@@ -56,69 +61,14 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
onUserContextMenu, onUserContextMenu,
}) => { }) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const childArray = React.Children.toArray(children).filter(Boolean); const childEntries: Array<AvatarStackEntry> = React.Children.toArray(children)
const userChildren = .filter(Boolean)
users?.map((user, index) => { .map((child, index) => ({
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined); key: React.isValidElement(child) && child.key != null ? child.key : `avatar-stack-child-${index}`,
const avatarNode = renderAvatar?.(user, size, index) ?? ( node: child,
<Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" /> }));
); const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number, displayName: string) => {
if (!avatarNode) return null;
if (enableProfileModal) {
const profileTrigger = (
<FocusRing offset={-2} data-flx="ui.avatars.avatar-stack.focus-ring">
<button
type="button"
className={styles.avatarButton}
aria-label={i18n._(OPEN_PROFILE_FOR_DESCRIPTOR, {displayName})}
data-flx="ui.avatars.avatar-stack.avatar-button"
>
{avatarNode}
</button>
</FocusRing>
);
return (
<PreloadableUserPopout
key={user.id}
user={user}
isWebhook={false}
guildId={guildId ?? undefined}
channelId={channelId ?? undefined}
disableContextMenu={true}
tooltip={showTooltips ? displayName : undefined}
data-flx="ui.avatars.avatar-stack.preloadable-user-popout"
>
{profileTrigger}
</PreloadableUserPopout>
);
}
const content = (
<div className={styles.avatarContent} data-flx="ui.avatars.avatar-stack.avatar-content">
{avatarNode}
</div>
);
if (!showTooltips) {
return React.cloneElement(content, {key: user.id});
}
return (
<Tooltip key={user.id} text={displayName} data-flx="ui.avatars.avatar-stack.tooltip">
{content}
</Tooltip>
);
}) ?? [];
const resolvedChildren = users ? userChildren.filter(Boolean) : childArray;
const totalCount = resolvedChildren.length;
const visibleChildren = resolvedChildren.slice(0, maxVisible);
const remainingCount = Math.max(0, totalCount - maxVisible);
const geometry = resolveAvatarStackGeometry(size, overlap);
const cssVars = {
'--avatar-size': geometry.sizeRem,
'--avatar-overlap': geometry.overlapRem,
'--avatar-outline': geometry.outlineRem,
} as React.CSSProperties;
const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number) => {
if (!onUserContextMenu) return node; if (!onUserContextMenu) return node;
const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
return ( return (
<div <div
className={styles.avatarContextMenuWrap} className={styles.avatarContextMenuWrap}
@@ -131,26 +81,80 @@ export const AvatarStack: React.FC<AvatarStackProps> = observer(
</div> </div>
); );
}; };
const userChildrenWithContextMenu = const userEntries: Array<AvatarStackEntry> = [];
users && onUserContextMenu const userKeyCounts = new Map<string, number>();
? userChildren.map((child, index) => { users?.forEach((user, index) => {
const user = users[index]; const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined);
return user ? wrapWithContextMenu(child, user, index) : child; const avatarNode = renderAvatar?.(user, size, index) ?? (
}) <Avatar user={user} size={size} guildId={guildId ?? undefined} data-flx="ui.avatars.avatar-stack.avatar" />
: resolvedChildren; );
const finalVisibleChildren = users ? userChildrenWithContextMenu.slice(0, maxVisible) : visibleChildren; if (!avatarNode) return;
let node: React.ReactNode;
if (enableProfileModal) {
node = (
<PreloadableUserPopout
user={user}
isWebhook={false}
guildId={guildId ?? undefined}
channelId={channelId ?? undefined}
disableContextMenu={true}
tooltip={showTooltips ? displayName : undefined}
data-flx="ui.avatars.avatar-stack.preloadable-user-popout"
>
<FocusRing offset={-2} data-flx="ui.avatars.avatar-stack.focus-ring">
<button
type="button"
className={styles.avatarButton}
aria-label={i18n._(OPEN_PROFILE_FOR_DESCRIPTOR, {displayName})}
data-flx="ui.avatars.avatar-stack.avatar-button"
>
{avatarNode}
</button>
</FocusRing>
</PreloadableUserPopout>
);
} else {
const content = (
<div className={styles.avatarContent} data-flx="ui.avatars.avatar-stack.avatar-content">
{avatarNode}
</div>
);
node = showTooltips ? (
<Tooltip text={displayName} data-flx="ui.avatars.avatar-stack.tooltip">
{content}
</Tooltip>
) : (
content
);
}
const occurrence = userKeyCounts.get(user.id) ?? 0;
userKeyCounts.set(user.id, occurrence + 1);
userEntries.push({
key: occurrence === 0 ? user.id : `${user.id}:${occurrence}`,
node: wrapWithContextMenu(node, user, index, displayName),
});
});
const resolvedEntries = users ? userEntries : childEntries;
const remainingCount = Math.max(0, resolvedEntries.length - maxVisible);
const visibleEntries = resolvedEntries.slice(0, maxVisible);
const geometry = resolveAvatarStackGeometry(size, overlap);
const cssVars = {
'--avatar-size': geometry.sizeRem,
'--avatar-overlap': geometry.overlapRem,
'--avatar-outline': geometry.outlineRem,
} as React.CSSProperties;
return ( return (
<div className={clsx(styles.container, className)} style={cssVars} data-flx="ui.avatars.avatar-stack.container"> <div className={clsx(styles.container, className)} style={cssVars} data-flx="ui.avatars.avatar-stack.container">
{finalVisibleChildren.map((child, index) => ( {visibleEntries.map((entry, index) => (
<div <div
key={index} key={entry.key}
className={clsx( className={clsx(
styles.avatar, styles.avatar,
(index < finalVisibleChildren.length - 1 || remainingCount > 0) && styles.withMask, (index < visibleEntries.length - 1 || remainingCount > 0) && styles.withMask,
)} )}
data-flx="ui.avatars.avatar-stack.avatar--2" data-flx="ui.avatars.avatar-stack.avatar--2"
> >
{child} {entry.node}
</div> </div>
))} ))}
{remainingCount > 0 && {remainingCount > 0 &&