Skip to content

fix(ios): make the Home Screen widgets actually refresh, and state their age - #548

Merged
Makisuo merged 1 commit into
mainfrom
fix/ios-widget-refresh
Aug 20, 2026
Merged

fix(ios): make the Home Screen widgets actually refresh, and state their age#548
Makisuo merged 1 commit into
mainfrom
fix/ios-widget-refresh

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

The report was "the iOS widgets don't seem to update often". It turned out to be two
independent bugs, neither of them in the network layer, plus a missing piece of
honesty on the widget itself.

1. The reload budget was being burned

WidgetPublisher called WidgetCenter.reloadTimelines(ofKind:) inside each
per-organization, per-surface publish — up to six per round — unconditionally, even when
the fetch returned data identical to what was already stored. reloadTimelines(ofKind:)
also rebuilds every instance of that kind, so publishing organization B was dragging
organization A's pinned widget through a rebuild with no new data for it.

iOS meters reloads, and the widgets' own timeline rebuilds are drawn from the same budget.
An ordinary day of opening the app exhausted it, after which the Home Screen only moved on
its own .after(+2h) policy — which is exactly what "frozen" looks like.

Now: at most one reload per kind per round, and only for a kind whose numbers a reader
could actually see change.

Getting that test right is the substance of the change. Plain equality is useless here —
WidgetIssue.lastSeenAt (seconds), occurrenceCount, and ServiceThroughput.points (a
sliding window) all move on every single fetch without changing a glyph, so != would
report "changed" every round and buy nothing. WidgetSnapshotContent.contentFingerprint
instead projects each snapshot through the same WidgetFormat / WidgetTime functions the
views call, so "changed" means literally "a reader could see a difference", and the rule
cannot drift away from what the views do without a test noticing.

One deliberate exception, documented at the comparison: sparkline buckets are excluded — an
hour-long series scrolling by one bucket is invisible, and including it would make
suppression a no-op on any organization with traffic. trend, which is derived from those
buckets, is included, so a change of shape that carries meaning still earns a reload.

WidgetReloadDecision also reloads identical-but-stale content: suppressing means the
widget keeps rendering the old generatedAt, so without that clause it would stay dimmed
and captioned "updated 2h ago" while the numbers were in fact current. Bounded to one
reload per staleAfter per surface.

2. Background wakes were silent no-ops once the app had been terminated

WidgetPublisher.context was set only from MainTabView.task(id:). A BGAppRefreshTask or
a silent push that launches the app into the background builds no view tree — so there was
no context, and refresh returned at its first guard. The two triggers that exist
precisely to keep a Home Screen current while the app is closed only ever worked when the
app happened to still be alive in memory.

bootstrap(api:), called from MapleApp.init, assembles a context from what a background
launch does have: the client built there, Clerk's keychain-restored session, and the App
Group index. Clerk.configure hydrates synchronously, so Clerk.shared.session is readable
by the end of init with no await.

Two properties worth checking in review:

  • It never clobbers the view tree. guard context == nil, and it runs once, in init,
    before any view exists. MainTabView still calls configure with the Clerk-verified
    membership list and wins.
  • Sign-out needs no extra flag. clear() empties the index, so the published.contains
    guard fails and a signed-out install cannot resurrect a context on its next wake.

Alongside those

  • Timeline policy 2h → 1h, on a front-loaded ladder (0, 1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120 min). Entries are free — iOS meters timeline requests, not entries — so the
    early rungs buy correct "now"/"3m"/"7m" labels for nothing, and the 90/120 tail is what
    lets a throttled widget keep reading honestly instead of insisting forever that it is an
    hour old. Factored into WidgetTimelineSchedule so the two providers cannot drift.
  • The background task is queued at launch too, not only on scenePhase == .background
    (which lives inside MainTabView, so a user on the sign-in screen never queued one).
    Guarded by pendingTaskRequests()submit replaces a pending request and pushes
    earliestBeginDate out another fifteen minutes, so an unguarded launch-time call would
    starve the task for anyone who opens the app often.

3. Both widgets now always say how old their numbers are

The age existed (snapshot.generatedAt, WidgetTime.age) but was rendered only past
staleAfter, and never at all on throughput. That is backwards: a widget silent about its
age is asking to be read as live, and a line the reader has never seen before reads as a new
kind of error on the day it finally appears. Stated every time, it is a fact you learn to
glance at, and the existing dimming past staleAfter is what escalates it.

Exactly one age carrier per family, never two:

