Skip to content

Commit c4ec87b

Browse files
committed
feat: experimental interoperability implementation
1 parent 37ec893 commit c4ec87b

17 files changed

+662
-166
lines changed

src/index.spec.ts

Lines changed: 256 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Component, Injectable, inject } from '@angular/core';
33
import { TestBed } from '@angular/core/testing';
44
import { BehaviorSubject, from } from 'rxjs';
55
import { writable as svelteWritable } from 'svelte/store';
6-
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
6+
import { afterAll, afterEach, beforeAll, describe, expect, it, test, vi } from 'vitest';
77
import type {
88
OnUseArgument,
99
Readable,
@@ -30,10 +30,10 @@ import {
3030
untrack,
3131
writable,
3232
} from './index';
33-
import { rawStoreSymbol } from './internal/exposeRawStores';
34-
import { RawStoreFlags } from './internal/store';
33+
import { RawStoreFlags, rawStoreSymbol } from './internal/store';
3534
import { flushUnused } from './internal/storeTrackingUsage';
3635
import type { RawStoreWritable } from './internal/storeWritable';
36+
import { runWithConsumer, type Signal, watchSignal, type Watcher } from './interop';
3737

3838
const expectCorrectlyCleanedUp = <T>(store: StoreInput<T>) => {
3939
const rawStore = (store as any)[rawStoreSymbol] as RawStoreWritable<T>;
@@ -3565,4 +3565,257 @@ describe('stores', () => {
35653565
expectCorrectlyCleanedUp(doubleDoubleDoubleStore);
35663566
});
35673567
});
3568+
3569+
describe('watch', () => {
3570+
it('should work', () => {
3571+
const onUseCalls: { onUnused: number }[] = [];
3572+
const store = writable(0, {
3573+
onUse: () => {
3574+
const call = { onUnused: 0 };
3575+
onUseCalls.push(call);
3576+
return () => {
3577+
call.onUnused++;
3578+
};
3579+
},
3580+
});
3581+
const notify = vi.fn();
3582+
const watcher = store[watchSignal](notify);
3583+
expect(watcher.isUpToDate()).toBe(false);
3584+
expect(onUseCalls.length).toBe(0);
3585+
expect(watcher.update()).toBe(true);
3586+
expect(onUseCalls.length).toBe(1);
3587+
expect(onUseCalls[0].onUnused).toBe(0);
3588+
expect(watcher.isUpToDate()).toBe(true);
3589+
expect(watcher.get()).toBe(0);
3590+
expect(notify).not.toHaveBeenCalled();
3591+
store.set(1);
3592+
expect(notify).toHaveBeenCalledOnce();
3593+
notify.mockClear();
3594+
expect(watcher.isUpToDate()).toBe(false);
3595+
store.set(2);
3596+
expect(notify).not.toHaveBeenCalled();
3597+
expect(watcher.update()).toBe(true);
3598+
expect(watcher.isUpToDate()).toBe(true);
3599+
expect(watcher.get()).toBe(2);
3600+
expect(notify).not.toHaveBeenCalled();
3601+
store.set(3);
3602+
expect(notify).toHaveBeenCalledOnce();
3603+
expect(watcher.isUpToDate()).toBe(false);
3604+
notify.mockClear();
3605+
store.set(4);
3606+
store.set(2);
3607+
expect(notify).not.toHaveBeenCalled();
3608+
expect(watcher.update()).toBe(false);
3609+
expect(watcher.isUpToDate()).toBe(true);
3610+
expect(watcher.update()).toBe(false);
3611+
expect(watcher.get()).toBe(2);
3612+
watcher.destroy();
3613+
expect(() => watcher.get()).toThrowError('invalid watcher state');
3614+
});
3615+
});
3616+
3617+
describe('fromWatch', () => {
3618+
it('should work', () => {
3619+
let notify: () => void;
3620+
let isUpdated = true;
3621+
let value = 0;
3622+
const watcher = {
3623+
isUpToDate: vi.fn(() => {
3624+
throw new Error('unexpected call to isUpToDate');
3625+
}),
3626+
update: vi.fn(() => isUpdated),
3627+
get: vi.fn(() => value),
3628+
destroy: vi.fn(),
3629+
} satisfies Watcher<number>;
3630+
const watchFn = vi.fn((notifyFn: () => void) => {
3631+
notify = notifyFn;
3632+
return watcher;
3633+
}) satisfies (notify: () => void) => Watcher<number>;
3634+
const clearMocks = () => {
3635+
watchFn.mockClear();
3636+
watcher.update.mockClear();
3637+
watcher.get.mockClear();
3638+
watcher.destroy.mockClear();
3639+
};
3640+
const store = asReadable({ [watchSignal]: watchFn });
3641+
expect(watchFn).not.toHaveBeenCalled();
3642+
expect(store.get()).toBe(0);
3643+
expect(watchFn).toHaveBeenCalledOnce();
3644+
expect(watcher.update).toHaveBeenCalledOnce();
3645+
expect(watcher.get).toHaveBeenCalledOnce();
3646+
expect(watcher.destroy).toHaveBeenCalledOnce();
3647+
clearMocks();
3648+
const values: number[] = [];
3649+
const unsubscribe = store.subscribe((value) => {
3650+
values.push(value);
3651+
});
3652+
expect(values).toEqual([0]);
3653+
expect(watchFn).toHaveBeenCalledOnce();
3654+
expect(watcher.update).toHaveBeenCalledOnce();
3655+
expect(watcher.get).toHaveBeenCalledOnce();
3656+
expect(watcher.destroy).not.toHaveBeenCalled();
3657+
clearMocks();
3658+
isUpdated = false;
3659+
batch(() => {
3660+
notify!();
3661+
});
3662+
expect(watcher.update).toHaveBeenCalledOnce();
3663+
expect(watchFn).not.toHaveBeenCalled();
3664+
expect(watcher.get).not.toHaveBeenCalled();
3665+
expect(watcher.destroy).not.toHaveBeenCalled();
3666+
clearMocks();
3667+
expect(values).toEqual([0]);
3668+
isUpdated = true;
3669+
value = 2;
3670+
batch(() => {
3671+
notify!();
3672+
});
3673+
expect(watcher.update).toHaveBeenCalledOnce();
3674+
expect(watcher.get).toHaveBeenCalledOnce();
3675+
expect(watcher.destroy).not.toHaveBeenCalled();
3676+
expect(watchFn).not.toHaveBeenCalled();
3677+
clearMocks();
3678+
expect(values).toEqual([0, 2]);
3679+
isUpdated = true;
3680+
watcher.get.mockImplementation(() => {
3681+
throw new Error('myerror');
3682+
});
3683+
expect(() => {
3684+
batch(() => {
3685+
notify!();
3686+
});
3687+
}).toThrowError('myerror');
3688+
expect(watcher.update).toHaveBeenCalledOnce();
3689+
expect(watcher.get).toHaveBeenCalledOnce();
3690+
expect(watcher.destroy).not.toHaveBeenCalled();
3691+
expect(watchFn).not.toHaveBeenCalled();
3692+
clearMocks();
3693+
expect(() => {
3694+
store.get();
3695+
}).toThrowError('myerror');
3696+
expect(values).toEqual([0, 2]);
3697+
expect(watcher.update).not.toHaveBeenCalled();
3698+
expect(watcher.get).not.toHaveBeenCalled();
3699+
expect(watcher.destroy).not.toHaveBeenCalled();
3700+
expect(watchFn).not.toHaveBeenCalled();
3701+
unsubscribe();
3702+
expect(watcher.destroy).toHaveBeenCalledOnce();
3703+
expect(watcher.update).not.toHaveBeenCalled();
3704+
expect(watcher.get).not.toHaveBeenCalled();
3705+
expect(watchFn).not.toHaveBeenCalled();
3706+
expect(watcher.isUpToDate).not.toHaveBeenCalled();
3707+
});
3708+
});
3709+
3710+
describe('watch / fromWatch', () => {
3711+
it('should work to convert back and forth a basic writable', () => {
3712+
const store = writable(0);
3713+
const otherStore = asReadable({ [watchSignal]: (notify) => store[watchSignal](notify) });
3714+
expect(otherStore()).toBe(0);
3715+
store.set(1);
3716+
expect(otherStore()).toBe(1);
3717+
store.set(2);
3718+
expect(otherStore()).toBe(2);
3719+
3720+
const values: number[] = [];
3721+
const unsubscribe = otherStore.subscribe((value) => {
3722+
values.push(value);
3723+
});
3724+
expect(values).toEqual([2]);
3725+
store.set(3);
3726+
expect(values).toEqual([2, 3]);
3727+
expect(otherStore()).toBe(3);
3728+
batch(() => {
3729+
store.set(4);
3730+
expect(otherStore()).toBe(4);
3731+
store.set(5);
3732+
store.set(3);
3733+
});
3734+
expect(values).toEqual([2, 3]);
3735+
unsubscribe();
3736+
});
3737+
3738+
it('should work to convert back and forth a basic Store', () => {
3739+
class MyStore extends Store<number> {
3740+
increase() {
3741+
this.update((value) => value + 1);
3742+
}
3743+
}
3744+
const store = new MyStore(0);
3745+
3746+
const otherStore = asReadable({ [watchSignal]: (notify) => store[watchSignal](notify) });
3747+
expect(otherStore()).toBe(0);
3748+
store.increase();
3749+
expect(otherStore()).toBe(1);
3750+
store.increase();
3751+
expect(otherStore()).toBe(2);
3752+
3753+
const values: number[] = [];
3754+
const unsubscribe = otherStore.subscribe((value) => {
3755+
values.push(value);
3756+
});
3757+
expect(values).toEqual([2]);
3758+
store.increase();
3759+
expect(values).toEqual([2, 3]);
3760+
expect(otherStore()).toBe(3);
3761+
batch(() => {
3762+
store.increase();
3763+
expect(otherStore()).toBe(4);
3764+
store.increase();
3765+
store.increase();
3766+
});
3767+
expect(values).toEqual([2, 3, 6]);
3768+
unsubscribe();
3769+
});
3770+
3771+
it('should work to convert back and forth a computed', () => {
3772+
const store = writable(0);
3773+
const doubleStore = computed(() => store() * 2);
3774+
const otherStore = asReadable({
3775+
[watchSignal]: (notify) => doubleStore[watchSignal](notify),
3776+
});
3777+
expect(otherStore()).toBe(0);
3778+
store.set(1);
3779+
expect(otherStore()).toBe(2);
3780+
store.set(2);
3781+
expect(otherStore()).toBe(4);
3782+
3783+
const values: number[] = [];
3784+
const unsubscribe = otherStore.subscribe((value) => {
3785+
values.push(value);
3786+
});
3787+
expect(values).toEqual([4]);
3788+
store.set(3);
3789+
expect(values).toEqual([4, 6]);
3790+
expect(otherStore()).toBe(6);
3791+
batch(() => {
3792+
store.set(4);
3793+
expect(otherStore()).toBe(8);
3794+
store.set(5);
3795+
store.set(3);
3796+
});
3797+
expect(values).toEqual([4, 6]);
3798+
unsubscribe();
3799+
});
3800+
});
3801+
3802+
describe('interop computed', () => {
3803+
test('should work with a computed from another library', () => {
3804+
const a = writable(0);
3805+
const notify = vi.fn();
3806+
const watchers: Watcher<any>[] = [];
3807+
const consumer = vi.fn(<T>(signal: Signal<T>) => {
3808+
const watcher = signal[watchSignal](notify);
3809+
watchers.push(watcher);
3810+
watcher.update();
3811+
});
3812+
const computeValue = () => runWithConsumer(() => 2 * a(), consumer);
3813+
expect(computeValue()).toBe(0);
3814+
expect(consumer).toHaveBeenCalledOnce();
3815+
expect(notify).not.toHaveBeenCalled();
3816+
a.set(1);
3817+
expect(notify).toHaveBeenCalledOnce();
3818+
expect(watchers).toHaveLength(1);
3819+
});
3820+
});
35683821
});

