Skip to content

Commit badbb78

Browse files
committed
fix: resolve async array mapping bugs causing SyntaxError in various scenarios
1 parent cf5cd2e commit badbb78

3 files changed

Lines changed: 346 additions & 80 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@stackables/bridge-compiler": patch
3+
---
4+
5+
Fix three code generation bugs that caused `SyntaxError: await is only valid in async functions` when array mappings combined `catch` fallbacks or element-scoped tools with control flow.
6+
7+
- **Bug 1 – catch inside array `.map()`:** `needsAsync` only checked for element-scoped tool calls. Wires with `catch` fallbacks or `catch` control flow that fall back to an async IIFE now also trigger the async `for...of` loop path.
8+
9+
- **Bug 2 – element-scoped tool inside `.flatMap()`:** When a `?? continue` (or similar) control flow was detected first, the compiler unconditionally emitted `.flatMap()`. If the same loop also contained an element-scoped tool (`alias tool:iter`), the `await __call(...)` was placed inside a synchronous `.flatMap()` callback. `needsAsync` is now evaluated before the control-flow check, and when true, a `for...of` loop with a native `continue` statement is emitted instead.
10+
11+
- **Bug 3 – nested sub-array async blindspot:** The inner sub-array handler in `buildElementBody` never calculated `needsAsync`, always falling back to a synchronous `.map()`. It now uses the same async `for...of` IIFE pattern when inner wires contain element-scoped tools or catch expressions.

packages/bridge-compiler/src/codegen.ts

Lines changed: 117 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ export function compileBridge(
9595
(i): i is ToolDef => i.kind === "tool",
9696
);
9797

98-
const ctx = new CodegenContext(bridge, constDefs, toolDefs, options.requestedFields);
98+
const ctx = new CodegenContext(
99+
bridge,
100+
constDefs,
101+
toolDefs,
102+
options.requestedFields,
103+
);
99104
return ctx.compile();
100105
}
101106

