Skip to content
This repository was archived by the owner on May 9, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/lib/deep-freeze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { DeepReadonly } from './deep-readonly';

export function deepFreeze<T extends object>(target: T): DeepReadonly<T> {
Object.freeze(target);

const targetIsFunction = isFunction(target);

for (const prop of Object.getOwnPropertyNames(target)) {
// Do not freeze Ivy properties (e.g. router state)
// https://github.com/ngrx/platform/issues/2109#issuecomment-582689060
if (prop.startsWith('ɵ')) {
continue;
}

if (
Object.hasOwn(target, prop) &&
(targetIsFunction
? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments'
: true)
) {
const propValue = (target as Record<string, unknown>)[prop];

if (
(isObjectLike(propValue) || isFunction(propValue)) &&
!Object.isFrozen(propValue)
) {
deepFreeze(propValue);
}
}
}

return target as DeepReadonly<T>;
}

function isFunction(target: unknown): target is () => unknown {
return typeof target === 'function';
}

function isObjectLike(target: unknown): target is object {
return typeof target === 'object' && target !== null;
}
5 changes: 5 additions & 0 deletions src/lib/deep-readonly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type DeepReadonly<T> = T extends Array<infer AT>
? ReadonlyArray<AT>
: T extends Record<string, unknown>
? Readonly<{ [Key in keyof T]: DeepReadonly<T[Key]> }>
: Readonly<T>;
24 changes: 24 additions & 0 deletions src/lib/signal-state.prod.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { testEffects } from '../tests/test-effects';
import { enableProdMode } from '@angular/core';
import { signalState } from './signal-state';

