From ee543aa78216e1ba82487af2799a8fdba8223749 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 30 Jan 2026 12:11:43 -0800 Subject: [PATCH 1/2] bump json --- package.json | 2 +- src/observe/public.ts | 1 + src/observe/with-switch.test.ts | 179 ++++++++++++++++++++++++++++++++ src/observe/with-switch.ts | 53 ++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/observe/with-switch.test.ts create mode 100644 src/observe/with-switch.ts diff --git a/package.json b/package.json index b1346cb6..418e0d8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.7", + "version": "0.9.8", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/src/observe/public.ts b/src/observe/public.ts index cfa7bb94..1546baad 100644 --- a/src/observe/public.ts +++ b/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/src/observe/with-switch.test.ts b/src/observe/with-switch.test.ts new file mode 100644 index 00000000..dbc098d2 --- /dev/null +++ b/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/src/observe/with-switch.ts b/src/observe/with-switch.ts new file mode 100644 index 00000000..d998a148 --- /dev/null +++ b/src/observe/with-switch.ts @@ -0,0 +1,53 @@ +// © 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 + if (currentUnsubscribe) { + currentUnsubscribe(); + } + + // 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(); + } + }; + }; +} From 0ea3e742f98ae040c538339eb55263b021edadad Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 30 Jan 2026 12:16:10 -0800 Subject: [PATCH 2/2] explicit unsubscribe to null just in case --- src/observe/with-switch.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/observe/with-switch.ts b/src/observe/with-switch.ts index d998a148..e33e0b6c 100644 --- a/src/observe/with-switch.ts +++ b/src/observe/with-switch.ts @@ -26,8 +26,10 @@ export function withSwitch>>( 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 @@ -47,6 +49,7 @@ export function withSwitch>>( keyUnsubscribe(); if (currentUnsubscribe) { currentUnsubscribe(); + currentUnsubscribe = null; } }; };