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

  1. HistoryGrid body is called with displayedRecordCount = 50
  2. visibleMonthGroups computed property slices first 50 records
  3. Grid renders all rows including LoadMoreTrigger
  4. LoadMoreTrigger.onAppear fires immediately (not on scroll)
  5. loadMore() increments displayedRecordCount to 100
  6. SwiftUI re-renders Grid with 100 records + new LoadMoreTrigger
  7. New LoadMoreTrigger.onAppear fires immediately
  8. 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

  1. The slicing logic (sliceMonthGroups) is correct - unit tests pass
  2. The @State displayedRecordCount initialization is correct
  3. The hasMoreToLoad computed property is correct
  4. The bug is subtle: onAppear semantics 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

  1. Add viewport detection to LoadMoreTrigger using GeometryReader
  2. Track if trigger has fired to prevent duplicate loads
  3. Use coordinate space (“scroll”) already set on the List
  4. Add “Load more” button as fallback if auto-trigger fails
  5. Update UI test to verify progressive loading works

Files to Modify

  • Minuta/Sources/Views/History/HistoryGrid.swift - LoadMoreTrigger view
  • Minuta/UITests/Flows/PerformanceTests.swift - Verify fix works

Testing

After fix:

  1. Grid height should be ~2500px on initial load (not 3904px)
  2. “N more…” text should be visible after scrolling
  3. Scrolling to bottom should trigger loading more records
  4. UI test testProgressiveLoading should 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:

  1. User on “3 months” view with 86 records, displayedRecordCount = 50
  2. Switch to “Year 2024” - records reload
  3. During load: filteredRecords.count = 0displayedRecordCount = 0
  4. After load: filteredRecords.count = 1000
  5. Old logic: neither reset branch applied, displayedRecordCount stayed at 0
  6. 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:

  1. 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.

  2. Month virtualization: Only months in/near the viewport render their content. Off-screen months show placeholders with cached heights.

  3. Load all records by default: Changed initialLoadCount from 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)