The resting layout is built once per snapshot — retargets stop rebuilding it per mouse sample

RestingLayoutCache (session-scoped, @ObservationIgnored on DragSession,
cleared at begin and end) holds each lane's resting layout as ids +
heights — never [Card] — keyed on the hovered board's applied
snapshotGeneration, the registry's new heightsGeneration, the lane, and
the operation-following hidden set. The grid stays event-time on purpose
(autoscroll moves a lane's origin with the snapshot standing still), so
re-grounding rule 1 holds exactly: nothing survives a reload, only the
per-sample repetition goes. The steady-state cost of a hover is now the
containment scan plus four stores and a divide.

Drag-perf suspect #3, card b9f48fd1.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 18:56:08 -04:00
parent 84f909a720
commit a51ad750ad
3 changed files with 602 additions and 23 deletions
+16
View File
@@ -323,6 +323,20 @@ final class DragSession {
/// whichever board's standard width it is being proposed into.
@ObservationIgnored private(set) var laneUnits: [Int] = []
/// The per-lane resting layouts this drag's retargets propose against, built once per snapshot
/// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1
/// of the re-grounding trio exactly as it was).
///
/// `@ObservationIgnored` for the reason `LaneDropRegistry` is not `@Observable` at all: it is
/// written from *event* handlers, and a cache fill that invalidated the strip would be the
/// animation feedback loop this whole model exists to avoid (03-board-ui.md § Motion).
///
/// **Session-scoped**, so it cannot outlive the drag it was filled for and so a layout can
/// assume the one input that is neither in its key nor read live, `cardHeights`, is the frozen
/// set this session picked up with. `begin` and `end` bracket every session this object has, and
/// both clear it.
@ObservationIgnored let restingLayouts = RestingLayoutCache()
// MARK: Where it would land
/// The current proposal, or `nil` when the drag has none a fresh session before the first
@@ -564,6 +578,7 @@ final class DragSession {
mixesKinds: Bool
) {
endHold()
restingLayouts.clear()
self.kind = kind
self.members = members
self.memberSet = Set(members)
@@ -627,6 +642,7 @@ final class DragSession {
folders = []
cardHeights = []
laneUnits = []
restingLayouts.clear()
proposal = nil
operation = .move
sourceStore = nil