Skip to content

Commit

Permalink
feat: asWritable
Browse files Browse the repository at this point in the history
  • Loading branch information
divdavem committed Dec 7, 2023
1 parent 0a3a998 commit 1793d84
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 0 deletions.
150 changes: 150 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
StoresInputValues,
SubscribableStore,
SubscriberObject,
asWritable,
asReadable,
batch,
computed,
Expand Down Expand Up @@ -1048,6 +1049,155 @@ describe('stores', () => {
});
});

describe('asWritable', () => {
class MyStore extends Store<number> {
constructor() {
super(0);
}
increment() {
this.update((value) => value + 1);
}
reset(value: number) {
this.set(value);
}
}

it('should work with a computed with a set function', () => {
const store = writable(0);
const computedDoubleStore = computed(() => store() * 2);
const writableDoubleStore = asWritable(computedDoubleStore, (value) => store.set(value / 2));
expect(writableDoubleStore()).toBe(0);
store.set(1);
expect(writableDoubleStore()).toBe(2);
writableDoubleStore.set(4);
expect(store()).toBe(2);
expect(writableDoubleStore()).toBe(4);
writableDoubleStore.update((value) => value + 2);
expect(writableDoubleStore()).toBe(6);
expect(store()).toBe(3);
});

it('should work with an instance of a class extending Store with no set function', () => {
const myStore = new MyStore();
const writableStore = asWritable(myStore);
expect(get(writableStore)).toBe(0);
expect(writableStore()).toBe(0);
myStore.increment();
expect(get(writableStore)).toBe(1);
expect(writableStore()).toBe(1);
expect((writableStore as any).increment).toBeUndefined();
// trying to change writableStore does nothing because no set function was provided:
writableStore.set(2);
const updateFunction = vi.fn((value: number) => value * 2);
writableStore.update(updateFunction);
expect(updateFunction).not.toHaveBeenCalled();
expect(writableStore()).toBe(1);
});

it('should work with an instance of a class extending Store with a set function', () => {
const myStore = new MyStore();
const writableStore = asWritable(myStore, (value) => myStore.reset(value));
expect(get(writableStore)).toBe(0);
expect(writableStore()).toBe(0);
myStore.increment();
expect(get(writableStore)).toBe(1);
expect(writableStore()).toBe(1);
expect((writableStore as any).increment).toBeUndefined();
writableStore.set(2);
expect(writableStore()).toBe(2);
writableStore.update((value) => value * 2);
expect(writableStore()).toBe(4);
});

it('should work with an instance of a class extending Store with multiple functions', () => {
const myStore = new MyStore();
const writableStore = asWritable(myStore, {
set: (value) => myStore.reset(value),
callIncrement: () => myStore.increment(),
});
expect(get(writableStore)).toBe(0);
expect(writableStore()).toBe(0);
myStore.increment();
expect(get(writableStore)).toBe(1);
expect(writableStore()).toBe(1);
expect((writableStore as any).increment).toBeUndefined();
writableStore.set(2);
expect(writableStore()).toBe(2);
writableStore.callIncrement();
expect(writableStore()).toBe(3);
writableStore.update((value) => value * 2);
expect(writableStore()).toBe(6);
});

it('should work with an InteropObservable', () => {
const behaviorSubject = new BehaviorSubject(0);
const myStore = { [symbolObservable]: () => behaviorSubject, next: () => {} };
const writableStore = asWritable(myStore, (value) => behaviorSubject.next(value));
expect(get(writableStore)).toBe(0);
expect(writableStore()).toBe(0);
behaviorSubject.next(1);
expect(get(writableStore)).toBe(1);
expect(writableStore()).toBe(1);
expect((writableStore as any).next).toBeUndefined();
writableStore.set(2);
expect(writableStore()).toBe(2);
writableStore.update((value) => value * 2);
expect(writableStore()).toBe(4);
});

it('should work with a writable', () => {
const myStore = writable(0);
const set = vi.fn(myStore.set);
const update = vi.fn(myStore.update);
const writableStore = asWritable(myStore, {
set,
update,
increment: () => {
myStore.update((value) => value + 1);
},
});
expect(get(writableStore)).toBe(0);
expect(writableStore()).toBe(0);
myStore.set(1);
expect(get(writableStore)).toBe(1);
expect(writableStore()).toBe(1);
expect(set).not.toHaveBeenCalled();
writableStore.set(2);
expect(set).toHaveBeenCalledOnce();
set.mockClear();
expect(writableStore()).toBe(2);
expect(update).not.toHaveBeenCalled();
writableStore.update((value) => value * 2);
expect(update).toHaveBeenCalledOnce();
update.mockClear();
expect(writableStore()).toBe(4);
writableStore.increment();
expect(writableStore()).toBe(5);
expect(set).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});

it('should not track when getting the value of the store in update', () => {
const counter = writable(0);
const exposedCounter = asWritable(counter, counter.set);
const num = writable(1);
const doubleNum = computed(() => {
exposedCounter.update((value) => value + 1);
return num() * 2;
});
expect(counter()).toBe(0);
expect(doubleNum()).toBe(2);
expect(counter()).toBe(1);
expect(doubleNum()).toBe(2);
num.set(3);
expect(counter()).toBe(1);
expect(doubleNum()).toBe(6);
expect(counter()).toBe(2);
expect(doubleNum()).toBe(6);
expect(counter()).toBe(2);
});
});