// needs a separate file because we can't revert the prod mode.
it(
'should not freeze state with immutability check in prod mode',
testEffects(() => {
enableProdMode();
const state = signalState(
{
person: { firstName: 'John', lastName: 'Smith' },
},
{ immutabilityCheck: true }
);

expect(() =>
state.$update((state) => {
(state as any).person.firstName = 'Johannes';
return state;
})
).not.toThrow();
})
);
245 changes: 182 additions & 63 deletions src/lib/signal-state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,211 @@
import { effect, isDevMode } from '@angular/core';
import * as angularCore from '@angular/core';
import { testEffects } from '../tests/test-effects';
import { signalState } from './signal-state';
import { ApplicationRef, Component, effect } from '@angular/core';
import { fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { state } from '@angular/animations';
import { selectSignal } from './select-signal';

describe('Signal State', () => {
const initialState = {
interface TestState {
user: { firstName: string; lastName: string };
foo: string;
numbers: number[];
}

const getInitialState = () => ({
user: {
firstName: 'John',
lastName: 'Smith',
},
foo: 'bar',
numbers: [1, 2, 3],
};
});

const setup = () => signalState(initialState);
describe('Basics', () => {
it('should support nested signals', () => {
const initialState = getInitialState();
const state = signalState(initialState);

it('should support nested signals', () => {
const state = setup();
expect(state()).toBe(initialState);
expect(state.user()).toBe(initialState.user);
expect(state.user.firstName()).toBe('John');
});

expect(state()).toBe(initialState);
expect(state.user()).toBe(initialState.user);
expect(state.user.firstName()).toBe(initialState.user.firstName);
});
it('should allow updates', () => {
const initialState = getInitialState();
const state = signalState(initialState);
state.$update((state) => ({
...state,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
}));
expect(state()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
});
});

it('should allow updates', () => {
const state = setup();
state.$update((state) => ({
...state,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
}));
expect(state()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
it('should update immutably', () => {
const initialState = getInitialState();
const state = signalState(initialState);
state.$update((state) => ({
...state,
foo: 'bar',
numbers: [3, 2, 1],
}));
expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
});
});

it('should update immutably', () => {
const state = setup();
state.$update((state) => ({
...state,
foo: 'bar',
numbers: [3, 2, 1],
}));
expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
describe('equal checks', () => {
const setup = () => signalState(getInitialState());

it(
'should not fire unchanged signals on update',
testEffects((runEffects) => {
const state = setup();

const numberEffect = jest.fn(() => state.numbers());
effect(numberEffect);

const userEffect = jest.fn(() => state.user());
effect(userEffect);

expect(numberEffect).toHaveBeenCalledTimes(0);
expect(userEffect).toHaveBeenCalledTimes(0);

// run effects for the first time
runEffects();
expect(numberEffect).toHaveBeenCalledTimes(1);
expect(userEffect).toHaveBeenCalledTimes(1);

// update state with effect run
state.$update((state) => ({ ...state, numbers: [4, 5, 6] }));
runEffects();
expect(numberEffect).toHaveBeenCalledTimes(2);
expect(userEffect).toHaveBeenCalledTimes(1);
})
);

it(
'should not fire for unchanged derived signals',
testEffects((runEffects) => {
const state = setup();

const numberCount = selectSignal(
state,
(state) => state.numbers.length
);

const numberEffect = jest.fn(() => numberCount());
effect(numberEffect);

// run effects for the first time
runEffects();
expect(numberEffect).toHaveBeenCalledTimes(1);

// update user
state.$update({
user: { firstName: 'Susanne', lastName: 'Taylor' },
});
runEffects();
expect(numberEffect).toHaveBeenCalledTimes(1);

// update numbers
state.$update({ numbers: [1] });
runEffects();
expect(numberEffect).toHaveBeenCalledTimes(2);
expect(numberCount()).toBe(1);
})
);
});

// TODO: Find a better way to execute effects
describe('Effects in Signals', () => {
@Component({
template: '',
})
class TestComponent {
userFired = 0;
numbersFired = 0;
state = setup();

constructor() {
effect(() => {
this.userFired++;
this.state.user();
describe('immutability', () => {
const setup = (immutabilityCheck = false) =>
signalState(getInitialState(), { immutabilityCheck });

it(
'should run nested-based effects on mutable updates',
testEffects((runEffects) => {
let numberCounter = 0;
const state = setup();
const effectFn = jest.fn(() => state.user());
effect(effectFn);
runEffects();

// mutable update
state.$update((state) => {
(state as any).user = { firstName: 'John', lastName: 'Smith' };
return state;
});

const numbersListener = effect(() => {
this.numbersFired++;
this.state.numbers();
runEffects();
expect(effectFn).toHaveBeenCalledTimes(2);
})
);

it(
'should run selectSignal-based effects on mutable updates',
testEffects((runEffects) => {
let numberCounter = 0;
const state = setup();
const userSignal = selectSignal(state, (state) => state.user);
const effectFn = jest.fn(() => userSignal());
effect(effectFn);
runEffects();

// mutable update
state.$update((state) => {
(state as any).user = { firstName: 'John', lastName: 'Smith' };
return state;
});
}
}
runEffects();
expect(effectFn).toHaveBeenCalledTimes(2);
})
);

it(
'should not freeze on mutable updates',
testEffects(() => {
const state = setup();

it('should not fire all signals on update', fakeAsync(() => {
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
expect(() =>
state.$update((state) => {
(state as any).foo = 'bar';
return { ...state };
})
).not.toThrow();
})
);

expect(component.numbersFired).toBe(0);
expect(component.userFired).toBe(0);
it(
'should freeze on mutable updates with immutability check',
testEffects(() => {
const state = setup(true);

fixture.detectChanges();
expect(() =>
state.$update((state) => {
(state as any).foo = 'bar';
return state;
})
).toThrow();
})
);

expect(component.numbersFired).toBe(1);
expect(component.userFired).toBe(1);
it(
'should freeze on consecutive mutable updates with immutability check',
testEffects(() => {
const state = setup(true);

component.state.$update((state) => ({ ...state, numbers: [4, 5, 6] }));
fixture.detectChanges();
state.$update((state) => ({ ...state, foo: 'foobar' }));

expect(component.numbersFired).toBe(2);
expect(component.userFired).toBe(1);
}));
expect(() =>
state.$update((state) => {
(state as any).foo = 'barfoo';
return state;
})
).toThrow();
})
);
});
});
Loading