From 8c9edd5ea9fa850cdcb191460c4b6a51f31c44c0 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 18 Aug 2026 15:47:27 +0300 Subject: [PATCH] Fix the room list flashing empty on launch The room summary provider publishes its loading state and its rooms through two independent streams, so the state can report loaded before the first summaries have been applied. Flipping the room list mode to .rooms at that point renders an empty list until the summaries arrive. Combine the two publishers into a single subscription so that the mode and the rooms are always updated from the same pair of values, and keep the skeletons up until the rooms have published. Only the initial transition is held back, an empty list later on is a filter or a search result and must not send the mode back to the skeletons. --- .../HomeScreen/HomeScreenViewModel.swift | 23 +++++---- .../Sources/HomeScreenViewModelTests.swift | 51 +++++++++++++++++-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 233f4d670..8503ba4ed 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -313,33 +313,34 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol return } + // Combined so that the mode and the rooms are always updated from the same pair of + // values: the state can report loaded before the first summaries have published and + // flipping to .rooms then would flash an empty list. roomSummaryProvider.statePublisher + .combineLatest(roomSummaryProvider.roomListPublisher) .receive(on: DispatchQueue.main) - .sink { [weak self] state in + .sink { [weak self] state, rooms in guard let self else { return } - updateRoomListMode(with: state) - } - .store(in: &cancellables) - - roomSummaryProvider.roomListPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateRooms() + updateRooms() + updateRoomListMode(with: state, hasRooms: !rooms.isEmpty) } .store(in: &cancellables) } - private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState) { + private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState, hasRooms: Bool) { let isLoadingData = !roomSummaryProviderState.isLoaded let hasNoRooms = roomSummaryProviderState.isLoaded && roomSummaryProviderState.totalNumberOfRooms == 0 + // Only the initial transition needs holding back until the rooms have published, an + // empty list later on is a filter or a search result. + let isInitialLoad = state.roomListMode == .skeletons var roomListMode = state.roomListMode if isLoadingData { roomListMode = .skeletons } else if hasNoRooms { roomListMode = .empty - } else { + } else if hasRooms || !isInitialLoad { roomListMode = .rooms } diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 83ba8241e..2d038efda 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -384,7 +384,52 @@ final class HomeScreenViewModelTests { enum InviteType { case rooms, spaces } - private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil) { + @Test + func roomListModeWaitsForTheRoomsToPublish() async throws { + let (roomListSubject, stateSubject) = setupViewModelWithManualProvider() + + #expect(context.viewState.roomListMode == .skeletons) + + // The provider reports loaded before the first summaries have been published. + stateSubject.send(.loaded(totalNumberOfRooms: 8)) + try await Task.sleep(for: .milliseconds(100)) + #expect(context.viewState.roomListMode == .skeletons) + + let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms } + roomListSubject.send(.mockRooms) + try await deferred.fulfill() + } + + @Test + func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws { + let (roomListSubject, stateSubject) = setupViewModelWithManualProvider() + + stateSubject.send(.loaded(totalNumberOfRooms: 8)) + let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms } + roomListSubject.send(.mockRooms) + try await deferred.fulfill() + + // A filter or a search without matches empties the list, the mode must not regress. + roomListSubject.send([]) + stateSubject.send(.loaded(totalNumberOfRooms: 9)) + try await Task.sleep(for: .milliseconds(100)) + #expect(context.viewState.roomListMode == .rooms) + } + + private func setupViewModelWithManualProvider() -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject) { + let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([]) + let stateSubject = CurrentValueSubject(.notLoaded) + + let provider = RoomSummaryProviderMock() + provider.roomListPublisher = roomListSubject.asCurrentValuePublisher() + provider.statePublisher = stateSubject.asCurrentValuePublisher() + + setupViewModel(roomSummaryProvider: provider) + + return (roomListSubject, stateSubject) + } + + private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) { cancellables.removeAll() var rooms: [RoomSummary] = .mockRooms @@ -398,10 +443,10 @@ final class HomeScreenViewModelTests { break } - roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms))) + self.roomSummaryProvider = roomSummaryProvider ?? RoomSummaryProviderMock(.init(state: .loaded(rooms))) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", - roomSummaryProvider: roomSummaryProvider)) + roomSummaryProvider: self.roomSummaryProvider)) clientProxy.joinRoomViaReturnValue = .success(()) clientProxy.joinRoomAliasReturnValue = .success(())