Skip to content

Commit f6357e0

Browse files
krisnyecursoragent
andauthored
Added new withSwitch observe utility function. (#69)
* bump json * explicit unsubscribe to null just in case --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 327ff94 commit f6357e0

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

packages/data/src/observe/public.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from "./with-async-map.js";
2525
export * from "./with-map.js";
2626
export * from "./with-map-data.js";
2727
export * from "./with-optional.js";
28+
export * from "./with-switch.js";
2829
export * from "./with-unwrap.js";
2930
export * from "./with-lazy.js";
3031
export * from "./with-batch.js";
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
3+
import { describe, test, expect, assertType } from "vitest";
4+
import { withSwitch } from "./with-switch.js";
5+
import { fromConstant } from "./from-constant.js";
6+
import { createState } from "./create-state.js";
7+
import type { Observe } from "./index.js";
8+
9+
describe("withSwitch", () => {
10+
test("should switch and observe the selected observable", () => {
11+
const record = {
12+
a: fromConstant(1),
13+
b: fromConstant(2),
14+
c: fromConstant(3),
15+
};
16+
const key = fromConstant("b" as const);
17+
const picked = withSwitch(record, key);
18+
19+
let result: number | undefined;
20+
picked((value) => {
21+
result = value;
22+
})();
23+
24+
expect(result).toBe(2);
25+
});
26+
27+
test("should switch observables when key changes", () => {
28+
const record = {
29+
a: fromConstant(10),
30+
b: fromConstant(20),
31+
c: fromConstant(30),
32+
};
33+
const [key, setKey] = createState<"a" | "b" | "c">("a");
34+
const picked = withSwitch(record, key);
35+
36+
const values: number[] = [];
37+
const unsubscribe = picked((value) => {
38+
values.push(value);
39+
});
40+
41+
setKey("b");
42+
setKey("c");
43+
44+
unsubscribe();
45+
46+
expect(values).toEqual([10, 20, 30]);
47+
});
48+
49+
test("should unsubscribe from previous observable when key changes", () => {
50+
const [observableA, setA] = createState(100);
51+
const [observableB, setB] = createState(200);
52+
const record = { a: observableA, b: observableB };
53+
const [key, setKey] = createState<"a" | "b">("a");
54+
const picked = withSwitch(record, key);
55+
56+
const values: number[] = [];
57+
const unsubscribe = picked((value) => {
58+
values.push(value);
59+
});
60+
61+
setA(101); // Should be observed
62+
setKey("b"); // Switch to b
63+
setA(102); // Should NOT be observed (unsubscribed from a)
64+
setB(201); // Should be observed
65+
66+
unsubscribe();
67+
68+
expect(values).toEqual([100, 101, 200, 201]);
69+
});
70+
71+
test("should clean up all subscriptions on unobserve", () => {
72+
const [observableA, setA] = createState(1);
73+
const [observableB, setB] = createState(2);
74+
const record = { a: observableA, b: observableB };
75+
const [key, setKey] = createState<"a" | "b">("a");
76+
const picked = withSwitch(record, key);
77+
78+
const values: number[] = [];
79+
const unsubscribe = picked((value) => {
80+
values.push(value);
81+
});
82+
83+
setA(10);
84+
unsubscribe();
85+
86+
// After unsubscribe, no further notifications
87+
setA(20);
88+
setB(30);
89+
setKey("b");
90+
91+
expect(values).toEqual([1, 10]);
92+
});
93+
94+
test("should handle rapid key changes", () => {
95+
const record = {
96+
x: fromConstant("first"),
97+
y: fromConstant("second"),
98+
z: fromConstant("third"),
99+
};
100+
const [key, setKey] = createState<"x" | "y" | "z">("x");
101+
const picked = withSwitch(record, key);
102+
103+
const values: string[] = [];
104+
const unsubscribe = picked((value) => {
105+
values.push(value);
106+
});
107+
108+
setKey("y");
109+
setKey("z");
110+
setKey("x");
111+
setKey("z");
112+
113+
unsubscribe();
114+
115+
expect(values).toEqual(["first", "second", "third", "first", "third"]);
116+
});
117+
118+
test("should throw error when key is not in record", () => {
119+
const record = {
120+
a: fromConstant(1),
121+
b: fromConstant(2),
122+
};
123+
const [key, setKey] = createState<string>("a");
124+
const picked = withSwitch(record, key);
125+
126+
const unsubscribe = picked(() => {});
127+
128+
expect(() => {
129+
setKey("invalid");
130+
}).toThrow('Key "invalid" not found in observable record');
131+
132+
unsubscribe();
133+
});
134+
135+
test("type inference: should infer union type from subset of keys", () => {
136+
// Compile-time type test
137+
const record = {
138+
a: fromConstant(true),
139+
b: fromConstant("hello"),
140+
c: fromConstant(42),
141+
};
142+
143+
const key = fromConstant("a" as "a" | "b");
144+
const result = withSwitch(record, key);
145+
146+
// Type should be Observe<boolean | string>, not Observe<boolean | string | number>
147+
assertType<Observe<boolean | string>>(result);
148+
});
149+
150+
test("type inference: should work with all keys", () => {
151+
// Compile-time type test
152+
const record = {
153+
a: fromConstant(true),
154+
b: fromConstant("hello"),
155+
c: fromConstant(42),
156+
};
157+
158+
const key = fromConstant("a" as "a" | "b" | "c");
159+
const result = withSwitch(record, key);
160+
161+
// Type should be Observe<boolean | string | number>
162+
assertType<Observe<boolean | string | number>>(result);
163+
});
164+
165+
test("type inference: should work with single key", () => {
166+
// Compile-time type test
167+
const record = {
168+
a: fromConstant(true),
169+
b: fromConstant("hello"),
170+
c: fromConstant(42),
171+
};
172+
173+
const key = fromConstant("b" as const);
174+
const result = withSwitch(record, key);
175+
176+
// Type should be Observe<string>
177+
assertType<Observe<string>>(result);
178+
});
179+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { Observe, Unobserve } from "./index.js";
3+
4+
/**
5+
* Dynamically switches between observables in a record based on a key observable.
6+
* When the key changes, automatically unsubscribes from the previous observable and subscribes to the new one.
7+
*
8+
* @example
9+
* ```typescript
10+
* const data = {
11+
* home: homeData$,
12+
* profile: profileData$,
13+
* settings: settingsData$
14+
* };
15+
* const currentTab$ = createState('home');
16+
* const currentData$ = withSwitch(data, currentTab$);
17+
* // When currentTab$ changes, automatically switches to observing the corresponding data observable
18+
* ```
19+
*/
20+
export function withSwitch<K extends string, T extends Record<K, Observe<any>>>(
21+
record: T,
22+
key: Observe<K>
23+
): Observe<T[K] extends Observe<infer U> ? U : never> {
24+
return (observer) => {
25+
let currentUnsubscribe: Unobserve | null = null;
26+
27+
const keyUnsubscribe = key((selectedKey) => {
28+
// Unsubscribe from the previous observable before subscribing to the new one
29+
currentUnsubscribe?.();
30+
if (currentUnsubscribe) {
31+
currentUnsubscribe();
32+
currentUnsubscribe = null;
33+
}
34+
35+
// Validate that the key exists in the record
36+
if (!(selectedKey in record)) {
37+
throw new Error(
38+
`Key "${selectedKey}" not found in observable record. Available keys: ${Object.keys(record).join(", ")}`
39+
);
40+
}
41+
42+
// Subscribe to the newly selected observable
43+
const selectedObservable = record[selectedKey];
44+
currentUnsubscribe = selectedObservable(observer);
45+
});
46+
47+
// Return a new unsubscribe function that unsubscribes from both the key observable and current selected observable
48+
return () => {
49+
keyUnsubscribe();
50+
if (currentUnsubscribe) {
51+
currentUnsubscribe();
52+
currentUnsubscribe = null;
53+
}
54+
};
55+
};
56+
}

0 commit comments

Comments
 (0)