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("+")));