Skip to content

perf(extensions): avoid full-document scan for Placeholder when showOnlyCurrent is true - #7705

Open
HMarzban wants to merge 2 commits into
ueberdosis:mainfrom
HMarzban:perf/placeholder-cursor-fast-path
Open

perf(extensions): avoid full-document scan for Placeholder when showOnlyCurrent is true#7705
HMarzban wants to merge 2 commits into
ueberdosis:mainfrom
HMarzban:perf/placeholder-cursor-fast-path

Conversation

@HMarzban

@HMarzban HMarzban commented Apr 4, 2026

Copy link
Copy Markdown

Summary

Optimizes @tiptap/extensions/placeholder so that when showOnlyCurrent is true (the default), placeholder decorations are built from the selection anchor only (O(depth)) instead of walking the entire document with doc.descendants on every update (O(n)).

When showOnlyCurrent is false, behavior is unchanged in intent: we still perform a full-document scan so placeholders can appear on every empty textblock.

Motivation

Large documents spend unnecessary work in the placeholder plugin on each transaction because the previous implementation always traversed the full node tree, even though the default configuration only needs the current empty block at the caret.

What changed

  • showOnlyCurrent: true: resolve anchor, walk relevant depths (includeChildren still respected via maxDepth), only textblock nodes, same emptiness rules as before (isNodeEmpty).
  • showOnlyCurrent: false: keep a doc.descendants pass for all empty textblocks (unavoidable for “show everywhere” semantics).
  • Unchanged API: PlaceholderOptions, dataAttribute / preparePlaceholderAttribute, PluginKey('placeholder'), and existing defaults.

Performance

  • Typical editing (default options): cost scales with nesting depth, not document size.
  • “All empty blocks” mode: still O(n) — by design.

Testing / QA

Suggested manual checks:

  • Empty paragraph at caret — placeholder shows as before.
  • Nested blocks (e.g. list → item → paragraph) — placeholder at caret.
  • showOnlyCurrent: false — placeholders on multiple empty blocks across the doc.
  • Read-only / showOnlyWhenEditable — matches prior behavior.
  • Custom dataAttribute — attribute still applied correctly.

Risk / compatibility

  • No intentional breaking changes; public options and decoration shape are preserved.
  • Please run the extensions package tests / CI in this repo.

Related file: packages/extensions/src/placeholder/placeholder.ts

HMarzban added 2 commits April 4, 2026 17:12
Port the O(depth) decoration path onto current Tiptap v3 placeholder in @tiptap/extensions (packages/extensions/src/placeholder).

When showOnlyCurrent is true (default), resolve the anchor and walk only the relevant depths instead of doc.descendants on every update. When showOnlyCurrent is false, keep a full-document scan for all empty textblocks.

Preserves upstream behavior: isNodeEmpty, textblock-only nodes, configurable dataAttribute / preparePlaceholderAttribute, and PluginKey('placeholder').

Made-with: Cursor
@changeset-bot

changeset-bot Bot commented Apr 4, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 14c2440

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@netlify

netlify Bot commented Apr 4, 2026

Copy link
Copy Markdown

Deploy Preview for tiptap-embed ready!

Name Link
🔨 Latest commit 14c2440
🔍 Latest deploy log https://app.netlify.com/projects/tiptap-embed/deploys/69d11c76aa70a50008d4aace
😎 Deploy Preview https://deploy-preview-7705--tiptap-embed.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

const isEmpty = !node.isLeaf && isNodeEmpty(node)

if (hasAnchor && isEmpty) {
decorations.push(createPlaceholderDecoration(editor, options, node, pos, hasAnchor, dataAttributeKey))

@arnaugomez arnaugomez Apr 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you return early here? If I understand it correctly, the function only returns one decoration for the current node.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn’t return early inside the loop.

The loop is over depths (1 … maxDepth). In principle more than one empty textblock on the resolved path could match (unusual schemas / nested textblocks). Stopping after the first push could drop a valid decoration, so we finish the loop.

On the API side: we collect a Decoration[] (length 0 or more) and pass it to DecorationSet.create(doc, decorations), which is what ProseMirror expects — an array of Decorations; an empty array is valid when there’s nothing to show.

Separately, the props.decorations hook may return null when the plugin is inactive (!active); that’s valid for “no decorations” and is separate from the Decoration[] passed into DecorationSet.create.

References: DecorationSet, EditorProps.decorations.

@bdbch
bdbch requested a review from a team as a code owner July 30, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants