Skip to content

iOS: avoid full widget rebuilds for live-only updates - #1055

Open
whisp0 wants to merge 1 commit into
ryanbr:mainfrom
whisp0:agent/ios-widget-live-update-fast-path
Open

iOS: avoid full widget rebuilds for live-only updates#1055
whisp0 wants to merge 1 commit into
ryanbr:mainfrom
whisp0:agent/ios-widget-live-update-fast-path

Conversation

@whisp0

@whisp0 whisp0 commented Aug 2, 2026

Copy link
Copy Markdown

What changed

  • add a live-only widget publish path for heart rate, battery, and connection updates
  • reuse the last score snapshot instead of re-querying the computed Rest series for live-only changes
  • skip App Group writes and WidgetKit reloads when no rendered value changed
  • force a full rebuild on cold start, local-day rollover, repository refresh, and Effort-scale changes
  • add unit coverage for rendered-content deduplication and rollover behavior

Why

The foreground heart-rate hook is admitted once per minute, but each admitted update called the full publisher. That path asks exploreSeries for the Rest metric with the default 4,000-day window even though a live HR, battery, or connection change cannot alter any score field. It then rewrote the shared snapshot and requested a WidgetKit timeline reload even when the rendered values were identical.

This keeps the widget's displayed behavior intact while removing repeated database/computation work. During a live session it can avoid up to roughly 60 full Rest-series reads per hour, plus reads triggered by battery and connection events.

Validation

  • git diff --check
  • Swift frontend parsing for every changed source and test file
  • added XCTest coverage for first publish, timestamp-only deduplication, live/score changes, same-day reuse, and day rollover

A full iOS build/test run was not available because the local environment has Command Line Tools rather than full Xcode.

@whisp0
whisp0 marked this pull request as ready for review August 2, 2026 23:48
@ryanbr

ryanbr commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Reviewed. The design holds up under the checks I could make, and I found nothing wrong with the logic. The gap is verification.

Checked rather than assumed

The dedup covers everything the widget renders. WidgetSnapshot has 11 stored properties; renderedContentChanged compares 10 of them and excludes only updated. That exclusion is safe — grep over StrandiOSWidgets/ and StrandiOS/Widgets/ finds no widget family reading .updated, so it really is metadata. Worth stating because #1022 changed what the widget draws only days ago; effortDisplay and effortWhoop are both in the comparison.

Score changes cannot be lost to the fast path. Only the three genuinely live-only hooks moved to publishLive (battery, connection, bpm). Repository refresh, scene phase, the new Effort-scale hook and AppModel.refreshAfterCompletedBackfill all still take the full path, so anything that can move a score still rebuilds.

The else if in saveAndReloadIfChanged is doing real work. On a rollover where the visible values legitimately match yesterday's, skipping the write entirely would leave updated pinned to the old day — liveUpdateRequiresFullBuild would then return true forever and every subsequent live tick would take the slow path, quietly defeating the optimization. Persisting the stamp without a reload is the right call, and the comment says why.

The Effort-scale hook is a genuine catch, not padding. Without it a Settings scale change would leave effortDisplay/effortWhoop stale in the snapshot until some unrelated refresh happened to fire, because the live path never recomputes them. Adding .onChange(of: effortScaleRaw) closes a hole the fast path would otherwise have opened.

It composes with today's widget work. I test-merged onto current main: clean, with #887's .unavailable and resolveSuiteName and #1022's ring fields all intact alongside the new methods.

Minor

publishLive calls load(), then saveAndReloadIfChanged calls load() again for the same bytes. Harmless next to the 4,000-day Rest query this removes, but the already-loaded snapshot could be passed through if you touch this again.

Verification

No checks have run on this branch at all — the fork / first-time-contributor approval gate, same as your #1056 and #1057. And StrandTests never runs in CI regardless (swift-packages.yml covers Packages/**; app-build.yml is compile-only), so the 55 lines of new XCTest coverage — first publish, timestamp-only dedup, live vs score changes, same-day reuse, rollover — are verified only by your local run.

I ran what is available, app-build.yml with pr: 1055, which merges current main live rather than trusting the frozen merge ref:

build (Strand, macOS)                       success
build (NOOPiOS, iOS Simulator, macos-26)    success

So it compiles against today's tree. That is the whole of what CI currently says about this PR, and it says nothing about whether the dedup behaves correctly at runtime — which is exactly what your tests would establish if anything ran them.

Nice writeup on the motivation, incidentally: "up to roughly 60 full Rest-series reads per hour" is the kind of number that makes the case without overselling it.

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