Skip to content

Commit

Permalink
Feat/react store context (#50)
Browse files Browse the repository at this point in the history
* store context

* fix
  • Loading branch information
kepta authored Oct 9, 2023
1 parent de8814a commit 87c0bd7
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 41 deletions.
3 changes: 2 additions & 1 deletion documentation/pages/docs/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"guide": "Guide",
"react": "React",
"advanced-topics": "Advanced topics",
"api": "API"
"api": "API",
"react-api": "API (React)"
}
11 changes: 11 additions & 0 deletions documentation/pages/docs/api/_meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"derived-field": "DerivedField",
"effect": "Effect",
"key": "Key",
"ref": "Ref",
"slice": "Slice",
"state-field": "StateField",
"store-state": "StoreState",
"store": "Store",
"transaction": "Transaction"
}
2 changes: 2 additions & 0 deletions documentation/pages/docs/api/store.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

### `.createStore`
8 changes: 8 additions & 0 deletions documentation/pages/docs/react-api.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# React API


### `createContextStore`

The same as [createStore](/docs/api/store/#createstore) with the difference that it accepts the following additional properties:

- `context`: The react context object created using `React.createContext()`
11 changes: 11 additions & 0 deletions documentation/pages/docs/react/common-errors.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Common errors


## Store Context

#### `Cannot create a context store with a slice that is already associated with another store`

This error occurs if you have multiple stores in your app and you are using the `createContextStore` method that uses React Context to share NSM store behind the scenes.

- Solution1: If you want to use *multiple stores* then you will have to use the [createStore](/docs/api/store/#createstore) function to create store and share it on by your self to all components that need it.
- Solution2: Use a single store for your entire app.
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type { BaseField } from './slice/field';
export type { Effect, EffectStore, EffectScheduler } from './effect/effect';
export type { IfSubset } from './types';
export type { Key } from './slice/key';
export type { Store } from './store';
export type { Store, StoreOptions } from './store';
export type { StoreState } from './store-state';

// for internal packages only
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { Slice } from './slice/slice';
import type { SliceId } from './types';
import { Transaction } from './transaction';

interface StoreOptions<TSliceName extends string> {
export interface StoreOptions<TSliceName extends string> {
name?: string;
slices: Slice<any, TSliceName, any>[];
debug?: DebugLogger;
Expand All @@ -24,7 +24,7 @@ interface StoreOptions<TSliceName extends string> {
* Overrides all effects schedulers for all effects in the store.
*/
effectScheduler?: EffectScheduler;
dispatchTransactionOverride?: DispatchTransaction<TSliceName>;
dispatchTransaction?: DispatchTransaction<TSliceName>;
};
manualEffectsTrigger?: boolean;
}
Expand Down Expand Up @@ -67,6 +67,12 @@ export class Store<TSliceName extends string = any> extends BaseStore {
// @internal
private _dispatchTxn: DispatchTransaction<TSliceName>;

private destroyController = new AbortController();

public get destroySignal() {
return this.destroyController.signal;
}

get state() {
return this._state;
}
Expand All @@ -77,6 +83,7 @@ export class Store<TSliceName extends string = any> extends BaseStore {
}

this.destroyed = true;
this.destroyController.abort();
this.effectsManager.destroy();
}

Expand All @@ -93,8 +100,7 @@ export class Store<TSliceName extends string = any> extends BaseStore {
this.initialState = this._state;

this._dispatchTxn =
options.overrides?.dispatchTransactionOverride ||
DEFAULT_DISPATCH_TRANSACTION;
options.overrides?.dispatchTransaction || DEFAULT_DISPATCH_TRANSACTION;

this.effectsManager = new EffectManager(this.options.slices, {
debug: this.options.debug,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ export type InferStateSliceName<T extends StoreState<any>> =
export type InferEffectStoreSliceName<T extends EffectStore<any>> =
T extends EffectStore<infer Name> ? Name : never;

// eslint-disable-next-line @typescript-eslint/ban-types
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
34 changes: 15 additions & 19 deletions packages/react/src/__tests__/store.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
/**
* @jest-environment jsdom
*/
import React, { useState } from 'react';
import {
expect,
jest,
test,
describe,
beforeEach,
afterEach,
} from '@jest/globals';
import React, { useEffect } from 'react';
import { expect, test, describe } from '@jest/globals';
import { act, render, screen, waitFor } from '@testing-library/react';
import {
Store,
StoreState,
createKey,
createStore,
EffectScheduler,
} from '@nalanda/core';
import { Store, createKey, EffectScheduler } from '@nalanda/core';
import { createContext, useContext, useRef } from 'react';
import { useTrack, useTrackField } from '../react';
import { createContextStore } from '../store';

const zeroTimeoutScheduler: EffectScheduler = (cb, opts) => {
setTimeout(() => {
Expand Down Expand Up @@ -52,13 +40,21 @@ const setup = () => {

const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<Store<any>>();

useEffect(() => {
return () => {
storeRef.current?.destroy();
};
}, []);

if (!storeRef.current) {
const store = createStore({
const store = createContextStore({
slices: [counterSlice],
name: 'test-store',
overrides: {
effectScheduler: zeroTimeoutScheduler,
},
context: StoreContext,
});
storeRef.current = store;
}
Expand Down Expand Up @@ -149,7 +145,7 @@ describe('useTrack', () => {
function MyComponent() {
const store = useStore();
_store = store;
const { counterNegative } = useTrack(counterSlice, store);
const { counterNegative } = useTrack(counterSlice);
renderCount++;

return <div>counterNegative={counterNegative}</div>;
Expand Down Expand Up @@ -204,7 +200,7 @@ describe('useTrackField', () => {
const store = useStore();
_store = store;

const counter = useTrackField(counterSlice, 'counter', store);
const counter = useTrackField(counterSlice, 'counter');

return <div>counter={counter}</div>;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/core-exports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export { cleanup } from '@nalanda/core';
export { createKey } from '@nalanda/core';
// NO STORE EXPORTS, as we want to use the React-specific store
// export { createStore, _storeSlicesInspect } from '@nalanda/core';
export { DerivedField, StateField } from '@nalanda/core';
export { ref } from '@nalanda/core';
export { Slice } from '@nalanda/core';

export type { BaseField } from '@nalanda/core';
export type { Effect, EffectStore, EffectScheduler } from '@nalanda/core';
export type { IfSubset } from '@nalanda/core';
export type { Key } from '@nalanda/core';
export type { Store } from '@nalanda/core';
export type { StoreState } from '@nalanda/core';
4 changes: 3 additions & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from '@nalanda/core';
export * from './core-exports';
export * from './react';
export { createContextStore } from './store';
export type { ContextStoreOptions } from './store';
60 changes: 45 additions & 15 deletions packages/react/src/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,63 @@ import {
_InferSliceFieldState,
_ExposedSliceFieldNames,
} from '@nalanda/core';
import { useCallback, useRef } from 'react';
import React, { useCallback, useContext, useRef } from 'react';
import useSyncExternalStoreExports from 'use-sync-external-store/shim';
import { StoreContextSymbol } from './store';

const { useSyncExternalStore } = useSyncExternalStoreExports;

// eslint-disable-next-line @typescript-eslint/ban-types
type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};

const dummyContext = React.createContext(null);

function useStoreFromContext(slice: Slice, store?: Store<any>): Store<any> {
const ctxStore = useContext(
(slice as any)[StoreContextSymbol] || dummyContext,
);

// the passed store takes precedence over the context store
const result = store || ctxStore;

if (!result) {
throw new Error(
`Could not find a store for slice ${slice.name}. Please use 'createContextStore()' for creating the store or pass store directly to the hook.`,
);
}

return result as any;
}

export function useTrack<
TExternal extends _AnyExternal = any,
TName extends string = any,
TDep extends string = any,
>(
slice: Slice<TExternal, TName, TDep>,
store: Store<any>,
store?: Store<any>,
): Simplify<_InferSliceFieldState<TExternal>> {
const ref = useRef<any>();

const _store = useStoreFromContext(slice, store);

const subscribe = useCallback(
(onStoreChange: () => void) => {
const sliceEffect = store.effect((effectStore) => {
const sliceEffect = _store.effect((effectStore) => {
ref.current = slice.track(effectStore);
onStoreChange();
});

return () => {
store.unregisterEffect(sliceEffect);
_store.unregisterEffect(sliceEffect);
};
},
[store, slice],
[_store, slice],
);

const getSnapshot = useCallback(() => {
return ref.current ?? slice.get(store.state);
}, [store, slice]);
return ref.current || slice.get(_store.state);
}, [_store, slice]);

return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
Expand All @@ -51,27 +74,34 @@ export function useTrackField<
>(
slice: Slice<TExternal, TName, TDep>,
fieldName: TFieldName,
store: Store<any>,
store?: Store<any>,
): _InferSliceFieldState<TExternal>[TFieldName] {
const ref = useRef<any>();
const ref = useRef<{ value: any } | null>(null);

const _store = useStoreFromContext(slice, store);

const subscribe = useCallback(
(onStoreChange: () => void) => {
const sliceEffect = store.effect((effectStore) => {
ref.current = slice.trackField(effectStore, fieldName);
const sliceEffect = _store.effect((effectStore) => {
if (!ref.current) {
ref.current = { value: undefined };
}
ref.current.value = slice.trackField(effectStore, fieldName);
onStoreChange();
});

return () => {
store.unregisterEffect(sliceEffect);
_store.unregisterEffect(sliceEffect);
};
},
[store, slice, fieldName],
[_store, slice, fieldName],
);

const getSnapshot = useCallback(() => {
return ref.current ?? slice.getField(store.state, fieldName);
}, [store, slice, fieldName]);
return ref.current
? ref.current.value
: slice.getField(_store.state, fieldName);
}, [_store, slice, fieldName]);

return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
45 changes: 45 additions & 0 deletions packages/react/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
createStore as createVanillaStore,
Store,
StoreOptions,
} from '@nalanda/core';

export interface ContextStoreOptions<TSliceName extends string>
extends StoreOptions<TSliceName> {
/**
* The react context object created using React.createContext()
*/
context: React.Context<Store<any> | null>;
}

export const StoreContextSymbol = Symbol('StoreContextKey');

export function createContextStore<TSliceName extends string = any>(
options: ContextStoreOptions<TSliceName>,
): Store<TSliceName> {
options.slices.forEach((slice) => {
// @ts-expect-error - this is a private symbol
if (slice[StoreContextSymbol]) {
throw new Error(
`Cannot create a context store with a slice that is already associated with another store. Please see https://nalanda.bangle.io/docs/react/common-errors/#store-context`,
);
}
// @ts-expect-error - this is a private symbol
slice[StoreContextSymbol] = options.context;
});

const store = createVanillaStore(options);

store.destroySignal.addEventListener(
'abort',
() => {
options.slices.forEach((slice) => {
// @ts-expect-error - this is a private symbol
delete slice[StoreContextSymbol];
});
},
{ once: true },
);

return store;
}

0 comments on commit 87c0bd7

Please sign in to comment.