Skip to content

Commit

Permalink
Use executor state in synchronizeStorage plugin (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski authored Apr 30, 2024
1 parent 220fc1e commit 7efbeb8
Show file tree
Hide file tree
Showing 12 changed files with 1,233 additions and 528 deletions.
606 changes: 455 additions & 151 deletions README.md

Large diffs are not rendered by default.

908 changes: 622 additions & 286 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,22 @@
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.6",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.4",
"@testing-library/react": "^15.0.5",
"@types/jest": "^29.5.12",
"@types/react": "^18.2.79",
"@types/react": "^18.3.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.2.5",
"rimraf": "^5.0.5",
"rollup": "^4.16.0",
"rollup": "^4.17.2",
"ts-jest": "^29.1.2",
"tslib": "^2.6.2",
"typedoc": "^0.25.13",
"typedoc-custom-css": "github:smikhalevski/typedoc-custom-css#master",
"typescript": "^5.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"dependencies": {
"parallel-universe": "^6.0.0"
Expand Down
14 changes: 9 additions & 5 deletions src/main/ExecutorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export class ExecutorManager implements Iterable<Executor> {
* @param initialValue The initial executor value.
* @param plugins The array of plugins that are applied to the newly created executor.
*/
getOrCreate<Value = any>(key: string, initialValue: undefined, plugins?: ExecutorPlugin<Value>[]): Executor<Value>;
getOrCreate<Value = any>(
key: string,
initialValue: undefined,
plugins?: Array<ExecutorPlugin<Value> | undefined | null>
): Executor<Value>;

/**
* Returns an existing executor or creates a new one.
Expand All @@ -63,10 +67,10 @@ export class ExecutorManager implements Iterable<Executor> {
getOrCreate<Value = any>(
key: string,
initialValue?: ExecutorTask<Value> | PromiseLike<Value> | Value,
plugins?: ExecutorPlugin<Value>[]
plugins?: Array<ExecutorPlugin<Value> | undefined | null>
): Executor<Value>;

getOrCreate(key: string, initialValue?: unknown, plugins?: ExecutorPlugin[]): Executor {
getOrCreate(key: string, initialValue?: unknown, plugins?: Array<ExecutorPlugin | undefined | null>): Executor {
let executor = this._executors.get(key);

if (executor !== undefined) {
Expand All @@ -77,9 +81,9 @@ export class ExecutorManager implements Iterable<Executor> {

this._initialStates.delete(key);

if (plugins !== undefined) {
if (plugins !== null && plugins !== undefined) {
for (const plugin of plugins) {
plugin(executor);
plugin?.(executor);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ export { useExecutor } from './useExecutor';
export { useExecutorManager, ExecutorManagerProvider } from './useExecutorManager';
export { useExecutorSuspense } from './useExecutorSuspense';

export type { Executor, ExecutorEvent, ExecutorPlugin, ExecutorTask } from './types';
export type { Executor, ExecutorEvent, ExecutorState, ExecutorPlugin, ExecutorTask } from './types';
4 changes: 2 additions & 2 deletions src/main/plugin/abortDeactivated.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* The plugin that aborts pending task after the timeout when the executor is deactivated.
* The plugin that aborts the pending task after the timeout if the executor is deactivated.
*
* ```ts
* import abortDeactivated from 'react-executor/plugin/abortDeactivated';
Expand All @@ -13,7 +13,7 @@
import type { ExecutorPlugin } from '../types';

/**
* Aborts pending task after the timeout when the executor is deactivated.
* Aborts the pending task after the timeout if the executor is deactivated.
*
* @param ms The timeout in milliseconds after which the task is aborted.
*/
Expand Down
101 changes: 50 additions & 51 deletions src/main/plugin/synchronizeStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,106 +12,110 @@
* @module plugin/synchronizeStorage
*/

import type { ExecutorPlugin } from '../types';
import { ExecutorImpl } from '../ExecutorImpl';
import type { ExecutorPlugin, ExecutorState } from '../types';

/**
* Serializes and deserializes values as a string.
* Serializes and deserializes values.
*
* @template Value The value to serialize.
*/
export interface Serializer<Value> {
serialize(record: Value): string;

deserialize(str: string): Value;
}

/**
* The record persisted in {@link Storage}.
*/
export interface StorageRecord<Value> {
/**
* The executor value, or `undefined` if the executor was cleared.
* Serializes a value as a string.
*
* @param value The value to serialize.
*/
value: Value | undefined;
stringify(value: Value): string;

/**
* The timestamp when the {@link value} was acquired.
* Deserializes a stringified value.
*
* @param valueStr The stringified value.
*/
timestamp: number;
}

export interface Storage {
getItem(key: string): string | null;

removeItem(key: string): void;

setItem(key: string, value: string): void;
parse(valueStr: string): Value;
}

/**
* Persists the executor value in the synchronous storage.
*
* Synchronization is enabled only for activated executors. If executor is disposed, corresponding item is removed from
* the storage.
* Synchronization is enabled only for activated executors. If executor is disposed, then the corresponding item is
* removed from the storage.
*
* @param storage The storage where executor value is persisted.
* @param storage The storage where executor value is persisted, usually a `localStorage` or a `sessionStorage`.
* @param serializer The storage record serializer.
* @template Value The value persisted in the storage.
*/
export default function synchronizeStorage<Value = any>(
storage: Storage,
serializer: Serializer<StorageRecord<Value>> = naturalSerializer
storage: Pick<Storage, 'setItem' | 'getItem' | 'removeItem'>,
serializer: Serializer<ExecutorState<Value>> = JSON
): ExecutorPlugin<Value> {
return executor => {
// The key corresponds to the executor state in the storage
const storageKey = 'executor/' + executor.key;

let latestStr: string | undefined | null;
let latestStateStr: string | undefined | null;

const receiveStr = (str: string | null) => {
let record;
const receiveState = (stateStr: string | null) => {
let state;

latestStr = str;
if (executor.isPending) {
return;
}

latestStateStr = stateStr;

if (str === null || (record = serializer.deserialize(str)).timestamp < executor.timestamp) {
storage.setItem(storageKey, serializer.serialize({ value: executor.value, timestamp: executor.timestamp }));
if (stateStr === null || (state = serializer.parse(stateStr)).timestamp < executor.timestamp) {
storage.setItem(storageKey, serializer.stringify(executor.toJSON()));
return;
}
if (record.timestamp === executor.timestamp) {
if (state.timestamp === executor.timestamp) {
return;
}
if (record.value === undefined) {
executor.clear();
if (executor instanceof ExecutorImpl) {
executor.value = state.value;
executor.reason = state.reason;
}
if (state.isFulfilled) {
executor.resolve(state.value!, state.timestamp);
} else if (state.isRejected) {
executor.reject(state.reason, state.timestamp);
} else {
executor.resolve(record.value, record.timestamp);
executor.clear();
}
if (state.isStale) {
executor.invalidate();
}
};

const handleStorage = (event: StorageEvent) => {
if (!executor.isPending && event.storageArea === storage && event.key === storageKey) {
receiveStr(event.newValue);
if (event.storageArea === storage && event.key === storageKey) {
receiveState(event.newValue);
}
};

receiveState(storage.getItem(storageKey));

executor.subscribe(event => {
switch (event.type) {
case 'activated':
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorage);
}
if (!executor.isPending) {
receiveStr(storage.getItem(storageKey));
}
receiveState(storage.getItem(storageKey));
break;

case 'cleared':
case 'fulfilled':
case 'rejected':
case 'invalidated':
if (!executor.isActive) {
break;
}
const str = serializer.serialize({ value: executor.value, timestamp: executor.timestamp });
const stateStr = serializer.stringify(executor.toJSON());

if (latestStr !== str) {
storage.setItem(storageKey, str);
if (latestStateStr !== stateStr) {
storage.setItem(storageKey, stateStr);
}
break;

Expand All @@ -128,8 +132,3 @@ export default function synchronizeStorage<Value = any>(
});
};
}

const naturalSerializer: Serializer<any> = {
serialize: value => JSON.stringify(value),
deserialize: str => JSON.parse(str),
};
6 changes: 3 additions & 3 deletions src/main/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export type ExecutorTask<Value = any> = (signal: AbortSignal, executor: Executor
*/
export interface ExecutorState<Value = any> {
/**
* The key of this executor, unique in scope of the {@link manager}.
* The key of this executor, unique in scope of the {@link Executor.manager}.
*/
readonly key: string;

Expand All @@ -150,8 +150,8 @@ export interface ExecutorState<Value = any> {
readonly isRejected: boolean;

/**
* `true` if {@link invalidate} was called on a {@link isSettled settled} executor and a new settlement hasn't
* occurred yet.
* `true` if {@link Executor.invalidate} was called on a {@link Executor.isSettled settled} executor and a new
* settlement hasn't occurred yet.
*/
readonly isStale: boolean;

Expand Down
16 changes: 11 additions & 5 deletions src/main/useExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useReducer } from 'react';
import { useDebugValue, useEffect, useReducer } from 'react';
import type { Executor, ExecutorPlugin, ExecutorTask } from './types';
import { useExecutorManager } from './useExecutorManager';

Expand All @@ -19,7 +19,7 @@ import { useExecutorManager } from './useExecutorManager';
export function useExecutor<Value = any>(
key: string,
initialValue: undefined,
plugins?: ExecutorPlugin<Value>[]
plugins?: Array<ExecutorPlugin<Value> | undefined | null>
): Executor<Value>;

/**
Expand All @@ -39,7 +39,7 @@ export function useExecutor<Value = any>(
export function useExecutor<Value = any>(
key: string,
initialValue?: ExecutorTask<Value> | PromiseLike<Value> | Value,
plugins?: ExecutorPlugin<Value>[]
plugins?: Array<ExecutorPlugin<Value> | undefined | null>
): Executor<Value>;

/**
Expand All @@ -55,13 +55,15 @@ export function useExecutor<Value>(executor: Executor<Value>): Executor<Value>;
export function useExecutor(
keyOrExecutor: string | Executor,
initialValue?: unknown,
plugins?: ExecutorPlugin[]
plugins?: Array<ExecutorPlugin | undefined | null>
): Executor {
const [, rerender] = useReducer(reduceCount, 0);
const manager = useExecutorManager();
const executor =
typeof keyOrExecutor === 'string' ? manager.getOrCreate(keyOrExecutor, initialValue, plugins) : keyOrExecutor;

useDebugValue(executor, toJSON);

useEffect(() => {
const deactivate = executor.activate();
const unsubscribe = executor.subscribe(rerender);
Expand All @@ -75,6 +77,10 @@ export function useExecutor(
return executor;
}

function reduceCount(count: number): number {
function reduceCount(count: number) {
return count + 1;
}

function toJSON(executor: Executor) {
return executor.toJSON();
}
Loading

0 comments on commit 7efbeb8

Please sign in to comment.