Skip to content

Integration: fix/screenstatekit-library merged and verified#18

Open
nashysolutions wants to merge 3 commits intoanthony1810:mainfrom
nashysolutions:test/all-fixes
Open

Integration: fix/screenstatekit-library merged and verified#18
nashysolutions wants to merge 3 commits intoanthony1810:mainfrom
nashysolutions:test/all-fixes

Conversation

@nashysolutions
Copy link

Summary

This branch merges fix/screenstatekit-library and exists as an integration staging ground where the ScreenStateKit changes were verified alongside all four companion Definery PRs before any individual branch is merged to main.

Changes included (from fix/screenstatekit-library):

Integration verification

All changes were tested in combination with:

  • fix/observable-granularity@Observable granularity on HomeViewState
  • fix/task-lifecycleCancelBag-based task lifecycle in HomeViewStore
  • fix/view-dispatch-consistencysend(action:) adoption in SwiftUI views
  • fix/shimmer-gradientPhaseShimmerModifier replacing Shimmer library

Results: build ✅ · 28/28 tests ✅ · 0 SwiftLint violations ✅

Profiler comparison (After.trace vs baseline): hitches 4 → 1, cause-graph edges 34,232 → 24,085, HomeSnapshot invalidation cascade (11,307 edges) eliminated entirely.

Test plan

  • Build succeeds against a Definery project consuming this branch
  • store(task:identifier:) stores tasks atomically with no TOCTOU window under concurrent dispatch
  • StateUpdatable.updateState mutations from network calls are not animated
  • send(action:) awaits the full action lifecycle and propagates cancellation correctly

🤖 Generated with Claude Code

nashysolutions and others added 3 commits February 24, 2026 09:39
…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>
@nashysolutions
Copy link
Author

Integration Merge Report — test/all-fixes

Date: 2026-02-24
Branch: test/all-fixes
Base: origin/develop @ 1021b76
Branches merged: fix/observable-granularityfix/task-lifecyclefix/view-dispatch-consistencyfix/shimmer-gradient
ScreenStateKit: fix/screenstatekit-library → merged to nashysolutions/ScreenStateKit:test/all-fixes; upstream published as 1.0.6


Merge Sequence

1. fix/observable-granularity — Clean

No conflicts. This was the most invasive branch: it removed the HomeSnapshot computed property from HomeViewState and replaced it with three individually @Observable-tracked properties (words, selectedLanguage, isPlaceholder). Because it was merged first, all subsequent branches had to be reconciled against its API.

Files changed: HomeViewState.swift, HomeView.swift, HomeViewStore.swift, HomeViewStoreTests.swift, HomeViewSnapshotTests.swift


2. fix/task-lifecycle — 1 file conflicted (HomeViewStore.swift)

HomeView.swift auto-merged cleanly (git correctly combined .onDisappear { viewStore.cancelAll() } from task-lifecycle with the surrounding observable property-access changes).

Conflict 1 — loadWords()

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 → isolatedReceiveassert 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:

  1. Established the authoritative property API (words, selectedLanguage, isPlaceholder) before any other branch could re-introduce snapshot references into HEAD
  2. Meant subsequent conflicts were predictable: always "keep HEAD's direct access, add the other branch's logic"
  3. 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

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