Skip to content
Documentation GitHub
Patterns

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

  1. Pass data via React context — Not viable for vanilla DOM NodeViews (no React render cycle)
  2. Use updateAttributes() to store value on the node — Would create CRDT operations on every property change, defeating render-time-only resolution
  3. 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

  1. extensionOptions is a plain JS object — mutation is legal and immediate
  2. editor.state.tr creates a transaction with zero steps
  3. ProseMirror’s dispatchTransaction calls NodeView.update() for every visible node
  4. The NodeView re-reads from the (now-updated) options object
  5. 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

Was this page helpful?