fix(ios): make the Home Screen widgets actually refresh, and state their age - #548
Merged
Conversation
…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.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
WidgetPublishercalledWidgetCenter.reloadTimelines(ofKind:)inside eachper-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, andServiceThroughput.points(asliding window) all move on every single fetch without changing a glyph, so
!=wouldreport "changed" every round and buy nothing.
WidgetSnapshotContent.contentFingerprintinstead projects each snapshot through the same
WidgetFormat/WidgetTimefunctions theviews 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 thosebuckets, is included, so a change of shape that carries meaning still earns a reload.
WidgetReloadDecisionalso reloads identical-but-stale content: suppressing means thewidget keeps rendering the old
generatedAt, so without that clause it would stay dimmedand captioned "updated 2h ago" while the numbers were in fact current. Bounded to one
reload per
staleAfterper surface.2. Background wakes were silent no-ops once the app had been terminated
WidgetPublisher.contextwas set only fromMainTabView.task(id:). ABGAppRefreshTaskora silent push that launches the app into the background builds no view tree — so there was
no context, and
refreshreturned at its firstguard. The two triggers that existprecisely 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 fromMapleApp.init, assembles a context from what a backgroundlaunch does have: the client built there, Clerk's keychain-restored session, and the App
Group index.
Clerk.configurehydrates synchronously, soClerk.shared.sessionis readableby the end of
initwith no await.Two properties worth checking in review:
guard context == nil, and it runs once, ininit,before any view exists.
MainTabViewstill callsconfigurewith the Clerk-verifiedmembership list and wins.
clear()empties the index, so thepublished.containsguard fails and a signed-out install cannot resurrect a context on its next wake.
Alongside those
0, 1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120min). Entries are free — iOS meters timeline requests, not entries — so theearly 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
WidgetTimelineScheduleso the two providers cannot drift.scenePhase == .background(which lives inside
MainTabView, so a user on the sign-in screen never queued one).Guarded by
pendingTaskRequests()—submitreplaces a pending request and pushesearliestBeginDateout another fifteen minutes, so an unguarded launch-time call wouldstarve 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 paststaleAfter, and never at all on throughput. That is backwards: a widget silent about itsage 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
staleAfteris what escalates it.Exactly one age carrier per family, never two:
UpdatedFooterSeverityLine— at 155pt tall a fourth row clips, which the file already documentedThroughputUpdatedFooterlast hour · updated 4m agoThe previous
"as of …"append onSeverityLineis removed, so medium cannot say it twice.Telemetry
New
maple.app.widget.reload_count(per round) andmaple.app.widget.changed(persurface). 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.refreshspan is bug 2 regressing.Verification
xcodebuildsucceeds for the app and the widget extension.WidgetSnapshotContentTests: ascrolling 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
lastSeenAtall tripit. Also asserts the fingerprint is stable across input order, since
make's totalordering 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) atlineLimit(1), withminimumScaleFactor(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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.