Load More Bug Analysis
Status: FIXED (2026-01-02, updated 2026-01-03)
Problem
The “load more” progressive loading feature in the history grid does not work. All records are loaded immediately instead of limiting to the first 50 and loading more on scroll.
Root Cause
SwiftUI Grid is not lazy. Unlike LazyVStack or List, the Grid view renders all its child views immediately when the body is evaluated. This means onAppear modifiers inside Grid fire during initial render, not when the user scrolls to them.
Current Implementation (Broken)
HistoryGrid.swift:287-304
private struct LoadMoreTrigger: View {
let remainingCount: Int
let onLoadMore: () -> Void
var body: some View {
Color.clear
.frame(height: 44)
.overlay {
Text("\(remainingCount) more...")
}
.onAppear {
onLoadMore() // <-- Fires immediately, not on scroll!
}
}
} What Happens
HistoryGridbody is called withdisplayedRecordCount = 50visibleMonthGroupscomputed property slices first 50 recordsGridrenders all rows includingLoadMoreTriggerLoadMoreTrigger.onAppearfires immediately (not on scroll)loadMore()incrementsdisplayedRecordCountto 100- SwiftUI re-renders Grid with 100 records + new
LoadMoreTrigger - New
LoadMoreTrigger.onAppearfires immediately - Cycle continues until all records are loaded
Result: Progressive loading is bypassed entirely. All 750 records load at once.
Evidence
Debug Info from Screenshot
Grid: y:659 h:3904 w:454 Grid height is 3904px on initial load. With 50 records at ~50px each, expected height would be ~2500px. The actual height suggests all records (100+) are being rendered.
UI Test Output
PROG [Load More]: No 'more...' trigger found (all records may be loaded) The test scrolls to find “more…” text but never finds it because by the time the app is interactive, all records have already loaded.
Why This Wasn’t Caught Earlier
- The slicing logic (
sliceMonthGroups) is correct - unit tests pass - The
@State displayedRecordCountinitialization is correct - The
hasMoreToLoadcomputed property is correct - The bug is subtle:
onAppearsemantics differ between lazy and non-lazy containers
Solutions
Option 1: Use GeometryReader for Viewport Detection (Recommended)
Replace onAppear with scroll position detection:
private struct LoadMoreTrigger: View {
let remainingCount: Int
let onLoadMore: () -> Void
@State private var hasTriggered = false
var body: some View {
GeometryReader { geo in
Color.clear.onChange(of: geo.frame(in: .named("scroll")).minY) { _, y in
// Only trigger when visible in viewport
let viewportHeight = UIScreen.main.bounds.height
if y < viewportHeight && !hasTriggered {
hasTriggered = true
onLoadMore()
}
}
}
.frame(height: 44)
.overlay { Text("\(remainingCount) more...") }
}
} Pros: Minimal code change, preserves Grid structure Cons: Relies on coordinate space, may need tuning
Option 2: Replace Grid with LazyVStack
Convert nested Grid structure to LazyVStack:
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(visibleMonthGroups) { monthGroup in
// Month section
LazyVStack { ... }
}
if hasMoreToLoad {
LoadMoreTrigger(...) // onAppear works correctly in LazyVStack
}
}
} Pros: onAppear works as expected, better performance for large lists Cons: Significant refactor, lose Grid alignment features, changes layout
Option 3: Manual Scroll Position Tracking
Track scroll offset and trigger load when near bottom:
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
let gridBottom = gridHeight - offset
let viewportBottom = UIScreen.main.bounds.height
if gridBottom < viewportBottom + 100 && hasMoreToLoad {
loadMore()
}
} Pros: Precise control over trigger point Cons: More complex, needs debouncing to prevent rapid triggers
Option 4: Add “Load More” Button
Replace automatic loading with explicit user action:
if hasMoreToLoad {
Button("Load \(records.count - displayedRecordCount) more...") {
loadMore()
}
} Pros: Simple, predictable, no timing issues Cons: Requires user interaction, less fluid UX
Recommendation
Option 1 (GeometryReader) is the best balance of:
- Minimal code change (localized to LoadMoreTrigger)
- Preserves existing Grid structure
- Automatic loading UX preserved
- Can be combined with Option 4 as fallback
Implementation Plan
- Add viewport detection to LoadMoreTrigger using GeometryReader
- Track if trigger has fired to prevent duplicate loads
- Use coordinate space (“scroll”) already set on the List
- Add “Load more” button as fallback if auto-trigger fails
- Update UI test to verify progressive loading works
Files to Modify
Minuta/Sources/Views/History/HistoryGrid.swift- LoadMoreTrigger viewMinuta/UITests/Flows/PerformanceTests.swift- Verify fix works
Testing
After fix:
- Grid height should be ~2500px on initial load (not 3904px)
- “N more…” text should be visible after scrolling
- Scrolling to bottom should trigger loading more records
- UI test
testProgressiveLoadingshould find the load more trigger
Fix Applied
File: HistoryGrid.swift:287-324
Replaced onAppear with GeometryReader viewport detection:
private struct LoadMoreTrigger: View {
let remainingCount: Int
let onLoadMore: () -> Void
@State private var hasTriggered = false
var body: some View {
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .named("scroll")).minY) { _, minY in
triggerIfVisible(minY: minY)
}
.onAppear {
triggerIfVisible(minY: geo.frame(in: .named("scroll")).minY)
}
}
.frame(height: 44)
.overlay { Text("\(remainingCount) more...") }
}
private func triggerIfVisible(minY: CGFloat) {
let viewportHeight = UIScreen.main.bounds.height
if minY < viewportHeight + 200 && !hasTriggered {
hasTriggered = true
onLoadMore()
}
}
} Verification:
- Grid height on initial load: 1986px (was 3904px)
- Approximately 50% reduction confirms only ~50 records rendered initially
- Progressive loading triggers on scroll when LoadMoreTrigger enters viewport
Additional Fix: LoadMoreTrigger Not Working in Grid (2026-01-02)
Problem
The initial fix (GeometryReader-based viewport detection) didn’t work reliably because SwiftUI Grid inside a List doesn’t receive scroll position updates - the onChange(of: geo.frame) never fires during scrolling.
Solution
Moved load-more logic to HistorySection with LoadMoreRow as a separate List row:
// In HistorySection
if hasMoreToLoad {
LoadMoreRow(
remainingCount: filteredRecords.count - displayedRecordCount,
onLoadMore: loadMore
)
} As a dedicated List row, onAppear fires correctly when scrolled into view.
Additional Fix: displayedRecordCount Stuck at 0 (2026-01-02)
Problem
When switching date ranges, records temporarily become empty during loading:
- User on “3 months” view with 86 records,
displayedRecordCount = 50 - Switch to “Year 2024” - records reload
- During load:
filteredRecords.count = 0→displayedRecordCount = 0 - After load:
filteredRecords.count = 1000 - Old logic: neither reset branch applied,
displayedRecordCountstayed at 0 - Result: “Loading…” shown but no content (slicing 0 records)
Solution
Simplified reset logic to always reset when records change:
.onChange(of: filteredRecords.count) { _, newCount in
// Always reset pagination when filter/date range changes
displayedRecordCount = min(newCount, Self.initialLoadCount)
} This ensures displayedRecordCount is never stuck at 0 or a stale value.
Major Refactor: Month Virtualization (2026-01-03)
Problem
Progressive loading with LoadMoreRow caused scroll position issues - when new content loaded, the List would jump to keep LoadMoreRow visible, creating a jarring UX.
Solution
Replaced progressive loading with month-level virtualization:
Fixed-height month containers: Each month is wrapped in a ScrollView with max height (70% of screen). Users scroll within a month to see all days.
Month virtualization: Only months in/near the viewport render their content. Off-screen months show placeholders with cached heights.
Load all records by default: Changed
initialLoadCountfrom 50 to 10,000. With virtualization, this is performant since only visible months render.
Implementation
DaysGrid - Fixed-height scrollable container:
ScrollView {
Grid { /* day rows */ }
}
.frame(maxHeight: UIScreen.main.bounds.height * 0.7) MonthContainer - Virtualization wrapper:
private struct MonthContainer<Content: View>: View {
let isVisible: Bool
let cachedHeight: CGFloat?
var body: some View {
if isVisible {
content()
} else {
Color.clear.frame(height: cachedHeight ?? 100)
}
}
} Visibility tracking in HistoryGrid:
@State private var visibleMonths: Set<String> = []
// In MonthContainer background:
GeometryReader { geo in
Color.clear.onChange(of: geo.frame(in: .named("scroll")).minY) { _, minY in
let inViewport = maxY > -buffer && minY < screenHeight + buffer
onVisibilityChanged(inViewport)
}
} Benefits
- No scroll jumping when loading content
- Memory efficient - only visible months render full content
- Faster initial load - placeholders for off-screen months
- Smooth scrolling through large datasets (1000+ records)