[State Management] State containers improvements (#54436) (#54562)

Some maintenance and minor fixes to state containers based on experience while working with them in #53582

Patch unit tests to use current "terminology" (e.g. "transition" vs "mutation")
Fix docs where "store" was used instead of "state container"
Allow to create state container without transition.
Fix freeze function to deeply freeze objects.
Restrict State to BaseState with extends object.
in set() function, make sure the flow goes through dispatch to make sure middleware see this update
Improve type inference for useTransition()
Improve type inference for createStateContainer().

Other issues noticed, but didn't fix in reasonable time:
Can't use addMiddleware without explicit type casting #54438
Transitions and Selectors allow any state, not bind to container's state #54439
This commit is contained in:
Anton Dosov 2020-01-13 14:12:25 +01:00 committed by GitHub
parent 19ddf27275
commit 26b5cd89c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 305 additions and 231 deletions

View file

@ -41,6 +41,7 @@ import {
PureTransition, PureTransition,
syncStates, syncStates,
getStateFromKbnUrl, getStateFromKbnUrl,
BaseState,
} from '../../../src/plugins/kibana_utils/public'; } from '../../../src/plugins/kibana_utils/public';
import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; import { useUrlTracker } from '../../../src/plugins/kibana_react/public';
import { import {
@ -79,7 +80,7 @@ const TodoApp: React.FC<TodoAppProps> = ({ filter }) => {
const { setText } = GlobalStateHelpers.useTransitions(); const { setText } = GlobalStateHelpers.useTransitions();
const { text } = GlobalStateHelpers.useState(); const { text } = GlobalStateHelpers.useState();
const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions();
const todos = useState(); const todos = useState().todos;
const filteredTodos = todos.filter(todo => { const filteredTodos = todos.filter(todo => {
if (!filter) return true; if (!filter) return true;
if (filter === 'completed') return todo.completed; if (filter === 'completed') return todo.completed;
@ -306,7 +307,7 @@ export const TodoAppPage: React.FC<{
); );
}; };
function withDefaultState<State>( function withDefaultState<State extends BaseState>(
stateContainer: BaseStateContainer<State>, stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
defaultState: State defaultState: State
@ -314,14 +315,10 @@ function withDefaultState<State>(
return { return {
...stateContainer, ...stateContainer,
set: (state: State | null) => { set: (state: State | null) => {
if (Array.isArray(defaultState)) { stateContainer.set({
stateContainer.set(state || defaultState); ...defaultState,
} else { ...state,
stateContainer.set({ });
...defaultState,
...state,
});
}
}, },
}; };
} }

View file

@ -162,6 +162,7 @@
"custom-event-polyfill": "^0.3.0", "custom-event-polyfill": "^0.3.0",
"d3": "3.5.17", "d3": "3.5.17",
"d3-cloud": "1.2.5", "d3-cloud": "1.2.5",
"deep-freeze-strict": "^1.1.1",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"del": "^5.1.0", "del": "^5.1.0",
"elastic-apm-node": "^3.2.0", "elastic-apm-node": "^3.2.0",
@ -308,6 +309,7 @@
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",
"@types/d3": "^3.5.43", "@types/d3": "^3.5.43",
"@types/dedent": "^0.7.0", "@types/dedent": "^0.7.0",
"@types/deep-freeze-strict": "^1.1.0",
"@types/delete-empty": "^2.0.0", "@types/delete-empty": "^2.0.0",
"@types/elasticsearch": "^5.0.33", "@types/elasticsearch": "^5.0.33",
"@types/enzyme": "^3.9.0", "@types/enzyme": "^3.9.0",

View file

@ -340,6 +340,14 @@
'@types/dedent', '@types/dedent',
], ],
}, },
{
groupSlug: 'deep-freeze-strict',
groupName: 'deep-freeze-strict related packages',
packageNames: [
'deep-freeze-strict',
'@types/deep-freeze-strict',
],
},
{ {
groupSlug: 'delete-empty', groupSlug: 'delete-empty',
groupName: 'delete-empty related packages', groupName: 'delete-empty related packages',

View file

@ -38,7 +38,7 @@ describe('demos', () => {
describe('state sync', () => { describe('state sync', () => {
test('url sync demo works', async () => { test('url sync demo works', async () => {
expect(await urlSyncResult).toMatchInlineSnapshot( expect(await urlSyncResult).toMatchInlineSnapshot(
`"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` `"http://localhost/#?_s=(todos:!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test)))"`
); );
}); });
}); });

View file

@ -19,14 +19,24 @@
import { createStateContainer } from '../../public/state_containers'; import { createStateContainer } from '../../public/state_containers';
const container = createStateContainer(0, { interface State {
increment: (cnt: number) => (by: number) => cnt + by, count: number;
double: (cnt: number) => () => cnt * 2, }
});
const container = createStateContainer(
{ count: 0 },
{
increment: (state: State) => (by: number) => ({ count: state.count + by }),
double: (state: State) => () => ({ count: state.count * 2 }),
},
{
count: (state: State) => () => state.count,
}
);
container.transitions.increment(5); container.transitions.increment(5);
container.transitions.double(); container.transitions.double();
console.log(container.get()); // eslint-disable-line console.log(container.selectors.count()); // eslint-disable-line
export const result = container.get(); export const result = container.selectors.count();

View file

@ -25,15 +25,19 @@ export interface TodoItem {
id: number; id: number;
} }
export type TodoState = TodoItem[]; export interface TodoState {
todos: TodoItem[];
}
export const defaultState: TodoState = [ export const defaultState: TodoState = {
{ todos: [
id: 0, {
text: 'Learning state containers', id: 0,
completed: false, text: 'Learning state containers',
}, completed: false,
]; },
],
};
export interface TodoActions { export interface TodoActions {
add: PureTransition<TodoState, [TodoItem]>; add: PureTransition<TodoState, [TodoItem]>;
@ -44,17 +48,34 @@ export interface TodoActions {
clearCompleted: PureTransition<TodoState, []>; clearCompleted: PureTransition<TodoState, []>;
} }
export interface TodosSelectors {
todos: (state: TodoState) => () => TodoItem[];
todo: (state: TodoState) => (id: number) => TodoItem | null;
}
export const pureTransitions: TodoActions = { export const pureTransitions: TodoActions = {
add: state => todo => [...state, todo], add: state => todo => ({ todos: [...state.todos, todo] }),
edit: state => todo => state.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), edit: state => todo => ({
delete: state => id => state.filter(item => item.id !== id), todos: state.todos.map(item => (item.id === todo.id ? { ...item, ...todo } : item)),
complete: state => id => }),
state.map(item => (item.id === id ? { ...item, completed: true } : item)), delete: state => id => ({ todos: state.todos.filter(item => item.id !== id) }),
completeAll: state => () => state.map(item => ({ ...item, completed: true })), complete: state => id => ({
clearCompleted: state => () => state.filter(({ completed }) => !completed), todos: state.todos.map(item => (item.id === id ? { ...item, completed: true } : item)),
}),
completeAll: state => () => ({ todos: state.todos.map(item => ({ ...item, completed: true })) }),
clearCompleted: state => () => ({ todos: state.todos.filter(({ completed }) => !completed) }),
}; };
const container = createStateContainer<TodoState, TodoActions>(defaultState, pureTransitions); export const pureSelectors: TodosSelectors = {
todos: state => () => state.todos,
todo: state => id => state.todos.find(todo => todo.id === id) ?? null,
};
const container = createStateContainer<TodoState, TodoActions, TodosSelectors>(
defaultState,
pureTransitions,
pureSelectors
);
container.transitions.add({ container.transitions.add({
id: 1, id: 1,
@ -64,6 +85,6 @@ container.transitions.add({
container.transitions.complete(0); container.transitions.complete(0);
container.transitions.complete(1); container.transitions.complete(1);
console.log(container.get()); // eslint-disable-line console.log(container.selectors.todos()); // eslint-disable-line
export const result = container.get(); export const result = container.selectors.todos();

View file

@ -18,7 +18,7 @@
*/ */
import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc';
import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; import { BaseState, BaseStateContainer, createStateContainer } from '../../public/state_containers';
import { import {
createKbnUrlStateStorage, createKbnUrlStateStorage,
syncState, syncState,
@ -55,7 +55,7 @@ export const result = Promise.resolve()
return window.location.href; return window.location.href;
}); });
function withDefaultState<State>( function withDefaultState<State extends BaseState>(
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
stateContainer: BaseStateContainer<State>, stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow

View file

@ -18,14 +18,21 @@ your services or apps.
```ts ```ts
import { createStateContainer } from 'src/plugins/kibana_utils'; import { createStateContainer } from 'src/plugins/kibana_utils';
const container = createStateContainer(0, { const container = createStateContainer(
increment: (cnt: number) => (by: number) => cnt + by, { count: 0 },
double: (cnt: number) => () => cnt * 2, {
}); increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }),
double: (state: {count: number}) => () => ({ count: state.count * 2 }),
},
{
count: (state: {count: number}) => () => state.count,
}
);
container.transitions.increment(5); container.transitions.increment(5);
container.transitions.double(); container.transitions.double();
console.log(container.get()); // 10
console.log(container.selectors.count()); // 10
``` ```

View file

@ -32,7 +32,7 @@ Create your a state container.
```ts ```ts
import { createStateContainer } from 'src/plugins/kibana_utils'; import { createStateContainer } from 'src/plugins/kibana_utils';
const container = createStateContainer<MyState>(defaultState, {}); const container = createStateContainer<MyState>(defaultState);
console.log(container.get()); console.log(container.get());
``` ```

View file

@ -1,13 +1,13 @@
# Consuming state in non-React setting # Consuming state in non-React setting
To read the current `state` of the store use `.get()` method. To read the current `state` of the store use `.get()` method or `getState()` alias method.
```ts ```ts
store.get(); stateContainer.get();
``` ```
To listen for latest state changes use `.state$` observable. To listen for latest state changes use `.state$` observable.
```ts ```ts
store.state$.subscribe(state => { ... }); stateContainer.state$.subscribe(state => { ... });
``` ```

View file

@ -9,7 +9,7 @@
```ts ```ts
import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils'; import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils';
const container = createStateContainer({}, {}); const container = createStateContainer({});
export const { export const {
Provider, Provider,
Consumer, Consumer,

View file

@ -19,18 +19,9 @@
import { createStateContainer } from './create_state_container'; import { createStateContainer } from './create_state_container';
const create = <S, T extends object>(state: S, transitions: T = {} as T) => { test('can create state container', () => {
const pureTransitions = { const stateContainer = createStateContainer({});
set: () => (newState: S) => newState, expect(stateContainer).toMatchObject({
...transitions,
};
const store = createStateContainer<typeof state, typeof pureTransitions>(state, pureTransitions);
return { store, mutators: store.transitions };
};
test('can create store', () => {
const { store } = create({});
expect(store).toMatchObject({
getState: expect.any(Function), getState: expect.any(Function),
state$: expect.any(Object), state$: expect.any(Object),
transitions: expect.any(Object), transitions: expect.any(Object),
@ -45,9 +36,9 @@ test('can set default state', () => {
const defaultState = { const defaultState = {
foo: 'bar', foo: 'bar',
}; };
const { store } = create(defaultState); const stateContainer = createStateContainer(defaultState);
expect(store.get()).toEqual(defaultState); expect(stateContainer.get()).toEqual(defaultState);
expect(store.getState()).toEqual(defaultState); expect(stateContainer.getState()).toEqual(defaultState);
}); });
test('can set state', () => { test('can set state', () => {
@ -57,12 +48,12 @@ test('can set state', () => {
const newState = { const newState = {
foo: 'baz', foo: 'baz',
}; };
const { store, mutators } = create(defaultState); const stateContainer = createStateContainer(defaultState);
mutators.set(newState); stateContainer.set(newState);
expect(store.get()).toEqual(newState); expect(stateContainer.get()).toEqual(newState);
expect(store.getState()).toEqual(newState); expect(stateContainer.getState()).toEqual(newState);
}); });
test('does not shallow merge states', () => { test('does not shallow merge states', () => {
@ -72,22 +63,22 @@ test('does not shallow merge states', () => {
const newState = { const newState = {
foo2: 'baz', foo2: 'baz',
}; };
const { store, mutators } = create(defaultState); const stateContainer = createStateContainer(defaultState);
mutators.set(newState as any); stateContainer.set(newState as any);
expect(store.get()).toEqual(newState); expect(stateContainer.get()).toEqual(newState);
expect(store.getState()).toEqual(newState); expect(stateContainer.getState()).toEqual(newState);
}); });
test('can subscribe and unsubscribe to state changes', () => { test('can subscribe and unsubscribe to state changes', () => {
const { store, mutators } = create({}); const stateContainer = createStateContainer({});
const spy = jest.fn(); const spy = jest.fn();
const subscription = store.state$.subscribe(spy); const subscription = stateContainer.state$.subscribe(spy);
mutators.set({ a: 1 }); stateContainer.set({ a: 1 });
mutators.set({ a: 2 }); stateContainer.set({ a: 2 });
subscription.unsubscribe(); subscription.unsubscribe();
mutators.set({ a: 3 }); stateContainer.set({ a: 3 });
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[0][0]).toEqual({ a: 1 }); expect(spy.mock.calls[0][0]).toEqual({ a: 1 });
@ -95,16 +86,16 @@ test('can subscribe and unsubscribe to state changes', () => {
}); });
test('multiple subscribers can subscribe', () => { test('multiple subscribers can subscribe', () => {
const { store, mutators } = create({}); const stateContainer = createStateContainer({});
const spy1 = jest.fn(); const spy1 = jest.fn();
const spy2 = jest.fn(); const spy2 = jest.fn();
const subscription1 = store.state$.subscribe(spy1); const subscription1 = stateContainer.state$.subscribe(spy1);
const subscription2 = store.state$.subscribe(spy2); const subscription2 = stateContainer.state$.subscribe(spy2);
mutators.set({ a: 1 }); stateContainer.set({ a: 1 });
subscription1.unsubscribe(); subscription1.unsubscribe();
mutators.set({ a: 2 }); stateContainer.set({ a: 2 });
subscription2.unsubscribe(); subscription2.unsubscribe();
mutators.set({ a: 3 }); stateContainer.set({ a: 3 });
expect(spy1).toHaveBeenCalledTimes(1); expect(spy1).toHaveBeenCalledTimes(1);
expect(spy2).toHaveBeenCalledTimes(2); expect(spy2).toHaveBeenCalledTimes(2);
@ -120,19 +111,19 @@ test('can create state container without transitions', () => {
expect(stateContainer.get()).toEqual(state); expect(stateContainer.get()).toEqual(state);
}); });
test('creates impure mutators from pure mutators', () => { test('creates transitions', () => {
const { mutators } = create( const stateContainer = createStateContainer(
{}, {},
{ {
setFoo: () => (bar: any) => ({ foo: bar }), setFoo: () => (bar: any) => ({ foo: bar }),
} }
); );
expect(typeof mutators.setFoo).toBe('function'); expect(typeof stateContainer.transitions.setFoo).toBe('function');
}); });
test('mutators can update state', () => { test('transitions can update state', () => {
const { store, mutators } = create( const stateContainer = createStateContainer(
{ {
value: 0, value: 0,
foo: 'bar', foo: 'bar',
@ -143,30 +134,30 @@ test('mutators can update state', () => {
} }
); );
expect(store.get()).toEqual({ expect(stateContainer.get()).toEqual({
value: 0, value: 0,
foo: 'bar', foo: 'bar',
}); });
mutators.add(11); stateContainer.transitions.add(11);
mutators.setFoo('baz'); stateContainer.transitions.setFoo('baz');
expect(store.get()).toEqual({ expect(stateContainer.get()).toEqual({
value: 11, value: 11,
foo: 'baz', foo: 'baz',
}); });
mutators.add(-20); stateContainer.transitions.add(-20);
mutators.setFoo('bazooka'); stateContainer.transitions.setFoo('bazooka');
expect(store.get()).toEqual({ expect(stateContainer.get()).toEqual({
value: -9, value: -9,
foo: 'bazooka', foo: 'bazooka',
}); });
}); });
test('mutators methods are not bound', () => { test('transitions methods are not bound', () => {
const { store, mutators } = create( const stateContainer = createStateContainer(
{ value: -3 }, { value: -3 },
{ {
add: (state: { value: number }) => (increment: number) => ({ add: (state: { value: number }) => (increment: number) => ({
@ -176,13 +167,13 @@ test('mutators methods are not bound', () => {
} }
); );
expect(store.get()).toEqual({ value: -3 }); expect(stateContainer.get()).toEqual({ value: -3 });
mutators.add(4); stateContainer.transitions.add(4);
expect(store.get()).toEqual({ value: 1 }); expect(stateContainer.get()).toEqual({ value: 1 });
}); });
test('created mutators are saved in store object', () => { test('created transitions are saved in stateContainer object', () => {
const { store, mutators } = create( const stateContainer = createStateContainer(
{ value: -3 }, { value: -3 },
{ {
add: (state: { value: number }) => (increment: number) => ({ add: (state: { value: number }) => (increment: number) => ({
@ -192,55 +183,57 @@ test('created mutators are saved in store object', () => {
} }
); );
expect(typeof store.transitions.add).toBe('function'); expect(typeof stateContainer.transitions.add).toBe('function');
mutators.add(5); stateContainer.transitions.add(5);
expect(store.get()).toEqual({ value: 2 }); expect(stateContainer.get()).toEqual({ value: 2 });
}); });
test('throws when state is modified inline - 1', () => { test('throws when state is modified inline', () => {
const container = createStateContainer({ a: 'b' }, {}); const container = createStateContainer({ a: 'b', array: [{ a: 'b' }] });
let error: TypeError | null = null; expect(() => {
try {
(container.get().a as any) = 'c'; (container.get().a as any) = 'c';
} catch (err) { }).toThrowErrorMatchingInlineSnapshot(
error = err; `"Cannot assign to read only property 'a' of object '#<Object>'"`
} );
expect(error).toBeInstanceOf(TypeError); expect(() => {
});
test('throws when state is modified inline - 2', () => {
const container = createStateContainer({ a: 'b' }, {});
let error: TypeError | null = null;
try {
(container.getState().a as any) = 'c'; (container.getState().a as any) = 'c';
} catch (err) { }).toThrowErrorMatchingInlineSnapshot(
error = err; `"Cannot assign to read only property 'a' of object '#<Object>'"`
} );
expect(error).toBeInstanceOf(TypeError); expect(() => {
(container.getState().array as any).push('c');
}).toThrowErrorMatchingInlineSnapshot(`"Cannot add property 1, object is not extensible"`);
expect(() => {
(container.getState().array[0] as any).c = 'b';
}).toThrowErrorMatchingInlineSnapshot(`"Cannot add property c, object is not extensible"`);
expect(() => {
container.set(null as any);
expect(container.getState()).toBeNull();
}).not.toThrow();
}); });
test('throws when state is modified inline in subscription', done => { test('throws when state is modified inline in subscription', () => {
const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState }); const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState });
container.subscribe(value => { container.subscribe(value => {
let error: TypeError | null = null; expect(() => {
try {
(value.a as any) = 'd'; (value.a as any) = 'd';
} catch (err) { }).toThrowErrorMatchingInlineSnapshot(
error = err; `"Cannot assign to read only property 'a' of object '#<Object>'"`
} );
expect(error).toBeInstanceOf(TypeError);
done();
}); });
container.transitions.set({ a: 'c' }); container.transitions.set({ a: 'c' });
}); });
describe('selectors', () => { describe('selectors', () => {
test('can specify no selectors, or can skip them', () => { test('can specify no selectors, or can skip them', () => {
createStateContainer({});
createStateContainer({}, {}); createStateContainer({}, {});
createStateContainer({}, {}, {}); createStateContainer({}, {}, {});
}); });

View file

@ -20,34 +20,52 @@
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { skip } from 'rxjs/operators'; import { skip } from 'rxjs/operators';
import { RecursiveReadonly } from '@kbn/utility-types'; import { RecursiveReadonly } from '@kbn/utility-types';
import deepFreeze from 'deep-freeze-strict';
import { import {
PureTransitionsToTransitions, PureTransitionsToTransitions,
PureTransition, PureTransition,
ReduxLikeStateContainer, ReduxLikeStateContainer,
PureSelectorsToSelectors, PureSelectorsToSelectors,
BaseState,
} from './types'; } from './types';
const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable'; const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable';
const $$setActionType = '@@SET';
const freeze: <T>(value: T) => RecursiveReadonly<T> = const freeze: <T>(value: T) => RecursiveReadonly<T> =
process.env.NODE_ENV !== 'production' process.env.NODE_ENV !== 'production'
? <T>(value: T): RecursiveReadonly<T> => { ? <T>(value: T): RecursiveReadonly<T> => {
if (!value) return value as RecursiveReadonly<T>; const isFreezable = value !== null && typeof value === 'object';
if (value instanceof Array) return value as RecursiveReadonly<T>; if (isFreezable) return deepFreeze(value) as RecursiveReadonly<T>;
if (typeof value === 'object') return Object.freeze({ ...value }) as RecursiveReadonly<T>; return value as RecursiveReadonly<T>;
else return value as RecursiveReadonly<T>;
} }
: <T>(value: T) => value as RecursiveReadonly<T>; : <T>(value: T) => value as RecursiveReadonly<T>;
export const createStateContainer = < export function createStateContainer<State extends BaseState>(
State, defaultState: State
PureTransitions extends object = {}, ): ReduxLikeStateContainer<State>;
PureSelectors extends object = {} export function createStateContainer<State extends BaseState, PureTransitions extends object>(
defaultState: State,
pureTransitions: PureTransitions
): ReduxLikeStateContainer<State, PureTransitions>;
export function createStateContainer<
State extends BaseState,
PureTransitions extends object,
PureSelectors extends object
>(
defaultState: State,
pureTransitions: PureTransitions,
pureSelectors: PureSelectors
): ReduxLikeStateContainer<State, PureTransitions, PureSelectors>;
export function createStateContainer<
State extends BaseState,
PureTransitions extends object,
PureSelectors extends object
>( >(
defaultState: State, defaultState: State,
pureTransitions: PureTransitions = {} as PureTransitions, pureTransitions: PureTransitions = {} as PureTransitions,
pureSelectors: PureSelectors = {} as PureSelectors pureSelectors: PureSelectors = {} as PureSelectors
): ReduxLikeStateContainer<State, PureTransitions, PureSelectors> => { ): ReduxLikeStateContainer<State, PureTransitions, PureSelectors> {
const data$ = new BehaviorSubject<RecursiveReadonly<State>>(freeze(defaultState)); const data$ = new BehaviorSubject<RecursiveReadonly<State>>(freeze(defaultState));
const state$ = data$.pipe(skip(1)); const state$ = data$.pipe(skip(1));
const get = () => data$.getValue(); const get = () => data$.getValue();
@ -56,9 +74,13 @@ export const createStateContainer = <
state$, state$,
getState: () => data$.getValue(), getState: () => data$.getValue(),
set: (state: State) => { set: (state: State) => {
data$.next(freeze(state)); container.dispatch({ type: $$setActionType, args: [state] });
}, },
reducer: (state, action) => { reducer: (state, action) => {
if (action.type === $$setActionType) {
return freeze(action.args[0] as State);
}
const pureTransition = (pureTransitions as Record<string, PureTransition<State, any[]>>)[ const pureTransition = (pureTransitions as Record<string, PureTransition<State, any[]>>)[
action.type action.type
]; ];
@ -86,4 +108,4 @@ export const createStateContainer = <
[$$observable]: state$, [$$observable]: state$,
}; };
return container; return container;
}; }

View file

@ -23,15 +23,6 @@ import { act, Simulate } from 'react-dom/test-utils';
import { createStateContainer } from './create_state_container'; import { createStateContainer } from './create_state_container';
import { createStateContainerReactHelpers } from './create_state_container_react_helpers'; import { createStateContainerReactHelpers } from './create_state_container_react_helpers';
const create = <S, T extends object>(state: S, transitions: T = {} as T) => {
const pureTransitions = {
set: () => (newState: S) => newState,
...transitions,
};
const store = createStateContainer<typeof state, typeof pureTransitions>(state, pureTransitions);
return { store, mutators: store.transitions };
};
let container: HTMLDivElement | null; let container: HTMLDivElement | null;
beforeEach(() => { beforeEach(() => {
@ -56,12 +47,12 @@ test('can create React context', () => {
}); });
test('<Provider> passes state to <Consumer>', () => { test('<Provider> passes state to <Consumer>', () => {
const { store } = create({ hello: 'world' }); const stateContainer = createStateContainer({ hello: 'world' });
const { Provider, Consumer } = createStateContainerReactHelpers<typeof store>(); const { Provider, Consumer } = createStateContainerReactHelpers<typeof stateContainer>();
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Consumer>{(s: typeof store) => s.get().hello}</Consumer> <Consumer>{(s: typeof stateContainer) => s.get().hello}</Consumer>
</Provider>, </Provider>,
container container
); );
@ -79,8 +70,8 @@ interface Props1 {
} }
test('<Provider> passes state to connect()()', () => { test('<Provider> passes state to connect()()', () => {
const { store } = create({ hello: 'Bob' }); const stateContainer = createStateContainer({ hello: 'Bob' });
const { Provider, connect } = createStateContainerReactHelpers(); const { Provider, connect } = createStateContainerReactHelpers<typeof stateContainer>();
const Demo: React.FC<Props1> = ({ message, stop }) => ( const Demo: React.FC<Props1> = ({ message, stop }) => (
<> <>
@ -92,7 +83,7 @@ test('<Provider> passes state to connect()()', () => {
const DemoConnected = connect<Props1, 'message'>(mergeProps)(Demo); const DemoConnected = connect<Props1, 'message'>(mergeProps)(Demo);
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<DemoConnected stop="?" /> <DemoConnected stop="?" />
</Provider>, </Provider>,
container container
@ -101,14 +92,14 @@ test('<Provider> passes state to connect()()', () => {
expect(container!.innerHTML).toBe('Bob?'); expect(container!.innerHTML).toBe('Bob?');
}); });
test('context receives Redux store', () => { test('context receives stateContainer', () => {
const { store } = create({ foo: 'bar' }); const stateContainer = createStateContainer({ foo: 'bar' });
const { Provider, context } = createStateContainerReactHelpers<typeof store>(); const { Provider, context } = createStateContainerReactHelpers<typeof stateContainer>();
ReactDOM.render( ReactDOM.render(
/* eslint-disable no-shadow */ /* eslint-disable no-shadow */
<Provider value={store}> <Provider value={stateContainer}>
<context.Consumer>{store => store.get().foo}</context.Consumer> <context.Consumer>{stateContainer => stateContainer.get().foo}</context.Consumer>
</Provider>, </Provider>,
/* eslint-enable no-shadow */ /* eslint-enable no-shadow */
container container
@ -117,21 +108,21 @@ test('context receives Redux store', () => {
expect(container!.innerHTML).toBe('bar'); expect(container!.innerHTML).toBe('bar');
}); });
xtest('can use multiple stores in one React app', () => {}); test.todo('can use multiple stores in one React app');
describe('hooks', () => { describe('hooks', () => {
describe('useStore', () => { describe('useStore', () => {
test('can select store using useStore hook', () => { test('can select store using useContainer hook', () => {
const { store } = create({ foo: 'bar' }); const stateContainer = createStateContainer({ foo: 'bar' });
const { Provider, useContainer } = createStateContainerReactHelpers<typeof store>(); const { Provider, useContainer } = createStateContainerReactHelpers<typeof stateContainer>();
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
const store = useContainer(); const stateContainer = useContainer();
return <>{store.get().foo}</>; return <>{stateContainer.get().foo}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -143,15 +134,15 @@ describe('hooks', () => {
describe('useState', () => { describe('useState', () => {
test('can select state using useState hook', () => { test('can select state using useState hook', () => {
const { store } = create({ foo: 'qux' }); const stateContainer = createStateContainer({ foo: 'qux' });
const { Provider, useState } = createStateContainerReactHelpers<typeof store>(); const { Provider, useState } = createStateContainerReactHelpers<typeof stateContainer>();
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
const { foo } = useState(); const { foo } = useState();
return <>{foo}</>; return <>{foo}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -161,23 +152,20 @@ describe('hooks', () => {
}); });
test('re-renders when state changes', () => { test('re-renders when state changes', () => {
const { const stateContainer = createStateContainer(
store,
mutators: { setFoo },
} = create(
{ foo: 'bar' }, { foo: 'bar' },
{ {
setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }), setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }),
} }
); );
const { Provider, useState } = createStateContainerReactHelpers<typeof store>(); const { Provider, useState } = createStateContainerReactHelpers<typeof stateContainer>();
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
const { foo } = useState(); const { foo } = useState();
return <>{foo}</>; return <>{foo}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -185,7 +173,7 @@ describe('hooks', () => {
expect(container!.innerHTML).toBe('bar'); expect(container!.innerHTML).toBe('bar');
act(() => { act(() => {
setFoo('baz'); stateContainer.transitions.setFoo('baz');
}); });
expect(container!.innerHTML).toBe('baz'); expect(container!.innerHTML).toBe('baz');
}); });
@ -193,7 +181,7 @@ describe('hooks', () => {
describe('useTransitions', () => { describe('useTransitions', () => {
test('useTransitions hook returns mutations that can update state', () => { test('useTransitions hook returns mutations that can update state', () => {
const { store } = create( const stateContainer = createStateContainer(
{ {
cnt: 0, cnt: 0,
}, },
@ -206,7 +194,7 @@ describe('hooks', () => {
); );
const { Provider, useState, useTransitions } = createStateContainerReactHelpers< const { Provider, useState, useTransitions } = createStateContainerReactHelpers<
typeof store typeof stateContainer
>(); >();
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
const { cnt } = useState(); const { cnt } = useState();
@ -220,7 +208,7 @@ describe('hooks', () => {
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -240,7 +228,7 @@ describe('hooks', () => {
describe('useSelector', () => { describe('useSelector', () => {
test('can select deeply nested value', () => { test('can select deeply nested value', () => {
const { store } = create({ const stateContainer = createStateContainer({
foo: { foo: {
bar: { bar: {
baz: 'qux', baz: 'qux',
@ -248,14 +236,14 @@ describe('hooks', () => {
}, },
}); });
const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz; const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz;
const { Provider, useSelector } = createStateContainerReactHelpers<typeof store>(); const { Provider, useSelector } = createStateContainerReactHelpers<typeof stateContainer>();
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
const value = useSelector(selector); const value = useSelector(selector);
return <>{value}</>; return <>{value}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -265,7 +253,7 @@ describe('hooks', () => {
}); });
test('re-renders when state changes', () => { test('re-renders when state changes', () => {
const { store, mutators } = create({ const stateContainer = createStateContainer({
foo: { foo: {
bar: { bar: {
baz: 'qux', baz: 'qux',
@ -280,7 +268,7 @@ describe('hooks', () => {
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -288,7 +276,7 @@ describe('hooks', () => {
expect(container!.innerHTML).toBe('qux'); expect(container!.innerHTML).toBe('qux');
act(() => { act(() => {
mutators.set({ stateContainer.set({
foo: { foo: {
bar: { bar: {
baz: 'quux', baz: 'quux',
@ -300,9 +288,9 @@ describe('hooks', () => {
}); });
test("re-renders only when selector's result changes", async () => { test("re-renders only when selector's result changes", async () => {
const { store, mutators } = create({ a: 'b', foo: 'bar' }); const stateContainer = createStateContainer({ a: 'b', foo: 'bar' });
const selector = (state: { foo: string }) => state.foo; const selector = (state: { foo: string }) => state.foo;
const { Provider, useSelector } = createStateContainerReactHelpers<typeof store>(); const { Provider, useSelector } = createStateContainerReactHelpers<typeof stateContainer>();
let cnt = 0; let cnt = 0;
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
@ -311,7 +299,7 @@ describe('hooks', () => {
return <>{value}</>; return <>{value}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -321,14 +309,14 @@ describe('hooks', () => {
expect(cnt).toBe(1); expect(cnt).toBe(1);
act(() => { act(() => {
mutators.set({ a: 'c', foo: 'bar' }); stateContainer.set({ a: 'c', foo: 'bar' });
}); });
await new Promise(r => setTimeout(r, 1)); await new Promise(r => setTimeout(r, 1));
expect(cnt).toBe(1); expect(cnt).toBe(1);
act(() => { act(() => {
mutators.set({ a: 'd', foo: 'bar 2' }); stateContainer.set({ a: 'd', foo: 'bar 2' });
}); });
await new Promise(r => setTimeout(r, 1)); await new Promise(r => setTimeout(r, 1));
@ -336,9 +324,9 @@ describe('hooks', () => {
}); });
test('does not re-render on same shape object', async () => { test('does not re-render on same shape object', async () => {
const { store, mutators } = create({ foo: { bar: 'baz' } }); const stateContainer = createStateContainer({ foo: { bar: 'baz' } });
const selector = (state: { foo: any }) => state.foo; const selector = (state: { foo: any }) => state.foo;
const { Provider, useSelector } = createStateContainerReactHelpers<typeof store>(); const { Provider, useSelector } = createStateContainerReactHelpers<typeof stateContainer>();
let cnt = 0; let cnt = 0;
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
@ -347,7 +335,7 @@ describe('hooks', () => {
return <>{JSON.stringify(value)}</>; return <>{JSON.stringify(value)}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -357,14 +345,14 @@ describe('hooks', () => {
expect(cnt).toBe(1); expect(cnt).toBe(1);
act(() => { act(() => {
mutators.set({ foo: { bar: 'baz' } }); stateContainer.set({ foo: { bar: 'baz' } });
}); });
await new Promise(r => setTimeout(r, 1)); await new Promise(r => setTimeout(r, 1));
expect(cnt).toBe(1); expect(cnt).toBe(1);
act(() => { act(() => {
mutators.set({ foo: { bar: 'qux' } }); stateContainer.set({ foo: { bar: 'qux' } });
}); });
await new Promise(r => setTimeout(r, 1)); await new Promise(r => setTimeout(r, 1));
@ -372,7 +360,7 @@ describe('hooks', () => {
}); });
test('can set custom comparator function to prevent re-renders on deep equality', async () => { test('can set custom comparator function to prevent re-renders on deep equality', async () => {
const { store, mutators } = create( const stateContainer = createStateContainer(
{ foo: { bar: 'baz' } }, { foo: { bar: 'baz' } },
{ {
set: () => (newState: { foo: { bar: string } }) => newState, set: () => (newState: { foo: { bar: string } }) => newState,
@ -380,7 +368,7 @@ describe('hooks', () => {
); );
const selector = (state: { foo: any }) => state.foo; const selector = (state: { foo: any }) => state.foo;
const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr); const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr);
const { Provider, useSelector } = createStateContainerReactHelpers<typeof store>(); const { Provider, useSelector } = createStateContainerReactHelpers<typeof stateContainer>();
let cnt = 0; let cnt = 0;
const Demo: React.FC<{}> = () => { const Demo: React.FC<{}> = () => {
@ -389,7 +377,7 @@ describe('hooks', () => {
return <>{JSON.stringify(value)}</>; return <>{JSON.stringify(value)}</>;
}; };
ReactDOM.render( ReactDOM.render(
<Provider value={store}> <Provider value={stateContainer}>
<Demo /> <Demo />
</Provider>, </Provider>,
container container
@ -399,13 +387,13 @@ describe('hooks', () => {
expect(cnt).toBe(1); expect(cnt).toBe(1);
act(() => { act(() => {
mutators.set({ foo: { bar: 'baz' } }); stateContainer.set({ foo: { bar: 'baz' } });
}); });
await new Promise(r => setTimeout(r, 1)); await new Promise(r => setTimeout(r, 1));
expect(cnt).toBe(1); expect(cnt).toBe(1);
}); });
xtest('unsubscribes when React un-mounts', () => {}); test.todo('unsubscribes when React un-mounts');
}); });
}); });

View file

@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = <Container extends StateContaine
return value; return value;
}; };
const useTransitions = (): Container['transitions'] => useContainer().transitions; const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
const useSelector = <Result>( const useSelector = <Result>(
selector: (state: UnboxState<Container>) => Result, selector: (state: UnboxState<Container>) => Result,

View file

@ -20,12 +20,13 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Ensure, RecursiveReadonly } from '@kbn/utility-types'; import { Ensure, RecursiveReadonly } from '@kbn/utility-types';
export type BaseState = object;
export interface TransitionDescription<Type extends string = string, Args extends any[] = any[]> { export interface TransitionDescription<Type extends string = string, Args extends any[] = any[]> {
type: Type; type: Type;
args: Args; args: Args;
} }
export type Transition<State, Args extends any[]> = (...args: Args) => State; export type Transition<State extends BaseState, Args extends any[]> = (...args: Args) => State;
export type PureTransition<State, Args extends any[]> = ( export type PureTransition<State extends BaseState, Args extends any[]> = (
state: RecursiveReadonly<State> state: RecursiveReadonly<State>
) => Transition<State, Args>; ) => Transition<State, Args>;
export type EnsurePureTransition<T> = Ensure<T, PureTransition<any, any>>; export type EnsurePureTransition<T> = Ensure<T, PureTransition<any, any>>;
@ -34,15 +35,15 @@ export type PureTransitionsToTransitions<T extends object> = {
[K in keyof T]: PureTransitionToTransition<EnsurePureTransition<T[K]>>; [K in keyof T]: PureTransitionToTransition<EnsurePureTransition<T[K]>>;
}; };
export interface BaseStateContainer<State> { export interface BaseStateContainer<State extends BaseState> {
get: () => RecursiveReadonly<State>; get: () => RecursiveReadonly<State>;
set: (state: State) => void; set: (state: State) => void;
state$: Observable<RecursiveReadonly<State>>; state$: Observable<RecursiveReadonly<State>>;
} }
export interface StateContainer< export interface StateContainer<
State, State extends BaseState,
PureTransitions extends object = {}, PureTransitions extends object,
PureSelectors extends object = {} PureSelectors extends object = {}
> extends BaseStateContainer<State> { > extends BaseStateContainer<State> {
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>; transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>;
@ -50,7 +51,7 @@ export interface StateContainer<
} }
export interface ReduxLikeStateContainer< export interface ReduxLikeStateContainer<
State, State extends BaseState,
PureTransitions extends object = {}, PureTransitions extends object = {},
PureSelectors extends object = {} PureSelectors extends object = {}
> extends StateContainer<State, PureTransitions, PureSelectors> { > extends StateContainer<State, PureTransitions, PureSelectors> {
@ -63,14 +64,16 @@ export interface ReduxLikeStateContainer<
} }
export type Dispatch<T> = (action: T) => void; export type Dispatch<T> = (action: T) => void;
export type Middleware<State extends BaseState = BaseState> = (
export type Middleware<State = any> = (
store: Pick<ReduxLikeStateContainer<State, any>, 'getState' | 'dispatch'> store: Pick<ReduxLikeStateContainer<State, any>, 'getState' | 'dispatch'>
) => ( ) => (
next: (action: TransitionDescription) => TransitionDescription | any next: (action: TransitionDescription) => TransitionDescription | any
) => Dispatch<TransitionDescription>; ) => Dispatch<TransitionDescription>;
export type Reducer<State> = (state: State, action: TransitionDescription) => State; export type Reducer<State extends BaseState> = (
state: State,
action: TransitionDescription
) => State;
export type UnboxState< export type UnboxState<
Container extends StateContainer<any, any> Container extends StateContainer<any, any>
@ -80,7 +83,7 @@ export type UnboxTransitions<
> = Container extends StateContainer<any, infer T> ? T : never; > = Container extends StateContainer<any, infer T> ? T : never;
export type Selector<Result, Args extends any[] = []> = (...args: Args) => Result; export type Selector<Result, Args extends any[] = []> = (...args: Args) => Result;
export type PureSelector<State, Result, Args extends any[] = []> = ( export type PureSelector<State extends BaseState, Result, Args extends any[] = []> = (
state: State state: State
) => Selector<Result, Args>; ) => Selector<Result, Args>;
export type EnsurePureSelector<T> = Ensure<T, PureSelector<any, any, any>>; export type EnsurePureSelector<T> = Ensure<T, PureSelector<any, any, any>>;
@ -93,7 +96,12 @@ export type PureSelectorsToSelectors<T extends object> = {
export type Comparator<Result> = (previous: Result, current: Result) => boolean; export type Comparator<Result> = (previous: Result, current: Result) => boolean;
export type MapStateToProps<State, StateProps extends object> = (state: State) => StateProps; export type MapStateToProps<State extends BaseState, StateProps extends object> = (
export type Connect<State> = <Props extends object, StatePropKeys extends keyof Props>( state: State
) => StateProps;
export type Connect<State extends BaseState> = <
Props extends object,
StatePropKeys extends keyof Props
>(
mapStateToProp: MapStateToProps<State, Pick<Props, StatePropKeys>> mapStateToProp: MapStateToProps<State, Pick<Props, StatePropKeys>>
) => (component: React.ComponentType<Props>) => React.FC<Omit<Props, StatePropKeys>>; ) => (component: React.ComponentType<Props>) => React.FC<Omit<Props, StatePropKeys>>;

View file

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import { BaseStateContainer, createStateContainer } from '../state_containers'; import { BaseState, BaseStateContainer, createStateContainer } from '../state_containers';
import { import {
defaultState, defaultState,
pureTransitions, pureTransitions,
@ -89,7 +89,7 @@ describe('state_sync', () => {
// initial sync of storage to state is not happening // initial sync of storage to state is not happening
expect(container.getState()).toEqual(defaultState); expect(container.getState()).toEqual(defaultState);
const storageState2 = [{ id: 1, text: 'todo', completed: true }]; const storageState2 = { todos: [{ id: 1, text: 'todo', completed: true }] };
(testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2);
storageChange$.next(storageState2); storageChange$.next(storageState2);
@ -124,7 +124,7 @@ describe('state_sync', () => {
start(); start();
const originalState = container.getState(); const originalState = container.getState();
const storageState = [...originalState]; const storageState = { ...originalState };
(testStateStorage.get as jest.Mock).mockImplementation(() => storageState); (testStateStorage.get as jest.Mock).mockImplementation(() => storageState);
storageChange$.next(storageState); storageChange$.next(storageState);
@ -134,7 +134,7 @@ describe('state_sync', () => {
}); });
it('storage change to null should notify state', () => { it('storage change to null should notify state', () => {
container.set([{ completed: false, id: 1, text: 'changed' }]); container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] });
const { stop, start } = syncStates([ const { stop, start } = syncStates([
{ {
stateContainer: withDefaultState(container, defaultState), stateContainer: withDefaultState(container, defaultState),
@ -189,8 +189,8 @@ describe('state_sync', () => {
]); ]);
start(); start();
const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; const newStateFromUrl = { todos: [{ completed: false, id: 1, text: 'changed' }] };
history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); history.replace('/#?_s=(todos:!((completed:!f,id:1,text:changed)))');
expect(container.getState()).toEqual(newStateFromUrl); expect(container.getState()).toEqual(newStateFromUrl);
expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl);
@ -220,7 +220,7 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength + 1); expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"`
); );
stop(); stop();
@ -248,14 +248,14 @@ describe('state_sync', () => {
expect(history.length).toBe(startHistoryLength + 1); expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"`
); );
await tick(); await tick();
expect(history.length).toBe(startHistoryLength + 1); expect(history.length).toBe(startHistoryLength + 1);
expect(getCurrentUrl()).toMatchInlineSnapshot( expect(getCurrentUrl()).toMatchInlineSnapshot(
`"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"`
); );
stop(); stop();
@ -294,7 +294,7 @@ describe('state_sync', () => {
}); });
}); });
function withDefaultState<State>( function withDefaultState<State extends BaseState>(
stateContainer: BaseStateContainer<State>, stateContainer: BaseStateContainer<State>,
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
defaultState: State defaultState: State
@ -302,7 +302,10 @@ function withDefaultState<State>(
return { return {
...stateContainer, ...stateContainer,
set: (state: State | null) => { set: (state: State | null) => {
stateContainer.set(state || defaultState); stateContainer.set({
...defaultState,
...state,
});
}, },
}; };
} }

View file

@ -23,6 +23,7 @@ import defaultComparator from 'fast-deep-equal';
import { IStateSyncConfig } from './types'; import { IStateSyncConfig } from './types';
import { IStateStorage } from './state_sync_state_storage'; import { IStateStorage } from './state_sync_state_storage';
import { distinctUntilChangedWithInitialValue } from '../../common'; import { distinctUntilChangedWithInitialValue } from '../../common';
import { BaseState } from '../state_containers';
/** /**
* Utility for syncing application state wrapped in state container * Utility for syncing application state wrapped in state container
@ -86,7 +87,10 @@ export interface ISyncStateRef<stateStorage extends IStateStorage = IStateStorag
// start syncing state with storage // start syncing state with storage
start: StartSyncStateFnType; start: StartSyncStateFnType;
} }
export function syncState<State = unknown, StateStorage extends IStateStorage = IStateStorage>({ export function syncState<
State extends BaseState,
StateStorage extends IStateStorage = IStateStorage
>({
storageKey, storageKey,
stateStorage, stateStorage,
stateContainer, stateContainer,

View file

@ -17,10 +17,11 @@
* under the License. * under the License.
*/ */
import { BaseStateContainer } from '../state_containers/types'; import { BaseState, BaseStateContainer } from '../state_containers/types';
import { IStateStorage } from './state_sync_state_storage'; import { IStateStorage } from './state_sync_state_storage';
export interface INullableBaseStateContainer<State> extends BaseStateContainer<State> { export interface INullableBaseStateContainer<State extends BaseState>
extends BaseStateContainer<State> {
// State container for stateSync() have to accept "null" // State container for stateSync() have to accept "null"
// for example, set() implementation could handle null and fallback to some default state // for example, set() implementation could handle null and fallback to some default state
// this is required to handle edge case, when state in storage becomes empty and syncing is in progress. // this is required to handle edge case, when state in storage becomes empty and syncing is in progress.
@ -29,7 +30,7 @@ export interface INullableBaseStateContainer<State> extends BaseStateContainer<S
} }
export interface IStateSyncConfig< export interface IStateSyncConfig<
State = unknown, State extends BaseState,
StateStorage extends IStateStorage = IStateStorage StateStorage extends IStateStorage = IStateStorage
> { > {
/** /**

View file

@ -3929,6 +3929,11 @@
resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050"
integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==
"@types/deep-freeze-strict@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4"
integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ=
"@types/delete-empty@^2.0.0": "@types/delete-empty@^2.0.0":
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964"
@ -10523,6 +10528,11 @@ deep-extend@^0.6.0:
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
deep-freeze-strict@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0"
integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA=
deep-is@^0.1.3, deep-is@~0.1.3: deep-is@^0.1.3, deep-is@~0.1.3:
version "0.1.3" version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"