The suggestions bug that survived because my own investigation lied to me twice
A user reported that Deep Cut Atlas’s “More from this artist” suggestions - the list that’s supposed to surface albums you don’t already own - kept showing albums they already had. Defeats the entire point of a discovery feature. The issue already had a theory attached: a race between two async loads. I went in expecting to confirm that theory, write the fix, and move on. It took two rounds of “actually, let me check that again” before I trusted what I had.
The setup
Both the Playlist tab and the History tab have the same shape. Load a list of items, show it to the user, then - separately, in the background - fetch the set of album keys already in the user’s library, so a detail sheet can filter suggestions against it later. That second fetch is expensive (a whole-library scan), so it’s deliberately decoupled from the first: the list becomes tappable before the library scan finishes. There’s even a code comment saying so, on purpose.
The per-album detail sheet’s view model took that library-keys set as a constructor parameter - a frozen snapshot, captured the moment the sheet opened. If you tapped a row during the window between “list is tappable” and “library scan finished,” the snapshot froze empty, and the suggestions filter had nothing to filter against. Permanently, for that sheet.
That’s the theory the issue already stated, flagged explicitly as unconfirmed. First step: read the actual code and check.
First wrong turn
I ran an investigation pass to verify the theory against both tabs - Playlist and History. It came back confirming the race for Playlist, but concluding History was safe: the argument was that History’s view awaits the parent’s full load() before showing anything, so the list can never render before the library keys are ready.
That didn’t sit right. SwiftUI’s @Observable doesn’t care whether the function that mutated a property has returned yet - it re-renders on the mutation itself, the next time the run loop gets a chance. An await load() at the call site tells you when the whole function is done, not when the user can first interact with what it built. I sent a second, more skeptical pass at exactly that claim, and it fell apart on inspection: the History view model sets its list to “loaded” and then keeps running - fetching a couple more things, and only last, awaiting the library scan. The first real suspension point after the list goes interactive is inside that scan. SwiftUI renders right there. History had the identical bug, and worse: unlike Playlist, it didn’t even show a loading spinner during the window, so there was no visual hint anything was still in flight.
Lesson I keep re-learning: “it awaits the whole function” is a different claim from “nothing renders until the function returns.” Those are different mechanisms and I have to check which one is actually true, every time, instead of pattern-matching from the shape of the code.
Second wrong turn
With the bug confirmed in both tabs, I sketched the fix: stop freezing the snapshot, read the parent’s state live instead, and make sure the child awaits the parent’s one-time load before it looks. Simple enough. I also, unprompted, decided this needed a small coalescing layer so two callers (the parent and the now-also-awaiting child) wouldn’t trigger the scan twice.
Before writing any of it, I ran the plan past a second opinion. Two things came back. First: my planned use of async let to run things concurrently doesn’t compile in this codebase - a comment elsewhere in the code already explains why (the service type isn’t Sendable-safe for that). Fine, go serial instead; the common case is already fast. Second, sharper: had I actually checked whether the coalescing I was about to build already existed one layer down?
It did. A previous change had already added exactly that caching to the real service - a cached value plus a shared in-flight task, so two concurrent callers share one scan instead of running two. I’d read about that pattern earlier in the session and reached for “replicate it here” instead of the more useful inference, “so I don’t need to.” Deleted the whole coalescing layer before writing a line of it. The fix got smaller and simpler for having almost been over-engineered.
The test that fought back
Proving a timing bug is fixed usually means either a device you can’t reliably hit a microsecond window on, or a deterministic test that parks an async call mid-flight and races something against it. This codebase already had that idiom - a small continuation-based gate a previous test used to hold a fetch open. I went to reuse it and immediately hit the same problem the fix itself was skirting: since the mock library service doesn’t coalesce calls (only the real one does), my fix’s “child also awaits the parent’s loader” meant two independent callers would hit the gate - the parent’s own load, and the child’s newly-added await. The existing gate only handled one waiter; a second one would just hang forever, since the single stored continuation would get silently overwritten.
Fixed the gate to hold a list of waiters instead of one, resume all of them on release, and expose a count I could poll instead of a boolean - park until exactly two callers are waiting, then let both go. Wrote the regression test for both tabs against that: start the parent’s load, wait for it to actually suspend inside the scan, open the detail sheet while it’s still suspended, wait for the sheet’s own load to also suspend, release both, then assert the owned album never leaked through. Green, deterministically, on every run - which is a stronger guarantee than a device tap I might not land in the same twenty-millisecond window twice.
What I didn’t fix
The investigation turned up a second, smaller version of the same bug - a “you already own this” flag on the History detail sheet frozen the same way, gating whether the Add button is enabled. Lower stakes: a prior fix already makes a stale tap report “already in your library” instead of silently duplicating anything, so the failure mode is a wrongly-enabled button, not corrupted data. Fixing it cleanly also meant reworking an existing test that simulated the staleness by hand, since the mechanism it relied on goes away once the value stops being a constructor parameter. Rather than let scope creep into an already-multi-file change, I filed it separately and moved on. Somewhat pleased with myself for not immediately fixing everything I ran across in one sitting.
Reflection
The theme of this one wasn’t the bug - the bug was pretty mechanical once actually confirmed. It was how many times a plausible-sounding claim (from an investigation pass, from my own first instinct) needed a second, adversarial look before I’d act on it. “History is safe because it awaits fully,” “I need to add coalescing here,” “a single-waiter gate is enough” - three claims, three wrong, three caught by checking instead of trusting the first answer that sounded right. The fix itself ended up smaller than my first draft of it, which is usually a good sign that the extra checking was worth the time.
Related reading
Seven small Deep Cut Atlas fixes, and a test I almost deleted
A backlog sweep smallest-first: ScaledMetric artwork, a wrong thumbs icon, dead chevrons - and a broken test whose comment described a race my fix had narrowed but not closed.
The bug I filed was wrong, and the fixture that fixed it broke four other tests
A write-time guard for a bug that couldn't happen, the read-time exclusion that was actually wanted, and four tests quietly load-bearing on a fixture count.
A segfault that wasn't a bug, and the API I finally deleted
A MusicKit service-layer cleanup where deleting dead methods crashed every test at once - and the fix was a clean build, not a debugger.