Skip to content

perf(drafts): rescan saved drafts only when a write can change them - #2906

Merged
kalvinnchau merged 1 commit into
mainfrom
peon/r4-draft-scan
Nov 7, 2026
Merged

kalvinnchau merged 1 commit into
mainfrom
peon/r4-draft-scan

Conversation

@kalvinnchau

@kalvinnchau kalvinnchau commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Every write to a view key now rescans every saved draft. Since #2853 added draft markers, draft-places subscribes to all view writes in the community. On each write it clears its cache, and every message row (useThreadDraft) re-reads it. The first reader then runs listDraftViews, which:

  • walks every localStorage key;
  • JSON.parses each draft;
  • projects each rich composer document to text.

Writes come from each composer keystroke (the draft save) and from scroll-place saves, among others. A keystroke in a draft that already has text never changes where drafts wait, so all of that work is thrown away. The Drafts & sent list (useDrafts, mounted for the sidebar badge) also re-reads every draft on writes to keys that are not drafts.

Change

  • subscribeView listeners receive the key that changed. writeView passes it, as does clearView with one key and the cross-window storage event. Scope-wide clears, focus and attachment changes still pass nothing, which means "anything may have changed".

  • draft-places skips the rescan, and does not notify, when the changed key cannot change where drafts wait:

    • a key that is not a draft;
    • a draft whose "counts as a draft" answer (same draftCounts rule) is the same as in the cached list.

    Anything doubtful falls back to the full rescan: a forgotten, unreadable, malformed or oversized key.

  • useDrafts ignores writes to keys that are not drafts.

  • The 8 MB draft cutoff is now one exported constant, MAX_DRAFT_VIEW_LENGTH, shared by listDraftViews and the new check.

No behavior change

The cached list is only kept when one key changed and that key's membership is provably unchanged, so the list equals what a full rescan would return.

  • Cap: a draft beyond the 500-entry cap is never in the list, so writes to it still rescan, as before.
  • Other windows: the storage event names the key, and the check reads current shared localStorage, so the result is the same however events arrive.
  • Drafts & sent: readDrafts depends only on draft keys, attachment counts and its activity record. The activity record is a separate localStorage key, not a view key.

New tests in draft-places.test.tsx:

  • writes to keys that aren't drafts, and keystrokes in a thread draft, don't rescan;
  • empty, clear and refill writes still update the marker;
  • a cross-window storage event still marks a draft.

The first test fails without the change.

Evidence

