Proposal changes stop rebuilding the whole board — lanes and card faces gate on their values

LaneView and CardFaceView become Equatable and are instantiated through
.equatable(): the strip's body re-runs on every drop-proposal change, and
without the gates that rebuilt every lane and every card face on every
cursor move of a drag. The == compares value inputs and window-lived
collaborator identities; the closures BoardView rebuilds each pass are
deliberately excluded (BoardDropContext.isEquivalent / MarqueeControl.
isEquivalent / CardFaceRole.isEquivalent own that judgment). Observation
reads inside the bodies still self-invalidate — the lane under the drag
keeps re-running; the other lanes stop.

Ports the pathfinder's CardView/ColumnView gating pattern (drag-perf
suspect #2, card cbb6e476).

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 18:28:33 -04:00
parent ef423bb9d2
commit 84f909a720
7 changed files with 497 additions and 2 deletions
+41 -1
View File
@@ -32,7 +32,15 @@ import SwiftUI
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
/// a click, and viewing an attachment's media is the card window's job, not the face's
/// (03-board-ui.md § Card face).
struct LaneView: View {
///
/// ### Equality gate
///
/// The lane is `Equatable` and instantiated through `.equatable()` (`BoardView.laneSlot`), because
/// the strip re-runs for reasons that have nothing to do with any one lane: `BoardView`'s body reads
/// the drag session, so **every drop-proposal change re-evaluates it**, and without a gate that
/// rebuilds every lane on the board and, through them, every card face on every cursor move
/// during a drag. See the `==` below for what the gate covers.
struct LaneView: View, Equatable {
let store: BoardStore
let lane: Lane
@@ -109,6 +117,34 @@ struct LaneView: View {
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
@State private var autoScroller = DragAutoScroller()
/// The whole of what this lane is a function of **as far as the strip is concerned**: the lane
/// value (`Lane` is `Equatable`, its cards included, so an edit anywhere under this lane makes it
/// a different value), the two layout figures the strip resolves rather than the lane
/// (`columns` follows the live resize session, `slotWidth` the strip's standard width), and the
/// window-lived collaborators the store by identity, the band and the drop machinery by their
/// own equivalence tests, which exist because `BoardView` rebuilds both structs, closures and
/// all, on every body pass.
///
/// **What the gate does not suppress is the point.** Everything this body reads through
/// Observation `drops.session`'s proposal, members and file target, `store.selection`,
/// `store.searchFilter`, `store.transient`, `appModel.styleRecents` invalidates this view
/// directly, and `.equatable()` has no say in that. So the lane a drag is actually over still
/// re-runs on every proposal change, and its `shadowRun` animation key still moves with it; what
/// stops is the *other* lanes re-running because the strip did.
///
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
/// `@Environment` values (SwiftUI invalidates on those itself), and `openCard` a closure the
/// host rebuilds every pass, which is a pure hand-off to a `WindowGroup` key and identical in
/// behaviour whatever closure object carries it.
nonisolated static func == (lhs: LaneView, rhs: LaneView) -> Bool {
lhs.lane == rhs.lane
&& lhs.columns == rhs.columns
&& lhs.slotWidth == rhs.slotWidth
&& lhs.store === rhs.store
&& lhs.marquee.isEquivalent(to: rhs.marquee)
&& lhs.drops.isEquivalent(to: rhs.drops)
}
var body: some View {
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
// lane's top edge, so it must sit outside the content inset rather than in it.
@@ -679,6 +715,10 @@ struct LaneView: View {
marquee: marquee,
drops: drops
)
// **The value gate** (`CardFaceView.==`) the lane's own, one level
// down: this body re-runs on every proposal change while a drag is over
// *this* lane, and the faces it draws are almost never what changed.
.equatable()
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
case let .shadow(_, height):