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
## Problem
`CollectionViewLayout` held its delegate as `unowned let`. In normal use
the `ListView` ownership graph keeps the delegate alive for the layout's
whole lifetime, but a layout swap (`LayoutManager.set(layout:)`) or test
setup that constructs a layout in isolation can deallocate the delegate
first, leaving a dangling `unowned` reference and a hard crash on next
access.
## Fix
Change `delegate` from `unowned let` to `private(set) weak var`, and
tighten the surrounding call sites:
- `prepare()` reads the delegate once at the top, returns early if it's
gone, and threads the unwrapped reference through `performLayout`,
`performRebuild`, and `performLayoutUpdate` to avoid per-call weak
loads.
- `init` keeps a non-optional delegate parameter; the weak storage
already conveys "may vanish later," and the parameter keeps construction
honest.
- `LayoutManager.set(layout:)` uses `listableInternalFatal` if the
outgoing layout's delegate has been deallocated, since silently dropping
a layout swap is worse than crashing on a "should never happen" state.
- `sendEndQueuingEditsAfterDelay()` snapshots the delegate locally and
weak-captures it, so the closure neither pins `self` nor extends the
delegate's lifetime past its natural end.
## Test
Adds
`CollectionViewLayoutTests.test_prepare_whenDelegateHasBeenDeallocated`,
which constructs a layout with a mock delegate, drops the strong
reference, asserts the weak references are nil, and calls `prepare()` to
verify it doesn't crash.
Verified locally that the test fails against `origin/main`'s source and
passes with this PR's fix.
**Before** (against `origin/main`'s `unowned let delegate`):
```
Test Case '-[ListableTests.CollectionViewLayoutTests test_prepare_whenDelegateHasBeenDeallocated]' started.
Fatal error: Attempted to read an unowned reference but object 0x1027ed7a0 was already destroyed
** TEST FAILED **
```
**After** (this PR):
```
Test Case '-[ListableTests.CollectionViewLayoutTests test_prepare_whenDelegateHasBeenDeallocated]' started.
Test Case '-[ListableTests.CollectionViewLayoutTests test_prepare_whenDelegateHasBeenDeallocated]' passed (0.001 seconds).
** TEST SUCCEEDED **
```
## Risk
Low. `ListView` retains its `Delegate` for its entire lifetime, so in
normal flows the delegate is alive every time the layout uses it. The
fix is defense-in-depth for layout swaps and isolated-construction
paths.
### Checklist
- [x] Ensure any public-facing changes are reflected in the
[changelog](https://github.com/square/Listable/blob/main/CHANGELOG.md).
Include them in the `Main` section.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0 commit comments