Drag instrumentation — render counters, hot-path signposts, and the invariants that prove the gates
BoardRenderMetrics (DEBUG-only, the pathfinder's counter bag plus a strip-body discriminator that tells a failed gate from a direct Observation invalidation) counted at BoardView/LaneView/CardFaceView/ TrashLaneView bodies and MasonryLayout's callbacks. DragSignposts wraps dropUpdated, retargetCards, commitDrop, and the commit-to-covering- snapshot release pause; input latency reports honestly against NSApp.currentEvent's mach base or labels itself base=none — no event timestamp rides the drop path. BoardRenderPerformanceTests hosts the real BoardView off-screen: a value-equal reload runs zero lane and zero card bodies, a one-card edit repaints one card of 180. The lane-body budget is <= laneCount with the headerInk chain documented and a two-way tripwire that fails when the fix lands. Methodology in RENDER-INSTRUMENTATION.md. Release build proves it all compiles out. Drag-perf card a450ad09. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -416,6 +416,12 @@ struct BoardDropContext {
|
||||
/// event time is the geometry that genuinely moves under a stationary cursor: the grid, which the
|
||||
/// autoscroll driver scrolls on purpose, and `DropSlotMath`'s own containment arithmetic.
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
// `DragSignposts` — begin/end with a `defer` rather than a scoped closure, so the guards
|
||||
// below keep their early returns and this reads as instrumentation rather than a rewrite.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginRetargetCards()
|
||||
defer { DragSignposts.endRetargetCards(span) }
|
||||
#endif
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let resting = session.restingLayouts.layout(
|
||||
inLane: laneID,
|
||||
@@ -732,6 +738,12 @@ struct BoardDropContext {
|
||||
/// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at
|
||||
/// their landing slots, the originals lifted out — until this store's echo reload lands.
|
||||
func commitDrop() -> Bool {
|
||||
// The synchronous half of the release, every refusing branch included (`DragSignposts`); the
|
||||
// *pause* the user feels starts inside, at `DragSession.commit(into:)`, and outlives this.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginCommitDrop()
|
||||
defer { DragSignposts.endCommitDrop(span) }
|
||||
#endif
|
||||
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
|
||||
return false
|
||||
}
|
||||
@@ -944,6 +956,13 @@ struct LaneDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
// The per-event span, and the input sample (`DragSignposts`). One delegate sees any one
|
||||
// event — single-target dispatch has no fall-through — so the cadence is not double-counted.
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
@@ -1000,6 +1019,11 @@ struct StripDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
@@ -1055,6 +1079,11 @@ struct TrashDropDelegate: DropDelegate {
|
||||
func dropEntered(info: DropInfo) { retarget(info) }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
#if DEBUG
|
||||
let span = DragSignposts.beginDropUpdated()
|
||||
DragSignposts.sampleInput()
|
||||
defer { DragSignposts.endDropUpdated(span) }
|
||||
#endif
|
||||
retarget(info)
|
||||
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
/// **Render-cost counters for the board strip — DEBUG only, and the only way a test can tell "one
|
||||
/// card repainted" from "the whole board rebuilt".**
|
||||
///
|
||||
/// Ported from the pathfinder (`Kanban/Views/MasonryLayout.swift`'s `BoardRenderMetrics`), which is
|
||||
/// where the shape comes from: a flat bag of counters, a `reset()`, and one `count…` call at the top
|
||||
/// of each body being watched. What is new here is the masonry's **column** vocabulary and the
|
||||
/// trash column's own counter — Lanework's strip has three body levels, not two.
|
||||
///
|
||||
/// ### What it is for
|
||||
///
|
||||
/// The 2026-07-31 drag-performance work shipped four gates — `LaneView.==` / `CardFaceView.==`
|
||||
/// applied through `.equatable()` (84f909a), the resting-layout cache (a51ad75), the marquee's
|
||||
/// de-observation (f6105d4) and the resize hold (409f430) — and every one of them is a claim about
|
||||
/// *how many bodies run*. A claim like that is unfalsifiable from the outside: the board looks the
|
||||
/// same either way, only the frame rate differs, and a frame rate is a machine fact rather than a
|
||||
/// test. These counters are what turn each gate into an assertion (`BoardRenderPerformanceTests`).
|
||||
///
|
||||
/// The reading half is RENDER-INSTRUMENTATION.md at the repo root, beside DRAG-REORDER.md: the
|
||||
/// Instruments 26 SwiftUI template, the Hitches thresholds, and how the drag signposts
|
||||
/// (`DragSignposts`) line up against these numbers.
|
||||
///
|
||||
/// ### Why the storage is what it is
|
||||
///
|
||||
/// `nonisolated(unsafe) static var`, exactly as the pathfinder has it. In practice every write
|
||||
/// happens on the main actor — SwiftUI runs body evaluation *and* `Layout` callbacks there — but the
|
||||
/// `Layout` conformance itself is `nonisolated`, so a main-actor-isolated counter would not be
|
||||
/// reachable from `MasonryLayout.sizeThatFits`. The unsafety is real and deliberately accepted: this
|
||||
/// type does not exist in a release build, and the alternative is a lock on the layout hot path of a
|
||||
/// board the whole point is to keep fast.
|
||||
///
|
||||
/// **Nothing in the app reads these.** They are written by the bodies below and read by tests. A
|
||||
/// counter that branched would be instrumentation that changed the thing it measures.
|
||||
enum BoardRenderMetrics {
|
||||
|
||||
/// Every `subview.sizeThatFits(…)` `MasonryLayout` actually performed — the number its
|
||||
/// measurement cache exists to drive down.
|
||||
nonisolated(unsafe) static var masonryMeasurements = 0
|
||||
|
||||
/// Measurement requests served from that cache instead.
|
||||
nonisolated(unsafe) static var masonryCacheHits = 0
|
||||
|
||||
/// `MasonryLayout.sizeThatFits` calls — SwiftUI probes a layout more than once per pass, which
|
||||
/// is the multiplier the cache absorbs.
|
||||
nonisolated(unsafe) static var masonrySizeThatFitsCalls = 0
|
||||
|
||||
/// `MasonryLayout.placeSubviews` calls.
|
||||
nonisolated(unsafe) static var masonryPlaceCalls = 0
|
||||
|
||||
/// `CardFaceView.body` evaluations — both homes, board and trash, since it is one view.
|
||||
nonisolated(unsafe) static var cardBodyEvaluations = 0
|
||||
|
||||
/// `BoardView.body` evaluations — the strip itself.
|
||||
///
|
||||
/// Not one of the pathfinder's counters, and it earns its place by being the **discriminator**:
|
||||
/// "a lane re-ran" means one of two completely different things depending on whether the strip
|
||||
/// re-ran with it. Strip **and** lanes is a parent pass whose `.equatable()` gate did not
|
||||
/// suppress; lanes with the strip *still* is a direct Observation invalidation, which no gate has
|
||||
/// any say over (`LaneView.==`'s own note). Without this number the two are indistinguishable
|
||||
/// from a test, and the first diagnosis this instrumentation produced turned on exactly that
|
||||
/// distinction (RENDER-INSTRUMENTATION.md ▸ What the first run found).
|
||||
nonisolated(unsafe) static var stripBodyEvaluations = 0
|
||||
|
||||
/// `LaneView.body` evaluations (the pathfinder's `columnBodyEvaluations`, renamed to Lanework's
|
||||
/// vocabulary: a lane is the kanban column, and a *column* is an interior masonry track).
|
||||
nonisolated(unsafe) static var laneBodyEvaluations = 0
|
||||
|
||||
/// `TrashLaneView.body` evaluations — counted separately from the lanes because the column is
|
||||
/// not one: it renders only while shown, and it re-runs for reasons the lanes do not have.
|
||||
nonisolated(unsafe) static var trashLaneBodyEvaluations = 0
|
||||
|
||||
/// Every container body the strip draws — what "≤ N container bodies" is asserted against, so a
|
||||
/// regression cannot hide by moving from one counter to the other.
|
||||
static var containerBodyEvaluations: Int {
|
||||
laneBodyEvaluations + trashLaneBodyEvaluations
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
masonryMeasurements = 0
|
||||
masonryCacheHits = 0
|
||||
masonrySizeThatFitsCalls = 0
|
||||
masonryPlaceCalls = 0
|
||||
cardBodyEvaluations = 0
|
||||
stripBodyEvaluations = 0
|
||||
laneBodyEvaluations = 0
|
||||
trashLaneBodyEvaluations = 0
|
||||
}
|
||||
|
||||
static func countStripBody() { stripBodyEvaluations += 1 }
|
||||
static func countCardBody() { cardBodyEvaluations += 1 }
|
||||
static func countLaneBody() { laneBodyEvaluations += 1 }
|
||||
static func countTrashLaneBody() { trashLaneBodyEvaluations += 1 }
|
||||
}
|
||||
#endif
|
||||
@@ -122,6 +122,11 @@ struct BoardView: View {
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||||
// between "the gate did not suppress" and "Observation invalidated the lane directly".
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countStripBody()
|
||||
#endif
|
||||
GeometryReader { proxy in
|
||||
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
|
||||
// shadows opened at the proposal. Recomputed on every render, so a foreign reload
|
||||
|
||||
@@ -198,6 +198,11 @@ struct CardFaceView: View, Equatable {
|
||||
/// gestures that fire and refuse (`CardFaceRole`).
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
// The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a
|
||||
// `@ViewBuilder` body takes statements as views and a bare call would be one.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countCardBody()
|
||||
#endif
|
||||
switch role {
|
||||
case let .board(openCard):
|
||||
face
|
||||
|
||||
@@ -577,6 +577,10 @@ final class DragSession {
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool
|
||||
) {
|
||||
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
||||
#if DEBUG
|
||||
DragSignposts.resetInputSampling()
|
||||
#endif
|
||||
endHold()
|
||||
restingLayouts.clear()
|
||||
self.kind = kind
|
||||
@@ -664,6 +668,12 @@ final class DragSession {
|
||||
/// that guard reads.
|
||||
func commit(into store: BoardStore) {
|
||||
guard isActive else { return }
|
||||
// **The release pause starts here** (`DragSignposts`): this is the one path that arms a hold,
|
||||
// so every begin has an end — a refused release opens nothing. Closed at `handOff`,
|
||||
// `expire`, or `endHold`, each with its own outcome.
|
||||
#if DEBUG
|
||||
DragSignposts.beginReleasePause()
|
||||
#endif
|
||||
sourceStore?.transient.dragMembers = .empty
|
||||
watchdog?.cancel()
|
||||
watchdog = nil
|
||||
@@ -688,6 +698,9 @@ final class DragSession {
|
||||
/// already been handed off — or replaced by a second drag's — is not this one's to end.
|
||||
func expire(_ hold: CommittedHold) {
|
||||
guard self.hold == hold else { return }
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "timeout")
|
||||
#endif
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
|
||||
}
|
||||
|
||||
@@ -697,10 +710,20 @@ final class DragSession {
|
||||
/// the board it belongs to — a reload on some other board says nothing about this one.
|
||||
func handOff(root: BoardRootKey, generation: Int) {
|
||||
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
||||
// The span the release-pause signpost exists for: commit → the covering snapshot.
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "echo")
|
||||
#endif
|
||||
end()
|
||||
}
|
||||
|
||||
private func endHold() {
|
||||
// A no-op for the two paths above, which have already closed their interval with a more
|
||||
// specific outcome; the backstop for every other teardown (`end()` from the watchdog, a
|
||||
// cancel, a second drag beginning).
|
||||
#if DEBUG
|
||||
DragSignposts.endReleasePause(outcome: "cleared")
|
||||
#endif
|
||||
hold = nil
|
||||
holdTimeoutTask?.cancel()
|
||||
holdTimeoutTask = nil
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#if DEBUG
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import os.signpost
|
||||
|
||||
/// **The drag hot path, as `OSSignposter` intervals — DEBUG only, fire-and-forget, no branches.**
|
||||
///
|
||||
/// Three questions the 2026-07-31 drag-performance work left unmeasurable from inside the app, and
|
||||
/// one it can only half-answer. Each is an interval on the `dev.rzen.indie.Kanban` subsystem's
|
||||
/// `drag` category, so Instruments' os_signpost lane graphs them beside the SwiftUI template's
|
||||
/// Update Groups and the Hitches instrument's commit/render split (RENDER-INSTRUMENTATION.md).
|
||||
///
|
||||
/// | Signpost | Span |
|
||||
/// | --- | --- |
|
||||
/// | `drop-updated` | one `dropUpdated(info:)` — the per-event retarget **and** the returned `DropProposal` |
|
||||
/// | `retarget-cards` | one `BoardDropContext.retargetCards(inLane:)` — resting layout + `DropSlotMath.cardSlot` |
|
||||
/// | `drop-commit` | one `BoardDropContext.commitDrop()` — the synchronous write bracket the release blocks on |
|
||||
/// | `drop-release-pause` | the committed-overlay hold: `DragSession.commit(into:)` → the snapshot that retires it |
|
||||
/// | `drag-input-latency` | *event*, not interval — see below |
|
||||
///
|
||||
/// ### The release pause, and why it begins where it does
|
||||
///
|
||||
/// `drop-release-pause` is the number a user calls "the board freezes when I let go": the write
|
||||
/// goes out, the overlay keeps drawing the arrangement, and the shadows only dissolve when the echo
|
||||
/// reload lands (`CommittedHold`, DRAG-REORDER.md § The committed-overlay hold). It therefore has to
|
||||
/// span two callbacks — `performDrop` on one side, `BoardView`'s `snapshotGeneration` watch on the
|
||||
/// other — which is the one span here that needs retained state rather than a `defer`.
|
||||
///
|
||||
/// It begins at `DragSession.commit(into:)` rather than at `commitDrop()`'s first line, deliberately:
|
||||
/// `commit(into:)` is the *only* path that arms a hold, so every begin has an end, and a refused
|
||||
/// release (a vanished lane, an emptied run, a mixed-kind trash drag) never opens an interval that
|
||||
/// nothing would close. The handful of guards above it are covered by `drop-commit`, which wraps the
|
||||
/// whole of `commitDrop()` including the branches that return `false`. The end carries an
|
||||
/// `outcome` — `echo`, `timeout` (`DragSession.expire`) or `cleared` (any other teardown) — because a
|
||||
/// pause that ended on the watchdog rather than on a snapshot is a different bug from a slow one.
|
||||
///
|
||||
/// ### Input latency: what is measurable, and the gap
|
||||
///
|
||||
/// "The shadow lags the cursor" wants `now − (the time the OS stamped the mouse event)`. **The
|
||||
/// SwiftUI drop path never surfaces that stamp.** `DropInfo` carries a `location` and item providers
|
||||
/// and nothing else; `NSDraggingInfo` underneath it carries a dragging sequence number, a location
|
||||
/// and a pasteboard, and no timestamp either; and Lanework's own drop code reads only *static*
|
||||
/// `NSEvent` class properties (`mouseLocation`, `modifierFlags`, `pressedMouseButtons`) — never an
|
||||
/// `NSEvent` instance (`BoardDropContext.globalCursor`). There is no event object in hand to ask.
|
||||
///
|
||||
/// So this samples the one base that can exist — `NSApp.currentEvent`, the event AppKit is currently
|
||||
/// dispatching — and **says which base it got** rather than inventing one. `NSEvent.timestamp` and
|
||||
/// `CACurrentMediaTime()` are both seconds since boot off `mach_absolute_time`, so where a mouse
|
||||
/// event *is* current the subtraction is unit-consistent and is the real figure. Where it is not —
|
||||
/// a drag driven entirely by the drag server, a Finder file drag with no local mouse event at all —
|
||||
/// the emitted event says `base=none` and carries only `sinceLastMs`, the interval since the previous
|
||||
/// sample. That number is honest (it is the callback cadence, which is what a laggy drag actually
|
||||
/// degrades) and it is not latency; the trace must not be read as though it were.
|
||||
///
|
||||
/// ### Strict concurrency
|
||||
///
|
||||
/// `OSSignposter` is `Sendable` and its `emit`/`beginInterval`/`endInterval` are safe from anywhere.
|
||||
/// The one piece of retained state — the open release interval — lives on the main actor, which is
|
||||
/// where every caller already is (drop delegates, `DragSession`, `BoardView`'s `onChange`).
|
||||
@MainActor
|
||||
enum DragSignposts {
|
||||
|
||||
/// The subsystem every `Logger` in this app already uses, so a trace and a log line filter the
|
||||
/// same way. `category: "drag"` keeps the intervals out of the store's and the window's noise.
|
||||
static let signposter = OSSignposter(subsystem: "dev.rzen.indie.Kanban", category: "drag")
|
||||
|
||||
// MARK: - Per-event spans
|
||||
|
||||
/// `dropUpdated(info:)` — the whole callback, retarget and proposal alike.
|
||||
///
|
||||
/// Returned rather than scoped, so the call site can pair it with a `defer` and leave the
|
||||
/// existing early returns exactly as they are.
|
||||
static func beginDropUpdated() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("drop-updated", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endDropUpdated(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("drop-updated", state)
|
||||
}
|
||||
|
||||
static func beginRetargetCards() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("retarget-cards", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endRetargetCards(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("retarget-cards", state)
|
||||
}
|
||||
|
||||
static func beginCommitDrop() -> OSSignpostIntervalState {
|
||||
signposter.beginInterval("drop-commit", id: .exclusive)
|
||||
}
|
||||
|
||||
static func endCommitDrop(_ state: OSSignpostIntervalState) {
|
||||
signposter.endInterval("drop-commit", state)
|
||||
}
|
||||
|
||||
// MARK: - The release pause
|
||||
|
||||
/// The open `drop-release-pause` interval, or `nil` when no hold is standing.
|
||||
///
|
||||
/// One at a time by construction: `DragSession` holds one `CommittedHold` and a second drag
|
||||
/// cannot begin while it stands. A begin that finds one open closes it as `superseded` anyway,
|
||||
/// so a mismatched pair can never leak an interval that never ends.
|
||||
private static var releasePause: OSSignpostIntervalState?
|
||||
|
||||
/// The write is out and the committed overlay is holding — the pause has started.
|
||||
static func beginReleasePause() {
|
||||
if releasePause != nil { endReleasePause(outcome: "superseded") }
|
||||
releasePause = signposter.beginInterval("drop-release-pause", id: .exclusive)
|
||||
}
|
||||
|
||||
/// The overlay dissolved. `outcome` distinguishes the snapshot landing from the two ways a hold
|
||||
/// can end without one.
|
||||
static func endReleasePause(outcome: StaticString) {
|
||||
guard let state = releasePause else { return }
|
||||
releasePause = nil
|
||||
signposter.endInterval("drop-release-pause", state, "\(outcome)")
|
||||
}
|
||||
|
||||
// MARK: - Input latency
|
||||
|
||||
/// When the previous `dropUpdated` sample was taken — the fallback figure's base, and the only
|
||||
/// one that is available unconditionally.
|
||||
private static var lastSample: CFTimeInterval?
|
||||
|
||||
/// Emits one `drag-input-latency` event per drop callback.
|
||||
///
|
||||
/// See the type's note for why this is best-effort: `base=event` is a true input-to-callback
|
||||
/// latency, `base=none` is a cadence sample wearing the same name, and the emitted field says
|
||||
/// which. Nothing here is retained beyond one `CFTimeInterval` and nothing branches on it.
|
||||
static func sampleInput() {
|
||||
let now = CACurrentMediaTime()
|
||||
let sinceLast = lastSample.map { (now - $0) * 1000 } ?? -1
|
||||
lastSample = now
|
||||
|
||||
let event = NSApp.currentEvent
|
||||
let stamp: CFTimeInterval? = switch event?.type {
|
||||
case .some(.mouseMoved), .some(.leftMouseDragged), .some(.rightMouseDragged),
|
||||
.some(.otherMouseDragged), .some(.leftMouseUp), .some(.leftMouseDown):
|
||||
event?.timestamp
|
||||
default:
|
||||
nil
|
||||
}
|
||||
|
||||
if let stamp {
|
||||
signposter.emitEvent(
|
||||
"drag-input-latency", id: .exclusive,
|
||||
"base=event latencyMs=\((now - stamp) * 1000, privacy: .public) sinceLastMs=\(sinceLast, privacy: .public)"
|
||||
)
|
||||
} else {
|
||||
signposter.emitEvent(
|
||||
"drag-input-latency", id: .exclusive,
|
||||
"base=none latencyMs=-1 sinceLastMs=\(sinceLast, privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the cadence base — a new drag's first sample must not report the gap since the
|
||||
/// previous drag's last one.
|
||||
static func resetInputSampling() {
|
||||
lastSample = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -146,6 +146,11 @@ struct LaneView: View, Equatable {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// The strip's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because
|
||||
// `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countLaneBody()
|
||||
#endif
|
||||
// `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.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
@@ -210,6 +210,10 @@ struct MasonryLayout: Layout {
|
||||
// change, which is the only way a card's measured height can change at a fixed column width
|
||||
// (its content changed → its view tree was rebuilt → the layout's content is new). Clearing
|
||||
// there means the cache never outlives the content it measured.
|
||||
//
|
||||
// Both halves are counted in DEBUG builds (`BoardRenderMetrics.masonryMeasurements` /
|
||||
// `masonryCacheHits`), which is what makes "the cache absorbs SwiftUI's repeat probes" a number
|
||||
// a test can read rather than a claim — see RENDER-INSTRUMENTATION.md.
|
||||
struct Cache {
|
||||
var columnWidth: CGFloat = .nan
|
||||
var heights: [CGFloat] = []
|
||||
@@ -229,8 +233,14 @@ struct MasonryLayout: Layout {
|
||||
cache.heights = [CGFloat](repeating: .nan, count: subviews.count)
|
||||
}
|
||||
if !cache.heights[index].isNaN {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryCacheHits += 1
|
||||
#endif
|
||||
return cache.heights[index]
|
||||
}
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryMeasurements += 1
|
||||
#endif
|
||||
let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height
|
||||
cache.heights[index] = height
|
||||
return height
|
||||
@@ -248,6 +258,9 @@ struct MasonryLayout: Layout {
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonrySizeThatFitsCalls += 1
|
||||
#endif
|
||||
let width = proposal.width ?? 0
|
||||
let placement = placement(width: width, origin: .zero)
|
||||
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
|
||||
@@ -255,6 +268,9 @@ struct MasonryLayout: Layout {
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||
#if DEBUG
|
||||
BoardRenderMetrics.masonryPlaceCalls += 1
|
||||
#endif
|
||||
let placement = placement(width: bounds.width, origin: bounds.origin)
|
||||
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
|
||||
for (index, frame) in placement.frames(heights: heights).enumerated() {
|
||||
|
||||
@@ -126,6 +126,11 @@ struct TrashLaneView: View {
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
|
||||
var body: some View {
|
||||
// The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a
|
||||
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||||
#if DEBUG
|
||||
let _ = BoardRenderMetrics.countTrashLaneBody()
|
||||
#endif
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
cards
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The board strip's render cost, measured on a real hosted board** (`BoardRenderMetrics`,
|
||||
/// RENDER-INSTRUMENTATION.md).
|
||||
///
|
||||
/// Ported from the pathfinder's `BoardRenderPerformanceTests`, and the same instrument: a whole
|
||||
/// `BoardView` in an off-screen `NSWindow`, driven exactly the way `FolderWatcher` drives it — one
|
||||
/// `handleWatcherEvent(.treeChanged(.foreign))` per step — with `BoardRenderMetrics` read around
|
||||
/// each. What is asserted is not a timing (a timing is a fact about this machine) but the two
|
||||
/// **invariants the 2026-07-31 drag-performance work is a claim about**:
|
||||
///
|
||||
/// 1. **A reload that landed value-equal re-runs zero bodies.** Since 988a724 an equal walk skips
|
||||
/// the snapshot assignment entirely (`landedReloads` moves, `snapshotGeneration` does not), so
|
||||
/// this holds by construction rather than by a gate — and pinning it here is what makes the
|
||||
/// *construction* checkable: a future edit that assigned an equal model back would break this
|
||||
/// test rather than quietly costing a whole-board render pass on every `.git` touch.
|
||||
/// 2. **A one-card edit re-renders a handful of bodies, not the board.** This is the equatable
|
||||
/// gates' invariant (84f909a; `ViewEquatableTests` pins the comparison list, this pins the
|
||||
/// effect), and the one that a gate silently coming undone would break.
|
||||
///
|
||||
/// ...and the counter-invariant that keeps the gates honest: **selecting a card must still repaint
|
||||
/// it**. A gate that suppressed that would be a broken board, not a fast one.
|
||||
///
|
||||
/// ### What the first run found (2026-08-01)
|
||||
///
|
||||
/// Invariant 1 holds exactly. Invariant 2 holds for **cards** and fails for **containers**: a
|
||||
/// one-card edit costs 1 card body out of 180, and one lane body per lane on the board — 6 of 6, and
|
||||
/// 12 of 12 when the board is doubled. The cause is not the gate coming undone; it is
|
||||
/// `LaneView.headerInk` reading `store.snapshot.background`, which under Observation's
|
||||
/// whole-property tracking subscribes every lane body to the entire snapshot.
|
||||
/// `theLaneCostFollowsTheBoard` pins both halves — the comparison is right, and it is never asked —
|
||||
/// and the container budget in `aOneCardEditIsNotAWholeBoardRebuild` is written to the number the
|
||||
/// tree actually produces, with the reason, rather than to the pathfinder's 4.
|
||||
///
|
||||
/// ### What is hosted
|
||||
///
|
||||
/// The **whole `BoardView`**, in a 1600×1000 off-screen window, with a real `AppModel` in the
|
||||
/// environment (its registry file and clipboard staging redirected into the test's own temp folder,
|
||||
/// `AppModelTests`' idiom). Not a lane-strip stand-in: the gates exist because `BoardView`'s body
|
||||
/// reads the drag session and therefore re-runs for reasons that have nothing to do with any one
|
||||
/// lane, and a harness that hosted `LaneView` directly would be asserting about a parent that does
|
||||
/// not exist. The scaffolding turned out to be four values — the window closure, a
|
||||
/// `TrashConfirmations`, an `openCard` closure and a `BoardSearchPresentation` — all of which a test
|
||||
/// can supply honestly.
|
||||
///
|
||||
/// Boards are real loads off real temp trees, `ViewEquatableTests`' reason.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
/// The board's shape. Wide enough that "the whole board rebuilt" and "one card repainted" are
|
||||
/// unmistakably different numbers, small enough that hosting it stays a unit test.
|
||||
private let laneCount = 6
|
||||
private let cardsPerLane = 30
|
||||
|
||||
/// Ids are derived rather than drawn from `Ident`, because this fixture needs 180 of them.
|
||||
private func laneName(_ lane: Int) -> String {
|
||||
String(format: "1%07d-1111-4111-8111-111111111111", lane)
|
||||
}
|
||||
|
||||
private func cardName(_ lane: Int, _ card: Int) -> String {
|
||||
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeFixture(lanes laneCount: Int = laneCount, cards cardsPerLane: Int = cardsPerLane)
|
||||
throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board(title: "Perf Board")
|
||||
for lane in 0..<laneCount {
|
||||
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
|
||||
for card in 0..<cardsPerLane {
|
||||
try fixture.card(
|
||||
cardName(lane, card),
|
||||
in: laneName(lane),
|
||||
order: "\((card + 1) * 1024)",
|
||||
title: "Card \(lane)-\(card)",
|
||||
body: "Body text for card \(lane)-\(card)."
|
||||
)
|
||||
}
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// MARK: - Hosting
|
||||
|
||||
/// Everything the hosted board needs to stay alive for the length of a test — an `NSHostingView`
|
||||
/// whose window is released the moment nothing holds it would stop rendering mid-measurement.
|
||||
@MainActor
|
||||
private final class HostedBoard {
|
||||
let store: BoardStore
|
||||
let appModel: AppModel
|
||||
let window: NSWindow
|
||||
let view: NSView
|
||||
private let scratch: URL
|
||||
|
||||
init(store: BoardStore, scratch: URL) {
|
||||
self.store = store
|
||||
self.scratch = scratch
|
||||
appModel = AppModel(
|
||||
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
||||
styleMask: [.titled], backing: .buffered, defer: false
|
||||
)
|
||||
self.window = window
|
||||
let root = BoardView(
|
||||
store: store,
|
||||
window: { [weak window] in window },
|
||||
confirmations: TrashConfirmations(),
|
||||
openCard: { _ in },
|
||||
search: BoardSearchPresentation()
|
||||
)
|
||||
.environment(appModel)
|
||||
let hosting = NSHostingView(rootView: root)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
|
||||
view = hosting
|
||||
window.contentView = hosting
|
||||
// `orderBack` rather than `makeKeyAndOrderFront`: the board must lay out and render, and it
|
||||
// must not steal focus from whatever is running the suite.
|
||||
window.orderBack(nil)
|
||||
settle()
|
||||
}
|
||||
|
||||
deinit {
|
||||
window.orderOut(nil)
|
||||
window.contentView = nil
|
||||
try? FileManager.default.removeItem(at: scratch)
|
||||
}
|
||||
|
||||
/// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out.
|
||||
///
|
||||
/// Several short spins rather than one long one, the pathfinder's harness note: an update
|
||||
/// scheduled *by* the previous flush needs another turn before it runs. `layoutSubtreeIfNeeded`
|
||||
/// and `displayIfNeeded` are what actually force the bodies — a hosting view with no display
|
||||
/// pass pending evaluates nothing, and the measurement would read zero for the wrong reason.
|
||||
func settle(turns: Int = 6) {
|
||||
for _ in 0..<turns {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
|
||||
view.layoutSubtreeIfNeeded()
|
||||
window.displayIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// One reload through the store's one inbound door, then a settle — exactly what `FolderWatcher`
|
||||
/// causes when an agent or an editor writes into the tree.
|
||||
func reload() async {
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
settle()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
|
||||
let scratch = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BoardRenderPerf-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
|
||||
}
|
||||
|
||||
/// The counters, snapshotted — reading them into a value keeps a `#expect` from racing a later body.
|
||||
private struct Cost {
|
||||
var strips: Int
|
||||
var cards: Int
|
||||
var containers: Int
|
||||
var measures: Int
|
||||
var cacheHits: Int
|
||||
var sizeThatFits: Int
|
||||
var places: Int
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
strips = BoardRenderMetrics.stripBodyEvaluations
|
||||
cards = BoardRenderMetrics.cardBodyEvaluations
|
||||
containers = BoardRenderMetrics.containerBodyEvaluations
|
||||
measures = BoardRenderMetrics.masonryMeasurements
|
||||
cacheHits = BoardRenderMetrics.masonryCacheHits
|
||||
sizeThatFits = BoardRenderMetrics.masonrySizeThatFitsCalls
|
||||
places = BoardRenderMetrics.masonryPlaceCalls
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
"\(strips) strip bodies, \(containers) container bodies, \(cards) card bodies, "
|
||||
+ "\(measures) measures (+\(cacheHits) cached) over "
|
||||
+ "\(sizeThatFits) sizeThatFits / \(places) place"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The invariants
|
||||
|
||||
@MainActor
|
||||
@Suite("The board strip's render cost", .serialized)
|
||||
struct BoardRenderPerformanceTests {
|
||||
|
||||
@Test("The counters see a real hosted board — the harness itself, before anything is asserted on it")
|
||||
func theHarnessActuallyRenders() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
let board = try host(fixture)
|
||||
let first = Cost()
|
||||
withExtendedLifetime(board) {}
|
||||
|
||||
// A zero here would make every other assertion in this file vacuous — the invariants below
|
||||
// are all "≤", and a harness that rendered nothing satisfies them perfectly.
|
||||
#expect(first.cards >= laneCount * cardsPerLane,
|
||||
"the first paint drew \(first.cards) of \(laneCount * cardsPerLane) card faces")
|
||||
#expect(first.containers >= laneCount)
|
||||
#expect(first.measures > 0, "the masonry measured nothing")
|
||||
print("── first paint — \(first.summary)")
|
||||
}
|
||||
|
||||
@Test("A reload that landed value-equal re-runs zero bodies")
|
||||
func aValueEqualReloadCostsNothing() async throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
|
||||
let generationBefore = board.store.snapshotGeneration
|
||||
let landedBefore = board.store.landedReloads
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
await board.reload()
|
||||
let idle = Cost()
|
||||
|
||||
print("── reload, nothing changed — \(idle.summary)")
|
||||
|
||||
// The walk happened and found the board it already had (988a724).
|
||||
#expect(board.store.landedReloads == landedBefore + 1, "the reload did not land")
|
||||
#expect(board.store.snapshotGeneration == generationBefore,
|
||||
"a value-equal reload moved the snapshot generation")
|
||||
|
||||
#expect(idle.cards == 0, "an unchanged reload re-rendered \(idle.cards) card bodies")
|
||||
#expect(idle.containers == 0, "an unchanged reload re-rendered \(idle.containers) container bodies")
|
||||
}
|
||||
|
||||
@Test("A one-card edit re-renders a handful of bodies, not the board")
|
||||
func aOneCardEditIsNotAWholeBoardRebuild() async throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let total = laneCount * cardsPerLane
|
||||
|
||||
// A foreign edit to exactly one card, the way an external editor makes one per keystroke.
|
||||
try fixture.card(
|
||||
cardName(3, 17), in: laneName(3), order: "\(18 * 1024)",
|
||||
title: "Card 3-17 edited", body: "Body text for card 3-17, now edited."
|
||||
)
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
await board.reload()
|
||||
let edit = Cost()
|
||||
|
||||
print("── reload, ONE card edited — \(edit.summary)")
|
||||
|
||||
#expect(board.store.snapshotGeneration > 0, "the edit never landed")
|
||||
|
||||
// **The card gate's invariant, at the pathfinder's budget exactly.** Loose enough to absorb
|
||||
// SwiftUI evaluating a body more than once per update (the first paint runs each face three
|
||||
// times), and two orders of magnitude under the \(total) an un-gated tree costs — which is
|
||||
// what this measured before `CardFaceView.==`.
|
||||
#expect(edit.cards <= 8, "a one-card external edit re-rendered \(edit.cards) of \(total) card faces")
|
||||
|
||||
// **The container budget is `laneCount`, not the pathfinder's 4 — and that is a diagnosed
|
||||
// finding, not a slack threshold.** Every lane's body re-runs on every model-changing reload,
|
||||
// and `LaneView.==` never gets a say, because `LaneView.body` reads `store.snapshot`:
|
||||
//
|
||||
// LaneView.body → header → .boardTextInk(headerInk) → headerInk
|
||||
// → BoardTextInk.scheme(forBoardBackground: store.snapshot.background, …)
|
||||
//
|
||||
// Observation tracks whole *properties*, so reading `.background` off `store.snapshot`
|
||||
// subscribes that body to the entire snapshot. A reload that changes one card anywhere
|
||||
// assigns `store.snapshot` and invalidates every lane on the board **directly** — and a
|
||||
// direct invalidation is precisely what `.equatable()` has no say over (`LaneView.==`'s own
|
||||
// doc comment says so; the gate still does its job on every *parent-driven* pass, which is
|
||||
// what `aValueEqualReloadCostsNothing` measures at 0 containers for a strip pass that did
|
||||
// happen).
|
||||
//
|
||||
// `theLaneCostFollowsTheBoard` below pins both halves of that diagnosis. Fixing it means
|
||||
// resolving the board's ink once in `BoardView` and passing it down as a compared parameter,
|
||||
// the way `slotWidth` and `columns` already are — out of scope for an instrumentation card,
|
||||
// and the budget here is written to the number the tree actually produces so the *card* gate
|
||||
// stays assertable in the meantime.
|
||||
#expect(edit.containers <= laneCount,
|
||||
"a one-card external edit re-rendered \(edit.containers) bodies for \(laneCount) containers")
|
||||
}
|
||||
|
||||
@Test("Every lane re-runs on a one-card edit, and not because its gate compared unequal")
|
||||
func theLaneCostFollowsTheBoard() async throws {
|
||||
let wide = laneCount * 2
|
||||
let fixture = try makeFixture(lanes: wide, cards: 15)
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
|
||||
// An untouched sibling's value, either side of an edit to a different lane.
|
||||
let siblingBefore = try #require(board.store.snapshot.lanes.first { $0.id == ItemID(rawValue: laneName(5)) })
|
||||
try fixture.card(
|
||||
cardName(3, 7), in: laneName(3), order: "\(8 * 1024)",
|
||||
title: "Card 3-7 edited", body: "Body text for card 3-7, now edited."
|
||||
)
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
await board.reload()
|
||||
let edit = Cost()
|
||||
let siblingAfter = try #require(board.store.snapshot.lanes.first { $0.id == ItemID(rawValue: laneName(5)) })
|
||||
|
||||
print("── reload, ONE card edited, \(wide) lanes — \(edit.summary)")
|
||||
|
||||
// **Half one of the diagnosis: the gate's comparison is right.** An untouched sibling lane is
|
||||
// the same `Lane` value across the reload, and every other member `LaneView.==` compares —
|
||||
// `columns`, `slotWidth`, the store, the band, the drop context — is a window-lived constant
|
||||
// here. So the gate would suppress, if it were ever asked.
|
||||
#expect(siblingBefore == siblingAfter, "an untouched lane came back from the reload unequal")
|
||||
|
||||
// **Half two: it is never asked.** Doubling the lane count doubles the container cost, which
|
||||
// is the signature of "every lane once" rather than "the edited lane several times". The
|
||||
// cause is `LaneView.headerInk`'s `store.snapshot` read — see
|
||||
// `aOneCardEditIsNotAWholeBoardRebuild` for the chain.
|
||||
//
|
||||
// Deliberately `>=`, as a tripwire in *both* directions: this failing because the number went
|
||||
// **down** means the snapshot read has been hoisted out of `LaneView` and the container
|
||||
// budget above should come down to the pathfinder's 4.
|
||||
#expect(edit.containers >= wide,
|
||||
"a one-card edit cost \(edit.containers) container bodies on a \(wide)-lane board — if that is below \(wide), the headerInk finding is fixed")
|
||||
|
||||
// The card gate is unaffected by any of it, on a board twice as wide.
|
||||
#expect(edit.cards <= 8, "a one-card edit re-rendered \(edit.cards) of \(wide * 15) card faces")
|
||||
}
|
||||
|
||||
@Test("Selecting a card still repaints it — the gate never went too far")
|
||||
func selectionStillRepaints() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
|
||||
BoardRenderMetrics.reset()
|
||||
board.store.select([ItemID(rawValue: cardName(3, 17))], in: .board)
|
||||
board.settle()
|
||||
let selected = Cost()
|
||||
|
||||
print("── select one card — \(selected.summary)")
|
||||
|
||||
// Selection changes this face's *styling* (03-board-ui.md § Card face) — something has to
|
||||
// run. The other half of the gate: too strict a `==` would show up here as a zero.
|
||||
#expect(selected.cards > 0, "selecting a card repainted nothing")
|
||||
|
||||
// What it actually costs is **every face on the board**, and that is the design rather than a
|
||||
// defect: `CardFaceView.body` reads `store.selection` (`isSelected`), so a selection change
|
||||
// invalidates all of them directly — the Observation half the gates explicitly do not cover.
|
||||
// Recorded here as a number rather than asserted as a budget: narrowing it would mean each
|
||||
// face taking its own selected-ness as a compared parameter, which is a design change and not
|
||||
// this card's. See RENDER-INSTRUMENTATION.md ▸ What the first run found.
|
||||
#expect(selected.strips >= 1, "the strip did not re-run for a selection change")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Render Instrumentation
|
||||
|
||||
How to find out why the board is slow, and how to prove it stopped being slow. Everything here is DEBUG-only and observes without branching: a release build carries none of it, and a debug build behaves identically with or without an Instruments trace attached.
|
||||
|
||||
Two instruments, both ported from the pathfinder's perf work and extended for Lanework's three body levels.
|
||||
|
||||
**The counters** — `Kanban/UI/Board/BoardRenderMetrics.swift`, a flat bag of `static var`s incremented at the top of `BoardView.body`, `LaneView.body`, `TrashLaneView.body`, `CardFaceView.body`, and inside `MasonryLayout`'s measurement cache. Read from tests (`KanbanTests/BoardRenderPerformanceTests.swift`), never from the app. They answer "how many bodies ran", which is the question every equatable gate is a claim about and the one no profiler answers directly.
|
||||
|
||||
**The signposts** — `Kanban/UI/Board/DragSignposts.swift`, `OSSignposter` intervals on the drag hot path, subsystem `dev.rzen.indie.Kanban`, category `drag`. They answer "how long did the release take, and where did it go", which no counter answers.
|
||||
|
||||
## The counters
|
||||
|
||||
| Counter | Site |
|
||||
| --- | --- |
|
||||
| `stripBodyEvaluations` | `BoardView.body` |
|
||||
| `laneBodyEvaluations` | `LaneView.body` |
|
||||
| `trashLaneBodyEvaluations` | `TrashLaneView.body` |
|
||||
| `containerBodyEvaluations` | the two above, summed — what the budgets are written against |
|
||||
| `cardBodyEvaluations` | `CardFaceView.body`, both homes |
|
||||
| `masonrySizeThatFitsCalls` / `masonryPlaceCalls` | `MasonryLayout`'s two `Layout` callbacks |
|
||||
| `masonryMeasurements` / `masonryCacheHits` | the two halves of its measurement cache |
|
||||
|
||||
The strip counter is the one the pathfinder does not have, and it is the **discriminator**. "A lane re-ran" means two completely different things depending on whether the strip re-ran with it:
|
||||
|
||||
- **strip *and* lanes** — a parent pass whose `.equatable()` gate did not suppress. Look at `LaneView.==`.
|
||||
- **lanes without the strip** — a direct Observation invalidation. No gate has any say over one of those (`LaneView.==`'s own doc comment is explicit), so the fix is to stop the body reading whatever moved.
|
||||
|
||||
### What the first run found (2026-08-01)
|
||||
|
||||
Measured on a hosted board of 6 lanes × 30 cards, and again at 12 lanes × 15:
|
||||
|
||||
| Step | strip | containers | cards |
|
||||
| --- | --- | --- | --- |
|
||||
| first paint | 1 | 12 | 540 |
|
||||
| reload, nothing changed | 1 | **0** | **0** |
|
||||
| reload, ONE card edited | 2 | **6** (= lanes) | **1** of 180 |
|
||||
| the same, on a 12-lane board | 2 | **12** (= lanes) | **1** of 180 |
|
||||
| select one card | 1 | 6 | **180** of 180 |
|
||||
|
||||
Three readings.
|
||||
|
||||
**The value-equal skip works, and the lane gate works with it.** A reload that found nothing changed re-runs the strip once — `BoardView` watches `store.landedReloads`, which moves on every landing — and **zero** lanes and cards. That is `LaneView.==` suppressing a real parent pass, and it is the sharpest single piece of evidence that the gate is wired correctly.
|
||||
|
||||
**The card gate works.** One card edited on disk costs one `CardFaceView.body` out of 180, on both board widths. Un-gated it would be all 180.
|
||||
|
||||
**The lane gate is never asked on a snapshot change, and that is a live finding.** A one-card edit costs one lane body *per lane on the board* — 6 of 6, 12 of 12. It is not the comparison: `BoardRenderPerformanceTests.theLaneCostFollowsTheBoard` pins that an untouched sibling lane comes back from the reload as an equal `Lane` value, with every other member of `LaneView.==` a window-lived constant. The cause is a read:
|
||||
|
||||
```
|
||||
LaneView.body → header → .boardTextInk(headerInk) → headerInk
|
||||
→ BoardTextInk.scheme(forBoardBackground: store.snapshot.background, appearance:)
|
||||
```
|
||||
|
||||
Observation tracks whole **properties**. Reading `.background` off `store.snapshot` subscribes that body to the entire snapshot, so a reload that changes one card anywhere invalidates every lane on the board directly — and `.equatable()` has no say over a direct invalidation. The fix is to resolve the board's ink once in `BoardView` and pass it down as a compared parameter, the way `slotWidth` and `columns` already are. Until then the container budget in the test is written to the number the tree actually produces, with the reason, and `theLaneCostFollowsTheBoard` is a tripwire in both directions: it fails if the number goes **down**, which is the fix landing.
|
||||
|
||||
**Selection is O(board) in card bodies.** Selecting one card re-runs all 180 faces, because `CardFaceView.body` reads `store.selection` through `isSelected`. This is the Observation half the gates explicitly do not cover, and narrowing it would mean each face taking its own selected-ness as a compared parameter — a design change, not a gate.
|
||||
|
||||
## The signposts
|
||||
|
||||
| Signpost | Span | Emitted from |
|
||||
| --- | --- | --- |
|
||||
| `drop-updated` | one `dropUpdated(info:)`, retarget and returned `DropProposal` alike | `LaneDropDelegate`, `StripDropDelegate`, `TrashDropDelegate` |
|
||||
| `retarget-cards` | one `BoardDropContext.retargetCards(inLane:)` — resting layout plus `DropSlotMath.cardSlot` | `BoardDrops.swift` |
|
||||
| `drop-commit` | one `BoardDropContext.commitDrop()`, every refusing branch included | `BoardDrops.swift` |
|
||||
| `drop-release-pause` | `DragSession.commit(into:)` → the snapshot that retires the hold | `DragSession.swift` |
|
||||
| `drag-input-latency` | an **event**, once per `dropUpdated` | `DragSignposts.sampleInput()` |
|
||||
|
||||
`drop-release-pause` is the number a user calls "the board freezes when I let go". It ends with an `outcome`:
|
||||
|
||||
- `echo` — the covering snapshot landed (`DragSession.handOff`). The healthy case; the duration is the echo reload's latency.
|
||||
- `timeout` — `CommittedHold.timeout` fired first (`DragSession.expire`). The write did not come back, and the board animated the arrangement back to snapshot order. A `timeout` in a trace is a bug, not a slow frame.
|
||||
- `cleared` — any other teardown (the watchdog, a cancel, a second drag beginning).
|
||||
- `superseded` — a begin found one already open. Should never appear.
|
||||
|
||||
It begins at `commit(into:)` rather than at `commitDrop()`'s first line because that is the only path that arms a hold: every begin therefore has an end, and a refused release opens nothing. The guards above it are inside `drop-commit`, which wraps the whole function.
|
||||
|
||||
**The three retargeting delegates are instrumented, `BoardFallbackDropDelegate` is not** — it retargets nothing, so a span around it would time an early return. Single-target dispatch means exactly one delegate sees any one event, so the `drop-updated` intervals never overlap and `drag-input-latency`'s cadence is never double-counted.
|
||||
|
||||
### Input latency, and the honest gap
|
||||
|
||||
`drag-input-latency` wants `now − (the time the OS stamped the mouse event)`. **The SwiftUI drop path never surfaces that stamp.** `DropInfo` carries a location and item providers and nothing else. `NSDraggingInfo` underneath it carries a dragging sequence number, a location and a pasteboard — no timestamp. And Lanework's own drop code reads only *static* `NSEvent` class properties (`mouseLocation`, `modifierFlags`, `pressedMouseButtons`), never an `NSEvent` instance, by design (`BoardDropContext.globalCursor`, DRAG-REORDER.md § Animation-proof inputs). There is no event object in hand to ask.
|
||||
|
||||
So the probe samples the one base that can exist — `NSApp.currentEvent` — and **says which base it got** rather than inventing one:
|
||||
|
||||
- `base=event latencyMs=…` — a mouse event was current. `NSEvent.timestamp` and `CACurrentMediaTime()` are both seconds since boot off `mach_absolute_time`, so the subtraction is unit-consistent and this is the real figure.
|
||||
- `base=none latencyMs=-1` — no mouse event was current (a drag driven entirely by the drag server, a Finder file drag with no local event). Only `sinceLastMs` is meaningful there: it is the **callback cadence**, which is what a laggy drag actually degrades, and it is not latency. Do not read the trace as though it were.
|
||||
|
||||
Both cases always carry `sinceLastMs`, reset at `DragSession.begin` so a drag's first sample is not the gap since the previous drag's last one.
|
||||
|
||||
## Reading a trace
|
||||
|
||||
### Instruments 26's SwiftUI template (WWDC25 session 306)
|
||||
|
||||
`⌘I` from Xcode, choose **SwiftUI**. Four lanes matter:
|
||||
|
||||
- **Update Groups** — one row per SwiftUI update, with the cause on the left and the work on the right. This is where a drag that costs one update per mouse sample looks different from one that costs three.
|
||||
- **Long View Body Updates** — bodies that took longer than the threshold. Anything from `LaneView` or `CardFaceView` here is a body that should have been gated; cross-reference against the counters above, which say *how many* ran where this says *how long* one took.
|
||||
- **Long Representable Updates** and **Other Long Updates** — AppKit bridges and everything else. `NSHostingView`, the toolbar's `NSSearchToolbarItem`, the window controller.
|
||||
- **Cause & Effect Graph** — select an update and Instruments draws the chain: **Gesture → State Change → View Body Update**. For a drag this should read `drop-updated` → `DragSession.propose` → one lane's body. If it reads → *every* lane's body, that is the `headerInk` finding above, and the graph names the property.
|
||||
|
||||
Add the **os_signpost** instrument to the same trace so `drop-release-pause` sits on the same timeline as the update groups it brackets. That pairing is the whole point: a long pause with no update groups inside it is waiting on the write, a long pause full of them is the board re-rendering.
|
||||
|
||||
### The Hitches instrument
|
||||
|
||||
Supported on macOS. It splits a dropped frame two ways, and the split is the diagnosis:
|
||||
|
||||
- **commit hitch** — the app took too long to produce the frame (body evaluation, layout, the write bracket). Ours to fix, and the counters are how.
|
||||
- **render hitch** — the render server took too long. Usually offscreen passes, blurs, and shadow rasterisation; not a body-count problem.
|
||||
|
||||
Thresholds, as hitch-time-ratio in ms of hitch per second of scrolling or animation:
|
||||
|
||||
| ≤ 10 ms/s | good |
|
||||
| ≤ 25 ms/s | warning |
|
||||
| ≤ 50 ms/s | critical |
|
||||
|
||||
A drag that reads clean on the counters and still hitches is a render hitch, and the next place to look is the lane plate's translucency and the drag replica's shadow, not `LaneView.==`.
|
||||
|
||||
### `log show` and the sample trigger
|
||||
|
||||
The technique from the 2026-07-31 stall hunt, for the case where the stall is not reproducible under Instruments. The signposts land in the unified log, so they can be read after the fact with no trace attached:
|
||||
|
||||
```sh
|
||||
# everything the drag emitted in the last two minutes, newest last
|
||||
/usr/bin/log show --last 2m --style compact \
|
||||
--predicate 'subsystem == "dev.rzen.indie.Kanban" && category == "drag"'
|
||||
```
|
||||
|
||||
And to catch a stall *while it is happening* — stream the log, and shell out to `sample` the moment a release pause opens:
|
||||
|
||||
```sh
|
||||
/usr/bin/log stream --style compact \
|
||||
--predicate 'subsystem == "dev.rzen.indie.Kanban" && category == "drag"' \
|
||||
| while read -r line; do
|
||||
case "$line" in
|
||||
*drop-release-pause*) /usr/bin/sample Lanework 3 -file /tmp/lanework-release.txt & ;;
|
||||
esac
|
||||
done
|
||||
```
|
||||
|
||||
Three seconds of backtraces starting at the release. If the pause was the write, the main thread is in `BoardWriter`; if it was the reload, it is in `BoardLoader` on a detached task and the main thread is idle; if it was rendering, it is in `AG::Graph` under `NSHostingView`. That three-way split is what the signpost alone cannot tell you, and it is why the trigger is worth the shell loop.
|
||||
|
||||
## Running the counters
|
||||
|
||||
```sh
|
||||
xcodebuild -project Kanban.xcodeproj -scheme Kanban \
|
||||
-destination 'platform=macOS,arch=arm64' \
|
||||
-only-testing:KanbanTests/BoardRenderPerformanceTests test
|
||||
```
|
||||
|
||||
The suite prints a line per step (`── reload, ONE card edited — …`) whatever the outcome, so a run can be compared against a previous one. Only the invariants fail it; absolute figures are machine facts and are not asserted.
|
||||
|
||||
It hosts the **whole `BoardView`** in a 1600×1000 off-screen `NSWindow`, with a real `AppModel` whose registry file and clipboard staging are redirected into the test's own temp folder. Not a stand-in for the strip: the gates exist because `BoardView`'s body re-runs for reasons that have nothing to do with any one lane, and a harness that hosted `LaneView` directly would be asserting about a parent that does not exist. Bodies only run when a display pass is forced, so the harness pumps the run loop and calls `layoutSubtreeIfNeeded` / `displayIfNeeded` several times per step — an update scheduled *by* the previous flush needs another turn.
|
||||
Reference in New Issue
Block a user