Skip to content

Commit

Permalink
fix(signals): create deep signals for custom class instances (#4614)
Browse files Browse the repository at this point in the history
Closes #4604
  • Loading branch information
markostanimirovic authored Dec 10, 2024
1 parent a31c2a6 commit 4d34dc4
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 32 deletions.
161 changes: 161 additions & 0 deletions modules/signals/spec/deep-signal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { isSignal, signal } from '@angular/core';
import { toDeepSignal } from '../src/deep-signal';

describe('toDeepSignal', () => {
it('creates deep signals for plain objects', () => {
const sig = signal({ m: { s: 't' } });
const deepSig = toDeepSignal(sig);

expect(sig).not.toBe(deepSig);

expect(isSignal(deepSig)).toBe(true);
expect(deepSig()).toEqual({ m: { s: 't' } });

expect(isSignal(deepSig.m)).toBe(true);
expect(deepSig.m()).toEqual({ s: 't' });

expect(isSignal(deepSig.m.s)).toBe(true);
expect(deepSig.m.s()).toBe('t');
});

it('creates deep signals for custom class instances', () => {
class User {
constructor(readonly firstName: string) {}
}

class UserState {
constructor(readonly user: User) {}
}

const sig = signal(new UserState(new User('John')));
const deepSig = toDeepSignal(sig);

expect(sig).not.toBe(deepSig);

expect(isSignal(deepSig)).toBe(true);
expect(deepSig()).toEqual({ user: { firstName: 'John' } });

expect(isSignal(deepSig.user)).toBe(true);
expect(deepSig.user()).toEqual({ firstName: 'John' });

expect(isSignal(deepSig.user.firstName)).toBe(true);
expect(deepSig.user.firstName()).toBe('John');
});

it('does not create deep signals for primitives', () => {
const num = signal(0);
const str = signal('str');
const bool = signal(true);

const deepNum = toDeepSignal(num);
const deepStr = toDeepSignal(str);
const deepBool = toDeepSignal(bool);

expect(deepNum).toBe(num);
expect(deepStr).toBe(str);
expect(deepBool).toBe(bool);
});

it('does not create deep signals for iterables', () => {
const array = signal([]);
const set = signal(new Set());
const map = signal(new Map());
const uintArray = signal(new Uint32Array());
const floatArray = signal(new Float64Array());

const deepArray = toDeepSignal(array);
const deepSet = toDeepSignal(set);
const deepMap = toDeepSignal(map);
const deepUintArray = toDeepSignal(uintArray);
const deepFloatArray = toDeepSignal(floatArray);

expect(deepArray).toBe(array);
expect(deepSet).toBe(set);
expect(deepMap).toBe(map);
expect(deepUintArray).toBe(uintArray);
expect(deepFloatArray).toBe(floatArray);
});

it('does not create deep signals for built-in object types', () => {
const weakSet = signal(new WeakSet());
const weakMap = signal(new WeakMap());
const promise = signal(Promise.resolve(10));
const date = signal(new Date());
const error = signal(new Error());
const regExp = signal(new RegExp(''));
const arrayBuffer = signal(new ArrayBuffer(10));
const dataView = signal(new DataView(new ArrayBuffer(10)));

const deepWeakSet = toDeepSignal(weakSet);
const deepWeakMap = toDeepSignal(weakMap);
const deepPromise = toDeepSignal(promise);
const deepDate = toDeepSignal(date);
const deepError = toDeepSignal(error);
const deepRegExp = toDeepSignal(regExp);
const deepArrayBuffer = toDeepSignal(arrayBuffer);
const deepDataView = toDeepSignal(dataView);

expect(deepWeakSet).toBe(weakSet);
expect(deepWeakMap).toBe(weakMap);
expect(deepPromise).toBe(promise);
expect(deepDate).toBe(date);
expect(deepError).toBe(error);
expect(deepRegExp).toBe(regExp);
expect(deepArrayBuffer).toBe(arrayBuffer);
expect(deepDataView).toBe(dataView);
});

it('does not create deep signals for functions', () => {
const fn1 = signal(new Function());
const fn2 = signal(function () {});
const fn3 = signal(() => {});

const deepFn1 = toDeepSignal(fn1);
const deepFn2 = toDeepSignal(fn2);
const deepFn3 = toDeepSignal(fn3);

expect(deepFn1).toBe(fn1);
expect(deepFn2).toBe(fn2);
expect(deepFn3).toBe(fn3);
});

it('does not create deep signals for custom class instances that are iterables', () => {
class CustomArray extends Array {}

class CustomSet extends Set {}

class CustomFloatArray extends Float32Array {}

const array = signal(new CustomArray());
const floatArray = signal(new CustomFloatArray());
const set = signal(new CustomSet());

const deepArray = toDeepSignal(array);
const deepFloatArray = toDeepSignal(floatArray);
const deepSet = toDeepSignal(set);

expect(deepArray).toBe(array);
expect(deepFloatArray).toBe(floatArray);
expect(deepSet).toBe(set);
});

it('does not create deep signals for custom class instances that extend built-in object types', () => {
class CustomWeakMap extends WeakMap {}

class CustomError extends Error {}

class CustomArrayBuffer extends ArrayBuffer {}

const weakMap = signal(new CustomWeakMap());
const error = signal(new CustomError());
const arrayBuffer = signal(new CustomArrayBuffer(10));

const deepWeakMap = toDeepSignal(weakMap);
const deepError = toDeepSignal(error);
const deepArrayBuffer = toDeepSignal(arrayBuffer);

expect(deepWeakMap).toBe(weakMap);
expect(deepError).toBe(error);
expect(deepArrayBuffer).toBe(arrayBuffer);
});
});
72 changes: 60 additions & 12 deletions modules/signals/spec/types/signal-state.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,45 +118,93 @@ describe('signalState', () => {
expectSnippet(snippet).toInfer('set', 'Signal<Set<{ foo: number; }>>');
});

it('does not create deep signals for an array', () => {
it('does not create deep signals for iterables', () => {
const snippet = `
const state = signalState<string[]>([]);
declare const stateKeys: keyof typeof state;
const arrayState = signalState<string[]>([]);
declare const arrayStateKeys: keyof typeof arrayState;
const setState = signalState(new Set<number>());
declare const setStateKeys: keyof typeof setState;
const mapState = signalState(new Map<number, { bar: boolean }>());
declare const mapStateKeys: keyof typeof mapState;
const uintArrayState = signalState(new Uint8ClampedArray());
declare const uintArrayStateKeys: keyof typeof uintArrayState;
`;

expectSnippet(snippet).toSucceed();

expectSnippet(snippet).toInfer(
'stateKeys',
'arrayStateKeys',
'unique symbol | keyof Signal<string[]>'
);

expectSnippet(snippet).toInfer(
'setStateKeys',
'unique symbol | keyof Signal<Set<number>>'
);

expectSnippet(snippet).toInfer(
'mapStateKeys',
'unique symbol | keyof Signal<Map<number, { bar: boolean; }>>'
);

expectSnippet(snippet).toInfer(
'uintArrayStateKeys',
'unique symbol | keyof Signal<Uint8ClampedArray>'
);
});

it('does not create deep signals for Map', () => {
it('does not create deep signals for built-in object types', () => {
const snippet = `
const state = signalState(new Map<number, { bar: boolean }>());
declare const stateKeys: keyof typeof state;
const weakSetState = signalState(new WeakSet<{ foo: string }>());
declare const weakSetStateKeys: keyof typeof weakSetState;
const dateState = signalState(new Date());
declare const dateStateKeys: keyof typeof dateState;
const errorState = signalState(new Error());
declare const errorStateKeys: keyof typeof errorState;
const regExpState = signalState(new RegExp(''));
declare const regExpStateKeys: keyof typeof regExpState;
`;

expectSnippet(snippet).toSucceed();

expectSnippet(snippet).toInfer(
'stateKeys',
'unique symbol | keyof Signal<Map<number, { bar: boolean; }>>'
'weakSetStateKeys',
'unique symbol | keyof Signal<WeakSet<{ foo: string; }>>'
);

expectSnippet(snippet).toInfer(
'dateStateKeys',
'unique symbol | keyof Signal<Date>'
);

expectSnippet(snippet).toInfer(
'errorStateKeys',
'unique symbol | keyof Signal<Error>'
);

expectSnippet(snippet).toInfer(
'regExpStateKeys',
'unique symbol | keyof Signal<RegExp>'
);
});

it('does not create deep signals for Set', () => {
it('does not create deep signals for functions', () => {
const snippet = `
const state = signalState(new Set<number>());
const state = signalState(() => {});
declare const stateKeys: keyof typeof state;
`;

expectSnippet(snippet).toSucceed();

expectSnippet(snippet).toInfer(
'stateKeys',
'unique symbol | keyof Signal<Set<number>>'
'unique symbol | keyof Signal<() => void>'
);
});

Expand Down
54 changes: 42 additions & 12 deletions modules/signals/spec/types/signal-store.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,33 +163,63 @@ describe('signalStore', () => {
expectSnippet(snippet).toInfer('set', 'Signal<Set<number>>');
});

it('does not create deep signals when state type is an array', () => {
it('does not create deep signals when state type is an iterable', () => {
const snippet = `
const Store = signalStore(withState<number[]>([]));
const store = new Store();
declare const storeKeys: keyof typeof store;
const ArrayStore = signalStore(withState<number[]>([]));
const arrayStore = new ArrayStore();
declare const arrayStoreKeys: keyof typeof arrayStore;
const SetStore = signalStore(withState(new Set<{ foo: string }>()));
const setStore = new SetStore();
declare const setStoreKeys: keyof typeof setStore;
const MapStore = signalStore(withState(new Map<string, { foo: number }>()));
const mapStore = new MapStore();
declare const mapStoreKeys: keyof typeof mapStore;
const FloatArrayStore = signalStore(withState(new Float32Array()));
const floatArrayStore = new FloatArrayStore();
declare const floatArrayStoreKeys: keyof typeof floatArrayStore;
`;

expectSnippet(snippet).toSucceed();

expectSnippet(snippet).toInfer('storeKeys', 'unique symbol');
expectSnippet(snippet).toInfer('arrayStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('setStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('mapStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('floatArrayStoreKeys', 'unique symbol');
});

it('does not create deep signals when state type is Map', () => {
it('does not create deep signals when state type is a built-in object type', () => {
const snippet = `
const Store = signalStore(withState(new Map<string, { foo: number }>()));
const store = new Store();
declare const storeKeys: keyof typeof store;
const WeakMapStore = signalStore(withState(new WeakMap<{ foo: string }, { bar: number }>()));
const weakMapStore = new WeakMapStore();
declare const weakMapStoreKeys: keyof typeof weakMapStore;
const DateStore = signalStore(withState(new Date()));
const dateStore = new DateStore();
declare const dateStoreKeys: keyof typeof dateStore;
const ErrorStore = signalStore(withState(new Error()));
const errorStore = new ErrorStore();
declare const errorStoreKeys: keyof typeof errorStore;
const RegExpStore = signalStore(withState(new RegExp('')));
const regExpStore = new RegExpStore();
declare const regExpStoreKeys: keyof typeof regExpStore;
`;

expectSnippet(snippet).toSucceed();

expectSnippet(snippet).toInfer('storeKeys', 'unique symbol');
expectSnippet(snippet).toInfer('weakMapStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('dateStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('errorStoreKeys', 'unique symbol');
expectSnippet(snippet).toInfer('regExpStoreKeys', 'unique symbol');
});

it('does not create deep signals when state type is Set', () => {
it('does not create deep signals when state type is a function', () => {
const snippet = `
const Store = signalStore(withState(new Set<{ foo: string }>()));
const Store = signalStore(withState(() => () => {}));
const store = new Store();
declare const storeKeys: keyof typeof store;
`;
Expand Down
34 changes: 33 additions & 1 deletion modules/signals/src/deep-signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,38 @@ export function toDeepSignal<T>(signal: Signal<T>): DeepSignal<T> {
});
}

const nonRecords = [
WeakSet,
WeakMap,
Promise,
Date,
Error,
RegExp,
ArrayBuffer,
DataView,
Function,
];

function isRecord(value: unknown): value is Record<string, unknown> {
return value?.constructor === Object;
if (value === null || typeof value !== 'object' || isIterable(value)) {
return false;
}

let proto = Object.getPrototypeOf(value);
if (proto === Object.prototype) {
return true;
}

while (proto && proto !== Object.prototype) {
if (nonRecords.includes(proto.constructor)) {
return false;
}
proto = Object.getPrototypeOf(proto);
}

return proto === Object.prototype;
}

function isIterable(value: any): value is Iterable<any> {
return typeof value?.[Symbol.iterator] === 'function';
}
Loading

0 comments on commit 4d34dc4

Please sign in to comment.