Skip to content

Commit 8b5d377

Browse files
authored
Added syncExternalStore plugin (#38)
1 parent 344efbe commit 8b5d377

12 files changed

Lines changed: 1165 additions & 340 deletions

README.md

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ npm install --save-prod react-executor
7777
- [`retryInvalidated`](#retryinvalidated)
7878
- [`retryRejected`](#retryrejected)
7979
- [`retryWhen`](#retrywhen)
80-
- [`syncStorage`](#syncstorage)
80+
- [`syncBrowserStorage`](#syncbrowserstorage)
81+
- [`syncExternalStore`](#syncsxternalstore)
8182

8283
<span class="toc-icon">⚛️&ensp;</span>[**React integration**](#react-integration)
8384

@@ -1411,17 +1412,14 @@ const executor = useExecutor('test', heavyTask, [
14111412
]);
14121413
```
14131414

1414-
## `syncStorage`
1415+
## `syncBrowserStorage`
14151416

1416-
Persists the executor value in the synchronous storage.
1417+
Synchronizes the executor state with `localStorage` or `sessionStorage` item. No-op in a non-browser environment.
14171418

1418-
<!-- prettier-ignore -->
14191419
```ts
1420-
import syncStorage from 'react-executor/plugin/syncStorage';
1420+
import syncBrowserStorage from 'react-executor/plugin/syncBrowserStorage';
14211421

1422-
const executor = useExecutor('test', 42, [
1423-
syncStorage(localStorage),
1424-
]);
1422+
const executor = useExecutor('test', 42, [syncBrowserStorage()]);
14251423
```
14261424

14271425
With this plugin, you can synchronize the executor state
@@ -1442,7 +1440,7 @@ Here's how you can enable serialization of objects with circular references:
14421440
import JSONMarshal from 'json-marshal';
14431441

14441442
const executor = useExecutor('test', 42, [
1445-
syncStorage(localStorage, {
1443+
syncBrowserStorage({
14461444
serializer: JSONMarshal,
14471445
}),
14481446
]);
@@ -1452,22 +1450,53 @@ const executor = useExecutor('test', 42, [
14521450
> With additional configuration, [json-marshal&#8239;<sup>↗</sup>](https://github.com/smikhalevski/json-marshal#readme)
14531451
> can stringify and parse any data structure.
14541452
1455-
By default, `syncStorage` plugin uses a [serialized executor key](#executor-keys) as a storage key. You can provide a
1456-
custom key
1457-
via [`storageKey`&#8239;<sup>↗</sup>](https://smikhalevski.github.io/react-executor/interfaces/plugin_syncStorage.SyncStorageOptions.html#storagekey)
1453+
By default, `syncBrowserStorage` plugin uses a [serialized executor key](#executor-keys) as a storage key. You can
1454+
provide a custom key
1455+
via [`storageKey`&#8239;<sup>↗</sup>](https://smikhalevski.github.io/react-executor/interfaces/plugin_syncBrowserStorage.SyncBrowserStorageOptions.html#storagekey)
14581456
option:
14591457

14601458
```ts
1461-
useExecutor('test', 42, [syncStorage(localStorage, { storageKey: 'hello' })]);
1459+
useExecutor('test', 42, [syncBrowserStorage({ storageKey: 'hello' })]);
14621460
```
14631461

1464-
In the environment where storage is unavailable (for example, [during SSR](#server-side-rendering)), you can
1465-
conditionally disable the plugin:
1462+
## `syncExternalStore`
1463+
1464+
Persists the executor state in the observable external store.
1465+
1466+
Here's the minimal external store example:
14661467

14671468
```ts
1468-
useExecutor('test', 42, [typeof localStorage !== 'undefined' ? syncStorage(localStorage) : null]);
1469+
import { useExecutor, ExecutorState } from 'react-executor';
1470+
import syncExternalStore, { ExternalStore } from 'react-executor/plugin/syncExternalStore';
1471+
1472+
let myStoredState: ExecutorState | null = null;
1473+
1474+
const myStore: ExternalStore<ExecutorState> = {
1475+
get() {
1476+
return myStoredState;
1477+
},
1478+
1479+
subscribe(listener) {
1480+
// Place subscription login here
1481+
return () => {};
1482+
},
1483+
};
1484+
1485+
const myExecutor = useExecutor('test', 42, [syncExternalStore(myStore)]);
14691486
```
14701487

1488+
When executor is [settled](#settle-an-executor), [cleared](#clear-an-executor), [invalidated](#invalidate-results) or
1489+
annotated then the plugin calls
1490+
the [`ExternalStore.set`&#8239;<sup>↗</sup>](https://smikhalevski.github.io/react-executor/interfaces/plugin_syncExternalStore.ExternalStore.html#set)
1491+
method on the store.
1492+
1493+
When executor is [detached](#detach-an-executor) then the plugin calls
1494+
the [`ExternalStore.delete`&#8239;<sup>↗</sup>](https://smikhalevski.github.io/react-executor/interfaces/plugin_syncExternalStore.ExternalStore.html#delete)
1495+
method on the store.
1496+
1497+
Prefer [`syncBrowserStorage`](#syncbrowserstorage) if you want to persist the executor state in a `localStorage`
1498+
or `sessionStorage`.
1499+
14711500
# React integration
14721501

14731502
In the basic scenario, to use executors in your React app, you don't need any additional configuration, just use
@@ -2091,21 +2120,21 @@ const App = () => <ExecutorManagerProvider value={manager}>{/* Render you app he
20912120

20922121
## Storage state versioning
20932122

2094-
You can store an executor state in a `localStorage` using the [`syncStorage`](#syncstorage) plugin:
2123+
You can store an executor state in a `localStorage` using the [`syncBrowserStorage`](#syncbrowserstorage) plugin:
20952124

20962125
```ts
20972126
import { useExecutor } from 'react-executor';
2098-
import syncStorage from 'react-executor/plugin/syncStorage';
2127+
import syncBrowserStorage from 'react-executor/plugin/syncBrowserStorage';
20992128

2100-
const playerExecutor = useExecutor('player', { health: '50%' }, [syncStorage(localStorage)]);
2129+
const playerExecutor = useExecutor('player', { health: '50%' }, [syncBrowserStorage()]);
21012130
// ⮕ Executor<{ health: string }>
21022131
```
21032132

21042133
But what if over time you'd like to change the structure of the value stored in the `playerExecutor`? For example,
21052134
make `health` property a number:
21062135

21072136
```ts
2108-
const playerExecutor = useExecutor('player', { health: 0.5 }, [syncStorage(localStorage)]);
2137+
const playerExecutor = useExecutor('player', { health: 0.5 }, [syncBrowserStorage()]);
21092138
```
21102139

21112140
After users have used the previous version of the app where `health` was a string, they would still receive a string
@@ -2139,10 +2168,10 @@ export function requireVersion(version: number): ExecutorPlugin {
21392168
Add the plugin to the executor:
21402169

21412170
```ts
2142-
const playerExecutor = useExecutor('player', { health: 0.5 }, [syncStorage(localStorage), requireVersion(1)]);
2171+
const playerExecutor = useExecutor('player', { health: 0.5 }, [syncBrowserStorage(), requireVersion(1)]);
21432172
```
21442173

2145-
After the `syncStorage` plugin reads the data from the `localStorage`, the `requireVersion` plugin ensures that
2174+
After the `syncBrowserStorage` plugin reads the data from the `localStorage`, the `requireVersion` plugin ensures that
21462175
the `version` annotation read from the `localStorage` matches the required version. On mismatch the executor is cleared
21472176
and the initial value `{ health: 0.5 }` is written to the storage.
21482177

@@ -2176,7 +2205,7 @@ Now `requireVersion` would apply the migration on the state version mismatch:
21762205

21772206
```ts
21782207
const playerExecutor = useExecutor('player', { health: 0.5 }, [
2179-
syncStorage(localStorage),
2208+
syncBrowserStorage(),
21802209

21812210
requireVersion(1, executor => {
21822211
executor.resolve({

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"./plugin/retryInvalidated": "./plugin/retryInvalidated.js",
3131
"./plugin/retryRejected": "./plugin/retryRejected.js",
3232
"./plugin/retryWhen": "./plugin/retryWhen.js",
33-
"./plugin/syncStorage": "./plugin/syncStorage.js",
33+
"./plugin/syncBrowserStorage": "./plugin/syncBrowserStorage.js",
34+
"./plugin/syncExternalStore": "./plugin/syncExternalStore.js",
3435
"./package.json": "./package.json"
3536
},
3637
"sideEffects": false,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* The plugin that synchronizes the executor state with a browser storage item.
3+
*
4+
* ```ts
5+
* import syncBrowserStorage from 'react-executor/plugin/syncBrowserStorage';
6+
*
7+
* const executor = useExecutor('test', 42, [syncBrowserStorage()]);
8+
* ```
9+
*
10+
* @module plugin/syncBrowserStorage
11+
*/
12+
13+
import type { Executor, ExecutorPlugin, ExecutorState, Serializer } from '../types.js';
14+
import { emptyObject } from '../utils.js';
15+
import syncExternalStore, { ExternalStore } from './syncExternalStore.js';
16+
import { PubSub } from 'parallel-universe';
17+
18+
/**
19+
* Options of the {@link syncBrowserStorage} plugin.
20+
*/
21+
export interface SyncBrowserStorageOptions {
22+
/**
23+
* The storage where executor state is persisted.
24+
*
25+
* @default "local"
26+
*/
27+
storageType?: 'local' | 'session';
28+
29+
/**
30+
* A storage item key, or a callback that returns a storage item key.
31+
*
32+
* By default, an {@link react-executor!ExecutorManagerOptions.keyIdGenerator executor key ID} is used.
33+
*/
34+
storageKey?: string | ((executor: Executor) => string);
35+
36+
/**
37+
* The storage item value serializer.
38+
*
39+
* @default JSON
40+
*/
41+
serializer?: Serializer;
42+
}
43+
44+
/**
45+
* Synchronizes the executor state with a browser storage item. No-op in a non-browser environment.
46+
*
47+
* If an executor is detached, then the corresponding item is removed from the storage.
48+
*
49+
* @param options Storage options.
50+
*/
51+
export default function syncBrowserStorage(options: SyncBrowserStorageOptions = emptyObject): ExecutorPlugin {
52+
const { storageType = 'local', storageKey, serializer = JSON } = options;
53+
54+
return executor => {
55+
const key =
56+
storageKey === undefined
57+
? executor.manager.keyIdGenerator(executor.key)
58+
: typeof storageKey === 'function'
59+
? storageKey(executor)
60+
: storageKey;
61+
62+
if (typeof key !== 'string') {
63+
throw new Error('Cannot guess a storage key for an executor, the "storageKey" option is required');
64+
}
65+
66+
if (typeof window !== 'undefined') {
67+
syncExternalStore(new BrowserStore(storageType, key, serializer))(executor);
68+
}
69+
};
70+
}
71+
72+
const pubSub = new PubSub<StorageEvent>();
73+
74+
function handleStorage(event: StorageEvent): void {
75+
pubSub.publish(event);
76+
}
77+
78+
class BrowserStore implements ExternalStore<ExecutorState> {
79+
readonly storageArea;
80+
81+
constructor(
82+
readonly storageType: 'local' | 'session',
83+
readonly key: string,
84+
readonly serializer: Serializer
85+
) {
86+
this.storageArea = this.storageType === 'session' ? sessionStorage : localStorage;
87+
}
88+
89+
get(): ExecutorState | null {
90+
const storedValue = this.storageArea.getItem(this.key);
91+
92+
if (storedValue === null) {
93+
return null;
94+
}
95+
96+
try {
97+
return this.serializer.parse(storedValue);
98+
} catch (error) {
99+
setTimeout(() => {
100+
// Throw unhandled error
101+
throw error;
102+
}, 0);
103+
return null;
104+
}
105+
}
106+
107+
set(state: ExecutorState): void {
108+
this.storageArea.setItem(this.key, this.serializer.stringify(state));
109+
}
110+
111+
delete(): void {
112+
this.storageArea.removeItem(this.key);
113+
}
114+
115+
subscribe(listener: (state: ExecutorState | null) => void): () => void {
116+
if (pubSub.listenerCount === 0) {
117+
window.addEventListener('storage', handleStorage);
118+
}
119+
120+
const unsubscribe = pubSub.subscribe(event => {
121+
if (event.storageArea === this.storageArea && event.key === this.key) {
122+
listener(this.get());
123+
}
124+
});
125+
126+
return () => {
127+
unsubscribe();
128+
129+
if (pubSub.listenerCount === 0) {
130+
window.removeEventListener('storage', handleStorage);
131+
}
132+
};
133+
}
134+
}

0 commit comments

Comments
 (0)