Skip to content

v1.1.0 — We Measure Twice Now

Choose a tag to compare

@github-actions github-actions released this 22 Apr 04:17
· 50 commits to main since this release

VoidReader 1.1 — "We Measure Twice Now"

The "we built a ruler before we cut" release

So 1.0 shipped. People loved it. Then someone opened a 50,000-line markdown file and went to make coffee while VoidReader contemplated the meaning of life for about 90 seconds. That was... not great.

The first instinct was to optimize — start swinging at whatever looked slow and hope the profile agreed. That instinct is how codebases end up with three caching layers, a LazyVStack wrapped in a ScrollView wrapped in regret, and nobody able to tell you why anything is faster.

So we did the other thing. We instrumented first. Shipped OSSignposter across every subsystem, built a performance lab that runs traces through xctrace in CI, and let the profile tell us what to fix. Then we fixed those things, in order, one at a time, with receipts.

This release is the result.


The Headlines

134x faster load time for large documents — 1,475ms → 11ms for a 60k-line doc. Not a typo. The trick: show you the first page while we render the rest in the background. Civilized software, arguably.

Large code blocks don't lock up the UI anymore — a 100,000-character code block used to stall the main thread while Highlightr tokenized. Now measurement runs off-main on a serial queue, and the block gets segmented across virtualized rows so SwiftUI only renders what's on screen.

Search doesn't walk the document for every keystroke — TextSearcher used to do an O(M·N) line-count on every match, and the find-bar re-searched the whole doc to populate result previews. Both fixed. Search on a 60k-line doc now feels local, not "please stand by."

Tables bigger than your monitor render smoothly — TableBlockView pre-measures rows and virtualizes, so a 50,000-row CSV table scrolls like a native grid instead of a powerpoint slide loading over dial-up.

Instruments-grade observability — OSSignposter on document lifecycle, rendering passes, image/mermaid/scroll subsystems. Open Instruments, hit record, read the timeline. No more guessing.


What Actually Happened

We Built a Ruler (add-performance-lab)

The whole "measure before you cut" story is backed by an OpenSpec change called add-performance-lab — a discipline layer, not a feature. It defines:

  • A set of fixture documents at known pathological sizes (100k code block, 250k mixed, 50k table)
  • A CI job that runs xctrace against each fixture, collecting timing data
  • A gate that diffs measurement windows against historical baselines

Also: turns out xctrace lies about whether a trace recorded. Its exit code can be non-zero on completed traces, and zero on failed ones. The CI was false-failing on successful runs and false-passing on broken ones. We narrowed the success gate to grep the trace log for the actual failure string — Recording failed with errors — rather than trusting the exit code. A whole category of "why did CI go green" mysteries dissolved.

Performance

  • Progressive rendering: AST-aware chunker slices the doc at block boundaries, first ~20KB renders immediately, rest loads in background
  • Off-main measurement cache: code block height + highlighted attributed strings computed on a serial queue (Highlightr's JSContext is thread-pinned, which shaped the architecture more than we expected) and cached per-document
  • Document-wide height index: scroll math uses a pre-computed cumulative height table instead of asking each block at scroll time
  • Large-code-block segmentation: 100k-character blocks split across LazyVStack rows with a shared groupID, so SwiftUI virtualizes rows of a single logical block. Counter-intuitive — SwiftUI renders N small things much better than one big thing.
  • Visible-region editor highlighting: syntax highlighting runs on what's on screen plus a buffer, not your entire 2MB manifesto
  • LazyVStack virtualization for the reader view
  • Chunked rendering: documents >1000 blocks render in digestible chunks
  • Cached regex: InlineMathParser no longer recompiles its regex on every. single. call.
  • Debounced scroll tracking: 200ms idle before updating position (your CPU says thank you)

Search

Three commits to teach the find-bar that searching a big document shouldn't require walking it three times per keystroke:

  • Cached match texts so the body-evaluation path doesn't re-search the full doc
  • Dropped an O(M·N) line-count + index round-trip in TextSearcher
  • Cached the highlighted AttributedString at the search-key boundary, not per-render

Net: typing in the find-bar on a 60k-line document no longer feels like dial-up.

Observability

  • OSSignposter infrastructure: Signposts.lifecycle, Signposts.rendering, plus subsystem posts for Mermaid render, image load, scroll tick
  • Debug telemetry: set VOID_READER_DEBUG=1 to see timing data for every subsystem
  • File logging: VOID_READER_DEBUG_FILE=/tmp/vr.log for post-mortem analysis
  • make profile: one command to launch with signposts wired for Instruments
  • make run-debug: one command to run with telemetry enabled

Sagas Worth Telling

The scroll percentage tracking saga. Three commits to get this right. Turns out GeometryReader must be outside LazyVStack to fire continuously during scroll. LazyVStack is lazy — it doesn't re-render during scroll, so preference keys inside it only fire once. Big thanks to Fiona for the architectural clarity on that one.

The segment-rows pivot. Originally tried to route large code blocks through a single NSTextView wholesale. It worked up to about 30k characters, then TextKit started thinking. A lot. The pivot was to stop thinking of a big code block as one thing — split it across LazyVStack rows with a shared groupID so SwiftUI virtualizes rows of a single logical block. Rendering a 100k-character block is now meaningfully faster than rendering a 10k-character block was in 1.0, because most of the rows never render at all.

The xctrace masquerade. Covered above under the perf lab, but it deserves its own beat: the first version of the CI gate trusted xctrace's exit code. It shouldn't have. Narrowing the success signal to the trace log's actual error string turned a whole class of flaky failures into deterministic ones. "Don't trust the exit code of a tool that processes its own crashes" is a hard-won lesson.

Developer Experience

  • XCUITest infrastructure: 7 automated tests because we're professionals now
  • Gitea CI: full .gitea/workflows/ for perf lab + UI tests, running on self-hosted runners
  • AST-aware chunker (also a perf thing, but makes future block-level features much easier to build)

Technical Details for the Curious

Metric 1.0 1.1
Time to first content (60k lines) ~1,475ms ~11ms
Editor rehighlight scope Full document Visible region + buffer
100k-char code block Main-thread stall Off-main, virtualized
Find-bar keystroke on large doc Full re-search Cached match texts
Memory (60k lines) Untested ~370MB
Scroll tracking Disabled for large docs Works everywhere
Observability print() OSSignposter + perf lab

Up Next

  • Memory optimization (370MB is acceptable but there's fat to cut)
  • Block height refinement for smoother scroll position estimates
  • Inline math ($x^2$) support
  • More polish for the markdown linter

Full Changelog: v1.0.4...v1.1.0

Co-authored with Claude Opus 4.5 and 4.7, who between them have learned that performance testing should happen before shipping, exit codes lie, and the right response to a slow system is a ruler, not a hammer.