[State Management][Docs] State syncing utils docs (#56479)

This commit is contained in:
Anton Dosov 2020-02-07 08:37:00 +01:00 committed by GitHub
parent ea84cbf11c
commit c001014f78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 562 additions and 8 deletions

View file

@ -2,4 +2,5 @@
Utilities for building Kibana plugins.
- [State containers](./docs/state_containers/README.md).
- [State containers](./docs/state_containers).
- [State syncing utilities](./docs/state_sync).

View file

@ -12,7 +12,6 @@ your services or apps.
container you can always access the latest state snapshot synchronously.
- Unlike Redux, state containers are less verbose, see example below.
## Example
```ts
@ -21,11 +20,11 @@ import { createStateContainer } from 'src/plugins/kibana_utils';
const container = createStateContainer(
{ count: 0 },
{
increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }),
double: (state: {count: number}) => () => ({ count: state.count * 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,
count: (state: { count: number }) => () => state.count,
}
);
@ -35,7 +34,6 @@ container.transitions.double();
console.log(container.selectors.count()); // 10
```
## Demos
See demos [here](../../demos/state_containers/).
@ -47,11 +45,11 @@ npx -q ts-node src/plugins/kibana_utils/demos/state_containers/counter.ts
npx -q ts-node src/plugins/kibana_utils/demos/state_containers/todomvc.ts
```
## Reference
- [Creating a state container](./creation.md).
- [State transitions](./transitions.md).
- [Using with React](./react.md).
- [Using without React`](./no_react.md).
- [Using without React](./no_react.md).
- [Parallels with Redux](./redux.md).
- [Syncing state with URL or SessionStorage](../state_sync)

View file

@ -0,0 +1,60 @@
# State Syncing Utilities
State syncing utilities are a set of helpers for syncing your application state
with URL or browser storage.
They are designed to work together with [state containers](../state_containers). But state containers are not required.
State syncing utilities include:
- `syncState` util which:
- Subscribes to state changes and pushes them to state storage.
- Optionally subscribes to state storage changes and pushes them to state.
- Two types of storage compatible with `syncState`:
- [KbnUrlStateStorage](./storages/kbn_url_storage.md) - Serializes state and persists it to URL's query param in rison or hashed format.
Listens for state updates in the URL and pushes them back to state.
- [SessionStorageStateStorage](./storages/session_storage.md) - Serializes state and persists it to session storage.
## Example
```ts
import {
createStateContainer,
syncState,
createKbnUrlStateStorage,
} from 'src/plugins/kibana_utils/public';
const stateContainer = createStateContainer({ count: 0 });
const stateStorage = createKbnUrlStateStorage();
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage,
});
start();
// state container change is synched to state storage
// kbnUrlStateStorage updates the URL to "/#?_a=(count:2)"
stateContainer.set({ count: 2 });
stop();
```
## Demos Plugins
See demos plugins [here](../../../../../examples/state_containers_examples).
To run them, start kibana with `--run-examples` flag.
## Reference
- [Syncing state with URL](./storages/kbn_url_storage.md).
- [Syncing state with sessionStorage](./storages/session_storage.md).
- [Setting up initial state](./initial_state.md).
- [Using without state containers](./no_state_containers.md).
- [Handling empty or incomplete incoming state](./empty_or_incomplete_incoming_state.md).
- [On-the-fly state migrations](./on_fly_state_migrations.md).
- [syncStates helper](./sync_states.md).
- [Helpers for Data plugin (syncing TimeRange, RefreshInterval and Filters)](./data_plugin_helpers.md).

View file

@ -0,0 +1,7 @@
# Helpers for syncing state with data plugins (TimeRange, RefreshInterval and Filters)
```ts
// TODO: Waiting for
// https://github.com/elastic/kibana/issues/55977
// https://github.com/elastic/kibana/pull/56128
```

View file

