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
5 changes: 5 additions & 0 deletions .changeset/2026-08-14-fix-jsx-child-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Keep mixed JSX children as separate siblings in DOM output.
64 changes: 60 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Published packages live in `packages/*`. `demos/` is a Vite app used as playgrou
- Add a changeset for user-facing changes. Public API breaks need a major bump and migration notes.
- Add or update a demo and tests for user-visible behavior. Prefer unit tests over e2e when deterministic.
- Fix fallow findings your change introduced. Don't suppress them.
- Comments should be clear, concise and use plain, simple english.
- Rather use a oneliner
- Try to always be intent-focused
- stay local with comments
- scannable, no long or complex wording
- assume the reader doesn't know what you're talking about

## Before opening a PR

Expand Down Expand Up @@ -54,10 +60,60 @@ decorations.filter(d => d.visible)

### Comments

- Comment only when the reason is not visible in the code. Never restate what the lines below already say.
- Two lines max. Only genuinely complex or hard to follow code earns more.
- Say why, not what: `// We keep the old value because the transaction may be reverted.`
- JSDoc on public APIs with `@param`, `@returns` and a runnable example. Those examples generate our API docs.
Comments are a last resort. Prefer code that explains itself through clear names, structure, and small functions.

* Default to **no comment**.
* Comment only when the **reason for a decision is not apparent from the code**.
* Keep comments **short, local, and intent-focused**.
* Prefer a short one-line fragment or sentence.
* Never restate what the code does.
* Never narrate control flow.
* Never explain surrounding architecture, history, edge cases, or implementation details unless they are essential to understanding the decision.
* Never use comments as a substitute for clearer code, naming, or structure.
* Do not write prose paragraphs, mini-documentation, or essay-style explanations in implementation code.
* Do not add examples, scenarios, or parenthetical explanations to comments.
* Do not use multi-line comments just because an explanation can be written. If it cannot be expressed concisely, reconsider whether the comment belongs in the code at all.
* Existing verbose comments are not a style precedent. Do not imitate them.
* When modifying code, remove comments that merely describe code made obvious by the change.

Prefer:

```ts
// Skip empty text nodes
// Preserve the original selection
// Stop after the first match
// Avoid dispatching during composition
// Keep inactive editors measurable
// Prevent collisions with imported IDs
```

Avoid:

```ts
// While composition is running, the update handler exits early because the
// view is still composing. The final update may also contain no document or
// selection changes, which means the menu would otherwise never update.
```

```ts
/**
* Page content width used for the off-screen host. This is necessary because
* inactive editors need to remain measurable for ResizeObserver to detect
* changes while no overlay is currently open.
*/
```

```ts
/**
* Generates a unique endnote ID. Imported documents use numeric DOCX IDs,
* while client-created endnotes use this prefix to ensure that IDs cannot
* collide with imported endnotes or footnotes.
*/
```

If a short comment loses useful detail, that detail usually belongs in the code structure, a test, commit/PR description, or documentation instead.

JSDoc is exempt **only when it documents a public API**. Public API JSDoc should still be concise and include `@param`, `@returns`, and a runnable example where appropriate.

### Writing

Expand Down
22 changes: 22 additions & 0 deletions packages/core/__tests__/jsx-runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'

import { jsx, jsxs } from '../src/jsx-runtime.js'

describe('JSX runtime', () => {
it('keeps text and element siblings separate', () => {
const element = jsx('strong', { children: 'text' })

expect(jsxs('p', { children: ['Before ', element] })).toEqual([
'p',
{},
'Before ',
['strong', {}, 'text'],
])
})

it('keeps text and slot siblings separate', () => {
const slot = jsx('slot', {})

expect(jsxs('p', { children: ['Before ', slot] })).toEqual(['p', {}, 'Before ', 0])
})
})
26 changes: 23 additions & 3 deletions packages/core/src/jsx-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function Fragment(props: { children: JSXRenderer[] }) {
return props.children
}

export const h: JSXRenderer = (tag, attributes) => {
function render(
tag: Parameters<JSXRenderer>[0],
attributes: Attributes | undefined,
hasMultipleChildren: boolean,
) {
// Treat the slot tag as the Prosemirror hole to render content into
if (tag === 'slot') {
return 0
Expand All @@ -56,12 +60,28 @@ export const h: JSXRenderer = (tag, attributes) => {
)
}

// Otherwise, return the tag, attributes, and children
if (hasMultipleChildren && Array.isArray(children)) {
return [tag, rest, ...children] as DOMOutputSpecArray
}

return [tag, rest, children]
}

export const h: JSXRenderer = (tag, attributes) => render(tag, attributes, false)

export const jsxs: JSXRenderer = (tag, attributes) => render(tag, attributes, true)

export const jsxDEV = (
tag: Parameters<JSXRenderer>[0],
attributes?: Attributes,
_key?: unknown,
isStaticChildren?: boolean,
) => {
return render(tag, attributes, Boolean(isStaticChildren))
}

// See
// https://esbuild.github.io/api/#jsx-import-source
// https://www.typescriptlang.org/tsconfig/#jsxImportSource

export { h as createElement, h as jsx, h as jsxDEV, h as jsxs }
export { h as createElement, h as jsx }
Loading