Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(signals): create deep signals for custom class instances #4614

Merged
Merged
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
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
Loading