When accepting tracked revisions in OOXML, nested revision wrappers can change which run elements (i.e., <w:r> content containers) should remain in the document. The outer wrapper can preserve content while an inner wrapper removes content, so processing order affects whether deleted content is still reachable when the preserved content is promoted.
The acceptChanges primitive (i.e., the document-mutation operation that accepts tracked changes) removes deletion wrappers before it unwraps insertion wrappers. That order prevents deleted run elements from being promoted along with accepted insertion content, while still preserving the live run elements that the insertion wrapper contains.[1]
Below is a test scenario of the bottom-up processing case of acceptChanges: nested insertions and deletions leave only the accepted insertion text in the output document.[2]
The scenario
Given a document with nested insertions and deletions,
When nested revisions are unwrapped bottom-up,
Then
w:inswrappers are removed.w:delwrappers are removed.- Nested deleted text is removed.
- Start text is preserved.
- End text is preserved.
Test fixture
The fixture places a deletion wrapper inside an insertion wrapper, which exercises the order used when accepting nested tracked changes.
Below is the test fixture code.
humanReadableTest.openspec('bottom-up processing resolves nested revisions')(
'bottom-up processing resolves nested revisions',
async ({ given, when, then, and, attachPrettyJson }: AllureBddContext) => {
const input = [
'<w:p>',
'<w:ins>',
'<w:r><w:t>Start </w:t></w:r>',
'<w:del><w:r><w:delText>remove-me</w:delText></w:r></w:del>',
'<w:r><w:t>end</w:t></w:r>',
'</w:ins>',
'</w:p>',
].join('');
let result: { xml: string; summary: ReturnType<typeof acceptChanges> };
await given('a document with nested insertions and deletions', async () => {});
await when('nested revisions are unwrapped bottom-up', async () => {
result = runAcceptChanges(input);
await attachPrettyJson('accept-nested-revisions-result', result);
});
await then('w:ins wrappers are removed', async () => {
expect(result.xml.includes('<w:ins')).toBe(false);
});
await and('w:del wrappers are removed', async () => {
expect(result.xml.includes('<w:del')).toBe(false);
});
await and('nested deleted text is removed', async () => {
expect(result.xml.includes('remove-me')).toBe(false);
});
await and('start text is preserved', async () => {
expect(result.xml.includes('Start ')).toBe(true);
});
await and('end text is preserved', async () => {
expect(result.xml.includes('end')).toBe(true);
});
},
);
Expected outcome
The scenario asserts the post-mutation XML content rather than the summary counters returned by acceptChanges, so the expected outcome is the set of XML predicates checked after the document is processed.
Below is the result that acceptChanges is expected to produce for this scenario.
expect(result.xml.includes('<w:ins')).toBe(false);
expect(result.xml.includes('<w:del')).toBe(false);
expect(result.xml.includes('remove-me')).toBe(false);
expect(result.xml.includes('Start ')).toBe(true);
expect(result.xml.includes('end')).toBe(true);
The result.xml value is expected to contain neither revision wrapper nor the nested deleted text, because accepting tracked changes removes <w:del> content and unwraps <w:ins> content into the surrounding paragraph.
A non-obvious detail
Nested insertion wrappers are unwrapped after deletion wrappers are removed, and insertion unwrapping is depth-sorted for wrapper handling. That ordering matters for OOXML tracked-change structures such as CT_TrackChange, where revision wrappers can contain paragraph-level content and other revision wrappers.[3]