A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body read store.selection in three places — isSelected, the drag replica's count, and the context menu's styleTarget — and Observation invalidates every reader of the property, past the equatable gate entirely. The band overlay stayed cheap, which is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind. Now LaneView and TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems takes its target as a deferred closure; TrashLaneRowView gains the same treatment plus the Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A growing band costs the selection's own running size; the real board's crossing fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a separate lane-level finding recorded in RENDER-INSTRUMENTATION.md. Also: select() gains defaultsSoleMember — the marquee's explicit nils never avoided the sole-member default, so a one-card band acquired a selectionHead and could scroll the lane out from under its own drag. MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
16 KiB
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 vars 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 atLaneView.==. - 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 — fixed 2026-08-07; kept for the history of the table above. Selecting one card re-ran all 180 faces, because CardFaceView.body read store.selection through isSelected. This was the Observation half the gates explicitly do not cover, and narrowing it meant exactly what the next section describes: each face taking its own selected-ness as a compared parameter.
The selection storm, measured and fixed (2026-08-07)
What surfaced it: drag-selecting felt sluggish — ~0.4 s between the marquee reaching a card and its highlight. KanbanTests/MarqueeRenderCostTests.swift (now the regression suite) measured a marquee sample two ways. A sample whose swept set is unchanged was already free — SwiftUI prunes equal-value @Observable writes before any body runs, so no dedupe guard was ever needed. A sample that changes the set cost the whole board: 180 bodies ≈ 85 ms on the 6×30 fixture, 515 bodies ≈ 233 ms on a copy of the real 515-card Redesign board (debug builds). The asymmetry the user feels is exactly that split: the band overlay is cheap and tracks the cursor, while the highlight waits for the full-board pass. The face's body reached store.selection in three places — isSelected, the drag replica's count (.onDrag's preview builder is non-escaping), and the context menu's styleTarget (.contextMenu's builder likewise) — and all three had to be re-sourced.
The fix: LaneView/TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems' target became a deferred closure. After: selecting one card re-runs 1 face; the growing-band stream costs the selection's own running size (the count parameter genuinely changes for every swept face) instead of the board — 230 bodies over 20 samples where it was 3,600.
What remains, and where the next fix lives. Wall-clock per crossing only roughly halved — 85 → 46 ms on the fixture, 233 → 112 ms on the 515-card board — because every lane body still re-runs per selection change (headers legitimately read the selection) and each lane pass re-measures its masonry: ~1,030 fresh masonryMeasurements (+~520 cache hits) per sample on the real board. The bodies are fixed; the residue is layout. The levers are the headerInk hoist above (stop lane bodies re-running for board-level reads) and the masonry measurement cache not surviving a lane body re-run — both lane-level, neither touched by the card-face change.
The zoom pair (2026-08-03)
Board zoom (03-board-ui.md ▸ Layout — zoom) added two steps to the suite, and they are the only ones here that assert a body count is non-zero — because for zoom, a suppressed render is the bug.
| Step | strip | containers | cards |
|---|---|---|---|
| zoom in one rung | 1 | 12 of 12 | 540 of 540 |
| Actual Size when already there | 0 | 0 | 0 |
A whole-board repaint is the right answer for the first row, not a budget overrun: every figure the strip draws is a multiple of the level, so every lane's chrome and every card's geometry genuinely changed. What is asserted is only that the numbers are non-zero.
The gate must not swallow a level change, and it doesn't — because the level travels in the environment. CardFaceView.== compares the card, its role, its store, the marquee and the drop context; a ⌘+ leaves all five identical. The faces repaint only because @Environment values are outside the comparison by design ("SwiftUI invalidates on those itself"), which is exactly why BoardZoomContext is an environment value rather than a read off AppModel. A zero in that row would be the board zooming its lane chrome while every card face stayed at 13pt — zoomRepaintsTheCardFaces is the tripwire.
A board nobody zoomed pays nothing. @Observable notifies on every set, equal or not, so BoardZoomStore.setLevel refuses an unchanged level outright rather than re-running the strip to draw what it was already drawing. actualSizeIsTheUntouchedBoard pins that guard, and pins that the resting ruler is the system's own body size.
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.timeoutfired first (DragSession.expire). The write did not come back, and the board animated the arrangement back to snapshot order. Atimeoutin 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.timestampandCACurrentMediaTime()are both seconds since boot offmach_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). OnlysinceLastMsis 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
LaneVieworCardFaceViewhere 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'sNSSearchToolbarItem, 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 theheaderInkfinding 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:
# 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:
/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
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.