Typing: no whole-text copies per keystroke (#327) #402

Closed
kayg wants to merge 1 commit from issue/327-typing into dev
Owner

Part of #327. Less JS per keystroke in a long note.

Why

A CPU profile of 90 keystrokes in the 50,000-word note (Chromium, CPU 4x, dev) puts two whole-text copies at the top of the JS that typing runs:

  • frontmatterEnd: 377 ms self, plus its sliceString. It copied the first 64 KB of the note on every call, and typing makes several calls per key (the hidden frontmatter, tags, log times, missing links, the bar, and others). Every fixture note has frontmatter, so the "most notes have none" early return never applied.
  • IndexedDB put of the note's metadata: 406 ms. base (the whole text) lived in the same record as pendingSince, which flips on every keystroke (pending, then confirmed), so each flip cloned the whole note again.

Together they were about 18% of the JS per key.

What

  • frontmatterEnd walks lines without copying, with the same answer as the old pattern (13 cases, including the edges, are checked against it in test/frontmatter.test.ts), and remembers its answer per document.
  • idbMetaStore keeps base under its own key and writes it only when it changed. Records written before this still read: a base inside the old record is used, and it is written out under its own key on the next put, before the record drops it (test/meta.test.ts).
  • Editor.svelte re-reads the note's properties only when an edit reaches them; typing in the body used to make a new fm object every key, which redrew the page style and set editing.page.
  • refresh() replaces editing.blocks only when it held something; a fresh [] each key woke everything that reads it.

No look changes. Sync semantics are unchanged: the same fields, written at the same moments.

Numbers

Profiles of the same 90 keystrokes, 10k Home, before (dev) and after:

before after
frontmatterEnd self time 377 ms gone (not in the top 40)
metadata put (IndexedDB) 406 ms gone
JS per key, 50,000-word note (busy minus throttling) 48 ms 40 ms (−18%)
JS per key, nested list 49 ms 43 ms (−13%)

Main-thread CPU per key (trace thread time) over three interleaved A/B rounds: 50,000-word note 84 / 61 / 66 ms before vs 67 / 62 / 63 ms after; nested list 106 / 92 / 102 vs 102 / 86 / 86. The host ran at load 20–25 throughout, so keystroke latency there is not comparable between runs. CPU time and profiles are the fair comparison.

What is left per key is CodeMirror itself: the Lezer markdown parse, DOM update and measure, and the Yjs transaction.

Verified

  • packages/editor: tsc and every vitest pass (124 tests, 4 new for the metadata store).
  • apps/web: bun run check is clean.
  • e2e on chromium-1440 and webkit-1440: editor, traps, enter, info, day, glass and width specs. 53 passed. glass.spec:11 fails the same way on dev.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Part of #327. Less JS per keystroke in a long note. ## Why A CPU profile of 90 keystrokes in the 50,000-word note (Chromium, CPU 4x, dev) puts two whole-text copies at the top of the JS that typing runs: - **`frontmatterEnd`**: 377 ms self, plus its `sliceString`. It copied the first 64 KB of the note on every call, and typing makes several calls per key (the hidden frontmatter, tags, log times, missing links, the bar, and others). Every fixture note has frontmatter, so the "most notes have none" early return never applied. - **IndexedDB `put` of the note's metadata**: 406 ms. `base` (the whole text) lived in the same record as `pendingSince`, which flips on every keystroke (pending, then confirmed), so each flip cloned the whole note again. Together they were about 18% of the JS per key. ## What - `frontmatterEnd` walks lines without copying, with the same answer as the old pattern (13 cases, including the edges, are checked against it in `test/frontmatter.test.ts`), and remembers its answer per document. - `idbMetaStore` keeps `base` under its own key and writes it only when it changed. Records written before this still read: a `base` inside the old record is used, and it is written out under its own key on the next put, before the record drops it (`test/meta.test.ts`). - `Editor.svelte` re-reads the note's properties only when an edit reaches them; typing in the body used to make a new `fm` object every key, which redrew the page style and set `editing.page`. - `refresh()` replaces `editing.blocks` only when it held something; a fresh `[]` each key woke everything that reads it. No look changes. Sync semantics are unchanged: the same fields, written at the same moments. ## Numbers Profiles of the same 90 keystrokes, 10k Home, before (dev) and after: | | before | after | |---|---:|---:| | `frontmatterEnd` self time | 377 ms | gone (not in the top 40) | | metadata `put` (IndexedDB) | 406 ms | gone | | JS per key, 50,000-word note (busy minus throttling) | 48 ms | 40 ms (−18%) | | JS per key, nested list | 49 ms | 43 ms (−13%) | Main-thread CPU per key (trace thread time) over three interleaved A/B rounds: 50,000-word note 84 / 61 / 66 ms before vs 67 / 62 / 63 ms after; nested list 106 / 92 / 102 vs 102 / 86 / 86. The host ran at load 20–25 throughout, so keystroke latency there is not comparable between runs. CPU time and profiles are the fair comparison. What is left per key is CodeMirror itself: the Lezer markdown parse, DOM update and measure, and the Yjs transaction. ## Verified - `packages/editor`: `tsc` and every vitest pass (124 tests, 4 new for the metadata store). - `apps/web`: `bun run check` is clean. - e2e on chromium-1440 and webkit-1440: editor, traps, enter, info, day, glass and width specs. 53 passed. `glass.spec:11` fails the same way on dev. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Typing: no whole-text copies per keystroke (#327)
Some checks failed
CI / server (pull_request) Successful in 29s
CI / rust (pull_request) Successful in 2m33s
CI / web (pull_request) Failing after 46m36s
d506f8160b
The two biggest pieces of JS per keystroke in a long note were copies of
the note's text, neither of them needed:

- frontmatterEnd copied the first 64 KB of the note on every call, several
  calls per keystroke (every note has frontmatter). It now walks lines
  without copying and remembers its answer per document.
- The note's metadata kept `base` (the whole text) in the same IndexedDB
  record as the fields that change on every keystroke, so each pending and
  confirmed flip cloned the whole text again. `base` now has its own key
  and is written only when it changed; older records still read.

Also: the editor re-reads the note's properties only when an edit reaches
them, and the bar's block list is replaced only when it held something, so
typing in the body no longer wakes the page style and the bar every key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

This work is consolidated in PR #508: #508. Closing this superseded PR.

This work is consolidated in PR #508: https://git.kayg.org/kayg/calternal/pulls/508. Closing this superseded PR.
kayg closed this pull request 2026-10-16 17:35:05 +00:00
Some checks failed
CI / server (pull_request) Successful in 29s
CI / rust (pull_request) Successful in 2m33s
CI / web (pull_request) Failing after 46m36s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!402
No description provided.