@storve/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/benchmarks/run.ts +102 -0
- package/benchmarks/week2.md +9 -0
- package/benchmarks/week2.ts +64 -0
- package/benchmarks/week4.md +13 -0
- package/benchmarks/week4.ts +178 -0
- package/benchmarks/week5.md +15 -0
- package/benchmarks/week5.ts +184 -0
- package/coverage/coverage-summary.json +31 -0
- package/dist/adapters/indexedDB.cjs +2 -0
- package/dist/adapters/indexedDB.cjs.map +1 -0
- package/dist/adapters/indexedDB.mjs +2 -0
- package/dist/adapters/indexedDB.mjs.map +1 -0
- package/dist/adapters/localStorage.cjs +2 -0
- package/dist/adapters/localStorage.cjs.map +1 -0
- package/dist/adapters/localStorage.mjs +2 -0
- package/dist/adapters/localStorage.mjs.map +1 -0
- package/dist/adapters/memory.cjs +2 -0
- package/dist/adapters/memory.cjs.map +1 -0
- package/dist/adapters/memory.mjs +2 -0
- package/dist/adapters/memory.mjs.map +1 -0
- package/dist/adapters/sessionStorage.cjs +2 -0
- package/dist/adapters/sessionStorage.cjs.map +1 -0
- package/dist/adapters/sessionStorage.mjs +2 -0
- package/dist/adapters/sessionStorage.mjs.map +1 -0
- package/dist/async-entry.d.ts +7 -0
- package/dist/async-entry.d.ts.map +1 -0
- package/dist/async.cjs +2 -0
- package/dist/async.cjs.map +1 -0
- package/dist/async.d.ts +52 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.mjs +2 -0
- package/dist/async.mjs.map +1 -0
- package/dist/batch.d.ts +12 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/computed-entry.d.ts +7 -0
- package/dist/computed-entry.d.ts.map +1 -0
- package/dist/computed.cjs +2 -0
- package/dist/computed.cjs.map +1 -0
- package/dist/computed.d.ts +56 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/computed.mjs +2 -0
- package/dist/computed.mjs.map +1 -0
- package/dist/devtools/history.d.ts +51 -0
- package/dist/devtools/history.d.ts.map +1 -0
- package/dist/devtools/index.d.ts +5 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/redux-bridge.d.ts +21 -0
- package/dist/devtools/redux-bridge.d.ts.map +1 -0
- package/dist/devtools/snapshots.d.ts +32 -0
- package/dist/devtools/snapshots.d.ts.map +1 -0
- package/dist/devtools/withDevtools.d.ts +17 -0
- package/dist/devtools/withDevtools.d.ts.map +1 -0
- package/dist/devtools.cjs +2 -0
- package/dist/devtools.cjs.map +1 -0
- package/dist/devtools.mjs +2 -0
- package/dist/devtools.mjs.map +1 -0
- package/dist/extensions/noop.d.ts +2 -0
- package/dist/extensions/noop.d.ts.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.js +118 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +116 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/persist/adapters/indexedDB.d.ts +12 -0
- package/dist/persist/adapters/indexedDB.d.ts.map +1 -0
- package/dist/persist/adapters/localStorage.d.ts +11 -0
- package/dist/persist/adapters/localStorage.d.ts.map +1 -0
- package/dist/persist/adapters/memory.d.ts +11 -0
- package/dist/persist/adapters/memory.d.ts.map +1 -0
- package/dist/persist/adapters/sessionStorage.d.ts +11 -0
- package/dist/persist/adapters/sessionStorage.d.ts.map +1 -0
- package/dist/persist/debounce.d.ts +12 -0
- package/dist/persist/debounce.d.ts.map +1 -0
- package/dist/persist/hydrate.d.ts +15 -0
- package/dist/persist/hydrate.d.ts.map +1 -0
- package/dist/persist/index.d.ts +34 -0
- package/dist/persist/index.d.ts.map +1 -0
- package/dist/persist/serialize.d.ts +28 -0
- package/dist/persist/serialize.d.ts.map +1 -0
- package/dist/persist.cjs +2 -0
- package/dist/persist.cjs.map +1 -0
- package/dist/persist.mjs +2 -0
- package/dist/persist.mjs.map +1 -0
- package/dist/proxy.d.ts +2 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/registry-D3X0HSbl.js +26 -0
- package/dist/registry-D3X0HSbl.js.map +1 -0
- package/dist/registry-RDjbeJdx.js +29 -0
- package/dist/registry-RDjbeJdx.js.map +1 -0
- package/dist/registry-qtr1UpFU.js +2 -0
- package/dist/registry-qtr1UpFU.js.map +1 -0
- package/dist/registry-zaKZ1P-s.js +2 -0
- package/dist/registry-zaKZ1P-s.js.map +1 -0
- package/dist/registry.d.ts +54 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/signals/createSignal.d.ts +19 -0
- package/dist/signals/createSignal.d.ts.map +1 -0
- package/dist/signals/index.d.ts +20 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/useSignal.d.ts +11 -0
- package/dist/signals/useSignal.d.ts.map +1 -0
- package/dist/signals.cjs +2 -0
- package/dist/signals.cjs.map +1 -0
- package/dist/signals.mjs +2 -0
- package/dist/signals.mjs.map +1 -0
- package/dist/stats.html +4949 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/sync/channel.d.ts +7 -0
- package/dist/sync/channel.d.ts.map +1 -0
- package/dist/sync/index.d.ts +3 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/protocol.d.ts +22 -0
- package/dist/sync/protocol.d.ts.map +1 -0
- package/dist/sync/withSync.d.ts +17 -0
- package/dist/sync/withSync.d.ts.map +1 -0
- package/dist/sync.cjs +2 -0
- package/dist/sync.cjs.map +1 -0
- package/dist/sync.mjs +2 -0
- package/dist/sync.mjs.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +91 -0
- package/rollup.config.mjs +44 -0
- package/src/async-entry.ts +6 -0
- package/src/async.ts +240 -0
- package/src/batch.ts +33 -0
- package/src/compose.ts +50 -0
- package/src/computed-entry.ts +6 -0
- package/src/computed.ts +187 -0
- package/src/devtools/history.ts +103 -0
- package/src/devtools/index.ts +5 -0
- package/src/devtools/redux-bridge.ts +70 -0
- package/src/devtools/snapshots.ts +54 -0
- package/src/devtools/withDevtools.ts +196 -0
- package/src/extensions/noop.ts +12 -0
- package/src/index.ts +4 -0
- package/src/persist/adapters/indexedDB.ts +114 -0
- package/src/persist/adapters/localStorage.ts +28 -0
- package/src/persist/adapters/memory.ts +26 -0
- package/src/persist/adapters/sessionStorage.ts +28 -0
- package/src/persist/debounce.ts +28 -0
- package/src/persist/hydrate.ts +60 -0
- package/src/persist/index.ts +141 -0
- package/src/persist/serialize.ts +60 -0
- package/src/proxy.ts +87 -0
- package/src/registry.ts +67 -0
- package/src/signals/createSignal.ts +81 -0
- package/src/signals/index.ts +20 -0
- package/src/signals/useSignal.ts +18 -0
- package/src/store.ts +250 -0
- package/src/sync/channel.ts +15 -0
- package/src/sync/index.ts +3 -0
- package/src/sync/protocol.ts +18 -0
- package/src/sync/withSync.ts +147 -0
- package/src/types.ts +159 -0
- package/tests/async.test.ts +1100 -0
- package/tests/batch.test.ts +41 -0
- package/tests/compose.test.ts +209 -0
- package/tests/computed.test.ts +867 -0
- package/tests/devtools.test.ts +1039 -0
- package/tests/integration/persist.integration.test.ts +258 -0
- package/tests/integration/signals.integration.test.ts +309 -0
- package/tests/integration.test.ts +278 -0
- package/tests/persist/adapters/indexedDB.adapter.test.ts +185 -0
- package/tests/persist/adapters/localStorage.adapter.test.ts +105 -0
- package/tests/persist/adapters/memory.adapter.test.ts +112 -0
- package/tests/persist/adapters/sessionStorage.adapter.test.ts +128 -0
- package/tests/persist/debounce.test.ts +121 -0
- package/tests/persist/hydrate.test.ts +120 -0
- package/tests/persist/migrate.test.ts +208 -0
- package/tests/persist/persist.test.ts +357 -0
- package/tests/persist/serialize.test.ts +128 -0
- package/tests/proxy.test.ts +473 -0
- package/tests/registry.test.ts +67 -0
- package/tests/signals/derived.test.ts +244 -0
- package/tests/signals/inference.test.ts +108 -0
- package/tests/signals/signal.test.ts +348 -0
- package/tests/signals/useSignal.test.tsx +275 -0
- package/tests/store.test.ts +482 -0
- package/tests/stress.test.ts +268 -0
- package/tests/sync.test.ts +576 -0
- package/tests/types.test.ts +32 -0
- package/tests/v0.3.test.ts +813 -0
- package/tree-shake-test.js +1 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +22 -0
- package/vitest_play.ts +7 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a new object with only the specified keys from state.
|
|
3
|
+
* If keys is undefine or empty, returns the full state.
|
|
4
|
+
*
|
|
5
|
+
* @template T - The state object type.
|
|
6
|
+
* @param {T} state - The complete state object.
|
|
7
|
+
* @param {Array<keyof T>} [keys] - The keys to pick.
|
|
8
|
+
* @returns {Partial<T> | T} A new object with picked keys, or the original state.
|
|
9
|
+
*/
|
|
10
|
+
export function pick<T extends object>(state: T, keys?: Array<keyof T>): Partial<T> | T {
|
|
11
|
+
if (keys === undefined || keys.length === 0) {
|
|
12
|
+
return { ...state }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const result: Partial<T> = {}
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < keys.length; i++) {
|
|
18
|
+
const key = keys[i]
|
|
19
|
+
if (key in state) {
|
|
20
|
+
result[key] = state[key]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Safely stringifies a value to JSON, throwing a clear error if it fails.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} value - The object or value to serialize.
|
|
31
|
+
* @returns {string} The JSON string representation.
|
|
32
|
+
* @throws {Error} Throws a detailed error if JSON.stringify fails.
|
|
33
|
+
*/
|
|
34
|
+
export function toJSON(value: unknown): string {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(value)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new Error(`[storve] Failed to serialize state to JSON: ${err instanceof Error ? err.message : String(err)}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Safely parses a JSON string, throwing a clear error if parsing fails or if the string is empty/null.
|
|
44
|
+
*
|
|
45
|
+
* @template T - The expected output type.
|
|
46
|
+
* @param {string} raw - The string to parse.
|
|
47
|
+
* @returns {T} The typed parsed JSON object.
|
|
48
|
+
* @throws {Error} Throws if 'raw' is empty or if JSON.parse fails.
|
|
49
|
+
*/
|
|
50
|
+
export function fromJSON<T>(raw: string): T {
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new Error('[storve] Cannot parse empty or null/undefined JSON string.')
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const parsed: T = JSON.parse(raw)
|
|
56
|
+
return parsed
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`[storve] Failed to parse JSON state: ${err instanceof Error ? err.message : String(err)}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const proxyMap = new WeakMap<object, object>();
|
|
2
|
+
const rawMap = new WeakMap<object, object>();
|
|
3
|
+
|
|
4
|
+
let isBatching = false;
|
|
5
|
+
|
|
6
|
+
function isPlainObjectOrArray(value: unknown): value is object {
|
|
7
|
+
if (value === null || typeof value !== 'object') return false;
|
|
8
|
+
const proto = Object.getPrototypeOf(value);
|
|
9
|
+
return proto === Object.prototype || Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createStateProxy<T extends object>(state: T, onChange: () => void): T {
|
|
13
|
+
if (!isPlainObjectOrArray(state)) {
|
|
14
|
+
return state;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (proxyMap.has(state)) {
|
|
18
|
+
return proxyMap.get(state) as T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (rawMap.has(state)) {
|
|
22
|
+
return state; // It's already a proxy
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handler: ProxyHandler<T> = {
|
|
26
|
+
get(target, prop, receiver) {
|
|
27
|
+
if (Array.isArray(target) && typeof prop === 'string' && ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].includes(prop)) {
|
|
28
|
+
return (...args: unknown[]) => {
|
|
29
|
+
const prevBatching = isBatching;
|
|
30
|
+
isBatching = true;
|
|
31
|
+
|
|
32
|
+
const method = Reflect.get(target, prop, receiver) as (...a: unknown[]) => unknown;
|
|
33
|
+
const result = Reflect.apply(method, receiver, args);
|
|
34
|
+
|
|
35
|
+
isBatching = prevBatching;
|
|
36
|
+
if (!isBatching) {
|
|
37
|
+
onChange();
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const value = Reflect.get(target, prop, receiver);
|
|
44
|
+
if (isPlainObjectOrArray(value) && proxyMap.has(value)) {
|
|
45
|
+
return proxyMap.get(value);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
},
|
|
49
|
+
set(target, prop, value, receiver) {
|
|
50
|
+
// Unpack if value is a proxy itself
|
|
51
|
+
const rawValue = rawMap.has(value as object) ? rawMap.get(value as object) : value;
|
|
52
|
+
|
|
53
|
+
// Immediately wrap new nested objects
|
|
54
|
+
if (isPlainObjectOrArray(rawValue)) {
|
|
55
|
+
createStateProxy(rawValue as object, onChange);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = Reflect.set(target, prop, rawValue, receiver);
|
|
59
|
+
|
|
60
|
+
// Trigger listeners on write
|
|
61
|
+
if (!isBatching) onChange();
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
},
|
|
65
|
+
deleteProperty(target, prop) {
|
|
66
|
+
const result = Reflect.deleteProperty(target, prop);
|
|
67
|
+
if (!isBatching) onChange();
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const proxy = new Proxy(state, handler);
|
|
73
|
+
proxyMap.set(state, proxy);
|
|
74
|
+
rawMap.set(proxy, state);
|
|
75
|
+
|
|
76
|
+
// Recursively proxy existing nested objects upfront
|
|
77
|
+
for (const key in state) {
|
|
78
|
+
if (Object.prototype.hasOwnProperty.call(state, key)) {
|
|
79
|
+
const val = state[(key as keyof typeof state)];
|
|
80
|
+
if (isPlainObjectOrArray(val)) {
|
|
81
|
+
createStateProxy(val as object, onChange);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return proxy;
|
|
87
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Store } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extension registry for store plugins.
|
|
5
|
+
* Features register when imported; createStore applies all registered extensions.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ProcessDefinitionResult {
|
|
10
|
+
state: Record<string, unknown>;
|
|
11
|
+
engines?: Map<string, unknown>;
|
|
12
|
+
/** Async keys to init after store has setState. Each init receives (onUpdate) => engine. */
|
|
13
|
+
asyncInits?: Array<{ key: string; init: (onUpdate: (state: unknown) => void) => unknown }>;
|
|
14
|
+
readOnlyKeys?: Set<string>;
|
|
15
|
+
/** Called when state changes. Extensions can recompute derived values via setComputed. */
|
|
16
|
+
onStateChanged?: (ctx: {
|
|
17
|
+
changedKeys: Set<string>;
|
|
18
|
+
getState: () => Record<string, unknown>;
|
|
19
|
+
setComputed: (key: string, value: unknown) => void;
|
|
20
|
+
store: unknown;
|
|
21
|
+
}) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExtensionContext {
|
|
25
|
+
engines: Map<string, unknown>;
|
|
26
|
+
store: Store<object>;
|
|
27
|
+
definition: object;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export interface StoreExtension {
|
|
33
|
+
/** Unique key to avoid double registration */
|
|
34
|
+
key: string;
|
|
35
|
+
/** Process definition values before store init. Return modified state + optional metadata. */
|
|
36
|
+
processDefinition?: (definition: Record<string, unknown>) => ProcessDefinitionResult;
|
|
37
|
+
/** Add methods to the store. Called after store is created. */
|
|
38
|
+
extendStore?: (context: ExtensionContext) => Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const extensions: StoreExtension[] = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register an extension. Called by feature modules on import (side-effect).
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
export function registerExtension(ext: StoreExtension & { order?: number }): void {
|
|
48
|
+
if (extensions.some((e) => e.key === ext.key)) return;
|
|
49
|
+
extensions.push(ext);
|
|
50
|
+
extensions.sort((a, b) => ((a as { order?: number }).order ?? 99) - ((b as { order?: number }).order ?? 99));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get all registered extensions. Used by createStore.
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export function getExtensions(): readonly StoreExtension[] {
|
|
58
|
+
return extensions;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Clear all extensions. For testing only.
|
|
63
|
+
* @internal
|
|
64
|
+
*/
|
|
65
|
+
export function __testingOnlyClearExtensions(): void {
|
|
66
|
+
extensions.length = 0
|
|
67
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Store, StoreState } from '../types';
|
|
2
|
+
import type { Signal } from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Signal that subscribes to a specific key in a Storve store.
|
|
6
|
+
* Signals provide fine-grained reactivity by only notifying listeners when
|
|
7
|
+
* the specific key's value changes.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const countSignal = signal(store, 'count');
|
|
11
|
+
*/
|
|
12
|
+
export function signal<D extends object, K extends keyof StoreState<D>>(
|
|
13
|
+
store: Store<D>,
|
|
14
|
+
key: K
|
|
15
|
+
): Signal<StoreState<D>[K]>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a derived read-only Signal that transforms a value from the store.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const doubleSignal = signal(store, 'count', v => v * 2);
|
|
22
|
+
*/
|
|
23
|
+
export function signal<D extends object, K extends keyof StoreState<D>, R>(
|
|
24
|
+
store: Store<D>,
|
|
25
|
+
key: K,
|
|
26
|
+
transform: (value: StoreState<D>[K]) => R
|
|
27
|
+
): Signal<R>;
|
|
28
|
+
|
|
29
|
+
export function signal<D extends object, K extends keyof StoreState<D>, R>(
|
|
30
|
+
store: Store<D>,
|
|
31
|
+
key: K,
|
|
32
|
+
transform?: (value: StoreState<D>[K]) => R
|
|
33
|
+
): Signal<R | StoreState<D>[K]> {
|
|
34
|
+
const isDerived = !!transform;
|
|
35
|
+
|
|
36
|
+
const get = () => {
|
|
37
|
+
const value = store.getState()[key];
|
|
38
|
+
return transform ? transform(value) : value;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const signalInstance = {
|
|
42
|
+
get,
|
|
43
|
+
set(value: StoreState<D>[K] | ((prev: StoreState<D>[K]) => StoreState<D>[K])) {
|
|
44
|
+
if (isDerived) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'Storve: cannot call set() on a derived signal. Derived signals are read-only.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const next =
|
|
50
|
+
typeof value === 'function'
|
|
51
|
+
? (value as (prev: StoreState<D>[K]) => StoreState<D>[K])(store.getState()[key])
|
|
52
|
+
: value;
|
|
53
|
+
store.setState({ [key]: next } as Partial<StoreState<D>>);
|
|
54
|
+
},
|
|
55
|
+
subscribe(listener: (value: R | StoreState<D>[K]) => void) {
|
|
56
|
+
let prev = transform
|
|
57
|
+
? transform(store.getState()[key])
|
|
58
|
+
: (store.getState()[key] as R | StoreState<D>[K]);
|
|
59
|
+
|
|
60
|
+
return store.subscribe(() => {
|
|
61
|
+
const next = transform
|
|
62
|
+
? transform(store.getState()[key])
|
|
63
|
+
: (store.getState()[key] as R | StoreState<D>[K]);
|
|
64
|
+
|
|
65
|
+
if (Object.is(prev, next)) return;
|
|
66
|
+
prev = next;
|
|
67
|
+
listener(next);
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
_derived: isDerived,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return new Proxy(signalInstance, {
|
|
74
|
+
set(target, prop, value) {
|
|
75
|
+
if (prop === '_derived') {
|
|
76
|
+
return true; // Silently ignore writes to _derived
|
|
77
|
+
}
|
|
78
|
+
return Reflect.set(target, prop, value);
|
|
79
|
+
},
|
|
80
|
+
}) as Signal<R | StoreState<D>[K]>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A subscribable reference to a single key in a Storve store.
|
|
3
|
+
* Read-only signals (derived) throw a clear error when set() is called.
|
|
4
|
+
*/
|
|
5
|
+
export interface Signal<T> {
|
|
6
|
+
/** Returns the current value of this signal */
|
|
7
|
+
get(): T;
|
|
8
|
+
/**
|
|
9
|
+
* Sets a new value. Throws if called on a derived (read-only) signal.
|
|
10
|
+
* Writes back to the store — the store remains the single source of truth.
|
|
11
|
+
*/
|
|
12
|
+
set(value: T | ((prev: T) => T)): void;
|
|
13
|
+
/** Subscribe to value changes. Returns an unsubscribe function. */
|
|
14
|
+
subscribe(listener: (value: T) => void): () => void;
|
|
15
|
+
/** Internal flag — true if this is a derived read-only signal */
|
|
16
|
+
readonly _derived: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { signal } from './createSignal';
|
|
20
|
+
export { useSignal } from './useSignal';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import type { Signal } from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React hook that subscribes to a Signal and returns its current value.
|
|
6
|
+
* The component re-renders ONLY when this signal's value changes.
|
|
7
|
+
* Unrelated store key changes are completely ignored.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const count = useSignal(countSignal) // re-renders only when count changes
|
|
11
|
+
*/
|
|
12
|
+
export function useSignal<T>(signal: Signal<T>): T {
|
|
13
|
+
return useSyncExternalStore(
|
|
14
|
+
(onStoreChange: () => void) => signal.subscribe(onStoreChange),
|
|
15
|
+
() => signal.get(),
|
|
16
|
+
() => signal.get()
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Store, StoreDefinition, Listener, StoreOptions, StoreState, StoreActions } from './types'
|
|
2
|
+
import { createStateProxy } from './proxy'
|
|
3
|
+
import { produce } from 'immer'
|
|
4
|
+
import { isBatching, subscribeToBatch } from './batch'
|
|
5
|
+
import { getExtensions } from './registry'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a reactive store with auto-tracking features via Proxies.
|
|
9
|
+
* Any mutations to the state via setState or directly to deep objects will notify subscribers.
|
|
10
|
+
* Extensions (async, computed) register when their modules are imported and extend the store.
|
|
11
|
+
*
|
|
12
|
+
* @param definition - The initial state object including optional actions.
|
|
13
|
+
* @param options - Configuration options for the store (e.g., immer).
|
|
14
|
+
* @returns A generic store instance with getState, setState, subscribe, batch, and actions.
|
|
15
|
+
*/
|
|
16
|
+
export function createStore<D extends object>(
|
|
17
|
+
definition: StoreDefinition<D>,
|
|
18
|
+
options: StoreOptions = {}
|
|
19
|
+
): Store<D> {
|
|
20
|
+
const { actions: rawActions = {}, ...initialData } =
|
|
21
|
+
definition as D & { actions?: Record<string, (...args: unknown[]) => unknown> }
|
|
22
|
+
|
|
23
|
+
let workingData: Record<string, unknown> = { ...initialData }
|
|
24
|
+
const allAsyncInits: Array<{ key: string; init: (onUpdate: (state: unknown) => void) => unknown }> = []
|
|
25
|
+
const readOnlyKeys = new Set<string>()
|
|
26
|
+
const onStateChangedCallbacks: Array<(ctx: {
|
|
27
|
+
changedKeys: Set<string>;
|
|
28
|
+
getState: () => Record<string, unknown>;
|
|
29
|
+
setComputed: (key: string, value: unknown) => void;
|
|
30
|
+
store: Store<D>;
|
|
31
|
+
}) => void> = []
|
|
32
|
+
|
|
33
|
+
// Run extension pipeline
|
|
34
|
+
for (const ext of getExtensions()) {
|
|
35
|
+
if (ext.processDefinition) {
|
|
36
|
+
const result = ext.processDefinition(workingData)
|
|
37
|
+
workingData = { ...workingData, ...result.state }
|
|
38
|
+
if (result.asyncInits) allAsyncInits.push(...result.asyncInits)
|
|
39
|
+
if (result.readOnlyKeys) result.readOnlyKeys.forEach((k) => readOnlyKeys.add(k))
|
|
40
|
+
if (result.onStateChanged) onStateChangedCallbacks.push(result.onStateChanged)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Run async inits — use ref so callback can call setState before store is assigned
|
|
45
|
+
const setStateRef: { current: ((p: Partial<StoreState<D>>) => void) | null } = { current: null }
|
|
46
|
+
const engines = new Map<string, unknown>()
|
|
47
|
+
for (const { key, init } of allAsyncInits) {
|
|
48
|
+
const engine = init((nodeState) => {
|
|
49
|
+
setStateRef.current?.({ [key]: nodeState } as Partial<StoreState<D>>)
|
|
50
|
+
})
|
|
51
|
+
engines.set(key, engine)
|
|
52
|
+
workingData[key] = (engine as { getState: () => unknown }).getState()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const initialState = workingData as StoreState<D>
|
|
56
|
+
const listeners = new Set<Listener<StoreState<D>>>()
|
|
57
|
+
let currentState = initialState as StoreState<D>
|
|
58
|
+
let batchCount = 0
|
|
59
|
+
let batchDirty = false
|
|
60
|
+
let unsubscribeBatch: (() => void) | null = null
|
|
61
|
+
let pendingChangedKeys = new Set<string>()
|
|
62
|
+
let lastSnapshot: StoreState<D> | null = null
|
|
63
|
+
let lastSnapshotState: StoreState<D> | null = null
|
|
64
|
+
|
|
65
|
+
const notify = () => {
|
|
66
|
+
if (batchCount > 0 || isBatching()) {
|
|
67
|
+
batchDirty = true
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
batchDirty = false
|
|
71
|
+
listeners.forEach((listener) => listener(currentState))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runOnStateChanged = (changedKeys: Set<string>) => {
|
|
75
|
+
const setComputed = (key: string, value: unknown) => {
|
|
76
|
+
(currentState as Record<string, unknown>)[key] = value
|
|
77
|
+
}
|
|
78
|
+
for (const cb of onStateChangedCallbacks) {
|
|
79
|
+
cb({
|
|
80
|
+
changedKeys,
|
|
81
|
+
getState: () => currentState as Record<string, unknown>,
|
|
82
|
+
setComputed,
|
|
83
|
+
store,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const proxyState = createStateProxy(initialState, notify)
|
|
89
|
+
|
|
90
|
+
const setState = (
|
|
91
|
+
updater:
|
|
92
|
+
| Partial<StoreState<D>>
|
|
93
|
+
| ((s: StoreState<D>) => Partial<StoreState<D>>)
|
|
94
|
+
| ((draft: StoreState<D>) => void)
|
|
95
|
+
) => {
|
|
96
|
+
let nextState: StoreState<D>
|
|
97
|
+
|
|
98
|
+
if (typeof updater === 'function') {
|
|
99
|
+
if (options.immer) {
|
|
100
|
+
nextState = produce(currentState, updater as (draft: StoreState<D>) => void) as StoreState<D>
|
|
101
|
+
} else {
|
|
102
|
+
nextState = {
|
|
103
|
+
...currentState,
|
|
104
|
+
...(updater as (s: StoreState<D>) => Partial<StoreState<D>>)(currentState),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
nextState = { ...currentState, ...updater }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (nextState === currentState) return
|
|
112
|
+
|
|
113
|
+
const writableNext = { ...nextState } as Record<string, unknown>
|
|
114
|
+
readOnlyKeys.forEach((k) => delete writableNext[k])
|
|
115
|
+
const prevState = currentState
|
|
116
|
+
const updatedKeys = new Set(
|
|
117
|
+
Object.keys(writableNext).filter(
|
|
118
|
+
(k) => (prevState as Record<string, unknown>)[k] !== writableNext[k]
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
// Notify even if no keys changed, because signals or other extensions might care about
|
|
122
|
+
// derived changes or reference-based equality in transforms.
|
|
123
|
+
// if (updatedKeys.size === 0) return
|
|
124
|
+
|
|
125
|
+
currentState = { ...currentState, ...writableNext } as StoreState<D>
|
|
126
|
+
|
|
127
|
+
if (batchCount > 0 || isBatching()) {
|
|
128
|
+
updatedKeys.forEach((k) => pendingChangedKeys.add(k))
|
|
129
|
+
batchDirty = true
|
|
130
|
+
} else {
|
|
131
|
+
runOnStateChanged(updatedKeys)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lastSnapshot = null
|
|
135
|
+
lastSnapshotState = null
|
|
136
|
+
|
|
137
|
+
batchCount++
|
|
138
|
+
try {
|
|
139
|
+
for (const key in currentState) {
|
|
140
|
+
if (
|
|
141
|
+
Object.prototype.hasOwnProperty.call(currentState, key) &&
|
|
142
|
+
(currentState as Record<string, unknown>)[key] !==
|
|
143
|
+
(prevState as Record<string, unknown>)[key]
|
|
144
|
+
) {
|
|
145
|
+
(proxyState as Record<string, unknown>)[key] =
|
|
146
|
+
currentState[key as keyof StoreState<D>]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
batchCount--
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (batchCount > 0) {
|
|
154
|
+
batchDirty = true
|
|
155
|
+
} else {
|
|
156
|
+
notify()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setStateRef.current = setState
|
|
161
|
+
|
|
162
|
+
const store = {
|
|
163
|
+
getState: () => {
|
|
164
|
+
if (lastSnapshot !== null && lastSnapshotState === currentState) {
|
|
165
|
+
return lastSnapshot
|
|
166
|
+
}
|
|
167
|
+
const snapshot = { ...currentState } as StoreState<D>
|
|
168
|
+
lastSnapshot = snapshot
|
|
169
|
+
lastSnapshotState = currentState
|
|
170
|
+
return snapshot
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
setState,
|
|
174
|
+
|
|
175
|
+
subscribe: (listener: Listener<StoreState<D>>) => {
|
|
176
|
+
listeners.add(listener)
|
|
177
|
+
if (listeners.size === 1) {
|
|
178
|
+
unsubscribeBatch = subscribeToBatch(() => {
|
|
179
|
+
if (batchDirty) {
|
|
180
|
+
batchDirty = false
|
|
181
|
+
runOnStateChanged(pendingChangedKeys)
|
|
182
|
+
pendingChangedKeys = new Set()
|
|
183
|
+
notify()
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
if (batchDirty) {
|
|
187
|
+
batchDirty = false
|
|
188
|
+
runOnStateChanged(pendingChangedKeys)
|
|
189
|
+
pendingChangedKeys = new Set()
|
|
190
|
+
notify()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return () => {
|
|
194
|
+
listeners.delete(listener)
|
|
195
|
+
if (listeners.size === 0) {
|
|
196
|
+
unsubscribeBatch?.()
|
|
197
|
+
unsubscribeBatch = null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
batch: (fn: () => void) => {
|
|
203
|
+
batchCount++
|
|
204
|
+
try {
|
|
205
|
+
fn()
|
|
206
|
+
} finally {
|
|
207
|
+
batchCount--
|
|
208
|
+
if (batchCount === 0 && batchDirty) {
|
|
209
|
+
batchDirty = false
|
|
210
|
+
runOnStateChanged(pendingChangedKeys)
|
|
211
|
+
pendingChangedKeys = new Set()
|
|
212
|
+
notify()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
actions: {} as StoreActions<D>,
|
|
218
|
+
|
|
219
|
+
// Default async stubs — overwritten by async extension when engines exist
|
|
220
|
+
fetch: async (key: string) => {
|
|
221
|
+
throw new Error(`Storve: no async key "${key}" found in store. Import "storve/async" to use createAsync.`);
|
|
222
|
+
},
|
|
223
|
+
refetch: async () => {},
|
|
224
|
+
invalidate: () => {},
|
|
225
|
+
invalidateAll: () => {},
|
|
226
|
+
getAsyncState: () => undefined,
|
|
227
|
+
} as Store<D>
|
|
228
|
+
|
|
229
|
+
// Add methods from extensions (async overwrites stubs when engines exist)
|
|
230
|
+
for (const ext of getExtensions()) {
|
|
231
|
+
if (ext.extendStore) {
|
|
232
|
+
const methods = ext.extendStore({ engines, store: store as unknown as Store<object>, definition: definition as object })
|
|
233
|
+
Object.defineProperties(store, Object.getOwnPropertyDescriptors(methods))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Trigger initial onStateChanged for extensions (e.g. devtools init)
|
|
238
|
+
runOnStateChanged(new Set(Object.keys(currentState)))
|
|
239
|
+
|
|
240
|
+
type RawActionsType = Record<string, (...args: unknown[]) => unknown>
|
|
241
|
+
const boundActions = {} as StoreActions<D>
|
|
242
|
+
Object.keys(rawActions).forEach((key) => {
|
|
243
|
+
(boundActions as RawActionsType)[key] = (...args: unknown[]) =>
|
|
244
|
+
(rawActions as RawActionsType)[key](...args)
|
|
245
|
+
})
|
|
246
|
+
Object.assign(store, boundActions)
|
|
247
|
+
store.actions = boundActions
|
|
248
|
+
|
|
249
|
+
return store
|
|
250
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens a BroadcastChannel if available in the current environment.
|
|
3
|
+
* Gracefully returns null in SSR or older browsers.
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export function openChannel(name: string): BroadcastChannel | null {
|
|
7
|
+
if (typeof window === 'undefined') return null;
|
|
8
|
+
if (typeof BroadcastChannel === 'undefined') return null;
|
|
9
|
+
try {
|
|
10
|
+
return new BroadcastChannel(name);
|
|
11
|
+
} catch {
|
|
12
|
+
/* v8 ignore next 2 */
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Random tab ID generated once per tab session.
|
|
3
|
+
*/
|
|
4
|
+
export const tabId = (function () {
|
|
5
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
6
|
+
return crypto.randomUUID();
|
|
7
|
+
}
|
|
8
|
+
return Math.random().toString(36).substring(2, 11);
|
|
9
|
+
})();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Message types for cross-tab synchronization.
|
|
13
|
+
* @template S - The state type
|
|
14
|
+
*/
|
|
15
|
+
export type SyncMessage<S> =
|
|
16
|
+
| { type: 'STATE_UPDATE'; payload: Partial<S>; tabId: string }
|
|
17
|
+
| { type: 'REQUEST_STATE'; tabId: string }
|
|
18
|
+
| { type: 'PROVIDE_STATE'; payload: S; targetTabId: string; tabId: string };
|