@@ -251,7 +256,9 @@ class CodegenContext {
251256
this.constDefs = constDefs;
252257
this.toolDefs = toolDefs;
253258
this.selfTrunkKey = `${SELF_MODULE}:${bridge.type}:${bridge.field}`;
254-
this.requestedFields = requestedFields?.length ? requestedFields : undefined;
259+
this.requestedFields = requestedFields?.length
260+
? requestedFields
261+
: undefined;
255262

256263
for (const h of bridge.handles) {
257264
switch (h.kind) {
@@ -1329,8 +1336,36 @@ class CodegenContext {
13291336
// Only check control flow on direct element wires, not sub-array element wires
13301337
const directElemWires = elemWires.filter((w) => w.to.path.length === 1);
13311338
const cf = detectControlFlow(directElemWires);
1332-
if (cf === "continue") {
1333-
// Use flatMap — skip elements that trigger continue
1339+
// Check if any element wire generates `await` (element-scoped tools or catch fallbacks)
1340+
const needsAsync = elemWires.some((w) => this.wireNeedsAwait(w));
1341+
1342+
if (needsAsync) {
1343+
// ALL async processing must use for...of loop
1344+
const preambleLines: string[] = [];
1345+
this.elementLocalVars.clear();
1346+
this.collectElementPreamble(elemWires, "_el0", preambleLines);
1347+
1348+
const body = cf
1349+
? this.buildElementBodyWithControlFlow(
1350+
elemWires,
1351+
arrayIterators,
1352+
0,
1353+
4,
1354+
cf === "continue" ? "for-continue" : "break",
1355+
)
1356+
: ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`;
1357+
1358+
lines.push(` const _result = [];`);
1359+
lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`);
1360+
for (const pl of preambleLines) {
1361+
lines.push(` ${pl}`);
1362+
}
1363+
lines.push(body);
1364+
lines.push(` }`);
1365+
lines.push(` return _result;`);
1366+
this.elementLocalVars.clear();
1367+
} else if (cf === "continue") {
1368+
// Use flatMap — skip elements that trigger continue (sync only)
13341369
const body = this.buildElementBodyWithControlFlow(
13351370
elemWires,
13361371
arrayIterators,
@@ -1342,7 +1377,7 @@ class CodegenContext {
13421377
lines.push(body);
13431378
lines.push(` });`);
13441379
} else if (cf === "break") {
1345-
// Use a loop with early break
1380+
// Use a loop with early break (sync)
13461381
const body = this.buildElementBodyWithControlFlow(
13471382
elemWires,
13481383
arrayIterators,
@@ -1357,44 +1392,7 @@ class CodegenContext {
13571392
lines.push(` return _result;`);
13581393
} else {
13591394
const body = this.buildElementBody(elemWires, arrayIterators, 0, 4);
1360-
// Check if any element wire references an element-scoped non-internal tool (requires await)
1361-
const needsAsync = elemWires.some((w) => {
1362-
if ("from" in w && !w.from.element) {
1363-
const srcKey = refTrunkKey(w.from);
1364-
if (
1365-
this.elementScopedTools.has(srcKey) &&
1366-
!this.internalToolKeys.has(srcKey)
1367-
)
1368-
return true;
1369-
// Check transitive: if the source is a define container that depends on an async element-scoped tool
1370-
if (
1371-
this.elementScopedTools.has(srcKey) &&
1372-
this.defineContainers.has(srcKey)
1373-
) {
1374-
return this.hasAsyncElementDeps(srcKey);
1375-
}
1376-
}
1377-
return false;
1378-
});
1379-
if (needsAsync) {
1380-
// Collect element-scoped real tool calls and define containers that need
1381-
// per-element computation. Emit them as loop-local variables.
1382-
const preambleLines: string[] = [];
1383-
this.elementLocalVars.clear();
1384-
this.collectElementPreamble(elemWires, "_el0", preambleLines);
1385-
const body = this.buildElementBody(elemWires, arrayIterators, 0, 4);
1386-
lines.push(` const _result = [];`);
1387-
lines.push(` for (const _el0 of (${arrayExpr} ?? [])) {`);
1388-
for (const pl of preambleLines) {
1389-
lines.push(` ${pl}`);
1390-
}
1391-
lines.push(` _result.push(${body});`);
1392-
lines.push(` }`);
1393-
lines.push(` return _result;`);
1394-
this.elementLocalVars.clear();
1395-
} else {
1396-
lines.push(` return (${arrayExpr} ?? []).map((_el0) => (${body}));`);
1397-
}
1395+
lines.push(` return (${arrayExpr} ?? []).map((_el0) => (${body}));`);
13981396
}
13991397
return;
14001398
}
@@ -1478,8 +1476,29 @@ class CodegenContext {
14781476
// Only check control flow on direct element wires (not sub-array element wires)
14791477
const directShifted = shifted.filter((w) => w.to.path.length === 1);
14801478
const cf = detectControlFlow(directShifted);
1479+
// Check if any element wire generates `await` (element-scoped tools or catch fallbacks)
1480+
const needsAsync = shifted.some((w) => this.wireNeedsAwait(w));
14811481
let mapExpr: string;
1482-
if (cf === "continue") {
1482+
if (needsAsync) {
1483+
// ALL async processing must use for...of inside an async IIFE
1484+
const preambleLines: string[] = [];
1485+
this.elementLocalVars.clear();
1486+
this.collectElementPreamble(shifted, "_el0", preambleLines);
1487+
1488+
const asyncBody = cf
1489+
? this.buildElementBodyWithControlFlow(
1490+
shifted,
1491+
arrayIterators,
1492+
0,
1493+
8,
1494+
cf === "continue" ? "for-continue" : "break",
1495+
)
1496+
: ` _result.push(${this.buildElementBody(shifted, arrayIterators, 0, 8)});`;
1497+
1498+
const preamble = preambleLines.map((l) => ` ${l}`).join("\n");
1499+
mapExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; for (const _el0 of _src) {\n${preamble}\n${asyncBody}\n } return _result; })()`;
1500+
this.elementLocalVars.clear();
1501+
} else if (cf === "continue") {
14831502
const cfBody = this.buildElementBodyWithControlFlow(
14841503
shifted,
14851504
arrayIterators,
@@ -1499,40 +1518,7 @@ class CodegenContext {
14991518
mapExpr = `(() => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; for (const _el0 of _src) {\n${cfBody}\n } return _result; })()`;
15001519
} else {
15011520
const body = this.buildElementBody(shifted, arrayIterators, 0, 6);
1502-
// Check if any element wire references an element-scoped non-internal tool (requires await)
1503-
const needsAsync = shifted.some((w) => {
1504-
if ("from" in w && !w.from.element) {
1505-
const srcKey = refTrunkKey(w.from);
1506-
if (
1507-
this.elementScopedTools.has(srcKey) &&
1508-
!this.internalToolKeys.has(srcKey)
1509-
)
1510-
return true;
1511-
if (
1512-
this.elementScopedTools.has(srcKey) &&
1513-
this.defineContainers.has(srcKey)
1514-
) {
1515-
return this.hasAsyncElementDeps(srcKey);
1516-
}
1517-
}
1518-
return false;
1519-
});
1520-
if (needsAsync) {
1521-
const preambleLines: string[] = [];
1522-
this.elementLocalVars.clear();
1523-
this.collectElementPreamble(shifted, "_el0", preambleLines);
1524-
const asyncBody = this.buildElementBody(
1525-
shifted,
1526-
arrayIterators,
1527-
0,
1528-
8,
1529-
);
1530-
const preamble = preambleLines.map((l) => ` ${l}`).join("\n");
1531-
mapExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _r = []; for (const _el0 of _src) {\n${preamble}\n _r.push(${asyncBody});\n } return _r; })()`;
1532-
this.elementLocalVars.clear();
1533-
} else {
1534-
mapExpr = `(${arrayExpr})?.map((_el0) => (${body})) ?? null`;
1535-
}
1521+
mapExpr = `(${arrayExpr})?.map((_el0) => (${body})) ?? null`;
15361522
}
15371523

15381524
if (!tree.children.has(arrayField)) {
@@ -1647,8 +1633,22 @@ class CodegenContext {
16471633
const srcExpr = this.elementWireToExpr(sourceW, elVar);
16481634
const innerElVar = `_el${depth + 1}`;
16491635
const innerCf = detectControlFlow(shifted);
1636+
// Check if inner loop needs async (element-scoped tools or catch fallbacks)
1637+
const innerNeedsAsync = shifted.some((w) => this.wireNeedsAwait(w));
16501638
let mapExpr: string;
1651-
if (innerCf === "continue") {
1639+
if (innerNeedsAsync) {
1640+
// Inner async loop must use for...of inside an async IIFE
1641+
const innerBody = innerCf
1642+
? this.buildElementBodyWithControlFlow(
1643+
shifted,
1644+
arrayIterators,
1645+
depth + 1,
1646+
indent + 4,
1647+
innerCf === "continue" ? "for-continue" : "break",
1648+
)
1649+
: `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, arrayIterators, depth + 1, indent + 4)});`;
1650+
mapExpr = `await (async () => { const _src = ${srcExpr}; if (_src == null) return null; const _result = []; for (const ${innerElVar} of _src) {\n${innerBody}\n${" ".repeat(indent + 2)}} return _result; })()`;
1651+
} else if (innerCf === "continue") {
16521652
const cfBody = this.buildElementBodyWithControlFlow(
16531653
shifted,
16541654
arrayIterators,
@@ -1696,7 +1696,7 @@ class CodegenContext {
16961696
arrayIterators: Record<string, string>,
16971697
depth: number,
16981698
indent: number,
1699-
mode: "break" | "continue",
1699+
mode: "break" | "continue" | "for-continue",
17001700
): string {
17011701
const elVar = `_el${depth}`;
17021702
const pad = " ".repeat(indent);
@@ -1740,6 +1740,14 @@ class CodegenContext {
17401740
return `${pad} if (!${checkExpr}) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`;
17411741
}
17421742

1743+
// mode === "for-continue" — same as break but uses native 'continue' keyword
1744+
if (mode === "for-continue") {
1745+
if (isNullish) {
1746+
return `${pad} if (${checkExpr} == null) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1747+
}
1748+
return `${pad} if (!${checkExpr}) continue;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
1749+
}
1750+
17431751
// mode === "break"
17441752
if (isNullish) {
17451753
return `${pad} if (${checkExpr} == null) break;\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`;
@@ -2018,6 +2026,35 @@ class CodegenContext {
20182026
return `await __call(tools[${JSON.stringify(fnName)}], ${inputObj}, ${JSON.stringify(fnName)})`;
20192027
}
20202028

2029+
/**
2030+
* Check if a wire's generated expression would contain `await`.
2031+
* Used to determine whether array loops must be async (for...of) instead of .map()/.flatMap().
2032+
*/
2033+
private wireNeedsAwait(w: Wire): boolean {
2034+
// Element-scoped non-internal tool reference generates await __call()
2035+
if ("from" in w && !w.from.element) {
2036+
const srcKey = refTrunkKey(w.from);
2037+
if (
2038+
this.elementScopedTools.has(srcKey) &&
2039+
!this.internalToolKeys.has(srcKey)
2040+
)
2041+
return true;
2042+
if (
2043+
this.elementScopedTools.has(srcKey) &&
2044+
this.defineContainers.has(srcKey)
2045+
) {
2046+
return this.hasAsyncElementDeps(srcKey);
2047+
}
2048+
}
2049+
// Catch fallback/control without errFlag → applyFallbacks generates await (async () => ...)()
2050+
if (
2051+
(hasCatchFallback(w) || hasCatchControl(w)) &&
2052+
!this.getSourceErrorFlag(w)
2053+
)
2054+
return true;
2055+
return false;
2056+
}
2057+
20212058
/** Check if an element-scoped tool has transitive async dependencies. */
20222059
private hasAsyncElementDeps(trunkKey: string): boolean {
20232060
const wires = this.bridge.wires.filter(

0 commit comments

Comments
 (0)