facebook/flow · error · InvalidReplacementError

Expected to find the ${target.type} as a direct child of the

Error message

Expected to find the ${target.type} as a direct child of the ${parent.type}.

What it means

replaceNodeMutation resolves the target's location by scanning the parent's visitor keys, checking both direct identity and getOriginalNode(...) so that detached (wrapper) nodes whose original matches the target are found. If no key or array slot holds the target, this InvalidReplacementError is thrown: the flow-transform counterpart of the 'not a direct child' invariant, indicating the parent link is stale or the target was already replaced or detached.

Source

Thrown at packages/flow-transform/src/transform/mutations/ReplaceNode.js:109

    const child = (parent as $FlowFixMe)[key];
    if (isNode(child)) {
      // $FlowFixMe[invalid-compare]
      if (child === target) {
        return {type: 'single', parent, key};
      }
    } else if (Array.isArray(child)) {
      for (let i = 0; i < child.length; i += 1) {
        const current = child[i];
        const originalNode = getOriginalNode(current);
        if (current === target || originalNode === target) {
          return {type: 'array', parent, key, targetIndex: i};
        }
      }
    }
  }

  // this shouldn't happen ever
  throw new InvalidReplacementError(
    `Expected to find the ${target.type} as a direct child of the ${parent.type}.`,
  );
}

View on GitHub (pinned to d1341dac89)

Solutions

  1. Replace each node at most once per pass; check your queued mutations for duplicate targets before applying
  2. Perform mutations during the traversal that found the nodes, not from a cached list afterwards
  3. Re-run traversal on the mutated AST if you need a second round of replacements

Example fix

// before
const seen = collectTargets(ast);
for (const t of seen) replaceNodeMutation(t, newNode); // second replace of same t throws

// after
const done = new Set();
for (const t of collectTargets(ast)) {
  if (done.has(t)) continue;
  done.add(t);
  replaceNodeMutation(t, newNode);
}
Defensive patterns

Strategy: try-catch

Validate before calling

const replaced = new Set();
function safeReplace(target, replacement) {
  if (replaced.has(target)) return; // one replacement per node per pass
  replaced.add(target);
  mutations.push(replaceNodeMutation(target, replacement));
}

Type guard

const isDirectChildOf = (parent, target) =>
  Object.keys(parent).some(k =>
    parent[k] === target ||
    (Array.isArray(parent[k]) &&
      parent[k].some(c => c === target || (c && c.original === target)))
  );

Try / catch

try {
  mutations.push(replaceNodeMutation(target, replacement));
} catch (e) {
  if (e.message.includes('as a direct child')) {
    throw new Error('Target is no longer attached (already replaced?): re-traverse before replacing');
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling replaceNodeMutation on a node that was already replaced earlier in the same pass; replacing a node whose parent pointer references a tree that was concurrently modified; mixing raw original nodes with detached-node wrappers from different transform runs.

Common situations: Queueing multiple replacements for the same node; caching nodes between the traversal and mutation phases; reusing detached-node wrappers built against an older AST.

Related errors


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