describe('derived', () => {
it('should derive from one store', () => {
const numbersStore = writable(0);
Expand Down
53 changes: 53 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,59 @@ export function asReadable<T, U>(
return res;
}

const defaultUpdate: any = function <T, U>(this: Writable<T, U>, updater: Updater<T, U>) {
this.set(updater(untrack(() => get(this))));
};

/**
* Returns a wrapper (for the given store) which only exposes the {@link WritableSignal} interface.
* When the value is changed from the given wrapper, the provided set function is called.
*
* @param store - store to wrap
* @param set - function that will be called when the value is changed from the wrapper
* (through the {@link Writable.set|set} or the {@link Writable.update|update} function).
* If set is not specified, a noop function is used (so the value of the store cannot be changed
* from the returned wrapper).
* @returns A wrapper which only exposes the {@link WritableSignal} interface.
*/
export function asWritable<T, W = T>(
store: StoreInput<T>,
set?: WritableSignal<T, W>['set']
): WritableSignal<T, W>;
/**
* Returns a wrapper (for the given store) which only exposes the {@link WritableSignal} interface and
* also adds the given extra properties on the returned object.
*
* @param store - store to wrap
* @param extraProps - object containing the extra properties to add on the returned object,
* and optionally the {@link Writable.set|set} and the {@link Writable.update|update} function of the
* {@link WritableSignal} interface.
* If the set function is not specified, a noop function is used.
* If the update function is not specified, a default function that calls set is used.
* @returns A wrapper which only exposes the {@link WritableSignal} interface and the given extra properties.
*/
export function asWritable<T, U, W = T>(
store: StoreInput<T>,
extraProps: U & Partial<Pick<WritableSignal<T, W>, 'set' | 'update'>>
): WritableSignal<T, W> & Omit<U, keyof WritableSignal<T, W>>;
export function asWritable<T, U, W = T>(
store: StoreInput<T>,
setOrExtraProps?:
| WritableSignal<T, W>['set']
| (U & Partial<Pick<WritableSignal<T, W>, 'set' | 'update'>>)
): WritableSignal<T, W> & Omit<U, keyof WritableSignal<T, W>> {
return asReadable(
store,
typeof setOrExtraProps === 'function'
? { set: setOrExtraProps, update: defaultUpdate }
: {
...setOrExtraProps,
set: setOrExtraProps?.set ?? noop,
update: setOrExtraProps?.update ?? (setOrExtraProps?.set ? defaultUpdate : noop),
}
) as any;
}

const triggerUpdate = Symbol();
const queueProcess = Symbol();
let willProcessQueue = false;
Expand Down

0 comments on commit 1793d84

Please sign in to comment.