Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Node as ProseMirrorNode, NodeType } from 'prosemirror-model';
import type { Editor } from '../core/Editor.js';
import { v4 as uuidv4 } from 'uuid';
import { NON_SEMANTIC_BLOCK_ATTRS } from '../extensions/diffing/algorithm/identity-attrs.js';
import type {
CreateTableInput,
CreateTableResult,
Expand Down Expand Up @@ -2944,26 +2945,11 @@ function buildSetCellTextParagraph(
}

/**
* Identity attrs that are auto-generated by appendTransaction hooks (block
* IDs, paragraph IDs, revision counters) or carried in from the source XML
* by the importer (`w:rsid*` revision IDs). They differ between an original
* and a freshly-rewritten paragraph even when the visible content is
* identical, so the NO_OP comparator must ignore them.
* Identity attrs that the cell-content NO_OP comparator must ignore: auto-
* generated session block IDs and OOXML revision IDs. Same list used by
* the diff fingerprint normalization (`NON_SEMANTIC_BLOCK_ATTRS`).
*/
const IDENTITY_BLOCK_ATTRS = new Set([
'sdBlockId',
'sdBlockRev',
'paraId',
'textId',
// Imported revision IDs (paragraph.js:136-140) - identical visible
// content can have different rsid* values when the source paragraph
// was imported from a real DOCX.
'rsidR',
'rsidRDefault',
'rsidP',
'rsidRPr',
'rsidDel',
]);
const IDENTITY_BLOCK_ATTRS = NON_SEMANTIC_BLOCK_ATTRS;

/**
* Compare two cell-content fragments while ignoring auto-generated block
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getAttributesDiff, getMarksDiff } from './attributes-diffing.ts';
import { applyAttrsDiff, getAttributesDiff, getMarksDiff } from './attributes-diffing.ts';

describe('getAttributesDiff', () => {
it('detects nested additions, deletions, and modifications', () => {
Expand Down Expand Up @@ -165,6 +165,83 @@ describe('getAttributesDiff', () => {
});
});

describe('applyAttrsDiff', () => {
// SD-3279: replay must not overwrite recipient's session-local identity
// attrs. The diff carries only the semantic delta (no sdBlockId path);
// applyAttrsDiff must preserve the recipient's sdBlockId untouched.
it('preserves recipient attrs that the diff does not mention', () => {
const recipientAttrs = { sdBlockId: 'recipient-uuid', sdBlockRev: 7, textAlign: 'left' };
const diff = {
added: {},
deleted: {},
modified: { textAlign: { from: 'left', to: 'right' } },
};

const merged = applyAttrsDiff(recipientAttrs, diff);

expect(merged).toEqual({ sdBlockId: 'recipient-uuid', sdBlockRev: 7, textAlign: 'right' });
});

it('applies added paths, including nested dotted paths', () => {
const merged = applyAttrsDiff(
{ existing: 1 },
{
added: { 'nested.deep.value': 42, topLevel: 'added' },
deleted: {},
modified: {},
},
);

expect(merged).toEqual({
existing: 1,
topLevel: 'added',
nested: { deep: { value: 42 } },
});
});

it('removes deleted paths and keeps siblings', () => {
const merged = applyAttrsDiff(
{ keep: 'me', drop: 'this', nested: { keep: 1, drop: 2 } },
{
added: {},
deleted: { drop: 'this', 'nested.drop': 2 },
modified: {},
},
);

expect(merged).toEqual({ keep: 'me', nested: { keep: 1 } });
});

it('does not mutate the input base attrs', () => {
const base = { sdBlockId: 'recipient', nested: { value: 1 } };
applyAttrsDiff(base, {
added: { 'nested.added': 2 },
deleted: {},
modified: { 'nested.value': { from: 1, to: 9 } },
});

expect(base).toEqual({ sdBlockId: 'recipient', nested: { value: 1 } });
});

it('returns a clone of base when diff is null', () => {
const base = { sdBlockId: 'recipient', textAlign: 'left' };
const merged = applyAttrsDiff(base, null);

expect(merged).toEqual(base);
expect(merged).not.toBe(base);
});

it('tolerates null base attrs', () => {
const merged = applyAttrsDiff(null, {
added: { textAlign: 'right' },
deleted: {},
modified: {},
});

expect(merged).toEqual({ textAlign: 'right' });
});
});

describe('getMarksDiff', () => {
it('detects mark additions, deletions, and modifications', () => {
const marksA = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,99 @@ function joinPath(base: string, key: string): string {
return base ? `${base}.${key}` : key;
}

/**
* Applies an {@link AttributesDiff} onto a base set of attributes, returning a
* new object where:
* - `added` paths are written
* - `modified` paths overwrite the existing value with the `to` value
* - `deleted` paths are removed
* Any key not referenced by the diff is preserved unchanged.
*
* Used at replay time so cross-editor diff apply does not overwrite the
* recipient editor's session-local identity attrs (sdBlockId, sdBlockRev) with
* the originator's values. See SD-3279.
*
* @param baseAttrs Recipient node's current attrs (preserved when not in diff).
* @param attrsDiff Diff to apply. When null, returns a shallow copy of base.
* @returns New attrs object with the diff layered onto base.
*/
export function applyAttrsDiff(
baseAttrs: Record<string, unknown> | null | undefined,
attrsDiff: AttributesDiff | null | undefined,
): Record<string, unknown> {
const result = cloneAttrs(baseAttrs ?? {});
if (!attrsDiff) {
return result;
}
for (const [path, value] of Object.entries(attrsDiff.added ?? {})) {
setAtPath(result, path, value);
}
for (const [path, change] of Object.entries(attrsDiff.modified ?? {})) {
setAtPath(result, path, change.to);
}
for (const path of Object.keys(attrsDiff.deleted ?? {})) {
deleteAtPath(result, path);
}
return result;
}

function cloneAttrs(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) {
return {};
}
const clone: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
clone[key] = cloneDeep(child);
}
return clone;
}

