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 { useAtom, useAtomValue } from 'jotai';
import { Box, config } from 'folds'; import { Box, config } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
import { NavCategory, NavCategoryHeader } from '../../../components/nav'; import { NavCategory, NavCategoryHeader } from '../../../components/nav';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
@ -26,19 +24,28 @@ import {
import { getChannelsRoomPath } from '../../pathUtils'; import { getChannelsRoomPath } from '../../pathUtils';
type ChannelsListProps = { type ChannelsListProps = {
// Scroll container ref — owned by the parent `PageNavContent` so virtualizer // Kept for API compatibility with PageNavContent's scrollRef wiring even
// measures the actual scrollable element instead of an inner div nested // though the list itself no longer virtualizes — parent still passes the
// inside `<Scroll>`. Wrong scroll target → clientHeight reads as 0 → // ref so other consumers (StreamHeader curtain gesture) can read it.
// virtualizer renders no rows. Match `Space.tsx` pattern.
scrollRef: MutableRefObject<HTMLDivElement | null>; scrollRef: MutableRefObject<HTMLDivElement | null>;
}; };
// Joined-hierarchy list rendered inside the channels left pane. Mirrors the // Joined-hierarchy list rendered inside the channels left pane. Mirrors the
// scaffold of `Space.tsx` (virtualized hierarchy of joined rooms grouped by // scaffold of `Space.tsx` (hierarchy of joined rooms grouped by sub-spaces)
// sub-spaces) but routes selections through the /channels/<space>/<room>/ // but routes selections through the /channels/<space>/<room>/ path so the
// path so the room timeline opens inside the channels surface, not the // room timeline opens inside the channels surface, not the legacy
// legacy /<spaceId>/<room>/ tree. // /<spaceId>/<room>/ tree.
export function ChannelsList({ scrollRef }: ChannelsListProps) { //
// 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 { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const space = useSpace(); 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) => const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId) closedCategories.has(categoryId)
); );
@ -100,26 +100,16 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
return ( return (
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<NavCategory <NavCategory>
style={{ {hierarchy.map((item, index) => {
height: virtualizer.getTotalSize(), const { roomId } = item;
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const { roomId } = hierarchy[vItem.index] ?? {};
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return null; if (!room) return null;
if (room.isSpaceRoom()) { if (room.isSpaceRoom()) {
const categoryId = makeNavCategoryId(space.roomId, roomId); const categoryId = makeNavCategoryId(space.roomId, roomId);
return ( return (
<VirtualTile <div key={roomId} style={{ paddingTop: index === 0 ? undefined : config.space.S400 }}>
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader> <NavCategoryHeader>
<RoomNavCategoryButton <RoomNavCategoryButton
data-category-id={categoryId} data-category-id={categoryId}
@ -130,17 +120,12 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
</RoomNavCategoryButton> </RoomNavCategoryButton>
</NavCategoryHeader> </NavCategoryHeader>
</div> </div>
</VirtualTile>
); );
} }
return ( return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem <RoomNavItem
key={roomId}
room={room} room={room}
selected={selectedRoomId === roomId} selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)} showAvatar={mDirects.has(roomId)}
@ -148,11 +133,9 @@ export function ChannelsList({ scrollRef }: ChannelsListProps) {
linkPath={getToLink(roomId)} linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)} notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/> />
</VirtualTile>
); );
})} })}
</NavCategory> </NavCategory>
</Box> </Box>
); );
} }