Skip to content

Commit 3a0735a

Browse files
committed
feat: add derive and combine
1 parent 9a17553 commit 3a0735a

11 files changed

Lines changed: 200 additions & 25 deletions

File tree

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"Parens",
1313
"pnpm",
1414
"preinstall",
15+
"Readables",
1516
"treeshake",
1617
"tsup",
1718
"typedoc",

src/collections/reactiveSet.ts

Whitespace-only changes.

src/combine.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { compute } from "./compute";
2+
import { type Config, type OwnedReadable, type Readable } from "./typings";
3+
4+
export type MapReadablesToValues<TDepValues extends readonly Readable[]> = {
5+
[K in keyof TDepValues]: TDepValues[K] extends Readable<infer V> ? V : never;
6+
};
7+
8+
export interface Combine {
9+
/**
10+
* Combines an array of Readables into a single Readable with the array of values.
11+
* @param deps An array of Readables to combine.
12+
* @returns A Readable with the combined values.
13+
*/
14+
<TDep$s extends readonly Readable[] = Readable[]>(deps: TDep$s): OwnedReadable<MapReadablesToValues<TDep$s>>;
15+
/**
16+
* Combines an array of Readables into a single Readable with transformed value.
17+
* @param deps An array of Readables to combine.
18+
* @param transform A pure function that takes an array of values and returns a new value.
19+
* @param config custom config for the combined Readable.
20+
* @returns A Readable with the transformed values.
21+
*/
22+
<TDep$s extends readonly Readable[] = Readable[], TValue = any>(
23+
deps: TDep$s,
24+
transform: (deps: MapReadablesToValues<TDep$s>) => TValue,
25+
config?: Config<TValue>,
26+
): OwnedReadable<TValue>;
27+
}
28+
29+
export const combine: Combine = <TDep$s extends readonly Readable[], TValue = any>(
30+
deps: TDep$s,
31+
transform?: (deps: MapReadablesToValues<TDep$s>) => TValue,
32+
config?: Config<TValue>,
33+
) =>
34+
compute(
35+
get => (transform ? transform(deps.map(get) as MapReadablesToValues<TDep$s>) : (deps.map(get) as TValue)),
36+
config,
37+
);

src/derive.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { compute } from "./compute";
2+
import { type Config, type OwnedReadable, type Readable } from "./typings";
3+
4+
export interface Derive {
5+
/**
6+
* Derive a new Readable with same value from the given Readable.
7+
* @param dep$ - The Readable to derive from.
8+
* @param config - Custom config for the derived Readable.
9+
* @returns A Readable with same value as the given Readable.
10+
*/
11+
<TDepValue = any, TValue = any>(dep$: Readable<TDepValue>): OwnedReadable<TValue>;
12+
/**
13+
* Derive a new Readable with transformed value from the given Readable.
14+
* @param dep$ - The Readable to derive from.
15+
* @param transform A pure function that takes an input value and returns a new value.
16+
* @param config custom config for the combined Readable.
17+
* @returns A Readable with transformed value from the given Readable.
18+
*/
19+
<TDepValue = any, TValue = any>(
20+
dep$: Readable<TDepValue>,
21+
transform: (depValue: TDepValue) => TValue,
22+
config?: Config<TValue>,
23+
): OwnedReadable<TValue>;
24+
}
25+
26+
export const derive: Derive = <TDepValue, TValue>(
27+
dep$: Readable<TDepValue>,
28+
transform?: (depValue: TDepValue) => TValue,
29+
config?: Config<TValue>,
30+
) => compute(get => (transform ? transform(get(dep$)) : (get(dep$) as unknown as TValue)), config);

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ export { type Listener, type RemoveListener } from "./event";
1717
export { batch, batchFlush, batchStart } from "./batch";
1818

1919
export { compute, type ComputeFn } from "./compute";
20+
export { derive, type Derive } from "./derive";
21+
export { combine, type Combine } from "./combine";
2022

