Skip to content

Commit 321d16f

Browse files
committed
Added syncExternalStore draft
1 parent 344efbe commit 321d16f

File tree

9 files changed

+828
-117
lines changed

9 files changed

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

src/main/utils.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,6 @@ export function isShallowEqual(a: any, b: any): boolean {
4242
return true;
4343
}
4444

45-
export function throwUnhandled(error: unknown): void {
46-
setTimeout(() => {
47-
throw error;
48-
}, 0);
49-
}
50-
5145
/**
5246
* Returns the observable that inverses boolean values emitted by another observable.
5347
*

src/test/ExecutorImpl.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { ExecutorImpl } from '../main/ExecutorImpl.js';
44
import { AbortError, noop } from '../main/utils.js';
55
import { ExecutorEvent, ExecutorState } from '../main/index.js';
66

7-
Date.now = () => 50;
7+
vi.useFakeTimers();
8+
vi.setSystemTime(50);
89

910
const expectedReason = new Error('expected');
1011

src/test/ExecutorManager.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, Mock, test, vi } from 'vitest';
22
import { ExecutorManager } from '../main/index.js';
33
import { ExecutorImpl } from '../main/ExecutorImpl.js';
44

5-
Date.now = () => 50;
5+
vi.useFakeTimers();
6+
vi.setSystemTime(50);
67

78
let listenerMock: Mock;
89
let manager: ExecutorManager;

0 commit comments

Comments
 (0)