@ -0,0 +1,54 @@
# Handling empty or incomplete incoming state
Users have direct access to storages where we sync our state to.
For example, in the URL, a user can manually change the URL and remove or corrupt important data which we expect to be there.
URLs may also be programmatically generated, increasing the risk for mistakes which application can't handle.
`syncState` doesn't handle such edge cases passing input from storage to the state container as is.
It is up to the application to handle such scenarios.
Consider the following example:
```ts
// window.location.href is "/#?_a=(count:0"
const defaultState = { count: 0 }; // default application state
const stateContainer = createStateContainer(defaultState);
const stateStorage = createKbnUrlStateStorage();
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage,
});
start();
// At this point, state and storage are in sync
// state: {count: 0}
// storage: {count: 0}
// Now user changes the URL manually to "/#?_a=(corrupt:0)",
// triggering a state update with {corrupt: 0}
```
The application could, for example, handle this gracefully, by using simple composition during setup:
```ts
const { start, stop } = syncState({
storageKey: '_a',
stateContainer: {
...stateContainer,
set: state => stateContainer.set({ ...defaultState, ...state }),
},
stateStorage,
});
```
In this case, the corrupt value will not get into state, preventing misshaped state.
To help application developers remember such edge cases,
`syncState` util sets a constraint,
that setter to state container should be able to handle `null` value (see [IStateSyncConfig](../../public/state_sync/types.ts)).
Incoming `null` value from state storage usually means that state is empty (e.g. URL without `storageKey` query param).
So when using `syncState` util applications are required to at least handle incoming `null`.

View file

@ -0,0 +1,71 @@
# Setting up initial state
The `syncState` util doesn't do any initial state syncing between state and storage.
Consider the following scenario:
```ts
// window.location.href is "/#?_a=(count:2)"
const defaultState = { count: 0 }; // default application state
const stateContainer = createStateContainer(defaultState);
const stateStorage = createKbnUrlStateStorage();
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage,
});
start();
// Now the storage and state are out of sync
// state: {count: 0}
// storage: {count: 2}
```
It is up to the application to decide, how initial state should be synced and which state should take precedence, depending on the specific use case.
Questions to consider:
1. Should default state take precedence over URL?
2. Should URL take precedence?
3. Do we have to do any state migrations for what is coming from the URL?
4. If URL doesn't have the whole state, should we merge it with default one or leave it behind?
5. Is there any other state loading in parallel (e.g. from a `SavedObject`)? How should we merge those?
6. Are we storing the state both in the URL and in the `sessionStorage`? Which one should take precedence and in which case?
A possible synchronization for the state conflict above could look like this:
```ts
// window.location.href is "/#?_a=(count:2)"
const defaultState = { count: 0 }; // default application state
const urlStateStorage = createKbnUrlStateStorage();
const initialStateFromUrl = urlStateStorage.get('_a');
// merge the default state and initial state from the url and use it as initial application state
const initialState = {
...defaultState,
...initialStateFromUrl,
};
const stateContainer = createStateContainer(initialState);
// preserve initial application state in the URL
if (!initialStateFromUrl) {
urlStateStorage.set('_a', initialState, { replace: true });
}
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage: urlStateStorage,
});
start();
// Now the storage and state are in sync
// state: {count: 2}
// storage: {count: 2}
```

View file

@ -0,0 +1,51 @@
# Using state syncing utilities without state containers
It is possible to use `syncState` utility even if your app is not using [state containers](../state_containers).
The `state` which is passed into `syncState` function should implement this simple interface:
```ts
export interface BaseStateContainer<State extends BaseState> {
get: () => State;
set: (state: State | null) => void;
state$: Observable<State>;
}
```
For example, assuming you have a custom state manager, setting up syncing state with the URL could look something like this:
```ts
// my_state_manager.ts
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';
export class MyStateManager {
private count: number = 0;
updated$ = new Subject();
setCount(count: number) {
if (this.count !== count) {
this.count = count;
this.updated$.next();
}
}
getCount() {
return this.count;
}
}
```
```ts
// app.ts
import { syncState, createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public';
import { MyStateManager } from './my_state_manager';
const myStateManager = new MyStateManager();
syncState({
get: () => ({ count: myStateManager.getCount() }),
set: state => state && myStateManager.setCount(state.count),
state$: myStateManager.updated$.pipe(map(() => ({ count: myStateManager.getCount() }))),
});
```

View file

