diff --git a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts index 33515bf2ca..2a8711ffb6 100644 --- a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts @@ -190,6 +190,9 @@ function protocolSignature( ): ProtocolSignature | null { // A guarded optional step's receiver still carries its nullish arms. const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression)); + // A record view of a Map or Set has no stored member to read: the call + // dispatches on the collection at runtime instead. + if (lowerer.classCollectionView(recvTs)) return null; const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text); if (!property) return null; const declarations = property.declarations; diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 0c71d9d881..f2a3004e4d 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -13,6 +13,11 @@ export class ClassProtocols { /** The value types of the string-keyed dictionaries that keep checked * identity (see markDictionaryViews). */ private readonly dictionaryViews = new Set(); + /** The structural types a lib Map or Set flows into while it answers one + * of their members (see markIdentityFlows). They keep checked identity + * without protocol dispatch: their members are invoked on the collection + * itself, not read from a property bag. */ + private readonly collectionViews = new Set(); constructor( private readonly checker: ts.TypeChecker, @@ -425,9 +430,16 @@ export class ClassProtocols { const added = [...marked].filter((symbol) => !before.has(symbol)); // A copy of an instance drops the methods its class defines, so a view // that exposes one of them stays live wherever such an instance may - // arrive. + // arrive. A record view of a collection needs no protocol: its checked + // identity is the collection itself, and its members are the + // collection's own methods, which protocol dispatch would read from a + // property bag instead of invoking. const kept = new Set( - added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)), + added.filter( + (symbol) => + !this.collectionViews.has(symbol) && + (methodViews.has(symbol) || this.needsView(symbol, marked, facts)), + ), ); let grew = true; while (grew) { @@ -635,6 +647,14 @@ export class ClassProtocols { ); } + /** Whether one of `names` is a member the collection type itself + * declares (`has`, `get`, `size`, `keys`, ...). */ + private exposesCollectionMember(collection: ts.Type, names: ReadonlySet): boolean { + return this.checker + .getPropertiesOfType(collection) + .some((property) => names.has(ts.symbolName(property))); + } + /** Whether class `symbol` declares a get or set accessor for one of * `names`. */ private hasAccessorFor(symbol: ts.Symbol, names: ReadonlySet): boolean { @@ -919,6 +939,41 @@ export class ClassProtocols { .find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type; } + /** A lib Map, Set, ReadonlyMap or ReadonlySet: a live collection. Handed + * to a structural type that spells one of its members, a copy would answer + * from its own fields and miss later writes to the collection, so the view + * stays the collection itself (checked-dynamic identity). */ + private isBuiltinCollection(type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + const target = type.isTypeReference() ? type.getTarget() : type; + const symbol = target?.getSymbol() ?? type.getSymbol(); + if (symbol === undefined) return false; + const name = ts.symbolName(symbol); + if (name !== "Map" && name !== "Set" && name !== "ReadonlyMap" && name !== "ReadonlySet") + return false; + return ( + (symbol.flags & ts.SymbolFlags.Class) === 0 && + symbol.declarations.length > 0 && + symbol.declarations.every( + (declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile, + ) + ); + } + + /** A builtin collection with an `any` or `unknown` type argument is + * UNTYPED (`new Map()`). It cannot back a structural view narrower than + * the values it may yield — `get` answers `any`, which the view declares + * as its own member type — so it keeps the record-shape refusal rather + * than carrying identity into the view. */ + private isUntypedCollection(type: ts.Type): boolean { + if (!type.isTypeReference()) return true; + const args = this.checker.getTypeArguments(type); + return ( + args.length === 0 || + args.some((arg) => (arg.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) + ); + } + private isProgramClassInstance(type: ts.Type): boolean { const symbol = type.getSymbol(); return ( @@ -941,6 +996,11 @@ export class ClassProtocols { return element !== undefined && this.carriesIdentity(element, marked); } if (this.isProgramClassInstance(type)) return true; + // A Map or Set is a live collection: a record built from it answers the + // members it copies once and misses every later write. An untyped + // `new Map()` cannot back a narrower view (see isUntypedCollection), so + // only a fully typed collection carries identity into the view. + if (this.isBuiltinCollection(type)) return !this.isUntypedCollection(type); // A live Pick or Omit view of a program class holds the instance itself. if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true; const symbol = type.getSymbol(); @@ -1013,6 +1073,17 @@ export class ClassProtocols { if (from === symbol) return false; const facts = this.facts; if (facts !== null) { + // A Map or Set that reaches the view, which answers one of its + // members: a record copy has no live backing behind `has`, `get` or + // `size`, and Node's answer changes with every later write. The view + // keeps checked identity, but not protocol dispatch: its members are + // the collection's own methods. + if (this.isBuiltinCollection(source)) { + const names = new Set( + this.checker.getPropertiesOfType(target).map((property) => ts.symbolName(property)), + ); + if (this.exposesCollectionMember(source, names)) this.collectionViews.add(symbol); + } // The flow: the source's classes and marked type reach `symbol`. const origins = facts.origins.get(symbol) ?? new Set(); for (const origin of this.originsOf(source, facts)) origins.add(origin); @@ -1029,6 +1100,18 @@ export class ClassProtocols { return true; } + /** Whether `type` is a structural view that a Map or Set reaches and that + * answers through the collection's own methods (see markIdentityFlows). + * Such a view keeps checked identity but never protocol dispatch: there + * is no stored member to read, and the collection answers the call. */ + collectionView(type: ts.Type): boolean { + this.index(); + if (type.isUnionType() || type.isIntersectionType()) + return ts.constituentTypes(type).some((part) => this.collectionView(part)); + const symbol = type.getSymbol(); + return symbol !== undefined && this.collectionViews.has(symbol); + } + /** Class-backed protocols require live receiver dispatch. Other interfaces, * including factories backed by static methods and ordinary records of * closures, retain their fixed native layouts. */ @@ -1048,9 +1131,12 @@ export class ClassProtocols { // it (a literal whose methods read `this`) or when it receives existing // objects (markDictionaryViews). const dictionaryValue = this.dictionaryValueType(type); + const view = type.getSymbol(); const answer = this.protocol(type) || - (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)); + (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)) || + // A record view of a Map or Set answers through the collection itself. + (view !== undefined && this.collectionViews.has(view)); this.answers.set(type, answer); return answer; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a16d2f3965..498c7d4e33 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -5119,6 +5119,13 @@ export class Lowerer { return this.classProtocols.usesCheckedIdentity(type); } + /** A record view a Map or Set answers: it keeps protocol identity but + * calls the collection's own methods, never a stored member. */ + classCollectionView(type: ts.Type): boolean { + this.classProtocols ??= new ClassProtocols(this.checker, this.moduleOrder); + return this.classProtocols.collectionView(type); + } + /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array * of those), names checked protocol identity, is an owned runtime diff --git a/packages/compiler/test/class-structural-views.test.ts b/packages/compiler/test/class-structural-views.test.ts index b030431d22..d9d5d5cef8 100644 --- a/packages/compiler/test/class-structural-views.test.ts +++ b/packages/compiler/test/class-structural-views.test.ts @@ -131,3 +131,23 @@ console.log(view.tick()); `); expect(kinds["view"]).toBe("dyn"); }); + +test("a Map received as a record with one of its members stays the collection", async () => { + const kinds = await globalKinds(` +type Exists = { has(key: string): boolean }; +const byId = new Map(); +const view: Exists = byId; +console.log(view.has("a"), byId.has("a")); +`); + expect(kinds["view"]).toBe("dyn"); +}); + +test("a Set received as a record with one of its members stays the collection", async () => { + const kinds = await globalKinds(` +type Exists = { has(key: string): boolean }; +const seen = new Set(); +const view: Exists = seen; +console.log(view.has("a"), seen.has("a")); +`); + expect(kinds["view"]).toBe("dyn"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json new file mode 100644 index 0000000000..420a665237 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6956-collection-record-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6956-collection-record-views.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6956-collection-record-views.ts b/tests/corpus/6956-collection-record-views.ts new file mode 100644 index 0000000000..dc296ab954 --- /dev/null +++ b/tests/corpus/6956-collection-record-views.ts @@ -0,0 +1,42 @@ +// A Map or Set handed to a record type that spells one of its members stays +// the collection itself: later writes are visible through the view, and the +// view casts back to the same collection. +type Exists = { has(key: string): boolean }; + +function freeId(existing: Exists): string { + for (let i = 0; i < 5; i++) { + const id = "id" + i; + if (!existing.has(id)) return id; + } + return "none"; +} + +const byId = new Map(); +byId.set("id0", 1); +console.log(freeId(byId)); +byId.set("id1", 2); +console.log(freeId(byId)); + +const seen = new Set(); +seen.add("id0"); +seen.add("id1"); +seen.add("id2"); +console.log(freeId(seen)); + +// The view is live: entries added after the conversion are visible. +const view: Exists = byId; +byId.set("id3", 3); +console.log(view.has("id3"), byId.has("id3")); +console.log((view as unknown as Map) === byId); +const setView: Exists = seen; +seen.add("id3"); +console.log(setView.has("id3"), (setView as unknown as Set) === seen); + +// A record literal of the same shape keeps working. +console.log(freeId({ has: (key: string) => key === "id0" })); + +// A member Map answers without a stored field. +type Sized = { readonly size: number }; +const sized: Sized = byId; +byId.set("id4", 4); +console.log(sized.size, byId.size); diff --git a/tests/harness/corpus-costs/6956-collection-record-views.ts.json b/tests/harness/corpus-costs/6956-collection-record-views.ts.json new file mode 100644 index 0000000000..60d7b373e4 --- /dev/null +++ b/tests/harness/corpus-costs/6956-collection-record-views.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 5500 }