Skip to content

perf: derive deferred-open stand-ins in the renderer instead of shipping them - #321

Merged
lidonggui merged 1 commit into
mainfrom
perf/derive-stand-ins
Oct 11, 2026
Merged

lidonggui merged 1 commit into
mainfrom
perf/derive-stand-ins

Conversation

@lidonggui

Copy link
Copy Markdown
Collaborator

What

On a deferred open (@roobli/md, the default), main no longer attaches a structural stand-in mdast node to every block or ships them over IPC. parseDocument sends nodes: null with nodesEnrichment: 'deferred'. blockSpansFromWire then derives each block's stand-in from its kind and source the first time something reads span.node. Engine-owned blocks never read it; the rest are re-parsed from source when they're enriched.

  • lazyNodeSpan(fields, build) returns a span whose node is a memoised getter. The getter is an own, enumerable property, so structuredClone, spreads and toEqual still see a plain value and the BlockSpan contract is unchanged.
  • standInNodeFor(kind, markdown) exposes the existing standInNode.
  • enrichSpanNone and finalizeEngineAdapterSpan build stand-ins the same lazy way, so main never builds them for the wire either.
  • Wire validator: a deferred open may omit nodes. A full open without nodes is still refused. Incremental saves are unchanged (no nodes, no nodesEnrichment).

Measured (huge, 8 MB, 43,970 blocks, Linux box)

before (f25d1fe) after
open wire payload (v8 serialised) 24.98 MB 14.07 MB
main publish (IPC send), profiled ~71 ms ~36 ms
deserialise (Node v8 proxy) ~164 ms ~42 ms
main parseDocument, profiled ~310 ms ~330 ms (unchanged within noise)
packaged bench BENCH_RUNS=5, interleaved ×2 1,826 / 1,671 ms 1,636 / 1,582 ms
profiled packaged open, interleaved ×3 1,747 / 1,669 / 1,600 ms 1,890* / 1,516 / 1,499 ms

*outlier

Honest note: building the stand-ins was only ~10 ms of parseDocument, so main parse time doesn't move. Its cost is the engine split plus canSkipDialectEnrich / engineSemanticKey on every block. The win is the payload and IPC: ~100–150 ms of open on huge.

Tests

  • pnpm typecheck and pnpm test pass.
  • New: derived stand-ins equal the ones main used to ship for every golden fixture (after a structured clone), and build the same ProseMirror document.
  • New: lazy stand-ins are built once and only when read.
  • New: an incremental-save wire still returns null from blockSpansFromWire.
  • Updated the three tests that asserted deferred nodes were non-null.

…ing them

A deferred open attached a structural stand-in mdast node to all 43,970
blocks of the huge document and sent them across IPC (about 11 MB of the
25 MB payload). Engine-owned blocks never read theirs, and the rest are
re-parsed from source when enriched.

parseDocument now sends nodes: null with nodesEnrichment 'deferred', and
blockSpansFromWire derives each stand-in from the block's kind and source
when it is first read (memoised, own enumerable getter so clones and spreads
still see a value). enrichSpanNone and finalizeEngineAdapterSpan build
stand-ins the same lazy way. The wire validator accepts a deferred open
without nodes and still refuses a full one without them.

Huge open payload 24.98 -> 14.07 MB; main IPC send ~71 -> ~36 ms; packaged
Linux open ~1.67 -> ~1.55 s (interleaved runs).
@lidonggui
lidonggui merged commit 4868f27 into main Oct 11, 2026
7 checks passed
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.

1 participant