Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/2026-07-30-repair-invalid-initial-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Content that does not match the schema is now repaired before the editor mounts, instead of crashing the view. Nodes in an impossible position are unwrapped, wrapped or moved to a parent that allows them, so their text is kept.
56 changes: 56 additions & 0 deletions packages/core/__tests__/onContentError.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,60 @@ describe('onContentError', () => {

expect(editor.getText()).toBe('keepme')
})

// A `doc` inside the `doc` node passes `schema.nodeFromJSON`, so the invalid document used to
// reach the view, which crashes because the `doc` node has no `toDOM`.
const docInsideDoc = {
type: 'doc',
content: [
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'PoC',
},
],
},
],
},
],
}

it('repairs a doc nested inside the doc node instead of crashing the view', () => {
let editor: Editor | undefined

expect(() => {
editor = new Editor({
content: docInsideDoc,
extensions: [Document, Paragraph, Text],
})
}).not.toThrow()

expect(editor?.getText()).toBe('PoC')
expect(() => editor?.state.doc.check()).not.toThrow()
})

it('repairs a doc nested inside the doc node (when enableContentCheck = true)', () => {
let contentErrorCalled = false
let editor: Editor | undefined

expect(() => {
editor = new Editor({
content: docInsideDoc,
extensions: [Document, Paragraph, Text],
enableContentCheck: true,
onContentError: () => {
contentErrorCalled = true
},
})
}).not.toThrow()

expect(contentErrorCalled).toBe(true)
expect(editor?.getText()).toBe('PoC')
expect(() => editor?.state.doc.check()).not.toThrow()
})
})
207 changes: 207 additions & 0 deletions packages/core/__tests__/repairNode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { getSchemaByResolvedExtensions, Node, repairNode } from '@tiptap/core'
import Bold from '@tiptap/extension-bold'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import type { JSONContent } from '@tiptap/core'
import { describe, expect, it } from 'vitest'

// A block node that can only live inside the doc node, like an image or a horizontal rule.
const BlockLeaf = Node.create({
name: 'blockLeaf',
group: 'block',
atom: true,
parseHTML: () => [{ tag: 'hr' }],
renderHTML: () => ['hr'],
})

const schema = getSchemaByResolvedExtensions([Document, Paragraph, Text, Bold, BlockLeaf])

/**
* Repairs the JSON content and makes sure the result matches the schema.
*/
function repairJSON(json: JSONContent) {
const repaired = repairNode(schema.nodeFromJSON(json))

expect(() => repaired?.check()).not.toThrow()

return repaired
}

const paragraphWithText = (text: string) => ({
type: 'paragraph',
content: [{ type: 'text', text }],
})

describe('repairNode', () => {
it('returns the same node for valid content', () => {
const node = schema.nodeFromJSON({
type: 'doc',
content: [paragraphWithText('Example Text')],
})

expect(repairNode(node)).toBe(node)
})

it('unwraps a doc nested inside the doc node', () => {
const repaired = repairJSON({
type: 'doc',
content: [{ type: 'doc', content: [paragraphWithText('PoC')] }],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('PoC')],
})
})

it('unwraps repeatedly nested doc nodes', () => {
const repaired = repairJSON({
type: 'doc',
content: [
{
type: 'doc',
content: [{ type: 'doc', content: [paragraphWithText('PoC')] }],
},
],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('PoC')],
})
})

it('unwraps a paragraph nested inside a paragraph', () => {
const repaired = repairJSON({
type: 'doc',
content: [
{
type: 'paragraph',
content: [paragraphWithText('Example Text')],
},
],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('Example Text')],
})
})

it('wraps text placed directly inside the doc node', () => {
const repaired = repairJSON({
type: 'doc',
content: [{ type: 'text', text: 'Example Text' }],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('Example Text')],
})
})

it('moves a block node out of a paragraph instead of dropping it', () => {
const repaired = repairJSON({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'blockLeaf' }],
},
],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [{ type: 'blockLeaf' }],
})
})

