Bug: useId() collides across Vue node views because editor.appContext is a shallow copy, not a shared reference
Bug: useId() collides across Vue node views because editor.appContext is a shallow copy, not a shared reference
Environment
@tiptap/vue-3: 3.31.3@tiptap/core: 3.31.3vue: 3.5.12- Browser: Chrome (reproduces in any browser, this is a JS-level issue, not rendering-related)
Summary
Every Vue node view rendered by @tiptap/vue-3 gets its own copy of editor.appContext
({...instance.appContext, provides: instance.provides}, see
src/EditorContent.ts).
Because it's a shallow copy and not the same object reference as the parent app's real
appContext, Vue's own useId() — which is documented to be "unique per Vue application
instance" — silently loses that guarantee inside node views: every node view's setup() sees a
"fresh" counter and starts generating the same IDs (v-0, v-1, ...) as every other node view,
instead of continuing the parent app's sequence.
This is surprising and easy to hit for any node view component that itself uses useId() (directly,
or indirectly through a child component/composable/third-party library that generates a DOM id this
way) to produce a DOM id it relies on being unique — e.g. to create/register a per-instance resource
keyed by that id (a chart library, a canvas library, anything doing document.getElementById-style
registration).
Minimal reproduction
Full runnable project (Vite + Vue 3.5.12 + @tiptap/vue-3 3.31.3, npm install && npm run dev)
available on request — the relevant files:
<!-- IdBlockView.vue — the component used as the node view -->
<template>
<node-view-wrapper class="id-block">useId() → <strong>{{ id }}</strong></node-view-wrapper>
</template>
<script setup>
import { useId } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
defineProps(nodeViewProps)
// This is the whole bug: two instances of this exact same component, mounted as sibling Tiptap
// node views in the same editor/app, produce the SAME id here instead of two different ones.
const id = useId()
console.log('useId() ->', id)
</script><!-- PlainIdBlock.vue — control group: identical useId() call, but NOT a node view -->
<template>
<div class="id-block">useId() → <strong>{{ id }}</strong></div>
</template>
<script setup>
import { useId } from 'vue'
const id = useId()
console.log('useId() (plain sibling) ->', id)
</script><!-- App.vue -->
<template>
<!-- Control group: two ordinary sibling components, no Tiptap involved -->
<PlainIdBlock />
<PlainIdBlock />
<!-- Bug: two Tiptap Vue node views, same component, same editor -->
<EditorContent :editor="editor" />
</template>
<script setup>
import { useEditor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
import { Node, mergeAttributes } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import PlainIdBlock from './PlainIdBlock.vue'
import IdBlockView from './IdBlockView.vue'
const IdBlock = Node.create({
name: 'idBlock',
group: 'block',
atom: true,
// parseHTML/renderHTML needed so the initial `content` string below round-trips at all —
// without them the custom tag is silently dropped by the HTML parser.
parseHTML() {
return [{ tag: 'div[data-id-block]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-id-block': '' })]
},
addNodeView() {
return VueNodeViewRenderer(IdBlockView)
},
})
const editor = useEditor({
extensions: [StarterKit, IdBlock],
content:
'<p>Two idBlock node views follow:</p><div data-id-block></div><div data-id-block></div>',
})
</script>Expected console output — the control group (PlainIdBlock, not a node view) gets this, and
it's what the idBlock node views below should also get, since they're rendered in the same app:
useId() (plain sibling) -> v-0
useId() (plain sibling) -> v-1Actual console output, captured verbatim from a running instance of this exact reproduction:
useId() (plain sibling) -> v-0
useId() (plain sibling) -> v-1
useId() -> v-0
useId() -> v-0Both idBlock instances get the exact same id (v-0), even though they're two separate component
instances in the same editor, mounted at the same time, in the same document — right next to two
plain sibling components that correctly got v-0/v-1 from the identical useId() call.
Root cause
EditorContent'ssetup()setseditor.appContextonce, from the current component instance, as a new plain object:// packages/vue-3/src/EditorContent.ts if (instance) editor.appContext = { ...instance.appContext, provides: instance.provides }Every time a node view is rendered,
VueRenderer.renderComponent()assigns that same cloned object as the vnode'sappContextand renders it with Vue's low-levelrender()API:// packages/vue-3/src/VueRenderer.ts let vNode = h(this.component, this.props) if (this.editor.appContext) vNode.appContext = this.editor.appContext render(vNode, this.el)providesis explicitly copied over, soprovide/injectcorrectly cross the node-view boundary — that part works as intended and is presumably the reason this clone exists in the first place (to keepprovidesup to date with whereverEditorContentcurrently sits in the tree, rather than freezing it at that one snapshot forever).But
{...instance.appContext}only copiesinstance.appContext's own enumerable properties at the momentEditorContentmounts. Vue's internaluseId()implementation keeps its counter as mutable state that must stay attached to the same app-context object identity to behave as "unique per app instance" — a shallow copy breaks that invariant. Since every node view'srender()call reuses this one cloned object, and the object itself was never actually threaded through Vue's real app-context machinery in the way a literalinstance.appContextreference would be,useId()inside a node view behaves as if it's in a brand new, isolated context every time.
Why this is a bug and not just an edge case
Vue's own docs are explicit about the contract useId() provides:
IDs generated by
useId()are unique-per-application... For cases where you have multiple Vue applications mounted on the same page, you can avoid ID conflicts by giving each app an ID prefix viaapp.config.idPrefix. — https://vuejs.org/api/composition-api-helpers.html#useid
@tiptap/vue-3 node views are documented as being part of the same application as the rest of the
editor (that's the whole point of forwarding provides — so provide/inject see them as such). A
consumer has no way to know, and no warning anywhere in the docs, that this "same app" guarantee quietly
stops applying to useId() (or, most likely, to any other Vue-internal mechanism that similarly
depends on appContext being the same object, not a copy of its current contents). This is a leaky
abstraction: provide/inject works as if node views are first-class members of the app, while
useId() silently behaves as if they're a separate one — with no way for a component author to detect
this difference without already knowing about this exact implementation detail.
Suggested fix
Reuse the same appContext object reference instead of spreading it into a new one, e.g.:
if (instance) {
editor.appContext = instance.appContext
editor.appContext.provides = instance.provides
}(mutating provides on the real object, rather than creating a new object with a copied provides)
would keep useId() (and anything else keyed off appContext identity) correctly shared, while still
keeping provides live-updated the way the current code intends. If mutating the app's real
appContext object like this has other side effects that make it undesirable, an alternative is
documenting the limitation explicitly (node views don't share useId()'s counter with the rest of the
app) so it isn't a silent trap.
Workaround (for anyone hitting this in the meantime)
Don't rely on useId() (or anything built on it) for uniqueness inside a node view. Use
crypto.randomUUID() (or any generator not scoped to Vue's app-context identity) for DOM ids you need
uniqueness guarantees for.
Source: ueberdosis/tiptap