Detecting image node deletion triggers randomly

I’m trying to find when an Image node is deleted from the Editor and then trigger a backend call. I’ve made a custom plugin to do so, it basically iterates over the newState/currentState’s descendants and stores the existing image nodes’ src in the set called newImageSources.

Note: An image node can be resized and moved in the editor.

Now I iterate over the oldState and after some conditions that check if “an image node is possibly deleted/moved from it’s old position”, I do a final check if the deletedNode’s src is in newImageSources set, and if it’s not present in it, I trigger a backend call to delete this node from the s3 bucket.

But this randomly gets triggered sometimes, it happens really randomly and we aren’t able to reproduce it specifically. Can you find any loophole that could possibly cause random triggers (i.e. without it getting triggered by the user by an explicit delete operation on the image node)

Here’s the code snippet for my Custom Plugin

import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";

const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";

interface ImageNode extends ProseMirrorNode {
  attrs: {
    src: string;
    id: string;

const TrackImageDeletionPlugin = (deleteImage): Plugin =>
  new Plugin({
    key: deleteKey,
    appendTransaction: (
      transactions: readonly Transaction[],
      oldState: EditorState,
      newState: EditorState,
    ) => {
      const newImageSources = new Set<string>();
      newState.doc.descendants((node) => {
        if ( === IMAGE_NODE_TYPE) {

      transactions.forEach((transaction) => {
        // transaction could be a selection
        if (!transaction.docChanged) return;

        const removedImages: ImageNode[] = [];

        // iterate through all the nodes in the old state
        oldState.doc.descendants((oldNode, oldPos) => {
          // if the node is not an image, then return as no point in checking
          if ( !== IMAGE_NODE_TYPE) return;

          if (oldPos < 0 || oldPos > newState.doc.content.size) return;
          const nodeExistsInNewStateAtOldPosition =

          // when image deleted from the end of the document, then document
          // closing tag is considered as the final node
          if (!nodeExistsInNewStateAtOldPosition) return;

          const currentNodeAtOldPosition = newState.doc.nodeAt(oldPos);

          // Check if the node has been deleted or replaced
          if (
            !currentNodeAtOldPosition ||
          ) {
            if (!newImageSources.has(oldNode.attrs.src)) {
              removedImages.push(oldNode as ImageNode);

        removedImages.forEach(async (node) => {
          const src = node.attrs.src;
          await onNodeDeleted(src, deleteImage);

      return null;

export default TrackImageDeletionPlugin;

async function onNodeDeleted(
  src: string,
  deleteImage: DeleteImage,
): Promise<void> {
  try {
    console.log("Image delete triggered");
    const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
    const resStatus = await deleteImage(assetUrlWithWorkspaceId);
    if (resStatus === 204) {
      console.log("Image deleted successfully");
  } catch (error) {
    console.error("Error deleting image: ", error);

Please let me know if the question is vague/not complete and what details should I add to get this thread going :smile: