diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md index 76dfb04c5d65c9..902f1c7fa5fe81 100644 --- a/packages/element/CHANGELOG.md +++ b/packages/element/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- `createInterpolateElement`: Warn and stop interpolating instead of throwing when an unmatched closing tag is present ([#60843](https://github.com/WordPress/gutenberg/issues/60843)). + ## 8.3.0 (2026-07-14) ## 8.2.0 (2026-07-01) diff --git a/packages/element/src/create-interpolate-element.ts b/packages/element/src/create-interpolate-element.ts index 13763575c32faa..f75336e749c527 100644 --- a/packages/element/src/create-interpolate-element.ts +++ b/packages/element/src/create-interpolate-element.ts @@ -252,6 +252,19 @@ function proceed( conversionMap: Record< string, ReactElement > ): boolean { return true; case 'closer': + // A closing tag with no matching opening tag on the stack is + // invalid. Warn and bail, keeping anything interpolated so far. + if ( 0 === stackDepth ) { + if ( globalThis.SCRIPT_DEBUG ) { + // eslint-disable-next-line no-console + console.warn( + `Unmatched closing tag '' in createInterpolateElement. The rest of the string was not interpolated.` + ); + } + addText(); + return false; + } + // If we're not nesting then this is easy - close the block. if ( 1 === stackDepth ) { closeOuterElement( startOffset ); diff --git a/packages/element/src/test/create-interpolate-element.tsx b/packages/element/src/test/create-interpolate-element.tsx index 0bdb8c61f6243a..dbfa7a71160dd5 100644 --- a/packages/element/src/test/create-interpolate-element.tsx +++ b/packages/element/src/test/create-interpolate-element.tsx @@ -73,6 +73,38 @@ describe( 'createInterpolateElement', () => { createInterpolateElement( testString, { 'spaced token': } ) ).toEqual( expectedElement ); } ); + it( 'warns and stops interpolating when there is an unmatched closing tag', () => { + const testString = 'This is a string'; + const expectedElement = <>{ testString }; + expect( + createInterpolateElement( testString, { item: } ) + ).toEqual( expectedElement ); + expect( console ).toHaveWarnedWith( + `Unmatched closing tag '' in createInterpolateElement. The rest of the string was not interpolated.` + ); + + // Tags matched before the unmatched closing tag stay interpolated, so + // only the remainder of the string is returned as text. + const partialString = 'This is a styled and string'; + const partialExpectedElement = createElement( + Fragment, + null, + 'This is a ', + createElement( 'em', null, 'styled' ), + ' and string' + ); + expect( + JSON.stringify( + createInterpolateElement( partialString, { + em: , + strong: , + } ) + ) + ).toEqual( JSON.stringify( partialExpectedElement ) ); + expect( console ).toHaveWarnedWith( + `Unmatched closing tag '' in createInterpolateElement. The rest of the string was not interpolated.` + ); + } ); it( 'returns expected react element for non nested components', () => { const testString = 'This is a string with a link.'; const expectedElement = createElement(