@octanejs/rxjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @octanejs/rxjs
2
+
3
+ [React-RxJS](https://github.com/re-rxjs/react-rxjs) bindings for
4
+ [octane](https://github.com/octanejs/octane).
5
+
6
+ The package reuses RxJS and `@rx-state/core`, replacing only the React hook and
7
+ component layer. Most applications can migrate by changing imports:
8
+
9
+ ```tsx
10
+ import { bind } from '@octanejs/rxjs';
11
+ import { interval } from 'rxjs';
12
+
13
+ const [useSeconds] = bind(interval(1000), 0);
14
+
15
+ function Clock() @{
16
+ const seconds = useSeconds();
17
+ <output>{seconds as any}</output>
18
+ }
19
+ ```
20
+
21
+ The root entry exports the core binding API. `@octanejs/rxjs/utils` mirrors the
22
+ complete framework-neutral `@react-rxjs/utils` surface.
23
+
24
+ ## Intentional differences
25
+
26
+ - A `StateObservable` is not itself a renderable JSX node. Render its value with
27
+ `useStateObservable` or the hook returned by `bind`.
28
+ - `@react-rxjs/dom` is not ported. Its only API wraps ReactDOM's legacy
29
+ `unstable_batchedUpdates`; octane batches observable updates natively.
30
+ - A state observable without a default value must be read inside `Subscribe`,
31
+ matching React-RxJS's subscription ownership contract.
32
+
33
+ The implementation is based on upstream commit
34
+ `330f4c329f635c577e39655bd46c0d80a13f3a41`.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@octanejs/rxjs",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "RxJS bindings for the octane renderer, based on React-RxJS.",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/octanejs/octane.git",
23
+ "directory": "packages/rxjs"
24
+ },
25
+ "main": "src/index.ts",
26
+ "module": "src/index.ts",
27
+ "types": "src/index.ts",
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "status.json"
32
+ ],
33
+ "exports": {
34
+ ".": "./src/index.ts",
35
+ "./utils": "./src/utils/index.ts"
36
+ },
37
+ "dependencies": {
38
+ "@rx-state/core": "0.1.4"
39
+ },
40
+ "peerDependencies": {
41
+ "rxjs": "^7.8.2",
42
+ "octane": "0.1.21"
43
+ },
44
+ "devDependencies": {
45
+ "@react-rxjs/core": "0.10.8",
46
+ "@react-rxjs/utils": "0.9.7",
47
+ "@tsrx/react": "^0.2.56",
48
+ "esbuild": "^0.28.1",
49
+ "react": "^19.2.7",
50
+ "react-dom": "^19.2.7",
51
+ "rxjs": "^7.8.2",
52
+ "vitest": "^4.1.10",
53
+ "octane": "0.1.21"
54
+ },
55
+ "scripts": {
56
+ "test": "vitest run"
57
+ }
58
+ }
@@ -0,0 +1,87 @@
1
+ import { Suspense, useEffect, useRef } from 'octane';
2
+ import { liftSuspense } from '@rx-state/core';
3
+ import { Subscription } from 'rxjs';
4
+ import type { OctaneNode } from 'octane';
5
+ import type { StateObservable } from '@rx-state/core';
6
+ import type { Observable } from 'rxjs';
7
+ import { SubscriptionContext } from './context.ts';
8
+
9
+ type Props = {
10
+ children?: OctaneNode;
11
+ source$?: Observable<unknown>;
12
+ fallback?: OctaneNode;
13
+ };
14
+
15
+ const EMPTY = Symbol('empty');
16
+
17
+ export function Subscribe(props: Props) @{
18
+ const owner = useRef<
19
+ | {
20
+ children: Subscription;
21
+ source: Observable<unknown> | undefined;
22
+ sourceSubscription: Subscription | null;
23
+ add: (source: StateObservable<unknown>) => void;
24
+ }
25
+ | null
26
+ >(null);
27
+
28
+ if (!owner.current) {
29
+ const children = new Subscription();
30
+ owner.current = {
31
+ children,
32
+ source: undefined,
33
+ sourceSubscription: null,
34
+ add(source) {
35
+ let synchronousError: unknown = EMPTY;
36
+ let synchronous = true;
37
+ children.add(
38
+ liftSuspense()(source).subscribe({
39
+ error(error) {
40
+ if (synchronous) synchronousError = error;
41
+ },
42
+ }),
43
+ );
44
+ synchronous = false;
45
+ if (synchronousError !== EMPTY) throw synchronousError;
46
+ },
47
+ };
48
+ }
49
+
50
+ if (owner.current.source !== props.source$) {
51
+ owner.current.sourceSubscription?.unsubscribe();
52
+ owner.current.source = props.source$;
53
+ owner.current.sourceSubscription = props.source$
54
+ ? liftSuspense()(props.source$).subscribe({
55
+ // The owned source subscription exists only to keep the source
56
+ // active. Consumers surface observable errors through their own
57
+ // subscriptions, so do not also report them as unhandled here.
58
+ error() {},
59
+ })
60
+ : null;
61
+ }
62
+
63
+ useEffect(
64
+ () => () => {
65
+ owner.current?.sourceSubscription?.unsubscribe();
66
+ owner.current?.children.unsubscribe();
67
+ owner.current = null;
68
+ },
69
+ [],
70
+ );
71
+
72
+ @if (props.fallback === undefined) {
73
+ <SubscriptionContext.Provider
74
+ value={owner.current.add}
75
+ >{props.children}</SubscriptionContext.Provider>
76
+ } @else {
77
+ <Suspense fallback={props.fallback}>
78
+ <SubscriptionContext.Provider
79
+ value={owner.current.add}
80
+ >{props.children}</SubscriptionContext.Provider>
81
+ </Suspense>
82
+ }
83
+ }
84
+
85
+ export function RemoveSubscribe(props: { children?: OctaneNode }) @{
86
+ <SubscriptionContext.Provider value={null}>{props.children}</SubscriptionContext.Provider>
87
+ }
package/src/bind.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { state } from '@rx-state/core';
2
+ import type { DefaultedStateObservable, StateObservable, SUSPENSE } from '@rx-state/core';
3
+ import type { Observable } from 'rxjs';
4
+ import { splitSlot } from './internal.ts';
5
+ import { useStateObservable } from './useStateObservable.ts';
6
+
7
+ type AddStopArg<A extends unknown[]> = number extends A['length']
8
+ ? A
9
+ : [...args: A, _stop?: undefined];
10
+ type Value<T> = Exclude<T, typeof SUSPENSE>;
11
+
12
+ export function bind<T>(observable: Observable<T>): [() => Value<T>, StateObservable<T>];
13
+ export function bind<T>(
14
+ observable: Observable<T>,
15
+ defaultValue: T,
16
+ ): [() => Value<T>, DefaultedStateObservable<T>];
17
+ export function bind<A extends unknown[], T>(
18
+ factory: (...args: A) => Observable<T>,
19
+ ): [(...args: AddStopArg<A>) => Value<T>, (...args: AddStopArg<A>) => StateObservable<T>];
20
+ export function bind<A extends unknown[], T>(
21
+ factory: (...args: A) => Observable<T>,
22
+ defaultValue: T | ((...args: A) => T),
23
+ ): [(...args: AddStopArg<A>) => Value<T>, (...args: AddStopArg<A>) => DefaultedStateObservable<T>];
24
+ export function bind(source: unknown, defaultValue?: unknown): readonly [unknown, unknown] {
25
+ const hasDefault = arguments.length > 1;
26
+ if (typeof source === 'function') {
27
+ const bound = hasDefault
28
+ ? state(source as (...args: unknown[]) => Observable<unknown>, defaultValue)
29
+ : state(source as (...args: unknown[]) => Observable<unknown>);
30
+ const useBound = (...runtimeArgs: unknown[]) => {
31
+ const [args, slot] = splitSlot(runtimeArgs);
32
+ return useStateObservable(bound(...args), slot, args);
33
+ };
34
+ return [useBound, bound] as const;
35
+ }
36
+ const bound = hasDefault
37
+ ? state(source as Observable<unknown>, defaultValue)
38
+ : state(source as Observable<unknown>);
39
+ const useBound = (slot?: symbol) => useStateObservable(bound, slot);
40
+ return [useBound, bound] as const;
41
+ }
package/src/context.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { createContext, useContext } from 'octane';
2
+ import type { StateObservable } from '@rx-state/core';
3
+
4
+ export type SubscribeState = (source: StateObservable<unknown>) => void;
5
+ export const SubscriptionContext = createContext<SubscribeState | null>(null);
6
+ export const useSubscription = () => useContext(SubscriptionContext);
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type {
2
+ AddStopArg,
3
+ DefaultedStateObservable,
4
+ EmptyObservableError,
5
+ NoSubscribersError,
6
+ PipeState,
7
+ StateObservable,
8
+ StatePromise,
9
+ WithDefaultOperator,
10
+ } from '@rx-state/core';
11
+ export { liftSuspense, sinkSuspense, SUSPENSE, withDefault } from '@rx-state/core';
12
+ export { bind } from './bind.ts';
13
+ export { shareLatest } from './shareLatest.ts';
14
+ export { state } from './state.ts';
15
+ export { RemoveSubscribe, Subscribe } from './Subscribe.tsrx';
16
+ export { useStateObservable } from './useStateObservable.ts';
@@ -0,0 +1,23 @@
1
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
2
+ const bareTagCache = new Map<string, symbol>();
3
+
4
+ export function subSlot(slot: symbol | undefined, tag: string): symbol {
5
+ if (slot === undefined) {
6
+ let bare = bareTagCache.get(tag);
7
+ if (bare === undefined) bareTagCache.set(tag, (bare = Symbol.for(`:rxjs:${tag}`)));
8
+ return bare;
9
+ }
10
+ let byTag = subSlotCache.get(slot);
11
+ if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
12
+ let derived = byTag.get(tag);
13
+ if (derived === undefined) {
14
+ byTag.set(tag, (derived = Symbol.for(`${slot.description ?? ''}:rxjs:${tag}`)));
15
+ }
16
+ return derived;
17
+ }
18
+
19
+ export function splitSlot(args: unknown[]): [unknown[], symbol | undefined] {
20
+ const tail = args[args.length - 1];
21
+ const slot = typeof tail === 'symbol' ? tail : undefined;
22
+ return [slot === undefined ? args : args.slice(0, -1), slot];
23
+ }
@@ -0,0 +1,10 @@
1
+ import { ReplaySubject, share } from 'rxjs';
2
+ import type { MonoTypeOperatorFunction } from 'rxjs';
3
+
4
+ export const shareLatest = <T>(): MonoTypeOperatorFunction<T> =>
5
+ share<T>({
6
+ connector: () => new ReplaySubject<T>(1),
7
+ resetOnError: true,
8
+ resetOnComplete: true,
9
+ resetOnRefCountZero: true,
10
+ });
package/src/state.ts ADDED
@@ -0,0 +1 @@
1
+ export { state } from '@rx-state/core';
@@ -0,0 +1,88 @@
1
+ import { liftSuspense, NoSubscribersError, StatePromise } from '@rx-state/core';
2
+ import { useReducer, useRef, useSyncExternalStore } from 'octane';
3
+ import type { DefaultedStateObservable, StateObservable, SUSPENSE } from '@rx-state/core';
4
+ import { useSubscription } from './context.ts';
5
+ import { subSlot } from './internal.ts';
6
+
7
+ type Value<T> = Exclude<T, typeof SUSPENSE>;
8
+ type VoidCallback = () => void;
9
+
10
+ interface ObservableRef<T> {
11
+ source: StateObservable<T>;
12
+ subscribe: (callback: VoidCallback) => VoidCallback;
13
+ getSnapshot: () => Value<T>;
14
+ }
15
+
16
+ export function useStateObservable<T>(
17
+ source: StateObservable<T>,
18
+ slot?: symbol,
19
+ identity?: readonly unknown[],
20
+ ): Value<T> {
21
+ const subscription = useSubscription();
22
+ const identityRef = useRef<readonly unknown[] | StateObservable<T>>(
23
+ identity ?? source,
24
+ subSlot(slot, 'identity'),
25
+ );
26
+ const previousIdentity = identityRef.current;
27
+ if (
28
+ identity &&
29
+ (!Array.isArray(previousIdentity) ||
30
+ previousIdentity.length !== identity.length ||
31
+ identity.some((value, index) => (previousIdentity as readonly unknown[])[index] !== value))
32
+ ) {
33
+ identityRef.current = identity;
34
+ }
35
+ const sourceIdentity = identityRef.current;
36
+ const errorRef = useRef<
37
+ { source: readonly unknown[] | StateObservable<T>; error: unknown } | undefined
38
+ >(undefined, subSlot(slot, 'error'));
39
+ const [, rerender] = useReducer((count: number) => count + 1, 0, subSlot(slot, 'error-update'));
40
+ const callbackRef = useRef<ObservableRef<T> | undefined>(undefined, subSlot(slot, 'callbacks'));
41
+
42
+ if (!callbackRef.current) {
43
+ const getSnapshot = () => {
44
+ const current = callbackRef.current!.source as DefaultedStateObservable<T>;
45
+ if (!current.getRefCount() && !current.getDefaultValue) {
46
+ if (!subscription) throw new Error('Missing Subscribe!');
47
+ subscription(current);
48
+ }
49
+ const value = current.getValue();
50
+ if (value instanceof StatePromise) {
51
+ throw value.catch((error) => {
52
+ if (error instanceof NoSubscribersError) return error;
53
+ throw error;
54
+ });
55
+ }
56
+ return value as Value<T>;
57
+ };
58
+ callbackRef.current = {
59
+ source: null as unknown as StateObservable<T>,
60
+ subscribe: () => () => {},
61
+ getSnapshot,
62
+ };
63
+ }
64
+
65
+ const ref = callbackRef.current;
66
+ if (ref.source !== source) {
67
+ ref.source = source;
68
+ ref.subscribe = (notify) => {
69
+ const active = liftSuspense()(source).subscribe({
70
+ next: notify,
71
+ error: (error) => {
72
+ errorRef.current = { source: sourceIdentity, error };
73
+ rerender(0);
74
+ },
75
+ });
76
+ return () => active.unsubscribe();
77
+ };
78
+ }
79
+
80
+ const value = useSyncExternalStore(
81
+ ref.subscribe,
82
+ ref.getSnapshot,
83
+ ref.getSnapshot,
84
+ subSlot(slot, 'store'),
85
+ );
86
+ if (errorRef.current?.source === sourceIdentity) throw errorRef.current.error;
87
+ return value;
88
+ }
@@ -0,0 +1,116 @@
1
+ import { KeyChanges } from './partitionByKey';
2
+ import { Observable, Subscription } from 'rxjs';
3
+
4
+ export interface MapWithChanges<K, V> extends Map<K, V> {
5
+ changes: Set<K>;
6
+ }
7
+
8
+ /**
9
+ * Creates a stream that combines the result of the streams from each key of the input stream.
10
+ *
11
+ * @param keys$ Stream of the list of keys to subscribe to.
12
+ * @param getInner$ Function that returns the stream for each key.
13
+ * @returns An stream with a map containing the latest value from the stream of each key.
14
+ */
15
+ export const combineKeys = <K, T>(
16
+ keys$: Observable<Iterable<K> | KeyChanges<K>>,
17
+ getInner$: (key: K) => Observable<T>,
18
+ ): Observable<MapWithChanges<K, T>> =>
19
+ new Observable((observer) => {
20
+ const innerSubscriptions = new Map<K, Subscription>();
21
+ let changes = new Set<K>();
22
+ const currentValue = new Map<K, T>();
23
+ let updatingSource = false;
24
+ let isPristine = true;
25
+
26
+ const next = () => {
27
+ if (!updatingSource) {
28
+ const result = Object.assign(currentValue, {
29
+ changes,
30
+ });
31
+ changes = new Set<K>();
32
+ isPristine = false;
33
+ observer.next(result);
34
+ }
35
+ };
36
+
37
+ const subscription = keys$.subscribe(
38
+ (nextKeysArr) => {
39
+ updatingSource = true;
40
+
41
+ const keys = new Set(inputIsKeyChanges(nextKeysArr) ? nextKeysArr.keys : nextKeysArr);
42
+
43
+ if (inputIsKeyChanges(nextKeysArr)) {
44
+ if (nextKeysArr.type === 'remove') {
45
+ keys.forEach((key) => {
46
+ const sub = innerSubscriptions.get(key);
47
+ if (!sub) return;
48
+ sub.unsubscribe();
49
+ innerSubscriptions.delete(key);
50
+ if (currentValue.has(key)) {
51
+ changes.add(key);
52
+ currentValue.delete(key);
53
+ }
54
+ });
55
+ // Keys after this block is the list of keys to add. Clear it.
56
+ keys.clear();
57
+ }
58
+ } else {
59
+ innerSubscriptions.forEach((sub, key) => {
60
+ if (!keys.has(key)) {
61
+ sub.unsubscribe();
62
+ innerSubscriptions.delete(key);
63
+ if (currentValue.has(key)) {
64
+ changes.add(key);
65
+ currentValue.delete(key);
66
+ }
67
+ } else {
68
+ keys.delete(key);
69
+ }
70
+ });
71
+ }
72
+
73
+ keys.forEach((key) => {
74
+ innerSubscriptions.set(
75
+ key,
76
+ getInner$(key).subscribe(
77
+ (x) => {
78
+ if (!currentValue.has(key) || currentValue.get(key) !== x) {
79
+ changes.add(key);
80
+ currentValue.set(key, x);
81
+ next();
82
+ }
83
+ },
84
+ (e) => {
85
+ observer.error(e);
86
+ },
87
+ ),
88
+ );
89
+ });
90
+
91
+ updatingSource = false;
92
+ // If there are no changes but the nextKeys are an empty iterator
93
+ // and we have never emitted before, that means that the first
94
+ // value that keys$ has emitted is an empty iterator, therefore
95
+ // we should emit an empy Map
96
+ if (changes.size || (isPristine && !keys.size)) next();
97
+ },
98
+ (e) => {
99
+ observer.error(e);
100
+ },
101
+ () => {
102
+ observer.complete();
103
+ },
104
+ );
105
+
106
+ return () => {
107
+ subscription.unsubscribe();
108
+ innerSubscriptions.forEach((sub) => {
109
+ sub.unsubscribe();
110
+ });
111
+ };
112
+ });
113
+
114
+ function inputIsKeyChanges<K>(input: Iterable<K> | KeyChanges<K>): input is KeyChanges<K> {
115
+ return 'type' in input && 'keys' in input;
116
+ }
@@ -0,0 +1,39 @@
1
+ import { Observable } from 'rxjs';
2
+ import { SUSPENSE } from '@rx-state/core';
3
+ import { bind } from '../bind.ts';
4
+
5
+ type SubstractTuples<A1, A2> = A2 extends [unknown, ...infer Rest2]
6
+ ? A1 extends [unknown, ...infer Rest1]
7
+ ? SubstractTuples<Rest1, Rest2>
8
+ : []
9
+ : A1;
10
+
11
+ const execSelf = <T>(fn: () => T) => fn();
12
+
13
+ /**
14
+ * Returns a version of bind where its hook will have the first parameters bound
15
+ * the results of the provided functions
16
+ *
17
+ * @param {...React.Context} context - The React.Context that should be bound to the hook.
18
+ */
19
+ export function contextBinder<
20
+ A extends (() => any)[],
21
+ OT extends {
22
+ [K in keyof A]: A[K] extends () => infer V ? V : unknown;
23
+ },
24
+ >(
25
+ ...args: A
26
+ ): <AA extends any[], T, ARGS extends [...OT, ...AA]>(
27
+ getObservable: (...args: ARGS) => Observable<T>,
28
+ defaultValue?: T | undefined,
29
+ ) => [
30
+ (...args: SubstractTuples<ARGS, OT>) => Exclude<T, typeof SUSPENSE>,
31
+ (...args: ARGS) => Observable<T>,
32
+ ];
33
+ export function contextBinder(...args: any[]) {
34
+ const useArgs = () => args.map(execSelf);
35
+ return function () {
36
+ const [hook, getter] = bind.apply(null, arguments as any) as any;
37
+ return [(...args: any[]) => (hook as any)(...useArgs(), ...args), getter];
38
+ } as any;
39
+ }
@@ -0,0 +1,83 @@
1
+ import { GroupedObservable, Observable, Observer } from 'rxjs';
2
+
3
+ /**
4
+ * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal.
5
+ *
6
+ * @returns [1, 2]
7
+ * 1. The getter function that returns the GroupedObservable<T, T>
8
+ * 2. The emitter function.
9
+ */
10
+ export function createKeyedSignal<T>(): [(key: T) => GroupedObservable<T, T>, (key: T) => void];
11
+
12
+ /**
13
+ * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal.
14
+ *
15
+ * @param keySelector a function that extracts the key from the emitted value
16
+ * @returns [1, 2]
17
+ * 1. The getter function that returns the GroupedObservable<K, T>
18
+ * 2. The emitter function.
19
+ */
20
+ export function createKeyedSignal<K, T>(): [
21
+ (key: K) => GroupedObservable<K, T>,
22
+ (key: K, value: T) => void,
23
+ ];
24
+
25
+ /**
26
+ * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal.
27
+ *
28
+ * @param keySelector a function that extracts the key from the emitted value
29
+ * @returns [1, 2]
30
+ * 1. The getter function that returns the GroupedObservable<K, T>
31
+ * 2. The emitter function.
32
+ */
33
+ export function createKeyedSignal<K, T>(
34
+ keySelector: (signal: T) => K,
35
+ ): [(key: K) => GroupedObservable<K, T>, (signal: T) => void];
36
+
37
+ /**
38
+ * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal.
39
+ *
40
+ * @param keySelector a function that extracts the key from the emitted value
41
+ * @param mapper a function that maps the arguments of the emitter function to the value of the GroupedObservable
42
+ * @returns [1, 2]
43
+ * 1. The getter function that returns the GroupedObservable<K, T>
44
+ * 2. The emitter function (...args: any[]) => T.
45
+ */
46
+ export function createKeyedSignal<K, T, A extends any[]>(
47
+ keySelector: (signal: T) => K,
48
+ mapper: (...args: A) => T,
49
+ ): [(key: K) => GroupedObservable<K, T>, (...args: A) => void];
50
+
51
+ export function createKeyedSignal<K, T, A extends any[]>(
52
+ keySelector?: (signal: T) => K,
53
+ mapper?: (...args: A) => T,
54
+ ): [(key: K) => GroupedObservable<K, T>, (...args: A) => void] {
55
+ const observersMap = new Map<K, Set<Observer<T>>>();
56
+
57
+ return [
58
+ (key: K) => {
59
+ const res = new Observable<T>((observer) => {
60
+ if (!observersMap.has(key)) {
61
+ observersMap.set(key, new Set());
62
+ }
63
+ const set = observersMap.get(key)!;
64
+ set.add(observer);
65
+ return () => {
66
+ set.delete(observer);
67
+ if (set.size === 0) {
68
+ observersMap.delete(key);
69
+ }
70
+ };
71
+ }) as GroupedObservable<K, T>;
72
+ (res as any).key = key;
73
+ return res;
74
+ },
75
+ (...args: A) => {
76
+ const payload = mapper ? mapper(...args) : args.length === 2 ? args[1] : args[0];
77
+ const key = keySelector ? keySelector(payload) : args[0];
78
+ observersMap.get(key)?.forEach((o) => {
79
+ o.next(payload);
80
+ });
81
+ },
82
+ ];
83
+ }
@@ -0,0 +1,24 @@
1
+ import { Observable } from 'rxjs';
2
+ import { createSignal } from './createSignal';
3
+
4
+ /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */
5
+ export function createListener<A extends unknown[], T>(
6
+ mapper: (...args: A) => T,
7
+ ): [Observable<T>, (...args: A) => void];
8
+
9
+ /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */
10
+ export function createListener(): [Observable<void>, () => void];
11
+
12
+ /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */
13
+ export function createListener<T>(): [Observable<T>, (payload: T) => void];
14
+
15
+ /**
16
+ * Creates a void signal. It's sugar for splitting the Observer and the Observable of a signal.
17
+ *
18
+ * @returns [1, 2]
19
+ * 1. The Observable
20
+ * 2. The emitter function.
21
+ */
22
+ export function createListener(...args: any[]) {
23
+ return (createSignal as any)(...args);
24
+ }
@@ -0,0 +1,39 @@
1
+ import { identity, Observable, Subject } from 'rxjs';
2
+
3
+ /**
4
+ * Creates a signal. It's sugar for splitting the Observer and the Observable of a signal.
5
+ *
6
+ * @param mapper a mapper function, for mapping the arguments of the emitter function into
7
+ * the value of the Observable.
8
+ * @returns [1, 2]
9
+ * 1. The Observable<T>
10
+ * 2. The emitter function.
11
+ */
12
+ export function createSignal<A extends unknown[], T>(
13
+ mapper: (...args: A) => T,
14
+ ): [Observable<T>, (...args: A) => void];
15
+
16
+ /**
17
+ * Creates a void signal. It's sugar for splitting the Observer and the Observable of a signal.
18
+ *
19
+ * @returns [1, 2]
20
+ * 1. The Observable<void>
21
+ * 2. The emitter function.
22
+ */
23
+ export function createSignal(): [Observable<void>, () => void];
24
+
25
+ /**
26
+ * Creates a signal. It's sugar for splitting the Observer and the Observable of a signal.
27
+ *
28
+ * @returns [1, 2]
29
+ * 1. The Observable<T>
30
+ * 2. The emitter function.
31
+ */
32
+ export function createSignal<T>(): [Observable<T>, (payload: T) => void];
33
+
34
+ export function createSignal<A extends unknown[], T>(
35
+ mapper: (...args: A) => T = identity as any,
36
+ ): [Observable<T>, (...args: A) => void] {
37
+ const subject = new Subject<T>();
38
+ return [subject.asObservable(), (...args: A) => subject.next(mapper(...args))];
39
+ }
@@ -0,0 +1,14 @@
1
+ export { combineKeys } from './combineKeys';
2
+ export type { MapWithChanges } from './combineKeys';
3
+ export { contextBinder } from './contextBinder';
4
+ export { createKeyedSignal } from './createKeyedSignal';
5
+ export { createListener } from './createListener';
6
+ export { createSignal } from './createSignal';
7
+ export { mergeWithKey } from './mergeWithKey';
8
+ export { partitionByKey } from './partitionByKey';
9
+ export type { KeyChanges } from './partitionByKey';
10
+ export { selfDependant, selfDependent } from './selfDependent';
11
+ export { suspend } from './suspend';
12
+ export { suspended } from './suspended';
13
+ export { switchMapSuspended } from './switchMapSuspended';
14
+ export { toKeySet } from './toKeySet';
@@ -0,0 +1,26 @@
1
+ import { Observable } from 'rxjs';
2
+
3
+ export const defaultStart =
4
+ <T, D>(value: D) =>
5
+ (source$: Observable<T>) =>
6
+ new Observable<T | D>((observer) => {
7
+ let emitted = false;
8
+ const subscription = source$.subscribe(
9
+ (x) => {
10
+ emitted = true;
11
+ observer.next(x);
12
+ },
13
+ (e) => {
14
+ observer.error(e);
15
+ },
16
+ () => {
17
+ observer.complete();
18
+ },
19
+ );
20
+
21
+ if (!emitted) {
22
+ observer.next(value);
23
+ }
24
+
25
+ return subscription;
26
+ });
@@ -0,0 +1,26 @@
1
+ import { merge, Observable, ObservableInput, from, SchedulerLike } from 'rxjs';
2
+ import { map } from 'rxjs/operators';
3
+
4
+ /**
5
+ * Emits the values from all the streams of the provided object, in a result
6
+ * which provides the key of the stream of that emission.
7
+ *
8
+ * @param input object of streams
9
+ */
10
+ export const mergeWithKey: <
11
+ O extends { [P in keyof any]: ObservableInput<any> },
12
+ OT extends {
13
+ [K in keyof O]: O[K] extends ObservableInput<infer V> ? { type: K; payload: V } : unknown;
14
+ },
15
+ >(
16
+ x: O,
17
+ concurrent?: number,
18
+ scheduler?: SchedulerLike,
19
+ ) => Observable<OT[keyof O]> = (input, ...optionalArgs) =>
20
+ merge<any[]>(
21
+ ...(Object.entries(input)
22
+ .map(
23
+ ([type, stream]) => from(stream).pipe(map((payload) => ({ type, payload }) as any)) as any,
24
+ )
25
+ .concat(optionalArgs) as any[]),
26
+ );
@@ -0,0 +1,216 @@
1
+ import { shareLatest } from '../shareLatest.ts';
2
+ import { GroupedObservable, identity, noop, Observable, Subject, Subscription } from 'rxjs';
3
+ import { map } from 'rxjs/operators';
4
+
5
+ export interface KeyChanges<K> {
6
+ type: 'add' | 'remove';
7
+ keys: Iterable<K>;
8
+ }
9
+
10
+ /**
11
+ * Groups the elements from the source stream by using `keySelector`, returning
12
+ * a stream of the active keys, and a function to get the stream of a specific group
13
+ *
14
+ * @param stream Input stream
15
+ * @param keySelector Function that specifies the key for each element in `stream`
16
+ * @param streamSelector Function to apply to each resulting group
17
+ * @returns [1, 2]
18
+ * 1. A function that accepts a key and returns the stream for the group of that key.
19
+ * 2. A stream of KeyChanges, an object that describes what keys have been added or deleted.
20
+ */
21
+ export function partitionByKey<T, K, R>(
22
+ stream: Observable<T>,
23
+ keySelector: (value: T) => K,
24
+ streamSelector: (grouped: Observable<T>, key: K) => Observable<R>,
25
+ ): [(key: K) => GroupedObservable<K, R>, Observable<KeyChanges<K>>];
26
+
27
+ /**
28
+ * Groups the elements from the source stream by using `keySelector`, returning
29
+ * a stream of the active keys, and a function to get the stream of a specific group
30
+ *
31
+ * @param stream Input stream
32
+ * @param keySelector Function that specifies the key for each element in `stream`
33
+ * @returns [1, 2]
34
+ * 1. A function that accepts a key and returns the stream for the group of that key.
35
+ * 2. A stream of KeyChanges, an object that describes what keys have been added or deleted.
36
+ */
37
+ export function partitionByKey<T, K>(
38
+ stream: Observable<T>,
39
+ keySelector: (value: T) => K,
40
+ ): [(key: K) => GroupedObservable<K, T>, Observable<KeyChanges<K>>];
41
+
42
+ export function partitionByKey<T, K, R>(
43
+ stream: Observable<T>,
44
+ keySelector: (value: T) => K,
45
+ streamSelector?: (grouped: Observable<T>, key: K) => Observable<R>,
46
+ ): [(key: K) => GroupedObservable<K, R>, Observable<KeyChanges<K>>] {
47
+ const groupedObservables$ = new Observable<{
48
+ groups: Map<K, InnerGroup<T, K, R>>;
49
+ changes: KeyChanges<K>;
50
+ }>((subscriber) => {
51
+ const groups: Map<K, InnerGroup<T, K, R>> = new Map();
52
+
53
+ let sourceCompleted = false;
54
+ const finalize =
55
+ (type: 'error' | 'complete') =>
56
+ (...args: any[]) => {
57
+ sourceCompleted = true;
58
+ if (groups.size) {
59
+ groups.forEach((g) => (g.source[type] as any)(...args));
60
+ } else {
61
+ subscriber[type](...args);
62
+ }
63
+ };
64
+
65
+ const sub = stream.subscribe(
66
+ (x) => {
67
+ const key = keySelector(x);
68
+ if (groups.has(key)) return groups.get(key)!.source.next(x);
69
+
70
+ let pendingFirstAdd = true;
71
+ const emitFirstAdd = () => {
72
+ if (pendingFirstAdd) {
73
+ pendingFirstAdd = false;
74
+ subscriber.next({
75
+ groups,
76
+ changes: {
77
+ type: 'add',
78
+ keys: [key],
79
+ },
80
+ });
81
+ }
82
+ };
83
+
84
+ const subject = new Subject<T>();
85
+ let pendingFirstVal = true;
86
+ const emitFirstValue = () => {
87
+ if (pendingFirstVal) {
88
+ pendingFirstVal = false;
89
+ subject.next(x);
90
+ }
91
+ };
92
+
93
+ const shared$ = shareLatest()((streamSelector || identity)(subject, key));
94
+ const res = new Observable((observer) => {
95
+ incRefcount();
96
+ const subscription = shared$.subscribe(observer);
97
+ subscription.add(decRefcount);
98
+ emitFirstValue();
99
+ return subscription;
100
+ }) as any as GroupedObservable<K, R>;
101
+ (res as any).key = key;
102
+
103
+ const innerGroup: InnerGroup<T, K, R> = {
104
+ source: subject,
105
+ observable: res,
106
+ subscription: new Subscription(),
107
+ };
108
+ groups.set(key, innerGroup);
109
+
110
+ innerGroup.subscription = shared$.subscribe(
111
+ noop,
112
+ (e) => subscriber.error(e),
113
+ () => {
114
+ groups.delete(key);
115
+ emitFirstAdd();
116
+ subscriber.next({
117
+ groups,
118
+ changes: {
119
+ type: 'remove',
120
+ keys: [key],
121
+ },
122
+ });
123
+
124
+ if (groups.size === 0 && sourceCompleted) {
125
+ subscriber.complete();
126
+ }
127
+ },
128
+ );
129
+ emitFirstAdd();
130
+ emitFirstValue();
131
+ },
132
+ finalize('error'),
133
+ finalize('complete'),
134
+ );
135
+
136
+ return () => {
137
+ sub.unsubscribe();
138
+ groups.forEach((g) => {
139
+ g.source.unsubscribe();
140
+ g.subscription.unsubscribe();
141
+ });
142
+ };
143
+ }).pipe(shareLatest());
144
+
145
+ let refCount = 0;
146
+ let sub: Subscription | undefined;
147
+ function incRefcount() {
148
+ refCount++;
149
+ if (refCount === 1) {
150
+ sub = groupedObservables$.subscribe();
151
+ }
152
+ }
153
+ function decRefcount() {
154
+ refCount--;
155
+ if (refCount === 0) {
156
+ sub?.unsubscribe();
157
+ }
158
+ }
159
+
160
+ return [
161
+ (key: K) => getGroupedObservable(groupedObservables$.pipe(map(({ groups }) => groups)), key),
162
+ groupedObservables$.pipe(
163
+ map((m, i): KeyChanges<K> => {
164
+ if (i === 0) {
165
+ // Replay all the previously added keys
166
+ return {
167
+ type: 'add',
168
+ keys: m.groups.keys(),
169
+ };
170
+ }
171
+ return m.changes;
172
+ }),
173
+ ),
174
+ ];
175
+ }
176
+
177
+ interface InnerGroup<T, K, R> {
178
+ source: Subject<T>;
179
+ observable: GroupedObservable<K, R>;
180
+ subscription: Subscription;
181
+ }
182
+
183
+ const getGroupedObservable = <K, T>(source$: Observable<Map<K, InnerGroup<any, K, T>>>, key: K) => {
184
+ const result = new Observable<T>((observer) => {
185
+ let innerSub: Subscription | undefined;
186
+ let outerSub: Subscription | undefined;
187
+ let foundSynchronously = false;
188
+ outerSub = source$.subscribe(
189
+ (n) => {
190
+ const innerGroup = n.get(key);
191
+ if (innerGroup && !innerSub) {
192
+ innerSub = innerGroup.observable.subscribe(observer);
193
+ outerSub?.unsubscribe();
194
+ foundSynchronously = true;
195
+ }
196
+ },
197
+ (e) => {
198
+ observer.error(e);
199
+ },
200
+ () => {
201
+ observer.complete();
202
+ },
203
+ );
204
+ if (foundSynchronously) {
205
+ outerSub.unsubscribe();
206
+ outerSub = undefined;
207
+ }
208
+
209
+ return () => {
210
+ innerSub?.unsubscribe();
211
+ outerSub?.unsubscribe();
212
+ };
213
+ }) as GroupedObservable<K, T>;
214
+ (result as any).key = key;
215
+ return result;
216
+ };
@@ -0,0 +1,34 @@
1
+ import { Observable, Subject, MonoTypeOperatorFunction, BehaviorSubject } from 'rxjs';
2
+ import { switchAll, tap } from 'rxjs/operators';
3
+
4
+ /**
5
+ * A creation operator that helps at creating observables that have circular
6
+ * dependencies
7
+ *
8
+ * @returns [1, 2]
9
+ * 1. The inner subject as an Observable
10
+ * 2. A pipable operator that taps into the inner Subject
11
+ */
12
+ export const selfDependent = <T>(): [Observable<T>, () => MonoTypeOperatorFunction<T>] => {
13
+ const activeSubject: BehaviorSubject<Subject<T>> = new BehaviorSubject(new Subject<T>());
14
+ return [
15
+ activeSubject.pipe(switchAll()),
16
+ () =>
17
+ tap({
18
+ next: (v) => activeSubject.value.next(v),
19
+ error: (e) => {
20
+ activeSubject.value.error(e);
21
+ activeSubject.next(new Subject<T>());
22
+ },
23
+ complete: () => {
24
+ activeSubject.value.complete();
25
+ activeSubject.next(new Subject<T>());
26
+ },
27
+ }) as MonoTypeOperatorFunction<T>,
28
+ ];
29
+ };
30
+
31
+ /**
32
+ * @deprecated renamed to `selfDependent`
33
+ */
34
+ export const selfDependant = selfDependent;
@@ -0,0 +1,12 @@
1
+ import { ObservableInput, from, Observable } from 'rxjs';
2
+ import { SUSPENSE } from '@rx-state/core';
3
+ import { defaultStart } from './internal-utils';
4
+
5
+ /**
6
+ * A RxJS creation operator that prepends a SUSPENSE on the source observable.
7
+ *
8
+ * @param source$ Source observable
9
+ */
10
+ export const suspend: <T>(source$: ObservableInput<T>) => Observable<T | typeof SUSPENSE> = <T>(
11
+ source$: ObservableInput<T>,
12
+ ) => defaultStart(SUSPENSE)(from(source$)) as any;
@@ -0,0 +1,8 @@
1
+ import { suspend } from './suspend';
2
+ import { OperatorFunction } from 'rxjs';
3
+ import { SUSPENSE } from '@rx-state/core';
4
+
5
+ /**
6
+ * A RxJS pipeable operator that prepends a SUSPENSE on the source observable.
7
+ */
8
+ export const suspended = <T>(): OperatorFunction<T, T | typeof SUSPENSE> => suspend;
@@ -0,0 +1,15 @@
1
+ import { ObservableInput, OperatorFunction, ObservedValueOf, pipe } from 'rxjs';
2
+ import { switchMap } from 'rxjs/operators';
3
+ import { suspend } from './suspend';
4
+ import { SUSPENSE } from '@rx-state/core';
5
+
6
+ /**
7
+ * Same behaviour as rxjs' `switchMap`, but prepending every new event with
8
+ * SUSPENSE.
9
+ *
10
+ * @param fn Projection function
11
+ */
12
+ export const switchMapSuspended = <T, O extends ObservableInput<any>>(
13
+ project: (value: T, index: number) => O,
14
+ ): OperatorFunction<T, ObservedValueOf<O> | typeof SUSPENSE> =>
15
+ pipe(switchMap((x, index) => suspend(project(x, index))));
@@ -0,0 +1,32 @@
1
+ import { Observable, OperatorFunction } from 'rxjs';
2
+ import { KeyChanges } from './partitionByKey';
3
+
4
+ /**
5
+ * Operator function that maps a stream of KeyChanges into a Set that contains
6
+ * the active keys.
7
+ */
8
+ export function toKeySet<K>(): OperatorFunction<KeyChanges<K>, Set<K>> {
9
+ return (source$) =>
10
+ new Observable<Set<K>>((observer) => {
11
+ const result = new Set<K>();
12
+ let pristine = true;
13
+ const subscription = source$.subscribe({
14
+ next({ type, keys }) {
15
+ const action = type === 'add' ? type : 'delete';
16
+ for (let k of keys) {
17
+ result[action](k);
18
+ }
19
+ observer.next(result);
20
+ pristine = false;
21
+ },
22
+ error(e) {
23
+ observer.error(e);
24
+ },
25
+ complete() {
26
+ observer.complete();
27
+ },
28
+ });
29
+ if (pristine) observer.next(result);
30
+ return subscription;
31
+ });
32
+ }
package/status.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "upstream": {
3
+ "package": "@react-rxjs/core + @react-rxjs/utils",
4
+ "version": "0.10.8 / 0.9.7"
5
+ },
6
+ "surface": "Core bind/state/Subscribe APIs and the complete @react-rxjs/utils surface.",
7
+ "divergences": [
8
+ "StateObservable values are not JSX nodes; render them through useStateObservable or bind.",
9
+ "@react-rxjs/dom is omitted because octane batches updates without ReactDOM.unstable_batchedUpdates."
10
+ ],
11
+ "ssr": "useSyncExternalStore supplies the state snapshot during server rendering; browser-only ReactDOM batching helpers are intentionally absent.",
12
+ "verified": "2026-07-30"
13
+ }