it('keeps the text of a paragraph a block node is moved out of', () => {
const repaired = repairJSON({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Example Text' }, { type: 'blockLeaf' }],
},
],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('Example Text'), { type: 'blockLeaf' }],
})
})

it('adds required children to an empty doc node', () => {
const repaired = repairJSON({ type: 'doc' })

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [{ type: 'paragraph' }],
})
})

it('removes duplicated marks', () => {
const repaired = repairJSON({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
marks: [{ type: 'bold' }, { type: 'bold' }],
},
],
},
],
})

expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example Text',
marks: [{ type: 'bold' }],
},
],
},
],
})
})

it('removes marks the parent node does not allow', () => {
const schemaWithoutMarks = getSchemaByResolvedExtensions([
Document,
Paragraph.extend({ marks: '' }),
Text,
Bold,
])

const repaired = repairNode(
schemaWithoutMarks.nodeFromJSON({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Example Text', marks: [{ type: 'bold' }] }],
},
],
}),
)

expect(() => repaired?.check()).not.toThrow()
expect(repaired?.toJSON()).toEqual({
type: 'doc',
content: [paragraphWithText('Example Text')],
})
})
})
42 changes: 34 additions & 8 deletions packages/core/src/Editor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* oslint-disableno-empty-object-type */
import type { MarkType, Node as ProseMirrorNode, NodeType, Schema } from '@tiptap/pm/model'
import type { MarkType, NodeType, Schema } from '@tiptap/pm/model'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
import { EditorState } from '@tiptap/pm/state'
import { type DirectEditorProps, EditorView } from '@tiptap/pm/view'
Expand Down Expand Up @@ -27,6 +28,7 @@ import { getTextSerializersFromSchema } from './helpers/getTextSerializersFromSc
import { isActive } from './helpers/isActive.js'
import { isNodeEmpty } from './helpers/isNodeEmpty.js'
import { createMappablePosition, getUpdatedPosition } from './helpers/MappablePosition.js'
import { repairNode } from './helpers/repairNode.js'
import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
import type { Storage } from './index.js'
import { NodePos } from './NodePos.js'
Expand Down Expand Up @@ -502,13 +504,10 @@ export class Editor extends EventEmitter<EditorEvents> {
}

// Content is invalid, but attempt to create it anyway, stripping out the invalid parts
const fallbackDoc = createDocument(
this.options.content,
this.schema,
this.options.parseOptions,
{
const fallbackDoc = this.repairInvalidDoc(
createDocument(this.options.content, this.schema, this.options.parseOptions, {
errorOnInvalidContent: false,
},
}),
)

// Seed editorState with the fallback doc so a handler can safely use `editor.commands`
Expand Down Expand Up @@ -541,7 +540,34 @@ export class Editor extends EventEmitter<EditorEvents> {

return this.editorState.doc
}
return doc
return this.repairInvalidDoc(doc)
}

/**
* Rendering a document that does not match the schema crashes the view, and the content is only
* checked when `enableContentCheck` is enabled, so repair it before it reaches ProseMirror.
*/
private repairInvalidDoc(doc: ProseMirrorNode): ProseMirrorNode {
// `createDocument` returns a fragment for array content, which has nothing to check.
if (!(doc instanceof ProseMirrorNode)) {
return doc
}

try {
doc.check()

return doc
} catch (error) {
// The error names the node types that did not fit, so there is no need to log the content.
console.warn(
'[tiptap warn]: Invalid content. The content did not match the schema and was repaired.',
'Error:',
error,
)

// A document that cannot be repaired would still crash the view, so start over empty.
return repairNode(doc) ?? this.schema.topNodeType.createAndFill() ?? doc
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export * from './isNodeViewSelected.js'
export * from './isTextSelection.js'
export * from './MappablePosition.js'
export * from './posToDOMRect.js'
export * from './repairNode.js'
export * from './resolveExtensions.js'
export * from './resolveFocusPosition.js'
export * from './rewriteUnknownContent.js'
Expand Down
Loading
Loading