Skip to content

Commit

Permalink
feat(val): add ref val
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed Jan 26, 2024
1 parent 95c1dd1 commit 65e83fb
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export interface Val<TValue = any> extends ReadonlyVal<TValue> {
value: TValue;
/** Set new value */
set(this: void, value: TValue): void;
/**
* Create a new Val referencing the value of the current Val as source.
* All ref Vals share the same value from the source Val.
* The act of setting a value on the ref Val is essentially setting the value on the source Val.
*
* With this pattern you can pass a ref Val as a writable Val to downstream.
* The ref Vals can be safely disposed without affecting the source Val and other ref Vals.
*/
ref(): Val<TValue>;
}

export type ValSetValue<TValue = any> = (value: TValue) => void;
Expand Down
39 changes: 39 additions & 0 deletions src/val.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import type { Val, ValConfig } from "./typings";
import { ReadonlyValImpl } from "./readonly-val";

class ValImpl<TValue = any> extends ReadonlyValImpl<TValue> {
#config?: ValConfig<TValue>;

public constructor(currentValue: TValue, config?: ValConfig<TValue>) {
const get = () => currentValue;

super(get, config);

this.#config = config;
this.set = (value: TValue) => {
if (!this.$equal?.(value, currentValue)) {
this._subs.dirty = true;
Expand All @@ -26,6 +29,42 @@ class ValImpl<TValue = any> extends ReadonlyValImpl<TValue> {
public override set value(value: TValue) {
this.set(value);
}

public ref(): Val<TValue> {
return new RefValImpl(this, this.#config);
}
}

class RefValImpl<TValue = any> extends ReadonlyValImpl<TValue> {
#config?: ValConfig<TValue>;
#source$: ValImpl<TValue>;

public constructor(source$: ValImpl<TValue>, config?: ValConfig<TValue>) {
super(source$.get, config, () =>
source$.$valCompute(() => {
this._subs.dirty = true;
this._subs.notify();
})
);

this.#source$ = source$;
this.#config = config;
this.set = source$.set;
}

public set: (this: void, value: TValue) => void;

public override get value() {
return this.get();
}

public override set value(value: TValue) {
this.set(value);
}

public ref(): Val<TValue> {
return new RefValImpl(this.#source$, this.#config);
}
}

/**
Expand Down
152 changes: 152 additions & 0 deletions test/val.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,156 @@ describe("Val", () => {
v.unsubscribe();
});
});

describe("ref", () => {
it("should create a ref val", () => {
const v = val(1);
const ref = v.ref();
expect(ref.value).toBe(1);
expect(ref.value).toBe(v.value);
});

it("should set value on source val", () => {
const v = val(1);
const ref = v.ref();
ref.set(2);
expect(ref.value).toBe(2);
expect(v.value).toBe(2);
});

it("should chain ref val from the same source", () => {
const v = val(1);
const ref1 = v.ref();
const ref2 = ref1.ref();
ref1.set(2);
expect(ref1.value).toBe(2);
expect(ref2.value).toBe(2);
expect(v.value).toBe(2);
});

it("should not dispose the source val", () => {
const v = val(0);
const ref = v.ref();

const spyV = jest.fn();
const spyRef = jest.fn();

const mockClear = () => {
spyV.mockClear();
spyRef.mockClear();
};

v.reaction(spyV, true);
ref.reaction(spyRef, true);

expect(spyV).toBeCalledTimes(0);
expect(spyRef).toBeCalledTimes(0);

mockClear();

v.set(1);
expect(spyV).toBeCalledTimes(1);
expect(spyRef).toBeCalledTimes(1);
expect(spyV).lastCalledWith(1);
expect(spyRef).lastCalledWith(1);

mockClear();

ref.set(2);
expect(spyV).toBeCalledTimes(1);
expect(spyRef).toBeCalledTimes(1);
expect(spyV).lastCalledWith(2);
expect(spyRef).lastCalledWith(2);

mockClear();

ref.dispose();

ref.set(3);
expect(spyV).toBeCalledTimes(1);
expect(spyRef).toBeCalledTimes(0);
expect(spyV).lastCalledWith(3);

v.dispose();
});

it("all ref value should share the value from source val", () => {
const v = val(0);
const spyV = jest.fn();

const refs = Array(10)
.fill(0)
.map(() => ({
ref: v.ref(),
spyRef: jest.fn(),
}));

v.reaction(spyV, true);
for (const { ref, spyRef } of refs) {
ref.reaction(spyRef, true);
}

const mockClear = () => {
spyV.mockClear();
for (const { spyRef } of refs) {
spyRef.mockClear();
}
};

expect(spyV).toBeCalledTimes(0);
for (const { spyRef } of refs) {
expect(spyRef).toBeCalledTimes(0);
}

mockClear();

v.set(1);
expect(spyV).toBeCalledTimes(1);
expect(spyV).lastCalledWith(1);
for (const { spyRef } of refs) {
expect(spyRef).toBeCalledTimes(1);
expect(spyRef).lastCalledWith(1);
}

mockClear();

refs[0].ref.value = 2;
expect(spyV).toBeCalledTimes(1);
expect(spyV).lastCalledWith(2);
for (const { spyRef } of refs) {
expect(spyRef).toBeCalledTimes(1);
expect(spyRef).lastCalledWith(2);
}

mockClear();

refs[1].ref.dispose();

refs[1].ref.set(3);
expect(spyV).toBeCalledTimes(1);
expect(spyV).lastCalledWith(3);
for (let i = 0; i < refs.length; i++) {
if (i === 1) {
expect(refs[i].spyRef).toBeCalledTimes(0);
} else {
expect(refs[i].spyRef).toBeCalledTimes(1);
expect(refs[i].spyRef).lastCalledWith(3);
}
}

mockClear();

refs[0].ref.set(4);
expect(spyV).toBeCalledTimes(1);
expect(spyV).lastCalledWith(4);
for (let i = 0; i < refs.length; i++) {
if (i === 1) {
expect(refs[i].spyRef).toBeCalledTimes(0);
} else {
expect(refs[i].spyRef).toBeCalledTimes(1);
expect(refs[i].spyRef).lastCalledWith(4);
}
}
});
});
});

0 comments on commit 65e83fb

Please sign in to comment.