diff --git a/modules/signals/spec/deep-signal.spec.ts b/modules/signals/spec/deep-signal.spec.ts new file mode 100644 index 0000000000..0fdd89795e --- /dev/null +++ b/modules/signals/spec/deep-signal.spec.ts @@ -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); + }); +}); diff --git a/modules/signals/spec/types/signal-state.types.spec.ts b/modules/signals/spec/types/signal-state.types.spec.ts index c9edeab141..e4bf63b2ad 100644 --- a/modules/signals/spec/types/signal-state.types.spec.ts +++ b/modules/signals/spec/types/signal-state.types.spec.ts @@ -118,37 +118,85 @@ describe('signalState', () => { expectSnippet(snippet).toInfer('set', 'Signal>'); }); - it('does not create deep signals for an array', () => { + it('does not create deep signals for iterables', () => { const snippet = ` - const state = signalState([]); - declare const stateKeys: keyof typeof state; + const arrayState = signalState([]); + declare const arrayStateKeys: keyof typeof arrayState; + + const setState = signalState(new Set()); + declare const setStateKeys: keyof typeof setState; + + const mapState = signalState(new Map()); + 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' ); + + expectSnippet(snippet).toInfer( + 'setStateKeys', + 'unique symbol | keyof Signal>' + ); + + expectSnippet(snippet).toInfer( + 'mapStateKeys', + 'unique symbol | keyof Signal>' + ); + + expectSnippet(snippet).toInfer( + 'uintArrayStateKeys', + 'unique symbol | keyof Signal' + ); }); - 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()); - 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>' + 'weakSetStateKeys', + 'unique symbol | keyof Signal>' + ); + + expectSnippet(snippet).toInfer( + 'dateStateKeys', + 'unique symbol | keyof Signal' + ); + + expectSnippet(snippet).toInfer( + 'errorStateKeys', + 'unique symbol | keyof Signal' + ); + + expectSnippet(snippet).toInfer( + 'regExpStateKeys', + 'unique symbol | keyof Signal' ); }); - it('does not create deep signals for Set', () => { + it('does not create deep signals for functions', () => { const snippet = ` - const state = signalState(new Set()); + const state = signalState(() => {}); declare const stateKeys: keyof typeof state; `; @@ -156,7 +204,7 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'stateKeys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal<() => void>' ); }); diff --git a/modules/signals/spec/types/signal-store.types.spec.ts b/modules/signals/spec/types/signal-store.types.spec.ts index aeb0ab73f4..6854f27064 100644 --- a/modules/signals/spec/types/signal-store.types.spec.ts +++ b/modules/signals/spec/types/signal-store.types.spec.ts @@ -163,33 +163,63 @@ describe('signalStore', () => { expectSnippet(snippet).toInfer('set', 'Signal>'); }); - 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([])); - const store = new Store(); - declare const storeKeys: keyof typeof store; + const ArrayStore = signalStore(withState([])); + 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())); + 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())); - 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; `; diff --git a/modules/signals/src/deep-signal.ts b/modules/signals/src/deep-signal.ts index 733e085fc7..7610d9ad8e 100644 --- a/modules/signals/src/deep-signal.ts +++ b/modules/signals/src/deep-signal.ts @@ -46,6 +46,38 @@ export function toDeepSignal(signal: Signal): DeepSignal { }); } +const nonRecords = [ + WeakSet, + WeakMap, + Promise, + Date, + Error, + RegExp, + ArrayBuffer, + DataView, + Function, +]; + function isRecord(value: unknown): value is Record { - 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 { + return typeof value?.[Symbol.iterator] === 'function'; } diff --git a/modules/signals/src/ts-helpers.ts b/modules/signals/src/ts-helpers.ts index f7df5cdb77..bf94d5965b 100644 --- a/modules/signals/src/ts-helpers.ts +++ b/modules/signals/src/ts-helpers.ts @@ -1,13 +1,19 @@ +type NonRecord = + | Iterable + | WeakSet + | WeakMap + | Promise + | Date + | Error + | RegExp + | ArrayBuffer + | DataView + | Function; + export type Prettify = { [K in keyof T]: T[K] } & {}; export type IsRecord = T extends object - ? T extends unknown[] - ? false - : T extends Set - ? false - : T extends Map - ? false - : T extends Function + ? T extends NonRecord ? false : true : false;