Skip to content

Commit a96c714

Browse files
committed
Added syncExternalStore
1 parent 344efbe commit a96c714

File tree

6 files changed

+545
-306
lines changed

6 files changed

+545
-306
lines changed

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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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 json = this.storageArea.getItem(this.key);
91+
92+
if (json === null) {
93+
return null;
94+
}
95+
try {
96+
return this.serializer.parse(json);
97+
} catch (e) {
98+
setTimeout(() => {
99+
throw e;
100+
});
101+
return null;
102+
}
103+
}
104+
105+
set(state: ExecutorState): void {
106+
this.storageArea.setItem(this.key, this.serializer.stringify(state));
107+
}
108+
109+
delete(): void {
110+
this.storageArea.removeItem(this.key);
111+
}
112+
113+
subscribe(listener: (state: ExecutorState | null) => void): () => void {
114+
if (pubSub.listenerCount === 0) {
115+
window.addEventListener('storage', handleStorage);
116+
}
117+
118+
const unsubscribe = pubSub.subscribe(event => {
119+
if (event.storageArea === this.storageArea && event.key === this.key) {
120+
listener(this.get());
121+
}
122+
});
123+
124+
return () => {
125+
unsubscribe();
126+
127+
if (pubSub.listenerCount === 0) {
128+
window.removeEventListener('storage', handleStorage);
129+
}
130+
};
131+
}
132+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { ExecutorPlugin, ExecutorState, Observable, PluginConfiguredPayload } from '../types.js';
2+
import { isObjectLike, isShallowEqual } from '../utils.js';
3+
import type { ExecutorImpl } from '../ExecutorImpl.js';
4+
5+
export interface ExternalStore<Value> extends Observable<Value | null> {
6+
get(): Value | null;
7+
8+
set?(value: Value): void;
9+
10+
delete?(): void;
11+
}
12+
13+
export default function syncExternalStore<Value>(store: ExternalStore<ExecutorState<Value>>): ExecutorPlugin<Value> {
14+
return executor => {
15+
const setExternalState = () => store.set?.(executor.getStateSnapshot());
16+
17+
const setExecutorState = (nextState: ExecutorState | null) => {
18+
if (executor.isPending) {
19+
// The executor would overwrite external state when settled
20+
return;
21+
}
22+
23+
const prevState = executor.getStateSnapshot();
24+
25+
if (
26+
// No external state
27+
nextState === null ||
28+
// Invalid external state
29+
!isExecutorState(nextState) ||
30+
// External state is outdated
31+
(nextState.settledAt !== 0 && nextState.settledAt < prevState.settledAt)
32+
) {
33+
setExternalState();
34+
return;
35+
}
36+
37+
publishChanges(executor as ExecutorImpl, prevState, nextState);
38+
};
39+
40+
const unsubscribe = store.subscribe(setExecutorState);
41+
42+
setExecutorState(store.get());
43+
44+
executor.subscribe(event => {
45+
switch (event.type) {
46+
case 'cleared':
47+
case 'fulfilled':
48+
case 'rejected':
49+
case 'invalidated':
50+
case 'annotated':
51+
setExternalState();
52+
break;
53+
54+
case 'aborted':
55+
setExecutorState(store.get());
56+
break;
57+
58+
case 'detached':
59+
store.delete?.();
60+
unsubscribe();
61+
break;
62+
}
63+
});
64+
65+
executor.publish({
66+
type: 'plugin_configured',
67+
payload: { type: 'syncExternalStore', options: { store } } satisfies PluginConfiguredPayload,
68+
});
69+
};
70+
}
71+
72+
function publishChanges(executor: ExecutorImpl, prevState: ExecutorState, nextState: ExecutorState): void {
73+
// Update the executor with the external state
74+
executor.value = nextState.value;
75+
executor.reason = nextState.reason;
76+
executor.annotations = nextState.annotations;
77+
executor.settledAt = nextState.settledAt;
78+
executor.invalidatedAt = nextState.invalidatedAt;
79+
executor.isFulfilled = nextState.isFulfilled;
80+
executor.version++;
81+
82+
if (!isShallowEqual(nextState.annotations, prevState.annotations)) {
83+
// Annotations have changed
84+
executor.publish({ type: 'annotated' });
85+
}
86+
87+
// The executor was resolved, rejected or cleared
88+
if (nextState.isFulfilled) {
89+
executor.publish({ type: 'fulfilled' });
90+
} else if (nextState.settledAt !== 0) {
91+
executor.publish({ type: 'rejected' });
92+
} else if (prevState.settledAt !== 0) {
93+
executor.publish({ type: 'cleared' });
94+
}
95+
96+
if (nextState.invalidatedAt !== 0 && prevState.invalidatedAt === 0) {
97+
// The executor was invalidated
98+
executor.publish({ type: 'invalidated' });
99+
}
100+
}
101+
102+
function isExecutorState(state: ExecutorState | null): state is ExecutorState {
103+
return (
104+
isObjectLike(state) &&
105+
isObjectLike(state.annotations) &&
106+
typeof state.settledAt === 'number' &&
107+
typeof state.invalidatedAt === 'number' &&
108+
typeof state.isFulfilled === 'boolean'
109+
);
110+
}

0 commit comments

Comments
 (0)