Skip to content

Commit 1e49f42

Browse files
Add shared subscription functionality with FakeSharedEmitter
1 parent a8fb2fe commit 1e49f42

File tree

7 files changed

+281
-4
lines changed

7 files changed

+281
-4
lines changed

demo/FakeSharedEmitter.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
type Callback<T> = {
2+
onSuccess: (data: T) => void
3+
onError?: (error: unknown) => void
4+
}
5+
6+
export const FakeSharedEmitter = (() => {
7+
const listeners = new Map<string, {
8+
callbacks: Callback<any>[],
9+
interval: NodeJS.Timeout
10+
}>();
11+
12+
let intervalDuration: undefined|number = undefined as undefined|number;
13+
14+
function start(key: string) {
15+
if (listeners.has(key)) return;
16+
17+
const interval = setInterval(() => {
18+
const data = `${key} - ${Math.floor(Math.random() * 1000)}`;
19+
console.log("pushing through subscriber...");
20+
listeners.get(key)?.callbacks.forEach((cb) => cb.onSuccess(data));
21+
}, intervalDuration ?? (1000 + Math.random() * 2000));
22+
23+
listeners.set(key, {
24+
callbacks: [],
25+
interval,
26+
});
27+
}
28+
29+
function subscribe<T>(key: string, onSuccess: Callback<T>['onSuccess'], onError?: Callback<T>['onError']) {
30+
if (!listeners.has(key)) start(key);
31+
32+
const callback = {
33+
onSuccess,
34+
onError,
35+
}
36+
const entry = listeners.get(key)!;
37+
entry.callbacks.push(callback);
38+
39+
return () => {
40+
entry.callbacks = entry.callbacks.filter((cb) => cb !== callback);
41+
if (entry.callbacks.length === 0) {
42+
clearInterval(entry.interval);
43+
listeners.delete(key);
44+
}
45+
};
46+
}
47+
48+
function forcePush(key: string, value: any) {
49+
if (listeners.has(key)) {
50+
listeners.get(key)!.callbacks.forEach((cb) => cb.onSuccess(value));
51+
}
52+
}
53+
54+
// noinspection JSUnusedGlobalSymbols
55+
return {
56+
subscribe,
57+
forcePush,
58+
start,
59+
stop(key: string) {
60+
if (listeners.has(key)) {
61+
listeners.get(key)!.callbacks.forEach((cb) => cb.onError?.(new Error(`Stopped by user: ${key}`)));
62+
clearInterval(listeners.get(key)!.interval);
63+
listeners.delete(key);
64+
}
65+
},
66+
clearAll() {
67+
for (const key of listeners.keys()) {
68+
clearInterval(listeners.get(key)!.interval);
69+
}
70+
listeners.clear();
71+
},
72+
intervalDuration
73+
};
74+
})();
75+
76+
window.FakeSharedEmitter = FakeSharedEmitter;

demo/app.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import * as React from 'react';
2-
import {SharedStatesProvider, useSharedFunction, useSharedState} from 'react-shared-states';
2+
import {
3+
SharedStatesProvider,
4+
sharedSubscriptionsApi,
5+
useSharedFunction,
6+
useSharedState,
7+
useSharedSubscription
8+
} from 'react-shared-states';
9+
import './FakeSharedEmitter';
10+
import {FakeSharedEmitter} from "./FakeSharedEmitter";
11+
12+
FakeSharedEmitter.intervalDuration = 3000;
13+
window.sharedSubscriptionsApi = sharedSubscriptionsApi;
314

415
const Comp1 = () => {
516
const [x, setX] = useSharedState('x', 0);
@@ -26,6 +37,28 @@ const Comp1 = () => {
2637
);
2738
}
2839

