From f7ad1f96a4d9ae1cb34eefe71d154d975165c2f3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 01:47:40 -0500 Subject: [PATCH] Lower Array.fromAsync over arrays The ESNext array lib is now part of the type world, and Array.fromAsync(source, mapFn?) compiles when the source is an array of values or of promises. Each call lowers to an async helper with the spec's loop: elements are awaited through the async-from-sync iterator, with the same microtask turns as a `for await` loop over an array, and a mapping function's result is awaited before it is stored. A throwing or rejecting mapping function and a rejected element close the iterator before the returned promise rejects, on the turn Node rejects it. Async generator sources, a thisArg, and arrays that mix values and promises are refused. --- .../language-features/async-iteration.mdx | 2 +- .../lowering/containers/array-from-async.ts | 338 ++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 2 + packages/compiler/src/frontend/program.ts | 9 +- .../corpus/5854-array-from-async.ts.json | 6 + .../corpus/5855-array-from-async-js.js.json | 6 + tests/corpus/5854-array-from-async.ts | 56 +++ tests/corpus/5855-array-from-async-js.js | 14 + 8 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/containers/array-from-async.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json create mode 100644 tests/corpus/5854-array-from-async.ts create mode 100644 tests/corpus/5855-array-from-async-js.js diff --git a/docs/content/limitations/language-features/async-iteration.mdx b/docs/content/limitations/language-features/async-iteration.mdx index d36981e2ce..ee570f17dd 100644 --- a/docs/content/limitations/language-features/async-iteration.mdx +++ b/docs/content/limitations/language-features/async-iteration.mdx @@ -1 +1 @@ -- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. +- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises with an optional mapping function; async generator sources, a `thisArg`, and arrays that mix values and promises are unsupported. diff --git a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts new file mode 100644 index 0000000000..43e8b49595 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts @@ -0,0 +1,338 @@ +import { boolLit, numLit, varRef } from "../../../ir/build.js"; +import * as ts from "../../ts7/adapter.js"; +import type { Lowerer } from "../lowerer.js"; +import { + BOOL, + CAUGHT, + F64, + VOID, + type IrExpr, + type IrParam, + type IrStmt, + type IrType, + type SrcLoc, + typeEquals, +} from "../../../ir/ir.js"; +import { locOf } from "../../program.js"; +import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js"; +import { newFnCtx } from "../function-context.js"; +import { lowerArrayCallback } from "./callback-arguments.js"; + +/** `Array.fromAsync(source, mapFn?)` over an array, as an async helper + * with the spec's loop: + * + * const out = []; let k = 0; + * for await (const v of source) out.push(mapFn ? await mapFn(v, k++) : v); + * return out; + * + * An array iterates through the async-from-sync iterator: each element is + * awaited (a promise element through its promise, any other value as one + * resolved promise), and the iterator result is awaited again, which the + * helper spells as the same microtask hops the `for await` array loop + * takes. A throwing or rejecting mapFn, and a rejected element, close the + * iterator before the returned promise rejects. Null when this is not an + * `Array.fromAsync` call. */ +export function lowerArrayFromAsyncCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + if (!lowerer.isStdlibGlobal(access.expression, "Array")) return null; + if (access.name.text !== "fromAsync") return null; + const loc = locOf(call); + const args = call.arguments; + if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `Array.fromAsync with ${args.length} arguments`, + call, + "pass the source and an optional mapping function", + ); + } + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (resultT?.kind !== "promise" || resultT.inner.kind !== "array") + lowerer.badType(call, lowerer.typeOf(call)); + const outT = resultT.inner; + const source = lowerer.lowerExpr(args[0]!); + if (source.type.kind !== "array") { + if (source.type.kind === "generator" && source.type.async) { + // An async generator's completion settles one microtask later here + // than in Node, which would reorder the returned promise. + lowerer.noLowering( + "Array.fromAsync over an async generator", + args[0]!, + "collect its values with for await", + ); + } + lowerer.noLowering( + `Array.fromAsync over a value of type '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}'`, + args[0]!, + "an array is the supported source", + ); + } + const step = arraySourceStep(lowerer, args[0]!, source.type.elem); + let mapper: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; + if (args[1]) { + const callback = lowerArrayCallback(lowerer, args[1], [step.valueT], outT); + if (callback.arity > 2) + lowerer.noLowering("an Array.fromAsync mapping function with three parameters", args[1]); + mapper = callback.fnArg; + const ret = mapper.type.ret; + if (ret.kind !== "promise" && !nonThenable(lowerer, ret)) { + lowerer.noLowering( + "an Array.fromAsync mapping function whose result may be a promise or thenable", + args[1], + "return a value or a Promise", + ); + } + } + const name = `%array.fromAsync.${lowerer.liftedFns.length}`; + const context = newFnCtx(false, null, null, outT); + context.isAsync = true; + lowerer.fnStack.push(context); + try { + const src = lowerer.declareHiddenLocal("source", source.type); + const params: IrParam[] = [{ localId: src.id, name: "source", type: source.type }]; + let mapperRef: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; + if (mapper) { + const local = lowerer.declareHiddenLocal("mapFn", mapper.type); + params.push({ localId: local.id, name: "mapFn", type: mapper.type }); + mapperRef = { kind: "varRef", localId: local.id, type: mapper.type, loc }; + } + const out = lowerer.declareHiddenLocal("%fromAsyncOut", outT); + const k = lowerer.declareHiddenLocal("%fromAsyncIndex", F64); + k.mutable = true; + const kRef = varRef(k.id, F64, loc); + const append = (value: IrExpr): IrStmt[] => { + const stmts: IrStmt[] = []; + let item = value; + if (mapperRef) { + const mapped: IrExpr = { + kind: "callValue", + callee: mapperRef, + args: [value, kRef].slice(0, mapperRef.type.params.length), + type: mapperRef.type.ret, + loc, + }; + if (mapped.type.kind === "promise") { + item = { kind: "awaitExpr", value: mapped, type: mapped.type.inner, loc }; + } else { + // Await of a value that is not a thenable: one resolved promise. + const held = lowerer.declareHiddenLocal("%fromAsyncMapped", mapped.type); + stmts.push({ kind: "varDecl", localId: held.id, init: mapped, loc }, hop(loc)); + item = varRef(held.id, held.type, loc); + } + } + stmts.push( + arrayValueStore( + lowerer, + varRef(out.id, outT, loc), + kRef, + // arrayValueStore stores an undefined arm (a hole) as undefined. + typeEquals(item.type, outT.elem) || item.type.kind === "union" + ? item + : lowerer.coerceToExpected(item, outT.elem), + outT.elem, + loc, + ), + { + kind: "assign", + localId: k.id, + value: { kind: "bin", op: "+", left: kRef, right: numLit(1, loc), type: F64, loc }, + loc, + }, + ); + return stmts; + }; + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: out.id, + init: { kind: "arrayLit", elems: [], type: outT, loc }, + loc, + }, + { kind: "varDecl", localId: k.id, init: numLit(0, loc), loc }, + ...step.loop(varRef(src.id, source.type, loc), append, loc), + { kind: "return", value: varRef(out.id, outT, loc), loc }, + ]; + lowerer.liftedFns.push({ + name, + params, + returnType: outT, + locals: context.locals, + body, + async: true, + loc, + }); + } finally { + lowerer.fnStack.pop(); + } + return { + kind: "call", + callee: name, + args: mapper ? [source, mapper] : [source], + type: resultT, + loc, + }; +} + +function hop(loc: SrcLoc): IrStmt { + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "async.hop", args: [], type: VOID, loc }, + loc, + }; +} + +/** A value whose await takes one microtask without calling user code: + * primitives, arrays and records without a `then` field. */ +function nonThenable(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "union") { + const arms = lowerer.unions.get(type.unionId)?.arms; + return arms !== undefined && arms.every((arm) => nonThenable(lowerer, arm)); + } + if (type.kind === "record") { + const shape = lowerer.shapes.get(type.shapeId); + return shape !== undefined && !shape.indexValue && !shape.fields.some((f) => f.name === "then"); + } + return ( + type.kind === "f64" || + type.kind === "bigint" || + type.kind === "string" || + type.kind === "bool" || + type.kind === "symbol" || + type.kind === "array" || + type.kind === "undefinedT" || + type.kind === "nullT" + ); +} + +/** The `for await` array loop (lowerForOf's async-from-sync form): two + * hops per element that is not a promise, the element's own await plus + * one hop for a promise element, two hops before the final done result, + * and one hop to close the iterator when the body completes abruptly. */ +function arraySourceStep( + lowerer: Lowerer, + node: ts.Expression, + elem: IrType, +): { + /** The awaited value each element hands to the mapping function. */ + valueT: IrType; + loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; +} { + const yieldedT = arrayValueType(lowerer, elem); + let promiseTag: number | null = null; + let valueT = yieldedT; + const promiseElem = elem.kind === "promise" ? elem : null; + if ( + promiseElem && + yieldedT.kind === "union" && + !["void", "union", "dyn", "undefinedT", "nullT"].includes(promiseElem.inner.kind) + ) { + promiseTag = lowerer.armTag(yieldedT.unionId, promiseElem); + valueT = lowerer.withUndefinedArm(promiseElem.inner); + } else if (!nonThenable(lowerer, elem)) { + lowerer.noLowering( + "Array.fromAsync over array values that may be promises or thenables", + node, + "use an array of values or an array of promises", + ); + } + return { + valueT, + loop(source, append, loc) { + const arr = lowerer.declareHiddenLocal("%fromAsyncArray", source.type); + const cursor = lowerer.declareHiddenLocal("%fromAsyncCursor", F64); + cursor.mutable = true; + const needsClose = lowerer.declareHiddenLocal("%fromAsyncNeedsClose", BOOL); + needsClose.mutable = true; + const value = lowerer.declareHiddenLocal("%fromAsyncValue", valueT); + value.mutable = true; + const arrRef = varRef(arr.id, source.type, loc); + const cursorRef = varRef(cursor.id, F64, loc); + const read = arrayValueRead(lowerer, arrRef, cursorRef, elem, loc); + const setClose = (on: boolean): IrStmt => ({ + kind: "assign", + localId: needsClose.id, + value: boolLit(on, loc), + loc, + }); + // A rejected element rejects the next result one reaction later, and + // Node closes the iterator before the returned promise rejects. + const awaitInto = (awaited: IrExpr): IrStmt => { + const error = lowerer.declareHiddenLocal("%fromAsyncNextError", CAUGHT); + return { + kind: "tryCatch", + tryBody: [{ kind: "assign", localId: value.id, value: awaited, loc }], + catchBody: [hop(loc), hop(loc), { kind: "rethrow", localId: error.id, loc }], + catchLocalId: error.id, + finallyBody: null, + loc, + }; + }; + const next: IrStmt[] = []; + if (promiseTag !== null) { + next.push( + awaitInto({ kind: "awaitUnionExpr", value: read, promiseTag, type: valueT, loc }), + hop(loc), + ); + } else { + next.push({ kind: "assign", localId: value.id, value: read, loc }, hop(loc), hop(loc)); + } + const length: IrExpr = { + kind: "arrIntrinsic", + method: "length", + receiver: arrRef, + args: [], + type: F64, + loc, + }; + const loop: IrStmt = { + kind: "for", + init: { kind: "varDecl", localId: cursor.id, init: numLit(0, loc), loc }, + cond: boolLit(true, loc), + update: { + kind: "assign", + localId: cursor.id, + value: { kind: "bin", op: "+", left: cursorRef, right: numLit(1, loc), type: F64, loc }, + loc, + }, + body: [ + setClose(false), + { + kind: "if", + cond: { kind: "bin", op: ">=", left: cursorRef, right: length, type: BOOL, loc }, + then: [hop(loc), hop(loc), { kind: "break", loc }], + else_: null, + loc, + }, + ...next, + setClose(true), + ...append(varRef(value.id, valueT, loc)), + ], + loc, + }; + return [ + { kind: "varDecl", localId: arr.id, init: source, loc }, + { kind: "varDecl", localId: needsClose.id, init: boolLit(false, loc), loc }, + { kind: "varDecl", localId: value.id, init: null, loc }, + { + kind: "tryCatch", + tryBody: [loop], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef(needsClose.id, BOOL, loc), + then: [hop(loc)], + else_: null, + loc, + }, + ], + loc, + }, + ]; + }, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 1ad34f32f7..e0a8c79bab 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -68,6 +68,7 @@ import { lowerArrayFromCall, lowerArrayOfCall, } from "./containers/array-construction.js"; +import { lowerArrayFromAsyncCall } from "./containers/array-from-async.js"; import { lowerDynArrayFilterCall, lowerDynArrayFlatMapCall } from "./containers/array-callbacks.js"; import { lowerGroupByStaticCall } from "./containers/collection-grouping.js"; import { lowerIteratorHelperCall } from "./containers/iterator-helpers.js"; @@ -1392,6 +1393,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerObjectStaticCall(lowerer, expr, expr.expression) ?? lowerObjectFromEntriesCall(lowerer, expr, expr.expression) ?? lowerArrayFromCall(lowerer, expr, expr.expression) ?? + lowerArrayFromAsyncCall(lowerer, expr, expr.expression) ?? lowerArrayOfCall(lowerer, expr, expr.expression) ?? lowerer.lowerArrayMethodCall(expr, expr.expression) ?? // Tuple reads preserve the receiver through argument evaluation; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 2dca21ce94..3a4ffd7472 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -138,10 +138,11 @@ const FORCED_OPTIONS: ts.Ts7CompilerOptions = { // classification is intentionally independent below; this option is only // the binder's scope decision. moduleDetection: ts.ModuleDetectionKind.Force, - // Explicit resource management is an ESNext library slice in TS 7 even - // though Node 24 ships the runtime protocol. Include that focused lib - // without widening the rest of the standard-library target past ES2025. - lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts"], + // Explicit resource management and Array.fromAsync are ESNext library + // slices in TS 7 even though Node 24 ships them. Include those focused + // libs without widening the rest of the standard-library target past + // ES2025. + lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts", "lib.esnext.array.d.ts"], types: [], allowImportingTsExtensions: true, allowJs: true, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json new file mode 100644 index 0000000000..927060a98b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5854-array-from-async.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5854-array-from-async.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json new file mode 100644 index 0000000000..d32f3625d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5855-array-from-async-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5855-array-from-async-js.js" + ], + "diags": [] +} diff --git a/tests/corpus/5854-array-from-async.ts b/tests/corpus/5854-array-from-async.ts new file mode 100644 index 0000000000..a2ab6ab1ef --- /dev/null +++ b/tests/corpus/5854-array-from-async.ts @@ -0,0 +1,56 @@ +// Array.fromAsync over arrays of values and of promises, with and without a +// mapping function, interleaved with other promise jobs: every result, +// rejection and iterator close lands on the microtask Node uses. Node.js prints +// the same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +function report(label: string, job: Promise): Promise { + return job.then( + (a) => log.push(label + " " + a.join(",")), + (e) => log.push(label + " caught " + (e as Error).message), + ); +} +async function main(): Promise { + const tick = ticker(24); + const jobs = [ + report("values", Array.fromAsync([1, 2, 3])), + report("promises", Array.fromAsync([Promise.resolve(4), Promise.resolve(5)])), + report("empty", Array.fromAsync([] as number[])), + report("map", Array.fromAsync([1, 2], (x: number, i: number) => x * 2 + i)), + report("async map", Array.fromAsync([1, 2], async (x: number) => x + 1)), + report("promise map", Array.fromAsync([3, 4], (x: number) => Promise.resolve(x * x))), + report("promises async map", Array.fromAsync([Promise.resolve(1), Promise.resolve(2)], async (x: number) => x * 5)), + report( + "map throws", + Array.fromAsync([1, 2], (x: number) => { + if (x === 2) throw new Error("map failed"); + return x; + }), + ), + report( + "async map rejects", + Array.fromAsync([1, 2, 3], async (x: number) => { + if (x === 2) throw new Error("async map failed"); + return x; + }), + ), + report("first rejected", Array.fromAsync([Promise.reject(new Error("first")), Promise.resolve(1)])), + report("second rejected", Array.fromAsync([Promise.resolve(1), Promise.reject(new Error("second"))])), + report( + "later", + Array.fromAsync([new Promise((resolve) => setTimeout(() => resolve(7), 1)), Promise.resolve(8)]), + ), + ]; + log.push("sync end"); + await Promise.all(jobs); + await tick; + console.log(log.join("\n")); + const strings = await Array.fromAsync(["a", "b"], async (s: string, i: number) => s.repeat(i + 1)); + console.log(strings.join("|"), strings.length); +} +main(); diff --git a/tests/corpus/5855-array-from-async-js.js b/tests/corpus/5855-array-from-async-js.js new file mode 100644 index 0000000000..972d3d35fb --- /dev/null +++ b/tests/corpus/5855-array-from-async-js.js @@ -0,0 +1,14 @@ +// Array.fromAsync in JavaScript over arrays of values and promises, with a +// mapping function, beside other promise jobs. Node.js prints the same. +const order = []; +const job = Array.fromAsync([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)], (x, i) => x * 10 + i); +Promise.resolve() + .then(() => order.push("a")) + .then(() => order.push("b")) + .then(() => order.push("c")) + .then(() => order.push("d")); +job.then((values) => { + order.push("values " + values.join(",")); + console.log(order.join(" ")); +}); +Array.fromAsync(["x", "y"]).then((values) => console.log(values.join("+")));