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
+17
View File
@@ -110,6 +110,23 @@ struct BoardDropContext {
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
let standard: @MainActor () -> CGFloat
/// Whether two of these would send a drop to the same place **the whole of what this value
/// contributes to `LaneView.==` and `CardFaceView.==`.**
///
/// `BoardView.dropContext` rebuilds this struct on every body pass, closures and all, so a naive
/// value comparison is impossible and a reference comparison is meaningless: the three closures
/// are freshly allocated each time and would make every lane and every card face unequal on every
/// pass, which is exactly the rebuild the gates exist to stop. They are also the members it is
/// safe to ignore each one *reads* window and strip geometry at event time rather than carrying
/// any (see this type's own note), so two contexts with the same store, session and registry
/// resolve every hover identically whatever closure objects they happen to hold.
nonisolated func isEquivalent(to other: BoardDropContext) -> Bool {
store === other.store
&& session === other.session
&& registry === other.registry
&& gap == other.gap
}
// MARK: The cursor
/// The physical cursor in the window's SwiftUI global space.
+4
View File
@@ -461,6 +461,10 @@ struct BoardView: View {
marquee: marqueeControl,
openCard: openCard
)
// **The value gate** (`LaneView.==`): this body re-runs on every drop-proposal change,
// and without this every lane on the board and every card face in it rebuilds on
// every cursor move of every drag.
.equatable()
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
.frame(width: slotWidth, alignment: .topLeading)
+53 -1
View File
@@ -49,6 +49,26 @@ enum CardFaceRole {
case .trash: .trash
}
}
/// Whether two roles put the face in the same home with the same collaborator the comparison
/// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`.
///
/// **The board case's `openCard` is deliberately not compared.** It is a closure the strip
/// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a
/// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object
/// carries it, and a face that changed *which window it opens into* would be a face in a
/// different window and therefore a different view identity entirely.
///
/// The trash case's `confirmations` **is** compared, by identity: it is window-lived state
/// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one
/// collaborator a role carries that the face actually reads state off.
nonisolated func isEquivalent(to other: CardFaceRole) -> Bool {
switch (self, other) {
case (.board, .board): true
case let (.trash(lhs), .trash(rhs)): lhs === rhs
default: false
}
}
}
// MARK: - Card face
@@ -94,7 +114,15 @@ enum CardFaceRole {
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
struct CardFaceView: View {
///
/// ### Equality gate
///
/// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`,
/// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do
/// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is
/// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See
/// the `==` below for what the gate covers and what it deliberately does not.
struct CardFaceView: View, Equatable {
let store: BoardStore
let card: Card
@@ -140,6 +168,30 @@ struct CardFaceView: View {
/// fallback is for.
@State private var measuredWidth: CGFloat = 0
/// The whole of what this face is a function of **as far as its parent is concerned**: the card
/// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), and the three window-lived collaborators
/// the store by identity, the band and the drop machinery by their own equivalence tests, which
/// exist because the strip 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 `store.selection`, `store.searchFilter`, `store.transient.pendingCut` and the
/// rename editor, `drops.session.isDragging`, `appModel.styleRecents` invalidates this view
/// directly, and `.equatable()` has no say in that. The gate only stops the *parent* handing a
/// face a new-but-identical set of inputs and re-running it for nothing, which during a card drag
/// is what every proposal change does to every face in the lane.
///
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
/// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's
/// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`).
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
lhs.card == rhs.card
&& lhs.role.isEquivalent(to: rhs.role)
&& lhs.store === rhs.store
&& lhs.marquee.isEquivalent(to: rhs.marquee)
&& lhs.drops.isEquivalent(to: rhs.drops)
}
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
+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):
+11
View File
@@ -40,6 +40,17 @@ struct MarqueeControl {
let registry: MarqueeTargetRegistry
let store: BoardStore
/// Whether two of these lend the same band the whole of what this value contributes to
/// `LaneView.==` and `CardFaceView.==` (`BoardDropContext.isEquivalent(to:)` is its twin).
///
/// All three members are window-lived objects, so identity is the comparison: this struct holds
/// no geometry and no closures of its own, and the strip rebuilds it on every body pass.
nonisolated func isEquivalent(to other: MarqueeControl) -> Bool {
session === other.session
&& registry === other.registry
&& store === other.store
}
/// The band, as one gesture attached with `simultaneousGesture` wherever empty space is.
///
/// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is
+2
View File
@@ -336,6 +336,8 @@ struct TrashLaneView: View {
marquee: marquee,
drops: drops
)
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
.equatable()
case let .entry(.lane(lane)):
// The opaque unit's row its own view, because "no styling accents" and
// "never expandable" are exactly what a card face is not