FHL Add RewriteMutatedBlocksOnly experimental feature for optimized DOM write-back#3300
FHL Add RewriteMutatedBlocksOnly experimental feature for optimized DOM write-back#3300BryanValverdeU wants to merge 2 commits intomasterfrom
Conversation
…rite-back Introduces a fast path in handleBlockGroupChildren that skips re-running block handlers for blocks that haven't been mutated (i.e. still have a valid cachedElement). Instead, their existing DOM elements are reused directly via reuseCachedElement, reducing unnecessary DOM operations when only a small portion of the content model was changed. Key details: - New isBlockFullyCached utility recursively checks whether a block and all its descendants retain their cached elements (no mutation occurred) - Fast path is gated behind the new 'RewriteMutatedBlocksOnly' experimental feature flag (off by default), following the same pattern as 'CacheList' - Span cells (spanAbove/spanLeft) are treated as trivially cached since they have no DOM element of their own - ListItem blocks are excluded from the fast path because their <li> elements are nested inside <ul>/<ol> and require list-stack management - Entity blocks are excluded since they use wrapper rather than cachedElement - Callbacks (onNodeCreated, domIndexer.onTable) are preserved in the fast path to match existing handler behavior - Feature checkbox added to the demo site Experimental Features panel - karma.fast.conf.js updated with a FailOnlyReporter to suppress passing test output, making failures easier to spot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an experimental “rewrite mutated blocks only” fast path in the content-model → DOM write-back pipeline to reduce unnecessary DOM mutations by reusing fully cached block subtrees.
Changes:
- Introduces
RewriteMutatedBlocksOnlyexperimental feature and wires it throughEditorContext/createEditorContext. - Adds
isBlockFullyCached()utility + unit tests, and updateshandleBlockGroupChildrento reuse cached DOM for fully cached blocks when the flag is enabled. - Updates demo to expose the new experimental feature and adjusts
karma.fast.conf.jsreporting.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts | Adds RewriteMutatedBlocksOnly feature flag. |
| packages/roosterjs-content-model-types/lib/context/EditorContext.ts | Adds rewriteMutatedBlocksOnly to editor context. |
| packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts | Enables the flag via core.experimentalFeatures. |
| packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts | Updates expectations to include the new context flag. |
| packages/roosterjs-content-model-dom/lib/modelToDom/utils/isBlockFullyCached.ts | New utility to detect “fully cached” blocks (recursively). |
| packages/roosterjs-content-model-dom/test/modelToDom/utils/isBlockFullyCachedTest.ts | Adds unit tests for isBlockFullyCached. |
| packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleBlockGroupChildren.ts | Implements the fast path that reuses cached DOM elements. |
| packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts | Adds tests validating the fast path behavior. |
| karma.fast.conf.js | Adds a “fail only” reporter and changes default reporters. |
| demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx | Adds UI checkbox for the new experimental feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Call callbacks that the individual handlers would call for cached elements | ||
| if (childBlock.blockType === 'Table') { | ||
| context.onNodeCreated?.(childBlock, childBlock.cachedElement!); | ||
| context.domIndexer?.onTable(childBlock.cachedElement!, childBlock); | ||
| } else if (childBlock.blockType === 'Divider') { | ||
| context.onNodeCreated?.(childBlock, childBlock.cachedElement!); | ||
| } else if ( | ||
| childBlock.blockType === 'BlockGroup' && | ||
| childBlock.blockGroupType === 'FormatContainer' | ||
| ) { | ||
| context.onNodeCreated?.(childBlock, childBlock.cachedElement!); | ||
| } | ||
| // Paragraphs: no callbacks when reusing (matches existing handleParagraph behavior) |
There was a problem hiding this comment.
In the fast path for fully-cached blocks, handleBlock (and therefore the normal handler recursion) is skipped. This changes onNodeCreated behavior for descendants that would normally fire even when cached (e.g., handleTable calls onNodeCreated for each tableRow and each non-span cell on every run; handleFormatContainer calls blockGroupChildren, which would still invoke onNodeCreated for cached Dividers inside). To keep behavior consistent with existing handlers (and with the docstring that this is called when nodes are created), the fast path should either (1) walk the cached subtree and invoke the same callbacks that the skipped handlers would have invoked, or (2) explicitly disable the fast path when context.onNodeCreated is provided / when callback parity is required.
| case 'ListItem': | ||
| return ( | ||
| !!block.cachedElement && | ||
| block.levels.every(l => !!l.cachedElement) && | ||
| block.blocks.length > 0 && | ||
| block.blocks.every(isBlockFullyCached) | ||
| ); | ||
|
|
There was a problem hiding this comment.
isBlockFullyCached includes a ListItem branch, but handleBlockGroupChildren explicitly excludes list items from the fast path. This makes the ListItem logic currently unused and untested in the new unit tests, increasing the risk of it drifting incorrect. Consider either adding test coverage for the ListItem branch or removing it until the fast path actually supports list items.
| case 'ListItem': | |
| return ( | |
| !!block.cachedElement && | |
| block.levels.every(l => !!l.cachedElement) && | |
| block.blocks.length > 0 && | |
| block.blocks.every(isBlockFullyCached) | |
| ); |
|
|
||
| const FailOnlyReporter = function (baseReporterDecorator) { | ||
| baseReporterDecorator(this); | ||
| this.specSuccess = function () {}; // suppress success output |
There was a problem hiding this comment.
FailOnlyReporter currently only overrides specSuccess on top of baseReporterDecorator, but baseReporterDecorator methods are no-ops by default. With reporters: ['failOnly'], this can result in no output at all, including for failing specs, which is the opposite of the intended “failures easier to spot”. Consider either extending an existing reporter that already prints failures (e.g. keep progress and only suppress passed output), or implement specFailure/summary output in FailOnlyReporter so failures are always logged.
| this.specSuccess = function () {}; // suppress success output | |
| // Suppress output for successful specs | |
| this.specSuccess = function () {}; | |
| // Explicitly log failing specs so they are always visible, | |
| // even when 'failOnly' is the only configured reporter. | |
| this.specFailure = function (browser, result) { | |
| const failedSuite = (result.suite && result.suite.join(' ')) || ''; | |
| const testName = result.description || ''; | |
| const failureMessage = | |
| result.log && result.log.length ? '\n' + result.log.join('\n') : ''; | |
| this.writeCommonMsg( | |
| '\nFAILED: ' + | |
| (failedSuite ? failedSuite + ' ' : '') + | |
| testName + | |
| failureMessage + | |
| '\n' | |
| ); | |
| }; | |
| // Print a concise test summary at the end of the run. | |
| this.onRunComplete = function (browsers, results) { | |
| const summary = | |
| '\nTest summary: ' + | |
| results.success + ' passed, ' + | |
| results.failed + ' failed, ' + | |
| results.skipped + ' skipped.\n'; | |
| this.writeCommonMsg(summary); | |
| }; |
| // Call callbacks that the individual handlers would call for cached elements | ||
| if (childBlock.blockType === 'Table') { | ||
| context.onNodeCreated?.(childBlock, childBlock.cachedElement!); | ||
| context.domIndexer?.onTable(childBlock.cachedElement!, childBlock); | ||
| } else if (childBlock.blockType === 'Divider') { |
There was a problem hiding this comment.
For fully-cached tables, the fast path calls onNodeCreated(table, tableElement) without clearing/rebuilding the table DOM. In the existing handleTable cached-element path, moveChildNodes(tableNode) clears all children before onNodeCreated is invoked, so callbacks currently observe an empty <table> at that point. If any onNodeCreated consumers rely on that ordering, the fast path changes behavior; consider either matching the handler’s ordering (or documenting/guarding the fast path when onNodeCreated is present).
JiuqingSong
left a comment
There was a problem hiding this comment.
Can you show what mutations are skipped?
Before this change, whether it should reuse cache is decided by each block handler. And we may still call format handlers even cache is still available (e.g. list items). I can see in your change list is excluded, which is right. But still curious what are impacted, what kind of mutations are skipped.
| return !!block.cachedElement && !block.isSelected; | ||
|
|
||
| case 'Entity': | ||
| return false; |
There was a problem hiding this comment.
why return false for entity?
| return ( | ||
| !!block.cachedElement && | ||
| block.levels.every(l => !!l.cachedElement) && | ||
| block.blocks.length > 0 && |
There was a problem hiding this comment.
If block.length == 0, should we treat it as cached?
There was a problem hiding this comment.
If not , add comment to explain why
@JiuqingSong Most of the reduced mutations come from handleTable. Even if we have available a cached table we remove all the elements inside of the table and then start to add them again. which causes mutations when removing the table cells and also when re-adding the table cells. Here table have a cached element, but we still call moveChildNodes Then we re-add the table body, rows and cells. Each cause a mutation Table Body Table Row, appended and then all the childs removed again We could reduce the scope of the change only for the handleTable if you prefer. |
Introduces a fast path in handleBlockGroupChildren that skips re-running block handlers for blocks that haven't been mutated (i.e. still have a valid cachedElement). Instead, their existing DOM elements are reused directly via reuseCachedElement, reducing unnecessary DOM operations when only a small portion of the content model was changed.
The motivation of this change is to prevent unneeded mutations from observers attached to the Editor div, which could cause uneeded effects on mutations.
Extra change
Attached a MutationObserver to the editorDiv,
new MutationObserver((asd) => console.log(asd)).observe($0, {childList:true,subtree: true })with the experimental feature on, less mutations are handled, on the gif, I am using Enter Key
Before

After
