facebook/flow · error · NodeIsDeletedError

Attempted to mutate a `${node.type}.${key}` on a deleted nod

Error message

Attempted to mutate a `${node.type}.${key}` on a deleted node.

What it means

MutationContext keeps a Set of every node marked deleted (via markDeletion, called by remove and replace mutations, which delete the whole subtree). markMutation runs assertNotDeleted first, so attempting any further mutation on a deleted node throws NodeIsDeletedError with this message. It exists because mutating a detached subtree produces output that silently disappears from the printed code.

Source

Thrown at packages/flow-transform/src/transform/MutationContext.js:65

      `Attempted to mutate a \`${node.type}.${key}\` when it has already been mutated.`,
    );

    const map = Array.isArray(
      // $FlowExpectedError[prop-missing]
      node[key],
    )
      ? this._mutatedArrays
      : this._mutatedKeys;

    map.set(node, map.get(node)?.add(key) ?? new Set([key]));
  }

  /**
   * Throws if the node has been deleted
   */
  assertNotDeleted(node: ESNode, message: string): void {
    if (this._deletedNodes.has(node)) {
      throw new NodeIsDeletedError(message);
    }
  }

  /**
   * Throws if the key of the node has been mutated
   */
  assertNotMutated(node: ESNode, key: string, message: string): void {
    if (this._mutatedKeys.get(node)?.has(key) === true) {
      throw new NodeIsMutatedError(message);
    }
  }

  appendCommentToSource(comment: Comment, placement: CommentPlacement): void {
    this.code = appendCommentToSource(this.code, comment, placement);
  }
}

View on GitHub (pinned to d1341dac89)

Solutions

  1. Issue exactly one mutation per node per transform pass
  2. When replacing a statement, fold the child-level changes into the replacement nodes instead of mutating the original afterwards
  3. Keep your own Set of nodes you have removed or replaced and skip them when applying later mutations

Example fix

// before
mutations.push(replaceWithMany(stmt, [a, b]));
mutations.push(mutate(stmt.body, 'key', v)); // stmt already deleted -> throws

// after
mutations.push(replaceWithMany(stmt, [a, b])); // single mutation owns the node
Defensive patterns

Strategy: validation

Validate before calling

// keep your own ledger of nodes you have removed or replaced this pass
const deleted = new Set();
function safePush(mutation) {
  if (deleted.has(mutation.target)) return; // skip stale mutations
  deleted.add(mutation.target);
  mutations.push(mutation);
}

Type guard

const isNodeDeleted = (mutationContext, node) => mutationContext._deletedNodes.has(node); // private field; prefer your own ledger

Try / catch

try {
  mutationContext.markMutation(node, key);
} catch (e) {
  if (e.constructor.name === 'NodeIsDeletedError') return; // skip mutations on removed subtrees
  throw e;
}

Prevention

When it happens

Trigger: Within one transform pass: a visitor first replaces a statement (ReplaceStatementWithMany marks the original deleted) and then another mutation anchors on that same statement or tries to write one of its keys; queueing several mutations against the same target and flushing them together.

Common situations: Codemods that both rewrite a statement and separately mutate its children; visitor libraries that collect mutations during traversal and apply them in a batch, letting two operations touch one node.

Related errors


AI-assisted analysis of facebook/flow@d1341dac89 (2026-08-17). Data as JSON: /api/errors/141977bb42b89aef. Report an issue: GitHub.