Paste Performance Plan: Bulk-Paste Mutator with Pre-Encoded Yjs Content #
Context #
components/Blocks/TextBlock/useHandlePaste.ts is the paste handler for ProseMirror text blocks. Large pastes (long Google Docs, long articles, Leaflet cards with many embedded facts) are visibly slow and have several correctness footguns:
- Per-block fact fanout. Each pasted block fires up to ~6 separate
rep.mutate.*calls (addBlock, then individualassertFactcalls forheading-level,text-alignment,text-size,is-list,check-list,list-style). Each is its own Replicache transaction and IDB commit. A 100-block paste produces ~500 transactions. setTimeout(..., 10)mount race. The handler creates a block viaaddBlock, then schedules a 10ms timer to inject content into the not-yet-mounted ProseMirror view. If mount takes longer than 10ms (likely with 200 blocks committing in one React commit), content is silently dropped.- Editor-state storm. Each timer fires its own
setEditorState, which triggers the unkeyeduseEditorStates.subscribelistener inmountProsemirror.ts:214for every mounted block on the page. - Per-block undo entries. Pasting 200 blocks requires 200 Cmd-Z presses to undo.
undoManager.withUndoGroupcannot fix this because the per-block undo adds happen insidesetTimeoutbodies, well after the synchronous group has closed. - 300ms Yjs persistence debounce. Pasted content reaches Replicache only after the
useYJSValueobserveDeepcallback fires; a tab close within 300ms of paste loses everything.
The fix is architectural: stop driving paste through the live editor view. Instead, encode each pasted block's content as a Yjs binary update on the client, then write all entities + facts (including block/text) in a single Replicache mutator transaction. When the new blocks subsequently mount, useYJSValue reads the block/text fact via the standard load path and ProseMirror initializes with content already present.
Architecture #
A Replicache mutator runs inside one WriteTransaction (see src/replicache/index.tsx:111-129). All ctx.assertFact, ctx.createEntity, and tx.set calls inside a single mutator are one IDB commit and produce one subscriber-batched re-fire. This is the same buffer-and-flush pattern as cachedServerMutationContext on the server, just expressed through Replicache's existing transaction model.
block/text (src/replicache/attributes.ts:66-69) is stored as a base64-encoded Yjs update. useYJSValue (mountProsemirror.ts:328-369) reads it and Y.applyUpdates into a fresh Y.Doc with fragment name "prosemirror" (line 332); ySyncPlugin then binds ProseMirror to that doc. y-prosemirror exports prosemirrorToYDoc(node, fragmentName) which produces the same Yjs state structure from a ProseMirror Node offline — confirmed at node_modules/y-prosemirror/dist/src/lib.d.ts:12. The offline encoding uses the same single-block schema (schema.ts) that the live editor mounts with; multiBlockSchema / multilineParser are unused in this file and should be deleted as part of phase 1.
parser.parse(child) returns a doc Node (prosemirror-model parse(): Node, not parseSlice), so it can be passed to prosemirrorToYDoc directly with no wrapping step.
Phase 1: Rewrite useHandlePaste.ts to build the payload offline #
File: components/Blocks/TextBlock/useHandlePaste.ts
The existing generic mutators are already sufficient — no new mutator needed.
assertFact(mutations.ts:366-373) acceptsFactInput | Array<FactInput>and writes all facts in one Replicache transaction / one IDB commit.createEntity(mutations.ts:483-489) acceptsArray<{ entityID, permission_set }>and creates all entities in one transaction.- Both honor
ignoreUndo, which the wrapper atindex.tsx:122reads asargs.ignoreUndo(works for either an object or a tagged-array). This suppresses the per-ctx.assertFactundo registrations inclientMutationContext.ts:78-98. Call signature: the mutator takes one argument;ignoreUndomust be set as a property on the array, mirroringclientMutationContext.ts:139-152. There is no second-arg overload.
Replace createBlockFromHTML's per-block mutation calls with offline payload construction, then two bulk mutator calls:
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import * as base64 from "base64-js";
import { generateNKeysBetween } from "fractional-indexing";
import type { FactInput } from "src/replicache/mutations";
type BuiltBlock = {
entityID: string;
parent: string;
type: Fact<"block/type">["data"]["value"];
facts: FactInput[]; // block/type, block/text, heading-level, alignment, ...
};
// inside the paste handler, after the existing HTML parse:
const built: BuiltBlock[] = [];
const extraEntities: Array<{ entityID: string; permission_set: string }> = [];
for (const child of children) {
const items = buildBlockFromHTML(child, {
parentType: propsRef.current.pageType,
permission_set: entity_set.set,
parent: pasteParent,
/* no rep — pure data shaping */
});
if (items) {
built.push(...items.blocks);
if (items.extraEntities) extraEntities.push(...items.extraEntities);
}
}
// Empty paste — bail before registering any undo entry.
if (built.length === 0) return true;
// Allocate positions for all top-level pasted blocks in one call. Nested
// recursion (lists, cards) generates its own keyspace under its own parent.
const topLevel = built.filter((b) => b.parent === pasteParent);
const positions = generateNKeysBetween(
propsRef.current.position || null,
propsRef.current.nextPosition || null,
topLevel.length,
);
const positionByEntity = new Map(topLevel.map((b, i) => [b.entityID, positions[i]]));
const entities: Array<{ entityID: string; permission_set: string }> & { ignoreUndo?: true } = [
...built.map((b) => ({ entityID: b.entityID, permission_set: entity_set.set })),
...extraEntities,
];
const allFacts: FactInput[] & { ignoreUndo?: true } = [];
for (const b of built) {
allFacts.push(...b.facts);
// Parent reference fact — note: canvas needs a different shape, see the
// "Canvas carve-out" below; canvas pastes do NOT go through the bulk path.
const position = positionByEntity.get(b.entityID);
if (position !== undefined) {
allFacts.push({
entity: b.parent,
attribute: "card/block",
data: { type: "ordered-reference", value: b.entityID, position },
});
} else {
// Nested (list child, card sub-entity) — buildBlockFromHTML emitted the
// parent reference fact itself with its own keyspace.
}
}
entities.ignoreUndo = true;
await rep.mutate.createEntity(entities);
allFacts.ignoreUndo = true;
await rep.mutate.assertFact(allFacts);
buildBlockFromHTML is a pure function — no rep.mutate calls, no undoManager.add, no setTimeout. For each HTML element:
- Determine
type,headingLevel,alignment,textSize, list attributes (same switch logic as today, lines 214-326), and emit them asFactInputentries infacts. - For
text/heading/blockquoteonly: callparser.parse(child)(returns a doc Node from the single-blockschema), thenprosemirrorToYDoc(doc, "prosemirror"), thenY.encodeStateAsUpdate(ydoc), thenbase64.fromByteArray(...). Emit as ablock/textFactInput. Do not Yjs-encode the other types below — they store content in different facts:code→ string inblock/code(+block/code-languagefor the language tag)math→ string inblock/mathbluesky-post→ string inblock/bluesky-postlink→ handled byaddLinkBlocksemantics; noblock/textbutton→button/text+button/urlimage→ no inline content; see phase 4 belowhorizontal-rule→ onlyblock/type, no other facts
- For link/code/math/bluesky-post/button: emit the specialized
block/typeand supporting facts (same logic as lines 327-429). - For
IMG: emit a block of typeimage(withblock/type) plus a placeholderblock/imagelocal fact mirroringaddImage.ts:58-69. The async upload runs after the bulk mutator resolves (phase 4) — the block must exist with at least a local-URL fact so the renderer is not blank. - For
HR: emitblock/type = "horizontal-rule". No other facts. - For
UL/OL: recurse. The nested list's children are siblings under the LI'sentityID(different parent scope), so they generate their own position keyspace via a localgenerateNKeysBetweencall. The outer top-level keyspace is unaffected.block/is-list,block/check-list,block/list-styleare emitted per-LI (current logic at lines 498-543). - For
data-entityid(Leaflet card paste): see "Card paste" sub-spec below.
Card paste sub-spec. Current logic at useHandlePaste.ts:431-496 does more than the original sketch suggested. buildBlockFromHTML must reproduce all of it as pure data:
- Parse
data-factsJSON. - Collect every unique
f.entityin the parsed facts; mint a freshv7()per old ID into anoldEntityIDToNewIDmap. - Walk the facts; for
data.type ∈ {"ordered-reference", "spatial-reference", "reference"}, remapdata.valuethrough the map. Image facts (data.type === "image") are kept aside for the async refetch path (see below). - Emit one
card-typed wrapper block. Emit ablock/cardreference fact from the wrapper tooldEntityIDToNewID[rootCardEntity]— without this the wrapper is orphaned in the renderer. - Push every remapped (non-image) fact into the top-level
allFactsarray, and every minted entity intoextraEntities. - Image facts: append to a shared async-upload queue, processed after both bulk mutator calls resolve. Each entry refetches
image.data.src, callsaddImage(blob, rep, { attribute, entityID: oldEntityIDToNewID[oldEntity] }). This consolidates with the<IMG>async path in phase 4.
Canvas carve-out. addBlock and the bulk path emit card/block + ordered-reference with a string position. Canvas requires canvas/block + spatial-reference with {x,y} (attributes.ts:19-22, 306-310, mutations.ts:42-69 addCanvasBlock, Canvas.tsx:144-150). Canvas paste also has a pre-existing bug at useHandlePaste.ts:276-283: when parentType === "canvas", every child reuses the active block via setTimeout, with last-write-wins clobbering, and the else branch never calls addBlock for canvas.
This plan does not address canvas paste. The bulk path is gated to parentType === "doc". Canvas paste keeps the existing live-view path unchanged. Fixing canvas paste (whether to consolidate into one block via offline Yjs merge, or to lay out N blocks with {x,y} offsets) is tracked separately.
Active-block reuse path (doc only). Keep the existing live-view branch for the first block when the active block's type is heading, blockquote, or matches the new block's type (useHandlePaste.ts:276-283, doc branch only). Yjs mergeUpdates semantics don't represent selection-replacement, so re-using the live editor for the single block that already exists is the pragmatic choice. The N-1 remaining blocks go through the bulk path. When this branch fires, built[0] is dropped from the bulk emission and its content is applied via the existing replaceSelectionWith path.
Active-block deletion of selected range: when the user has a selection in the active block at paste time, today we tr.delete(selection.from, selection.to) before replaceSelectionWith. Today this runs inside the per-block setTimeout for every block — harmless for new blocks (empty), but unnecessary. Move it to a single pre-loop step on the live-view branch, before the first block is applied.
Delete createBlockFromHTML (lines 162-604) entirely after the rewrite. flattenHTMLToTextBlocks (lines 606-655) stays — buildBlockFromHTML uses it. Move flattenHTMLToTextBlocks's tag whitelist to a module-level Set to avoid re-allocating on each call. Also delete the unused multilineParser + multiBlockSchema imports at lines 6, 21-22.
Phase 2: Single undo entry per paste #
After both bulk mutator calls resolve, register one undo entry at the paste-handler level. Note the ignoreUndo array-property pattern (mirrors clientMutationContext.ts:139-152):
const newEntityIDs = entities.map((e) => e.entityID);
undoManager.add({
undo: async () => {
// deleteEntity already handles facts + reference cleanup
// (clientMutationContext.ts:121-135 scans both eav and vae).
for (const id of newEntityIDs) {
await rep.mutate.deleteEntity({ entity: id, ignoreUndo: true });
}
},
redo: async () => {
entities.ignoreUndo = true;
await rep.mutate.createEntity(entities);
allFacts.ignoreUndo = true;
await rep.mutate.assertFact(allFacts);
},
});
ignoreUndo: true on the bulk mutator calls is what makes this work — without it, each ctx.assertFact invocation inside the assertFact mutator body registers its own undo entry (clientMutationContext.ts:78-98), and the user would still need N Cmd-Z presses. Skip the undoManager.add registration entirely if built.length === 0 (handled by the early return in phase 1).
A future optimization (not in this phase): add a bulk deleteEntities mutator so the undo callback is one transaction instead of N. Not needed for correctness — undo of paste is rare, and N deleteEntity calls are still far better than the status quo.
Phase 3: Cursor placement after paste #
Move the focusBlock call out of the per-block path. After both bulk mutator calls resolve, call once:
focusBlock({ value: lastBlock.entityID, type: lastBlock.type, parent: lastBlock.parent }, { type: "end" });
focusBlock (src/utils/focusBlock.ts:72) currently bails with a bare if (!nextBlock || !nextBlock.view) return; — no polling, no retry. Since the bulk path resolves before the new blocks have mounted, this call would silently no-op. This is unspecified work the phase must implement: add a retry loop subscribing to useEditorStates (or polling with requestAnimationFrame) that resolves when editorStates[entityID].view becomes defined, with a max-attempts cap (e.g., 30 frames / 500ms) to avoid infinite loops if the block fails to mount (off-screen virtualization, error boundary).
focusBlock also calls flushSync at line 16. The new call site is after await rep.mutate.assertFact(...), i.e., a microtask outside the original paste event. React permits flushSync there but will warn if a render happens to be in flight. Verify during implementation; if warnings appear, wrap the focus call in queueMicrotask or move the cursor placement into the retry callback (which runs after React has committed the new block).
Phase 4: Image upload pipeline (independent of phases 1-3) #
addImage does fetch → createImageBitmap × 2 → canvas re-encode → upload. Unbounded concurrent calls during a 30-image paste pin the main thread for CPU work (decode/encode/thumbhash) and saturate network for upload — both matter.
- Add a shared concurrency limiter wrapping the whole
addImagepipeline. Both main-thread fairness (multiplecreateImageBitmap+ canvas operations) and upload-bandwidth fairness benefit from serializing the entire pipeline, not justfetch. - Share the bitmap between
normalizeOrientationandgetThumbHashto eliminate one full decode per image (src/utils/addImage.ts:86-138). - Fix
isAnimatedFormatto also matchimage/webpcontainers that are animated (currently only matches gif/apng despite the comment claiming otherwise). - Add
.catch()on the twofetchsites (useHandlePaste.ts:386-394, 484-494) so failed image fetches don't leave silent emptyimage-typed blocks. The block currently renders asreturn nullfor readers without write permission (components/Blocks/ImageBlock.tsx:90-91) — i.e., the image disappears entirely. Surface the failure (toast or fallback to link block).
The phase 1 bulk path emits image-typed blocks with a placeholder block/image local-URL fact (matching addImage.ts:58-69) so the renderer is not blank between the bulk commit and the upload completing. The actual upload is queued and fires after both bulk mutator calls resolve. The card-paste image-refetch path (formerly useHandlePaste.ts:484-494) shares the same queue.
Phase 5: Keyed useEditorStates.subscribe in mountProsemirror (independent) #
mountProsemirror.ts:214-219 subscribes every block to every useEditorStates change. With N blocks on the page, every editor transaction fires N × N callback invocations. This degrades typing performance independently of paste.
Zustand 5 (^5.0.11, see package.json) only supports the 2-arg subscribe(selector, listener) form when the store is wrapped in the subscribeWithSelector middleware at create-time. useEditorStates is currently created without middleware (src/state/useEditorState.ts:4-16), so the call signature change must be paired with a one-line middleware addition:
// src/state/useEditorState.ts
import { subscribeWithSelector } from "zustand/middleware";
export const useEditorStates = create(subscribeWithSelector(() => ({ /* ... */ })));
Existing single-arg subscribers (mountProsemirror.ts:214, FootnoteEditor.tsx:149) continue to work — the middleware extends the API, it doesn't replace it. Then in mountProsemirror.ts:
const unsubscribe = useEditorStates.subscribe(
(s) => s.editorStates[entityID],
(editorState) => {
if (editorState?.initial) return;
if (editorState?.editor) editorState.view?.updateState(editorState.editor);
}
);
This is independent of the paste rewrite but compounds the win.
Phase 1b: isNewEntity shortcut in assertFact (measurement-gated) #
clientMutationContext.ts:50-51 calls scanIndex(tx).eav(f.entity, f.attribute) for every cardinality-one fact. In today's per-block model this scan touches a small tx view. In the bulk path it runs ~400-1000 times in a single transaction (e.g., 200 blocks × ~3 cardinality-one facts each), with no scan memoization (each call is an async btree walk on a unique prefix). Realistic cost: 100-500ms inside one transaction — exactly the interval we are trying to eliminate.
Fix: add an isNewEntity?: true flag on FactInput. When set, assertFact skips the eav scan and the Y.mergeUpdates branch (the entity is by construction empty for these attributes). The phase 1 payload-construction code sets this flag on every fact whose entity is in the freshly minted set (built[].entityID ∪ extraEntities[].entityID). Facts targeting pre-existing entities (e.g., the parent card/block reference) leave it unset.
This phase is measurement-gated: ship phase 1 first, instrument with performance.mark (validation step 2 below), and add the shortcut if the eav scans dominate. The fix is one if-statement in clientMutationContext.ts and one field on FactInput. It does break the phase-1 claim that no changes are needed in clientMutationContext.ts, but the change is local and the alternative — measured 100-500ms inside one transaction — would make the "order of magnitude" win disappoint.
Risks and behavior preservation #
| Risk | Mitigation |
|---|---|
prosemirrorToYDoc produces different Yjs clientID than the live editor would |
Yjs is CRDT — different clientIDs merge cleanly. Paste-author identity is the same human regardless. No code in the repo compares Yjs clientIDs. |
createEntity commits in one tx, assertFact in the next — block mounts between them and useYJSValue reads no block/text |
Both the parent card/block reference fact AND the block/type fact live in the second transaction, so getBlocksWithType (src/replicache/getBlocks.ts:25-44) filters the in-between-state blocks out entirely. The two useSubscribe callbacks fire after each tx, but the first fires with no visible blocks (no parent ref yet); the second fires with the complete set. If a render-of-empty-set turns out to be problematic, merge to one mutator (see Future work). |
| Active-block reuse path remains as legacy code | Kept intentionally small (doc-only, first block only on matching active type). Document the split. |
| Canvas paste regression | Bulk path is gated to parentType === "doc". Canvas paste keeps the existing live-view branch. Tracked as a separate plan. |
| Server-side execution | The two generic mutators (createEntity, assertFact) already run on the server via cachedServerMutationContext. The Yjs binary value is opaque to the server — stored as a fact and replayed. No special server work. |
undoManager.add registered at handler level captures stale closures |
The undo callback references entities and allFacts arrays, both captured at paste time. Re-running paste from redo is equivalent — no view state needed. The ignoreUndo property persists on the arrays across redo invocations (idempotent). |
| Width/height on pasted images | Out of scope for paste itself; addImage already handles dimension extraction via createImageBitmap. |
| Rollout — no feature-flag infrastructure in repo | Keep createBlockFromHTML alive behind a localStorage kill-switch (e.g., localStorage.getItem("legacyPaste") === "1" falls through to the old path) for one release. Delete in a follow-up after the new path bakes. |
Validation #
After implementation:
- Manual: paste a 200-paragraph Google Doc into a leaflet. Observe (a) UI does not freeze, (b) all content appears, (c) Cmd-Z removes everything in one press, (d) closing the tab within 1 second of paste preserves content on reload.
- Performance: instrument with
performance.mark/measurearound the paste handler. Compare total wall time before/after on the same 200-block payload. Expect order-of-magnitude improvement. - Replicache transaction count: add a temporary counter in
clientMutationContext. Expect 2 (createEntity + assertFact) + N (async images) after fix, vs. ~500+ before. - Undo correctness: paste, type more, paste again, Cmd-Z three times. Expect "undo paste 2" → "undo typing" → "undo paste 1".
- Selection / cursor: paste at end of empty block, paste at end of block with text, paste mid-paragraph. Cursor lands at end of last pasted content in all three cases.
- Card paste: copy a Leaflet card with nested entities and images. Paste into another doc. Verify all sub-entities are created (Replicache local data inspector) and images upload correctly.
- Edge cases:
- Plain-text paste (no HTML): markdown conversion still runs (no path change).
- URL-only paste: link-mark insertion still works (live-view path, unchanged).
- Empty clipboard: no-op (existing guards still apply).
- Paste into canvas vs. doc: parent attribute resolution correct in both.
Implementation order #
- Phase 1 (rewrite paste handler to build payload + call generic mutators; canvas gated out; legacy path retained behind kill-switch).
- Phase 2 (single undo entry) — depends on phase 1.
- Phase 3 (cursor placement, including the
focusBlockretry loop) — small, follow-up to phase 1. - Instrument with
performance.mark(validation step 2). Decide on Phase 1b. - Phase 1b (
isNewEntityshortcut inclientMutationContext) — only if the measurements show the eav scans dominating. - Phase 4 (image pipeline) — independent, parallel work.
- Phase 5 (keyed subscribe +
subscribeWithSelectormiddleware) — independent, parallel work. Compounds the win.
Phases 1-3 (and 1b if needed) ship as one feature; phases 4 and 5 are separate PRs. Canvas paste is tracked as a separate plan.
Future work (not in this plan) #
- Single-transaction bulk primitive. If the two-transaction split (createEntity + assertFact) ever shows up in profiles, or if the in-between-mount risk in the table above actually bites, add a generic
bulkAssert: Mutation<{ entities, facts }>tomutations.tsand switch the paste handler to one call. The handler-side payload construction doesn't change. - Bulk
deleteEntitiesmutator. Replace the NdeleteEntitycalls in the undo callback with one transaction. - Canvas paste. Either consolidate all pasted children into the active block via offline Yjs merge, or lay out N blocks with
{x,y}offsets andaddCanvasBlock-shape facts. Separate plan.
Files touched #
components/Blocks/TextBlock/useHandlePaste.ts— full rewrite of paste flow; keepflattenHTMLToTextBlocks. Delete unusedmultilineParser/multiBlockSchemaimports.src/utils/addImage.ts— share bitmap between decode passes; fixisAnimatedFormat.- New file:
src/utils/uploadQueue.ts(or inline limiter inaddImage.ts) — shared concurrency cap. components/Blocks/TextBlock/mountProsemirror.ts— keyed subscribe (phase 5).src/state/useEditorState.ts— wrapcreate()insubscribeWithSelector(phase 5 prereq).src/utils/focusBlock.ts— add retry-on-mount loop (phase 3); no current code path polls for an unmounted view.src/replicache/clientMutationContext.ts+src/replicache/mutations.ts(or theFactInputtype definition) — only if Phase 1b is needed: addisNewEntityflag and the scan-skip branch.