Main 7817418db (after #2943 removed the sidebar draft marker) vs this change. This PR is rebased onto 3884917e5; the commits in between don't touch drafts. 4 Linux and 4 macOS boxes each ran 6 interleaved rounds. The table shows the mean of per-box medians, with the per-box Δ range in parentheses.

metric Linux: main Linux: this PR Linux Δ % macOS: main macOS: this PR macOS Δ %
typing, 100 saved drafts: script 386.2 ms 350.3 ms −9.3% (−11.5..−4.5) 297.8 ms 269.2 ms −9.6% (−12.3..−7.9)
typing, 100 saved drafts: busy 755.9 ms 725.0 ms −4.1% (−5.3..−1.6) 546.1 ms 509.1 ms −6.8% (−8.6..−4.9)
typing, 100 saved drafts: key-to-paint 36.8 ms 36.3 ms −1.2% (−2.8..+0.4) 24.2 ms 21.3 ms −11.9% (−14.8..−8.6)
typing, no saved drafts: script 298.3 ms 297.0 ms −0.4% (noise) 232.9 ms 231.7 ms −0.5% (noise)
mention typing, 100 saved drafts: script 2384.8 ms 2358.7 ms −1.1% 1460.6 ms 1439.4 ms −1.5%
channel switch: script 505.3 ms 510.0 ms +0.9% (noise) 295.3 ms 296.2 ms +0.3% (noise)

The seeded drafts add about 30% script to plain typing on main (298.3 → 386.2 ms on Linux, 232.9 → 297.8 ms on macOS); this change removes about a third of that. Most of the rest is the Drafts & sent list re-reading every draft on each draft keystroke, which this PR leaves unchanged.

Lower is better. The typing benchmark (an Event Timing harness on the browser fixture) runs a production build in Chromium at 4× CPU throttling and types 12 keys in the channel composer per phase, 2 phases per run. Before typing, 100 copies of the composer's own saved draft are seeded into other channels. Key-to-paint is the Event Timing keydown duration averaged over every key; keys under 16 ms count as 0. Builds are interleaved per round; each box's median is taken, then the mean of box medians per OS. With no saved drafts the change is within noise, as expected.

Checks:

  • vitest for draft-places, view-state and drafts-sent passes (89 tests).
  • tsc and biome pass.

🤖 Generated with Claude Code

@kalvinnchau
kalvinnchau marked this pull request as ready for review November 7, 2026 02:47
@kalvinnchau
kalvinnchau requested a review from a team as a code owner November 7, 2026 02:47
view listeners now receive the changed key. draft places skip the
localstorage rescan and row notifications when a write leaves the set
of draft places unchanged (a keystroke in an existing draft, or any
non-draft view key such as scroll places). the drafts & sent list
ignores non-draft keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed at a507984; no blocking findings. The cached draft list is kept only when the single changed key keeps its draftCounts answer. Missing, unreadable, malformed and oversized keys, multi-key clears, attachment changes and focus all still trigger the full rescan, and drafts past the 500 cap still rescan. Every other subscribeView listener ignores the new key argument, and useDrafts only skips keys that readDrafts never reads.

@kalvinnchau
kalvinnchau merged commit d4a6c43 into main Nov 7, 2026
60 checks passed
@kalvinnchau
kalvinnchau deleted the peon/r4-draft-scan branch November 7, 2026 03:14
kalvinnchau added a commit that referenced this pull request Nov 9, 2026
Follows #2906, now merged.

## Problem

With saved drafts, each keystroke in a draft still re-reads every saved
draft. #2906 stops `draft-places` from rescanning. The Drafts & sent
list (`useDrafts`, mounted for the sidebar badge) still calls
`readDrafts` on every draft write, and `readDrafts`:
- walks every localStorage key;
- `JSON.parse`s each draft and projects it to text;
- re-checks each draft's activity record.

The keystroke changes only one draft.

## Change

- `rereadDraft(session, previous, key)` in `drafts.ts` updates the one
changed entry of the last snapshot, writes its activity record if it
changed, and re-sorts. It reuses the same per-entry code as `readDrafts`
(`draftEntry`, `newestFirst`).
- `useDrafts` keeps the snapshot it last showed. On a write to one draft
key it calls `rereadDraft`. Otherwise it falls back to `readDrafts`.
- `parseDraftView` in `view-state.ts` is the one parse-with-size-limit
step. `listDraftViews` and `draft-places`' membership check now share
it, and `MAX_DRAFT_VIEW_LENGTH` is private again.

## No behavior change

`rereadDraft` returns a snapshot only when it can prove that the
snapshot equals a full read. In every other case it returns undefined,
and the caller does the full read. It falls back when:
- the previous snapshot is limited or unavailable;
- the key is not listed (a new draft);
- the value is not a readable, in-limit object;
- the draft no longer counts (blank);
- the activity records don't match the listed entries one for one (for
example, another window changed a record).

New tests in `drafts.test.ts`:
- One edited draft is re-read exactly as a full read would see it,
across every fallback (blank, new key, limited, malformed, lost record,
a stale `draft:` key).
- A keystroke re-reads only the edited draft, with no localStorage key
walk. This test fails without the change.

## Evidence

Since #2940, Drafts & sent is an opt-in plugin, and the list (and this
change) runs only for users who enable it. Measured on main `a0635285a`
with the plugin turned on, without and with this PR. 2 Linux and 2 macOS
boxes each ran 6 interleaved rounds. The table shows the mean of per-box
medians, with the per-box Δ range in parentheses.

| metric | Linux: plugin on | Linux: + this PR | macOS: plugin on |
macOS: + this PR |
|---|---|---|---|---|
| typing, 100 saved drafts: script | 348.0 ms | −17.1% (−17.9..−16.2) |
264.1 ms | −12.5% (−14.1..−10.9) |
| typing, 100 saved drafts: busy | 722.0 ms | −8.0% (−10.2..−5.8) |
507.1 ms | −6.8% (−7.3..−6.4) |
| typing, 100 saved drafts: key-to-paint | 35.8 ms | −1.4% | 20.9 ms |
−3.6% (−4.8..−2.4) |
| mention typing, 100 saved drafts: script | 1383.9 ms | −2.9%
(−3.6..−2.2) | 895.5 ms | −5.3% |
| palette typing, 100 saved drafts: script | 1620.7 ms | −0.9% (noise) |
1086.9 ms | −0.6% (noise) |
| channel switch: script | 488.1 ms | +0.2% (noise) | 276.7 ms | +0.0%
(noise) |

With the plugin off (the default), the list is not mounted and this PR
changes nothing measurable: on `fa5bf08f7`, typing with 100 saved drafts
was −10.4% (#2906 alone) vs −11.7% (with this PR) on Linux and −13.6% vs
−13.4% on macOS.

Lower is better. The typing benchmark (an Event Timing harness on the
browser fixture) runs a production build in Chromium at 4× CPU
throttling and types 12 keys per phase. Before typing, 100 copies of the
composer's own saved draft are seeded into other channels. Key-to-paint
is the Event Timing keydown duration averaged over every key; keys under
16 ms count as 0.

Checks:
- vitest for `drafts-sent`, `view-state` and `draft-places` passes (91
tests).
- `tsc` and `biome` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kalvin Chau <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants