Integration: fix/screenstatekit-library merged and verified#18
Integration: fix/screenstatekit-library merged and verified#18nashysolutions wants to merge 3 commits intoanthony1810:mainfrom
Conversation
…t animation - ScreenState: change open class Sendable to @unchecked Sendable with a doc comment stating the invariant all subclasses must maintain (fixes anthony1810#19) - CancelBag: add async-safe store(task:identifier:) that inserts synchronously within actor isolation, eliminating the TOCTOU window between registration and storage (fixes anthony1810#20) - NonIsolatedActionLocker: add doc comment explicitly marking it not thread-safe and directing users to IsolatedActionLocker for concurrent use (fixes anthony1810#21) - StateUpdatable.updateState: change default animation from .smooth to .none so state mutations from network responses are no longer animated by default; callers can opt in at the call site where animation is intentional (fixes #22) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The protocol previously only exposed nonisolated receive(action:), which spawns an unstructured fire-and-forget task with no cancellation support. Callers in async contexts (SwiftUI .task, .refreshable) had to reach into conforming types' internal isolatedReceive method to get awaitable dispatch. Adding func send(action: Action) async makes the safe, structured path part of the public contract. receive(action:) is retained for sync contexts (button callbacks, onAppear) where awaiting is not possible (fixes anthony1810#15). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integration Merge Report —
|
| Side | Code |
|---|---|
| HEAD (observable) | let selectedLanguage = await state?.selectedLanguage ?? .english |
| task-lifecycle | let selectedLanguage = await state?.snapshot.selectedLanguage ?? .english + try Task.checkCancellation() |
Why: Both branches modified the same line. Task-lifecycle was written against the original state.snapshot.* API; observable had already deleted snapshot and exposed properties directly. Git could not auto-resolve.
Resolution: Keep observable's direct property access; add task-lifecycle's try Task.checkCancellation() call on the next line.
Conflict 2 — loadMore()
| Side | Code |
|---|---|
| HEAD (observable) | let currentLanguage = await state.selectedLanguage / let currentWords = await state.words |
| task-lifecycle | let currentSnapshot = await state.snapshot + try Task.checkCancellation() + loaderFactory(currentSnapshot.selectedLanguage) |
Why: Same root cause as Conflict 1 — task-lifecycle still used the removed snapshot property.
Resolution: Keep observable's direct property reads; insert task-lifecycle's try Task.checkCancellation() between the reads and the loader call. This preserves currentWords (needed downstream for the deduplication filter) which task-lifecycle's snapshot-based version had lost.
3. fix/view-dispatch-consistency — 1 file conflicted (HomeViewStore.swift)
HomeView.swift auto-merged cleanly (git correctly applied send(action:) in .task and .refreshable, .id("loadMore")). HomeViewStoreTests.swift also auto-merged cleanly (send-renamed test calls + observable property assertions).
Conflict — loadMore()
| Side | Code |
|---|---|
| HEAD (merged state) | let currentLanguage = await state.selectedLanguage / let currentWords = await state.words / try Task.checkCancellation() |
| view-dispatch | Inserted let currentSnapshot = await state.snapshot + try Task.checkCancellation() + loaderFactory(currentSnapshot.selectedLanguage) before the existing code |
Why: View-dispatch diverged from develop (which still had state.snapshot). When merging into our already-integrated HEAD, git saw the content it expected to insert had been superseded — but placed the conflict marker before the HEAD code rather than recognising the two as equivalent intent.
Resolution: Discard view-dispatch's snapshot-based block entirely. The task-lifecycle merge had already provided the correct implementation with direct property access and cancellation checks.
4. fix/shimmer-gradient — 1 file conflicted (HomeView.swift)
Conflict — shimmer call site in wordList
| Side | Code |
|---|---|
| HEAD | .placeholder(viewState) / .shimmering(active: viewState.isPlaceholder) |
| shimmer-gradient | .placeholder(viewState.snapshot) / .modifier(PhaseShimmerModifier(phase: shimmerPhase, isActive: viewState.snapshot.isPlaceholder)) |
Why: Shimmer-gradient was also written against the original snapshot API. It replaced the Shimmer library call site with PhaseShimmerModifier but referenced viewState.snapshot.isPlaceholder — a property that no longer existed in the integrated HEAD.
Resolution: Accept shimmer-gradient's PhaseShimmerModifier modifier; replace viewState.snapshot with viewState (for .placeholder()) and viewState.isPlaceholder directly (for isActive:).
Additional fix — .onChange (auto-merged with wrong API)
The .onChange(of: viewState.snapshot.isPlaceholder, initial: true) modifier was auto-merged from shimmer-gradient without conflict, but it also used the removed snapshot property. This would have been a compile error. Fixed manually alongside the conflict resolution.
Post-merge Issues Discovered
Build failure — ScreenStateKit CancelBag API mismatch
fix/task-lifecycle used await cancelBag.store(task: task, identifier: action.cancelIdentifier) — a method that exists on CancelBag in the fix/screenstatekit-library branch. ScreenStateKit 1.0.6 (the version resolved by Xcode) was published without that method. The equivalent API is a Task extension: task.store(in: cancelBag, withIdentifier: identifier).
Root cause: The fix/task-lifecycle PR was written against the unreleased fix/screenstatekit-library API. When 1.0.6 was published from a different cut of that branch (or the actor-isolated store was intentionally not included), the call site broke.
Fix: One-line change in HomeViewStore.swift to use the Task extension.
Test failure 1 — Crash in HomeView with few words shows load more progress
Symptom: Fatal crash at <external symbol> during snapshot test run.
Root cause: fix/task-lifecycle added assert(state != nil, "...") at the top of isolatedReceive(action:). The snapshot test renders homeView.contentBody directly — not body. The .task { await viewStore.binding(state:) ... } modifier lives on body, so it never runs. When SnapshotTesting renders loadMoreSection, the .onAppear { viewStore.receive(action: .loadMore) } fires, which dispatches a task → isolatedReceive → assert fires with state == nil → crash.
Fix (applied to fix/task-lifecycle): Replace assert with guard state != nil else { return }. All code paths inside isolatedReceive already use state? optional chaining, so the silent early return is safe. The guard also documents the precondition without crashing in test contexts where partial view hierarchies are rendered.
Lesson: assert() is a blunt tool. Any actor method that can be triggered by view lifecycle callbacks (.onAppear, .task) is reachable from snapshot tests that render sub-views. Prefer guard + early return for recoverable preconditions.
Test failure 2 — Snapshot mismatch in HomeView with placeholder shows skeleton loading
Symptom: Precision 0.43 vs required 0.90. Light mode snapshot.
Root cause: The reference PNGs were recorded against the Shimmer library's .shimmering() modifier. fix/shimmer-gradient replaced this with PhaseShimmerModifier — a custom ViewModifier + Animatable driven by a @State var shimmerPhase: CGFloat = 0 on the parent. In the static snapshot context (no animation running), the Shimmer library still rendered its internal shimmer state, whereas PhaseShimmerModifier at phase = 0 places the gradient band entirely off-screen to the left. The two outputs differ substantially.
Fix (applied to fix/shimmer-gradient): Re-recorded both placeholder snapshots (light + dark) against the integrated codebase. Only those two PNGs changed; all other references remained byte-identical, confirming the regression was isolated to the shimmer rendering path.
Root Cause Summary
All four Definery branches diverged from the same develop commit. Three of them independently wrote code against HomeViewState.snapshot.* — a property that fix/observable-granularity deleted. This single architectural change was the source of every merge conflict:
| Branch | Conflict cause |
|---|---|
fix/task-lifecycle |
state?.snapshot.selectedLanguage / state.snapshot.* in two functions |
fix/view-dispatch-consistency |
Re-inserted a currentSnapshot block that task-lifecycle had already superseded |
fix/shimmer-gradient |
viewState.snapshot.isPlaceholder in conflict + auto-merged .onChange |
What the Merge Order Bought Us
Merging fix/observable-granularity first was the right call. It:
- Established the authoritative property API (
words,selectedLanguage,isPlaceholder) before any other branch could re-introducesnapshotreferences into HEAD - Meant subsequent conflicts were predictable: always "keep HEAD's direct access, add the other branch's logic"
- Kept the direction of resolution consistent across all three remaining merges
Had a different branch gone first, observable's deletions would have conflicted with all the established code rather than the other way around, producing harder-to-reason-about three-way diffs.
Files Touched Across All Merges
| File | Branches |
|---|---|
HomeView.swift |
All 4 |
HomeViewStore.swift |
observable, task-lifecycle, view-dispatch |
HomeViewState.swift |
observable only |
HomeViewStoreTests.swift |
observable, view-dispatch |
HomeViewSnapshotTests.swift |
observable only |
| Snapshot PNGs (placeholder ×2) | shimmer-gradient |
Final State
- Build: ✅ clean
- Tests: 28/28 ✅
- SwiftLint: 0 violations on all merged files
- ScreenStateKit: resolved to published
1.0.6
Summary
This branch merges
fix/screenstatekit-libraryand exists as an integration staging ground where the ScreenStateKit changes were verified alongside all four companion Definery PRs before any individual branch is merged tomain.Changes included (from
fix/screenstatekit-library):ScreenState— changedopen class … Sendableto@unchecked Sendablewith a doc comment stating the thread-safety invariant all subclasses must uphold (closes Improve task cancellation flow & lifecycle management #19)CancelBag— added async-safestore(task:identifier:)that inserts synchronously within actor isolation, eliminating the TOCTOU window between task creation and storage (closes Improve Cancellation Flow, Refactor CancelBag, ScreenActionStore and Error Handling. #20)ActionLocker— added doc comment toNonIsolatedActionLockerexplicitly marking it not thread-safe and directing callers toIsolatedActionLockerfor concurrent use (closes Improve Cancellation Flow, Refactor CancelBag, ScreenActionStore and Error Handling. #21)StateUpdatable.updateState— changed default animation from.smoothto.noneso state mutations from network responses are not animated by default; callers opt in at the call site (closes #22)ScreenActionStore— addedfunc send(action: Action) asyncas the primary structured-concurrency dispatch path;receive(action:)is retained for sync contexts (closes Replace ASCII diagram with draw.io architecture diagrams #15)Integration verification
All changes were tested in combination with:
fix/observable-granularity—@Observablegranularity onHomeViewStatefix/task-lifecycle—CancelBag-based task lifecycle inHomeViewStorefix/view-dispatch-consistency—send(action:)adoption in SwiftUI viewsfix/shimmer-gradient—PhaseShimmerModifierreplacing Shimmer libraryResults: build ✅ · 28/28 tests ✅ · 0 SwiftLint violations ✅
Profiler comparison (
After.tracevs baseline): hitches 4 → 1, cause-graph edges 34,232 → 24,085,HomeSnapshotinvalidation cascade (11,307 edges) eliminated entirely.Test plan
store(task:identifier:)stores tasks atomically with no TOCTOU window under concurrent dispatchStateUpdatable.updateStatemutations from network calls are not animatedsend(action:)awaits the full action lifecycle and propagates cancellation correctly🤖 Generated with Claude Code