@pixotope/react-context-store 0.0.1

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.
@@ -0,0 +1,238 @@
1
+ import {
2
+ act,
3
+ renderHook,
4
+ RenderHookOptions,
5
+ RenderHookResult,
6
+ } from "@testing-library/react";
7
+ import React from "react";
8
+ import { vi, describe, test, beforeEach, it, expect } from "vitest";
9
+
10
+ import { createContextStore } from "./store";
11
+
12
+ const initialState = {
13
+ count: 0,
14
+ user: {
15
+ name: "John",
16
+ age: 20,
17
+ },
18
+ hobbies: ["football", "basketball"],
19
+ };
20
+
21
+ type HookFunction<T> = () => T;
22
+
23
+ function createDefaultStore() {
24
+ return createContextStore(initialState);
25
+ }
26
+
27
+ type ContextRes = ReturnType<typeof createDefaultStore>;
28
+
29
+ const renderHookWithProvider = <T,>(
30
+ hook: HookFunction<T>,
31
+ Provider: ContextRes["Provider"],
32
+ options?: RenderHookOptions<T>
33
+ ): RenderHookResult<T, T> => {
34
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
35
+ <Provider>{children}</Provider>
36
+ );
37
+
38
+ return renderHook(hook, { ...options, wrapper });
39
+ };
40
+
41
+ describe("createContextStore", () => {
42
+ let store: ReturnType<typeof createDefaultStore>;
43
+
44
+ beforeEach(() => {
45
+ store = createContextStore(initialState);
46
+ });
47
+
48
+ it.concurrent("should return the initial state", async () => {
49
+ const { result } = renderHookWithProvider(
50
+ () => store.useStore(),
51
+ store.Provider
52
+ );
53
+
54
+ expect(result.current.get()).toEqual(initialState);
55
+ });
56
+ it.concurrent("should return the updated state", async () => {
57
+ const { result } = renderHookWithProvider(
58
+ () => store.useStore(),
59
+ store.Provider
60
+ );
61
+ act(() => {
62
+ result.current.set((prev) => ({ ...prev, count: 1 }));
63
+ });
64
+
65
+ expect(result.current.get().count).toEqual(1);
66
+ });
67
+ it.concurrent(
68
+ "should return the reference to the same object if not changed",
69
+ () => {
70
+ const { result } = renderHookWithProvider(
71
+ () => store.useStore(),
72
+ store.Provider
73
+ );
74
+ const prevUser = result.current.get().user;
75
+ act(() => {
76
+ result.current.set((prev) => ({ ...prev, count: 1 }));
77
+ });
78
+ const userAfterCountChange = result.current.get().user;
79
+
80
+ expect(prevUser).toBe(userAfterCountChange);
81
+
82
+ act(() => {
83
+ result.current.set((prev) => ({
84
+ ...prev,
85
+ hobbies: ["gardening"],
86
+ }));
87
+ });
88
+
89
+ expect(prevUser).toBe(userAfterCountChange);
90
+ }
91
+ );
92
+ it.concurrent("should call compare function passed to useStore", () => {
93
+ const compare = vi.fn(() => true);
94
+ const { result } = renderHookWithProvider(
95
+ () => store.useStore((store) => store, compare),
96
+ store.Provider
97
+ );
98
+ act(() => {
99
+ result.current.set((prev) => ({
100
+ ...prev,
101
+ user: {
102
+ name: "Micheal",
103
+ age: 30,
104
+ },
105
+ }));
106
+ });
107
+
108
+ expect(compare).toBeCalled();
109
+ });
110
+ it.concurrent("should cause re-render if compare returns false", () => {
111
+ const compare = vi.fn(() => false);
112
+ const { result } = renderHookWithProvider(
113
+ () => store.useStore((store) => store, compare),
114
+ store.Provider
115
+ );
116
+ act(() => {
117
+ result.current.set((prev) => ({
118
+ ...prev,
119
+ user: {
120
+ name: "Micheal",
121
+ age: 30,
122
+ },
123
+ }));
124
+ });
125
+
126
+ expect(compare).toBeCalled();
127
+ expect(result.current.get().user.name).toEqual("Micheal");
128
+ });
129
+ it.concurrent("should not cause re-render if compare returns true", () => {
130
+ const compare = vi.fn(() => true);
131
+ const { result } = renderHookWithProvider(
132
+ () => store.useStore((store) => store, compare),
133
+ store.Provider
134
+ );
135
+ const prevUser = result.current.get().user;
136
+ act(() => {
137
+ result.current.set((prev) => ({
138
+ ...prev,
139
+ user: {
140
+ name: "Micheal",
141
+ age: 30,
142
+ },
143
+ }));
144
+ });
145
+
146
+ expect(result.current.get().user).toEqual(prevUser);
147
+ });
148
+ test.concurrent("selector is called with the current state", () => {
149
+ const selector = vi.fn((state) => state.user);
150
+ renderHookWithProvider(() => store.useStore(selector), store.Provider);
151
+ expect(selector).toBeCalledWith(initialState);
152
+ });
153
+ test.concurrent(
154
+ "selector is called with the current state after update",
155
+ () => {
156
+ const selector = vi.fn((state) => state.user);
157
+ const { result } = renderHookWithProvider(
158
+ () => store.useStore(selector),
159
+ store.Provider
160
+ );
161
+ act(() => {
162
+ result.current.set((prev) => ({
163
+ ...prev,
164
+ user: {
165
+ name: "Micheal",
166
+ age: 30,
167
+ },
168
+ }));
169
+ });
170
+ expect(selector).toBeCalledWith({
171
+ ...initialState,
172
+ user: {
173
+ name: "Micheal",
174
+ age: 30,
175
+ },
176
+ });
177
+ }
178
+ );
179
+ test.concurrent("selector passed to userStore returns correct value", () => {
180
+ const selector = vi.fn((state: typeof initialState) => state.user);
181
+ const { result } = renderHookWithProvider(
182
+ () => store.useStore(selector),
183
+ store.Provider
184
+ );
185
+ expect(result.current.get()).toEqual(initialState.user);
186
+ });
187
+ test.concurrent(
188
+ "hook returns a selctor function that returns the correct value",
189
+ () => {
190
+ const { result } = renderHookWithProvider(
191
+ () => store.useStore((state) => state.user),
192
+ store.Provider
193
+ );
194
+ expect(result.current.selector()).toEqual(initialState);
195
+ }
196
+ );
197
+ test.concurrent(
198
+ "hook returns a selctor function that returns the correct value after update",
199
+ () => {
200
+ const { result } = renderHookWithProvider(
201
+ () => store.useStore((state) => state.user),
202
+ store.Provider
203
+ );
204
+ act(() => {
205
+ result.current.set((prev) => ({
206
+ ...prev,
207
+ user: {
208
+ name: "Micheal",
209
+ age: 30,
210
+ },
211
+ }));
212
+ });
213
+ expect(result.current.selector()).toEqual({
214
+ ...initialState,
215
+ user: {
216
+ name: "Micheal",
217
+ age: 30,
218
+ },
219
+ });
220
+ }
221
+ );
222
+ it.concurrent("should only cause re-render if selected value changes", () => {
223
+ const selector = vi.fn((state: typeof initialState) => state.user);
224
+ const { result } = renderHookWithProvider(
225
+ () => store.useStore(selector),
226
+ store.Provider
227
+ );
228
+ const prevUser = result.current.get();
229
+ act(() => {
230
+ result.current.set((prev) => ({
231
+ ...prev,
232
+ count: 1,
233
+ }));
234
+ });
235
+
236
+ expect(result.current.get()).toBe(prevUser);
237
+ });
238
+ });
package/src/store.tsx ADDED
@@ -0,0 +1,327 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ } from "react";
8
+ import { useSyncExternalStore } from "use-sync-external-store/shim";
9
+ import { shallowEqual } from "@pixotope/utils/comparison";
10
+
11
+ const LIB_NAME = "@pixotope/react-context-store";
12
+
13
+ export type SelectorOptions<Selected> = (
14
+ first: Selected,
15
+ second: Selected
16
+ ) => boolean;
17
+
18
+ export type ContextOptions = {
19
+ /**
20
+ * If true, the store state will be preserved across mounts and un-mounts
21
+ * of the Provider.
22
+ * @default false
23
+ */
24
+ global?: boolean;
25
+ /**
26
+ * A function that will be used to compare the selected state.
27
+ * @default shallowEqual
28
+ */
29
+ compare?: SelectorOptions<unknown>;
30
+ };
31
+
32
+ type SetterArgs<Store> = Store | ((prev: Store) => Store);
33
+ type ExtractActionKeys<T> = {
34
+ [K in keyof T]: T[K] extends (
35
+ stateProps: never,
36
+ action: infer A
37
+ ) => void | Promise<void>
38
+ ? A extends ActionablePayload<infer Payload>
39
+ ? Payload extends undefined
40
+ ? () => void
41
+ : (payload: Payload) => void
42
+ : () => void
43
+ : never;
44
+ };
45
+
46
+ type Prettify<T> = {
47
+ [K in keyof T]: T[K];
48
+ } & {};
49
+
50
+ export type ActionablePayload<Payload = any> = {
51
+ payload: Payload;
52
+ };
53
+
54
+ type StateActionProps<Store> = {
55
+ set: (value: SetterArgs<Store>) => void;
56
+ get: () => Store;
57
+ };
58
+
59
+ type Actions<Store> = {
60
+ [key: string]: (
61
+ stateProps: StateActionProps<Store>,
62
+ action: ActionablePayload
63
+ ) => void | Promise<void>;
64
+ };
65
+
66
+ type ContextReturnType<Store, A extends Actions<Store>> = {
67
+ Provider: React.FC<{ children: React.ReactNode }>;
68
+ useStore: <SelectorOutput = Store>(
69
+ selector?: (store: Store) => SelectorOutput,
70
+ options?: SelectorOptions<SelectorOutput>
71
+ ) => {
72
+ get: () => SelectorOutput;
73
+ set: (value: SetterArgs<Store>) => void;
74
+ selector: () => Store;
75
+ };
76
+ useActions: () => Prettify<ExtractActionKeys<A>>;
77
+ useSetStore: () => (value: SetterArgs<Store>) => void;
78
+ subscribe: <SelectorOutput = Store>(
79
+ selector: (store: Store) => SelectorOutput,
80
+ callback: (state: SelectorOutput) => void,
81
+ options?: SelectorOptions<SelectorOutput>
82
+ ) => () => void;
83
+ unsubscribe: (callback: () => void) => void;
84
+ };
85
+
86
+ function isFunction(value: any): value is (prev: any) => any {
87
+ return typeof value === "function";
88
+ }
89
+
90
+ export function createContextStore<Store, A extends Actions<Store>>(
91
+ ...args: Extract<A, { payload: A }> extends { payload: infer Payload }
92
+ ? [initialState: Store, actions?: A, options?: ContextOptions]
93
+ : [initialState: Store, options?: ContextOptions]
94
+ ): ContextReturnType<Store, A>;
95
+ export function createContextStore<Store, A extends Actions<Store>>(
96
+ initialState: Store,
97
+ actions: A = {} as A,
98
+ options: ContextOptions = {}
99
+ ): ContextReturnType<Store, A> {
100
+ let globalStore: Store | undefined = options.global
101
+ ? initialState
102
+ : undefined;
103
+
104
+ function useStoreData({
105
+ defaultSubscriber = [],
106
+ }: {
107
+ defaultSubscriber?: ((state: Store) => void)[];
108
+ } = {}): {
109
+ get: () => Store;
110
+ set: (value: SetterArgs<Store>) => void;
111
+ subscribe: (callback: (state: Store) => void) => () => void;
112
+ } {
113
+ const store = useRef<Store>(globalStore ?? initialState);
114
+
115
+ const get = useCallback(() => store.current, []);
116
+
117
+ const subscribers = useRef(
118
+ new Set<(state: Store) => void>(defaultSubscriber)
119
+ );
120
+
121
+ const set = useCallback((value: SetterArgs<Store>) => {
122
+ store.current = isFunction(value) ? value(store.current) : value;
123
+
124
+ if (options.global) {
125
+ globalStore = store.current;
126
+ }
127
+
128
+ subscribers.current.forEach((callback) => callback(store.current));
129
+ }, []);
130
+
131
+ const subscribe = useCallback((callback: (state: Store) => void) => {
132
+ subscribers.current.add(callback);
133
+
134
+ return () => subscribers.current.delete(callback);
135
+ }, []);
136
+
137
+ return {
138
+ get,
139
+ set,
140
+ subscribe,
141
+ };
142
+ }
143
+
144
+ type UseStoreDataReturnType = ReturnType<typeof useStoreData>;
145
+
146
+ const StoreContext = createContext<UseStoreDataReturnType | null>(null);
147
+
148
+ /**
149
+ * This is the provider that will be used to wrap the react component tree
150
+ * to provide the store to all the components in the tree.
151
+ */
152
+ function Provider({ children }: { children: React.ReactNode }) {
153
+ return (
154
+ <StoreContext.Provider
155
+ value={useStoreData({ defaultSubscriber: [observable.broadcast] })}
156
+ >
157
+ {children}
158
+ </StoreContext.Provider>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * This holds all the observables that are subscribed to the store
164
+ * but not being actively used by any component. Helps to broadcast
165
+ * store updates to all the observables that are interested in reacting
166
+ * to state outside the react component tree.
167
+ */
168
+ const observers = new Set<(state: Store) => void>();
169
+
170
+ function subscribeExternal<SelectorOutput = Store>(
171
+ selector: (store: Store) => SelectorOutput = (store) =>
172
+ store as unknown as SelectorOutput,
173
+ callback: (state: SelectorOutput) => void,
174
+ compare: SelectorOptions<SelectorOutput> = (first, second) =>
175
+ first === second
176
+ ): () => void {
177
+ let lastSelectedState: SelectorOutput | undefined;
178
+
179
+ const shouldSendUpdates = (newSelectedState: SelectorOutput) => {
180
+ if (
181
+ lastSelectedState === undefined ||
182
+ !compare(lastSelectedState, newSelectedState)
183
+ ) {
184
+ lastSelectedState = newSelectedState;
185
+
186
+ return true;
187
+ }
188
+
189
+ return false;
190
+ };
191
+
192
+ const selectedCb = (store: Store) => {
193
+ const selected = selector(store);
194
+ const shouldSend = shouldSendUpdates(selected);
195
+
196
+ if (shouldSend) {
197
+ callback(selector(store));
198
+ }
199
+ };
200
+ observers.add(selectedCb);
201
+
202
+ return () => observers.delete(selectedCb);
203
+ }
204
+
205
+ /**
206
+ * A simple observable that proxy store updates to all the subscribers
207
+ * that are interested in reacting to state outside the react component tree.
208
+ */
209
+ const observable = {
210
+ subscribe: subscribeExternal,
211
+ unsubscribe: (callback: (state: Store) => void) => {
212
+ observers.delete(callback);
213
+ },
214
+ broadcast: (state: Store) => {
215
+ observers.forEach((callback) => callback(state));
216
+ },
217
+ } as const;
218
+
219
+ /**
220
+ * This is the hook that will be used to access the store from any component
221
+ * in the react component tree.
222
+ * @param selector A function that will be used to select the part of the store
223
+ * that is needed by the component.
224
+ * @param options Options to customize the behavior of the hook.
225
+ * @returns An object with the selected state, a function to update the store
226
+ * and a function to get the entire store.
227
+ * @example
228
+ * ```tsx
229
+ * const { get, set } = useStore(store => store.user);
230
+ * const { get, set } = useStore(store => store.user, { deepEqual: false });
231
+ * const { get, set } = useStore(store => store.user, {
232
+ * compare: (first, second) => first.id === second.id
233
+ * });
234
+ * ```
235
+ */
236
+ function useStore<SelectorOutput = Store>(
237
+ selector: (store: Store) => SelectorOutput = (store) =>
238
+ store as unknown as SelectorOutput,
239
+ compare: SelectorOptions<SelectorOutput> = options.compare ?? shallowEqual
240
+ ): {
241
+ get: () => SelectorOutput;
242
+ set: (value: SetterArgs<Store>) => void;
243
+ selector: () => Store;
244
+ } {
245
+ const store = useContext(StoreContext);
246
+ const lastSelectedState = useRef<SelectorOutput | undefined>(undefined);
247
+
248
+ if (!store) {
249
+ throw new Error(
250
+ `[${LIB_NAME}] Store not found. Make sure the component is wrapped in a ${Provider} component.`
251
+ );
252
+ }
253
+
254
+ const state = useSyncExternalStore(
255
+ store.subscribe,
256
+ () => {
257
+ const selectedState = selector(store.get());
258
+
259
+ if (
260
+ lastSelectedState.current === undefined ||
261
+ !compare(lastSelectedState.current, selectedState)
262
+ ) {
263
+ lastSelectedState.current = selectedState;
264
+ }
265
+
266
+ return lastSelectedState.current;
267
+ },
268
+ () => selector(initialState)
269
+ );
270
+
271
+ return {
272
+ get: () => state,
273
+ set: store.set,
274
+ selector: () => store.get(),
275
+ };
276
+ }
277
+
278
+ function useActions() {
279
+ const store = useContext(StoreContext);
280
+
281
+ if (!store) {
282
+ throw new Error(
283
+ `[${LIB_NAME}] Store not found. Make sure the component is wrapped in a ${Provider} component.`
284
+ );
285
+ }
286
+
287
+ const actionProxy = useMemo(
288
+ () =>
289
+ new Proxy(actions, {
290
+ get: (target, prop) => {
291
+ const action = target[prop as string];
292
+
293
+ if (action) {
294
+ return (args: Parameters<typeof action>["1"]) => {
295
+ action(
296
+ {
297
+ set: store.set,
298
+ get: store.get,
299
+ },
300
+ {
301
+ payload: args,
302
+ }
303
+ );
304
+ };
305
+ }
306
+ },
307
+ set: () => {
308
+ throw new Error(`[${LIB_NAME}] Actions cannot be updated`);
309
+ },
310
+ }),
311
+ []
312
+ );
313
+
314
+ return actionProxy as unknown as ExtractActionKeys<typeof actions>;
315
+ }
316
+
317
+ const useSetStore = () => useStore(() => false).set;
318
+
319
+ return {
320
+ Provider,
321
+ useStore,
322
+ useSetStore,
323
+ useActions,
324
+ subscribe: observable.subscribe,
325
+ unsubscribe: observable.unsubscribe,
326
+ };
327
+ }
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom";
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": "./src",
4
+ "rootDir": "./src",
5
+ "target": "es6",
6
+ "module": "es6",
7
+ "lib": ["es6", "dom", "dom.iterable"],
8
+ "moduleResolution": "bundler",
9
+ "allowJs": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "useDefineForClassFields": true,
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "esModuleInterop": true,
18
+ "noEmit": true,
19
+ "noUnusedLocals": false,
20
+ "noUnusedParameters": false,
21
+ "noImplicitReturns": false,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "jsx": "react",
24
+ "types": ["vitest/globals"]
25
+ },
26
+ "include": ["src"],
27
+ "exclude": ["node_modules"]
28
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "jsdom",
7
+ setupFiles: "./src/vitest-setup.ts",
8
+ testTimeout: 30_000,
9
+ hookTimeout: 30_000,
10
+ css: true,
11
+ include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
12
+ server: {
13
+ deps: {
14
+ inline: [],
15
+ },
16
+ },
17
+ },
18
+ resolve: {
19
+ alias: {},
20
+ },
21
+ });