Books — fix infinite render loop via value-based navigation
Opening a book chapter froze the app in an infinite render loop. Root
cause: the books screens used the eager `NavigationLink { destination }`
form inside `List`/`LazyVStack`. That form keeps the destination view
structurally parented to the source row, so `BookReaderView`'s ScrollView
got trapped inside a `List` row — a row sizes to intrinsic height, a
ScrollView has none, so the two never converge and re-measure forever.
Switch the whole books navigation chain to value-based navigation:
- practiceHomeView, BookLibraryView, BookChapterListView use
NavigationLink(value:).
- PracticeView's NavigationStack declares the BooksRoute, Book, and
BookChapter destinations once, at the stack root (mixing eager and
value-based pushes in one path caused pushed screens to pop back).
- BookReaderView is built from just a BookChapter; it resolves its Book
by slug via @Query.
Also:
- BookChapter gains a stored paragraphCount so the chapter list no longer
decodes the full paragraph JSON on every render (bookDataVersion -> 6
to re-seed).
- BookSpeechController builds its AVSpeechSynthesizer lazily.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,16 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
|
||||
/// the system speech daemon, so allocating one per `BookReaderView` struct
|
||||
/// construction (SwiftUI rebuilds the struct on every parent render) is a
|
||||
/// real cost — deferring it keeps controller construction cheap.
|
||||
@ObservationIgnored
|
||||
private lazy var synthesizer: AVSpeechSynthesizer = {
|
||||
let synth = AVSpeechSynthesizer()
|
||||
synth.delegate = self
|
||||
return synth
|
||||
}()
|
||||
private var queue: [QueueEntry] = []
|
||||
private var queueCursor: Int = 0
|
||||
private var audioSessionConfigured = false
|
||||
@@ -38,7 +47,6 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Public control
|
||||
|
||||
@@ -9,7 +9,7 @@ actor DataLoader {
|
||||
static let textbookDataVersion = 14
|
||||
static let textbookDataKey = "textbookDataVersion"
|
||||
|
||||
static let bookDataVersion = 5 // bump: per-book glossary added
|
||||
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
||||
static let bookDataKey = "bookDataVersion"
|
||||
|
||||
/// Quick check: does the DB need seeding or course data refresh?
|
||||
@@ -632,6 +632,7 @@ actor DataLoader {
|
||||
bookSlug: slug,
|
||||
number: number,
|
||||
title: chTitle,
|
||||
paragraphCount: paragraphsES.count,
|
||||
paragraphsESJSON: esData,
|
||||
paragraphsENJSON: enData
|
||||
)
|
||||
|
||||
@@ -19,9 +19,7 @@ struct BookChapterListView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(allChapters) { chapter in
|
||||
NavigationLink {
|
||||
BookReaderView(chapter: chapter, book: book)
|
||||
} label: {
|
||||
NavigationLink(value: chapter) {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(chapter.number)")
|
||||
.font(.subheadline.weight(.bold).monospacedDigit())
|
||||
@@ -31,7 +29,7 @@ struct BookChapterListView: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("\(chapter.paragraphsES().count) paragraph\(chapter.paragraphsES().count == 1 ? "" : "s")")
|
||||
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Route value for pushing the books library. Lets `PracticeView` use a
|
||||
/// value-based link so the entire books navigation chain is consistent —
|
||||
/// mixing a view-based push with value-based pushes deeper in the same
|
||||
/// NavigationStack made pushed screens pop back immediately.
|
||||
enum BooksRoute: Hashable {
|
||||
case library
|
||||
}
|
||||
|
||||
struct BookLibraryView: View {
|
||||
@Query(sort: \Book.title) private var books: [Book]
|
||||
|
||||
@@ -17,9 +25,7 @@ struct BookLibraryView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(books) { book in
|
||||
NavigationLink {
|
||||
BookChapterListView(book: book)
|
||||
} label: {
|
||||
NavigationLink(value: book) {
|
||||
BookCard(book: book)
|
||||
}
|
||||
.tint(.primary)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
struct BookReaderView: View {
|
||||
let chapter: BookChapter
|
||||
let book: Book
|
||||
|
||||
/// The book this chapter belongs to, resolved by slug — used for the
|
||||
/// pre-computed glossary. A @Query is safe here because the reader is
|
||||
/// built lazily by `navigationDestination`: one instance, when opened.
|
||||
@Query private var bookMatches: [Book]
|
||||
private var book: Book? { bookMatches.first }
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var speech = BookSpeechController()
|
||||
@@ -19,6 +25,12 @@ struct BookReaderView: View {
|
||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
let slug = chapter.bookSlug
|
||||
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
||||
}
|
||||
|
||||
private var paragraphsES: [String] { chapter.paragraphsES() }
|
||||
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
||||
|
||||
@@ -87,7 +99,7 @@ struct BookReaderView: View {
|
||||
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
||||
speech.rate = Float(storedRate)
|
||||
if glossary.isEmpty {
|
||||
glossary = book.glossary()
|
||||
glossary = book?.glossary() ?? [:]
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
|
||||
@@ -21,6 +21,19 @@ struct PracticeView: View {
|
||||
practiceHomeView
|
||||
}
|
||||
}
|
||||
// Book navigation is value-based and declared once here, at the
|
||||
// stack root. Eager `NavigationLink { destination }` forms inside
|
||||
// the List/LazyVStack of the book screens caused an infinite
|
||||
// render loop; value-based links build destinations lazily.
|
||||
.navigationDestination(for: BooksRoute.self) { _ in
|
||||
BookLibraryView()
|
||||
}
|
||||
.navigationDestination(for: Book.self) { book in
|
||||
BookChapterListView(book: book)
|
||||
}
|
||||
.navigationDestination(for: BookChapter.self) { chapter in
|
||||
BookReaderView(chapter: chapter)
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
@@ -261,9 +274,7 @@ struct PracticeView: View {
|
||||
.padding(.horizontal)
|
||||
|
||||
// Books
|
||||
NavigationLink {
|
||||
BookLibraryView()
|
||||
} label: {
|
||||
NavigationLink(value: BooksRoute.library) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.title3)
|
||||
|
||||
@@ -10,6 +10,9 @@ public final class BookChapter {
|
||||
public var bookSlug: String = ""
|
||||
public var number: Int = 0
|
||||
public var title: String = ""
|
||||
/// Spanish paragraph count, stored at seed time so chapter lists can show
|
||||
/// it without decoding the full `paragraphsESJSON` blob on every render.
|
||||
public var paragraphCount: Int = 0
|
||||
public var paragraphsESJSON: Data = Data()
|
||||
public var paragraphsENJSON: Data = Data()
|
||||
|
||||
@@ -18,6 +21,7 @@ public final class BookChapter {
|
||||
bookSlug: String,
|
||||
number: Int,
|
||||
title: String,
|
||||
paragraphCount: Int,
|
||||
paragraphsESJSON: Data,
|
||||
paragraphsENJSON: Data
|
||||
) {
|
||||
@@ -25,6 +29,7 @@ public final class BookChapter {
|
||||
self.bookSlug = bookSlug
|
||||
self.number = number
|
||||
self.title = title
|
||||
self.paragraphCount = paragraphCount
|
||||
self.paragraphsESJSON = paragraphsESJSON
|
||||
self.paragraphsENJSON = paragraphsENJSON
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user