@octanejs/zustand 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/package.json +47 -0
- package/src/index.ts +89 -0
- package/src/middleware.ts +8 -0
- package/src/shallow.ts +29 -0
- package/src/traditional.ts +144 -0
- package/src/vanilla.ts +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @octanejs/zustand
|
|
2
|
+
|
|
3
|
+
[zustand](https://github.com/pmndrs/zustand) for the [octane](https://github.com/octanejs/octane) renderer.
|
|
4
|
+
|
|
5
|
+
zustand separates a framework-agnostic **vanilla store** (`createStore`) from a tiny
|
|
6
|
+
**React binding** (`create` + `useStore`) built on `useSyncExternalStore`. This package
|
|
7
|
+
reuses the vanilla store unchanged (re-exported verbatim from `zustand/vanilla`) and
|
|
8
|
+
reimplements only the binding on top of octane's `useSyncExternalStore`. The public
|
|
9
|
+
surface matches zustand 1:1 — most zustand code works by changing the import.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
// before
|
|
13
|
+
import { create } from 'zustand';
|
|
14
|
+
// after
|
|
15
|
+
import { create } from '@octanejs/zustand';
|
|
16
|
+
|
|
17
|
+
const useBearStore = create((set) => ({
|
|
18
|
+
bears: 0,
|
|
19
|
+
increase: () => set((s) => ({ bears: s.bears + 1 })),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
function BearCounter() @{
|
|
23
|
+
const bears = useBearStore((s) => s.bears);
|
|
24
|
+
<h1>{bears as string} bears</h1>
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Entry points
|
|
29
|
+
|
|
30
|
+
| import | what you get | notes |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `@octanejs/zustand` | `create`, `useStore`, `createStore` | the React binding, octane-bound |
|
|
33
|
+
| `@octanejs/zustand/vanilla` | `createStore` + types | re-exported verbatim from zustand |
|
|
34
|
+
| `@octanejs/zustand/shallow` | `shallow`, `useShallow` | `shallow` verbatim; `useShallow` octane-bound |
|
|
35
|
+
| `@octanejs/zustand/middleware` | `persist`, `devtools`, `subscribeWithSelector`, `combine`, `redux`, `createJSONStorage`, … | re-exported verbatim (all framework-agnostic) |
|
|
36
|
+
| `@octanejs/zustand/traditional` | `createWithEqualityFn`, `useStoreWithEqualityFn` | octane-bound (selector + equality fn) |
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
octane keys hooks by a compiler-injected per-call-site `Symbol`, appended as the last
|
|
41
|
+
argument of every `use*` call. A custom hook is just a wrapper that **forwards** that
|
|
42
|
+
slot to the base hook it composes — which is all `useStore` does. Because the slot is
|
|
43
|
+
per-call-site, `useBearStore(a)` and `useBearStore(b)` in one component (or the same
|
|
44
|
+
hook used twice) stay independent, exactly like in React.
|
|
45
|
+
|
|
46
|
+
> **Naming matters.** The hook you call must follow the `use*` convention (`const
|
|
47
|
+
> useBearStore = create(...)`) so the compiler recognises it and injects the slot —
|
|
48
|
+
> this is the same `use*`-is-reserved-for-hooks rule React uses.
|
|
49
|
+
|
|
50
|
+
## Selecting object slices — `useShallow`
|
|
51
|
+
|
|
52
|
+
A selector that returns a fresh object/array each call (`(s) => ({ a: s.a })`) never
|
|
53
|
+
compares Object.is-equal, so it re-renders on every store change. Wrap it with
|
|
54
|
+
`useShallow` to compare by shallow equality:
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { useShallow } from '@octanejs/zustand/shallow';
|
|
58
|
+
|
|
59
|
+
function Sliced() @{
|
|
60
|
+
const { a, b } = useBearStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
61
|
+
// re-renders only when a or b actually changes
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Divergences from React
|
|
66
|
+
|
|
67
|
+
- **Unstable selectors don't loop.** React's `useSyncExternalStore` throws the dev
|
|
68
|
+
warning _"The result of getSnapshot should be cached"_ and re-renders in a loop when
|
|
69
|
+
a selector returns a new reference every render. octane settles after a bounded
|
|
70
|
+
number of renders instead — no loop, no warning. Prefer `useShallow` regardless, for
|
|
71
|
+
the same reason you would in React (avoid the extra renders).
|
|
72
|
+
|
|
73
|
+
## Equality functions — `traditional`
|
|
74
|
+
|
|
75
|
+
For the equality-fn pattern, `@octanejs/zustand/traditional` provides
|
|
76
|
+
`createWithEqualityFn` / `useStoreWithEqualityFn`:
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { createWithEqualityFn } from '@octanejs/zustand/traditional';
|
|
80
|
+
import { shallow } from '@octanejs/zustand/shallow';
|
|
81
|
+
|
|
82
|
+
const useStore = createWithEqualityFn((set) => ({ a: 0, b: 0 }), shallow);
|
|
83
|
+
const { a } = useStore((s) => ({ a: s.a })); // bails out via shallow
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For most object-slice selections, prefer `useShallow` — the v5-recommended approach.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/zustand",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "zustand bindings for the octane renderer — reuses zustand's framework-agnostic vanilla store and swaps the React binding for octane's useSyncExternalStore.",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Dominic Gannaway",
|
|
9
|
+
"email": "dg@domgan.com"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
17
|
+
"directory": "packages/zustand"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./vanilla": "./src/vanilla.ts",
|
|
29
|
+
"./shallow": "./src/shallow.ts",
|
|
30
|
+
"./middleware": "./src/middleware.ts",
|
|
31
|
+
"./traditional": "./src/traditional.ts"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"zustand": "^5.0.0",
|
|
35
|
+
"octane": "0.1.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@tsrx/react": "^0.2.32",
|
|
39
|
+
"esbuild": "^0.28.1",
|
|
40
|
+
"react": "^19.2.0",
|
|
41
|
+
"react-dom": "^19.2.0",
|
|
42
|
+
"vitest": "^4.1.9"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @octanejs/zustand — zustand for the octane renderer.
|
|
2
|
+
//
|
|
3
|
+
// zustand cleanly separates a framework-agnostic vanilla store (`createStore`)
|
|
4
|
+
// from a tiny React binding (`create` + `useStore`) layered on
|
|
5
|
+
// `useSyncExternalStore`. This package reuses the vanilla store UNCHANGED (it's
|
|
6
|
+
// pure JS — re-exported verbatim from `zustand/vanilla`) and reimplements only
|
|
7
|
+
// the binding on top of octane's `useSyncExternalStore`. The public surface
|
|
8
|
+
// (`create`, `useStore`, `createStore`) matches zustand 1:1, so existing zustand
|
|
9
|
+
// code works by changing the import from `zustand` to `@octanejs/zustand`.
|
|
10
|
+
//
|
|
11
|
+
// The one octane-specific detail is hook slots: octane keys hooks by a
|
|
12
|
+
// compiler-injected per-call-site Symbol, appended as the LAST argument of every
|
|
13
|
+
// `use*` call. A custom hook is just a wrapper that FORWARDS that slot to the
|
|
14
|
+
// base hook it composes — which is exactly what `useStore` does below. Because
|
|
15
|
+
// the slot is per-call-site, `useFoo(a)` and `useFoo(b)` in one component (or
|
|
16
|
+
// the same `useFoo` used twice) stay independent, just like in React.
|
|
17
|
+
import { useSyncExternalStore } from 'octane';
|
|
18
|
+
import { createStore } from 'zustand/vanilla';
|
|
19
|
+
import type { Mutate, StateCreator, StoreApi, StoreMutatorIdentifier } from 'zustand/vanilla';
|
|
20
|
+
|
|
21
|
+
// Re-export the vanilla core so `@octanejs/zustand` is a drop-in for both the
|
|
22
|
+
// store-only (`createStore`) and binding (`create`) entry points of zustand.
|
|
23
|
+
export { createStore } from 'zustand/vanilla';
|
|
24
|
+
export type * from 'zustand/vanilla';
|
|
25
|
+
|
|
26
|
+
type ExtractState<S> = S extends { getState: () => infer T } ? T : never;
|
|
27
|
+
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'getInitialState' | 'subscribe'>;
|
|
28
|
+
|
|
29
|
+
const identity = <T>(arg: T): T => arg;
|
|
30
|
+
|
|
31
|
+
export function useStore<S extends ReadonlyStoreApi<unknown>>(api: S): ExtractState<S>;
|
|
32
|
+
export function useStore<S extends ReadonlyStoreApi<unknown>, U>(
|
|
33
|
+
api: S,
|
|
34
|
+
selector: (state: ExtractState<S>) => U,
|
|
35
|
+
): U;
|
|
36
|
+
export function useStore<TState, StateSlice>(
|
|
37
|
+
api: ReadonlyStoreApi<TState>,
|
|
38
|
+
// The compiler appends the call-site slot Symbol after the user args, so the
|
|
39
|
+
// runtime tuple is `[selector?, slot?]`. Both are optional to the author; the
|
|
40
|
+
// slot is supplied by the octane transform.
|
|
41
|
+
...rest: [selector?: (state: TState) => StateSlice, slot?: symbol]
|
|
42
|
+
): StateSlice {
|
|
43
|
+
// Resolve the slot the way every base hook does: it's the LAST argument and a
|
|
44
|
+
// Symbol. Counting from the end means `useFoo()` (where the slot lands in the
|
|
45
|
+
// selector position) and `useFoo(sel)` both work.
|
|
46
|
+
const tail = rest[rest.length - 1];
|
|
47
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
48
|
+
const selector = (typeof rest[0] === 'function' ? rest[0] : identity) as (
|
|
49
|
+
state: TState,
|
|
50
|
+
) => StateSlice;
|
|
51
|
+
return useSyncExternalStore(
|
|
52
|
+
api.subscribe,
|
|
53
|
+
() => selector(api.getState()),
|
|
54
|
+
() => selector(api.getInitialState()),
|
|
55
|
+
slot,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type UseBoundStore<S extends ReadonlyStoreApi<unknown>> = {
|
|
60
|
+
(): ExtractState<S>;
|
|
61
|
+
<U>(selector: (state: ExtractState<S>) => U): U;
|
|
62
|
+
} & S;
|
|
63
|
+
|
|
64
|
+
type Create = {
|
|
65
|
+
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
|
|
66
|
+
initializer: StateCreator<T, [], Mos>,
|
|
67
|
+
): UseBoundStore<Mutate<StoreApi<T>, Mos>>;
|
|
68
|
+
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
|
|
69
|
+
initializer: StateCreator<T, [], Mos>,
|
|
70
|
+
) => UseBoundStore<Mutate<StoreApi<T>, Mos>>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const createImpl = (<T>(createState: StateCreator<T, [], []>) => {
|
|
74
|
+
const api = createStore(createState);
|
|
75
|
+
// The bound hook forwards ALL args (selector + the appended slot) straight
|
|
76
|
+
// through to `useStore`. It is NOT compiled by octane (it lives in this
|
|
77
|
+
// dependency), so no slot is injected here — the slot is the one the consumer's
|
|
78
|
+
// `useFoo(...)` call site carries, which is exactly what makes each call-site
|
|
79
|
+
// independent.
|
|
80
|
+
const useBoundStore = (...args: unknown[]) =>
|
|
81
|
+
(useStore as (...a: unknown[]) => unknown)(api, ...args);
|
|
82
|
+
Object.assign(useBoundStore, api);
|
|
83
|
+
return useBoundStore;
|
|
84
|
+
}) as Create;
|
|
85
|
+
|
|
86
|
+
export const create = (<T>(createState?: StateCreator<T, [], []>) =>
|
|
87
|
+
createState ? createImpl(createState) : createImpl) as Create;
|
|
88
|
+
|
|
89
|
+
export default create;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// `@octanejs/zustand/middleware` — re-exported verbatim from zustand.
|
|
2
|
+
//
|
|
3
|
+
// Every zustand middleware (persist, devtools, subscribeWithSelector, combine,
|
|
4
|
+
// redux, createJSONStorage, …) is a framework-agnostic store enhancer of the form
|
|
5
|
+
// `(set, get, api) => state` — it operates purely on the vanilla store and imports
|
|
6
|
+
// NO React. So it composes with octane's `create`/`useStore` unchanged, and we
|
|
7
|
+
// re-export it as-is (mirroring src/vanilla.ts).
|
|
8
|
+
export * from 'zustand/middleware';
|
package/src/shallow.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `@octanejs/zustand/shallow` — the zustand selector-equality helpers.
|
|
2
|
+
//
|
|
3
|
+
// `shallow` is a pure comparator (no hooks) and is re-exported verbatim from
|
|
4
|
+
// zustand. `useShallow` IS a hook (zustand builds it on React.useRef), so it gets
|
|
5
|
+
// an octane reimplementation: same memoize-the-selection-by-shallow-equality
|
|
6
|
+
// logic, but built on octane's `useRef` with the compiler-injected slot forwarded
|
|
7
|
+
// through (exactly like `useStore`). Use it to select an object/array slice
|
|
8
|
+
// without the unstable-selector re-render churn:
|
|
9
|
+
//
|
|
10
|
+
// const { a, b } = useBearStore(useShallow((s) => ({ a: s.a, b: s.b })));
|
|
11
|
+
import { useRef } from 'octane';
|
|
12
|
+
import { shallow } from 'zustand/vanilla/shallow';
|
|
13
|
+
|
|
14
|
+
export { shallow } from 'zustand/vanilla/shallow';
|
|
15
|
+
|
|
16
|
+
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U;
|
|
17
|
+
export function useShallow<S, U>(
|
|
18
|
+
selector: (state: S) => U,
|
|
19
|
+
// Compiler-injected trailing slot (forwarded to the inner useRef).
|
|
20
|
+
...rest: [slot?: symbol]
|
|
21
|
+
): (state: S) => U {
|
|
22
|
+
const tail = rest[rest.length - 1];
|
|
23
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
24
|
+
const prev = useRef<U | undefined>(undefined, slot);
|
|
25
|
+
return (state: S) => {
|
|
26
|
+
const next = selector(state);
|
|
27
|
+
return shallow(prev.current, next) ? (prev.current as U) : (prev.current = next);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// `@octanejs/zustand/traditional` — zustand's equality-function binding.
|
|
2
|
+
//
|
|
3
|
+
// `createWithEqualityFn` / `useStoreWithEqualityFn` let a selector pair with a
|
|
4
|
+
// custom equality function (e.g. `shallow`). zustand builds these on React's
|
|
5
|
+
// `use-sync-external-store/shim/with-selector` — a polyfill whose extra machinery
|
|
6
|
+
// (a closure-memoizer in useMemo + a useEffect commit) exists for React's
|
|
7
|
+
// CONCURRENT rendering, where a render can be produced and then thrown away.
|
|
8
|
+
//
|
|
9
|
+
// octane renders synchronously (a render always commits), so none of that is
|
|
10
|
+
// needed: we build directly on octane's REAL `useSyncExternalStore`. A ref caches
|
|
11
|
+
// the last-returned selection and `getSnapshot` returns that SAME reference while
|
|
12
|
+
// the equality fn says the selection is unchanged — so useSyncExternalStore's own
|
|
13
|
+
// Object.is check bails out the re-render. One extra base hook (the ref), with its
|
|
14
|
+
// slot derived from the wrapper's forwarded slot.
|
|
15
|
+
//
|
|
16
|
+
// Note: v5 recommends `useShallow` over this equality-fn pattern for object
|
|
17
|
+
// slices; `traditional` exists for code that still uses it.
|
|
18
|
+
import { useSyncExternalStore, useRef } from 'octane';
|
|
19
|
+
import { createStore } from 'zustand/vanilla';
|
|
20
|
+
import type { StateCreator, StoreApi } from 'zustand/vanilla';
|
|
21
|
+
|
|
22
|
+
type ExtractState<S> = S extends { getState: () => infer T } ? T : never;
|
|
23
|
+
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'getInitialState' | 'subscribe'>;
|
|
24
|
+
|
|
25
|
+
const identity = <T>(arg: T): T => arg;
|
|
26
|
+
|
|
27
|
+
// Derive a stable, distinct sub-slot from the wrapper's slot. `Symbol.for` interns
|
|
28
|
+
// by description, so the same call site always yields the same sub-slot; the
|
|
29
|
+
// `:wsel:` namespace keeps it clear of useSyncExternalStore's `:uses:` slots.
|
|
30
|
+
function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
31
|
+
return slot !== undefined ? Symbol.for((slot.description ?? '') + ':wsel:' + tag) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SelectionCell<U> {
|
|
35
|
+
hasValue: boolean;
|
|
36
|
+
value: U | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>>(
|
|
40
|
+
api: S,
|
|
41
|
+
): ExtractState<S>;
|
|
42
|
+
export function useStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>, U>(
|
|
43
|
+
api: S,
|
|
44
|
+
selector: (state: ExtractState<S>) => U,
|
|
45
|
+
equalityFn?: (a: U, b: U) => boolean,
|
|
46
|
+
): U;
|
|
47
|
+
export function useStoreWithEqualityFn<TState, StateSlice>(
|
|
48
|
+
api: ReadonlyStoreApi<TState>,
|
|
49
|
+
// Runtime args are `[selector?, equalityFn?, slot?]`; the slot is appended by
|
|
50
|
+
// the octane compiler and is always LAST.
|
|
51
|
+
...rest: [
|
|
52
|
+
selector?: (state: TState) => StateSlice,
|
|
53
|
+
equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
|
|
54
|
+
slot?: symbol,
|
|
55
|
+
]
|
|
56
|
+
): StateSlice {
|
|
57
|
+
const tail = rest[rest.length - 1];
|
|
58
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
59
|
+
const userArgs = slot !== undefined ? rest.slice(0, -1) : rest;
|
|
60
|
+
const selector = (typeof userArgs[0] === 'function' ? userArgs[0] : identity) as (
|
|
61
|
+
state: TState,
|
|
62
|
+
) => StateSlice;
|
|
63
|
+
const equalityFn =
|
|
64
|
+
typeof userArgs[1] === 'function'
|
|
65
|
+
? (userArgs[1] as (a: StateSlice, b: StateSlice) => boolean)
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
const cache = useRef<SelectionCell<StateSlice>>(
|
|
69
|
+
{ hasValue: false, value: undefined },
|
|
70
|
+
subSlot(slot, 'sel'),
|
|
71
|
+
);
|
|
72
|
+
// Returns the cached reference while the selection is "equal" → octane's
|
|
73
|
+
// useSyncExternalStore sees an Object.is-equal snapshot and bails out.
|
|
74
|
+
const select = (state: TState): StateSlice => {
|
|
75
|
+
const next = selector(state);
|
|
76
|
+
const c = cache.current;
|
|
77
|
+
if (c.hasValue) {
|
|
78
|
+
const prev = c.value as StateSlice;
|
|
79
|
+
if (equalityFn ? equalityFn(prev, next) : Object.is(prev, next)) return prev;
|
|
80
|
+
}
|
|
81
|
+
c.hasValue = true;
|
|
82
|
+
c.value = next;
|
|
83
|
+
return next;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return useSyncExternalStore(
|
|
87
|
+
api.subscribe,
|
|
88
|
+
() => select(api.getState()),
|
|
89
|
+
() => select(api.getInitialState()),
|
|
90
|
+
slot,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type UseBoundStoreWithEqualityFn<S extends ReadonlyStoreApi<unknown>> = {
|
|
95
|
+
(): ExtractState<S>;
|
|
96
|
+
<U>(selector: (state: ExtractState<S>) => U, equalityFn?: (a: U, b: U) => boolean): U;
|
|
97
|
+
} & S;
|
|
98
|
+
|
|
99
|
+
type CreateWithEqualityFn = {
|
|
100
|
+
<T, U = T>(
|
|
101
|
+
initializer: StateCreator<T, [], []>,
|
|
102
|
+
defaultEqualityFn?: (a: U, b: U) => boolean,
|
|
103
|
+
): UseBoundStoreWithEqualityFn<StoreApi<T>>;
|
|
104
|
+
<T, U = T>(): (
|
|
105
|
+
initializer: StateCreator<T, [], []>,
|
|
106
|
+
defaultEqualityFn?: (a: U, b: U) => boolean,
|
|
107
|
+
) => UseBoundStoreWithEqualityFn<StoreApi<T>>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const createWithEqualityFnImpl = (<T>(
|
|
111
|
+
createState: StateCreator<T, [], []>,
|
|
112
|
+
defaultEqualityFn?: (a: unknown, b: unknown) => boolean,
|
|
113
|
+
) => {
|
|
114
|
+
const api = createStore(createState);
|
|
115
|
+
// The bound hook receives `[selector?, equalityFn?, slot?]`. We resolve the
|
|
116
|
+
// default equality fn, then hand off to useStoreWithEqualityFn with the slot
|
|
117
|
+
// kept last (it re-parses the same trailing-slot shape).
|
|
118
|
+
const useBoundStoreWithEqualityFn = (...args: unknown[]) => {
|
|
119
|
+
const tail = args[args.length - 1];
|
|
120
|
+
const slot = typeof tail === 'symbol' ? tail : undefined;
|
|
121
|
+
const u = slot !== undefined ? args.slice(0, -1) : args;
|
|
122
|
+
const selector = u[0];
|
|
123
|
+
const equalityFn = u.length > 1 ? u[1] : defaultEqualityFn;
|
|
124
|
+
return (useStoreWithEqualityFn as (...a: unknown[]) => unknown)(
|
|
125
|
+
api,
|
|
126
|
+
selector,
|
|
127
|
+
equalityFn,
|
|
128
|
+
slot,
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
Object.assign(useBoundStoreWithEqualityFn, api);
|
|
132
|
+
return useBoundStoreWithEqualityFn;
|
|
133
|
+
}) as CreateWithEqualityFn;
|
|
134
|
+
|
|
135
|
+
export const createWithEqualityFn = (<T, U = T>(
|
|
136
|
+
createState?: StateCreator<T, [], []>,
|
|
137
|
+
defaultEqualityFn?: (a: U, b: U) => boolean,
|
|
138
|
+
) =>
|
|
139
|
+
createState
|
|
140
|
+
? createWithEqualityFnImpl(
|
|
141
|
+
createState,
|
|
142
|
+
defaultEqualityFn as (a: unknown, b: unknown) => boolean,
|
|
143
|
+
)
|
|
144
|
+
: createWithEqualityFnImpl) as CreateWithEqualityFn;
|
package/src/vanilla.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The framework-agnostic core, re-exported verbatim from zustand. @octanejs/zustand
|
|
2
|
+
// reuses zustand's vanilla store unchanged — only the React binding (see index.ts)
|
|
3
|
+
// is swapped for one built on octane's `useSyncExternalStore`. Authors who only
|
|
4
|
+
// need the store (no component binding) can import from `@octanejs/zustand/vanilla`
|
|
5
|
+
// exactly as they would from `zustand/vanilla`.
|
|
6
|
+
export * from 'zustand/vanilla';
|
|
7
|
+
export { createStore as default } from 'zustand/vanilla';
|