Avoid flashing the room list skeletons on launch

On a cold launch the home screen appeared just before the cached rooms
published, briefly showing skeletons that were immediately replaced.

The home screen now starts in a new awaitingCachedRooms mode, keeping
the list blank while a cached room list is about to publish and falling
back to the skeletons after 500ms. Without a cache the skeletons show
straight away as before.
This commit is contained in:
Stefan Ceriu
2026-10-02 10:06:00 +03:00
parent 4b6953e80f
commit a50c80c179
5 changed files with 42 additions and 10 deletions
@@ -53,12 +53,16 @@ enum HomeScreenViewAction {
} }
enum HomeScreenRoomListMode: CustomStringConvertible { enum HomeScreenRoomListMode: CustomStringConvertible {
/// Shows nothing briefly on launch as the cached rooms usually publish in time to go straight to them.
case awaitingCachedRooms
case skeletons case skeletons
case empty case empty
case rooms case rooms
var description: String { var description: String {
switch self { switch self {
case .awaitingCachedRooms:
return "Awaiting cached rooms"
case .skeletons: case .skeletons:
return "Showing placeholders" return "Showing placeholders"
case .empty: case .empty:
@@ -96,7 +100,7 @@ struct HomeScreenViewState: BindableState {
var shouldShowNewSoundBanner = false var shouldShowNewSoundBanner = false
var rooms: [HomeScreenRoom] = [] var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .skeletons var roomListMode: HomeScreenRoomListMode = .awaitingCachedRooms
var hasPendingInvitations = false var hasPendingInvitations = false
@@ -113,7 +117,7 @@ struct HomeScreenViewState: BindableState {
var isRoomListSearchEnabled = true var isRoomListSearchEnabled = true
var visibleRooms: [HomeScreenRoom] { var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons { if roomListMode == .skeletons || roomListMode == .awaitingCachedRooms {
return placeholderRooms return placeholderRooms
} }
@@ -181,6 +181,13 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
updateRoomListMode(with: roomSummaryProvider.statePublisher.value, updateRoomListMode(with: roomSummaryProvider.statePublisher.value,
hasRooms: !roomSummaryProvider.roomListPublisher.value.isEmpty) hasRooms: !roomSummaryProvider.roomListPublisher.value.isEmpty)
} }
// Fall back to the skeletons if the cached rooms take too long to publish.
Task { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard let self, state.roomListMode == .awaitingCachedRooms else { return }
state.roomListMode = .skeletons
}
} }
// MARK: - Public // MARK: - Public
@@ -342,8 +349,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.empty // Loaded, there are no rooms at all. .empty // Loaded, there are no rooms at all.
} else if hasRooms || isFiltering { } else if hasRooms || isFiltering {
.rooms // Loaded and the summaries have published (or filtered down to nothing). .rooms // Loaded and the summaries have published (or filtered down to nothing).
} else if state.roomListMode == .skeletons { } else if state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
.skeletons // Loaded but nothing published yet, flipping to .rooms would flash an empty list. state.roomListMode // Loaded but nothing published yet, flipping to .rooms would flash an empty list.
} else { } else {
.rooms .rooms
} }
@@ -352,7 +359,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
return return
} }
if roomListMode == .rooms, state.roomListMode == .skeletons { if roomListMode == .rooms, state.roomListMode == .skeletons || state.roomListMode == .awaitingCachedRooms {
analyticsService.signpost.finishTransaction(.cachedRoomList) analyticsService.signpost.finishTransaction(.cachedRoomList)
} }
@@ -219,7 +219,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
let userID = "@alice:example.com" let userID = "@alice:example.com"
let roomSummaryProviderState: RoomSummaryProviderMockConfigurationState = switch mode { let roomSummaryProviderState: RoomSummaryProviderMockConfigurationState = switch mode {
case .skeletons: case .awaitingCachedRooms, .skeletons:
.loading .loading
case .empty: case .empty:
.loaded([]) .loaded([])
@@ -25,7 +25,7 @@ struct HomeScreenContent: View {
GeometryReader { geometry in GeometryReader { geometry in
ScrollView { ScrollView {
switch context.viewState.roomListMode { switch context.viewState.roomListMode {
case .skeletons: case .awaitingCachedRooms, .skeletons:
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: context.mediaProvider, action: context.send) HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: context.mediaProvider, action: context.send)
@@ -34,6 +34,8 @@ struct HomeScreenContent: View {
} }
} }
.disabled(true) .disabled(true)
// Laid out but hidden while awaiting so the rooms replace it without a growing animation.
.opacity(context.viewState.roomListMode == .awaitingCachedRooms ? 0 : 1)
.accessibilityRepresentation { .accessibilityRepresentation {
Text(L10n.commonLoading) Text(L10n.commonLoading)
} }
@@ -103,7 +105,7 @@ struct HomeScreenContent: View {
} }
} }
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
.scrollDisabled(context.viewState.roomListMode == .skeletons) .scrollDisabled(context.viewState.roomListMode == .skeletons || context.viewState.roomListMode == .awaitingCachedRooms)
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic) .scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
.animation(.elementDefault, value: context.viewState.roomListMode) .animation(.elementDefault, value: context.viewState.roomListMode)
.animation(.none, value: context.viewState.visibleRooms) .animation(.none, value: context.viewState.visibleRooms)
@@ -421,6 +421,25 @@ final class HomeScreenViewModelTests {
try await deferred.fulfill() try await deferred.fulfill()
} }
@Test
func roomListModeGoesStraightToTheCachedRooms() async throws {
let (roomListSubject, _) = setupViewModelWithManualProvider(state: .loaded(totalNumberOfRooms: 8))
#expect(context.viewState.roomListMode == .awaitingCachedRooms)
let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms }
roomListSubject.send(.mockRooms)
try await deferred.fulfill()
}
@Test
func roomListModeShowsSkeletonsWhenTheCachedRoomsAreSlow() async throws {
_ = setupViewModelWithManualProvider(state: .loaded(totalNumberOfRooms: 8))
#expect(context.viewState.roomListMode == .awaitingCachedRooms)
let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .skeletons }
try await deferred.fulfill()
}
@Test @Test
func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws { func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws {
let (roomListSubject, stateSubject) = setupViewModelWithManualProvider() let (roomListSubject, stateSubject) = setupViewModelWithManualProvider()
@@ -437,9 +456,9 @@ final class HomeScreenViewModelTests {
try await failure.fulfill() try await failure.fulfill()
} }
private func setupViewModelWithManualProvider() -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject<RoomSummaryProviderState, Never>) { private func setupViewModelWithManualProvider(state: RoomSummaryProviderState = .notLoaded) -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject<RoomSummaryProviderState, Never>) {
let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([]) let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([])
let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(.notLoaded) let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(state)
let provider = RoomSummaryProviderMock() let provider = RoomSummaryProviderMock()
provider.roomListPublisher = roomListSubject.asCurrentValuePublisher() provider.roomListPublisher = roomListSubject.asCurrentValuePublisher()