Skip to content

[lexical] Perf: Children fast path with suffix-incremental cache update in $reconcileChildren#8482

Merged
etrepum merged 26 commits into
facebook:mainfrom
mayrang:perf/reconcile-children-append-fast-path
May 12, 2026
Merged

[lexical] Perf: Children fast path with suffix-incremental cache update in $reconcileChildren#8482
etrepum merged 26 commits into
facebook:mainfrom
mayrang:perf/reconcile-children-append-fast-path

Conversation

@mayrang

@mayrang mayrang commented May 8, 2026

Copy link
Copy Markdown
Contributor

Description

Continuation of #8481 (GenMap for NodeMap / keyToDOMMap clone). With the Map clone cost out of the way, instrumentation showed $reconcileRoot is the next ~74% of cycle time on a 5k-paragraph doc — and $reconcileChildren rebuilds two Array<NodeKey> from linked-list walks even when only one child is dirty, then walks every child to accumulate the parent's text-content cache. This PR adds two layered fast paths in $reconcileChildren:

  1. Structurally-clean fast path — when the children list is unchanged (size, __first, __last, parent isn't in _cloneNotNeeded), skip the prev/next array rebuild and walk in-place via the linked list, reading cached __lexicalTextContent for non-dirty children.
  2. Suffix-incremental update — when dirty children form a contiguous suffix of the parent's child list (the canonical "typing at the end" case), splice the new tail into the parent's cached text instead of rebuilding it. Dirty children are gathered once per cycle into a parent → dirty-keys map (O(D)).

Together these collapse the typing-at-end case from O(N) text walk to O(K) reconcile, K = dirty count.

Cached text size

Per @etrepum's review on this PR, dirty children's prev-state size is read from a node-keyed WeakMap<LexicalNode, number> cache that the reconciler maintains as an invariant ("never set on writable nodes before reconciliation, always set on any reconciled node"). The O(1) lookup replaces the original $prevSubtreeTextLength recursive walk and removes both the getLatest() trap (custom DecoratorNode subclasses with internal getLatest() calls would have resolved via next state) and the quadratic-risk fallback in the newSuffix loop. $cachedTextSize invariants on miss instead of falling through, so any future regression of the invariant is loud rather than a silent miscache.

The cache lives in a WeakMap rather than a node-side Symbol property so that Object.freeze(node) in dev mode doesn't fight with the cache write. $createNode now also sets dom.__lexicalTextContent = '' for empty elements (the $createChildren write is skipped when there are no children) so the children fast paths' cached-text reads can treat the cache as always set for ElementNode children.

Backwards compatibility

__cachedText and __lexicalTextContent invariants from #8099 / #8096 stay intact — the suffix splice produces a string identical to what the existing walk would produce. The 5 RootNode __cachedText regression tests pass unchanged.

The cached-size WeakMap is reconciler-internal, has no public API surface, and never escapes the reconciler module. Memory: one WeakMap entry per node (8 bytes), GC'd when the node is.

Bench numbers

editorCycle.bench.ts runs a head-to-head A/B in a single vitest bench invocation — addresses the comment on #8481 that the DOM bench was less interesting without an in-file implementation comparison. A @internal __benchOnly.skipChildrenFastPath flag on the reconciler lets the bench toggle between the new fast path and the general $reconcileNodeChildren walk; the flag has no effect when false (default), one extra boolean read at the fast path entry guard.

pnpm vitest bench --project bench-dom (typing one character per cycle, jsdom):

size with fast path general path speedup
1,000 paragraphs 0.089ms (p99 0.270ms) 0.247ms (p99 0.467ms) 2.77x
5,000 paragraphs 0.129ms (p99 0.602ms) 1.095ms (p99 1.796ms) 8.46x

Cumulative from the pre-#8481 baseline on the 5k case: 1.558ms → 1.047ms (#8481) → 0.129ms (this PR), -92% total.

Read-only update / pure editor.read are unchanged — both fast paths only fire when there are dirty children to reconcile and the parent's structural-clean condition holds.

Addresses #7422.

Test plan

  • pnpm test-unit — 2488 passed (7 new in LexicalReconciler.test.ts covering: typing-at-end suffix, multi-dirty contiguous suffix, non-contiguous fallback, format-toggle propagation, empty trailing paragraph, linebreak between texts, and a length-changing TextNode-direct-child suffix regression).
  • pnpm tsc --noEmit -p tsconfig.json clean.
  • pnpm vitest bench --project bench-dom — see numbers above.
  • pnpm test-e2e-ci-chromium on the same NodeMap/keyToDOMMap-heavy categories as [lexical] Perf: Adopt GenMap copy-on-write for NodeMap and reconciler keyToDOMMap #8481 (TextEntry, History, Focus, Mutations, Selection, CopyAndPaste, DateTime, Tables, TextDragDrop). 220+ tests passed with --retries=0.
  • Manual playground sanity in chromium and Safari: typing, undo/redo, paste, format toggles, large-doc typing latency — no regressions.

mayrang added 3 commits May 9, 2026 01:41
…s unchanged

Two-layer fast path in $reconcileChildren for the common case where a
parent is in the dirty set only because some descendant changed, not
because the parent's own children list moved.

Layer 1 — skip the prev/next children array rebuild when:
  - prevSize === nextSize
  - prev.__first === next.__first
  - prev.__last === next.__last
  - parent is not in editor._cloneNotNeeded

The cloneNotNeeded check is the right structural-change signal:
splice / append / remove all call parent.getWritable(), which adds the
parent to _cloneNotNeeded. The intentionallyMarkedAsDirty flag in
dirtyElements is unreliable for this purpose because $applyAllTransforms
force-marks root with `true` for its "transform finalizer" semantic
(LexicalUpdates.ts:315), even when no children moved.

Layer 2 — for each child in the walk, skip the $reconcileNode call when
the child key is in neither dirtyLeaves nor dirtyElements. dirtyElements
propagation guarantees that a clean child has no dirty descendants, so
the child's existing DOM and cached __lexicalTextContent are still valid;
just accumulate the cached text into subTreeTextContent.

Measured on a 5000-paragraph doc, typing 1 char per cycle (50 cycles,
3-run average, jsdom):

  baseline                        mean 1.566ms / median 1.395ms
  + Layer 1 fast path             mean 1.136ms / median 0.972ms  (-27%)
  + Layer 2 (skip non-dirty)      mean 1.057ms / median 0.902ms  (-32%)
  + GenMap (other PR)             mean 0.523ms / median 0.460ms  (-67%)
Layer 1+2 still walks every child of a structurally-clean parent, which is the
dominant cost when typing into a large document (a 5000-paragraph doc spends
~96% of its update cycle in that walk). Add a third layer that detects the
common case — dirty children form a contiguous suffix — and updates the
parent's text-content cache by splicing the new suffix into the prefix that
didn't change, instead of rebuilding the cache from scratch.

The detection runs once per reconcile cycle (build a Map<parentKey, dirty
children Set>), and the suffix walk uses parent.__last back-traversal so the
overall cost is O(K) where K is the dirty count, regardless of total
children.

5000-paragraph typing benchmark: 0.442ms → 0.132ms mean (-70% on top of
Layer 1+2, -91% vs main). 1000-paragraph: 0.139ms → 0.089ms (-36%).
Read-only update and pure read are unaffected — the new path only triggers
when there are dirty children to reconcile.

$prevSubtreeTextLength reads instance fields (__text on TextNode) instead of
calling getTextContent(), which routes through getLatest() and would resolve
to the next state for nodes dirtied during this update.
Adds an `@internal __benchOnly.skipChildrenFastPath` flag to
`LexicalReconciler` and a paired bench in `editorCycle.bench.ts` so
`vitest bench` produces a single head-to-head summary comparing the
children fast path against the general $reconcileNodeChildren walk
without needing two branches checked out.

Addresses facebook#8481's review comment that the DOM bench output is less
interesting on its own without an A/B against another implementation.
The flag has no effect when false (default) — one extra boolean read
at the fast path entry guard.
@vercel

vercel Bot commented May 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 12, 2026 8:29pm
lexical-playground Ready Ready Preview, Comment May 12, 2026 8:29pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 8, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 8, 2026
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/__bench__/dom/editorCycle.bench.ts
Comment thread packages/lexical/src/__tests__/unit/LexicalReconciler.test.ts Outdated
Comment thread packages/lexical/src/__tests__/unit/LexicalReconciler.test.ts Outdated
Comment thread packages/lexical/src/__tests__/unit/LexicalReconciler.test.ts Outdated
Comment thread packages/lexical/src/__tests__/unit/LexicalReconciler.test.ts Outdated
Comment thread packages/lexical/src/__tests__/unit/LexicalReconciler.test.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
mayrang added 3 commits May 9, 2026 08:53
… + safer narrowing

Replace the recursive `$prevSubtreeTextLength` walk with a node-keyed cache
of `getTextContentSize()` (a `WeakMap<LexicalNode, number>` so the entry
survives `Object.freeze` in dev). The reconciler sets the entry on every
node it reconciles or creates, so the previous-state instance always
carries a current label and the suffix-incremental fast path can read it
in O(1) without ever falling back to a recursive walk that would resolve
through `getLatest()` -> next state.

This also collapses the `getTextContent()` fallback in the suffix newSuffix
loop's quadratic-risk surface — once the cache invariant holds, there's no
recursion to repeat.

Other review items addressed in the same pass:

- `activePrevKeyToDOMMap` is now typed `Map<NodeKey, HTMLElement &
  LexicalPrivateDOM>` so the suffix path doesn't need to cast every
  `__lexicalTextContent` read.
- Tests and the editorCycle bench narrow via `invariant($isParagraphNode)`
  / `invariant($isTextNode)` instead of `<NodeType>` generics or `as`
  casts.
Per the post-review code-reviewer pass on the previous commit:

- $cachedTextSize now invariants on cache miss instead of silently
  falling through to getTextContentSize() (which would resolve via
  getLatest() -> next state and permanently miscache the wrong value).
  If the invariant ever breaks, the error is loud and the bug is found
  in tests rather than user editors.
- The 13-line cachedSize computation duplicated across $reconcileNode
  and $createNode is now $computeCachedTextSize.
…eNode

Closes the last cache-miss branch for ElementNode reads in the children
fast paths' newSuffix and Layer 1 loops. $createChildren only runs when
childrenSize > 0, so empty elements left their dom.__lexicalTextContent
unset; consumers had to fall back to node.getTextContent() — cheap for
empty elements specifically, but the structural concern from facebook#8482's
review (ElementNode getTextContent fallback could compound across nested
calls) is removed at the source.

The fast paths' cached-text reads can now treat the cache as always set
for ElementNode children.
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
…ion test

Style fixes (etrepum's three suggestion comments):
- Type the local `dom` in $createNode as `HTMLElement & LexicalPrivateDOM`
  so the empty-element write and the $computeCachedTextSize call don't
  need casts.
- Drop the `as` cast at the $reconcileNode-side $computeCachedTextSize
  call site (now redundant with the local type).

Test addition:
- "sustained typing on the same paragraph stays correct (cache freshness)"
  exercises the multi-cycle propagated-dirty same-instance case. With a
  multi-paragraph layout (so the root-level suffix path actually fires),
  it types into the same paragraph across 4 cycles and verifies the cached
  text stays correct each round. This is the scenario where a Symbol-keyed
  property + skip-if-frozen alternative cache implementation silently
  produces wrong splices in dev (verified empirically); the WeakMap
  approach passes by re-setting through Object.freeze.
Comment thread packages/lexical/src/LexicalReconciler.ts Outdated
…property + ctor pre-init

Addresses etrepum's review on PR facebook#8482 (cache implementation).

- LexicalNode constructor: pre-allocate the cached-text-size slot as a
  non-enumerable property so all node instances share the same V8 hidden-
  class shape and the setter is a stable inline-cache hit instead of a
  per-instance shape transition.
- LexicalReconciler: replace the WeakMap-keyed cache with a Symbol-keyed
  property. Leaf-only — ElementNodes already carry their text content in
  dom.__lexicalTextContent (written at the end of $reconcileChildren), so
  $cachedTextSize routes elements to the DOM cache via the $getDOMSlot
  indirection (handles TableNode and similar wrapping the keyed DOM).
- Skip leaf writes when the instance is frozen in DEV. Cross-parent moves
  can pass an already-frozen leaf back through $createNode/$reconcileNode;
  the frozen instance still carries a valid Symbol from its prior cycle,
  and a leaf that genuinely needs a new size went through getWritable()
  -> static clone -> ctor -> fresh undefined slot.
- Drop $computeCachedTextSize (its branches are absorbed into the setter).

Bench (3 runs, vitest): with-children-fast-path
  size=1000:  WeakMap 10,978 hz  ->  Symbol+pre-init 11,338 hz  (+3.3%)
  size=5000:  WeakMap  7,441 hz  ->  Symbol+pre-init  7,587 hz  (+2.0%)

Constructor cost: isolated microbench shows ~70 ns extra per node for the
additional defineProperty (~350 us per 5000-paragraph import — negligible
relative to the typing hot-path gain).
…ext invariant fix

Two changes that surfaced together:

- MIN_FAST_PATH_CHILDREN = 4: gate the suffix-incremental fast path on a
  non-negligible parent size, per etrepum's review note. The bookkeeping
  (cache lookups, suffix walk, splice) doesn't pay for parents with very
  few children. Bench-tuned via editorCycle.bench; size=1000 / size=5000
  typing land within noise of the un-gated baseline.
- Root `__cachedText` invariant fix: `reconcileTextFormat` can call
  `setTextFormat` on the parent during a child reconcile, which clones
  root mid-cycle. The local `nextNode` in `$reconcileNode` then points
  at the stale pre-clone instance — its `__cachedText` matches the
  freshly-computed `subTreeTextContent` and the cache setter no-ops
  while the actual root in the map carries `null` (RootNode constructor's
  default). The fix re-fetches via `nextNode.getLatest()` before the
  comparison so the writable assign lands on the correct instance.

The latent cachedText bug was masked before this PR's change because the
suffix fast path explicitly nulls `subTreeTextFormat` so `reconcileTextFormat`
never runs. The MIN gate routes small parents through the general path,
which is where the cloning happens.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Another audit has uncovered an inconsistency of where __lexicalFirstTextKey is stored mayrang/lexical@perf/reconcile-children-append-fast-path...etrepum:lexical:claude/audit-reconciler-optimization-49ZhC

Looks like we are getting very close here!

New finding (failing test on the audit branch)

AUDIT-4 — $bubbleChildFirstText reads __lexicalFirstTextKey from the keyed DOM, but cache writes go to slot.element.

The cache is written by $reconcileChildren (line 1221) and $createChildren (line 515) at slot.element — the inner DOM returned by node.getDOMSlot(dom). For elements without wrapping DOM, slot.element === dom and the read finds the cache. For elements with wrapping DOM (TableNode wraps a <table> in a scrollable <div>; custom inline wrappers like a <main><section></section></main>), slot.element !== dom:

  • $bubbleChildFirstText(childDom) at line 1265, 1339, and 1096 receives the keyed DOM (outer wrapper).
  • childDom.__lexicalFirstTextKey is undefined on the wrapper.
  • The bubble silently returns. The parent's first-text capture loses this child.

When the wrapper element is a non-dirty prefix child carrying the canonical first text descendant, the parent's __lexicalFirstTextKey and __textFormat end up reflecting a later dirty sibling's first text instead. The AUDIT-4 test asserts para.__textFormat === BOLD (textA in the wrapper) but gets ITALIC (textB in linkB) because of the missed bubble.

The empty-element write at $createNode line 413-414 has the inverse asymmetry: it writes to dom (keyed DOM), not the inner slot. For an empty wrapping element this leaves the slot's cache unset, which would also break $cachedTextSize's element branch (line 99-108) if such an element participates in a suffix-path oldSuffixLength computation.

Production impact assessment

  • TableNode: the only wrapping element in Lexical core. Block-level, typically a direct child of root. Root's __textFormat propagation is gated by $isRootOrShadowRoot at line 531, so the symptom is masked at root. Inside a shadow root or other non-root parent that hosts tables (e.g., layout columns), the symptom would surface.
  • Custom Lexical extensions: any node that overrides getDOMSlot to return an inner element — common pattern for app-specific layout shells, scrollable wrappers, or DOM accessibility wrappers — would hit this bug.

Suggested fix

Route all cache reads/writes through activeEditorDOMRenderConfig.$getDOMSlot(node, keyedDom, activeEditor).element so they're symmetric. Three call sites need updating:

  • $bubbleChildFirstText should accept the node and keyed DOM, then resolve to the slot's inner element internally.
  • $reconcileChildren's Layer 2 read at line 1088 (text content fallback for non-dirty elements) — same routing.
  • $createNode's empty-element write at line 413-414 — should also use the slot's inner element.

$cachedTextSize already routes through $getDOMSlot (lines 99-101); the rest of the cache plumbing should follow suit.

Observations on the capture-guard pattern

  • Per-iteration $beginCaptureGuard() allocates a fresh CaptureGuard object. The comment at line 176-178 claims V8 escape analysis will stack-allocate it. This is plausible for the monomorphic shape but not guaranteed across V8 versions or for cold paths. For high-frequency reconciles (typing in long documents), this is one allocation per child per dirty cycle — small but measurable. Inlining the three saved values as locals would be a perf-only change with no functional effect.
  • The reset at $createChildren entry (line 487-490) is necessary for correctness because $createChildren is called both as the inner walk for a new element (from $createNode) and as the new-children path from $reconcileChildren/$reconcileNodeChildren. The reset+outer-save pattern correctly handles both.
  • $reconcileRoot's explicit reset of all four accumulators is defensive; the existing $reconcileChildrenWithDirection reset would normally cover this on the first child reconcile, but resetting at root start guards against intermediate states leaking across editor.update boundaries (e.g., from a prior errored reconcile).

Failing audit test lives at etrepum/lexical@claude/audit-reconciler-optimization-49ZhC (commit 6757f78).

Adds AUDIT-4 demonstrating that the cache reads in $bubbleChildFirstText
(and the parallel read in Layer 2's non-dirty branch at line 1095) go
through the keyed DOM, while the cache writes in $createChildren and
$reconcileChildren go through `slot.element` (the inner DOM returned
by `node.getDOMSlot(dom)`).

For elements without wrapping DOM, keyed DOM === slot.element, and the
read finds the cache. For wrapping elements (TableNode wraps a `<table>`
in a scrollable `<div>`; custom inline wrappers like the test's
`<main><section></section></main>`), keyed DOM !== slot.element and the
read on the keyed DOM returns undefined. The bubble silently skips and
the parent's first-text capture loses this child.

When the wrapper element is a non-dirty prefix child carrying the
canonical first text descendant, the parent's __lexicalFirstTextKey and
__textFormat end up reflecting a later dirty sibling's first text
instead. Test asserts para.__textFormat = BOLD (textA in the wrapper)
but gets ITALIC (textB in linkB) because of the missed bubble.

In production, TableNode is the only wrapping element in Lexical core.
TableNode is block-level and usually sits at root, where __textFormat
propagation is gated by the $isRootOrShadowRoot check, so the symptom
is masked. The bug would surface for custom Lexical extensions that
define inline wrapping elements, or for any future core node with the
same shape.

The empty-element write at $createNode line 413-414 has the inverse
asymmetry: it writes to `dom` (keyed DOM), not the inner slot. For
empty wrapping elements this leaves the slot's cache unset, which
would also break $cachedTextSize if such an empty wrapper participates
in a suffix-path oldSuffixLength computation. The audit-fix-up cycle
didn't reach this site.

Suggested fix shape: route all cache reads/writes through
`activeEditorDOMRenderConfig.$getDOMSlot(node, dom, activeEditor).element`
(the inner slot), symmetric with $reconcileChildren's writes. Or have
$createNode for an empty element route the cache write the same way.
A read-time fallback that tries slot.element if keyed DOM has no cache
would also work but is less principled.
@mayrang

mayrang commented May 11, 2026

Copy link
Copy Markdown
Contributor Author

@etrepum — pulled in the AUDIT-4 test and applied the principled fix (31bd5b770). Routed __lexicalTextContent / __lexicalFirstTextKey reads and writes through $getDOMSlot(...).element at the five sites you flagged ($bubbleChildFirstText, $createNode's empty-element write, $reconcileChildren Layer 2, $reconcileNode's two non-dirty branches) plus two more I caught on a self-audit pass with the same shape:

  • $tryReconcileSuffixWithSizeDelta's newSuffix rebuild loop reads __lexicalTextContent off _keyToDOMMap.get(key) (outer keyed DOM).
  • $reconcileChildren's sizeDelta=0 suffix splice has the same pattern off activePrevKeyToDOMMap.get(cur).

Both are perf misses (correctness preserved via the getTextContent() fallback), but the same asymmetry shape so I folded them in.

Bench numbers from editorCycle.bench.ts on macOS (Node 20.19, jsdom):

Scenario baseline principled (pushed) β (read-time fallback)
size=1000 typing :: general 3,914 hz 3,590 (-8.3%) 3,909 (-0.1%)
size=1000 append :: general 2,386 hz 2,204 (-7.6%) 2,334 (-2.2%)
size=1000 remove :: general 1,009 hz 922 (-8.6%) 992 (-1.7%)
size=5000 typing :: general 871 hz 801 (-8.1%) 857 (-1.6%)
size=5000 append :: general 758 hz 706 (-6.9%) 769 (+1.4%)
(with fast path scenarios) unchanged across all three (±2% noise)

Fast path is unaffected either way. The general path is 7-9% slower in the principled version because every clean-child cache read now resolves through $getDOMSlot even when slot.element === dom (the common ParagraphNode case).

I also tried the read-time fallback you mentioned as the less-principled alternative — direct read on keyed DOM first, slot fallback only when the direct read is undefined. Writes still land on slot.element. Since we never write undefined to the cache, the direct read either finds a valid cached value (non-wrapping case, where slot.element === keyedDom) or definitively misses (wrapping case → falls through to slot lookup). β recovers the general path to within noise of baseline.

Pushed the principled version since that's the shape you preferred. If the general-path perf trade-off matters more in practice, let me know and I'll swap to the fallback; otherwise the principled symmetry stays.

@etrepum

etrepum commented May 11, 2026

Copy link
Copy Markdown
Collaborator

I don’t have a strong opinion on the process here, just that we aim for correctness and performance. The audit’s suggestions were from Claude not me.

To that point, perhaps it would it make more sense to lift all of these private caches to the outer DOM rather than targeting the slot. I think the only reason we were looking at the slot is because that’s where the text cache happened to be, but there’s no real requirement that it must remain there.

Per etrepum's review (5/12): the `__lexicalTextContent` /
`__lexicalFirstTextKey` cache doesn't need to live on the slot element —
it was there only because that's where the original text cache happened
to be written. Moving it to the outer keyed DOM keeps every read and
write on the same DOM (the one in `_keyToDOMMap`), so wrapping elements
(TableNode etc.) get symmetric cache plumbing without any slot dispatch
on the hot path.

Concretely:

- `$cachedTextSize`, `$bubbleChildFirstText` (signature reverted to
  `(childKeyedDom)`), `$createNode`'s empty-element write,
  `$reconcileNode`'s two non-dirty branches, and the two newSuffix
  rebuild loops (`$tryReconcileSuffixWithSizeDelta` and
  `$reconcileChildren`'s sizeDelta=0 suffix splice) all read/write
  directly off the keyed DOM, no helper.
- `$reconcileChildren` and `$createChildren` look up
  `activeEditor._keyToDOMMap.get(<element>.__key)` once at entry into a
  local `cacheDom`. All cache R/W in those functions goes through
  `cacheDom`. DOM operations (insertBefore, replaceChild, etc.) still
  use `slot.element` directly. `$resolveSuffixPathFormat` and
  `$tryReconcileSuffixWithSizeDelta` receive `cacheDom` (was `slot.element`).
- The `$getElementCacheDom` helper from the previous principled fix is
  removed (no slot dispatch needed anywhere).

The principled (slot.element) fix was correct but added a method
dispatch to every clean-child cache read in the general path. Bench
(`editorCycle`, size=1000-5000, jsdom, Node 20.19) showed the general
path was 6-9% slower than baseline. This version lands within ±3% of
baseline on the general path (most within rme); one scenario
(size=5000 remove general) is +17% faster than baseline. Fast-path
numbers are unchanged within noise (one borderline scenario at
size=1000 -4.7%, within combined rme).

AUDIT-4 test from the prior commit still passes. Full unit suite (113
files / 2505 tests) green.
@mayrang
mayrang force-pushed the perf/reconcile-children-append-fast-path branch from 31bd5b7 to 9c0c284 Compare May 11, 2026 22:29
@mayrang

mayrang commented May 11, 2026

Copy link
Copy Markdown
Contributor Author

Adopted that (9c0c28451). All cache R/W now goes through the outer keyed DOM:

  • $cachedTextSize, $bubbleChildFirstText (signature reverted to (childKeyedDom)), $createNode's empty-element write, $reconcileNode's non-dirty branches, and the two newSuffix rebuild loops all read/write directly off the keyed DOM. No helper.
  • $reconcileChildren and $createChildren look up activeEditor._keyToDOMMap.get(<element>.__key) once at entry into a local cacheDom. Cache R/W goes through cacheDom; DOM ops (insertBefore, replaceChild, etc.) still target slot.element.

Bench (editorCycle.bench.ts, jsdom, Node 20.19):

Scenario baseline previous (slot routing) this revision (keyed DOM)
size=1000 typing :: general 3,914 hz 3,590 (-8.3%) 3,964 (+1.3%)
size=1000 append :: general 2,386 hz 2,204 (-7.6%) 2,365 (-0.9%)
size=1000 remove :: general 1,009 hz 922 (-8.6%) 982 (-2.7%)
size=5000 typing :: general 871 hz 801 (-8.1%) 852 (-2.2%)
size=5000 append :: general 758 hz 706 (-6.9%) 772 (+1.8%)
size=5000 remove :: general 119 hz 118 (-0.5%) 139 (+16.9%)
(with fast path scenarios) within ±2% noise across all three

General path is back within ±3% of baseline (most within rme); size=5000 remove general is actually a bit faster than baseline now. Fast path unchanged within noise. AUDIT-4 test still passes; full unit suite (113 files / 2505 tests) green.

- AUDIT-5: $tryReconcileSuffixWithSizeDelta now takes `slot` and
  `cacheDom` as separate parameters. DOM ops ($reconcileNode /
  $destroyNode) route through slot.element; cache R/W stays on
  cacheDom. Pre-fix, wrapping ElementNode parents (custom nodes with
  a getDOMSlot returning an inner element, TableNode with the
  scrollable wrapper) leaked DOM on the size-delta=-1 destroy path
  because the child's parentNode was the inner slot, not the outer
  keyed DOM.

- Regression test: BlockWrapperElementNode parent across append
  (sizeDelta=+1, K=2) and remove (sizeDelta=-1, K=1) cycles. Without
  the fix, the remove cycle leaves an orphan child under the inner
  slot.

- Gate cleanup (L570 follow-up): drop the redundant `prevElement.__last
  === nextElement.__last` check from the same-size branch. Any
  structural mutation that keeps size constant routes through
  getWritable() on the parent and lands it in _cloneNotNeeded, so the
  cloned-parent check alone is sufficient.

- Invariant tightening: $bubbleChildFirstText distinguishes
  `undefined` (cache missing - invariant violation, now throws) from
  `null` (legitimately no text descendant - silent return). Matches
  the $cachedTextSize throw-on-miss style for the new cache fields
  this PR introduces.
@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Posting at top level since some of these are responses on already-resolved threads — wanted to make sure the headline doesn't get lost in notifications. Pushed in e6f378210.

Critical: fresh audit found a wrapping-parent bug in $tryReconcileSuffixWithSizeDelta. It was conflating slot.element (DOM op target) with the outer keyed DOM (cache R/W). For wrapping ElementNode parents — TableNode with the scrollable wrapper, or any custom node whose getDOMSlot returns an inner element — this routed $destroyNode / $reconcileNode to the wrong parentDOM. The size-delta=-1 destroy path silently leaked DOM (dom.parentNode === parentDOM guard didn't match, removeChild skipped but _keyToDOMMap.delete still ran → orphan under the inner slot); the size-delta=±1 reconcile path's parentDOM.replaceChild would throw NotFoundError on any $updateDOM-returns-true reconcile for the same reason.

Helper now takes slot and cacheDom separately. Regression test (AUDIT-5) covers a BlockWrapperElementNode parent across append (sizeDelta=+1, K=2) and remove-last (sizeDelta=-1, K=1) cycles; without the fix the remove leaves an orphan paragraph.

Same push:

  • Dropped prevElement.__last === nextElement.__last from the same-size gate per your L570 inline. _cloneNotNeeded is the load-bearing check; any same-size mutation that could shift __last routes through getWritable() on the parent. MIN_FAST_PATH_CHILDREN and Math.abs(sizeDelta) <= 1 stayed.
  • $bubbleChildFirstText now distinguishes undefined (cache missing, throws) from null (no text descendant, silent return), matching the $cachedTextSize throw-on-miss style for the new cache fields.

Bench stable post-fix: 5k typing 8.49x, 5k append 5.93x, 5k remove-last 10.94x vs general path. Critical fix is on a wrapping-parent path that the bench's standard ParagraphNode children don't exercise, so no perf delta on the headline numbers.

…Suffix uses current map

Resolves the remaining audit-round findings on the children fast-path
work. Three substantive areas:

- Same-size suffix newSuffix builder was reading each reconciled child's
  cached text from `activePrevKeyToDOMMap` (cycle-start snapshot). After
  $updateDOM=true reconciles the snapshot points at the detached old
  DOM whose __lexicalTextContent is from the previous cycle, producing
  a stale parent cache (silently, since the lazy `node.getTextContent()`
  fallback was removed in the round 2 strict-on-miss tightening). Switch
  the read to `activeEditor._keyToDOMMap.get(cur)`, mirroring the size-
  delta helper which already uses the current map. AUDIT-6 pins this.

- All remaining lazy-fallback cache reads are now strict invariants.
  $reconcileNode's two non-dirty branches were already lazy-filling
  __lexicalTextContent but not __lexicalFirstTextKey; with $bubbleChild
  FirstText now strict, the asymmetric fallback was a latent crash hazard
  (textContent set, firstTextKey undefined → invariant fires one line
  later). The three suffix-builder ElementNode reads in the helper, the
  same-size suffix newSuffix builder, and the structurally-clean walk
  now invariant on miss as well. The `?? ''` defensive read after a
  successful $tryReconcileSuffixWithSizeDelta call is also tightened to
  invariant on the helper's post-condition.

- AUDIT-5b reconcile-arm test added (forces $updateDOM=true via a
  RerenderParagraphNode subclass that overrides static clone so the
  writable clone keeps the subclass identity). The assertion is DOM
  identity survival of the non-dirty prefix; pre-fix the recovery path
  rebuilds every DOM. AUDIT-6 covers the same-size newSuffix L1054
  cache-read fix. AUDIT-7 sets up a K=3 contiguous suffix with size
  delta=+1 to exercise the helper's K-check bail; documented honestly
  as an output sentinel since the helper's splice math happens to be
  generic for valid (k, sizeDelta) combinations.

Also drops the redundant `prevElement.__last === nextElement.__last`
clause from the same-size gate (`_cloneNotNeeded` already covers any
mutation that could shift `__last`), with the comment tightened to call
out the contract boundary (mutations bypassing `getWritable` are out of
scope). The L1393 cloned-non-dirty branch is currently unreachable
under `getWritable()`'s dirty-marking semantics; kept as defense in
depth with a comment.

Test names normalized to AUDIT-1 through AUDIT-7 (with intentional 5b).
Shared `BlockWrapperElementNode` hoisted to the top of the suffix-fast-
path describe block so AUDIT-5 and AUDIT-5b reuse one definition.
@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Another round of self-audit on top of e6f378210 — pushed at ea34800ed. Posting summary since some of these address comments on resolved threads and span multiple audit rounds.

Cache correctness (caught by self-audit, latent pre-existing): the same-size suffix newSuffix builder was reading each reconciled child's cached text from activePrevKeyToDOMMap (cycle-start snapshot). When a dirty child's $updateDOM returns true, $reconcileNode's replaceChild detaches the old DOM and stores a new one in _keyToDOMMap; the snapshot still points at the detached old DOM whose __lexicalTextContent is from the previous cycle. Pre-existing — the lazy node.getTextContent() fallback this PR's round-2 tightening removed wasn't preventing the stale read either (the stale cached value is itself a string, so the fallback never fires). No user-visible regression in current lexical because the built-in nodes that flip updateDOM=true (list tag, code language, heading tag) preserve text through the transition, so stale read happens to equal correct value. A custom block node combining DOM replacement with text mutation would surface it. Fix: switch the read to activeEditor._keyToDOMMap.get(cur), mirroring the size-delta helper at L856. AUDIT-6 pins this — pre-fix returns the stale value when both rerender paragraphs have their text mutated.

Strict-on-miss consistency: $reconcileNode's two non-dirty branches were lazy-filling __lexicalTextContent but not __lexicalFirstTextKey; with $bubbleChildFirstText strictly throwing on undefined, the asymmetric fallback was a latent crash hazard (textContent set, firstTextKey undefined → invariant fires one line later). Tightened those reads to invariants, plus the three suffix-builder ElementNode reads and the ?? '' defensive at the helper's post-condition. The PR's two new cache fields now uniformly fail loudly on miss, matching $cachedTextSize's policy.

Test additions:

  • AUDIT-5b — reconcile arm of the AUDIT-5 fix. Uses a RerenderParagraphNode subclass that forces $updateDOM=true; needed a static clone override (without it $cloneWithProperties returns a plain ParagraphNode and the override is shadowed). Assertion is DOM identity survival of the non-dirty prefix — the original DOM-shape assertion passed under both fix and revert because jsdom's DOMException cross-realm instanceof Error returns false, so Lexical's recovery rebuilt the DOM correctly and shape-asserts passed by accident.
  • AUDIT-6 — same-size suffix + $updateDOM=true. Pins the L1054 fix above.
  • AUDIT-7 — K=3 contiguous suffix with sizeDelta=+1, output sentinel for the helper bail. Honest in the comment that the helper's splice math is generic for valid (k, sizeDelta) so the test pins the output, not the bail strictly.

Gate cleanup (continuation of your L570): comment now also calls out that the _cloneNotNeeded invariant covers Lexical's mutation API only — direct pointer mutation that bypasses getWritable() is outside the contract.

Test polish: shared BlockWrapperElementNode hoisted to the top of the suffix-fast-path describe (was duplicated). Names normalized to AUDIT-1 through AUDIT-7 (with intentional 5b). Tests reordered to 5 → 5b → 6 → 7.

862 unit pass. tsc / prettier / eslint clean. Bench unchanged from the previous report (5k typing 8.49x vs general path).

@etrepum

etrepum commented May 12, 2026

Copy link
Copy Markdown
Collaborator

If you use $config to define the test classes you won't need the static method boilerplate, it provides default implementations

…w feedback

Per facebook#8482 (comment):
`BlockWrapperElementNode` and the two `RerenderParagraphNode`s (5b /
6) now use `$config()` so lexical provides default implementations
for `static getType` / `static clone` / `static importJSON` /
`exportJSON` instead of hand-rolled boilerplate. `RerenderParagraphNode`
passes `{extends: ParagraphNode}` so the writable clone keeps the
subclass identity (which was the load-bearing detail behind the
manual `static clone` override).

-17 LOC, no behavior change. AUDIT-5 / 5b / 6 + 7 still pin the same
DOM identity / cache / bail invariants.
@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Done in a02e3eb2f$config() applied to the wrapper and both RerenderParagraphNodes. extends: ParagraphNode keeps the writable clone as the subclass (which was the load-bearing piece behind the manual static clone). -17 LOC.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since we ended up with defineProperty to enforce non-enumerable anyway, do you happen to know if there's any meaningful performance difference between using a symbol and a string for the CACHED_TEXT_SIZE_KEY property?

I think everything looks good here, but I want to spend some time doing another close read of the code myself before merge.

@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Doesn't look like there's a meaningful perf diff. Microbench, 3 runs, defineProperty-preinit applied to both:

scenario (size=10000) Symbol hz String hz
construct + preinit slot 1,433 1,431
write-all then read-all 64.8k 65.8k
read-only hot loop 146k 146k

Within ~1% across runs — preinit flattens the hidden class for both and V8's IC handles the two lookup paths equivalently.

Design-wise I'd lean toward keeping the Symbol though. Surfaced while trying the swap locally: this is the only reconciler-internal cache that sits on the LexicalNode instance — the existing __lexical* caches (__lexicalTextContent, __lexicalFirstTextKey) all live on HTMLElement & LexicalPrivateDOM, so they never intersect keyof LexicalNode. With the Symbol key the slot falls out of string & keyof T patterns for free; swapping to '__lexicalCachedTextSize' as const joins it into keyof LexicalNode and the writer-side intersection collapses to never at packages/lexical-yjs/src/Utils.ts:355 (writableNode[property as string & keyof typeof writableNode] = ...).

That one site is trivially fixable, but the trade it surfaces is: with a string key the reconciler-internal slot becomes part of LexicalNode's public type surface, and any future generic-write/iterate path through the node type has to remember to exclude it. The Symbol enforces that boundary at the type level — which matters more here than for the DOM-side caches because the node type is what external packages tend to walk.

Either direction is workable once the yjs site is updated, but on that reasoning I think the Symbol might be worth keeping.

@etrepum

etrepum commented May 12, 2026

Copy link
Copy Markdown
Collaborator

I also prefer the symbol because it's truly an implementation detail that is better off not leaking out, but would be willing to use a string if it was meaningfully better for performance. A moot point, but I don't think lexical-yjs or its types would be an issue if the property were a string. The descriptor makes it non-enumerable and there are no constraints on the value.

@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Fair — I was looking at the tsc surface, but you're right that runtime is fine (the property is non-enumerable and yjs's sync only iterates from the shared-type keys). Symbol stays.

@mayrang

mayrang commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the time on this one. Honestly the audit rounds were probably more work than the original PR was — the cross-parent cache invariants and the wrapper-DOM walks especially were where I learned the most. Glad it landed.

@etrepum etrepum mentioned this pull request May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants