Skip to content

Commit c7ff9df

Browse files
pkozlowski-opensourceatscott
authored andcommitted
fix(core): drop mutate function from the signals public API (angular#51821)
This change removes the `mutate` method from the `WritableSignal` interface and completely drops it from the public API surface. The initial API proposal for Angular signals included the mutate method, allowing in-place modification of JS objects, without changing their references (identity). This was based on the reasoning that identity change on modification is not necessary as we can send the “modified” notification through the signals graph. Unfortunately the signal-specific change notification is lost as soon as we read signal value outside of a reactive context (outside of a reactive graph). In other words - any code outside of the Angular signals library can’t know that an object is modified. Secondly, to make the mutate method work, we’ve defaulted the signal value equality function to the one that considers non-primitive values as always different. This is unfortunate for people working with immutable data structures (this is notably the case for the popular state management libraries) as the default equality function de-optimizes memoization in computed, making the application less performant. Given the above reasons we prefer to remove the mutate method in the signals library - at least for now. There are just too many sharp edges and tradeoffs that we don’t fully understand yet. BREAKING CHANGE: The `mutate` method was removed from the `WritableSignal` interface and completely dropped from the public API surface. As an alternative please use the update method and make immutable changes to the object. Example before: ```typescript items.mutate(itemsArray => itemsArray.push(newItem)); ``` Example after: ```typescript items.update(itemsArray => [itemsArray, …newItem]); ``` PR Close angular#51821
1 parent 04169e1 commit c7ff9df

File tree

4 files changed

+27
-74
lines changed

4 files changed

+27
-74
lines changed

goldens/public-api/core/index.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1629,7 +1629,6 @@ export abstract class ViewRef extends ChangeDetectorRef {
16291629
// @public
16301630
export interface WritableSignal<T> extends Signal<T> {
16311631
asReadonly(): Signal<T>;
1632-
mutate(mutatorFn: (value: T) => void): void;
16331632
set(value: T): void;
16341633
update(updateFn: (value: T) => T): void;
16351634
}

packages/core/src/signals/src/api.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,5 @@ export type ValueEqualityFn<T> = (a: T, b: T) => boolean;
5353
* @developerPreview
5454
*/
5555
export function defaultEquals<T>(a: T, b: T) {
56-
// `Object.is` compares two values using identity semantics which is desired behavior for
57-
// primitive values. If `Object.is` determines two values to be equal we need to make sure that
58-
// those don't represent objects (we want to make sure that 2 objects are always considered
59-
// "unequal"). The null check is needed for the special case of JavaScript reporting null values
60-
// as objects (`typeof null === 'object'`).
61-
return (a === null || typeof a !== 'object') && Object.is(a, b);
56+
return Object.is(a, b);
6257
}

packages/core/src/signals/src/signal.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,6 @@ export interface WritableSignal<T> extends Signal<T> {
3535
*/
3636
update(updateFn: (value: T) => T): void;
3737

38-
/**
39-
* Update the current value by mutating it in-place, and
40-
* notify any dependents.
41-
*/
42-
mutate(mutatorFn: (value: T) => void): void;
43-
4438
/**
4539
* Returns a readonly version of this signal. Readonly signals can be accessed to read their value
4640
* but can't be changed using set, update or mutate methods. The readonly signals do _not_ have
@@ -79,7 +73,6 @@ export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): Wr
7973

8074
signalFn.set = signalSetFn;
8175
signalFn.update = signalUpdateFn;
82-
signalFn.mutate = signalMutateFn;
8376
signalFn.asReadonly = signalAsReadonlyFn;
8477
(signalFn as any)[SIGNAL] = node;
8578

@@ -133,23 +126,9 @@ function signalSetFn<T>(this: SignalFn<T>, newValue: T) {
133126
}
134127

135128
function signalUpdateFn<T>(this: SignalFn<T>, updater: (value: T) => T): void {
136-
if (!producerUpdatesAllowed()) {
137-
throwInvalidWriteToSignalError();
138-
}
139-
140129
signalSetFn.call(this as any, updater(this[SIGNAL].value) as any);
141130
}
142131

143-
function signalMutateFn<T>(this: SignalFn<T>, mutator: (value: T) => void): void {
144-
const node = this[SIGNAL];
145-
if (!producerUpdatesAllowed()) {
146-
throwInvalidWriteToSignalError();
147-
}
148-
// Mutate bypasses equality checks as it's by definition changing the value.
149-
mutator(node.value);
150-
signalValueChanged(node);
151-
}
152-
153132
function signalAsReadonlyFn<T>(this: SignalFn<T>) {
154133
const node = this[SIGNAL];
155134
if (node.readonlyFn === undefined) {

packages/core/test/signals/signal_spec.ts

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,6 @@ describe('signals', () => {
2424
expect(counter()).toEqual(1);
2525
});
2626

27-
it('should have mutate function for mutable, out of bound updates', () => {
28-
const state = signal<string[]>(['a']);
29-
const derived = computed(() => state().join(':'));
30-
31-
expect(derived()).toEqual('a');
32-
33-
state.mutate((s) => {
34-
s.push('b');
35-
});
36-
expect(derived()).toEqual('a:b');
37-
});
38-
3927
it('should not update signal when new value is equal to the previous one', () => {
4028
const state = signal('aaa', {equal: (a, b) => a.length === b.length});
4129
expect(state()).toEqual('aaa');
@@ -72,31 +60,32 @@ describe('signals', () => {
7260
expect(upper()).toEqual('D');
7361
});
7462

75-
it('should consider objects as non-equal with the default equality function', () => {
76-
let stateValue: unknown = {};
77-
const state = signal(stateValue);
78-
let computeCount = 0;
79-
const derived = computed(() => `${typeof state()}:${++computeCount}`);
80-
expect(derived()).toEqual('object:1');
81-
82-
// reset signal value to the same object instance, expect change notification
83-
state.set(stateValue);
84-
expect(derived()).toEqual('object:2');
85-
86-
// reset signal value to a different object instance, expect change notification
87-
stateValue = {};
88-
state.set(stateValue);
89-
expect(derived()).toEqual('object:3');
90-
91-
// reset signal value to a different object type, expect change notification
92-
stateValue = [];
93-
state.set(stateValue);
94-
expect(derived()).toEqual('object:4');
95-
96-
// reset signal value to the same array instance, expect change notification
97-
state.set(stateValue);
98-
expect(derived()).toEqual('object:5');
99-
});
63+
it('should consider objects as equal based on their identity with the default equality function',
64+
() => {
65+
let stateValue: unknown = {};
66+
const state = signal(stateValue);
67+
let computeCount = 0;
68+
const derived = computed(() => `${typeof state()}:${++computeCount}`);
69+
expect(derived()).toEqual('object:1');
70+
71+
// reset signal value to the same object instance, expect NO change notification
72+
state.set(stateValue);
73+
expect(derived()).toEqual('object:1');
74+
75+
// reset signal value to a different object instance, expect change notification
76+
stateValue = {};
77+
state.set(stateValue);
78+
expect(derived()).toEqual('object:2');
79+
80+
// reset signal value to a different object type, expect change notification
81+
stateValue = [];
82+
state.set(stateValue);
83+
expect(derived()).toEqual('object:3');
84+
85+
// reset signal value to the same array instance, expect NO change notification
86+
state.set(stateValue);
87+
expect(derived()).toEqual('object:3');
88+
});
10089

10190
it('should allow converting writable signals to their readonly counterpart', () => {
10291
const counter = signal(0);
@@ -106,8 +95,6 @@ describe('signals', () => {
10695
expect(readOnlyCounter.set).toBeUndefined();
10796
// @ts-expect-error
10897
expect(readOnlyCounter.update).toBeUndefined();
109-
// @ts-expect-error
110-
expect(readOnlyCounter.mutate).toBeUndefined();
11198

11299
const double = computed(() => readOnlyCounter() * 2);
113100
expect(double()).toBe(0);
@@ -142,13 +129,6 @@ describe('signals', () => {
142129
expect(log).toBe(1);
143130
});
144131

145-
it('should call the post-signal-set fn when invoking .mutate()', () => {
146-
const counter = signal(0);
147-
148-
counter.mutate(() => {});
149-
expect(log).toBe(1);
150-
});
151-
152132
it('should not call the post-signal-set fn when the value doesn\'t change', () => {
153133
const counter = signal(0);
154134

0 commit comments

Comments
 (0)