@ -0,0 +1,48 @@
# On-the-fly state migrations
When retrieving initial state from storage we shouldn't forget about possible outdated state.
Consider the scenario, where user launches application from a bookmarked link, which contains outdated state.
Similar to [handling initial state](./initial_state.md) example, applications could handle migrations during Initialization.
```ts
import { migrate } from '../app/state_helpers';
const urlStateStorage = createKbnUrlStateStorage();
const initialStateFromUrl = urlStateStorage.get('_a');
// merge default state and initial state and migrate it to the current version
const initialState = migrate({
...defaultState,
...initialStateFromUrl,
});
const stateContainer = createStateContainer(initialState);
```
It is also possible to apply migrations for any incoming state, similar to [handling empty or incomplete state](./empty_or_incomplete_incoming_state.md).
Imagine an edge case scenario, where a user is working in your application, and then pastes an old link for the same application, containing older state with a different structure.
Since no application remount will happen, we need to transition to a new state on-the-fly, by applying necessary migrations.
```ts
import { migrate } from '../app/state_helpers';
const urlStateStorage = createKbnUrlStateStorage();
const initialStateFromUrl = urlStateStorage.get('_a');
// merge default state and initial state and migrate them to current version if needed
const initialState = migrate({
...defaultState,
...initialStateFromUrl,
});
const stateContainer = createStateContainer(initialState);
const { start, stop } = syncState({
storageKey: '_a',
stateContainer: {
...stateContainer,
set: state => stateContainer.set(migrate({ ...defaultState, ...state })),
},
stateStorage,
});
```

View file

@ -0,0 +1,7 @@
# State Storage
Two types of storage compatible with `syncState`:
- [KbnUrlStateStorage](./kbn_url_storage.md) - Serialises state and persists it to URL's query param in rison or hashed format (similar to what AppState & GlobalState did in legacy world).
Listens for state updates in the URL and pushes updates back to state.
- [SessionStorageStateStorage](./session_storage.md) - Serializes state and persists it to session storage.

View file

