{"record":{"id":"56b64c051a12c429","repo":"facebook/flow","slug":"attempted-to-mutate-a-node-type-key-when-i","errorCode":null,"errorMessage":"Attempted to mutate a `${node.type}.${key}` when it has already been mutated.","messagePattern":"Attempted to mutate a `(.+?)\\.(.+?)` when it has already been mutated\\.","errorType":"exception","errorClass":"NodeIsMutatedError","httpStatus":null,"severity":"error","filePath":"packages/flow-transform/src/transform/MutationContext.js","lineNumber":74,"sourceCode":"\n    map.set(node, map.get(node)?.add(key) ?? new Set([key]));\n  }\n\n  /**\n   * Throws if the node has been deleted\n   */\n  assertNotDeleted(node: ESNode, message: string): void {\n    if (this._deletedNodes.has(node)) {\n      throw new NodeIsDeletedError(message);\n    }\n  }\n\n  /**\n   * Throws if the key of the node has been mutated\n   */\n  assertNotMutated(node: ESNode, key: string, message: string): void {\n    if (this._mutatedKeys.get(node)?.has(key) === true) {\n      throw new NodeIsMutatedError(message);\n    }\n  }\n\n  appendCommentToSource(comment: Comment, placement: CommentPlacement): void {\n    this.code = appendCommentToSource(this.code, comment, placement);\n  }\n}\n","sourceCodeStart":56,"sourceCodeEnd":82,"githubUrl":"https://github.com/facebook/flow/blob/d1341dac899a79c027762f6b423d896045287620/packages/flow-transform/src/transform/MutationContext.js#L56-L82","documentation":"MutationContext also tracks which non-array keys of each node have already been mutated; assertNotMutated throws NodeIsMutatedError when the same node.key is written twice within a single transform. Scalar keys must keep a single authoritative value, so a second write is treated as a logic bug (array keys are tracked in a separate map and tolerated, since concurrent array edits are considered safe).","triggerScenarios":"Two visitors (or two queued mutations) in one pass both writing the same singular property of the same node, e.g. replacing a function's body twice, or setting a declarator's init and then replacing the declarator's init again.","commonSituations":"Composing independent codemods that each touch the same property; a generic visitor plus a special-case visitor both firing for one node because the special case returns the node instead of stopping propagation.","solutions":["Consolidate both writes into one mutation that produces the final value","Handle each node in exactly one visitor so the second never runs","Run the two changes as two separate transform passes on fresh ASTs instead of one pass"],"exampleFix":"// before\nvisitorA replaces fn.body;\nvisitorB also replaces fn.body; // second write to same key -> throws\n\n// after\nvisitorB replaces fn.body and includes visitorA's edits in the replacement;","handlingStrategy":"validation","validationCode":"// dedupe queued mutations per node before applying\nconst seen = new Set();\nmutations = mutations.filter(m => {\n  if (seen.has(m.target)) return false; // one write per node per pass\n  seen.add(m.target);\n  return true;\n});","typeGuard":"const alreadyMutated = (touched, node, key) =>\n  (touched.get(node) || new Set()).has(key);","tryCatchPattern":"try {\n  mutationContext.markMutation(node, key);\n} catch (e) {\n  if (e.constructor.name === 'NodeIsMutatedError') return; // skip duplicate write, first one wins\n  throw e;\n}","preventionTips":["Ensure exactly one visitor handles a given node.key per pass (return the node to stop propagation)","Merge overlapping edits into a single replacement value","Run independent changes as separate transform passes on fresh ASTs"],"tags":["flow-transform","mutation","duplicate-write"],"backgroundTag":"duplicate-mutation","analyzedSha":"d1341dac899a79c027762f6b423d896045287620","analyzedAt":"2026-08-17T00:07:02.212Z","schemaVersion":2},"datasetVersion":"2026-08-17T04:17:16.089Z"}