The marquee registry stops being observable — reflow writes cost a dictionary store and nothing else

Drops @Observable from MarqueeTargetRegistry (MarqueeSession keeps it —
its rect genuinely renders the band). The audit found no body read
anywhere: the begin guard and sample loop read from inside the drag
gesture, the arrows from inside a key handler, so nothing ever needed
invalidating when a frame moved — while every make-room reflow had each
sliding face re-firing onGeometryChange per display frame, each write
paying Observation registrar bookkeeping on top of the reflow's own
render work (the confirmed A/B culprit of 2026-07-31). Write-gating on
drag-active was rejected: a suppressed write never replays, leaving the
band and arrows navigating stale rectangles. A tripwire test pins the
registry against anyone re-adding the macro.

Drag-perf confirmed culprit, card eb7b75ce.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 19:14:11 -04:00
parent a51ad750ad
commit f6105d4389
2 changed files with 84 additions and 1 deletions
+65
View File
@@ -1,5 +1,6 @@
import CoreGraphics
import Foundation
import Observation
import Testing
@testable import Kanban
@@ -184,6 +185,70 @@ struct NavigationMathTests {
}
}
// MARK: - The registry the arrows and the band read
/// The registry is the one piece of the marquee/arrow pair that is *written* from a view every card
/// face keeps its drawn frame in it and it is deliberately unobserved so those writes invalidate
/// nothing (`MarqueeTargetRegistry`, `LaneDropRegistry`'s rule). That is a property of the type rather
/// than of any call site, so it is pinned here: a drag's make-room reflow animates positions, and an
/// observed registry turns each sliding face into a stream of view invalidations at the display's
/// refresh rate for as long as the reflow runs.
@Suite("MarqueeTargetRegistry ▸ registering a frame invalidates nothing")
@MainActor
struct MarqueeTargetRegistryTests {
/// A box the tracking callback can flip `withObservationTracking`'s `onChange` is `@Sendable`,
/// and Observation calls it from wherever the mutation happened.
private final class Tripwire: @unchecked Sendable {
var fired = false
}
@Test("Every reader the band and the arrows use registers no observation")
func writesAreNotObserved() {
let registry = MarqueeTargetRegistry()
registry.update(target(card1, x: 0, y: 0))
let tripwire = Tripwire()
withObservationTracking {
// Exactly the three reads the event-time callers make: the sample loop's `all`
// (`MarqueeControl.gesture`), the arrows' keyed lookup (`BoardView.step`/`.extend`), and
// the band's begin guard.
_ = registry.all
_ = registry.targets[card1]
_ = registry.contains(.zero)
} onChange: {
tripwire.fired = true
}
// A reflow's worth of re-registration: the same card at a new frame, a new card, a removal.
registry.update(target(card1, x: 0, y: 40))
registry.update(target(card2, x: 0, y: 200))
registry.remove(card2)
#expect(tripwire.fired == false, "MarqueeTargetRegistry must not be @Observable")
}
@Test("The frames it hands back are still live after those writes")
func readsStayCorrect() {
let registry = MarqueeTargetRegistry()
registry.update(target(card1, x: 0, y: 0))
registry.update(target(card2, x: 0, y: 120))
registry.update(target(card1, x: 0, y: 40))
#expect(registry.targets[card1]?.frame.minY == 40)
#expect(registry.all.count == 2)
// Inside the moved card1 (y 40140), and above where it now starts.
#expect(registry.contains(CGPoint(x: 10, y: 50)))
#expect(!registry.contains(CGPoint(x: 10, y: 20)))
// Inside card2 (y 120220) and nothing else.
#expect(registry.contains(CGPoint(x: 10, y: 200)))
registry.remove(card2)
#expect(registry.targets[card2] == nil)
#expect(!registry.contains(CGPoint(x: 10, y: 200)))
}
}
// MARK: - SortMath
@Suite("SortMath ▸ within-lane sort")