Skip to content
Draft
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
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChangeEvent>;
}

Expand Down Expand Up @@ -38,9 +38,26 @@ export class Proxable<T extends object> {
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();
Expand Down
68 changes: 68 additions & 0 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
});

});