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

1 change: 1 addition & 0 deletions packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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 @@ -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",
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 @@ -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 <kbd>Enter</kbd> / <kbd>Space</kbd> ([#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
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 63 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,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 ) {
Comment thread
manzoorwanijk marked this conversation as resolved.
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,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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
);
}
}
}
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 @@ -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' },
},
},
] );
Expand Down Expand Up @@ -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<ins title="Added" class="revision-diff-added"> rewritten</ins>',
__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( [
Expand Down
Loading
Loading