Skip to content

fix(diff): keep each copy of a whole-replaced wrapper's comment intact (#1157) - #1169

Merged
JSv4 merged 2 commits into
mainfrom
fix/issue-1157
Oct 16, 2026
Merged

JSv4 merged 2 commits into
mainfrom
fix/issue-1157

Conversation

@JSv4

@JSv4 JSv4 commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Closes #1157

First of a three-PR stack for the comparison engine: this one (#1157), then #1156, then #1149. All three change how the redline renderer reconciles markers, so they are stacked to keep them from conflicting.

Why

When a comparison replaces a block wrapper (a w:sdt content control or a block-level w:customXml) whole, the redline holds a deleted copy and an inserted copy of it. When a comment range sat between the wrapper's blocks, accepting the redline moved the comment's reference out of the wrapper into a paragraph of its own before it, and left an empty paragraph inside. Reject was exact.

How it works

NormalizeComments (in IrMarkupRenderer) reconciles comment markers after rendering. Its first phase collapses a comment that both documents share down to one surviving start, end and reference, but only if those markers survive in both the accept and the reject view. It judged that by looking for a w:ins/w:del ancestor. Markers between a replaced wrapper's blocks have no such ancestor. They are one-sided only because the wrapper copy itself is tracked (with Word's customXmlDelRange/customXmlInsRange envelope markers). So the comment was treated as unchanged, and the survivors were taken from different copies.

  • New classifier, CommentMarkerView. It says which view a marker belongs to: deleted, inserted, or both. The nearest tracked ancestor decides, whether that is a w:ins/w:del or a wrapper emitted as a whole deleted or inserted copy. Recognising that wrapper shape lives next to the code that emits it (StructuredRevisionOps.TrackedWrapperIsDeleted).
  • All three phases use it. The collapse phase only collapses when some marker survives both views. The wrap phase only wraps markers that do. The dedup phase renumbers per view, so the deleted copy gets a fresh comment id and a cloned definition. The dedup phase also skips ids the collapse phase already handled. Without that, a comment opening before the wrapper and closing inside it was split in two, which was worse than main.
  • Threading. When the dedup phase clones a threaded comment, the cloned reply's w15:paraIdParent now points at the cloned parent, and each clone gets a fresh w16cid:durableId.
  • Block-level custom XML. Range markers directly inside a block-level w:customXml were never reconciled at all, which left duplicate range ids. They are now reconciled. Inline (run-level) w:customXml is deliberately left alone, because the fine diff emits it whole inside one w:ins/w:del, so its markers already follow that copy.

Validation

  • New tests in BlockWrapperNestedDiffTests:
    • Comment ranges between a replaced wrapper's blocks, 12 rows: w:sdt and w:customXml; plain wrapper, nested wrapper, and a table cell inside the wrapper; each with and without the wrapper inside a body table cell. Each row checks the block-for-block round trip, including where the reference lands. It also checks that each copy keeps a complete range and reference under its own id resolving to the comment's text, and that no new validator errors appear. All 12 fail on main.
    • A comment opening before a replaced wrapper: reject keeps the reference on the same comment.
    • A comment range inside an edited inline w:customXml: each view keeps a whole range.
    • A threaded comment between a replaced wrapper's blocks: each view keeps its own thread, with unique comment ids, paraIds and durableIds.
  • Mutation checks. Reverting each piece fails its tests: the classifier, the dedup skip, the block-level w:customXml allow-list, the inline exclusion, the parent remapping, and the fresh durable ids.
  • Existing test changed (approved). Compare_MarkerBetweenTheWrappersBlocks_StaysInTheRedline checked only the reject side for its comment rows and cited this issue. It now runs the full round trip, as its bookmark rows already did.
  • Suites. The full .NET suite passes: 6,883 passed, 0 failed, 2 skipped. The Release build has no warnings.

What is still wrong (tracked in #1167)

  • A comment that opens before the wrapper and closes inside it. The comment can open in a paragraph or in an earlier unchanged wrapper. Accept still puts the reference before the wrapper, because a single shared range start cannot belong to both copies.
  • A range wholly inside the wrapper whose reference sits after it. Each view keeps only half the range.
  • How each wrapper type compares with main: for w:sdt, both shapes behave as on main. For w:customXml, main kept both copies' markers under one duplicated id. The redline is now well formed, but these two shapes lose the range or the reference's position.

Adversarial review

One round of hostile review ran before opening. It confirmed the fix for the block wrapper shapes and for Consolidate, and found three problems, all fixed in the second commit:

  • A regression for inline w:customXml comment ranges. Widening the allow-list had broken both views; it is now limited to block-level wrappers.
  • CHANGELOG wording on what remains broken. It now names both shapes and says how each wrapper type compares with main.
  • Cloned threaded comments. A clone's reply pointed at the original parent, and durable ids were duplicated. Both are now fixed.

It also checked that property-level revisions, moves, nested wrappers, and wholly added or deleted wrappers classify correctly.

JSv4 added 2 commits October 16, 2026 01:37
#1157)

When a comparison replaces a w:sdt or w:customXml wrapper whole and a
comment range sits between the wrapper's blocks, NormalizeComments
treated the bare range markers between blocks as surviving both views
and collapsed the comment to one marker of each kind across the deleted
and inserted copies. Accepting then displaced the comment reference into
a body-level paragraph before the wrapper.

A marker's revision view is now read from its nearest tracked context:
a w:ins/w:del around it, or a block wrapper emitted as a whole deleted
or inserted copy (detected from the customXml range envelope that
StructuredRevisionOps places). Phases A, A2 and B share that classifier,
so such a comment skips the collapse and is deduplicated: the deleted
copy gets a fresh id and a cloned definition. Range markers directly
inside a w:customXml wrapper are now reconciled as well.
…clones (#1157)

Review follow-ups to the whole-replaced-wrapper comment fix:

- Only a block-level w:customXml joins the comment normalizer's run-level
  allow-list. The fine path emits an inline w:customXml whole inside one
  w:ins/w:del, so its markers already toggle with that copy and cannot be
  lifted out; collapsing them broke the range in both views.
- Phase B's threading clones now re-point a cloned reply's paraIdParent at
  its parent's clone when the parent was cloned too, and give each cloned
  commentsIds entry a fresh durableId instead of repeating the original's.
- The CHANGELOG and design doc name the shapes that remain inexact
  (#1167) accurately, including how they differ from before for customXml.
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.

Accepting a whole-wrapper replacement moves the comment reference out of the wrapper

1 participant