src/index.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@
66
*/
77

88
import { equal } from './internal/equal';
9-
import {
10-
exposeRawStore,
11-
getRawStore,
12-
rawStoreSymbol,
13-
symbolObservable,
14-
} from './internal/exposeRawStores';
9+
import { exposeRawStore, getRawStore, symbolObservable } from './internal/exposeRawStores';
1510
import { RawStoreComputed } from './internal/storeComputed';
1611
import { RawStoreConst } from './internal/storeConst';
1712
import {
@@ -24,6 +19,7 @@ import { RawStoreWithOnUse } from './internal/storeWithOnUse';
2419
import { RawStoreWritable } from './internal/storeWritable';
2520
import { noop } from './internal/subscribeConsumer';
2621
import { untrack } from './internal/untrack';
22+
import { watchRawStore } from './internal/watch';
2723
import type {
2824
AsyncDeriveFn,
2925
AsyncDeriveOptions,
@@ -45,11 +41,15 @@ import type {
4541
WritableSignal,
4642
} from './types';
4743

48-
export { batch } from './internal/batch';
44+
import { rawStoreSymbol } from './internal/store';
45+
import { watchSignal, type Watcher } from './interop';
46+
4947
export { equal } from './internal/equal';
5048
export { symbolObservable } from './internal/exposeRawStores';
5149
export { untrack } from './internal/untrack';
50+
export * as SignalInterop from './interop';
5251
export type * from './types';
52+
export { batch } from './interop';
5353

5454
/**
5555
* Returns a wrapper (for the given store) which only exposes the {@link ReadableSignal} interface.
@@ -303,6 +303,10 @@ export abstract class Store<T> implements Readable<T> {
303303
[symbolObservable](): this {
304304
return this;
305305
}
306+
307+
[watchSignal](notify: () => void): Watcher<T> {
308+
return watchRawStore(this[rawStoreSymbol], notify);
309+
}
306310
}
307311

308312
const createStoreWithOnUse = <T>(initValue: T, onUse: OnUseFn<T>) => {

0 commit comments

Comments
 (0)