Skip to content
Open
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
23 changes: 21 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- `BlockManager`: Add stacking context isolation to category list ([#77759](https://github.com/WordPress/gutenberg/pull/77759)).

### Internal

- Updated `diff` dependency from `^4.0.2` to `^8.0.3` ([#77992](https://github.com/WordPress/gutenberg/pull/77992)).

## 15.18.0 (2026-04-29)

### Enhancements
Expand Down
2 changes: 1 addition & 1 deletion packages/block-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"clsx": "^2.1.1",
"colord": "^2.7.0",
"deepmerge": "^4.3.0",
"diff": "^4.0.2",
"diff": "^8.0.3",
"fast-deep-equal": "^3.1.3",
"memize": "^2.1.0",
"parsel-js": "^1.1.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
* External dependencies
*/
import clsx from 'clsx';
// diff doesn't tree-shake correctly, so we import from the individual
// module here, to avoid including too much of the library
import { diffChars } from 'diff/lib/diff/character';
import { diffChars } from 'diff';

/**
* WordPress dependencies
Expand Down
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Internal

- Update `date-fns` dependency to `v4.1.0` ([#78057](https://github.com/WordPress/gutenberg/pull/78057)).
- Updated `diff` dependency from `^4.0.2` to `^8.0.3` ([#77992](https://github.com/WordPress/gutenberg/pull/77992)).

## 14.45.0 (2026-04-29)

Expand Down
2 changes: 1 addition & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"clsx": "^2.1.1",
"colord": "^2.7.0",
"date-fns": "^4.1.0",
"diff": "^4.0.2",
"diff": "^8.0.3",
"fast-deep-equal": "^3.1.3",
"memize": "^2.1.0",
"react-autosize-textarea": "^7.1.0",
Expand Down
98 changes: 73 additions & 25 deletions packages/editor/src/components/post-revisions-preview/block-diff.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* External dependencies
*/
import { diffArrays } from 'diff/lib/diff/array';
import { diffWords } from 'diff/lib/diff/word';
/*
* `diffWordsWithSpace` preserves the v4-style per-word output. v6+
* stopped treating whitespace as a token in `diffWords`, which coalesces
* adjacent word changes into a single removed/added pair.
*/
import { diffArrays, diffWordsWithSpace } from 'diff';

/**
* WordPress dependencies
Expand All @@ -28,6 +32,26 @@ import { unlock } from '../../lock-unlock';

const { parseRawBlock } = unlock( blocksPrivateApis );

/**
* Whether a grammar-parsed raw block is a whitespace-only freeform pseudo-block
* (the `\n\n` between block markers, etc). These are stripped from both arrays
* before LCS to keep the matching pivot stable: under `diff` v6's tie-breaker,
* a whitespace block could otherwise be selected as the LCS anchor in
* `[paragraph, whitespace, paragraph]` swaps, mis-pairing the surrounding
* paragraphs in `pairSimilarBlocks`. Whitespace pseudo-blocks don't render
* anyway (`parseRawBlock` returns undefined for them), so dropping them
* before the diff has no user-visible effect.
*
* @param {Object} rawBlock A raw block from `@wordpress/block-serialization-default-parser`.
* @return {boolean} True if the block should be excluded from LCS matching.
*/
function isWhitespaceRawBlock( rawBlock ) {
return (
rawBlock.blockName === null &&
( ! rawBlock.innerHTML || ! rawBlock.innerHTML.trim() )
);
}

/**
* Safely stringifies a value for display and comparison.
*
Expand Down Expand Up @@ -233,27 +257,34 @@ function pairSimilarBlocks( blocks ) {
};

// Decide where to place the modified block by checking
// what's between the removed and added positions.
// If there are unpaired added blocks between them,
// placing at the removed position would put the modified
// block before content that comes before it in the
// current revision — so use the added position.
// Otherwise, use the removed position to keep the
// previous revision's order intact.
// what's between the removed and added positions. If any
// block between them is in the current revision (an
// unchanged block, or an unpaired added block), placing
// the modification at the removed position would put it
// before content that already comes before it in the
// current revision — so use the added position instead.
// Otherwise, use the removed position to keep the previous
// revision's reading order intact.
//
// 'removed' blocks (and added blocks already absorbed via
// `pairedAdded`) aren't checked because they aren't in the
// current revision and so don't count as crossing it.
const lo = Math.min( rem.index, bestMatch.index );
const hi = Math.max( rem.index, bestMatch.index );
let hasAddedBetween = false;
let crossesCurrentContent = false;
for ( let i = lo + 1; i < hi; i++ ) {
if (
blocks[ i ].__revisionDiffStatus?.status === 'added' &&
! pairedAdded.has( i )
) {
hasAddedBetween = true;
const status = blocks[ i ].__revisionDiffStatus?.status;
if ( status === undefined ) {
crossesCurrentContent = true;
break;
}
if ( status === 'added' && ! pairedAdded.has( i ) ) {
crossesCurrentContent = true;
break;
}
}

if ( hasAddedBetween ) {
if ( crossesCurrentContent ) {
// Use the added position — don't jump before
// current-revision content.
modifications.set( bestMatch.index, modifiedBlock );
Expand Down Expand Up @@ -287,11 +318,25 @@ function pairSimilarBlocks( blocks ) {
* Detects modifications when exactly 1 block is removed and 1 is added
* with the same blockName (1:1 replacement = modification).
*
* Whitespace-only freeform pseudo-blocks are filtered at every recursive
* level so this function is safe to call directly with raw output from
* `@wordpress/block-serialization-default-parser`. The duplicate work for
* inner-block recursion is negligible and keeps the contract self-contained.
*
* @param {Array} currentRaw Current revision's raw blocks.
* @param {Array} previousRaw Previous revision's raw blocks.
* @return {Array} Merged raw blocks with diff status injected.
*/
function diffRawBlocks( currentRaw, previousRaw ) {
// Strip whitespace-only freeform pseudo-blocks before LCS — see
// `isWhitespaceRawBlock` for why.
const currentBlocks = currentRaw.filter(
( b ) => ! isWhitespaceRawBlock( b )
);
const previousBlocks = previousRaw.filter(
( b ) => ! isWhitespaceRawBlock( b )
);

const createBlockSignature = ( rawBlock ) =>
JSON.stringify( {
name: rawBlock.blockName,
Expand All @@ -302,8 +347,8 @@ function diffRawBlocks( currentRaw, previousRaw ) {
( c ) => c !== null && c.trim() !== ''
),
} );
const currentSigs = currentRaw.map( createBlockSignature );
const previousSigs = previousRaw.map( createBlockSignature );
const currentSigs = currentBlocks.map( createBlockSignature );
const previousSigs = previousBlocks.map( createBlockSignature );

const diff = diffArrays( previousSigs, currentSigs );

Expand All @@ -315,22 +360,22 @@ function diffRawBlocks( currentRaw, previousRaw ) {
if ( part.added ) {
for ( let i = 0; i < part.count; i++ ) {
result.push( {
...currentRaw[ currIdx++ ],
...currentBlocks[ currIdx++ ],
__revisionDiffStatus: { status: 'added' },
} );
}
} else if ( part.removed ) {
for ( let i = 0; i < part.count; i++ ) {
result.push( {
...previousRaw[ prevIdx++ ],
...previousBlocks[ prevIdx++ ],
__revisionDiffStatus: { status: 'removed' },
} );
}
} else {
// Matched blocks - recursively diff their innerBlocks.
for ( let i = 0; i < part.count; i++ ) {
const currBlock = currentRaw[ currIdx++ ];
const prevBlock = previousRaw[ prevIdx++ ];
const currBlock = currentBlocks[ currIdx++ ];
const prevBlock = previousBlocks[ prevIdx++ ];

// Recursively diff inner blocks.
const diffedInnerBlocks = diffRawBlocks(
Expand Down Expand Up @@ -502,8 +547,8 @@ function applyRichTextDiff( currentRichText, previousRichText ) {
const currentText = currentRichText.toPlainText();
const previousText = previousRichText.toPlainText();

// Diff the plain text (words for cleaner output)
const textDiff = diffWords( previousText, currentText );
// Diff the plain text (words for cleaner output).
const textDiff = diffWordsWithSpace( previousText, currentText );

let result = create( { text: '' } );
let currentIdx = 0;
Expand Down Expand Up @@ -660,7 +705,10 @@ function applyDiffToBlock( currentBlock, previousBlock, diffStatus ) {
previousBlock.attributes[ attrName ]
);
if ( currStr !== prevStr ) {
changedAttributes[ attrName ] = diffWords( prevStr, currStr );
changedAttributes[ attrName ] = diffWordsWithSpace(
prevStr,
currStr
);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { diffArrays } from 'diff/lib/diff/array';
import { diffArrays } from 'diff';

/**
* Preserves clientIds from previously rendered blocks to prevent flashing.
Expand Down
Loading
Loading