Skip to content

Commit

Permalink
feat: add signal
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed May 17, 2024
1 parent 4118637 commit 31cf2bc
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 1 deletion.
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,21 @@ effect(() => {
});
```

This issue does not exist in `value-enhancer` because we do not collect dependencies implicitly.
This issue does not exist in `value-enhancer` because we do not collect dependencies implicitly by default.

In case of subscribing to flexible dynamic dependencies are needed, `value-enhancer` does offer a simple `signal` API which is similar to Jotai.

```ts
import { val, signal } from "value-enhancer";

const count$ = val(0);
const a$ = val("a");
const b$ = val("b");

const s$ = signal(get => {
return get(count$) % 2 === 0 ? get(a$) : get(b$);
});
```

</details>

Expand Down Expand Up @@ -353,6 +367,26 @@ itemList$.set([val(4), val(5), val(6)]);
console.log(firstItem$.value); // 4
```

## Signal

`signal` is useful for subscribing to flexible dynamic dependencies.

The `get` function passed to the effect callback can be used to get the current value of a Val and subscribe to it.
The effect callback will be re-evaluated whenever the dependencies change.
Stale dependencies are unsubscribed automatically.

```js
import { val, signal } from "value-enhancer";

const count$ = val(0);
const a$ = val("a");
const b$ = val("b");

const s$ = signal(get => {
return get(count$) % 2 === 0 ? get(a$) : get(b$);
});
```

## From

`from` creates a Val from any value source. Both `derive` and `combine` are implemented using `from` under the hood.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { flatten } from "./flatten";
export { flattenFrom } from "./flatten-from";
export { from } from "./from";
export { nextTick } from "./scheduler";
export { signal, type SignalEffectCollector } from "./signal";
export type {
FlattenVal,
ReadonlyVal,
Expand Down
80 changes: 80 additions & 0 deletions src/signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ValAgent } from "./agent";
import type { ReadonlyVal, ValConfig, ValDisposer } from "./typings";
import { invoke } from "./utils";
import { ValImpl } from "./val";

export type SignalEffectCollector<TValue = any> = (
val$: ReadonlyVal<TValue>
) => TValue;

/**
* Create a signal val that subscribes to other vals dynamically.
*
* The `get` function passed to the effect callback can be used to get the current value of a Val and subscribe to it.
* The effect callback will be re-evaluated whenever the dependencies change.
* Stale dependencies are unsubscribed automatically.
*
* @param effect - The effect function which will be called immediately and whenever the dependencies change.
* @param config - Optional val config.
* @returns A signal val.
*
* @example
* ```ts
* import { signal, val } from "value-enhancer";
*
* const a$ = val(1);
* const b$ = val("b");
* const c$ = val("c");
* const s$ = signal(get => (get(a$) % 2 === 0 ? get(b$) : get(c$)));
* ```
*/
export const signal = <TValue = any>(
effect: (get: SignalEffectCollector) => TValue,
config?: ValConfig<TValue>
) => {
let scopeLevel = 0;

let currentDisposers = new Map<ReadonlyVal, ValDisposer>();
let oldDisposers = new Map<ReadonlyVal, ValDisposer>();

const get: SignalEffectCollector<TValue> = val$ => {
if (!currentDisposers.has(val$)) {
let disposer = oldDisposers.get(val$);
if (disposer) {
oldDisposers.delete(val$);
} else {
disposer = val$.$valCompute(agent.notify_);
}
currentDisposers.set(val$, disposer);
}
return val$.value;
};

const agent = new ValAgent(
() => {
if (++scopeLevel === 1) {
const tmp = currentDisposers;
currentDisposers = oldDisposers;
oldDisposers = tmp;
}

const value = effect(get);

if (--scopeLevel === 0 && oldDisposers.size) {
oldDisposers.forEach(invoke);
oldDisposers.clear();
}

return value;
},
config,
() => () => {
if (currentDisposers.size) {
currentDisposers.forEach(invoke);
currentDisposers.clear();
}
}
);

return new ValImpl(agent);
};
49 changes: 49 additions & 0 deletions test/signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it, jest } from "@jest/globals";
import { nextTick, signal, val } from "../src";

describe("signal", () => {
it("should collect effect dynamically", async () => {
const a$ = val(1);
const b$ = val("b");
const c$ = val("c");
const s$ = signal(get => (get(a$) % 2 === 0 ? get(b$) : get(c$)));

expect(s$.value).toBe("c");

a$.set(2);

expect(s$.value).toBe("b");

const spySubscribe = jest.fn();
s$.subscribe(spySubscribe);

expect(spySubscribe).toBeCalledTimes(1);
expect(spySubscribe).lastCalledWith("b");

spySubscribe.mockClear();

b$.set("bb");

await nextTick();

expect(spySubscribe).toBeCalledTimes(1);
expect(spySubscribe).lastCalledWith("bb");

spySubscribe.mockClear();

c$.set("cc");

await nextTick();

expect(spySubscribe).toBeCalledTimes(0);

a$.set(3);

await nextTick();

expect(spySubscribe).toBeCalledTimes(1);
expect(spySubscribe).lastCalledWith("cc");

s$.dispose();
});
});

0 comments on commit 31cf2bc

Please sign in to comment.