2123
export { readable, writable, toWritable } from "./readable";
2224
export { watch, type WatchEffect } from "./watch";
2325

24-
export { isReadable, unsubscribe, identity, strictEqual, arrayShallowEqual } from "./utils";
26+
export { isReadable, unsubscribe, strictEqual, arrayShallowEqual } from "./utils";
2527

2628
export {
2729
reactiveMap,

src/typings.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { type BRAND } from "./utils";
22

3-
export type $sValueTuple<TValInputs extends readonly Readable[]> = Readonly<{
4-
[K in keyof TValInputs]: Extract$Value<TValInputs[K]>;
5-
}>;
6-
73
/**
84
* Custom config for the Readable/Writable.
95
*/

src/utils.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type $sValueTuple, type Readable } from "./typings";
1+
import { type Readable } from "./typings";
22

33
export const BRAND = /* @__PURE__ */ Symbol.for("@embra/reactivity");
44
export type BRAND = typeof BRAND;
@@ -28,9 +28,6 @@ export const unsubscribe = (
2828
}
2929
};
3030

31-
/** Returns the value passed in. */
32-
export const identity = <TValue>(value: TValue): TValue => value;
33-
3431
/**
3532
* `Object.is`
3633
*/
@@ -64,11 +61,6 @@ export const arrayShallowEqual = (arrA: any, arrB: any): boolean => {
6461
return true;
6562
};
6663

