From d483c437f7fb5fc26bee348bdf2b5e40e85b8b8b Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 16 Oct 2026 20:40:53 +0900 Subject: [PATCH] Keep list reloads in the same view branch as loaded data List screens drew "reloading with data on screen" in its own switch branch, separate from .hasData. SwiftUI then rebuilds the list on every reload and re-runs its .task. MyTurnTagCreator hit this as a reload loop after it began reloading the shops on each tag change; nothing in the template reloads that way yet, so this removes the trap. List screens now switch on DataState.listContent(isEmpty:). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LDCmJWEL2WvoVE4ThKsR2a --- CHANGELOG.md | 1 + NativeAppTemplate/Data/DataState.swift | 28 +++++++++++++ .../UI/Shop List/ShopListView.swift | 14 ++----- .../ItemTag List/ItemTagListView.swift | 14 ++----- .../Data/DataStateListContentTest.swift | 39 +++++++++++++++++++ 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 NativeAppTemplateTests/Data/DataStateListContentTest.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d86bb..a8049aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- List screens switch on `DataState.listContent(isEmpty:)`, where a reload with data on screen is the same `.list` case as loaded data instead of a `switch` branch of its own. A separate branch makes SwiftUI rebuild the list on every reload, re-register its `navigationDestination` and re-run its `.task`; in MyTurnTagCreator that turned a reload after each tag change into a loop that hit the API's rate limit. Nothing here reloads that way yet, so this removes the trap rather than a live bug (Shops, number tags) - Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) ### Fixed diff --git a/NativeAppTemplate/Data/DataState.swift b/NativeAppTemplate/Data/DataState.swift index 159ef24..0ac4f43 100644 --- a/NativeAppTemplate/Data/DataState.swift +++ b/NativeAppTemplate/Data/DataState.swift @@ -9,3 +9,31 @@ enum DataState { case hasData case failed } + +/// What a list screen shows for its `DataState`. +enum ListContent { + case loading + case list + case failed +} + +extension DataState { + /// A reload keeps the list already on screen instead of blinking a full-screen + /// LoadingView over it. It is the same `.list` case as `.hasData` on purpose: a + /// separate `switch` branch is a different view to SwiftUI, so the list would be + /// rebuilt on every reload, re-registering its `navigationDestination` and re-running + /// its `.task`. In MyTurnTagCreator that turned a reload after each tag change into a + /// loop that hit the API's rate limit. + func listContent(isEmpty: Bool) -> ListContent { + switch self { + case .initial: + .loading + case .loading: + isEmpty ? .loading : .list + case .hasData: + .list + case .failed: + .failed + } + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index f9f0a72..1f379e7 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -62,18 +62,10 @@ extension ShopListView { private extension ShopListView { var contentView: some View { @ViewBuilder var contentView: some View { - switch viewModel.state { - case .initial: - LoadingView() + switch viewModel.state.listContent(isEmpty: viewModel.isEmpty) { case .loading: - // Keep showing the existing list while refreshing so re-appearance - // reloads don't blink a full-screen LoadingView over loaded data. - if viewModel.isEmpty { - LoadingView() - } else { - shopListView - } - case .hasData: + LoadingView() + case .list: shopListView case .failed: reloadView diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift index 8acff23..3ee0e9a 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagListView.swift @@ -30,18 +30,10 @@ private extension ItemTagListView { if viewModel.isBusy { LoadingView() } else { - switch viewModel.state { - case .initial: - LoadingView() + switch viewModel.state.listContent(isEmpty: viewModel.isEmpty) { case .loading: - // Keep showing the existing list while refreshing so re-appearance - // reloads don't blink a full-screen LoadingView over loaded data. - if viewModel.isEmpty { - LoadingView() - } else { - itemTagListView - } - case .hasData: + LoadingView() + case .list: itemTagListView case .failed: reloadView diff --git a/NativeAppTemplateTests/Data/DataStateListContentTest.swift b/NativeAppTemplateTests/Data/DataStateListContentTest.swift new file mode 100644 index 0000000..8b2819a --- /dev/null +++ b/NativeAppTemplateTests/Data/DataStateListContentTest.swift @@ -0,0 +1,39 @@ +// +// DataStateListContentTest.swift +// NativeAppTemplate +// + +@testable import NativeAppTemplate +import Testing + +/// List screens switch on `listContent(isEmpty:)`. A reload of a list already on screen +/// must stay `.list`, the same case as `.hasData`: as a branch of its own, SwiftUI rebuilds +/// the list on every reload and re-runs its `.task`. In MyTurnTagCreator 2.1.0 that made the +/// Shops tab reload about twice a second while a shop was open. +@Suite +struct DataStateListContentTest { + @Test + func reloadingWithDataKeepsTheList() { + // A reload with shops on screen (.loading), then .hasData: both the one `.list` case. + #expect(DataState.loading.listContent(isEmpty: false) == .list) + #expect(DataState.hasData.listContent(isEmpty: false) == .list) + } + + @Test + func firstLoadShowsLoading() { + #expect(DataState.initial.listContent(isEmpty: true) == .loading) + #expect(DataState.loading.listContent(isEmpty: true) == .loading) + } + + @Test + func loadedEmptyListIsStillTheList() { + // An empty result is shown by the list view itself (e.g. "no shops yet") + #expect(DataState.hasData.listContent(isEmpty: true) == .list) + } + + @Test + func failureShowsTheErrorView() { + #expect(DataState.failed.listContent(isEmpty: true) == .failed) + #expect(DataState.failed.listContent(isEmpty: false) == .failed) + } +}