floppy-disk 3.0.0-alpha.3 → 3.0.0-alpha.5

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.
@@ -89,7 +89,7 @@ export type MutationOptions<TData, TVariable> = InitStoreOptions<MutationState<T
89
89
  * const { isPending } = useCreateUser();
90
90
  * const result = await useCreateUser.execute({ name: 'John' });
91
91
  */
92
- export declare const createMutation: <TData, TVariable = undefined>(mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable>) => Promise<TData>, options?: MutationOptions<TData, TVariable>) => (<TStateSlice = MutationState<TData, TVariable>>(selector?: (state: MutationState<TData, TVariable>) => TStateSlice) => TStateSlice) & {
92
+ export declare const createMutation: <TData, TVariable = undefined>(mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable>) => Promise<TData>, options?: MutationOptions<TData, TVariable>) => (() => MutationState<TData, TVariable>) & {
93
93
  subscribe: (subscriber: import("../vanilla.d.mts").Subscriber<MutationState<TData, TVariable>>) => () => void;
94
94
  getSubscribers: () => Set<import("../vanilla.d.mts").Subscriber<MutationState<TData, TVariable>>>;
95
95
  getState: () => MutationState<TData, TVariable>;
@@ -67,9 +67,9 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
67
67
  /**
68
68
  * Time (in milliseconds) that data is considered fresh.
69
69
  *
70
- * While fresh, revalidation will be skipped.
70
+ * While fresh, revalidation will be skipped unless explicitly invalidated.
71
71
  *
72
- * @default 2500 ms (2.5 minutes)
72
+ * @default 2500 ms (2.5 seconds)
73
73
  */
74
74
  staleTime?: number;
75
75
  /**
@@ -131,16 +131,20 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
131
131
  * @returns A function to retrieve or create a query instance by variable
132
132
  *
133
133
  * @remarks
134
- * - Queries are cached by a deterministic key derived from `variable`.
134
+ * - Queries are **keyed by variable** (via deterministic hashing).
135
135
  * - Each unique variable maps to its own store instance.
136
- * - Queries support:
137
- * - Caching with `staleTime`
138
- * - Explicit invalidation independent of freshness
139
- * - Automatic garbage collection (`gcTime`)
140
- * - Retry logic via `shouldRetry`
141
- * - Background revalidation (focus / reconnect)
142
- * - Execution is deduplicated: multiple calls share the same in-flight promise.
143
- * - Ongoing executions can be optionally overwritten.
136
+ *
137
+ * Core features:
138
+ * - Caching via `staleTime`
139
+ * - Explicit invalidation (independent of freshness)
140
+ * - Retry logic via `shouldRetry`
141
+ * - Background revalidation (focus / reconnect)
142
+ * - Garbage collection via `gcTime`
143
+ *
144
+ * Execution behavior:
145
+ * - By default, executions **overwrite ongoing executions**
146
+ * - Set `overwriteOngoingExecution: false` to enable deduplication
147
+ * - Internal revalidation (focus/reconnect) uses deduplication by default
144
148
  *
145
149
  * @example
146
150
  * const userQuery = createQuery<UserDetail, { id: string }>(async ({ id }) => {
@@ -153,7 +157,7 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
153
157
  * // ...
154
158
  * }
155
159
  */
156
- export declare const createQuery: <TData, TVariable extends Record<string, any> = never>(queryFn: (variable: TVariable, currentState: QueryState<TData>) => Promise<TData>, options?: QueryOptions<TData, TVariable>) => ((variable?: TVariable) => (<TStateSlice = QueryState<TData>>(options?: {
160
+ export declare const createQuery: <TData, TVariable extends Record<string, any> = never>(queryFn: (variable: TVariable, currentState: QueryState<TData>) => Promise<TData>, options?: QueryOptions<TData, TVariable>) => ((variable?: TVariable) => ((options?: {
157
161
  /**
158
162
  * Whether the query should execute automatically on mount.
159
163
  *
@@ -176,7 +180,7 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
176
180
  * // While loading userId=2, still show userId=1 data
177
181
  * useQuery({ id: userId }, { keepPreviousData: true });
178
182
  */ keepPreviousData?: boolean;
179
- }, selector?: (state: QueryState<TData>) => TStateSlice) => TStateSlice) & {
183
+ }) => QueryState<TData>) & {
180
184
  metadata: {
181
185
  isInvalidated?: boolean;
182
186
  promise?: Promise<QueryState<TData>> | undefined;
@@ -208,7 +212,7 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
208
212
  * @returns A promise resolving to the latest query state
209
213
  *
210
214
  * @remarks
211
- * - By default, each call starts a new execution even if one is already in progress.
215
+ * - By default, each call **starts a new execution** even if one is already in progress.
212
216
  * - Set `overwriteOngoingExecution: false` to reuse an ongoing execution (deduplication).
213
217
  * - Handles:
214
218
  * - Pending state
@@ -12,14 +12,17 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
12
12
  * @remarks
13
13
  * - Combines the vanilla store with React integration.
14
14
  * - The returned function can be used directly as a hook.
15
+ * - The hook uses Proxy-based tracking to automatically detect which state fields are used.
16
+ * - Components will only re-render when the accessed values change.
15
17
  *
16
18
  * @example
17
- * const useCounter = createStore({ count: 0 });
19
+ * const useMyStore = createStore({ foo: 1, bar: 2 });
18
20
  *
19
21
  * function Component() {
20
- * const count = useCounter((s) => s.count);
22
+ * const state = useMyStore();
23
+ * return <div>{state.foo}</div>;
21
24
  * }
22
25
  *
23
- * useCounter.setState({ count: 1 });
26
+ * useMyStore.setState({ foo: 2 }); // only components using foo will re-render
24
27
  */
25
- export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (<TStateSlice = TState>(selector?: (state: TState) => TStateSlice) => TStateSlice) & import("../vanilla.d.mts").StoreApi<TState>;
28
+ export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (() => TState) & import("../vanilla.d.mts").StoreApi<TState>;
@@ -12,18 +12,25 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
12
12
  * - Keys are deterministically hashed, ensuring stable identity.
13
13
  * - Stores are lazily created and cached.
14
14
  * - Each store has its own state, subscribers, and lifecycle.
15
+ * - Each returned store includes:
16
+ * - React hook (with Proxy-based tracking)
17
+ * - Store API methods
18
+ * - `delete()` for manual cleanup
15
19
  * - Useful for scenarios like:
16
20
  * - Query caches
17
21
  * - Entity-based state
18
22
  * - Dynamic instances
19
23
  *
20
24
  * @example
21
- * const getUserStore = createStores({ name: '' });
25
+ * const userStore = createStores<{ name: string }, { id: number }>({ name: '' });
22
26
  *
23
- * const userStore = getUserStore({ id: 1 });
24
- * const name = userStore((s) => s.name);
27
+ * function Component() {
28
+ * const useUserStore = userStore({ id: 1 });
29
+ * const state = useUserStore();
30
+ * return <div>{state.name}</div>;
31
+ * }
25
32
  */
26
- export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (<TStateSlice = TState>(selector?: (state: TState) => TStateSlice) => TStateSlice) & {
33
+ export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (() => TState) & {
27
34
  delete: () => boolean;
28
35
  setState: (value: import("../vanilla.d.mts").SetState<TState>) => void;
29
36
  getState: () => TState;
@@ -1,18 +1,28 @@
1
1
  import { type StoreApi } from 'floppy-disk/vanilla';
2
- export declare const useStoreUpdateNotifier: <TState extends Record<string, any>, TStateSlice = TState>(store: StoreApi<TState>, selector: (state: TState) => TStateSlice) => void;
2
+ type Path = Array<string | number | symbol>;
3
+ export declare const getValueByPath: (obj: any, path: Path) => any;
4
+ export declare const isPrefixPath: (candidatePrefix: Path, targetPath: Path) => boolean;
5
+ export declare const compressPaths: (paths: Path[]) => Path[];
6
+ export declare const useStoreStateProxy: <TState extends Record<string, any>>(storeState: TState) => readonly [TState, import("react").RefObject<Path[]>];
3
7
  /**
4
- * React hook for subscribing to a store with optional state selection.
8
+ * React hook for subscribing to a store using automatic dependency tracking.
5
9
  *
6
10
  * @param store - The store instance to subscribe to
7
- * @param selector - Optional selector to derive a slice of state
8
11
  *
9
- * @returns The selected state slice (or full state if no selector is provided)
12
+ * @returns A proxied version of the store state
10
13
  *
11
14
  * @remarks
12
- * - The selector does **not** need to be memoized.
13
- * - The hook internally keeps the latest selector reference to avoid re-subscription.
15
+ * - This hook uses a **Proxy-based tracking mechanism** to detect which parts of the state are accessed during render.
16
+ * - The component will only re-render when the **accessed values actually change**.
17
+ * - State must be treated as **immutable**:
18
+ * - Updates must replace objects rather than mutate them
19
+ * - Otherwise, changes may not be detected
20
+ * - No selector or memoization is needed.
14
21
  *
15
22
  * @example
16
- * const count = useStoreState(store, (s) => s.count);
23
+ * const state = useStoreState(store);
24
+ * return <div>{state.user.name}</div>;
25
+ * // Component will only re-render if `user.name` changes
17
26
  */
18
- export declare const useStoreState: <TState extends Record<string, any>, TStateSlice = TState>(store: StoreApi<TState>, selector?: (state: TState) => TStateSlice) => TStateSlice;
27
+ export declare const useStoreState: <TState extends Record<string, any>>(storeState: TState, subscribe: StoreApi<TState>["subscribe"]) => TState;
28
+ export {};
package/esm/react.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './react/use-isomorphic-layout-effect.mjs';
2
- export * from './react/use-store.mjs';
2
+ export { useStoreState } from './react/use-store.mjs';
3
3
  export * from './react/create-store.mjs';
4
4
  export * from './react/create-stores.mjs';
5
5
  export * from './react/create-query.mjs';
package/esm/react.mjs CHANGED
@@ -1,30 +1,75 @@
1
- import { useLayoutEffect, useEffect, useState, useRef } from 'react';
2
- import { isClient, identity, shallow, initStore, getHash, noop } from 'floppy-disk/vanilla';
1
+ import { useLayoutEffect, useEffect, useState, useRef, useMemo } from 'react';
2
+ import { isClient, initStore, getHash, noop } from 'floppy-disk/vanilla';
3
3
 
4
4
  const useIsomorphicLayoutEffect = isClient ? useLayoutEffect : useEffect;
5
5
 
6
- const useStoreUpdateNotifier = (store, selector) => {
7
- const [, reRender] = useState({});
8
- const selectorRef = useRef(selector);
9
- selectorRef.current = selector;
10
- useIsomorphicLayoutEffect(
11
- () => store.subscribe((state, prevState) => {
12
- if (selectorRef.current === identity) return reRender({});
13
- const prevSlice = selectorRef.current(prevState);
14
- const nextSlice = selectorRef.current(state);
15
- if (!shallow(prevSlice, nextSlice)) reRender({});
16
- }),
17
- [store]
18
- );
6
+ const getValueByPath = (obj, path) => path.reduce((acc, key) => acc == null ? void 0 : acc[key], obj);
7
+ const isPrefixPath = (candidatePrefix, targetPath) => {
8
+ if (candidatePrefix.length >= targetPath.length) return false;
9
+ for (let i = 0; i < candidatePrefix.length; i++) {
10
+ if (candidatePrefix[i] !== targetPath[i]) return false;
11
+ }
12
+ return true;
13
+ };
14
+ const compressPaths = (paths) => {
15
+ const result = [];
16
+ let prev = null;
17
+ for (let i = paths.length - 1; i >= 0; i--) {
18
+ const current = paths[i];
19
+ if (!prev || !isPrefixPath(current, prev)) result.push(current);
20
+ prev = current;
21
+ }
22
+ return result;
19
23
  };
20
- const useStoreState = (store, selector = identity) => {
21
- useStoreUpdateNotifier(store, selector);
22
- return selector(store.getState());
24
+ const useStoreStateProxy = (storeState) => {
25
+ const usedPathsRef = useRef([]);
26
+ usedPathsRef.current = [];
27
+ const trackedState = useMemo(() => {
28
+ const track = (path) => usedPathsRef.current.push(path);
29
+ const proxyCache = /* @__PURE__ */ new WeakMap();
30
+ const createDeepProxy = (target, path = []) => {
31
+ if (typeof target !== "object" || target === null) {
32
+ return target;
33
+ }
34
+ if (proxyCache.has(target)) {
35
+ return proxyCache.get(target);
36
+ }
37
+ const proxy = new Proxy(target, {
38
+ get(obj, key) {
39
+ const newPath = [...path, key];
40
+ track(newPath);
41
+ const value = obj[key];
42
+ return createDeepProxy(value, newPath);
43
+ }
44
+ });
45
+ proxyCache.set(target, proxy);
46
+ return proxy;
47
+ };
48
+ return createDeepProxy(storeState);
49
+ }, [storeState]);
50
+ return [trackedState, usedPathsRef];
51
+ };
52
+ const useStoreState = (storeState, subscribe) => {
53
+ const [trackedState, usedPathsRef] = useStoreStateProxy(storeState);
54
+ const [, reRender] = useState({});
55
+ useIsomorphicLayoutEffect(() => {
56
+ return subscribe((nextState, prevState, changedKeys) => {
57
+ const paths = compressPaths(usedPathsRef.current);
58
+ for (const path of paths) {
59
+ const rootKey = path[0];
60
+ if (!changedKeys.includes(rootKey)) continue;
61
+ const prevVal = getValueByPath(prevState, path);
62
+ const nextVal = getValueByPath(nextState, path);
63
+ if (!Object.is(prevVal, nextVal)) return reRender({});
64
+ }
65
+ });
66
+ }, [subscribe]);
67
+ return trackedState;
23
68
  };
24
69
 
25
70
  const createStore = (initialState, options) => {
26
71
  const store = initStore(initialState, options);
27
- const useStore = (selector) => useStoreState(store, selector);
72
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
28
73
  return Object.assign(useStore, store);
29
74
  };
30
75
 
@@ -39,9 +84,7 @@ const createStores = (initialState, options) => {
39
84
  store = initStore(initialState, options);
40
85
  stores.set(keyHash, store);
41
86
  }
42
- const useStore = (selector) => {
43
- return useStoreState(store, selector);
44
- };
87
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
45
88
  return Object.assign(useStore, {
46
89
  ...store,
47
90
  delete: () => {
@@ -323,20 +366,50 @@ const createQuery = (queryFn, options = {}) => {
323
366
  stores.set(variableHash, store);
324
367
  internals.set(store, configureInternals(store, variable, variableHash));
325
368
  }
326
- const useStore = (options2 = {}, selector = identity) => {
327
- useStoreUpdateNotifier(store, selector);
328
- useIsomorphicLayoutEffect(() => {
329
- if (options2.enabled !== false) revalidate(store, variable, false);
330
- }, [store, options2.enabled]);
369
+ const useStore = (options2 = {}) => {
370
+ const { enabled = true, keepPreviousData } = options2;
331
371
  const storeState = store.getState();
332
- let storeStateToBeUsed = storeState;
333
372
  const prevState = useRef({});
334
- if (storeState.isSuccess) {
335
- prevState.current = { data: storeState.data, dataUpdatedAt: storeState.dataUpdatedAt };
336
- } else if (storeState.state === "INITIAL" && options2.keepPreviousData) {
373
+ let storeStateToBeUsed = storeState;
374
+ if (storeState.state !== "INITIAL") {
375
+ prevState.current = {
376
+ data: storeState.data,
377
+ dataUpdatedAt: storeState.dataUpdatedAt
378
+ };
379
+ } else if (keepPreviousData) {
337
380
  storeStateToBeUsed = { ...storeState, ...prevState.current };
338
381
  }
339
- return selector(storeStateToBeUsed);
382
+ const [trackedState, usedPathsRef] = useStoreStateProxy(
383
+ enabled && storeState.state === "INITIAL" ? (
384
+ // Optimize rendering on initial state
385
+ // Do { isPending: true } → result
386
+ // instead of { isPending: false } → { isPending: true } → result
387
+ { ...storeStateToBeUsed, isPending: true }
388
+ ) : storeStateToBeUsed
389
+ );
390
+ const [, reRender] = useState({});
391
+ useIsomorphicLayoutEffect(() => {
392
+ return store.subscribe((nextState, prevState2, changedKeys) => {
393
+ if (prevState2.state === "INITIAL" && !prevState2.isPending && nextState.isPending) {
394
+ return;
395
+ }
396
+ const paths = compressPaths(usedPathsRef.current);
397
+ for (const path of paths) {
398
+ const rootKey = path[0];
399
+ if (!changedKeys.includes(rootKey)) continue;
400
+ const prevVal = getValueByPath(prevState2, path);
401
+ const nextVal = getValueByPath(nextState, path);
402
+ if (!Object.is(prevVal, nextVal)) return reRender({});
403
+ }
404
+ });
405
+ }, [store]);
406
+ useIsomorphicLayoutEffect(() => {
407
+ if (enabled !== false) revalidate(store, variable, false);
408
+ }, [store, enabled]);
409
+ if (keepPreviousData) {
410
+ !!trackedState.error;
411
+ }
412
+ return trackedState;
340
413
  };
341
414
  return Object.assign(useStore, {
342
415
  subscribe: store.subscribe,
@@ -408,7 +481,7 @@ const createMutation = (mutationFn, options = {}) => {
408
481
  const { onSuccess = noop, onError, onSettled = noop } = options;
409
482
  const initialState = INITIAL_STATE;
410
483
  const store = initStore(initialState, options);
411
- const useStore = (selector) => useStoreState(store, selector);
484
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
412
485
  const execute = (variable) => {
413
486
  const stateBeforeExecute = store.getState();
414
487
  if (stateBeforeExecute.isPending) {
@@ -500,4 +573,4 @@ const createMutation = (mutationFn, options = {}) => {
500
573
  });
501
574
  };
502
575
 
503
- export { createMutation, createQuery, createStore, createStores, useIsomorphicLayoutEffect, useStoreState, useStoreUpdateNotifier };
576
+ export { createMutation, createQuery, createStore, createStores, useIsomorphicLayoutEffect, useStoreState };
@@ -6,14 +6,6 @@ export declare const isClient: boolean;
6
6
  * Empty function.
7
7
  */
8
8
  export declare const noop: () => void;
9
- /**
10
- * Identity function.
11
- *
12
- * It accepts 1 argument, and simply return it.
13
- *
14
- * `const identity = value => value`
15
- */
16
- export declare const identity: <T>(value: T) => T;
17
9
  /**
18
10
  * If the value is a function, it will invoke the function.\
19
11
  * If the value is not a function, it will just return it.
@@ -11,18 +11,21 @@ export type SetState<TState> = Partial<TState> | ((state: TState) => Partial<TSt
11
11
  *
12
12
  * @param state - The latest state
13
13
  * @param prevState - The previous state before the update
14
+ * @param changedKeys - The top-level keys that changed (shallow diff)
14
15
  *
15
16
  * @remarks
16
- * - Subscribers are only called when the state actually changes.
17
+ * - Subscribers are only called when at least one field changes.
17
18
  * - Change detection is performed per key using `Object.is`.
19
+ * - `changedKeys` only includes top-level keys; nested changes must be inferred by the consumer.
18
20
  */
19
- export type Subscriber<TState> = (state: TState, prevState: TState) => void;
21
+ export type Subscriber<TState> = (state: TState, prevState: TState, changedKeys: Array<keyof TState>) => void;
20
22
  /**
21
23
  * Core store API for managing state.
22
24
  *
23
25
  * @remarks
24
26
  * - The store performs **shallow change detection per key** before notifying subscribers.
25
27
  * - Subscribers are only notified when at least one field changes.
28
+ * - State is treated as **immutable**. Mutating nested values directly will not trigger updates.
26
29
  * - Designed to be framework-agnostic (React bindings are built separately).
27
30
  * - By default, `setState` is **disabled on the server** to prevent accidental shared state between requests.
28
31
  */
@@ -48,13 +51,17 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
48
51
  onSubscribe?: (state: TState, store: StoreApi<TState>) => void;
49
52
  onUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
50
53
  onLastUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
54
+ /**
55
+ * By default, calling `setState` on the server is disallowed to prevent shared state across requests.
56
+ * Set this to `true` only if you explicitly intend to mutate state during server execution.
57
+ */
51
58
  allowSetStateServerSide?: boolean;
52
59
  };
53
60
  /**
54
61
  * Creates a vanilla store with pub-sub capabilities.
55
62
  *
56
- * The store state is expected to be an **object**.\
57
- * Updates are applied as partial merges, so non-object states are not supported.
63
+ * The store state must be an **object**.\
64
+ * Updates are applied as shallow merges, so non-object states are not supported.
58
65
  *
59
66
  * @param initialState - The initial state of the store
60
67
  * @param options - Optional lifecycle hooks
@@ -64,11 +71,13 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
64
71
  * @remarks
65
72
  * - State updates are **shallowly compared per key** before notifying subscribers.
66
73
  * - Subscribers are only notified when at least one updated field changes (using `Object.is` comparison).
67
- * - Subscribers receive both the new state and the previous state.
74
+ * - Subscribers receive the new state, previous state, and changed top-level keys.
75
+ * - State is expected to be treated as **immutable**.
76
+ * - Mutating nested values directly will not trigger updates.
68
77
  * - Lifecycle hooks allow side-effect management tied to subscription count.
69
- * - By default, `setState` is **disabled on the server** to prevent accidental shared state between requests.
70
- * - This avoids leaking data between users in server environments.
71
- * - You can override this by setting `allowSetStateServerSide: true`.
78
+ * - By default, `setState` is **not allowed on the server** to prevent accidental shared state between requests.
79
+ * - This helps avoid leaking data between users in server environments.
80
+ * - If you intentionally want to allow this behavior, set `allowSetStateServerSide: true`.
72
81
  *
73
82
  * @example
74
83
  * const store = initStore({ count: 0 });
package/esm/vanilla.d.mts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './vanilla/basic.mjs';
2
- export * from './vanilla/shallow.mjs';
3
2
  export * from './vanilla/hash.mjs';
4
3
  export * from './vanilla/store.mjs';
package/esm/vanilla.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  const isClient = typeof window !== "undefined" && !("Deno" in window);
2
2
  const noop = () => {
3
3
  };
4
- const identity = (value) => value;
5
4
  const getValue = (valueOrComputeValueFn, ...params) => {
6
5
  if (typeof valueOrComputeValueFn === "function") {
7
6
  return valueOrComputeValueFn(...params);
@@ -9,41 +8,6 @@ const getValue = (valueOrComputeValueFn, ...params) => {
9
8
  return valueOrComputeValueFn;
10
9
  };
11
10
 
12
- const shallow = (a, b) => {
13
- if (Object.is(a, b)) {
14
- return true;
15
- }
16
- if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) {
17
- return false;
18
- }
19
- if (a instanceof Map && b instanceof Map) {
20
- if (a.size !== b.size) return false;
21
- for (const [key, value] of a) {
22
- if (!Object.is(value, b.get(key))) {
23
- return false;
24
- }
25
- }
26
- return true;
27
- }
28
- if (a instanceof Set && b instanceof Set) {
29
- if (a.size !== b.size) return false;
30
- for (const value of a) {
31
- if (!b.has(value)) return false;
32
- }
33
- return true;
34
- }
35
- const keysA = Object.keys(a);
36
- if (keysA.length !== Object.keys(b).length) {
37
- return false;
38
- }
39
- for (let i = 0; i < keysA.length; i++) {
40
- if (!Object.prototype.hasOwnProperty.call(b, keysA[i]) || !Object.is(a[keysA[i]], b[keysA[i]])) {
41
- return false;
42
- }
43
- }
44
- return true;
45
- };
46
-
47
11
  const hasObjectPrototype = (value) => {
48
12
  return Object.prototype.toString.call(value) === "[object Object]";
49
13
  };
@@ -96,13 +60,15 @@ const initStore = (initialState, options = {}) => {
96
60
  }
97
61
  const prevState = state;
98
62
  const newValue = getValue(value, state);
63
+ const changedKeys = [];
99
64
  for (const key in newValue) {
100
65
  if (!Object.is(prevState[key], newValue[key])) {
101
- state = { ...prevState, ...newValue };
102
- [...subscribers].forEach((subscriber) => subscriber(state, prevState));
103
- return;
66
+ changedKeys.push(key);
104
67
  }
105
68
  }
69
+ if (changedKeys.length === 0) return;
70
+ state = { ...prevState, ...newValue };
71
+ [...subscribers].forEach((subscriber) => subscriber(state, prevState, changedKeys));
106
72
  };
107
73
  const storeApi = {
108
74
  getState,
@@ -113,4 +79,4 @@ const initStore = (initialState, options = {}) => {
113
79
  return storeApi;
114
80
  };
115
81
 
116
- export { getHash, getValue, identity, initStore, isClient, isPlainObject, noop, shallow };
82
+ export { getHash, getValue, initStore, isClient, isPlainObject, noop };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "floppy-disk",
3
3
  "description": "Lightweight, simple, and powerful state management library",
4
4
  "private": false,
5
- "version": "3.0.0-alpha.3",
5
+ "version": "3.0.0-alpha.5",
6
6
  "publishConfig": {
7
7
  "tag": "alpha"
8
8
  },
@@ -89,7 +89,7 @@ export type MutationOptions<TData, TVariable> = InitStoreOptions<MutationState<T
89
89
  * const { isPending } = useCreateUser();
90
90
  * const result = await useCreateUser.execute({ name: 'John' });
91
91
  */
92
- export declare const createMutation: <TData, TVariable = undefined>(mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable>) => Promise<TData>, options?: MutationOptions<TData, TVariable>) => (<TStateSlice = MutationState<TData, TVariable>>(selector?: (state: MutationState<TData, TVariable>) => TStateSlice) => TStateSlice) & {
92
+ export declare const createMutation: <TData, TVariable = undefined>(mutationFn: (variable: TVariable, stateBeforeExecute: MutationState<TData, TVariable>) => Promise<TData>, options?: MutationOptions<TData, TVariable>) => (() => MutationState<TData, TVariable>) & {
93
93
  subscribe: (subscriber: import("../vanilla.ts").Subscriber<MutationState<TData, TVariable>>) => () => void;
94
94
  getSubscribers: () => Set<import("../vanilla.ts").Subscriber<MutationState<TData, TVariable>>>;
95
95
  getState: () => MutationState<TData, TVariable>;
@@ -67,9 +67,9 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
67
67
  /**
68
68
  * Time (in milliseconds) that data is considered fresh.
69
69
  *
70
- * While fresh, revalidation will be skipped.
70
+ * While fresh, revalidation will be skipped unless explicitly invalidated.
71
71
  *
72
- * @default 2500 ms (2.5 minutes)
72
+ * @default 2500 ms (2.5 seconds)
73
73
  */
74
74
  staleTime?: number;
75
75
  /**
@@ -131,16 +131,20 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
131
131
  * @returns A function to retrieve or create a query instance by variable
132
132
  *
133
133
  * @remarks
134
- * - Queries are cached by a deterministic key derived from `variable`.
134
+ * - Queries are **keyed by variable** (via deterministic hashing).
135
135
  * - Each unique variable maps to its own store instance.
136
- * - Queries support:
137
- * - Caching with `staleTime`
138
- * - Explicit invalidation independent of freshness
139
- * - Automatic garbage collection (`gcTime`)
140
- * - Retry logic via `shouldRetry`
141
- * - Background revalidation (focus / reconnect)
142
- * - Execution is deduplicated: multiple calls share the same in-flight promise.
143
- * - Ongoing executions can be optionally overwritten.
136
+ *
137
+ * Core features:
138
+ * - Caching via `staleTime`
139
+ * - Explicit invalidation (independent of freshness)
140
+ * - Retry logic via `shouldRetry`
141
+ * - Background revalidation (focus / reconnect)
142
+ * - Garbage collection via `gcTime`
143
+ *
144
+ * Execution behavior:
145
+ * - By default, executions **overwrite ongoing executions**
146
+ * - Set `overwriteOngoingExecution: false` to enable deduplication
147
+ * - Internal revalidation (focus/reconnect) uses deduplication by default
144
148
  *
145
149
  * @example
146
150
  * const userQuery = createQuery<UserDetail, { id: string }>(async ({ id }) => {
@@ -153,7 +157,7 @@ export type QueryOptions<TData, TVariable extends Record<string, any>> = InitSto
153
157
  * // ...
154
158
  * }
155
159
  */
156
- export declare const createQuery: <TData, TVariable extends Record<string, any> = never>(queryFn: (variable: TVariable, currentState: QueryState<TData>) => Promise<TData>, options?: QueryOptions<TData, TVariable>) => ((variable?: TVariable) => (<TStateSlice = QueryState<TData>>(options?: {
160
+ export declare const createQuery: <TData, TVariable extends Record<string, any> = never>(queryFn: (variable: TVariable, currentState: QueryState<TData>) => Promise<TData>, options?: QueryOptions<TData, TVariable>) => ((variable?: TVariable) => ((options?: {
157
161
  /**
158
162
  * Whether the query should execute automatically on mount.
159
163
  *
@@ -176,7 +180,7 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
176
180
  * // While loading userId=2, still show userId=1 data
177
181
  * useQuery({ id: userId }, { keepPreviousData: true });
178
182
  */ keepPreviousData?: boolean;
179
- }, selector?: (state: QueryState<TData>) => TStateSlice) => TStateSlice) & {
183
+ }) => QueryState<TData>) & {
180
184
  metadata: {
181
185
  isInvalidated?: boolean;
182
186
  promise?: Promise<QueryState<TData>> | undefined;
@@ -208,7 +212,7 @@ export declare const createQuery: <TData, TVariable extends Record<string, any>
208
212
  * @returns A promise resolving to the latest query state
209
213
  *
210
214
  * @remarks
211
- * - By default, each call starts a new execution even if one is already in progress.
215
+ * - By default, each call **starts a new execution** even if one is already in progress.
212
216
  * - Set `overwriteOngoingExecution: false` to reuse an ongoing execution (deduplication).
213
217
  * - Handles:
214
218
  * - Pending state
@@ -12,14 +12,17 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
12
12
  * @remarks
13
13
  * - Combines the vanilla store with React integration.
14
14
  * - The returned function can be used directly as a hook.
15
+ * - The hook uses Proxy-based tracking to automatically detect which state fields are used.
16
+ * - Components will only re-render when the accessed values change.
15
17
  *
16
18
  * @example
17
- * const useCounter = createStore({ count: 0 });
19
+ * const useMyStore = createStore({ foo: 1, bar: 2 });
18
20
  *
19
21
  * function Component() {
20
- * const count = useCounter((s) => s.count);
22
+ * const state = useMyStore();
23
+ * return <div>{state.foo}</div>;
21
24
  * }
22
25
  *
23
- * useCounter.setState({ count: 1 });
26
+ * useMyStore.setState({ foo: 2 }); // only components using foo will re-render
24
27
  */
25
- export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (<TStateSlice = TState>(selector?: (state: TState) => TStateSlice) => TStateSlice) & import("../vanilla.ts").StoreApi<TState>;
28
+ export declare const createStore: <TState extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (() => TState) & import("../vanilla.ts").StoreApi<TState>;
@@ -12,18 +12,25 @@ import { type InitStoreOptions } from 'floppy-disk/vanilla';
12
12
  * - Keys are deterministically hashed, ensuring stable identity.
13
13
  * - Stores are lazily created and cached.
14
14
  * - Each store has its own state, subscribers, and lifecycle.
15
+ * - Each returned store includes:
16
+ * - React hook (with Proxy-based tracking)
17
+ * - Store API methods
18
+ * - `delete()` for manual cleanup
15
19
  * - Useful for scenarios like:
16
20
  * - Query caches
17
21
  * - Entity-based state
18
22
  * - Dynamic instances
19
23
  *
20
24
  * @example
21
- * const getUserStore = createStores({ name: '' });
25
+ * const userStore = createStores<{ name: string }, { id: number }>({ name: '' });
22
26
  *
23
- * const userStore = getUserStore({ id: 1 });
24
- * const name = userStore((s) => s.name);
27
+ * function Component() {
28
+ * const useUserStore = userStore({ id: 1 });
29
+ * const state = useUserStore();
30
+ * return <div>{state.name}</div>;
31
+ * }
25
32
  */
26
- export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (<TStateSlice = TState>(selector?: (state: TState) => TStateSlice) => TStateSlice) & {
33
+ export declare const createStores: <TState extends Record<string, any>, TKey extends Record<string, any>>(initialState: TState, options?: InitStoreOptions<TState>) => (key?: TKey) => (() => TState) & {
27
34
  delete: () => boolean;
28
35
  setState: (value: import("../vanilla.ts").SetState<TState>) => void;
29
36
  getState: () => TState;
@@ -1,18 +1,28 @@
1
1
  import { type StoreApi } from 'floppy-disk/vanilla';
2
- export declare const useStoreUpdateNotifier: <TState extends Record<string, any>, TStateSlice = TState>(store: StoreApi<TState>, selector: (state: TState) => TStateSlice) => void;
2
+ type Path = Array<string | number | symbol>;
3
+ export declare const getValueByPath: (obj: any, path: Path) => any;
4
+ export declare const isPrefixPath: (candidatePrefix: Path, targetPath: Path) => boolean;
5
+ export declare const compressPaths: (paths: Path[]) => Path[];
6
+ export declare const useStoreStateProxy: <TState extends Record<string, any>>(storeState: TState) => readonly [TState, import("react").RefObject<Path[]>];
3
7
  /**
4
- * React hook for subscribing to a store with optional state selection.
8
+ * React hook for subscribing to a store using automatic dependency tracking.
5
9
  *
6
10
  * @param store - The store instance to subscribe to
7
- * @param selector - Optional selector to derive a slice of state
8
11
  *
9
- * @returns The selected state slice (or full state if no selector is provided)
12
+ * @returns A proxied version of the store state
10
13
  *
11
14
  * @remarks
12
- * - The selector does **not** need to be memoized.
13
- * - The hook internally keeps the latest selector reference to avoid re-subscription.
15
+ * - This hook uses a **Proxy-based tracking mechanism** to detect which parts of the state are accessed during render.
16
+ * - The component will only re-render when the **accessed values actually change**.
17
+ * - State must be treated as **immutable**:
18
+ * - Updates must replace objects rather than mutate them
19
+ * - Otherwise, changes may not be detected
20
+ * - No selector or memoization is needed.
14
21
  *
15
22
  * @example
16
- * const count = useStoreState(store, (s) => s.count);
23
+ * const state = useStoreState(store);
24
+ * return <div>{state.user.name}</div>;
25
+ * // Component will only re-render if `user.name` changes
17
26
  */
18
- export declare const useStoreState: <TState extends Record<string, any>, TStateSlice = TState>(store: StoreApi<TState>, selector?: (state: TState) => TStateSlice) => TStateSlice;
27
+ export declare const useStoreState: <TState extends Record<string, any>>(storeState: TState, subscribe: StoreApi<TState>["subscribe"]) => TState;
28
+ export {};
package/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './react/use-isomorphic-layout-effect';
2
- export * from './react/use-store';
2
+ export { useStoreState } from './react/use-store';
3
3
  export * from './react/create-store';
4
4
  export * from './react/create-stores';
5
5
  export * from './react/create-query';
package/react.js CHANGED
@@ -5,28 +5,73 @@ var vanilla = require('floppy-disk/vanilla');
5
5
 
6
6
  const useIsomorphicLayoutEffect = vanilla.isClient ? react.useLayoutEffect : react.useEffect;
7
7
 
8
- const useStoreUpdateNotifier = (store, selector) => {
9
- const [, reRender] = react.useState({});
10
- const selectorRef = react.useRef(selector);
11
- selectorRef.current = selector;
12
- useIsomorphicLayoutEffect(
13
- () => store.subscribe((state, prevState) => {
14
- if (selectorRef.current === vanilla.identity) return reRender({});
15
- const prevSlice = selectorRef.current(prevState);
16
- const nextSlice = selectorRef.current(state);
17
- if (!vanilla.shallow(prevSlice, nextSlice)) reRender({});
18
- }),
19
- [store]
20
- );
8
+ const getValueByPath = (obj, path) => path.reduce((acc, key) => acc == null ? void 0 : acc[key], obj);
9
+ const isPrefixPath = (candidatePrefix, targetPath) => {
10
+ if (candidatePrefix.length >= targetPath.length) return false;
11
+ for (let i = 0; i < candidatePrefix.length; i++) {
12
+ if (candidatePrefix[i] !== targetPath[i]) return false;
13
+ }
14
+ return true;
15
+ };
16
+ const compressPaths = (paths) => {
17
+ const result = [];
18
+ let prev = null;
19
+ for (let i = paths.length - 1; i >= 0; i--) {
20
+ const current = paths[i];
21
+ if (!prev || !isPrefixPath(current, prev)) result.push(current);
22
+ prev = current;
23
+ }
24
+ return result;
21
25
  };
22
- const useStoreState = (store, selector = vanilla.identity) => {
23
- useStoreUpdateNotifier(store, selector);
24
- return selector(store.getState());
26
+ const useStoreStateProxy = (storeState) => {
27
+ const usedPathsRef = react.useRef([]);
28
+ usedPathsRef.current = [];
29
+ const trackedState = react.useMemo(() => {
30
+ const track = (path) => usedPathsRef.current.push(path);
31
+ const proxyCache = /* @__PURE__ */ new WeakMap();
32
+ const createDeepProxy = (target, path = []) => {
33
+ if (typeof target !== "object" || target === null) {
34
+ return target;
35
+ }
36
+ if (proxyCache.has(target)) {
37
+ return proxyCache.get(target);
38
+ }
39
+ const proxy = new Proxy(target, {
40
+ get(obj, key) {
41
+ const newPath = [...path, key];
42
+ track(newPath);
43
+ const value = obj[key];
44
+ return createDeepProxy(value, newPath);
45
+ }
46
+ });
47
+ proxyCache.set(target, proxy);
48
+ return proxy;
49
+ };
50
+ return createDeepProxy(storeState);
51
+ }, [storeState]);
52
+ return [trackedState, usedPathsRef];
53
+ };
54
+ const useStoreState = (storeState, subscribe) => {
55
+ const [trackedState, usedPathsRef] = useStoreStateProxy(storeState);
56
+ const [, reRender] = react.useState({});
57
+ useIsomorphicLayoutEffect(() => {
58
+ return subscribe((nextState, prevState, changedKeys) => {
59
+ const paths = compressPaths(usedPathsRef.current);
60
+ for (const path of paths) {
61
+ const rootKey = path[0];
62
+ if (!changedKeys.includes(rootKey)) continue;
63
+ const prevVal = getValueByPath(prevState, path);
64
+ const nextVal = getValueByPath(nextState, path);
65
+ if (!Object.is(prevVal, nextVal)) return reRender({});
66
+ }
67
+ });
68
+ }, [subscribe]);
69
+ return trackedState;
25
70
  };
26
71
 
27
72
  const createStore = (initialState, options) => {
28
73
  const store = vanilla.initStore(initialState, options);
29
- const useStore = (selector) => useStoreState(store, selector);
74
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
30
75
  return Object.assign(useStore, store);
31
76
  };
32
77
 
@@ -41,9 +86,7 @@ const createStores = (initialState, options) => {
41
86
  store = vanilla.initStore(initialState, options);
42
87
  stores.set(keyHash, store);
43
88
  }
44
- const useStore = (selector) => {
45
- return useStoreState(store, selector);
46
- };
89
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
47
90
  return Object.assign(useStore, {
48
91
  ...store,
49
92
  delete: () => {
@@ -325,20 +368,50 @@ const createQuery = (queryFn, options = {}) => {
325
368
  stores.set(variableHash, store);
326
369
  internals.set(store, configureInternals(store, variable, variableHash));
327
370
  }
328
- const useStore = (options2 = {}, selector = vanilla.identity) => {
329
- useStoreUpdateNotifier(store, selector);
330
- useIsomorphicLayoutEffect(() => {
331
- if (options2.enabled !== false) revalidate(store, variable, false);
332
- }, [store, options2.enabled]);
371
+ const useStore = (options2 = {}) => {
372
+ const { enabled = true, keepPreviousData } = options2;
333
373
  const storeState = store.getState();
334
- let storeStateToBeUsed = storeState;
335
374
  const prevState = react.useRef({});
336
- if (storeState.isSuccess) {
337
- prevState.current = { data: storeState.data, dataUpdatedAt: storeState.dataUpdatedAt };
338
- } else if (storeState.state === "INITIAL" && options2.keepPreviousData) {
375
+ let storeStateToBeUsed = storeState;
376
+ if (storeState.state !== "INITIAL") {
377
+ prevState.current = {
378
+ data: storeState.data,
379
+ dataUpdatedAt: storeState.dataUpdatedAt
380
+ };
381
+ } else if (keepPreviousData) {
339
382
  storeStateToBeUsed = { ...storeState, ...prevState.current };
340
383
  }
341
- return selector(storeStateToBeUsed);
384
+ const [trackedState, usedPathsRef] = useStoreStateProxy(
385
+ enabled && storeState.state === "INITIAL" ? (
386
+ // Optimize rendering on initial state
387
+ // Do { isPending: true } → result
388
+ // instead of { isPending: false } → { isPending: true } → result
389
+ { ...storeStateToBeUsed, isPending: true }
390
+ ) : storeStateToBeUsed
391
+ );
392
+ const [, reRender] = react.useState({});
393
+ useIsomorphicLayoutEffect(() => {
394
+ return store.subscribe((nextState, prevState2, changedKeys) => {
395
+ if (prevState2.state === "INITIAL" && !prevState2.isPending && nextState.isPending) {
396
+ return;
397
+ }
398
+ const paths = compressPaths(usedPathsRef.current);
399
+ for (const path of paths) {
400
+ const rootKey = path[0];
401
+ if (!changedKeys.includes(rootKey)) continue;
402
+ const prevVal = getValueByPath(prevState2, path);
403
+ const nextVal = getValueByPath(nextState, path);
404
+ if (!Object.is(prevVal, nextVal)) return reRender({});
405
+ }
406
+ });
407
+ }, [store]);
408
+ useIsomorphicLayoutEffect(() => {
409
+ if (enabled !== false) revalidate(store, variable, false);
410
+ }, [store, enabled]);
411
+ if (keepPreviousData) {
412
+ !!trackedState.error;
413
+ }
414
+ return trackedState;
342
415
  };
343
416
  return Object.assign(useStore, {
344
417
  subscribe: store.subscribe,
@@ -410,7 +483,7 @@ const createMutation = (mutationFn, options = {}) => {
410
483
  const { onSuccess = vanilla.noop, onError, onSettled = vanilla.noop } = options;
411
484
  const initialState = INITIAL_STATE;
412
485
  const store = vanilla.initStore(initialState, options);
413
- const useStore = (selector) => useStoreState(store, selector);
486
+ const useStore = () => useStoreState(store.getState(), store.subscribe);
414
487
  const execute = (variable) => {
415
488
  const stateBeforeExecute = store.getState();
416
489
  if (stateBeforeExecute.isPending) {
@@ -508,4 +581,3 @@ exports.createStore = createStore;
508
581
  exports.createStores = createStores;
509
582
  exports.useIsomorphicLayoutEffect = useIsomorphicLayoutEffect;
510
583
  exports.useStoreState = useStoreState;
511
- exports.useStoreUpdateNotifier = useStoreUpdateNotifier;
@@ -6,14 +6,6 @@ export declare const isClient: boolean;
6
6
  * Empty function.
7
7
  */
8
8
  export declare const noop: () => void;
9
- /**
10
- * Identity function.
11
- *
12
- * It accepts 1 argument, and simply return it.
13
- *
14
- * `const identity = value => value`
15
- */
16
- export declare const identity: <T>(value: T) => T;
17
9
  /**
18
10
  * If the value is a function, it will invoke the function.\
19
11
  * If the value is not a function, it will just return it.
@@ -11,18 +11,21 @@ export type SetState<TState> = Partial<TState> | ((state: TState) => Partial<TSt
11
11
  *
12
12
  * @param state - The latest state
13
13
  * @param prevState - The previous state before the update
14
+ * @param changedKeys - The top-level keys that changed (shallow diff)
14
15
  *
15
16
  * @remarks
16
- * - Subscribers are only called when the state actually changes.
17
+ * - Subscribers are only called when at least one field changes.
17
18
  * - Change detection is performed per key using `Object.is`.
19
+ * - `changedKeys` only includes top-level keys; nested changes must be inferred by the consumer.
18
20
  */
19
- export type Subscriber<TState> = (state: TState, prevState: TState) => void;
21
+ export type Subscriber<TState> = (state: TState, prevState: TState, changedKeys: Array<keyof TState>) => void;
20
22
  /**
21
23
  * Core store API for managing state.
22
24
  *
23
25
  * @remarks
24
26
  * - The store performs **shallow change detection per key** before notifying subscribers.
25
27
  * - Subscribers are only notified when at least one field changes.
28
+ * - State is treated as **immutable**. Mutating nested values directly will not trigger updates.
26
29
  * - Designed to be framework-agnostic (React bindings are built separately).
27
30
  * - By default, `setState` is **disabled on the server** to prevent accidental shared state between requests.
28
31
  */
@@ -48,13 +51,17 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
48
51
  onSubscribe?: (state: TState, store: StoreApi<TState>) => void;
49
52
  onUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
50
53
  onLastUnsubscribe?: (state: TState, store: StoreApi<TState>) => void;
54
+ /**
55
+ * By default, calling `setState` on the server is disallowed to prevent shared state across requests.
56
+ * Set this to `true` only if you explicitly intend to mutate state during server execution.
57
+ */
51
58
  allowSetStateServerSide?: boolean;
52
59
  };
53
60
  /**
54
61
  * Creates a vanilla store with pub-sub capabilities.
55
62
  *
56
- * The store state is expected to be an **object**.\
57
- * Updates are applied as partial merges, so non-object states are not supported.
63
+ * The store state must be an **object**.\
64
+ * Updates are applied as shallow merges, so non-object states are not supported.
58
65
  *
59
66
  * @param initialState - The initial state of the store
60
67
  * @param options - Optional lifecycle hooks
@@ -64,11 +71,13 @@ export type InitStoreOptions<TState extends Record<string, any>> = {
64
71
  * @remarks
65
72
  * - State updates are **shallowly compared per key** before notifying subscribers.
66
73
  * - Subscribers are only notified when at least one updated field changes (using `Object.is` comparison).
67
- * - Subscribers receive both the new state and the previous state.
74
+ * - Subscribers receive the new state, previous state, and changed top-level keys.
75
+ * - State is expected to be treated as **immutable**.
76
+ * - Mutating nested values directly will not trigger updates.
68
77
  * - Lifecycle hooks allow side-effect management tied to subscription count.
69
- * - By default, `setState` is **disabled on the server** to prevent accidental shared state between requests.
70
- * - This avoids leaking data between users in server environments.
71
- * - You can override this by setting `allowSetStateServerSide: true`.
78
+ * - By default, `setState` is **not allowed on the server** to prevent accidental shared state between requests.
79
+ * - This helps avoid leaking data between users in server environments.
80
+ * - If you intentionally want to allow this behavior, set `allowSetStateServerSide: true`.
72
81
  *
73
82
  * @example
74
83
  * const store = initStore({ count: 0 });
package/vanilla.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './vanilla/basic';
2
- export * from './vanilla/shallow';
3
2
  export * from './vanilla/hash';
4
3
  export * from './vanilla/store';
package/vanilla.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const isClient = typeof window !== "undefined" && !("Deno" in window);
4
4
  const noop = () => {
5
5
  };
6
- const identity = (value) => value;
7
6
  const getValue = (valueOrComputeValueFn, ...params) => {
8
7
  if (typeof valueOrComputeValueFn === "function") {
9
8
  return valueOrComputeValueFn(...params);
@@ -11,41 +10,6 @@ const getValue = (valueOrComputeValueFn, ...params) => {
11
10
  return valueOrComputeValueFn;
12
11
  };
13
12
 
14
- const shallow = (a, b) => {
15
- if (Object.is(a, b)) {
16
- return true;
17
- }
18
- if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) {
19
- return false;
20
- }
21
- if (a instanceof Map && b instanceof Map) {
22
- if (a.size !== b.size) return false;
23
- for (const [key, value] of a) {
24
- if (!Object.is(value, b.get(key))) {
25
- return false;
26
- }
27
- }
28
- return true;
29
- }
30
- if (a instanceof Set && b instanceof Set) {
31
- if (a.size !== b.size) return false;
32
- for (const value of a) {
33
- if (!b.has(value)) return false;
34
- }
35
- return true;
36
- }
37
- const keysA = Object.keys(a);
38
- if (keysA.length !== Object.keys(b).length) {
39
- return false;
40
- }
41
- for (let i = 0; i < keysA.length; i++) {
42
- if (!Object.prototype.hasOwnProperty.call(b, keysA[i]) || !Object.is(a[keysA[i]], b[keysA[i]])) {
43
- return false;
44
- }
45
- }
46
- return true;
47
- };
48
-
49
13
  const hasObjectPrototype = (value) => {
50
14
  return Object.prototype.toString.call(value) === "[object Object]";
51
15
  };
@@ -98,13 +62,15 @@ const initStore = (initialState, options = {}) => {
98
62
  }
99
63
  const prevState = state;
100
64
  const newValue = getValue(value, state);
65
+ const changedKeys = [];
101
66
  for (const key in newValue) {
102
67
  if (!Object.is(prevState[key], newValue[key])) {
103
- state = { ...prevState, ...newValue };
104
- [...subscribers].forEach((subscriber) => subscriber(state, prevState));
105
- return;
68
+ changedKeys.push(key);
106
69
  }
107
70
  }
71
+ if (changedKeys.length === 0) return;
72
+ state = { ...prevState, ...newValue };
73
+ [...subscribers].forEach((subscriber) => subscriber(state, prevState, changedKeys));
108
74
  };
109
75
  const storeApi = {
110
76
  getState,
@@ -117,9 +83,7 @@ const initStore = (initialState, options = {}) => {
117
83
 
118
84
  exports.getHash = getHash;
119
85
  exports.getValue = getValue;
120
- exports.identity = identity;
121
86
  exports.initStore = initStore;
122
87
  exports.isClient = isClient;
123
88
  exports.isPlainObject = isPlainObject;
124
89
  exports.noop = noop;
125
- exports.shallow = shallow;
@@ -1,6 +0,0 @@
1
- /**
2
- * Shallow compare 2 values.
3
- *
4
- * Reference: https://github.com/pmndrs/zustand/blob/e414f7ccf41eae09517ee2dcb44c9f5ae8a35a25/src/vanilla/shallow.ts
5
- */
6
- export declare const shallow: <T>(a: T, b: T) => boolean;
@@ -1,6 +0,0 @@
1
- /**
2
- * Shallow compare 2 values.
3
- *
4
- * Reference: https://github.com/pmndrs/zustand/blob/e414f7ccf41eae09517ee2dcb44c9f5ae8a35a25/src/vanilla/shallow.ts
5
- */
6
- export declare const shallow: <T>(a: T, b: T) => boolean;