diff --git a/packages/data/src/observe/public.ts b/packages/data/src/observe/public.ts index cfa7bb9..1546baa 100644 --- a/packages/data/src/observe/public.ts +++ b/packages/data/src/observe/public.ts @@ -25,6 +25,7 @@ export * from "./with-async-map.js"; export * from "./with-map.js"; export * from "./with-map-data.js"; export * from "./with-optional.js"; +export * from "./with-switch.js"; export * from "./with-unwrap.js"; export * from "./with-lazy.js"; export * from "./with-batch.js"; diff --git a/packages/data/src/observe/with-switch.test.ts b/packages/data/src/observe/with-switch.test.ts new file mode 100644 index 0000000..dbc098d --- /dev/null +++ b/packages/data/src/observe/with-switch.test.ts @@ -0,0 +1,179 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, test, expect, assertType } from "vitest"; +import { withSwitch } from "./with-switch.js"; +import { fromConstant } from "./from-constant.js"; +import { createState } from "./create-state.js"; +import type { Observe } from "./index.js"; + +describe("withSwitch", () => { + test("should switch and observe the selected observable", () => { + const record = { + a: fromConstant(1), + b: fromConstant(2), + c: fromConstant(3), + }; + const key = fromConstant("b" as const); + const picked = withSwitch(record, key); + + let result: number | undefined; + picked((value) => { + result = value; + })(); + + expect(result).toBe(2); + }); + + test("should switch observables when key changes", () => { + const record = { + a: fromConstant(10), + b: fromConstant(20), + c: fromConstant(30), + }; + const [key, setKey] = createState<"a" | "b" | "c">("a"); + const picked = withSwitch(record, key); + + const values: number[] = []; + const unsubscribe = picked((value) => { + values.push(value); + }); + + setKey("b"); + setKey("c"); + + unsubscribe(); + + expect(values).toEqual([10, 20, 30]); + }); + + test("should unsubscribe from previous observable when key changes", () => { + const [observableA, setA] = createState(100); + const [observableB, setB] = createState(200); + const record = { a: observableA, b: observableB }; + const [key, setKey] = createState<"a" | "b">("a"); + const picked = withSwitch(record, key); + + const values: number[] = []; + const unsubscribe = picked((value) => { + values.push(value); + }); + + setA(101); // Should be observed + setKey("b"); // Switch to b + setA(102); // Should NOT be observed (unsubscribed from a) + setB(201); // Should be observed + + unsubscribe(); + + expect(values).toEqual([100, 101, 200, 201]); + }); + + test("should clean up all subscriptions on unobserve", () => { + const [observableA, setA] = createState(1); + const [observableB, setB] = createState(2); + const record = { a: observableA, b: observableB }; + const [key, setKey] = createState<"a" | "b">("a"); + const picked = withSwitch(record, key); + + const values: number[] = []; + const unsubscribe = picked((value) => { + values.push(value); + }); + + setA(10); + unsubscribe(); + + // After unsubscribe, no further notifications + setA(20); + setB(30); + setKey("b"); + + expect(values).toEqual([1, 10]); + }); + + test("should handle rapid key changes", () => { + const record = { + x: fromConstant("first"), + y: fromConstant("second"), + z: fromConstant("third"), + }; + const [key, setKey] = createState<"x" | "y" | "z">("x"); + const picked = withSwitch(record, key); + + const values: string[] = []; + const unsubscribe = picked((value) => { + values.push(value); + }); + + setKey("y"); + setKey("z"); + setKey("x"); + setKey("z"); + + unsubscribe(); + + expect(values).toEqual(["first", "second", "third", "first", "third"]); + }); + + test("should throw error when key is not in record", () => { + const record = { + a: fromConstant(1), + b: fromConstant(2), + }; + const [key, setKey] = createState("a"); + const picked = withSwitch(record, key); + + const unsubscribe = picked(() => {}); + + expect(() => { + setKey("invalid"); + }).toThrow('Key "invalid" not found in observable record'); + + unsubscribe(); + }); + + test("type inference: should infer union type from subset of keys", () => { + // Compile-time type test + const record = { + a: fromConstant(true), + b: fromConstant("hello"), + c: fromConstant(42), + }; + + const key = fromConstant("a" as "a" | "b"); + const result = withSwitch(record, key); + + // Type should be Observe, not Observe + assertType>(result); + }); + + test("type inference: should work with all keys", () => { + // Compile-time type test + const record = { + a: fromConstant(true), + b: fromConstant("hello"), + c: fromConstant(42), + }; + + const key = fromConstant("a" as "a" | "b" | "c"); + const result = withSwitch(record, key); + + // Type should be Observe + assertType>(result); + }); + + test("type inference: should work with single key", () => { + // Compile-time type test + const record = { + a: fromConstant(true), + b: fromConstant("hello"), + c: fromConstant(42), + }; + + const key = fromConstant("b" as const); + const result = withSwitch(record, key); + + // Type should be Observe + assertType>(result); + }); +}); diff --git a/packages/data/src/observe/with-switch.ts b/packages/data/src/observe/with-switch.ts new file mode 100644 index 0000000..e33e0b6 --- /dev/null +++ b/packages/data/src/observe/with-switch.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Observe, Unobserve } from "./index.js"; + +/** + * Dynamically switches between observables in a record based on a key observable. + * When the key changes, automatically unsubscribes from the previous observable and subscribes to the new one. + * + * @example + * ```typescript + * const data = { + * home: homeData$, + * profile: profileData$, + * settings: settingsData$ + * }; + * const currentTab$ = createState('home'); + * const currentData$ = withSwitch(data, currentTab$); + * // When currentTab$ changes, automatically switches to observing the corresponding data observable + * ``` + */ +export function withSwitch>>( + record: T, + key: Observe +): Observe ? U : never> { + return (observer) => { + let currentUnsubscribe: Unobserve | null = null; + + const keyUnsubscribe = key((selectedKey) => { + // Unsubscribe from the previous observable before subscribing to the new one + currentUnsubscribe?.(); + if (currentUnsubscribe) { + currentUnsubscribe(); + currentUnsubscribe = null; + } + + // Validate that the key exists in the record + if (!(selectedKey in record)) { + throw new Error( + `Key "${selectedKey}" not found in observable record. Available keys: ${Object.keys(record).join(", ")}` + ); + } + + // Subscribe to the newly selected observable + const selectedObservable = record[selectedKey]; + currentUnsubscribe = selectedObservable(observer); + }); + + // Return a new unsubscribe function that unsubscribes from both the key observable and current selected observable + return () => { + keyUnsubscribe(); + if (currentUnsubscribe) { + currentUnsubscribe(); + currentUnsubscribe = null; + } + }; + }; +}