Skip to content

Seed UserPreferences at launch instead of during view rendering - #41

Open
gsbernstein wants to merge 1 commit into
masterfrom
cursor/seed-preferences-once-999a
Open

Seed UserPreferences at launch instead of during view rendering#41
gsbernstein wants to merge 1 commit into
masterfrom
cursor/seed-preferences-once-999a

Conversation

@gsbernstein

@gsbernstein gsbernstein commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Why the insert exists at all

SwiftData persists the schema, but it never creates model instances. On a fresh install — or on the launch right after the store-reset recovery in BedtimeApp — the store is empty, so @Query private var preferences: [UserPreferences] comes back with nothing and there is no settings record to read a sleep goal or wake time from. Something has to insert the first UserPreferences() so its defaults become a real, editable, persisted row.

The insert is necessary. Doing it while rendering is not.

Problem

ContentView created and inserted that record from a computed property:

private var userPreferences: UserPreferences {
    if let existing = preferences.first {
        return existing
    } else {
        let new = UserPreferences()
        modelContext.insert(new)   // during body evaluation
        return new
    }
}

userPreferences was read from body and from six other computed properties feeding it (sleepBank, fullWindowSleepBank, bedtimeRecommendation, sleepBankInsight, and two bindings). SwiftUI reads a view's properties an unspecified number of times per update, and @Query does not reflect the insert until the context change propagates, so on an empty store a single update could:

  • insert more than one default record, each render pass seeing an empty query;
  • mutate persistent state in the middle of a render, which is what SwiftUI's "publishing changes from within view updates" rule warns about;
  • feed a different throwaway instance to different cards within the same frame, so a binding written by one card updated an object nothing else was reading.

This is a latent correctness issue rather than the cause of the recent TestFlight crash — that one was the ModelContainer fatalError, which master already recovers from.

Fix

Seed once in BedtimeApp.init(), before the view tree exists:

init() {
    Self.seedDefaultPreferencesIfNeeded(in: sharedModelContainer)
}

It fetches with fetchLimit = 1, inserts only when the store is genuinely empty, and saves. A failed fetch deliberately does not insert.

ContentView then only ever reads. The dashboard is gated on the fetched record, and the six preference-dependent computed properties became functions taking the resolved UserPreferences, so the values are computed once per update and shared by every card:

var body: some View {
    if let userPreferences = preferences.first {
        dashboard(for: userPreferences)
    } else {
        ProgressView()
            .task { seedDefaultPreferencesIfNeeded() }
    }
}

The placeholder branch is only reachable if the launch seed failed, or in previews and tests that start from an empty in-memory container. It seeds from .task — an explicit side-effect context — rather than from body.

Notes for review

  • No behavior change in the normal path: the record exists before the first frame, so the dashboard renders immediately and the placeholder never appears.
  • .task { fetchSleepData() } stays attached to the dashboard's NavigationStack, matching its current placement, so the HealthKit fetch is unaffected.
  • Both remaining insert calls are now outside body evaluation — one at app init, one inside .task.

Verification

Xcode Cloud is green on this branch:

Bedger | Release                  SUCCESS
Bedger | Release | Archive - iOS  SUCCESS

That covers compilation and archiving only. Runtime behavior can't be exercised here — the Cloud Agent environment is Linux, and Xcode plus the Apple frameworks are macOS-only — so this is worth a manual pass on a simulator: delete the app first, launch, confirm the dashboard appears without a flash of the placeholder, change a setting, and relaunch to confirm it persisted against a single settings record.

Open in Web Open in Cursor 

ContentView created and inserted the default UserPreferences record from a
computed property read while SwiftUI evaluated body. SwiftUI reads view
properties an unspecified number of times per update, so that could insert
several default records and mutated persistent state mid-render.

Seed the record in BedtimeApp.init instead, before the view tree is built,
and have ContentView read preferences without writing. The dashboard is
gated on the fetched record; the placeholder branch only appears if that
seed failed or in previews starting from an empty in-memory container, and
seeds from .task rather than from body.

Co-authored-by: Greg <gsbernstein@users.noreply.github.com>
@gsbernstein
gsbernstein marked this pull request as ready for review August 7, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants