From 086024db38e285dd0caf8c008dcc70d70f77b5db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:44:48 +0000 Subject: [PATCH 1/3] Initial plan From 4be903e48702dc195b68ba9209bdb5efafe79a6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:48:47 +0000 Subject: [PATCH 2/3] Add AbortSignal support to subscribe() method Co-authored-by: ReinsBrain <1740167+ReinsBrain@users.noreply.github.com> --- src/index.ts | 21 +++++++++++-- test/unit/index.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7efe4bb..3f9fa58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export interface ChangeEvent { export interface ProxableObject { [ key: string] : any; - subscribe ( path: string, cb: Listener ) : () => void; + subscribe ( path: string, cb: Listener, options?: { signal?: AbortSignal } ) : () => void; observable? () : RxObservable; } @@ -38,9 +38,26 @@ export class Proxable { const root = this._createProxy ( data, [] ) as T & ProxableObject; Object.defineProperty( root, 'subscribe', { - value: ( path: string, cb: Listener ) => { + value: ( path: string, cb: Listener, options?: { signal?: AbortSignal } ) => { const off = this.index.add ( path, cb ); this.listenerCache = new WeakMap(); + + // Wire AbortSignal to off() if provided + if ( options?.signal ) { + const signal = options.signal; + // If already aborted, call off() immediately + if ( signal.aborted ) { + off(); + this.listenerCache = new WeakMap(); + } else { + // Otherwise, listen for abort event + signal.addEventListener( 'abort', () => { + off(); + this.listenerCache = new WeakMap(); + }, { once: true } ); + } + } + return () => { off(); this.listenerCache = new WeakMap(); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index add006f..b3259e4 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -93,4 +93,72 @@ describe ( 'Proxable', () => { assert.equal ( count, 21 ); }); + it ( 'should automatically unsubscribe when AbortSignal is aborted', () => { + const obs = Proxable.create<{ value: number }>(); + const controller = new AbortController(); + + let count = 0; + obs.subscribe ( 'value', () => count++, { signal: controller.signal } ); + + obs.value = 1; + assert.equal ( count, 1 ); + + // Abort the signal + controller.abort(); + + // Should not fire after abort + obs.value = 2; + assert.equal ( count, 1 ); + }); + + it ( 'should not subscribe if AbortSignal is already aborted', () => { + const obs = Proxable.create<{ value: number }>(); + const controller = new AbortController(); + controller.abort(); // Abort before subscribing + + let count = 0; + obs.subscribe ( 'value', () => count++, { signal: controller.signal } ); + + // Should not fire since signal was already aborted + obs.value = 1; + assert.equal ( count, 0 ); + }); + + it ( 'should work with manual unsubscribe even when using AbortSignal', () => { + const obs = Proxable.create<{ value: number }>(); + const controller = new AbortController(); + + let count = 0; + const off = obs.subscribe ( 'value', () => count++, { signal: controller.signal } ); + + obs.value = 1; + assert.equal ( count, 1 ); + + // Manual unsubscribe should work + off(); + + // Should not fire after manual unsubscribe + obs.value = 2; + assert.equal ( count, 1 ); + + // Aborting after manual unsubscribe should be safe + controller.abort(); + obs.value = 3; + assert.equal ( count, 1 ); + }); + + it ( 'should work without options parameter (backward compatibility)', () => { + const obs = Proxable.create<{ value: number }>(); + + let count = 0; + const off = obs.subscribe ( 'value', () => count++ ); + + obs.value = 1; + assert.equal ( count, 1 ); + + off(); + obs.value = 2; + assert.equal ( count, 1 ); + }); + }); \ No newline at end of file From b881755db68e0d07f1a7280f8e44961dd4b6c34d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:49:41 +0000 Subject: [PATCH 3/3] Add lifecycle and cleanup documentation to README Co-authored-by: ReinsBrain <1740167+ReinsBrain@users.noreply.github.com> --- README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/README.md b/README.md index 4b3323e..d1d3dfa 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,90 @@ off(); +## 🔄 Lifecycle and cleanup + +Subscriptions in Proxable remain active until you explicitly unsubscribe. This is especially important in Web Components or other dynamic UI contexts where components are created and destroyed. + +### Manual unsubscribe pattern + +The traditional approach is to store the unsubscribe function and call it in your component's cleanup lifecycle: + +```ts +class MyElement extends HTMLElement { + private unsubscribe?: () => void; + + connectedCallback() { + const store = Proxable.create<{ count: number }>(); + + this.unsubscribe = store.subscribe('count', (value) => { + this.render(value); + }); + } + + disconnectedCallback() { + // Clean up subscription when component is removed + this.unsubscribe?.(); + } + + render(count: number) { + this.textContent = `Count: ${count}`; + } +} +``` + +### AbortSignal pattern + +For a more ergonomic approach, you can use `AbortController` and `AbortSignal` to automatically clean up subscriptions: + +```ts +class MyElement extends HTMLElement { + private controller?: AbortController; + + connectedCallback() { + this.controller = new AbortController(); + const store = Proxable.create<{ count: number }>(); + + // Subscription will automatically clean up when signal is aborted + store.subscribe('count', (value) => { + this.render(value); + }, { signal: this.controller.signal }); + } + + disconnectedCallback() { + // Abort signal to clean up all subscriptions + this.controller?.abort(); + } + + render(count: number) { + this.textContent = `Count: ${count}`; + } +} +``` + +### Helper for older patterns + +If you want to use `AbortSignal` with the returned unsubscribe function, you can create a small helper: + +```ts +function subscribeWithSignal( + observable: any, + path: string, + callback: (value: any, path: string[]) => void, + signal: AbortSignal +) { + const off = observable.subscribe(path, callback); + + if (signal.aborted) { + off(); + return () => {}; + } + + signal.addEventListener('abort', off, { once: true }); + return off; +} +``` + +Both patterns (manual `off()` and `AbortSignal`) can coexist in your application. Use whichever feels more natural for your use case. ## Benchmarks