You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: serialize chat animations behind a fence to stop the missing-final-attributes crash
ChatLayout answers attribute queries with nil during restoreContentOffset; an inset
write or push overlapping a settling batch spring collided with UIKit's animated
bounds-change cross-fade and threw. All animated transactions now bracket a fence,
mutations defer and replay, insets apply via upstream's keyboard recipe, and entrance
motion moves off layout attributes onto cells. Includes the concurrent quality pass
(Reduce Motion tokens, spring parameterization, receipt-reveal fencing).
| Calling `router.present(.x)` next to a viewmodel mutator that may *block* the flow | A viewmodel that surfaces a blocking error via `session.dialogItem` (e.g. `GiveViewModel.showNoBalanceError`) does not stop the router — `DialogWindow` renders the dialog above the sheet, but the sheet is still presented underneath and reappears once the dialog is dismissed. Gate the router on the precondition: expose `attemptPresent() -> Bool` on the viewmodel and write `if vm.attemptPresent() { router.present(.x) }`. Putting the check inside an `isPresented``didSet` is not enough — the router call still runs unconditionally on the next line. |
555
555
| Parsing keypad-emitted amounts with `Decimal(string:)` or `NumberFormatter.decimal(from:)`|`KeyPadView`'s decimal key inserts `AmountValidator.localizedDecimalSeparator`, so on comma-decimal locales the bound string contains ",". `Decimal(string:)` stops at the comma and silently drops the fraction; `NumberFormatter.decimal(from:)` only parses the device locale's format. **Parse keypad strings with `AmountValidator`** (in FlipcashCore's Validation family) — it normalizes the locale separator before parsing. `NumberFormatter.decimal(from:)` remains appropriate for currency-formatted strings (already through a formatter, locale-correct). |
556
556
| Injecting shared DI via a custom keyPath `@Environment(\.key)` with a trapping default | SwiftUI resolves keyPath env **eagerly** during dynamic-list/transition `DynamicProperty` updates (against a placeholder environment), so a `fatalError`/`preconditionFailure` default fires and crashes at launch (`<dep> was not injected`). Inject shared DI as **type-based `@Environment(Type.self)` on an `@Observable`** — the trap is deferred to body access, so it survives the eager pass. That's why `Container`/`SessionContainer` are `@Observable`. Safe-value `@Entry` keyPath defaults (e.g. `nestedSheetDepth = 0`) are unaffected — the hazard is specifically a *crashing* default. |
557
+
| Touching the chat transcript's diff/batch pipeline without the fence | ChatLayout's `restoreContentOffset` answers attribute queries with nil while it re-anchors; if that forced layout pass overlaps animated layout work (a settling batch spring, an inset write inside an animation context), UIKit throws `NSInternalInconsistencyException: missing final attributes for cell`. Every animated transaction in `ChatViewController` brackets itself on `ChatAnimationFence`, and every mutation that must not overlap one (transcript push, `setBottomInset`, offset restores, `freezeInset`/`restoreInset`) defers through `fence.whenIdle` and applies insets via the upstream recipe (`performWithoutAnimation` + empty-batch interrupt + write inside `performBatchUpdates` + restore in the same transaction). Also: **never set transforms on `ChatLayoutAttributes`** — the layout round-trips attribute frames through its keep-at-bottom compensation, so a scale gets baked into stored frames; entrance motion goes on the *cell* in `willDisplay` (`playEntranceIfNeeded`). Contract pinned by `ChatAnimationFenceTests`, the fence section of `ChatViewControllerTests`, and the baseline-attributes tests in `ChatMotionTests`. |
0 commit comments