From fd3059c43a28aa56c64eb9aae4710310e234086f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:22:25 +0000 Subject: [PATCH 1/3] Initial plan From 4ff61a54db8ac0d4c514817c3423896fc089c7b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:31:10 +0000 Subject: [PATCH 2/3] Create HooksCore and HooksBase classes, refactor DataCore to use hooks system Co-authored-by: sciborrudnicki <19817956+sciborrudnicki@users.noreply.github.com> --- src/lib/data-base.abstract.ts | 35 +++++++------- src/lib/data-core.abstract.ts | 88 ++-------------------------------- src/lib/hooks-base.abstract.ts | 64 +++++++++++++++++++++++++ src/lib/hooks-core.abstract.ts | 37 ++++++++++++++ src/lib/index.ts | 2 + src/lib/weak-data.class.ts | 20 +++++--- src/public-api.ts | 2 + 7 files changed, 142 insertions(+), 106 deletions(-) create mode 100644 src/lib/hooks-base.abstract.ts create mode 100644 src/lib/hooks-core.abstract.ts diff --git a/src/lib/data-base.abstract.ts b/src/lib/data-base.abstract.ts index bc3a72f..a6b1360 100644 --- a/src/lib/data-base.abstract.ts +++ b/src/lib/data-base.abstract.ts @@ -52,11 +52,12 @@ export abstract class DataBase extends DataCore { public clear(): this { const oldValue = this.value; const newValue = null as unknown as T; - return this.#value = null as unknown as T, - // Invokes the onChange callback if `newValue` and `this.value` has changed. - (typeof this.onChangeCallback === 'function' && DataBase.hasChanged(oldValue, newValue) - ? this.onChangeCallback(newValue, this.value) : newValue), - this; + this.#value = null as unknown as T; + // Invokes the onChange callback if `newValue` and `this.value` has changed. + if (typeof this.onChangeCallback === 'function' && DataBase.hasChanged(oldValue, newValue)) { + this.onChangeCallback(newValue, oldValue); + } + return this; } /** @@ -80,17 +81,19 @@ export abstract class DataBase extends DataCore { * @returns {this} The `this` current instance. */ public set(value: T): this { + super.validate(); const oldValue = this.value; - let newValue: T; - return super.validate(), - // Assigns the new value. - (newValue = typeof super.onSetCallback === 'function' ? super.onSetCallback(value) : value), - // Assigns the new value to the private value. - (this.#value = newValue), - // Invokes the onChange callback if `newValue` and `this.value` has changed. - (typeof this.onChangeCallback === 'function' && DataBase.hasChanged(oldValue, newValue) - ? this.onChangeCallback(newValue, this.value) : newValue), - // Returns this instance. - this; + // Invoke onSet callback before setting the value. + if (typeof super.onSetCallback === 'function') { + super.onSetCallback(value); + } + // Assign the new value to the private value. + this.#value = value; + // Invokes the onChange callback if the value has changed. + if (typeof this.onChangeCallback === 'function' && DataBase.hasChanged(oldValue, value)) { + this.onChangeCallback(value, oldValue); + } + // Returns this instance. + return this; } } diff --git a/src/lib/data-core.abstract.ts b/src/lib/data-core.abstract.ts index 661722f..764eee8 100644 --- a/src/lib/data-core.abstract.ts +++ b/src/lib/data-core.abstract.ts @@ -1,5 +1,5 @@ // Abstract. -import { Immutability } from './immutability.abstract'; +import { HooksBase } from './hooks-base.abstract'; // Interface. import { DataShape } from '@typedly/data'; /** @@ -8,12 +8,12 @@ import { DataShape } from '@typedly/data'; * @abstract * @class DataCore * @template T Represents the type of data value. - * @extends {Immutability} + * @extends {HooksBase} * @implements {DataShape} */ export abstract class DataCore - // For immutability features. - extends Immutability + // For immutability and hooks features. + extends HooksBase // For data shape contract, to use instead of `DataCore`. implements DataShape { /** @@ -82,53 +82,7 @@ export abstract class DataCore */ public abstract get value(): T; - /** - * @description Returns the onChange callback function. - * @protected - * @readonly - * @type {((value: T, oldValue: T) => T) | undefined} - */ - protected get onChangeCallback(): ((value: T, oldValue: T) => T) | undefined { - return this.#onChangeCallback; - } - - /** - * @description Returns the onDestroy callback function. - * @protected - * @readonly - * @type {(() => void) | undefined} - */ - protected get onDestroyCallback(): (() => void) | undefined { - return this.#onDestroyCallback; - } - - /** - * @description Returns the onSet callback function. - * @protected - * @readonly - * @type {((value: T) => T) | undefined} - */ - protected get onSetCallback(): ((value: T) => T) | undefined { - return this.#onSetCallback; - } - - /** - * @description Privately stored onChange callback function, defaults `undefined`. - * @type {?(value: T, oldValue: T) => T} - */ - #onChangeCallback?: (value: T, oldValue: T) => T; - - /** - * @description Privately stored onDestroy callback function, defaults `undefined`. - * @type {?() => void} - */ - #onDestroyCallback?: () => void; - /** - * @description Privately stored onSet callback function, defaults `undefined`. - * @type {?(value: T) => T} - */ - #onSetCallback?: (value: T) => T; /** * @description Clears the value by setting to `undefined` or `null`. @@ -152,43 +106,11 @@ export abstract class DataCore * @returns {this} */ public override lock(): this { - return Immutability.deepFreeze(this.value), + return HooksBase.deepFreeze(this.value), super.lock(), this; } - /** - * @description Sets the callback function invoked when the data value changes. - * @public - * @abstract - * @param {?(value: T, oldValue: T) => T} callbackfn The callback function to invoke. - * @returns {this} The `this` current instance. - */ - public onChange(callbackfn?: (value: T, oldValue: T) => T): this { - return this.#onChangeCallback = callbackfn, this; - } - - /** - * @description Sets the callback function to be invoked when destroying the data instance. - * @public - * @param {?() => void} callbackfn - * @returns {this} - */ - public onDestroy(callbackfn?: () => void): this { - return this.#onDestroyCallback = callbackfn, this; - } - - /** - * @description Sets the callback function to be invoked when setting the data value. - * @public - * @abstract - * @param {?(value: T) => T} callbackfn The callback function to invoke. - * @returns {this} The `this` current instance. - */ - public onSet(callbackfn?: (value: T) => T): this { - return this.#onSetCallback = callbackfn, this; - } - /** * @description Sets the value of `T` in arbitrary parameter array. * @public diff --git a/src/lib/hooks-base.abstract.ts b/src/lib/hooks-base.abstract.ts new file mode 100644 index 0000000..b758338 --- /dev/null +++ b/src/lib/hooks-base.abstract.ts @@ -0,0 +1,64 @@ +// Abstract. +import { HooksCore } from './hooks-core.abstract'; + +/** + * @description Enhanced hooks system with property-specific change and set hooks. + * @export + * @abstract + * @class HooksBase + * @template T Represents the type of data value. + * @extends {HooksCore} + */ +export abstract class HooksBase extends HooksCore { + /** + * @description Privately stored property-specific onChange callback function, defaults `undefined`. + * @type {?(value: T, oldValue: T) => void} + */ + #onChangeCallback?: (value: T, oldValue: T) => void; + + /** + * @description Privately stored property-specific onSet callback function, defaults `undefined`. + * @type {?(value: T) => void} + */ + #onSetCallback?: (value: T) => void; + + /** + * @description Returns the property-specific onChange callback function. + * @protected + * @readonly + * @type {((value: T, oldValue: T) => void) | undefined} + */ + protected get onChangeCallback(): ((value: T, oldValue: T) => void) | undefined { + return this.#onChangeCallback; + } + + /** + * @description Returns the property-specific onSet callback function. + * @protected + * @readonly + * @type {((value: T) => void) | undefined} + */ + protected get onSetCallback(): ((value: T) => void) | undefined { + return this.#onSetCallback; + } + + /** + * @description Sets the property-specific callback function invoked when the data value changes. + * @public + * @param {?(value: T, oldValue: T) => void} callbackfn The callback function to invoke. + * @returns {this} The `this` current instance. + */ + public onChange(callbackfn?: (value: T, oldValue: T) => void): this { + return this.#onChangeCallback = callbackfn, this; + } + + /** + * @description Sets the property-specific callback function to be invoked when setting the data value. + * @public + * @param {?(value: T) => void} callbackfn The callback function to invoke. + * @returns {this} The `this` current instance. + */ + public onSet(callbackfn?: (value: T) => void): this { + return this.#onSetCallback = callbackfn, this; + } +} diff --git a/src/lib/hooks-core.abstract.ts b/src/lib/hooks-core.abstract.ts new file mode 100644 index 0000000..55d0013 --- /dev/null +++ b/src/lib/hooks-core.abstract.ts @@ -0,0 +1,37 @@ +// Abstract. +import { Immutability } from './immutability.abstract'; + +/** + * @description Core hooks system for managing callbacks. + * @export + * @abstract + * @class HooksCore + * @extends {Immutability} + */ +export abstract class HooksCore extends Immutability { + /** + * @description Privately stored onDestroy callback function, defaults `undefined`. + * @type {?() => void} + */ + #onDestroyCallback?: () => void; + + /** + * @description Returns the onDestroy callback function. + * @protected + * @readonly + * @type {(() => void) | undefined} + */ + protected get onDestroyCallback(): (() => void) | undefined { + return this.#onDestroyCallback; + } + + /** + * @description Sets the callback function to be invoked when destroying the data instance. + * @public + * @param {?() => void} callbackfn + * @returns {this} + */ + public onDestroy(callbackfn?: () => void): this { + return this.#onDestroyCallback = callbackfn, this; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 7fd1828..be0fd86 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,5 +1,7 @@ // Abstract class. export { DataCore } from './data-core.abstract'; +export { HooksBase } from './hooks-base.abstract'; +export { HooksCore } from './hooks-core.abstract'; export { Immutability } from './immutability.abstract'; // Class. export { Data } from './data.class'; diff --git a/src/lib/weak-data.class.ts b/src/lib/weak-data.class.ts index c5b2101..1a5705a 100644 --- a/src/lib/weak-data.class.ts +++ b/src/lib/weak-data.class.ts @@ -144,13 +144,19 @@ export class WeakData extends DataCore { * @returns {this} The `this` current instance. */ public set(value: T): this { - let newValue: T; - return super.validate(), - (newValue = super.onSetCallback ? super.onSetCallback(value) : value), - // Invokes the onChange callback if `newValue` and `this.value` has changed. - (this.onChangeCallback && DataCore.hasChanged(this.value, newValue) ? this.onChangeCallback(newValue, this.value) : newValue), - WeakData.#valueOf().set(this, newValue), - this; + super.validate(); + const oldValue = this.value; + // Invoke onSet callback before setting the value. + if (super.onSetCallback) { + super.onSetCallback(value); + } + // Set the new value. + WeakData.#valueOf().set(this, value); + // Invokes the onChange callback if the value has changed. + if (this.onChangeCallback && DataCore.hasChanged(oldValue, value)) { + this.onChangeCallback(value, oldValue); + } + return this; } /** diff --git a/src/public-api.ts b/src/public-api.ts index 1b0f772..61fa779 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -4,6 +4,8 @@ export { // Abstract. DataCore, + HooksBase, + HooksCore, Immutability, // Class. From b292957bef70058423b184a03b13de1f8c385e1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:34:34 +0000 Subject: [PATCH 3/3] Fix Immutability.deepFreeze reference and add comprehensive hooks tests Co-authored-by: sciborrudnicki <19817956+sciborrudnicki@users.noreply.github.com> --- src/lib/data-core.abstract.ts | 3 +- src/test/hooks.spec.ts | 227 ++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/test/hooks.spec.ts diff --git a/src/lib/data-core.abstract.ts b/src/lib/data-core.abstract.ts index 764eee8..ca06337 100644 --- a/src/lib/data-core.abstract.ts +++ b/src/lib/data-core.abstract.ts @@ -1,5 +1,6 @@ // Abstract. import { HooksBase } from './hooks-base.abstract'; +import { Immutability } from './immutability.abstract'; // Interface. import { DataShape } from '@typedly/data'; /** @@ -106,7 +107,7 @@ export abstract class DataCore * @returns {this} */ public override lock(): this { - return HooksBase.deepFreeze(this.value), + return Immutability.deepFreeze(this.value), super.lock(), this; } diff --git a/src/test/hooks.spec.ts b/src/test/hooks.spec.ts new file mode 100644 index 0000000..3730307 --- /dev/null +++ b/src/test/hooks.spec.ts @@ -0,0 +1,227 @@ +import { Data } from "../lib"; + +describe('Hooks System', () => { + describe('onChange hook', () => { + it('should call onChange callback when value changes', () => { + const instance = new Data('initial'); + let changeCallbackCalled = false; + let capturedNewValue: string | undefined; + let capturedOldValue: string | undefined; + + instance.onChange((newValue, oldValue) => { + changeCallbackCalled = true; + capturedNewValue = newValue; + capturedOldValue = oldValue; + }); + + instance.set('updated'); + + expect(changeCallbackCalled).toBe(true); + expect(capturedNewValue).toBe('updated'); + expect(capturedOldValue).toBe('initial'); + }); + + it('should not call onChange callback when value is the same', () => { + const instance = new Data('same'); + let changeCallbackCalled = false; + + instance.onChange(() => { + changeCallbackCalled = true; + }); + + instance.set('same'); + + expect(changeCallbackCalled).toBe(false); + }); + + it('should call onChange when clearing value', () => { + const instance = new Data('value'); + let changeCallbackCalled = false; + let capturedNewValue: string | null | undefined; + let capturedOldValue: string | null | undefined; + + instance.onChange((newValue, oldValue) => { + changeCallbackCalled = true; + capturedNewValue = newValue; + capturedOldValue = oldValue; + }); + + instance.clear(); + + expect(changeCallbackCalled).toBe(true); + expect(capturedNewValue).toBe(null); + expect(capturedOldValue).toBe('value'); + }); + + it('should allow unregistering onChange callback', () => { + const instance = new Data('initial'); + let changeCallbackCalled = false; + + instance.onChange(() => { + changeCallbackCalled = true; + }); + + // Unregister by passing undefined + instance.onChange(undefined); + + instance.set('updated'); + + expect(changeCallbackCalled).toBe(false); + }); + }); + + describe('onSet hook', () => { + it('should call onSet callback before value is set', () => { + const instance = new Data('initial'); + let setCallbackCalled = false; + let capturedValue: string | undefined; + + instance.onSet((value) => { + setCallbackCalled = true; + capturedValue = value; + }); + + instance.set('new value'); + + expect(setCallbackCalled).toBe(true); + expect(capturedValue).toBe('new value'); + expect(instance.value).toBe('new value'); + }); + + it('should allow unregistering onSet callback', () => { + const instance = new Data('initial'); + let setCallbackCalled = false; + + instance.onSet(() => { + setCallbackCalled = true; + }); + + // Unregister by passing undefined + instance.onSet(undefined); + + instance.set('updated'); + + expect(setCallbackCalled).toBe(false); + }); + }); + + describe('onDestroy hook', () => { + it('should call onDestroy callback when instance is destroyed', () => { + const instance = new Data('initial'); + let destroyCallbackCalled = false; + + instance.onDestroy(() => { + destroyCallbackCalled = true; + }); + + instance.destroy(); + + expect(destroyCallbackCalled).toBe(true); + }); + + it('should allow unregistering onDestroy callback', () => { + const instance = new Data('initial'); + let destroyCallbackCalled = false; + + instance.onDestroy(() => { + destroyCallbackCalled = true; + }); + + // Unregister by passing undefined + instance.onDestroy(undefined); + + instance.destroy(); + + expect(destroyCallbackCalled).toBe(false); + }); + }); + + describe('Multiple hooks', () => { + it('should call both onSet and onChange when setting a value', () => { + const instance = new Data('initial'); + let setCallbackCalled = false; + let changeCallbackCalled = false; + const callOrder: string[] = []; + + instance.onSet(() => { + setCallbackCalled = true; + callOrder.push('onSet'); + }); + + instance.onChange(() => { + changeCallbackCalled = true; + callOrder.push('onChange'); + }); + + instance.set('updated'); + + expect(setCallbackCalled).toBe(true); + expect(changeCallbackCalled).toBe(true); + expect(callOrder).toEqual(['onSet', 'onChange']); + }); + + it('should handle multiple hook registrations correctly', () => { + const instance = new Data(0); + let firstOnChangeCalled = false; + let secondOnChangeCalled = false; + + instance.onChange(() => { + firstOnChangeCalled = true; + }); + + // Second registration should replace first + instance.onChange(() => { + secondOnChangeCalled = true; + }); + + instance.set(1); + + expect(firstOnChangeCalled).toBe(false); + expect(secondOnChangeCalled).toBe(true); + }); + }); + + describe('Callback return type', () => { + it('onChange callback should return void', () => { + const instance = new Data('initial'); + + const callback = (newValue: string, oldValue: string): void => { + // Callback returns void + }; + + instance.onChange(callback); + instance.set('updated'); + + // No error should occur + expect(instance.value).toBe('updated'); + }); + + it('onSet callback should return void', () => { + const instance = new Data('initial'); + + const callback = (value: string): void => { + // Callback returns void + }; + + instance.onSet(callback); + instance.set('updated'); + + // Value should be set correctly + expect(instance.value).toBe('updated'); + }); + + it('onDestroy callback should return void', () => { + const instance = new Data('initial'); + + const callback = (): void => { + // Callback returns void + }; + + instance.onDestroy(callback); + instance.destroy(); + + // No error should occur + expect(() => instance.value).toThrowError(); + }); + }); +});