From 97eb84fd468cbd8985c9b3203cb2295e0aefd573 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:43:37 +0200 Subject: [PATCH] fix(app): keep avatar stack entries keyed by user across re-renders (#1908) --- .../src/features/ui/avatars/AvatarStack.tsx | 152 +++++++++--------- 1 file changed, 78 insertions(+), 74 deletions(-) diff --git a/fluxer_app/src/features/ui/avatars/AvatarStack.tsx b/fluxer_app/src/features/ui/avatars/AvatarStack.tsx index 795f0402f..b6139190e 100644 --- a/fluxer_app/src/features/ui/avatars/AvatarStack.tsx +++ b/fluxer_app/src/features/ui/avatars/AvatarStack.tsx @@ -39,6 +39,11 @@ export interface AvatarStackProps { onUserContextMenu?: (event: React.MouseEvent, user: User, index: number) => void; } +interface AvatarStackEntry { + key: string; + node: React.ReactNode; +} + export const AvatarStack: React.FC = observer( ({ children, @@ -56,69 +61,14 @@ export const AvatarStack: React.FC = observer( onUserContextMenu, }) => { const {i18n} = useLingui(); - const childArray = React.Children.toArray(children).filter(Boolean); - const userChildren = - users?.map((user, index) => { - const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined); - const avatarNode = renderAvatar?.(user, size, index) ?? ( - - ); - if (!avatarNode) return null; - if (enableProfileModal) { - const profileTrigger = ( - - - - ); - return ( - - {profileTrigger} - - ); - } - const content = ( -
- {avatarNode} -
- ); - if (!showTooltips) { - return React.cloneElement(content, {key: user.id}); - } - return ( - - {content} - - ); - }) ?? []; - 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) => { + const childEntries: Array = React.Children.toArray(children) + .filter(Boolean) + .map((child, index) => ({ + key: React.isValidElement(child) && child.key != null ? child.key : `avatar-stack-child-${index}`, + node: child, + })); + const wrapWithContextMenu = (node: React.ReactNode, user: User, index: number, displayName: string) => { if (!onUserContextMenu) return node; - const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined); return (
= observer(
); }; - const userChildrenWithContextMenu = - users && onUserContextMenu - ? userChildren.map((child, index) => { - const user = users[index]; - return user ? wrapWithContextMenu(child, user, index) : child; - }) - : resolvedChildren; - const finalVisibleChildren = users ? userChildrenWithContextMenu.slice(0, maxVisible) : visibleChildren; + const userEntries: Array = []; + const userKeyCounts = new Map(); + users?.forEach((user, index) => { + const displayName = NicknameUtils.getNickname(user, guildId ?? null, channelId ?? undefined); + const avatarNode = renderAvatar?.(user, size, index) ?? ( + + ); + if (!avatarNode) return; + let node: React.ReactNode; + if (enableProfileModal) { + node = ( + + + + + + ); + } else { + const content = ( +
+ {avatarNode} +
+ ); + node = showTooltips ? ( + + {content} + + ) : ( + 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 (
- {finalVisibleChildren.map((child, index) => ( + {visibleEntries.map((entry, index) => (
0) && styles.withMask, + (index < visibleEntries.length - 1 || remainingCount > 0) && styles.withMask, )} data-flx="ui.avatars.avatar-stack.avatar--2" > - {child} + {entry.node}
))} {remainingCount > 0 &&