Stop macOS crashes while opening and stepping through exercises.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-06 20:51:06 +02:00
commit c561c0acb5
3 changed files with 42 additions and 19 deletions

View file

@ -10,7 +10,9 @@ final class AppModel: ObservableObject {
@Published var mode: ColorMode
@Published var lang: UiLang
@Published var tab: AppTab = .practice
@Published var practicePath: [Route] = []
@Published var practicePath = NavigationPath()
/// Bumps whenever a full path replace is scheduled, so a queued push cannot land afterwards.
private var pathGeneration = 0
@Published var searchQuery = ""
@Published var tick = 0
@Published var update = UpdateState()
@ -48,13 +50,17 @@ final class AppModel: ObservableObject {
case .stats:
tab = .progress
case .welcome, .subjects:
tab = .practice
practicePath = []
showPracticeRoot()
default:
applyPracticeRoute(r, replace: practicePath.isEmpty)
}
}
func showPracticeRoot() {
tab = .practice
schedulePracticePath(NavigationPath())
}
func goPage(_ page: String?) {
guard let page, let r = routeFromPage(page) else { return }
go(r)
@ -66,8 +72,7 @@ final class AppModel: ObservableObject {
case .stats:
tab = .progress
case .welcome, .subjects:
tab = .practice
practicePath = []
showPracticeRoot()
default:
// Switching tabs and assigning a deep NavigationStack path in the
// same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError).
@ -76,18 +81,31 @@ final class AppModel: ObservableObject {
}
/// Sets the practice tab path safely, especially when jumping from Search.
///
/// The sidebar-style tab view keeps one navigation column for every tab.
/// A typed `[Route]` path does not match the other tabs, so the column
/// traps in `boundPathChange` while an exercise is being opened. The path
/// is therefore type-erased, and a push is applied on the next turn instead
/// of replacing the whole stack during the click.
private func applyPracticeRoute(_ r: Route, replace: Bool) {
let stack = replace ? r.stack : (practicePath + [r])
if !replace, practicePath.last == r { return }
let switching = tab != .practice
tab = .practice
if switching || replace {
practicePath = []
DispatchQueue.main.async { [weak self] in
self?.practicePath = stack
}
schedulePracticePath(NavigationPath(r.stack))
} else {
practicePath = stack
let generation = pathGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.pathGeneration == generation else { return }
self.practicePath.append(r)
}
}
}
private func schedulePracticePath(_ path: NavigationPath) {
pathGeneration += 1
practicePath = NavigationPath()
DispatchQueue.main.async { [weak self] in
self?.practicePath = path
}
}

View file

@ -17,6 +17,9 @@ struct GraduatelyApp: App {
struct NativeRoot: View {
@ObservedObject var vm: AppModel
@State private var progressPath = NavigationPath()
@State private var settingsPath = NavigationPath()
@State private var searchPath = NavigationPath()
var body: some View {
Group {
@ -49,17 +52,17 @@ struct NativeRoot: View {
}
}
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) {
NavigationStack {
NavigationStack(path: $progressPath) {
StatsScreen(vm: vm)
}
}
Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) {
NavigationStack {
NavigationStack(path: $settingsPath) {
SettingsScreen(vm: vm)
}
}
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: .search) {
NavigationStack {
NavigationStack(path: $searchPath) {
SearchScreen(vm: vm)
}
}

View file

@ -16,8 +16,7 @@ struct GraduatelyApp: App {
CommandGroup(replacing: .newItem) {}
CommandGroup(after: .sidebar) {
Button(vm.tr("search_home")) {
vm.tab = .practice
vm.practicePath = []
vm.showPracticeRoot()
}
.keyboardShortcut("1", modifiers: [.command])
Button(vm.tr("stats")) {
@ -47,6 +46,9 @@ struct GraduatelyApp: App {
struct MacRoot: View {
@ObservedObject var vm: AppModel
@State private var progressPath = NavigationPath()
@State private var settingsPath = NavigationPath()
@State private var searchPath = NavigationPath()
var body: some View {
tabs
@ -77,17 +79,17 @@ struct MacRoot: View {
}
}
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) {
NavigationStack {
NavigationStack(path: $progressPath) {
StatsScreen(vm: vm)
}
}
Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) {
NavigationStack {
NavigationStack(path: $settingsPath) {
SettingsScreen(vm: vm)
}
}
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
NavigationStack {
NavigationStack(path: $searchPath) {
SearchScreen(vm: vm)
}
}