Skip to content

FHL Add RewriteMutatedBlocksOnly experimental feature for optimized DOM write-back#3300

Open
BryanValverdeU wants to merge 2 commits intomasterfrom
u/bvalverde/improverewritecontent
Open

FHL Add RewriteMutatedBlocksOnly experimental feature for optimized DOM write-back#3300
BryanValverdeU wants to merge 2 commits intomasterfrom
u/bvalverde/improverewritecontent

Conversation

@BryanValverdeU
Copy link
Contributor

@BryanValverdeU BryanValverdeU commented Mar 5, 2026

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

  • karma.fast.conf.js updated with a FailOnlyReporter to suppress passing test output, making failures easier to spot

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
AnimationBefore

After
AnimationAfter

BryanValverdeU and others added 2 commits March 5, 2026 17:09
…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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 RewriteMutatedBlocksOnly experimental feature and wires it through EditorContext/createEditorContext.
  • Adds isBlockFullyCached() utility + unit tests, and updates handleBlockGroupChildren to 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.js reporting.

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.

Comment on lines +63 to +75
// 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)
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +60
case 'ListItem':
return (
!!block.cachedElement &&
block.levels.every(l => !!l.cachedElement) &&
block.blocks.length > 0 &&
block.blocks.every(isBlockFullyCached)
);

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
case 'ListItem':
return (
!!block.cachedElement &&
block.levels.every(l => !!l.cachedElement) &&
block.blocks.length > 0 &&
block.blocks.every(isBlockFullyCached)
);

Copilot uses AI. Check for mistakes.

const FailOnlyReporter = function (baseReporterDecorator) {
baseReporterDecorator(this);
this.specSuccess = function () {}; // suppress success output
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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);
};

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +67
// 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') {
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@JiuqingSong JiuqingSong left a comment

Choose a reason for hiding this comment

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

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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

why return false for entity?

return (
!!block.cachedElement &&
block.levels.every(l => !!l.cachedElement) &&
block.blocks.length > 0 &&
Copy link
Collaborator

Choose a reason for hiding this comment

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

If block.length == 0, should we treat it as cached?

Copy link
Collaborator

Choose a reason for hiding this comment

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

If not , add comment to explain why

@BryanValverdeU
Copy link
Contributor Author

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.

@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

if (tableNode) {
refNode = reuseCachedElement(parent, tableNode, refNode, context.rewriteFromModel);
moveChildNodes(tableNode);

Then we re-add the table body, rows and cells. Each cause a mutation

Table Body

const tbody = doc.createElement('tbody');
tableNode.appendChild(tbody);

Table Row, appended and then all the childs removed again

const tr = (context.allowCacheElement && tableRow.cachedElement) || doc.createElement('tr');
tbody.appendChild(tr);
moveChildNodes(tr);

We could reduce the scope of the change only for the handleTable if you prefer.

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.

3 participants