toeverything/AFFiNE · error · BlockSuiteError

1

1

Error message

There are unclosed nodes

What it means

ASTWalker.walk() (framework/store/src/adapter/base.ts) drives snapshot conversions: it opens the root target node, recursively visits the source tree while the setEnter/setLeave callbacks open/close matching target nodes via ASTWalkerContext, then asserts the stack is back to length 1. 'There are unclosed nodes' means the enter callback called context.openNode() one or more times without matching closeNode() calls on every path — the built target AST is unbalanced.

Source

Thrown at blocksuite/framework/store/src/adapter/base.ts:312

  private readonly context: ASTWalkerContext<TNode>;

  setEnter = (fn: WalkerFn<ONode, TNode>) => {
    this._enter = fn;
  };

  setLeave = (fn: WalkerFn<ONode, TNode>) => {
    this._leave = fn;
  };

  setONodeTypeGuard = (fn: (node: unknown) => node is ONode) => {
    this._isONode = fn;
  };

  walk = async (oNode: ONode, tNode: TNode) => {
    this.context.openNode(tNode);
    await this._visit({ node: oNode, parent: null, prop: null, index: null });
    if (this.context.stack.length !== 1) {
      throw new BlockSuiteError(1, 'There are unclosed nodes');
    }
    return this.context.currentNode();
  };

  walkONode = async (oNode: ONode) => {
    await this._visit({ node: oNode, parent: null, prop: null, index: null });
  };

  constructor() {
    this.context = new ASTWalkerContext<TNode>();
  }
}

View on GitHub (pinned to b4c8548c09)

Solutions

  1. Make openNode/closeNode strictly symmetric: every openNode on a code path needs a closeNode on that same path (use try/finally if conversions can throw midway).
  2. For void/leaf nodes, don't open a target node at all, or close it immediately after adding it.
  3. Re-run the walk with logging on context.stack depth per node to find the first node left open.
  4. Model the conversion on BlockSuite's built-in adapters, which pair open/close around child traversal.

Example fix

// before
walker.setEnter(({ node }, ctx) => {
  if (isVoid(node)) { ctx.openNode(voidTarget(node)); return; } // never closed
  ctx.openNode(target(node));
});

// after
walker.setEnter(({ node }, ctx) => {
  if (isVoid(node)) {
    ctx.openNode(voidTarget(node));
    ctx.closeNode();
    ctx.skip();
    return;
  }
  ctx.openNode(target(node));
});
walker.setLeave((_o, ctx) => ctx.closeNode());
Defensive patterns

Strategy: try-catch

Validate before calling

// Symmetric open/close on every path makes the assertion hold by construction:
walker.setEnter((o, ctx) => {
  ctx.openNode(toTarget(o.node));
});
walker.setLeave((_o, ctx) => {
  ctx.closeNode();
});

Try / catch

try {
  result = await walker.walk(oRoot, tRoot);
} catch (e) {
  if (e instanceof BlockSuiteError && /unclosed nodes/.test(e.message)) {
    // an enter callback opened a node it never closed — audit branches and retry
    throw new Error('adapter AST mismatch: review openNode/closeNode pairing');
  } else throw e;
}

Prevention

When it happens

Trigger: Writing a custom adapter's walker where an enter handler opens a node but a conditional branch, early return, or thrown error skips the closeNode() (or the leave handler that was supposed to close it); mismatched open/close counts around skip logic (context.skip() / setOpenNode) when transforming markdown/HTML/other formats into snapshots.

Common situations: Custom markdown/Notion/HTML adapters built on ASTWalker; refactors that add an early return inside an enter callback; handling 'void' nodes (e.g. void HTML tags) with openNode but no closeNode.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/7071bd9acb147893. Report an issue: GitHub.