diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index a07a4d3e1..789ee7a8c 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -170,14 +170,19 @@ export function collectClassMethodMutations( // A string index whose values cannot be functions excludes callable // method slots. Class instances have no implicit index signature, so such // a dictionary is only a class view when the class declares that index - // signature, and then every method must be assignable to its value type. - const functionFreeDictionary = (value: ts.Expression): boolean => - lowerer.checker - .getIndexInfosOfType(lowerer.typeOf(value)) + // signature, and then every method must be assignable to its value type, + // or when the dictionary keeps checked identity: it then holds the + // existing objects it receives, class instances included. + const functionFreeDictionary = (value: ts.Expression): boolean => { + const type = lowerer.typeOf(value); + if (lowerer.classImplementedProtocol(type)) return false; + return lowerer.checker + .getIndexInfosOfType(type) .some( (index) => (index.keyType.flags & ts.TypeFlags.StringLike) !== 0 && functionFree(index.type), ); + }; const literalLoopKeys = (key: ts.Expression): (string | number)[] | null => { if (!ts.isIdentifier(key)) return null; const symbol = lowerer.checker.getSymbolAtLocation(key); diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 4edf1ab7e..00eedb6c0 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -983,11 +983,13 @@ export class ClassProtocols { if ((type.flags & ts.TypeFlags.Object) === 0) return false; const previous = this.answers.get(type); if (previous !== undefined) return previous; + // A dictionary keeps checked identity when the protocols already mark + // it (a literal whose methods read `this`) or when it receives existing + // objects (markDictionaryViews). const dictionaryValue = this.dictionaryValueType(type); const answer = - dictionaryValue !== undefined - ? (this.index(), this.dictionaryViews.has(dictionaryValue)) - : this.protocol(type); + this.protocol(type) || + (dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)); this.answers.set(type, answer); return answer; } diff --git a/packages/compiler/test/class-method-mutations.test.ts b/packages/compiler/test/class-method-mutations.test.ts index b63e47619..3e29a1da2 100644 --- a/packages/compiler/test/class-method-mutations.test.ts +++ b/packages/compiler/test/class-method-mutations.test.ts @@ -178,3 +178,20 @@ test("a store into a generic dictionary of dictionaries keeps checked dispatch", .replace("const settings: Settings =", "const settings: Settings ="); expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]); }); + +test("a runtime-key store into a dictionary view of a class instance keeps checked dispatch", async () => { + const source = ` +class Counter { + n = 0; + tick(): number { return ++this.n; } +} +const counter = new Counter(); +const view: {} = counter; +const slots: Record = view; +const key: string = String(process.argv.length); +slots[key] = "replaced"; +console.log(counter.tick()); +`; + // The method has a slot the write can replace, which calls consult. + expect(await callersOf(source, "%Counter.tick")).toContain("%method.value:%Counter.tick"); +}); diff --git a/packages/compiler/test/dictionary-views.test.ts b/packages/compiler/test/dictionary-views.test.ts index b841447cd..a9cfd1056 100644 --- a/packages/compiler/test/dictionary-views.test.ts +++ b/packages/compiler/test/dictionary-views.test.ts @@ -75,3 +75,15 @@ console.log(table["a"]);`), ); expect(kinds["table"]).toBe("record"); }); + +test("a dictionary literal whose methods read this keeps checked identity", async () => { + const kinds = await globalKinds( + program(`interface Table { [key: string]: () => number } +const methods: Table = { + base() { return 2; }, + twice() { return this.base() * 2; }, +}; +console.log(methods.twice());`), + ); + expect(kinds["methods"]).toBe("dyn"); +}); diff --git a/tests/corpus/7012-dictionary-views-keep-objects.ts b/tests/corpus/7012-dictionary-views-keep-objects.ts index 8ede80926..0df47cbb8 100644 --- a/tests/corpus/7012-dictionary-views-keep-objects.ts +++ b/tests/corpus/7012-dictionary-views-keep-objects.ts @@ -53,9 +53,46 @@ const slots: Record = greeterView; slots[process.argv.length > 99 ? "never" : "greet"] = "replaced"; slots["name"] = "renamed"; console.log(typeof greeter.greet, greeter.name, JSON.stringify(greeter)); +// A write under a runtime string key replaces a method too, and a direct +// call reaches the replacement, which is not a function. +class Counter { + n = 0; + tick(): number { + return ++this.n; + } +} +const counter = new Counter(); +const counterView: {} = counter; +const counterSlots: Record = counterView; +const methodKey: string = process.argv.length > 99 ? "never" : "tick"; +counterSlots[methodKey] = "replaced"; +try { + console.log(counter.tick()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} +try { + console.log(greeter.greet()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} // Deleting through the dictionary removes the key from the object. const pruned: { a: string; b?: string } = { a: "x", b: "y" }; const prunedTable: Record = pruned; delete prunedTable[process.argv.length > 99 ? "never" : "b"]; console.log(JSON.stringify(pruned), "b" in pruned); + +// A dictionary literal whose methods read `this` keeps its live object. +interface Table { + [key: string]: () => number; +} +const table: Table = { + base() { + return 2; + }, + twice() { + return this.base() * 2; + }, +}; +console.log(table.twice(), Object.keys(table).join(","));