diff --git a/package-lock.json b/package-lock.json
index e1eafa6ec6b617..0fd5258aa2719c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30232,6 +30232,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "dev": true,
"engines": {
"node": ">=0.3.1"
}
@@ -59451,7 +59452,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",
@@ -59521,6 +59522,15 @@
"dequal": "^2.0.3"
}
},
+ "packages/block-editor/node_modules/diff": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+ "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"packages/block-editor/node_modules/postcss-urlrebase": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/postcss-urlrebase/-/postcss-urlrebase-1.4.0.tgz",
@@ -62074,7 +62084,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",
@@ -62139,6 +62149,15 @@
"dequal": "^2.0.3"
}
},
+ "packages/editor/node_modules/diff": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+ "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"packages/editor/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md
index 3169bc788d3b80..d190cba33faaa0 100644
--- a/packages/block-editor/CHANGELOG.md
+++ b/packages/block-editor/CHANGELOG.md
@@ -23,6 +23,7 @@
### Internal
- Remove legacy `Notice` overrides in block placeholder notices and media replace flow error UI ([#78231](https://github.com/WordPress/gutenberg/pull/78231)).
+- Updated `diff` dependency from `^4.0.2` to `^8.0.3` ([#77992](https://github.com/WordPress/gutenberg/pull/77992)).
## 15.19.0 (2026-05-14)
diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json
index 3be3c0af91f0b2..f86034c6a6de53 100644
--- a/packages/block-editor/package.json
+++ b/packages/block-editor/package.json
@@ -103,7 +103,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",
diff --git a/packages/block-editor/src/components/block-compare/index.js b/packages/block-editor/src/components/block-compare/index.js
index 4b9b6db596864e..6e44b5c133f1ba 100644
--- a/packages/block-editor/src/components/block-compare/index.js
+++ b/packages/block-editor/src/components/block-compare/index.js
@@ -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
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index 9620c04aa04388..998f1aceabf566 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -27,6 +27,10 @@
- `mediaFinalize` now returns the post-finalize attachment (transformed from the REST response), so the upload-media queue can refresh the in-flight attachment URL. Required for the front-end `srcset` to render on client-side-media uploads that exceeded the big-image threshold.
- Template actions panel: Fix the keyboard activation of the "Change template" preview so it only opens the swap modal on Enter / Space ([#78641](https://github.com/WordPress/gutenberg/pull/78641)).
+### Internal
+
+- Updated `diff` dependency from `^4.0.2` to `^8.0.3` ([#77992](https://github.com/WordPress/gutenberg/pull/77992)).
+
## 14.46.0 (2026-05-14)
### Internal
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 1a7ab506907751..cebf24a47f1a4f 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -110,7 +110,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",
diff --git a/packages/editor/src/components/post-revisions-preview/block-diff.js b/packages/editor/src/components/post-revisions-preview/block-diff.js
index 96657837530341..7c57cc6b0976b1 100644
--- a/packages/editor/src/components/post-revisions-preview/block-diff.js
+++ b/packages/editor/src/components/post-revisions-preview/block-diff.js
@@ -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
@@ -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.
*
@@ -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 );
@@ -287,11 +318,21 @@ 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.
+ currentRaw = currentRaw.filter( ( b ) => ! isWhitespaceRawBlock( b ) );
+ previousRaw = previousRaw.filter( ( b ) => ! isWhitespaceRawBlock( b ) );
+
const createBlockSignature = ( rawBlock ) =>
JSON.stringify( {
name: rawBlock.blockName,
@@ -502,8 +543,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;
@@ -660,7 +701,10 @@ function applyDiffToBlock( currentBlock, previousBlock, diffStatus ) {
previousBlock.attributes[ attrName ]
);
if ( currStr !== prevStr ) {
- changedAttributes[ attrName ] = diffWords( prevStr, currStr );
+ changedAttributes[ attrName ] = diffWordsWithSpace(
+ prevStr,
+ currStr
+ );
}
}
}
diff --git a/packages/editor/src/components/post-revisions-preview/preserve-client-ids.js b/packages/editor/src/components/post-revisions-preview/preserve-client-ids.js
index eb907f6281b7f7..b57acf17f0321a 100644
--- a/packages/editor/src/components/post-revisions-preview/preserve-client-ids.js
+++ b/packages/editor/src/components/post-revisions-preview/preserve-client-ids.js
@@ -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.
diff --git a/packages/editor/src/components/post-revisions-preview/test/block-diff.js b/packages/editor/src/components/post-revisions-preview/test/block-diff.js
index a37708015b2d7f..71f9a4683b02b0 100644
--- a/packages/editor/src/components/post-revisions-preview/test/block-diff.js
+++ b/packages/editor/src/components/post-revisions-preview/test/block-diff.js
@@ -339,30 +339,32 @@ describe( 'diffRevisionContent', () => {
] );
const blocks = diffRevisionContent( current, previous );
- // LCS matches one block ("First block content" at prev[0] -> curr[1]).
+ // LCS matches one block ("Second block content" at prev[1] -> curr[0]).
// 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.
+ // (Pre-v8, LCS matched the other block. Both are equally-valid
+ // choices for a pure swap.)
expect( normalizeBlockTree( blocks ) ).toMatchObject( [
{
name: 'core/paragraph',
attributes: {
- content: 'Second block content',
- __revisionDiffStatus: { status: 'added' },
+ content: 'First block content',
+ __revisionDiffStatus: { status: 'removed' },
},
},
{
name: 'core/paragraph',
attributes: {
- content: 'First block content',
+ content: 'Second block content',
__revisionDiffStatus: undefined,
},
},
{
name: 'core/paragraph',
attributes: {
- content: 'Second block content',
- __revisionDiffStatus: { status: 'removed' },
+ content: 'First block content',
+ __revisionDiffStatus: { status: 'added' },
},
},
] );
@@ -441,6 +443,107 @@ describe( 'diffRevisionContent', () => {
] );
} );
+ it( 'filters whitespace-only freeform pseudo-blocks before LCS', () => {
+ /*
+ * Direct canary for the whitespace-pseudo-block filter in
+ * `diffRawBlocks`. The grammar parser emits
+ * `{ blockName: null, innerHTML: '\n\n' }` for the whitespace
+ * between block markers; under `diff` v6+'s LCS tie-breaker,
+ * those pseudo-blocks would otherwise be selected as the match
+ * anchor in [paragraph, whitespace, paragraph] swaps, leaving
+ * `pairSimilarBlocks` with two removed and two added paragraphs
+ * to mis-match by similarity. With the filter, the LCS picks a
+ * content block and the surrounding paragraphs pair cleanly.
+ */
+ const previous = serialize( [
+ createBlock( 'core/paragraph', { content: 'Alpha content' } ),
+ createBlock( 'core/paragraph', { content: 'Beta content' } ),
+ ] );
+ const current = serialize( [
+ createBlock( 'core/paragraph', {
+ content: 'Beta content modified',
+ } ),
+ createBlock( 'core/paragraph', { content: 'Alpha content' } ),
+ ] );
+ const blocks = diffRevisionContent( current, previous );
+ const normalized = normalizeBlockTree( blocks );
+
+ const statuses = normalized.map(
+ ( b ) => b.attributes.__revisionDiffStatus?.status
+ );
+ // Exactly one modified pair and one unchanged anchor — not the
+ // double-modified mis-pair that the unfiltered LCS would yield.
+ expect( statuses.filter( ( s ) => s === 'modified' ) ).toHaveLength(
+ 1
+ );
+ expect( statuses.filter( ( s ) => s === undefined ) ).toHaveLength( 1 );
+
+ const unchanged = normalized.find(
+ ( b ) => b.attributes.__revisionDiffStatus === undefined
+ );
+ expect( unchanged.attributes.content ).toBe( 'Alpha content' );
+ } );
+
+ it( 'places paired modification at current-revision position when only unchanged blocks sit between', () => {
+ /*
+ * Direct canary for the `crossesCurrentContent` "unchanged
+ * between removed and added" branch. The modified block crosses
+ * two unchanged paragraphs; the placement heuristic should
+ * anchor it at its current-revision position (index 0), not at
+ * the removed position (index 3) — otherwise the modified block
+ * would render after content that already comes before it in
+ * the current revision.
+ */
+ const previous = serialize( [
+ createBlock( 'core/paragraph', {
+ content: 'Stays one anchor sentence',
+ } ),
+ createBlock( 'core/paragraph', {
+ content: 'Stays two anchor sentence',
+ } ),
+ createBlock( 'core/paragraph', {
+ content: 'Original tail content sentence',
+ } ),
+ ] );
+ const current = serialize( [
+ createBlock( 'core/paragraph', {
+ content: 'Original tail content sentence rewritten',
+ } ),
+ createBlock( 'core/paragraph', {
+ content: 'Stays one anchor sentence',
+ } ),
+ createBlock( 'core/paragraph', {
+ content: 'Stays two anchor sentence',
+ } ),
+ ] );
+ const blocks = diffRevisionContent( current, previous );
+
+ expect( normalizeBlockTree( blocks ) ).toMatchObject( [
+ {
+ name: 'core/paragraph',
+ attributes: {
+ content:
+ 'Original tail content sentence rewritten',
+ __revisionDiffStatus: { status: 'modified' },
+ },
+ },
+ {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'Stays one anchor sentence',
+ __revisionDiffStatus: undefined,
+ },
+ },
+ {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'Stays two anchor sentence',
+ __revisionDiffStatus: undefined,
+ },
+ },
+ ] );
+ } );
+
describe( 'inner blocks', () => {
it( 'handles deeply nested inner blocks', () => {
const previous = serialize( [
diff --git a/packages/editor/src/components/revision-fields-diff/index.js b/packages/editor/src/components/revision-fields-diff/index.js
index 2f978ae5523547..5b34d5e017342f 100644
--- a/packages/editor/src/components/revision-fields-diff/index.js
+++ b/packages/editor/src/components/revision-fields-diff/index.js
@@ -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
@@ -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 ) {