Skip to content

Commit e921e10

Browse files
crisbetothePunderWoman
authored andcommitted
refactor(core): correctly distinguish getter functions from writable signals (angular#54252)
Fixes that `ɵunwrapWritableSignal` inferring getter functions as not matching the interface of `WritableSignal` instead of preserving them. PR Close angular#54252
1 parent a4a76c3 commit e921e10

7 files changed

Lines changed: 59 additions & 5 deletions

File tree

goldens/public-api/core/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,6 +1766,8 @@ export abstract class ViewRef extends ChangeDetectorRef {
17661766

17671767
// @public
17681768
export interface WritableSignal<T> extends Signal<T> {
1769+
// (undocumented)
1770+
[WRITABLE_SIGNAL]: T;
17691771
asReadonly(): Signal<T>;
17701772
set(value: T): void;
17711773
update(updateFn: (value: T) => T): void;

packages/compiler-cli/src/ngtsc/testing/fake_core/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,21 @@ export type Signal<T> = (() => T)&{
126126
[SIGNAL]: unknown;
127127
};
128128

129+
/**
130+
* Symbol used to tell distinguish `WritableSignal` from other non-writable signals and functions.
131+
*/
132+
const WRITABLE_SIGNAL = /* @__PURE__ */ Symbol('WRITABLE_SIGNAL');
133+
129134
export interface WritableSignal<T> extends Signal<T> {
135+
[WRITABLE_SIGNAL]: T;
130136
set(value: T): void;
131137
update(updateFn: (value: T) => T): void;
132138
asReadonly(): Signal<T>;
133139
}
134140

135-
export function ɵunwrapWritableSignal<T>(value: T|WritableSignal<T>): T {
141+
// Note: needs to be kept in sync with the copies in `render3/reactivity/signal.ts` and
142+
// `ngtsc/typecheck/testing/index.ts` to ensure consistent tests.
143+
export function ɵunwrapWritableSignal<T>(value: T|{[WRITABLE_SIGNAL]: T}): T {
136144
return null!;
137145
}
138146

packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,12 @@ export function angularCoreDts(): TestFile {
118118
export declare function signal<T>(initialValue: T): WritableSignal<T>;
119119
export declare function computed<T>(computation: () => T): Signal<T>;
120120
121+
const WRITABLE_SIGNAL = /* @__PURE__ */ Symbol('WRITABLE_SIGNAL');
122+
121123
export interface WritableSignal<T> extends Signal<T> {
124+
[WRITABLE_SIGNAL]: T;
125+
set(value: T): void;
126+
update(updateFn: (value: T) => T): void;
122127
asReadonly(): Signal<T>;
123128
}
124129
@@ -177,7 +182,9 @@ export function angularCoreDts(): TestFile {
177182
178183
export type Signal<T> = (() => T);
179184
180-
export function ɵunwrapWritableSignal<T>(value: T|WritableSignal<T>): T {
185+
// Note: needs to be kept in sync with the copies in render3/reactivity/signal.ts and
186+
// fake_core/index.ts to ensure consistent tests.
187+
export function ɵunwrapWritableSignal<T>(value: T|{[WRITABLE_SIGNAL]: T}): T {
181188
return null!;
182189
}
183190

packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,36 @@ export declare class AnimationEvent {
479479
expect(diags).toEqual([]);
480480
});
481481

482+
it('should type check a two-way binding to a function value', () => {
483+
env.tsconfig({strictTemplates: true});
484+
env.write('test.ts', `
485+
import {Component, Directive, Input, Output, EventEmitter} from '@angular/core';
486+
487+
type TestFn = (val: number | null | undefined) => string;
488+
489+
@Directive({selector: '[dir]', standalone: true})
490+
export class Dir {
491+
@Input() val!: TestFn;
492+
@Output() valChange = new EventEmitter<TestFn>();
493+
}
494+
495+
@Component({
496+
template: '<input dir [(val)]="invalidType">',
497+
standalone: true,
498+
imports: [Dir],
499+
})
500+
export class FooCmp {
501+
invalidType = (val: string) => 0;
502+
}
503+
`);
504+
505+
const diags = env.driveDiagnostics();
506+
expect(diags.length).toBe(1);
507+
expect(diags[0].messageText).toEqual(jasmine.objectContaining({
508+
messageText: `Type '(val: string) => number' is not assignable to type 'TestFn'.`,
509+
}));
510+
});
511+
482512
describe('strictInputTypes', () => {
483513
beforeEach(() => {
484514
env.write('test.ts', `

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,5 @@ export class Identifiers {
408408
// type-checking
409409
static InputSignalBrandWriteType = {name: 'ɵINPUT_SIGNAL_BRAND_WRITE_TYPE', moduleName: CORE};
410410
static UnwrapDirectiveSignalInputs = {name: 'ɵUnwrapDirectiveSignalInputs', moduleName: CORE};
411-
static WritableSignal = {name: 'WritableSignal', moduleName: CORE};
412411
static unwrapWritableSignal = {name: 'ɵunwrapWritableSignal', moduleName: CORE};
413412
}

packages/core/src/render3/reactivity/signal.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ import {createSignal, SIGNAL, SignalGetter, SignalNode, signalSetFn, signalUpdat
1010

1111
import {isSignal, Signal, ValueEqualityFn} from './api';
1212

13+
/** Symbol used distinguish `WritableSignal` from other non-writable signals and functions. */
14+
const WRITABLE_SIGNAL = /* @__PURE__ */ Symbol('WRITABLE_SIGNAL');
15+
1316
/**
1417
* A `Signal` with a value that can be mutated via a setter interface.
1518
*/
1619
export interface WritableSignal<T> extends Signal<T> {
20+
[WRITABLE_SIGNAL]: T;
21+
1722
/**
1823
* Directly set the signal to a new value, and notify any dependents.
1924
*/
@@ -37,7 +42,11 @@ export interface WritableSignal<T> extends Signal<T> {
3742
* Utility function used during template type checking to extract the value from a `WritableSignal`.
3843
* @codeGenApi
3944
*/
40-
export function ɵunwrapWritableSignal<T>(value: T|WritableSignal<T>): T {
45+
export function ɵunwrapWritableSignal<T>(value: T|{[WRITABLE_SIGNAL]: T}): T {
46+
// Note: needs to be kept in sync with the copies in `fake_core/index.ts` and
47+
// `ngtsc/typecheck/testing/index.ts` to ensure consistent tests.
48+
// Note: the function uses `WRITABLE_SIGNAL` as a brand instead of `WritableSignal<T>`,
49+
// because the latter incorrectly unwraps non-signal getter functions.
4150
return null!;
4251
}
4352

packages/core/test/render3/jit_environment_spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ const AOT_ONLY = new Set<string>([
3434
// used in type-checking.
3535
'ɵINPUT_SIGNAL_BRAND_WRITE_TYPE',
3636
'ɵUnwrapDirectiveSignalInputs',
37-
'WritableSignal',
3837
'ɵunwrapWritableSignal',
3938
]);
4039

0 commit comments

Comments
 (0)