[lexical] Perf: Children fast path with suffix-incremental cache update in $reconcileChildren#8482
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… + 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.
…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.
…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
left a comment
There was a problem hiding this comment.
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.__lexicalFirstTextKeyisundefinedon 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
__textFormatpropagation is gated by$isRootOrShadowRootat 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
getDOMSlotto 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:
$bubbleChildFirstTextshould 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 freshCaptureGuardobject. 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
$createChildrenentry (line 487-490) is necessary for correctness because$createChildrenis 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$reconcileChildrenWithDirectionreset 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.
|
@etrepum — pulled in the AUDIT-4 test and applied the principled fix (
Both are perf misses (correctness preserved via the Bench numbers from
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 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 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. |
|
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.
31bd5b7 to
9c0c284
Compare
|
Adopted that (
Bench (
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.
|
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 Critical: fresh audit found a wrapping-parent bug in Helper now takes Same push:
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 |
…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.
|
Another round of self-audit on top of Cache correctness (caught by self-audit, latent pre-existing): the same-size suffix newSuffix builder was reading each reconciled child's cached text from Strict-on-miss consistency: Test additions:
Gate cleanup (continuation of your L570): comment now also calls out that the Test polish: shared 862 unit pass. tsc / prettier / eslint clean. Bench unchanged from the previous report (5k typing 8.49x vs general path). |
|
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.
|
Done in |
etrepum
left a comment
There was a problem hiding this comment.
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.
|
Doesn't look like there's a meaningful perf diff. Microbench, 3 runs,
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 That one site is trivially fixable, but the trade it surfaces is: with a string key the reconciler-internal slot becomes part of Either direction is workable once the yjs site is updated, but on that reasoning I think the Symbol might be worth keeping. |
|
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. |
|
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. |
|
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. |
Description
Continuation of #8481 (GenMap for NodeMap / keyToDOMMap clone). With the Map clone cost out of the way, instrumentation showed
$reconcileRootis the next ~74% of cycle time on a 5k-paragraph doc — and$reconcileChildrenrebuilds twoArray<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:__first,__last, parent isn't in_cloneNotNeeded), skip the prev/next array rebuild and walk in-place via the linked list, reading cached__lexicalTextContentfor non-dirty children.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$prevSubtreeTextLengthrecursive walk and removes both thegetLatest()trap (customDecoratorNodesubclasses with internalgetLatest()calls would have resolved via next state) and the quadratic-risk fallback in the newSuffix loop.$cachedTextSizeinvariants 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
WeakMaprather than a node-sideSymbolproperty so thatObject.freeze(node)in dev mode doesn't fight with the cache write.$createNodenow also setsdom.__lexicalTextContent = ''for empty elements (the$createChildrenwrite 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
__cachedTextand__lexicalTextContentinvariants from #8099 / #8096 stay intact — the suffix splice produces a string identical to what the existing walk would produce. The 5RootNode __cachedTextregression tests pass unchanged.The cached-size
WeakMapis reconciler-internal, has no public API surface, and never escapes the reconciler module. Memory: oneWeakMapentry per node (8 bytes), GC'd when the node is.Bench numbers
editorCycle.bench.tsruns a head-to-head A/B in a singlevitest benchinvocation — addresses the comment on #8481 that the DOM bench was less interesting without an in-file implementation comparison. A@internal __benchOnly.skipChildrenFastPathflag on the reconciler lets the bench toggle between the new fast path and the general$reconcileNodeChildrenwalk; 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):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.readare 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 inLexicalReconciler.test.tscovering: 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.jsonclean.pnpm vitest bench --project bench-dom— see numbers above.pnpm test-e2e-ci-chromiumon 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.