TipTap Extension Option Mutation + No-Op Dispatch for NodeView Re-Render
TipTap Extension Option Mutation + No-Op Dispatch for NodeView Re-Render
Problem
TipTap extensions are configured once at editor mount time. Extension options (like callback functions or data providers) are frozen by the extension manager after initialization. When the underlying data changes (e.g., a React hook provides new property values), vanilla DOM NodeViews don’t re-render because no ProseMirror transaction occurs.
Symptoms:
- Inline node chips show stale values after the data source updates
- Changing a property value doesn’t update the rendered chip until the user types something
- Extension options set via
configure({})at mount time are effectively immutable
Investigation
Steps Tried
- Pass data via React context — Not viable for vanilla DOM NodeViews (no React render cycle)
- Use
updateAttributes()to store value on the node — Would create CRDT operations on every property change, defeating render-time-only resolution - React NodeView wrapper — Adds significant complexity; vanilla DOM is faster for simple inline atoms
Root Cause
TipTap’s ExtensionManager stores extension options in extension.options as a plain object. ProseMirror only calls
NodeView.update() when a transaction is dispatched — there is no “options changed” lifecycle hook. Mutating the
options object alone has no effect until the next transaction triggers the NodeView update cycle.
Solution
Directly mutate the extension’s options object by reference, then dispatch a no-op transaction to force ProseMirror to
call update() on all NodeViews.
Code
// In the parent component (e.g., InklingsEditor.tsx):useEffect(() => { if (!editor) return;
// Find the live extension instance const propertyRefExt = editor.extensionManager.extensions.find( (ext) => ext.name === "propertyRef" );
if (propertyRefExt) { // Mutate options by reference — the NodeView reads from this same object propertyRefExt.options.getPropertyValue = getPropertyValue; propertyRefExt.options.onPropertyClick = highlightPropertyInPanel;
// No-op dispatch: empty transaction forces all NodeViews to call update() editor.view.dispatch(editor.state.tr); }}, [editor, getPropertyValue, highlightPropertyInPanel]);// In the NodeView (PropertyRef.ts):update(node: ProseMirrorNode) { if (node.type.name !== this.extension.name) return false;
// Re-read from extensionOptions (which was mutated above) const getPropertyValue = this.extension.options.getPropertyValue; const resolution = getPropertyValue?.(node.attrs.propertyName);
// Update DOM based on new resolution if (!resolution?.exists) { this.dom.className = "property-ref-pill property-ref-pill--missing"; this.dom.textContent = node.attrs.propertyName; } else if (!resolution.value) { this.dom.className = "property-ref-pill property-ref-pill--empty"; this.dom.textContent = "(no value)"; } else { this.dom.className = "property-ref-pill"; this.dom.textContent = resolution.value; }
return true;}Why This Works
extensionOptionsis a plain JS object — mutation is legal and immediateeditor.state.trcreates a transaction with zero steps- ProseMirror’s
dispatchTransactioncallsNodeView.update()for every visible node - The NodeView re-reads from the (now-updated) options object
- DOM updates reflect the new data
Implementation Notes
- The no-op transaction does not create undo history (no steps = nothing to undo)
- The no-op transaction does not trigger Loro CRDT operations (no document changes)
- This pattern works for any vanilla DOM NodeView — React NodeViews have their own re-render mechanism
- Group multiple option mutations before a single dispatch to avoid unnecessary re-renders
Prevention
When to Use This Pattern
- Inline node rendering depends on external data (property values, resolved links, user preferences)
- The data source is a React hook that updates independently of the editor
- You’re using vanilla DOM NodeViews (not React NodeViews)
When NOT to Use This Pattern
- If the data is intrinsic to the node (use
updateAttributes()instead) - If you’re already using React NodeViews (use React state/context)
- If the data rarely changes (initial configuration via
configure({})is sufficient)
Alternative: Custom ProseMirror Plugin
For more complex scenarios, a ProseMirror plugin with state and view.update() can provide a more structured
approach, but the options mutation pattern is simpler for the common case of “external data changed, re-render nodes.”
References
- INK-253: Property Value Insertion in Content Blocks
apps/desktop/src-react/components/editor/extensions/PropertyRef.tsapps/desktop/src-react/components/editor/InklingsEditor.tsx- TipTap NodeView documentation: https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views
Was this page helpful?
Thanks for your feedback!