67-
const getValue = <TValue>($: Readable<TValue>): TValue => $.value;
68-
69-
export const getValues = <T extends readonly Readable[]>($s: T): [...$sValueTuple<T>] =>
70-
$s.map(getValue) as [...$sValueTuple<T>];
71-
7264
interface IsReadable {
7365
<T extends Readable>($: T): $ is T;
7466
($: unknown): $ is Readable;

test/collections/reactiveMap.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from "vitest";
1+
import { describe, it, expect, vi, afterAll, beforeAll } from "vitest";
22

33
import { batch, reactiveMap } from "../../src";
44

@@ -37,6 +37,14 @@ describe("ReactiveMap", () => {
3737
expect(map.get("foo")).toEqual(1);
3838
});
3939

40+
it("should update existing value", () => {
41+
const map = reactiveMap<string, number>();
42+
map.set("foo", 1);
43+
expect(map.get("foo")).toEqual(1);
44+
map.set("foo", 2);
45+
expect(map.get("foo")).toEqual(2);
46+
});
47+
4048
it("should notify on set", () => {
4149
const map = reactiveMap<string, number>();
4250

@@ -449,7 +457,17 @@ describe("ReactiveMap", () => {
449457
});
450458
});
451459

452-
describe("dispose", () => {
460+
describe.each(["test", "production"])("dispose [%s]", NODE_ENV => {
461+
const originalEnv = process.env.NODE_ENV;
462+
463+
beforeAll(() => {
464+
process.env.NODE_ENV = NODE_ENV;
465+
});
466+
467+
afterAll(() => {
468+
process.env.NODE_ENV = originalEnv;
469+
});
470+
453471
it("should clear the map and dispose of resources", () => {
454472
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => void 0);
455473

@@ -495,6 +513,14 @@ describe("ReactiveMap", () => {
495513

496514
consoleErrorMock.mockRestore();
497515
});
516+
517+
it("should allow disposing multiple times", () => {
518+
const map = reactiveMap<string, number>();
519+
expect(() => {
520+
map.dispose();
521+
map.dispose();
522+
}).not.toThrow();
523+
});
498524
});
499525

500526
describe("onChanged", () => {
@@ -521,6 +547,10 @@ describe("ReactiveMap", () => {
521547
});
522548

523549
it("should not notify listeners after dispose", () => {
550+
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => void 0);
551+
552+
expect(consoleErrorMock).not.toBeCalled();
553+
524554
const map = reactiveMap<string, number>();
525555
const listener = vi.fn();
526556

@@ -529,6 +559,9 @@ describe("ReactiveMap", () => {
529559

530560
map.set("foo", 1);
531561
expect(listener).toHaveBeenCalledTimes(0);
562+
expect(consoleErrorMock).toBeCalled();
563+
564+
consoleErrorMock.mockRestore();
532565
});
533566

534567
it("should not notify disposed listeners", () => {
@@ -541,6 +574,28 @@ describe("ReactiveMap", () => {
541574
map.set("foo", 1);
542575
expect(listener).toHaveBeenCalledTimes(0);
543576
});
577+
578+
it("should not notify removed listeners", () => {
579+
const map = reactiveMap<string, number>();
580+
const listener1 = vi.fn();
581+
const dispose = map.onChanged(listener1);
582+
583+
map.set("foo", 1);
584+
expect(listener1).toHaveBeenCalledTimes(1);
585+
586+
listener1.mockClear();
587+
588+
dispose();
589+
map.set("bar", 2);
590+
expect(listener1).toHaveBeenCalledTimes(0);
591+
592+
const listener2 = vi.fn();
593+
map.onChanged(listener2);
594+
595+
map.set("baz", 3);
596+
expect(listener2).toHaveBeenCalledWith({ upsert: [["baz", 3]], delete: [] });
597+
expect(listener1).toHaveBeenCalledTimes(0);
598+
});
544599
});
545600

546601
describe("onDisposeValue", () => {
@@ -567,5 +622,23 @@ describe("ReactiveMap", () => {
567622
expect(listener1).toHaveBeenCalledWith(2);
568623
expect(listener2).toHaveBeenCalledTimes(0);
569624
});
625+
626+
it("should not notify removed listeners", () => {
627+
const map = reactiveMap<string, number>();
628+
const listener = vi.fn();
629+
const dispose = map.onDisposeValue(listener);
630+
631+
map.set("foo", 1);
632+
map.delete("foo");
633+
expect(listener).toHaveBeenCalledTimes(1);
634+
expect(listener).toHaveBeenCalledWith(1);
635+
636+
listener.mockClear();
637+
638+
dispose();
639+
map.set("bar", 2);
640+
map.delete("bar");
641+
expect(listener).toHaveBeenCalledTimes(0);
642+
});
570643
});
571644
});

test/combine.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { combine, writable } from "../src";
4+
5+
describe("combine", () => {
6+
it("should combine an array of Readables into a single Readable with the array of values", () => {
7+
const dep1$ = writable(1);
8+
const dep2$ = writable(2);
9+
const combined$ = combine([dep1$, dep2$]);
10+
11+
expect(combined$.value).toEqual([1, 2]);
12+
13+
dep1$.value = 3;
14+
expect(combined$.value).toEqual([3, 2]);
15+
});
16+
17+
it("should combine an array of Readables into a single Readable with transformed value", () => {
18+
const dep1$ = writable(1);
19+
const dep2$ = writable(2);
20+
const combined$ = combine([dep1$, dep2$], ([v1, v2]) => v1 + v2);
21+
22+
expect(combined$.value).toBe(3);
23+
24+
dep1$.value = 3;
25+
expect(combined$.value).toBe(5);
26+
});
27+
});

test/derive.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { derive, writable } from "../src";
4+
5+
describe("derive", () => {
6+
it("should derive a new Readable with same value", () => {
7+
const dep$ = writable(42);
8+
const derived$ = derive(dep$);
9+
10+
expect(derived$.value).toBe(dep$.value);
11+
12+
dep$.value = 100;
13+
expect(derived$.value).toBe(100);
14+
});
15+
16+
it("should derive a new Readable with transformed value", () => {
17+
const dep$ = writable(42);
18+
const derived$ = derive(dep$, value => value * 2);
19+
20+
expect(derived$.value).toBe(84);
21+
22+
dep$.value = 100;
23+
expect(derived$.value).toBe(200);
24+
});
25+
});

0 commit comments

Comments
 (0)