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
- 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).
- For void/leaf nodes, don't open a target node at all, or close it immediately after adding it.
- Re-run the walk with logging on context.stack depth per node to find the first node left open.
- 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
- Pair every context.openNode() with a closeNode() on all code paths (use try/finally).
- Use skip() for void nodes instead of opening target nodes you never close.
- Log context.stack depth per visited node while developing custom adapters.
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
- TransformerError
- TransformerNotImplementedError
- ErrorCode.TransformerError
- TransformerError
- No root node found in the tree
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/7071bd9acb147893.
Report an issue: GitHub.