From 5503f346af6175ee5a73ac66dee0ec2984b3809b Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 15 Jul 2026 13:50:37 -0400 Subject: [PATCH 1/2] fix(block-editor): preserve hard breaks on load for restricted fields Add `hardBreak` to the always-allowed `basicNodes` map so `purifyNodeTree`/`removeInvalidNodes` no longer strips Shift+Enter line breaks when Block Editor content is re-opened for editing on a field configured with allowed-block restrictions (allowedBlocks.length > 1). Also fixes the latent `paragrah` -> `paragraph` typo so the paragraph basic-node fallback works as intended. Adds parser.utils regression coverage. Closes #35985 --- .../src/lib/shared/utils/parser.utils.spec.ts | 125 ++++++++++++++++++ .../src/lib/shared/utils/parser.utils.ts | 6 +- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts diff --git a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts new file mode 100644 index 000000000000..3a3de51bcd3a --- /dev/null +++ b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts @@ -0,0 +1,125 @@ +import { JSONContent } from '@tiptap/core'; + +import { getBlockMap, purifyNodeTree, removeInvalidNodes } from './parser.utils'; + +describe('parser.utils', () => { + describe('getBlockMap', () => { + it('should always include the basic nodes (paragraph, text, doc, hardBreak)', () => { + const map = getBlockMap(['heading']); + + expect(map.paragraph).toBe(true); + expect(map.text).toBe(true); + expect(map.doc).toBe(true); + expect(map.hardBreak).toBe(true); + }); + + it('should expand related content dependencies (e.g. table)', () => { + const map = getBlockMap(['table']); + + expect(map.table).toBe(true); + expect(map.tableRow).toBe(true); + expect(map.tableHeader).toBe(true); + expect(map.tableCell).toBe(true); + }); + + it('should add plain allowed blocks as-is', () => { + const map = getBlockMap(['heading', 'blockquote']); + + expect(map.heading).toBe(true); + expect(map.blockquote).toBe(true); + }); + }); + + describe('purifyNodeTree', () => { + it('should drop nodes whose type is not in the block map', () => { + const content: JSONContent[] = [ + { type: 'paragraph', content: [{ type: 'text', text: 'keep' }] }, + { type: 'blockquote', content: [{ type: 'text', text: 'drop' }] } + ]; + + const result = purifyNodeTree(content, getBlockMap(['paragraph'])); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + }); + + it('should preserve hardBreak nodes nested inside a paragraph', () => { + const content: JSONContent[] = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'line 1' }, + { type: 'hardBreak' }, + { type: 'text', text: 'line 2' } + ] + } + ]; + + const result = purifyNodeTree(content, getBlockMap(['paragraph'])); + + expect(result[0].content).toHaveLength(3); + expect(result[0].content[1].type).toBe('hardBreak'); + }); + }); + + describe('removeInvalidNodes', () => { + // Regression for #35985: hard breaks were stripped when re-opening + // content for editing on a Block Editor field with allowed-block + // restrictions (allowedBlocks.length > 1). + it('should keep hardBreak nodes on a restricted-blocks field', () => { + const allowedBlocks = ['heading', 'paragraph', 'orderedList']; + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: '123 Main St' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Suite 100' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Springfield' } + ] + } + ] + }; + + const result = removeInvalidNodes(content, allowedBlocks); + + const paragraph = result[0]; + expect(paragraph.type).toBe('paragraph'); + + const hardBreaks = paragraph.content.filter((node) => node.type === 'hardBreak'); + expect(hardBreaks).toHaveLength(2); + }); + + it('should keep paragraph nodes via the basic-node fallback even when not in allowedBlocks', () => { + const allowedBlocks = ['heading', 'orderedList']; + const content: JSONContent = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'plain text' }] }] + }; + + const result = removeInvalidNodes(content, allowedBlocks); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + }); + + it('should still strip nodes that are neither basic nor allowed', () => { + const allowedBlocks = ['heading', 'paragraph']; + const content: JSONContent = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'keep' }] }, + { type: 'codeBlock', content: [{ type: 'text', text: 'drop' }] } + ] + }; + + const result = removeInvalidNodes(content, allowedBlocks); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + }); + }); +}); diff --git a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts index 1cff0e148c02..f94bfbac5fe0 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts @@ -32,7 +32,11 @@ const video: BlockMap = { youtube: true }; -const basicNodes: BlockMap = { paragrah: true, text: true, doc: true }; +// Nodes that are always allowed regardless of the field's allowed-block +// restrictions. `hardBreak` (Shift+Enter line break) must live here so +// `purifyNodeTree`/`removeInvalidNodes` never strips it when content is +// re-opened for editing on a restricted field. +const basicNodes: BlockMap = { paragraph: true, text: true, doc: true, hardBreak: true }; const gridContent: BlockMap = { gridBlock: true, From ac5bd5b92c5f274ece066679a5864103eeecbc29 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 15 Jul 2026 16:14:09 -0400 Subject: [PATCH 2/2] refactor(block-editor): use NodeTypes enum in basicNodes + cover isHeading/array paths Address code review on #35985 fix: - Reference NodeTypes.PARAGRAPH/TEXT/DOC/HARD_BREAK instead of string literals in basicNodes (prevents the typo class that caused the bug). - Add parser.utils tests for the isHeading composite-key branch and the bare-array input branch of removeInvalidNodes. --- .../src/lib/shared/utils/parser.utils.spec.ts | 41 +++++++++++++++++++ .../src/lib/shared/utils/parser.utils.ts | 9 +++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts index 3a3de51bcd3a..27de53b4778f 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.spec.ts @@ -60,6 +60,27 @@ describe('parser.utils', () => { expect(result[0].content).toHaveLength(3); expect(result[0].content[1].type).toBe('hardBreak'); }); + + it('should keep a heading whose composite level key is allowed', () => { + const content: JSONContent[] = [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'title' }] } + ]; + + const result = purifyNodeTree(content, getBlockMap(['heading2'])); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('heading'); + }); + + it('should strip a heading whose level is not in the allowed composite keys', () => { + const content: JSONContent[] = [ + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'title' }] } + ]; + + const result = purifyNodeTree(content, getBlockMap(['heading2'])); + + expect(result).toHaveLength(0); + }); }); describe('removeInvalidNodes', () => { @@ -93,6 +114,26 @@ describe('parser.utils', () => { expect(hardBreaks).toHaveLength(2); }); + it('should accept a bare JSONContent array as input (not just a doc node)', () => { + const allowedBlocks = ['heading', 'paragraph']; + const content: JSONContent[] = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'line 1' }, + { type: 'hardBreak' }, + { type: 'text', text: 'line 2' } + ] + } + ]; + + const result = removeInvalidNodes(content, allowedBlocks); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content.filter((node) => node.type === 'hardBreak')).toHaveLength(1); + }); + it('should keep paragraph nodes via the basic-node fallback even when not in allowedBlocks', () => { const allowedBlocks = ['heading', 'orderedList']; const content: JSONContent = { diff --git a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts index f94bfbac5fe0..dd76e469075c 100644 --- a/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts +++ b/core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts @@ -1,5 +1,7 @@ import { Content, JSONContent } from '@tiptap/core'; +import { NodeTypes } from './constants.utils'; + interface BlockMap { [key: string]: boolean; } @@ -36,7 +38,12 @@ const video: BlockMap = { // restrictions. `hardBreak` (Shift+Enter line break) must live here so // `purifyNodeTree`/`removeInvalidNodes` never strips it when content is // re-opened for editing on a restricted field. -const basicNodes: BlockMap = { paragraph: true, text: true, doc: true, hardBreak: true }; +const basicNodes: BlockMap = { + [NodeTypes.PARAGRAPH]: true, + [NodeTypes.TEXT]: true, + [NodeTypes.DOC]: true, + [NodeTypes.HARD_BREAK]: true +}; const gridContent: BlockMap = { gridBlock: true,