Repository navigation
perf(drafts): rescan saved drafts only when a write can change them - #2906
Merged
Merged
Conversation
kalvinnchau
force-pushed
the
peon/r4-draft-scan
branch
from
November 6, 2026 23:49
9652685 to
29ffbf3
Compare
kalvinnchau
marked this pull request as ready for review
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]>
kalvinnchau
force-pushed
the
peon/r4-draft-scan
branch
from
November 7, 2026 02:55
29ffbf3 to
a507984
Compare
wpfleger96
approved these changes
Nov 7, 2026
wpfleger96
left a comment
Member
There was a problem hiding this comment.
🤖 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
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every write to a view key now rescans every saved draft. Since #2853 added draft markers,
draft-placessubscribes 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 runslistDraftViews, which:JSON.parses each draft;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
subscribeViewlisteners receive the key that changed.writeViewpasses it, as doesclearViewwith one key and the cross-windowstorageevent. Scope-wide clears, focus and attachment changes still pass nothing, which means "anything may have changed".draft-placesskips the rescan, and does not notify, when the changed key cannot change where drafts wait:draftCountsrule) is the same as in the cached list.Anything doubtful falls back to the full rescan: a forgotten, unreadable, malformed or oversized key.
useDraftsignores writes to keys that are not drafts.The 8 MB draft cutoff is now one exported constant,
MAX_DRAFT_VIEW_LENGTH, shared bylistDraftViewsand 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.
readDraftsdepends 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:The first test fails without the change.
Evidence
Main
7817418db(after #2943 removed the sidebar draft marker) vs this change. This PR is rebased onto3884917e5; 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.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:
draft-places,view-stateanddrafts-sentpasses (89 tests).tscandbiomepass.🤖 Generated with Claude Code