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
4 changes: 4 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Internal

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

## 14.44.0 (2026-04-15)
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": "^3.6.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
78 changes: 59 additions & 19 deletions packages/editor/src/components/post-revisions-preview/block-diff.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/**
* External dependencies
*/
import { diffArrays } from 'diff/lib/diff/array';
import { diffWords } from 'diff/lib/diff/word';
/*
* `diffWordsWithSpace` keeps whitespace as its own token, matching the
* behaviour `diffWords` had in `diff` v4. v6+ stopped treating whitespace
* as a token, which would otherwise coalesce adjacent word changes into a
* single removed/added pair instead of reporting them per-word.
*/
import { diffArrays, diffWordsWithSpace } from 'diff';

/**
* WordPress dependencies
Expand All @@ -28,6 +33,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 @@ -234,26 +259,29 @@ 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,
// If anything between them is part of the current revision
// (an unpaired added block, or an unchanged block),
// 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.
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 @@ -292,6 +320,15 @@ function pairSimilarBlocks( 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 +339,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 +352,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 +539,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 +697,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
Original file line number Diff line number Diff line change
Expand Up @@ -338,34 +338,45 @@ describe( 'diffRevisionContent', () => {
createBlock( 'core/paragraph', { content: 'First block content' } ),
] );
const blocks = diffRevisionContent( current, previous );
const normalized = normalizeBlockTree( blocks );

// LCS matches one block ("First block content" at prev[0] -> curr[1]).
// The other block appears as removed + added (showing the reorder).
// We intentionally don't pair identical blocks as "modified" since
// there's no actual content change - just a position change.
expect( normalizeBlockTree( blocks ) ).toMatchObject( [
{
name: 'core/paragraph',
attributes: {
content: 'Second block content',
__revisionDiffStatus: { status: 'added' },
},
},
{
name: 'core/paragraph',
attributes: {
content: 'First block content',
__revisionDiffStatus: undefined,
},
},
{
name: 'core/paragraph',
attributes: {
content: 'Second block content',
__revisionDiffStatus: { status: 'removed' },
},
},
] );
/*
* For a pure swap, LCS has two equally-valid choices for the
* "unchanged" anchor — either block could be the anchor while the
* other reads as removed+added. The choice is implementation-
* defined (it differs across `diff` library versions, for
* instance), so we assert the user-facing invariant rather than
* which side gets matched: exactly one block stays unmarked, the
* other shows up as a removed/added pair with the same content
* (a position change, not a modification).
*/
const statuses = normalized.map(
( b ) => b.attributes.__revisionDiffStatus?.status
);
const unchanged = normalized.filter(
( _, i ) => statuses[ i ] === undefined
);
const added = normalized.filter(
( _, i ) => statuses[ i ] === 'added'
);
const removed = normalized.filter(
( _, i ) => statuses[ i ] === 'removed'
);

expect( normalized ).toHaveLength( 3 );
expect( unchanged ).toHaveLength( 1 );
expect( added ).toHaveLength( 1 );
expect( removed ).toHaveLength( 1 );

expect( added[ 0 ].attributes.content ).toBe(
removed[ 0 ].attributes.content
);
expect( unchanged[ 0 ].attributes.content ).not.toBe(
added[ 0 ].attributes.content
);
expect( [ 'First block content', 'Second block content' ] ).toContain(
unchanged[ 0 ].attributes.content
);
} );

it( 'pairs blocks as modified when attrs differ but content is identical', () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/editor/src/components/revision-fields-diff/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* External dependencies
*/
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 { diffWordsWithSpace } from 'diff';

/**
* WordPress dependencies
Expand Down Expand Up @@ -71,7 +76,7 @@ export default function RevisionFieldsDiffPanel() {
continue;
}

result[ key ] = diffWords( prevStr, revStr );
result[ key ] = diffWordsWithSpace( prevStr, revStr );
}

if ( Object.keys( result ).length === 0 ) {
Expand Down
Loading