diff --git a/packages/compiler/src/frontend/lowering/js-collection-types.ts b/packages/compiler/src/frontend/lowering/js-collection-types.ts index a4c182523..2be4e0dbd 100644 --- a/packages/compiler/src/frontend/lowering/js-collection-types.ts +++ b/packages/compiler/src/frontend/lowering/js-collection-types.ts @@ -75,9 +75,15 @@ export function initializedCollectionType( /** The inferred collection type of an identifier that names a proven * binding, or null. Computed for every JavaScript file of the program on - * first use. */ + * first use. The inference itself reads types (a candidate's constructor + * through a destructured require, for example); while it runs, those reads + * see no inferred types rather than starting it again. */ export function inferredCollectionType(lowerer: Lowerer, node: ts.Identifier): ts.Type | null { - const types = (lowerer.inferredCollectionTypes ??= inferProgram(lowerer)); + if (lowerer.inferredCollectionTypes === undefined) { + lowerer.inferredCollectionTypes = new Map(); + lowerer.inferredCollectionTypes = inferProgram(lowerer); + } + const types = lowerer.inferredCollectionTypes; return types.size === 0 ? null : (types.get(node) ?? null); } diff --git a/packages/compiler/test/js-collection-types.test.ts b/packages/compiler/test/js-collection-types.test.ts index 6a6f65798..7a5ef967a 100644 --- a/packages/compiler/test/js-collection-types.test.ts +++ b/packages/compiler/test/js-collection-types.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -62,3 +62,30 @@ test("untyped JavaScript collections take the types their uses prove, and only t rmSync(dir, { recursive: true, force: true }); } }); + +// Resolving a candidate's constructor through a destructured require reads +// types while the inference runs; those reads must not start it again. +test("the inference runs once when a candidate's values come from a destructured require", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-js-collection-reentry-")); + try { + const entry = join(dir, "main.cjs"); + writeFileSync( + entry, + [ + 'const util = require("node:util");', + "const { TextDecoder } = util;", + "const decoder = new TextDecoder();", + 'const decoders = new Map([["utf-8", decoder]]);', + "const seen = new Set([decoder]);", + "console.log(decoders.size, seen.size);", + "", + ].join("\n"), + ); + const output = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + expect(validateModule(deserializeModule(readFileSync(output, "utf8")))).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});