Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/data/src/observe/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
179 changes: 179 additions & 0 deletions packages/data/src/observe/with-switch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>("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<boolean | string>, not Observe<boolean | string | number>
assertType<Observe<boolean | string>>(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<boolean | string | number>
assertType<Observe<boolean | string | number>>(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<string>
assertType<Observe<string>>(result);
});
});
56 changes: 56 additions & 0 deletions packages/data/src/observe/with-switch.ts
Original file line number Diff line number Diff line change
@@ -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<K extends string, T extends Record<K, Observe<any>>>(
record: T,
key: Observe<K>
): Observe<T[K] extends Observe<infer U> ? 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;
}
};
};
}