@ -0,0 +1,162 @@
# Kbn Url Storage
`KbnUrlStateStorage` is a state storage for `syncState` utility which:
- Keeps state in sync with the URL.
- Serializes data and stores it in the URL in one of the supported formats:
1. [Rison](https://github.com/w33ble/rison-node) encoded.
2. Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in `sessionStorage`.
See kibana's advanced option for more context `state:storeInSessionStorage`
- Takes care of listening to the URL updates and notifies state about the updates.
- Takes care of batching URL updates to prevent redundant browser history records.
### Basic Example
With state sync utility:
```ts
import {
createStateContainer,
syncState,
createKbnUrlStateStorage,
} from 'src/plugins/kibana_utils/public';
const stateContainer = createStateContainer({ count: 0 });
const stateStorage = createKbnUrlStateStorage();
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage,
});
start();
// state container change is synced to state storage
// in this case, kbnUrlStateStorage updates the URL to "/#?_a=(count:2)"
stateContainer.set({ count: 2 });
stop();
```
Or just by itself:
```ts
// assuming url is "/#?_a=(count:2)"
const stateStorage = createKbnUrlStateStorage();
stateStorage.get('_a'); // returns {count: a}
stateStorage.set('_a', { count: 0 }); // updates url to "/#?_a=(count:0)"
```
### Setting URL format option
```ts
const stateStorage = createKbnUrlStateStorage({
useHash: core.uiSettings.get('state:storeInSessionStorage'), // put the complete encoded rison or just the hash into the URL
});
```
### Passing [history](https://github.com/ReactTraining/history) instance
Under the hood `kbnUrlStateStorage` uses [history](https://github.com/ReactTraining/history) for updating the URL and for listening to the URL updates.
To prevent bugs caused by missing history updates, make sure your app uses one instance of history for URL changes which may interfere with each other.
For example, if you use `react-router`:
```tsx
const App = props => {
useEffect(() => {
const stateStorage = createKbnUrlStateStorage({
useHash: props.uiSettings.get('state:storeInSessionStorage'),
history: props.history,
});
//....
});
return <Router history={props.history} />;
};
```
### Url updates batching
`kbnUrlStateStorage` batches synchronous URL updates. Consider the example.
```ts
const urlStateStorage = createKbnUrlStateStorage();
urlStateStorage.set('_a', { state: 1 });
urlStateStorage.set('_b', { state: 2 });
// URL hasn't been updated yet
setTimeout(() => {
// now URL is actually "/#?_a=(state:1)&_b=(state:2)"
// and 2 updates were batched into 1 history.push() call
}, 0);
```
For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis:
- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates.
`replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened
- `kbnUrlStateStorage.cancel()` - cancels any pending updates
### Sharing one `kbnUrlStateStorage` instance
`kbnUrlStateStorage` is stateful, as it keeps track of pending updates.
So if there are multiple state syncs happening in the same time, then one instance of `kbnUrlStateStorage` should be used to make sure, that the same update queue is used.
Otherwise it could cause redundant browser history records.
```ts
// wrong:
const { start, stop } = syncStates([
{
storageKey: '_a',
stateContainerA,
stateStorage: createKbnUrlStateStorage(),
},
{
storageKey: '_b',
stateContainerB,
stateStorage: createKbnUrlStateStorage(),
},
]);
// better:
const kbnUrlStateStorage = createKbnUrlStateStorage();
const { start, stop } = syncStates([
{
storageKey: '_a',
stateContainerA,
stateStorage: kbnUrlStateStorage,
},
{
storageKey: '_b',
stateContainerB,
stateStorage: kbnUrlStateStorage,
},
]);
// the best:
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
const kbnUrlStateStorage = createKbnUrlStateStorage({ history });
const { start, stop } = syncStates([
{
storageKey: '_a',
stateContainerA,
stateStorage: kbnUrlStateStorage,
},
{
storageKey: '_b',
stateContainerB,
stateStorage: kbnUrlStateStorage,
},
]);
<Router history={history} />;
```

View file

@ -0,0 +1,39 @@
# Session Storage
To sync state from state containers to `sessionStorage` use `createSessionStorageStateStorage`.
```ts
import {
createStateContainer,
syncState,
createSessionStorageStateStorage,
} from 'src/plugins/kibana_utils/public';
const stateContainer = createStateContainer({ count: 0 });
const stateStorage = createSessionStorageStateStorage();
const { start, stop } = syncState({
storageKey: '_a',
stateContainer,
stateStorage,
});
start();
// state container change is synced to state storage
// in this case, stateStorage serialises state and stores it in `window.sessionStorage` by key `_a`
stateContainer.set({ count: 2 });
stop();
```
You can also use `createSessionStorageStateStorage` imperatively:
```ts
const stateStorage = createSessionStorageStateStorage();
stateStorage.set('_a', { count: 2 });
stateStorage.get('_a');
```
**Note**: external changes to `sessionStorageStateStorage` or `window.sessionStorage` don't trigger state container updates.

View file

@ -0,0 +1,34 @@
# Sync states utility
In case you need to sync multiple states or one state to multiple storages, there is a handy util for that.
It saves a bit of boilerplate by returning `start` and `stop` functions for all `syncState` configs at once.
```ts
import {
createStateContainer,
syncStates,
createKbnUrlStateStorage,
createSessionStorageStateStorage,
} from 'src/plugins/kibana_utils/public';
const stateContainer = createStateContainer({ count: 0 });
const urlStateStorage = createKbnUrlStateStorage();
const sessionStorageStateStorage = createSessionStorageStateStorage();
const { start, stop } = syncStates([
{
storageKey: '_a',
stateContainer,
stateStorage: urlStateStorage,
},
{
storageKey: '_a',
stateContainer,
stateStorage: sessionStorageStateStorage,
},
]);
start(); // start syncing to all storages at once
stop(); // stop syncing to all storages at once
```

View file

@ -151,6 +151,28 @@ describe('state_sync', () => {
stop();
});
it('storage change with incomplete or differently shaped object should notify state and set new object as is', () => {
container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] });
const { stop, start } = syncStates([
{
stateContainer: container,
storageKey: '_s',
stateStorage: testStateStorage,
},
]);
start();
const differentlyShapedObject = {
different: 'test',
};
(testStateStorage.get as jest.Mock).mockImplementation(() => differentlyShapedObject);
storageChange$.next(differentlyShapedObject as any);
expect(container.getState()).toStrictEqual(differentlyShapedObject);
stop();
});
});
describe('integration', () => {