Skip to content

Add PreferenceKey, preference, and onPreferenceChange#57

Merged
colemancda merged 5 commits into
masterfrom
feature/preferences
Jul 24, 2026
Merged

Add PreferenceKey, preference, and onPreferenceChange#57
colemancda merged 5 commits into
masterfrom
feature/preferences

Conversation

@colemancda

Copy link
Copy Markdown
Member

Child-to-parent data flow: PreferenceKey, .preference(key:value:), and .onPreferenceChange(_:perform:). This closes the last Tier A gap apart from accessibility.

No bridge involvement

Unlike GeometryReader, none of this crosses the bridge — a preference is published and reduced entirely within one Swift resolve pass, so the interpreter is untouched by this PR. onPreferenceChange installs a collector for its subtree, resolves it, and reads the reduction. Reduction starts from the key's defaultValue so the result is the same whether or not anything published.

Two bugs found by running it, not by testing it

Both were invisible to the unit tests I'd written first, and both came out of the demo screen:

  1. Chained observers swallowed each other's keys. An observer scopes a fresh collector to its subtree, and I only forwarded the key it watched — so with .onPreferenceChange(A) inside .onPreferenceChange(B), B's values were dropped. On screen the max reduced correctly while the collected list stayed empty. Collectors now carry a per-key merge closure (values are type-erased, so re-reducing elsewhere needs the concrete key type captured at record time) and propagate everything upward.

  2. Chained observers re-evaluated forever — this hung the app. They share an identity path, and their change-memos shared a storage key. persistentObject can't cast PreferenceMemo<Double> to PreferenceMemo<[String]>, so each pass overwrote the other's memo, every delivery looked new, and the callbacks — which write state — never settled. Tapping "Add a row" killed the process. Memos are now keyed by preference type as well as path.

The second one has a regression test I verified genuinely catches it: with the fix reverted the callback fires on all 5 evaluation passes instead of once.

Verification

  • swift test — 7 new tests, 96 total passing: reduction across a subtree, tree order, the default when nothing publishes, delivery-once settling, nested observers, and the two regressions above.
  • Emulator (new "Preferences" screen): three rows publish 30/60/90 and two chained observers report Largest published: 90 and Collected: row 0, row 1, row 2. Adding a row updates both to 120 / four names; removing one drops back. The app stays alive and settles each time.

Scope

transformPreference, anchorPreference, and PreferenceKey with non-Equatable values aren't included — onPreferenceChange requires Equatable to detect change, as it does in SwiftUI. Note that a delivered preference costs an extra evaluation pass, since the callback normally writes state.

@colemancda
colemancda merged commit 65bc4c8 into master Jul 24, 2026
6 checks passed
@colemancda
colemancda deleted the feature/preferences branch July 24, 2026 02:08
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