diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 55a7631a4..36dae9b10 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return DYN; } + // SEVERAL PROMISE ARMS are ONE: `Promise | Promise` is + // `Promise`. A promise arm's tag IS the `instanceof Promise` + // test, so JavaScript cannot tell the arms apart at run time (both + // answer true, both `await` to a value of their payload), and the + // merged payload union is exactly what `await` produces either way. + // The merge happens here, in the union the checker gave us, so every + // use site (a declaration slot, an argument, an `await`, `.then`) + // sees the same one-promise-arm layout; the conversion of a + // `Promise` value into it is the checked promise-payload widening + // (promisePayloadWidens / promiseWiden). A payload that cannot ride + // that union (a promise of a promise, a regex or generator payload) + // keeps the whole union fenced. + const mergedArms = mergePromiseArms(arms, ctx); + if (mergedArms === null) return null; + // Promise arms were the whole union (`Promise | Promise` + // merges to the plain `Promise`): a one-armed union has no + // representation and needs none — the merge left the arm alone. + if (mergedArms.length === 1) { + if (unions.recursivePending(widened)) return null; + return mergedArms[0]!; + } if ( - arms.some( + mergedArms.some( (a) => a.kind === "void" || a.kind === "union" || a.kind === "dyn" || // A generator arm only comes from an Iterable (an optional // one keeps its unit arms beside the iterable union's). - (a.kind === "generator" && iterableArmsElem(arms) === null), + (a.kind === "generator" && iterableArmsElem(mergedArms) === null), ) || - !maybePromiseUnionOk(parts, arms, ctx) + !maybePromiseUnionOk(parts, mergedArms, ctx) ) { return null; } - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + mergedArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); const discriminant = - unionDiscriminant(recordParts, arms, ctx) ?? + unionDiscriminant(recordParts, mergedArms, ctx) ?? (substitutedUnions.length === 1 - ? remapUnionDiscriminant(substitutedUnions[0]!, arms) + ? remapUnionDiscriminant(substitutedUnions[0]!, mergedArms) : undefined); if (unions.recursivePending(widened)) { // The knot closed through this union. A frame that resolved @@ -5446,9 +5467,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // same ts.Type answers differently per instantiation — so // recursive generic-open unions stay fenced. if (contextResolutions !== sensitivityAtEntry) return null; - return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) }; + return { + kind: "union", + unionId: unions.finalizeRecursive(widened, mergedArms, discriminant), + }; } - return { kind: "union", unionId: unions.intern(arms, discriminant) }; + return { kind: "union", unionId: unions.intern(mergedArms, discriminant) }; } finally { unions.inProgress.delete(widened); } @@ -8871,6 +8895,79 @@ function maybePromiseUnionOk( return maybePromiseThenableArm(parts, ctx) === null; } +/** Merge the promise arms of one union into a single `Promise` + * (mapTypeInner's union rule): JavaScript-wise the arms are + * indistinguishable — `instanceof Promise` answers true for both, and + * `await` settles to the payload union — so the merged arm is the same + * value in the same slot. Payloads join like union arms: a `void` payload + * becomes the undefined unit, a payload that is already a union flattens, + * and a checked-dynamic payload (`Promise`) absorbs the arms the + * checked-dynamic representation holds faithfully (dynSubsumableUnionArm). + * Null keeps the whole union fenced: a promise-of-a-promise payload, a + * payload with no union home (regex, generator), or a dyn payload beside an + * arm whose semantics would degrade. */ +function mergePromiseArms(arms: IrType[], ctx: TypeMapperCtx): IrType[] | null { + let promiseArms = 0; + for (const arm of arms) if (arm.kind === "promise") promiseArms++; + if (promiseArms < 2) return arms; + const payloads: IrType[] = []; + let dynamic = false; + for (const arm of arms) { + if (arm.kind !== "promise") continue; + const inner = arm.inner; + if (inner.kind === "void") { + payloads.push(UNDEFINED_T); + continue; + } + if (inner.kind === "dyn") { + dynamic = true; + continue; + } + if (inner.kind === "union") { + const substituted = ctx.unions.get(inner.unionId); + if (!substituted?.arms.length) return null; + payloads.push(...substituted.arms); + continue; + } + payloads.push(inner); + } + if (dynamic) { + if (!payloads.every((arm) => dynSubsumableUnionArm(arm, ctx))) return null; + payloads.length = 0; + payloads.push(DYN); + } + const byKey = new Map(); + for (const arm of payloads) { + // A promise payload has no arm home beside the others: `await` of the + // merged promise would adopt it as a thenable, which no union tag does. + if (arm.kind === "promise") return null; + byKey.set(typeKey(arm), arm); + } + const payload = [...byKey.values()]; + if (payload.length === 0) return null; + // Every arm is the undefined unit: the promise never settles a value, + // exactly `Promise`. + if (payload.every((arm) => arm.kind === "undefinedT")) payload.length = 0; + // One payload arm is that arm itself (a regex or a promise payload has a + // promise slot but no union arm); a JOINT payload is a union, so its arms + // need union homes. + const inner: IrType | null = + payload.length === 0 + ? VOID + : payload.length === 1 + ? payload[0]! + : payload.every((arm) => armHasUnionHome(arm)) + ? { + kind: "union", + unionId: ctx.unions.intern( + [...payload].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)), + ), + } + : null; + const merged: IrType | null = inner === null ? null : { kind: "promise", inner }; + return merged === null ? null : [...arms.filter((arm) => arm.kind !== "promise"), merged]; +} + /** The first non-promise constituent of a promise-armed union whose type * has a `then` member (a thenable data arm), or null. */ function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null { @@ -9025,7 +9122,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx): } const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise"); if (promiseArms.length > 1) { - return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`; + return `the union shape is supported, but its promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}') do not merge into one promise (their payloads have no common union layout)`; } if (promiseArms.length === 1 && dataArms.length > 1) { const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func"); diff --git a/packages/compiler/test/promise-union-widening.test.ts b/packages/compiler/test/promise-union-widening.test.ts index 36813b41d..cc9e2adef 100644 --- a/packages/compiler/test/promise-union-widening.test.ts +++ b/packages/compiler/test/promise-union-widening.test.ts @@ -77,16 +77,62 @@ test("an equal promise arm stays a plain copy", async () => { expect(promisePayloadConversions(retag)).toBe(0); }); -test("two destination promise arms keep the conversion fence", async () => { +test("two destination promise arms merge into one widened payload", async () => { + // `Promise | Promise` is `Promise`: the + // checked-dynamic payload absorbs the faithful one, so the destination is + // the same one-promise-arm union the single-arm case has. const result = await compileSource(` function settle(flag: boolean): Promise | undefined { return flag ? Promise.resolve() : undefined; } - const ambiguous: Promise | Promise | undefined = settle(true); - console.log(ambiguous === undefined); + const merged: Promise | Promise | undefined = settle(true); + console.log(merged === undefined); export {}; `); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.diagnostics.map((d) => d.code)).toContain("SC2003"); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag.")); + expect(retag).toHaveLength(1); + expect(promisePayloadConversions(retag)).toBe(1); +}); + +test("several promise arms settle to one promise of the payload union", async () => { + const result = await compileSource(` + interface Decision { kind: "stop"; note: string } + interface Settled { reason: string } + function pick(n: number): void | Promise | Promise | Decision { + if (n === 0) return; + if (n === 1) return Promise.resolve({ reason: "one" }); + return { kind: "stop", note: "x" }; + } + async function main(): Promise { + const hit = pick(1); + if (hit instanceof Promise) console.log("then", await hit.then((v) => v === undefined)); + const decided = await pick(2); + console.log("decision", decided === undefined ? "?" : "note" in decided ? decided.note : "?"); + } + void main(); + export {}; + `); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const unions = result.module.unions ?? []; + // The IR invariant the merge establishes: no union holds two promise arms + // (the promise arm's tag IS the `instanceof Promise` test). + for (const union of unions) + expect(union.arms.filter((arm) => arm.kind === "promise").length).toBeLessThan(2); + // The declared union is the promise beside the undefined unit. + const armed = unions.filter((union) => union.arms.some((arm) => arm.kind === "promise")); + expect(armed.length).toBeGreaterThan(0); + const promiseArm = armed[0]!.arms.find((arm) => arm.kind === "promise"); + expect(promiseArm?.kind).toBe("promise"); + // `void | Promise | Promise` merges to a payload UNION: + // the settled record beside the undefined unit. + if (promiseArm?.kind === "promise") { + expect(promiseArm.inner.kind).toBe("union"); + if (promiseArm.inner.kind === "union") { + const payload = unions.find((union) => union.id === promiseArm.inner.unionId); + expect(payload?.arms.map((arm) => arm.kind).sort()).toEqual(["record", "undefinedT"]); + } + } }); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json new file mode 100644 index 000000000..77516bf48 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8417-promise-union-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8417-promise-union-arms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8417-promise-union-arms.ts b/tests/corpus/8417-promise-union-arms.ts new file mode 100644 index 000000000..7a8484089 --- /dev/null +++ b/tests/corpus/8417-promise-union-arms.ts @@ -0,0 +1,74 @@ +// Several promise arms in one union: `void | Promise | Promise | +// Decision` is one promise arm `Promise` — `instanceof +// Promise` is true for either arm and `await` settles to the payload union. +interface Decision { + kind: "stop" | "continue"; + note: string; +} + +interface Settled { + reason: string; +} + +type TurnResult = void | Promise | Promise | Decision; + +function pick(n: number): TurnResult { + if (n === 0) return; + if (n === 1) return Promise.resolve({ reason: "one" }); + if (n === 2) return Promise.resolve(); + return { kind: "continue", note: "x" }; +} + +function fail(): void | Promise { + return Promise.reject(new Error("boom")); +} + +class Agent { + finishTurn?: (turn: number) => TurnResult; + + async turn(n: number): Promise { + const hook = this.finishTurn; + if (!hook) return "none"; + const out = await hook(n); + if (out === undefined) return "void"; + if ("note" in out) return `decision:${out.note}`; + return `settled:${out.reason}`; + } +} + +async function main(): Promise { + // A promise stored in the union is the same object: the payload + // conversion never re-boxes it. + const original = Promise.resolve({ reason: "one" }); + const stored: TurnResult = original; + console.log("identity", stored === original); + + const hit = pick(1); + console.log("promise", hit instanceof Promise, pick(3) instanceof Promise); + if (hit instanceof Promise) { + const settled = await hit; + console.log("narrowed", settled === undefined ? "void" : "object"); + console.log("then", await hit.then((value) => (value === undefined ? "u" : "s"))); + } + + console.log("void", (await pick(0)) === undefined); + const settled = await pick(1); + console.log("awaited", typeof settled === "object" && "reason" in settled ? settled.reason : "?"); + const decided = await pick(3); + console.log("decision", typeof decided === "object" && "note" in decided ? decided.note : "?"); + + const empty = pick(2); + if (empty instanceof Promise) await empty.then(() => console.log("then void")); + + try { + await fail(); + } catch (error) { + console.log("rejected", (error as Error).message); + } + + const agent = new Agent(); + console.log("no hook", await agent.turn(1)); + agent.finishTurn = pick; + console.log("hook", await agent.turn(1), await agent.turn(3), await agent.turn(0)); +} +void main(); diff --git a/tests/diagnostics/maybe-promise-fences.ts b/tests/diagnostics/maybe-promise-fences.ts index e6fdf98fa..c2381908a 100644 --- a/tests/diagnostics/maybe-promise-fences.ts +++ b/tests/diagnostics/maybe-promise-fences.ts @@ -10,11 +10,13 @@ function thenable(): Thenish | Promise { } console.log(typeof thenable); -// Two promise arms: the union tag cannot tell them apart by instanceof. -function twoPromises(): Promise | Promise | boolean { +// Promise arms merge into one promise of their payload union (`await` settles +// to either payload). They do not merge when a payload has no union arm home: +// a RegExp payload is the promise slot's own, not an arm. +function splitPromises(): Promise | Promise | boolean { return true; } -console.log(typeof twoPromises); +console.log(typeof splitPromises); // Promises have no inspect lowering, so neither does a union holding one. function maybe(b: boolean): string | Promise { diff --git a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt index ab325aaaa..57a98ad2a 100644 --- a/tests/harness/__snapshots__/maybe-promise-fences.ts.txt +++ b/tests/harness/__snapshots__/maybe-promise-fences.ts.txt @@ -16,36 +16,36 @@ maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blo hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it -maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise' and 'Promise'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test) +maybe-promise-fences.ts:16:10 - error SC2009: values of type 'boolean | Promise | Promise' cannot be compiled: the union shape is supported, but its promise arms ('Promise' and 'Promise') do not merge into one promise (their payloads have no common union layout) - 13 | // Two promise arms: the union tag cannot tell them apart by instanceof. - 14 | function twoPromises(): Promise | Promise | boolean { - | ^~~~~~~~~~~ - 15 | return true; + 15 | // a RegExp payload is the promise slot's own, not an arm. + 16 | function splitPromises(): Promise | Promise | boolean { + | ^~~~~~~~~~~~~ + 17 | return true; hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration +maybe-promise-fences.ts:19:20 - error SC2004: uses of 'splitPromises' inherit the blocker on its declaration - 16 | } - 17 | console.log(typeof twoPromises); - | ^~~~~~~~~~~ - 18 | + 18 | } + 19 | console.log(typeof splitPromises); + | ^~~~~~~~~~~~~ + 20 | - hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it + hint: the declaration of 'splitPromises' did not compile — fix the diagnostic reported there and these sites clear with it -maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet +maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise | string' values ('Promise' values have no inspect lowering) are not supported yet - 22 | } - 23 | console.log(maybe(true)); + 24 | } + 25 | console.log(maybe(true)); | ^~~~~~~~~~~ - 24 | + 26 | -maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet +maybe-promise-fences.ts:29:50 - error SC2020: 'Promise.resolve over 'Promise | string' at this result type' is part of the standard library types but has no scriptc lowering yet - 26 | // must be that arm's own promise type. - 27 | const widened = Promise.resolve(maybe(false)); + 28 | // must be that arm's own promise type. + 29 | const widened = Promise.resolve(maybe(false)); | ^~~~~~~~~~~~ - 28 | console.log(typeof widened); + 30 | console.log(typeof widened); hint: the promise arm passes through identically, so it must already be the result's promise type \ No newline at end of file diff --git a/tests/harness/corpus-costs/8417-promise-union-arms.ts.json b/tests/harness/corpus-costs/8417-promise-union-arms.ts.json new file mode 100644 index 000000000..ec6332e7a --- /dev/null +++ b/tests/harness/corpus-costs/8417-promise-union-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 5400 }