Family Carrier
Issues small, large, empty state UpdatedFooter
Issues medium folded into SeverityLine — at 155pt tall a fourth row clips, which the file already documented
Throughput small, medium ThroughputUpdatedFooter
Throughput large joined onto the existing window line: last hour · updated 4m ago
All Lock Screen accessory families none — no room; the 0.55 dimming carries it

The previous "as of …" append on SeverityLine is removed, so medium cannot say it twice.

Telemetry

New maple.app.widget.reload_count (per round) and maple.app.widget.changed (per
surface). Both bugs above were invisible in production precisely because failures here are
silent by design and a widget showing old-but-plausible numbers looks like working software.
A background round that never appears as a widget.refresh span is bug 2 regressing.

Verification

  • xcodebuild succeeds for the app and the widget extension.
  • 107 package tests pass, including new coverage in WidgetSnapshotContentTests: a
    scrolling sparkline, sub-display float drift, and an occurrence count that still
    abbreviates to "41.2K" all suppress the reload; a rate crossing a rounding boundary, a
    flipped trend, a changed service set, and a moved-across-a-minute lastSeenAt all trip
    it. Also asserts the fingerprint is stable across input order, since make's total
    ordering is load-bearing for suppression.

Not verified, and worth a look before merge: I ran the app in the simulator in fixture
mode and it published snapshots fine, but I could not reliably drive the Home Screen widget
gallery through synthetic taps to see the new footer rendered. The layout change is
additive and low-risk, but the medium Issues family is the one to eyeball — its secondary
line now carries up to four segments (3 critical · 2 high · +5 more · updated 12m ago) at
lineLimit(1), with minimumScaleFactor(0.85) as the margin.

Items 1–3 are independent; if item 2 wants its own build to watch background rounds land,
the telemetry from item 1 is already in place for it.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…eir age

"The widgets don't update" was two independent bugs, neither of them the
network.

**The reload budget was being burned.** `WidgetPublisher` called
`reloadTimelines(ofKind:)` inside each per-organization, per-surface publish —
up to six a round — unconditionally, even when the fetch returned data
identical to what was already stored. `reloadTimelines(ofKind:)` rebuilds
*every* instance of that kind, so publishing organization B was also dragging
organization A's pinned widget through a rebuild with no new data for it. iOS
meters reloads, and the widgets' own timeline rebuilds come out of the same
budget, so an ordinary day of opening the app exhausted it — after which the
Home Screen only moved on its own `.after(+2h)` policy.

Now: one reload per kind per round, and only for a kind whose numbers a reader
could see change. Equality is the wrong test for that — `lastSeenAt`,
`occurrenceCount`, and the throughput sparkline all move on every fetch without
changing a glyph, so `!=` would report "changed" every round and buy nothing.
`WidgetSnapshotContent.contentFingerprint` projects each snapshot through the
same `WidgetFormat`/`WidgetTime` functions the views call, so the rule cannot
drift from what is on screen without a test noticing. One deliberate exception,
documented at the comparison: sparkline buckets are excluded (an hour-long
series scrolling by one bucket is invisible), while `trend`, derived from them,
is not.

**Background wakes were silent no-ops once the app had been terminated.**
`context` was set only from `MainTabView.task`, so a `BGAppRefreshTask` or a
silent push that launches the app into the background — no view tree — found no
context and returned at the first guard. The two triggers built precisely to
keep a Home Screen current while the app is closed only ever worked when the app
happened to still be alive in memory. `bootstrap(api:)`, called from
`MapleApp.init`, assembles a context from Clerk's keychain-restored session and
the App Group index; `Clerk.configure` hydrates synchronously, so no await is
needed. Sign-out cannot resurrect it, because `clear()` empties the index the
bootstrap reads from.

Alongside those: the timeline policy drops from two hours to one, on a
front-loaded ladder with a 90/120-minute tail so a throttled widget keeps
reading honestly instead of freezing on its last entry; and the background task
is now queued at launch as well, guarded by `pendingTaskRequests()` — `submit`
replaces a pending request, so an unguarded call would push the window out
forever for anyone who opens the app often.

Finally, both widgets now always say how old their numbers are. The age existed
but appeared only past `staleAfter`, and not at all on throughput — a widget
silent about its age is asking to be read as live, and a line nobody has seen
before reads as an error on the day it appears.
@Makisuo
Makisuo merged commit c1a96c5 into main Aug 20, 2026
26 checks passed
@Makisuo
Makisuo deleted the fix/ios-widget-refresh branch August 20, 2026 09:58
@Makisuo
Makisuo deployed to pr-preview August 20, 2026 09:58 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit f0b0234 · View workflow run

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.

1 participant