fix(channels): render rooms list non-virtualized so re-picking a workspace on native doesn't strand the list empty after a remount

This commit is contained in:
heaven 2026-05-27 23:49:41 +03:00
parent 136aacded1
commit fda6c7bd7e

View file

@ -3,14 +3,12 @@ import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue } from 'jotai';
import { Box, config } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { NavCategory, NavCategoryHeader } from '../../../components/nav';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
@ -26,19 +24,28 @@ import {
import { getChannelsRoomPath } from '../../pathUtils';
type ChannelsListProps = {
// Scroll container ref — owned by the parent `PageNavContent` so virtualizer
// measures the actual scrollable element instead of an inner div nested
// inside `<Scroll>`. Wrong scroll target → clientHeight reads as 0 →
// virtualizer renders no rows. Match `Space.tsx` pattern.
// Kept for API compatibility with PageNavContent's scrollRef wiring even
// though the list itself no longer virtualizes — parent still passes the
// ref so other consumers (StreamHeader curtain gesture) can read it.
scrollRef: MutableRefObject<HTMLDivElement | null>;
};
// Joined-hierarchy list rendered inside the channels left pane. Mirrors the
// scaffold of `Space.tsx` (virtualized hierarchy of joined rooms grouped by
// sub-spaces) but routes selections through the /channels/<space>/<room>/
// path so the room timeline opens inside the channels surface, not the
// legacy /<spaceId>/<room>/ tree.
export function ChannelsList({ scrollRef }: ChannelsListProps) {
// scaffold of `Space.tsx` (hierarchy of joined rooms grouped by sub-spaces)
// but routes selections through the /channels/<space>/<room>/ path so the
// room timeline opens inside the channels surface, not the legacy
// /<spaceId>/<room>/ tree.
//
// NOT virtualized on purpose. Lists in the channels pane are bounded (one
// orphan workspace + its joined rooms — typically tens, not thousands),
// and @tanstack/react-virtual had a fragile race here with folds' Scroll
// component: every re-render of `<Scroll>` creates a fresh inline ref
// callback, React 18 detaches the old callback (nulling scrollRef.current)
// before children's useLayoutEffects run, and the virtualizer's
// `_willUpdate` saw null on its first chance to subscribe — leaving the
// list permanently empty on the 2nd+ remount of the same workspace. Plain
// `.map()` sidesteps the whole subscription dance.
export function ChannelsList({ scrollRef: _scrollRef }: ChannelsListProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const space = useSpace();
@ -84,13 +91,6 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
)
);
const virtualizer = useVirtualizer({
count: hierarchy.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 0,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
@ -100,26 +100,16 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
return (
<Box direction="Column" gap="300">
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const { roomId } = hierarchy[vItem.index] ?? {};
<NavCategory>
{hierarchy.map((item, index) => {
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<div key={roomId} style={{ paddingTop: index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
@ -130,17 +120,12 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
@ -148,11 +133,9 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
</NavCategory>
</Box>
);
}