function cloneDeep(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(cloneDeep);
}
if (isPlainObject(value)) {
const clone: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
clone[key] = cloneDeep(child);
}
return clone;
}
return value;
}

function splitPath(path: string): string[] {
return path.split('.');
}

function setAtPath(target: Record<string, unknown>, path: string, value: unknown): void {
const segments = splitPath(path);
let cursor: Record<string, unknown> = target;
for (let i = 0; i < segments.length - 1; i++) {
const key = segments[i];
const next = cursor[key];
if (!isPlainObject(next)) {
cursor[key] = {};
}
cursor = cursor[key] as Record<string, unknown>;
}
cursor[segments[segments.length - 1]] = cloneDeep(value);
}

function deleteAtPath(target: Record<string, unknown>, path: string): void {
const segments = splitPath(path);
let cursor: Record<string, unknown> = target;
for (let i = 0; i < segments.length - 1; i++) {
const key = segments[i];
const next = cursor[key];
if (!isPlainObject(next)) {
return;
}
cursor = next;
}
delete cursor[segments[segments.length - 1]];
}

/**
* Determines if a value is a plain object (no arrays or nulls).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import {
import { diffSequences, reorderDiffOperations } from './sequence-diffing';
import { getAttributesDiff, type AttributesDiff } from './attributes-diffing';
import { getInsertionPos, type NodePositionInfo } from './diff-utils';
import { NON_SEMANTIC_BLOCK_ATTRS } from './identity-attrs';

// Non-paragraph block-node attr diffing must ignore session-local identity
// attrs. Otherwise a cross-editor diff carries the originator's sdBlockId in
// the `modified` paths and replay overwrites the recipient's ID. Paragraphs
// already strip these upstream via normalizeParagraphAttrs. See SD-3279.
const NON_PARAGRAPH_BLOCK_IGNORED_ATTRS: string[] = Array.from(NON_SEMANTIC_BLOCK_ATTRS);

type NodeJSON = ReturnType<PMNode['toJSON']>;

Expand Down Expand Up @@ -220,7 +227,11 @@ function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDi
return buildModifiedParagraphDiff(oldNodeInfo, newNodeInfo);
}

const attrsDiff = getAttributesDiff(oldNodeInfo.node.attrs, newNodeInfo.node.attrs);
const attrsDiff = getAttributesDiff(
oldNodeInfo.node.attrs,
newNodeInfo.node.attrs,
NON_PARAGRAPH_BLOCK_IGNORED_ATTRS,
);
if (!attrsDiff) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* OOXML/import identity and revision fields that should not affect visible
* diff semantics.
*
* `sdBlockId` and `sdBlockRev` are assigned by the block-node plugin and are
* session-local — two editor instances loaded from the same DOCX hold
* different values. `paraId`, `textId`, and the `rsid*` family are import-side
* identity/revision fields whose values can differ across copies of the same
* visible content.
*
* The diff fingerprint must be stable against changes to these attrs, otherwise
* `diff.apply` across two editor instances rejects with `PRECONDITION_FAILED`
* even when both editors hold the same document content. See SD-3279.
*/
export const NON_SEMANTIC_BLOCK_ATTRS = new Set<string>([
'sdBlockId',
'sdBlockRev',
'paraId',
'textId',
'rsidR',
'rsidRDefault',
'rsidP',
'rsidRPr',
'rsidDel',
]);
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ describe('normalizeParagraphAttrs', () => {
rsidP: '00112233',
rsidRPr: '00445566',
rsidDel: '00778899',
sdBlockId: 'session-local-uuid-1',
sdBlockRev: 7,
align: 'center',
indent: { left: 720 },
};
Expand Down Expand Up @@ -266,6 +268,69 @@ describe('normalizeDocJSON', () => {
expect(cellParagraph.attrs).toEqual({});
});

it('strips identity attrs from non-paragraph block nodes (tables, rows, cells, sections)', () => {
// sdBlockId is assigned per editor session to every block node that
// accepts it (paragraphs, tables, rows, cells, sections). Including it
// in the diff fingerprint makes two editors loaded from the same DOCX
// produce different fingerprints.
const docJSON = {
type: 'doc',
content: [
{
type: 'table',
attrs: { sdBlockId: 'table-uuid-A', sdBlockRev: 1, tableStyleId: 'Grid' },
content: [
{
type: 'tableRow',
attrs: { sdBlockId: 'row-uuid-A', sdBlockRev: 2 },
content: [
{
type: 'tableCell',
attrs: { sdBlockId: 'cell-uuid-A', sdBlockRev: 3, cellWidth: 100 },
content: [
{
type: 'paragraph',
attrs: { sdBlockId: 'p-uuid-A', sdBlockRev: 4, align: 'left' },
},
],
},
],
},
],
},
],
};

const result = normalizeDocJSON(docJSON) as any;

expect(result.content[0].attrs).toEqual({ tableStyleId: 'Grid' });
expect(result.content[0].content[0].attrs).toEqual({});
expect(result.content[0].content[0].content[0].attrs).toEqual({ cellWidth: 100 });
expect(result.content[0].content[0].content[0].content[0].attrs).toEqual({ align: 'left' });
});

it('produces the same normalized output for two doc trees that differ only in sdBlockId values', () => {
const makeDoc = (uuidA: string, uuidB: string) => ({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { sdBlockId: uuidA, align: 'left' },
content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: 'Hello' }] }],
},
{
type: 'table',
attrs: { sdBlockId: uuidB, tableStyleId: 'Grid' },
content: [],
},
],
});

const a = normalizeDocJSON(makeDoc('uuid-A1', 'uuid-A2'));
const b = normalizeDocJSON(makeDoc('uuid-B1', 'uuid-B2'));
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});

it('returns the doc unchanged when there is no content', () => {
const docJSON = { type: 'doc' };
expect(normalizeDocJSON(docJSON)).toEqual(docJSON);
Expand Down
Loading
Loading