40+
const Comp2 = () => {
41+
const {trigger} = useSharedSubscription('test-sub', (set, onError) => {
42+
return FakeSharedEmitter.subscribe("x", (data: string) => {
43+
if (data === "do-error") {
44+
onError(new Error("Error"));
45+
return;
46+
}
47+
set(data);
48+
console.log("data loaded...", data);
49+
}, onError)
50+
});
51+
52+
53+
return (
54+
<div>
55+
<h1 className="text-red-600">Comp2</h1>
56+
<button onClick={() => trigger()}>subscribe</button>
57+
<br/>
58+
</div>
59+
)
60+
}
61+
2962
const App = () => {
3063

3164
const [x, setX] = useSharedState('x', 0);
@@ -34,7 +67,7 @@ const App = () => {
3467
setX(x+1)
3568
}
3669
const handle2 = () => {
37-
setX2(x2+1);
70+
setX2(x-1);
3871
}
3972

4073
return (
@@ -52,6 +85,7 @@ const App = () => {
5285
<Comp1/>
5386
</SharedStatesProvider>
5487
</SharedStatesProvider>
88+
{x > 3 && <Comp2/>}
5589
</div>
5690
);
5791
};

demo/env.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {FakeSharedEmitter} from "./FakeSharedEmitter";
2+
3+
declare global {
4+
interface Window {
5+
FakeSharedEmitter: typeof FakeSharedEmitter
6+
}
7+
}

src/hooks/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ export {
55
export {
66
useSharedFunction,
77
sharedFunctionsApi
8-
} from "./use-shared-function";
8+
} from "./use-shared-function";
9+
export {
10+
useSharedSubscription,
11+
sharedSubscriptionsApi
12+
} from "./use-shared-subscription";
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import type {AFunction, NonEmptyString, PotentialPromise, Prefix} from "../types";
2+
import {useSyncExternalStore} from "react";
3+
import {type SharedApi, SharedData} from "../SharedData";
4+
import useShared from "./use-shared";
5+
import {log} from "../lib/utils";
6+
7+
type Unsubscribe = () => void;
8+
namespace SubscriberEvents{
9+
export type OnError = (error: unknown) => void;
10+
export type Set<T> = (value: T) => void
11+
}
12+
13+
type Subscriber<T> = (set: SubscriberEvents.Set<T>, onError: SubscriberEvents.OnError) => PotentialPromise<Unsubscribe | void | undefined>;
14+
15+
type SharedSubscriptionsState<T> = {
16+
fnState: {
17+
data?: T;
18+
isLoading: boolean;
19+
error?: unknown;
20+
track: number
21+
},
22+
unsubscribe?: Unsubscribe | void;
23+
}
24+
25+
class SharedSubscriptionsData extends SharedData<SharedSubscriptionsState<unknown>> {
26+
defaultValue() {
27+
return {
28+
fnState: {
29+
data: undefined,
30+
isLoading: false,
31+
error: undefined,
32+
track: 0,
33+
}
34+
};
35+
}
36+
37+
init(key: string, prefix: Prefix) {
38+
super.init(key, prefix, this.defaultValue());
39+
}
40+
41+
setValue<T>(key: string, prefix: Prefix, data: SharedSubscriptionsState<T>) {
42+
super.setValue(key, prefix, data);
43+
}
44+
45+
removeListener(key: string, prefix: Prefix, listener: AFunction) {
46+
super.removeListener(key, prefix, listener);
47+
/*const entry = this.get(key, prefix);
48+
if (entry?.listeners.length === 0) {
49+
entry.unsubscribe?.();
50+
entry.unsubscribe = undefined;
51+
}*/
52+
}
53+
}
54+
55+
export class SharedSubscriptionsApi implements SharedApi<SharedSubscriptionsState<unknown>>{
56+
get<T, S extends string = string>(key: NonEmptyString<S>, scopeName: Prefix = "_global") {
57+
const prefix: Prefix = scopeName || "_global";
58+
return sharedSubscriptionsData.get(key, prefix)?.fnState as T;
59+
}
60+
set<T, S extends string = string>(key: NonEmptyString<S>, fnState: SharedSubscriptionsState<T>, scopeName: Prefix = "_global") {
61+
const prefix: Prefix = scopeName || "_global";
62+
sharedSubscriptionsData.setValue(key, prefix, fnState);
63+
}
64+
clearAll() {
65+
sharedSubscriptionsData.clearAll();
66+
}
67+
clear(key: string, scopeName: Prefix = "_global") {
68+
const prefix: Prefix = scopeName || "_global";
69+
sharedSubscriptionsData.clear(key, prefix);
70+
}
71+
has(key: string, scopeName: Prefix = "_global") {
72+
const prefix: Prefix = scopeName || "_global";
73+
return Boolean(sharedSubscriptionsData.has(key, prefix));
74+
}
75+
getAll() {
76+
return sharedSubscriptionsData.data;
77+
}
78+
}
79+
80+
export const sharedSubscriptionsApi = new SharedSubscriptionsApi();
81+
82+
const sharedSubscriptionsData = new SharedSubscriptionsData();
83+
84+
export const useSharedSubscription = <T, S extends string = string>(key: NonEmptyString<S>, subscriber: Subscriber<T>, scopeName?: Prefix) => {
85+
86+
const {prefix} = useShared(scopeName);
87+
88+
sharedSubscriptionsData.init(key, prefix);
89+
90+
const state = useSyncExternalStore<NonNullable<SharedSubscriptionsState<T>['fnState']>>((listener) => {
91+
sharedSubscriptionsData.init(key, prefix);
92+
sharedSubscriptionsData.addListener(key, prefix, listener);
93+
94+
return () => {
95+
sharedSubscriptionsData.removeListener(key, prefix, listener);
96+
}
97+
}, () => sharedSubscriptionsData.get(key, prefix)!.fnState as NonNullable<SharedSubscriptionsState<T>['fnState']>);
98+
99+
const set = (value: T) => {
100+
const entry = sharedSubscriptionsData.get(key, prefix)!;
101+
entry.fnState = { ...entry.fnState, data: value, track: entry.fnState.track + 1 };
102+
entry.listeners.forEach(l => l());
103+
}
104+
105+
const onError = (error: unknown) => {
106+
const entry = sharedSubscriptionsData.get(key, prefix)!;
107+
entry.fnState = { ...entry.fnState, isLoading: false, data: undefined, error };
108+
entry.listeners.forEach(l => l());
109+
}
110+
111+
const trigger = async (force: boolean) => {
112+
const entry = sharedSubscriptionsData.get(key, prefix)!;
113+
if (force) {
114+
const unsubscribe = entry.unsubscribe;
115+
if (unsubscribe) {
116+
unsubscribe();
117+
entry.unsubscribe = undefined;
118+
}
119+
entry.fnState = { ...entry.fnState, isLoading: false, data: undefined, error: undefined };
120+
}
121+
if (entry.fnState.isLoading || entry.fnState.data !== undefined) return entry.fnState;
122+
log("triggered !!");
123+
entry.fnState = { ...entry.fnState, isLoading: true, error: undefined };
124+
entry.listeners.forEach(l => l());
125+
try {
126+
entry.unsubscribe = await subscriber(set, onError);
127+
} catch (error) {
128+
entry.fnState = { ...entry.fnState, isLoading: false, error };
129+
}
130+
entry.listeners.forEach(l => l());
131+
};
132+
133+
// noinspection JSUnusedGlobalSymbols
134+
return {
135+
state,
136+
trigger: () => {
137+
void trigger(false);
138+
},
139+
forceTrigger: () => {
140+
void trigger(true);
141+
},
142+
unsubscribe: () => {
143+
//TODO: think of something
144+
}
145+
} as const;
146+
};

src/lib/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const log = (...args: any[]) => {
2+
if (process.env.NODE_ENV !== 'development') return;
3+
console.log(
4+
'%c[react-shared-states]',
5+
'color: #007acc; font-weight: bold',
6+
...args,
7+
)
8+
}

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export type AFunction<R = unknown, Args extends unknown[] = unknown[]> = (...args: Args) => Promise<R> | R;
1+
export type PotentialPromise<T> = T | Promise<T>;
2+
3+
export type AFunction<R = unknown, Args extends unknown[] = unknown[]> = (...args: Args) => PotentialPromise<R>;
24

35
export type Prefix = "_global" | ({} & string);
46

0 commit comments

Comments
 (0)