Skip to content
This repository was archived by the owner on Mar 13, 2023. It is now read-only.
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: CI
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
branches: [ master ]
branches: [ main ]
workflow_dispatch:
jobs:
cancel_previous:
Expand Down
2 changes: 1 addition & 1 deletion example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function App() {
<Button
title="Clear"
onPress={() =>
eventStore.dispatch((state: MessageQueue) => ({
eventStore.dispatch((_: MessageQueue) => ({
messages: [],
}))
}
Expand Down
225 changes: 219 additions & 6 deletions src/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { createStore } from '../store';
import type { Persistor } from '..';
import { createStore, Store, StoreConfig } from '../store';

interface Event {
id: string;
description: string;
}

type EventStore = { events: Event[] };

describe('Sovran', () => {
it('should work', async () => {
// Create a new store
const sovran = createStore<{ events: Event[] }>({ events: [] });
const sovran = createStore<EventStore>({ events: [] });

// Setup a subscription callback
const subscription = jest.fn();
Expand Down Expand Up @@ -38,8 +41,8 @@ describe('Sovran', () => {
});

it('should work with multiple stores', async () => {
const sovran = createStore<{ events: Event[] }>({ events: [] });
const sovran2 = createStore<{ events: Event[] }>({ events: [] });
const sovran = createStore<EventStore>({ events: [] });
const sovran2 = createStore<EventStore>({ events: [] });
const subscription = jest.fn();
const subscription2 = jest.fn();
sovran.subscribe(subscription);
Expand Down Expand Up @@ -68,7 +71,7 @@ describe('Sovran', () => {
});

it('should work with multiple subscribers', async () => {
const sovran = createStore<{ events: Event[] }>({ events: [] });
const sovran = createStore<EventStore>({ events: [] });
const subscription = jest.fn();
const subscription2 = jest.fn();
sovran.subscribe(subscription);
Expand Down Expand Up @@ -96,7 +99,7 @@ describe('Sovran', () => {
});

it('should work with multiple events sent', async () => {
const sovran = createStore<{ events: Event[] }>({ events: [] });
const sovran = createStore<EventStore>({ events: [] });
const n = 100;

for (let i = 0; i < n; i++) {
Expand All @@ -116,4 +119,214 @@ describe('Sovran', () => {

expect(sovran.getState().events.length).toEqual(n);
});

it('should handle unsubscribe', async () => {
// Create a new store
const sovran = createStore<EventStore>({ events: [] });

// Setup a subscription callback
const subscription = jest.fn();
const unsubscribe = sovran.subscribe(subscription);

// Now unsubscribe
unsubscribe();

const sampleEvent: Event = {
id: '1',
description: 'test',
};

const expectedState = {
events: [sampleEvent],
};

// Dispatch an action to add a new event in our store
await sovran.dispatch((state) => {
return {
events: [...state.events, sampleEvent],
};
});

// Subscription gets called
expect(subscription).not.toHaveBeenCalled();

// And we can also access state from here
expect(sovran.getState()).toEqual(expectedState);
});

it("should not call subscribers if the state didn't change", async () => {
const sampleEvent: Event = {
id: '1',
description: 'test',
};
// Create a new store
const sovran = createStore<EventStore>({ events: [sampleEvent] });

// Setup a subscription callback
const subscription = jest.fn();
sovran.subscribe(subscription);

const expectedState = {
events: [sampleEvent],
};

// Dispatch an action that doesn't change the state
await sovran.dispatch((state) => {
return state;
});

// Subscription gets called
expect(subscription).not.toHaveBeenCalled();

// And we can also access state from here
expect(sovran.getState()).toEqual(expectedState);
});

it('should handle gracefully errors in actions', async () => {
const sampleEvent: Event = {
id: '1',
description: 'test',
};
// Create a new store
const sovran = createStore<EventStore>({ events: [sampleEvent] });

// Setup a subscription callback
const subscription = jest.fn();
sovran.subscribe(subscription);

const expectedState = {
events: [sampleEvent],
};

// Dispatch an action that doesn't change the state
await sovran.dispatch(() => {
throw new Error('Whoops!');
});

// Subscription gets called
expect(subscription).not.toHaveBeenCalled();

// And we can also access state from here
expect(sovran.getState()).toEqual(expectedState);
});

/**
* Tests the persistence calls of the Store to comply to the interface
*/
describe('Persistence', () => {
const getMockPersistor = <T>(initialState: T): Persistor => {
return {
get: jest.fn().mockResolvedValue(initialState),
set: jest.fn(),
};
};

const getAwaitableSovranConstructor = async <T>(
initialState: T,
config: StoreConfig
): Promise<Store<T>> => {
// This weird looking thing is to block the jest runner to wait until the persistor is initialized using the usual async/await instead of weird callbacks
return await new Promise((resolve) => {
const sovran: Store<T> = createStore<T>(initialState, {
...config,
persist: {
storeId: 'test',
...config.persist,
onInitialized: () => resolve(sovran),
},
});
});
};

beforeEach(() => {
// Using legacy fake timers cause the modern type have a conflict with async/await that we use everywhere here
jest.useFakeTimers('legacy');
});

it('calls the persistor on init and after the delay', async () => {
const ID = 'persistorTest';
const INTERVAL = 5000;

const persistedEvent: Event = {
id: '0',
description: 'myPersistedEvent',
};

const persistedState = { events: [persistedEvent] };

const mockPesistor: Persistor = getMockPersistor(persistedState);

const sovran = await getAwaitableSovranConstructor<EventStore>(
{ events: [] },
{
persist: {
storeId: ID,
saveDelay: INTERVAL,
persistor: mockPesistor,
},
}
);

// The persistor should have been called and the state should be updated
expect(sovran.getState()).toEqual(persistedState);
expect(mockPesistor.get).toHaveBeenCalledTimes(1);

// Now add a new event, then see if the state is persisted after a timeout

const sampleEvent: Event = {
id: '1',
description: 'test',
};

const expectedState = {
events: [persistedEvent, sampleEvent],
};

// Dispatch an action to add a new event in our store
await sovran.dispatch((state) => {
return {
events: [...state.events, sampleEvent],
};
});

// And we can also access state from here
expect(sovran.getState()).toEqual(expectedState);

jest.advanceTimersByTime(INTERVAL);

expect(mockPesistor.set).toHaveBeenCalledWith(ID, expectedState);
});

it('saves initial state if storage is empty on startup', async () => {
const ID = 'persistorTest';
const INTERVAL = 5000;

// Nothing is in the persisted state
const persistedState = undefined;
// But we have a default initial state for sovran
const sampleEvent: Event = {
id: '1',
description: 'test',
};
const initialState = { events: [sampleEvent] };

const mockPesistor: Persistor = getMockPersistor(persistedState);

const sovran = await getAwaitableSovranConstructor<EventStore>(
initialState,
{
persist: {
storeId: ID,
saveDelay: INTERVAL,
persistor: mockPesistor,
},
}
);

// The initial state should have been persisted, state should match initial
expect(sovran.getState()).toEqual(initialState);
expect(mockPesistor.get).toHaveBeenCalledTimes(1);
expect(mockPesistor.set).toHaveBeenCalledWith(ID, initialState);
});
});
});
4 changes: 4 additions & 0 deletions src/persistor/persistor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface PersistenceConfig {
* Persistor
*/
persistor?: Persistor;
/**
* Callback to be called when the store is initialized
*/
onInitialized?: (state: any) => void;
}

export interface Persistor {
Expand Down
30 changes: 20 additions & 10 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface Store<T extends {}> {
* @param {T | Promise<T>} action - action to dispatch
* @returns {T} new state
*/
dispatch: (action: Action<T>) => void;
dispatch: (action: Action<T>) => Promise<T>;
/**
* Get the current state of the store
* @returns {T} current state
Expand Down Expand Up @@ -94,7 +94,7 @@ export const createStore = <T extends {}>(
config?: StoreConfig
): Store<T> => {
let state = initialState;
let queue: Action<T>[] = [];
let queue: { call: Action<T>; finally: (newState: T) => void }[] = [];
let isPersisted = config?.persist !== undefined;
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
let persistor: Persistor =
Expand All @@ -104,17 +104,20 @@ export const createStore = <T extends {}>(
: DEFAULT_STORE_NAME;

if (isPersisted) {
persistor.get<T>(storeId).then((persistedState) => {
persistor.get<T>(storeId).then(async (persistedState) => {
if (
persistedState !== undefined &&
persistedState !== null &&
typeof persistedState === 'object'
) {
dispatch((oldState) => {
const restoredState = await dispatch((oldState) => {
return merge(oldState, persistedState);
});
config?.persist?.onInitialized?.(restoredState);
} else {
persistor.set(storeId, getState());
const stateToSave = getState();
persistor.set(storeId, stateToSave);
config?.persist?.onInitialized?.(stateToSave);
}
});
}
Expand All @@ -138,18 +141,23 @@ export const createStore = <T extends {}>(

const getState = () => ({ ...state });

const dispatch = async (action: Action<T>) => {
queue.push(action);
queueObserve.notify(queue);
const dispatch = async (action: Action<T>): Promise<T> => {
return new Promise<T>((resolve) => {
queue.push({
call: action,
finally: resolve,
});
queueObserve.notify(queue);
});
};

const processQueue = async () => {
const processQueue = async (): Promise<T> => {
queueObserve.unsubscribe(processQueue);
while (queue.length > 0) {
const action = queue.shift();
try {
if (action !== undefined) {
const newState = await action(state);
const newState = await action.call(state);
if (newState !== state) {
state = newState;
// TODO: Debounce notifications
Expand All @@ -161,6 +169,8 @@ export const createStore = <T extends {}>(
}
} catch {
console.warn('Promise not handled correctly');
} finally {
action?.finally(state);
}
}
queueObserve.subscribe(processQueue);
Expand Down