diff --git a/package-lock.json b/package-lock.json index bc72305e9c5521..27bd582f0b85f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30255,6 +30255,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" } @@ -59394,7 +59395,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", @@ -59418,6 +59419,15 @@ "react-dom": "^18.0.0" } }, + "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", @@ -61317,7 +61327,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", @@ -61336,6 +61346,15 @@ "react-dom": "^18.0.0" } }, + "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/element": { "name": "@wordpress/element", "version": "6.46.0", diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 576b0fbe61596e..3eed269ecc39fd 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -9,6 +9,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 c2d5b90b5628fc..4fd6808677ed0b 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -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", 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 444db48bdd0b63..a7130e45677a46 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -10,6 +10,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. +### 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 348d55028c0f37..c2cf981aa34db9 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -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", 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..96b32a7cafff19 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,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, @@ -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 ); @@ -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( @@ -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; @@ -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 + ); } } } 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..a53873d69898ac 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 @@ -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', () => { @@ -441,6 +452,100 @@ 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 ); + const normalized = normalizeBlockTree( blocks ); + + expect( normalized ).toHaveLength( 3 ); + expect( normalized[ 0 ].attributes.__revisionDiffStatus?.status ).toBe( + 'modified' + ); + expect( normalized[ 1 ].attributes.content ).toBe( + 'Stays one anchor sentence' + ); + expect( + normalized[ 1 ].attributes.__revisionDiffStatus + ).toBeUndefined(); + expect( normalized[ 2 ].attributes.content ).toBe( + 'Stays two anchor sentence' + ); + expect( + normalized[ 2 ].attributes.__revisionDiffStatus + ).toBeUndefined(); + } ); + 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 ) {