@octanejs/tanstack-store 0.0.1
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 +71 -0
- package/package.json +57 -0
- package/src/createStoreContext.tsrx +68 -0
- package/src/createStoreContext.tsrx.d.ts +9 -0
- package/src/index.ts +14 -0
- package/src/internal.ts +36 -0
- package/src/useAtom.ts +37 -0
- package/src/useCreateAtom.ts +38 -0
- package/src/useCreateStore.ts +45 -0
- package/src/useSelector.ts +81 -0
- package/src/useStore.ts +47 -0
- package/src/useSyncExternalStoreWithSelector.ts +90 -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,71 @@
|
|
|
1
|
+
# @octanejs/tanstack-store
|
|
2
|
+
|
|
3
|
+
[TanStack Store](https://tanstack.com/store) bindings for the
|
|
4
|
+
[Octane](https://github.com/octanejs/octane) UI framework.
|
|
5
|
+
|
|
6
|
+
This package ports `@tanstack/react-store@0.11.0` by reusing the
|
|
7
|
+
framework-agnostic `@tanstack/store` core unchanged and transcribing its small
|
|
8
|
+
React hook layer onto Octane. The supported runtime and type surfaces match the
|
|
9
|
+
React package, so an application can migrate by changing its import:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// before
|
|
13
|
+
import { createStore, useSelector } from '@tanstack/react-store'
|
|
14
|
+
|
|
15
|
+
// after
|
|
16
|
+
import { createStore, useSelector } from '@octanejs/tanstack-store'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { createStore, useSelector } from '@octanejs/tanstack-store'
|
|
21
|
+
|
|
22
|
+
const counter = createStore({ count: 0 })
|
|
23
|
+
|
|
24
|
+
export function Counter() @{
|
|
25
|
+
const count = useSelector(counter, (state) => state.count)
|
|
26
|
+
|
|
27
|
+
<button
|
|
28
|
+
onClick={() =>
|
|
29
|
+
counter.setState((state) => ({ count: state.count + 1 }))
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
{'Count: ' + count}
|
|
33
|
+
</button>
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API
|
|
38
|
+
|
|
39
|
+
The package re-exports all of `@tanstack/store@0.11.0`, including atoms,
|
|
40
|
+
stores, actions, derived sources, `shallow`, and async atoms. It also provides
|
|
41
|
+
the stable adapter surface from `@tanstack/react-store@0.11.0`:
|
|
42
|
+
|
|
43
|
+
- `useSelector` reads any atom or store and supports a custom comparator.
|
|
44
|
+
- `useAtom` returns a writable atom's current value and setter.
|
|
45
|
+
- `useCreateAtom` and `useCreateStore` create a stable source for a component
|
|
46
|
+
lifetime.
|
|
47
|
+
- `createStoreContext` transports typed bundles of atoms and stores through an
|
|
48
|
+
Octane subtree.
|
|
49
|
+
- `useStore` remains available as the upstream-deprecated compatibility alias
|
|
50
|
+
for `useSelector`.
|
|
51
|
+
|
|
52
|
+
The upstream experimental `_useStore` export is intentionally omitted. Use
|
|
53
|
+
`useSelector` with `store.actions` or `store.setState` instead.
|
|
54
|
+
|
|
55
|
+
The adapter forwards Octane's compiler-assigned hook slots through its composed
|
|
56
|
+
hooks. Distinct calls in one component therefore keep independent subscriptions
|
|
57
|
+
and component-created sources, including when optional selector and comparator
|
|
58
|
+
arguments are omitted.
|
|
59
|
+
|
|
60
|
+
## Verification
|
|
61
|
+
|
|
62
|
+
Behavioral tests cover writable and derived sources, comparator semantics,
|
|
63
|
+
source replacement, multiple hook call sites, context nesting, subscription
|
|
64
|
+
cleanup, actions, and server rendering. Differential tests compile the same
|
|
65
|
+
`.tsrx` fixture for Octane and React and compare rendered output after each
|
|
66
|
+
interaction. Compile-time tests cover overload inference and readonly versus
|
|
67
|
+
writable source constraints.
|
|
68
|
+
|
|
69
|
+
Current scope and verification status are tracked in the generated
|
|
70
|
+
[bindings status table](../../docs/bindings-status.md), sourced from this
|
|
71
|
+
package's [`status.json`](./status.json).
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/tanstack-store",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"octane": {
|
|
10
|
+
"hookSlots": {
|
|
11
|
+
"manual": [
|
|
12
|
+
"src"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"description": "TanStack Store bindings for Octane — reuses the framework-agnostic @tanstack/store core with an Octane hook adapter.",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Dominic Gannaway",
|
|
19
|
+
"email": "dg@domgan.com"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
27
|
+
"directory": "packages/tanstack-store"
|
|
28
|
+
},
|
|
29
|
+
"main": "src/index.ts",
|
|
30
|
+
"module": "src/index.ts",
|
|
31
|
+
"types": "src/index.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": "./src/index.ts"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@tanstack/store": "0.11.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"octane": "0.1.7"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@tanstack/react-store": "^0.11.0",
|
|
47
|
+
"@tsrx/react": "^0.2.37",
|
|
48
|
+
"esbuild": "^0.28.1",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"react-dom": "^19.2.0",
|
|
51
|
+
"vitest": "^4.1.9",
|
|
52
|
+
"octane": "0.1.7"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createContext, useContext } from 'octane';
|
|
2
|
+
import type { ComponentBody } from 'octane';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a typed Octane context for sharing a bundle of atoms and stores with a subtree.
|
|
6
|
+
*
|
|
7
|
+
* The returned `StoreProvider` only transports the provided object through
|
|
8
|
+
* Octane context. Consumers destructure the contextual atoms and stores, then
|
|
9
|
+
* compose them with the existing hooks like {@link useSelector},
|
|
10
|
+
* {@link useSelector} and {@link useAtom}.
|
|
11
|
+
*
|
|
12
|
+
* The object shape is preserved exactly, so keyed atoms and stores remain fully
|
|
13
|
+
* typed when read back with `useStoreContext()`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const { StoreProvider, useStoreContext } = createStoreContext<{
|
|
18
|
+
* countAtom: Atom<number>
|
|
19
|
+
* totalsStore: Store<{ count: number }>
|
|
20
|
+
* }>()
|
|
21
|
+
*
|
|
22
|
+
* function CountButton() {
|
|
23
|
+
* const { countAtom, totalsStore } = useStoreContext()
|
|
24
|
+
* const count = useSelector(countAtom)
|
|
25
|
+
* const total = useSelector(totalsStore, (state) => state.count)
|
|
26
|
+
*
|
|
27
|
+
* return (
|
|
28
|
+
* <button
|
|
29
|
+
* type="button"
|
|
30
|
+
* onClick={() => totalsStore.setState((state) => ({ ...state, count: state.count + 1 }))}
|
|
31
|
+
* >
|
|
32
|
+
* {count} / {total}
|
|
33
|
+
* </button>
|
|
34
|
+
* )
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @throws When `useStoreContext()` is called outside the matching `StoreProvider`.
|
|
39
|
+
*/
|
|
40
|
+
export function createStoreContext<TValue extends object>(): {
|
|
41
|
+
StoreProvider: ComponentBody<{
|
|
42
|
+
value: TValue;
|
|
43
|
+
children?: any;
|
|
44
|
+
}>;
|
|
45
|
+
useStoreContext: () => TValue;
|
|
46
|
+
} {
|
|
47
|
+
const Context = createContext<TValue | null>(null);
|
|
48
|
+
(Context as typeof Context & { displayName?: string }).displayName = 'StoreContext';
|
|
49
|
+
|
|
50
|
+
function StoreProvider({ children, value }: { value: TValue; children?: any }) {
|
|
51
|
+
return <Context.Provider value={value}>{children}</Context.Provider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function useStoreContext() {
|
|
55
|
+
const value = useContext(Context);
|
|
56
|
+
|
|
57
|
+
if (value === null) {
|
|
58
|
+
throw new Error('Missing StoreProvider for StoreContext');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
StoreProvider,
|
|
66
|
+
useStoreContext,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from '@tanstack/store';
|
|
2
|
+
|
|
3
|
+
// create hooks
|
|
4
|
+
export * from './createStoreContext.tsrx';
|
|
5
|
+
export * from './useCreateAtom';
|
|
6
|
+
export * from './useCreateStore';
|
|
7
|
+
|
|
8
|
+
// read hooks
|
|
9
|
+
export * from './useSelector';
|
|
10
|
+
|
|
11
|
+
// tuple hooks - [state, setState]
|
|
12
|
+
export * from './useAtom';
|
|
13
|
+
|
|
14
|
+
export * from './useStore'; // @deprecated in favor of useSelector
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Slot mechanics for the binding's plain-`.ts` hooks. Octane injects a
|
|
2
|
+
// per-call-site Symbol into calls made by compiled components; the binding
|
|
3
|
+
// forwards that symbol and derives a distinct sub-slot for each hook it
|
|
4
|
+
// composes.
|
|
5
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
6
|
+
const bareTagCache = new Map<string, symbol>();
|
|
7
|
+
|
|
8
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
9
|
+
if (slot === undefined) {
|
|
10
|
+
let bare = bareTagCache.get(tag);
|
|
11
|
+
if (bare === undefined) {
|
|
12
|
+
bare = Symbol.for(`@octanejs/tanstack-store:${tag}`);
|
|
13
|
+
bareTagCache.set(tag, bare);
|
|
14
|
+
}
|
|
15
|
+
return bare;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let byTag = subSlotCache.get(slot);
|
|
19
|
+
if (byTag === undefined) {
|
|
20
|
+
byTag = new Map();
|
|
21
|
+
subSlotCache.set(slot, byTag);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let child = byTag.get(tag);
|
|
25
|
+
if (child === undefined) {
|
|
26
|
+
child = Symbol.for(`${slot.description ?? ''}:${tag}`);
|
|
27
|
+
byTag.set(tag, child);
|
|
28
|
+
}
|
|
29
|
+
return child;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function splitSlot(args: unknown[]): [unknown[], symbol | undefined] {
|
|
33
|
+
const tail = args[args.length - 1];
|
|
34
|
+
const slot = typeof tail === 'symbol' ? tail : undefined;
|
|
35
|
+
return [slot === undefined ? args : args.slice(0, -1), slot];
|
|
36
|
+
}
|
package/src/useAtom.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useSelector } from './useSelector';
|
|
2
|
+
import { splitSlot, subSlot } from './internal';
|
|
3
|
+
import type { Atom } from '@tanstack/store';
|
|
4
|
+
import type { UseSelectorOptions } from './useSelector';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the current atom value together with a stable setter.
|
|
8
|
+
*
|
|
9
|
+
* This is the writable-atom convenience hook for components that need to both
|
|
10
|
+
* read and update the same atom.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const [count, setCount] = useAtom(countAtom)
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function useAtom<TValue>(
|
|
18
|
+
atom: Atom<TValue>,
|
|
19
|
+
options?: UseSelectorOptions<TValue>,
|
|
20
|
+
): [TValue, Atom<TValue>['set']];
|
|
21
|
+
export function useAtom<TValue>(
|
|
22
|
+
atom: Atom<TValue>,
|
|
23
|
+
...rest: [options?: UseSelectorOptions<TValue>, slot?: symbol]
|
|
24
|
+
): [TValue, Atom<TValue>['set']] {
|
|
25
|
+
const [user, slot] = splitSlot(rest);
|
|
26
|
+
const options = user[0] as UseSelectorOptions<TValue> | undefined;
|
|
27
|
+
const value = (
|
|
28
|
+
useSelector as (
|
|
29
|
+
source: Atom<TValue>,
|
|
30
|
+
selector: undefined,
|
|
31
|
+
options: UseSelectorOptions<TValue> | undefined,
|
|
32
|
+
slot: symbol,
|
|
33
|
+
) => TValue
|
|
34
|
+
)(atom, undefined, options, subSlot(slot, 'atom:value'));
|
|
35
|
+
|
|
36
|
+
return [value, atom.set];
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useState } from 'octane';
|
|
2
|
+
import { createAtom } from '@tanstack/store';
|
|
3
|
+
import { splitSlot } from './internal';
|
|
4
|
+
import type { Atom, AtomOptions, ReadonlyAtom } from '@tanstack/store';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a stable atom instance for the lifetime of the component.
|
|
8
|
+
*
|
|
9
|
+
* Pass an initial value to create a writable atom, or a getter function to
|
|
10
|
+
* create a readonly derived atom. This hook mirrors the overloads from
|
|
11
|
+
* {@link createAtom}, but ensures the atom is only created once per mount.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const countAtom = useCreateAtom(0)
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function useCreateAtom<T>(
|
|
19
|
+
getValue: (prev?: NoInfer<T>) => T,
|
|
20
|
+
options?: AtomOptions<T>,
|
|
21
|
+
): ReadonlyAtom<T>;
|
|
22
|
+
export function useCreateAtom<T>(initialValue: T, options?: AtomOptions<T>): Atom<T>;
|
|
23
|
+
export function useCreateAtom<T>(
|
|
24
|
+
valueOrFn: T | ((prev?: T) => T),
|
|
25
|
+
...rest: [options?: AtomOptions<T>, slot?: symbol]
|
|
26
|
+
): Atom<T> | ReadonlyAtom<T> {
|
|
27
|
+
const [user, slot] = splitSlot(rest);
|
|
28
|
+
const options = user[0] as AtomOptions<T> | undefined;
|
|
29
|
+
const [atom] = useState<Atom<T> | ReadonlyAtom<T>>(() => {
|
|
30
|
+
if (typeof valueOrFn === 'function') {
|
|
31
|
+
return createAtom(valueOrFn as (prev?: NoInfer<T>) => T, options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return createAtom(valueOrFn, options);
|
|
35
|
+
}, slot);
|
|
36
|
+
|
|
37
|
+
return atom;
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useState } from 'octane';
|
|
2
|
+
import { createStore } from '@tanstack/store';
|
|
3
|
+
import type { ReadonlyStore, Store, StoreActionMap, StoreActionsFactory } from '@tanstack/store';
|
|
4
|
+
import { splitSlot } from './internal';
|
|
5
|
+
|
|
6
|
+
type NonFunction<T> = T extends (...args: Array<any>) => any ? never : T;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a stable store instance for the lifetime of the component.
|
|
10
|
+
*
|
|
11
|
+
* Pass an initial value to create a writable store, or a getter function to
|
|
12
|
+
* create a readonly derived store. This hook mirrors the overloads from
|
|
13
|
+
* {@link createStore}, but ensures the store is only created once per mount.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const counterStore = useCreateStore({ count: 0 })
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useCreateStore<T>(getValue: (prev?: NoInfer<T>) => T): ReadonlyStore<T>;
|
|
21
|
+
export function useCreateStore<T>(initialValue: T): Store<T>;
|
|
22
|
+
export function useCreateStore<T, TActions extends StoreActionMap>(
|
|
23
|
+
initialValue: NonFunction<T>,
|
|
24
|
+
actions: StoreActionsFactory<T, TActions>,
|
|
25
|
+
): Store<T, TActions>;
|
|
26
|
+
export function useCreateStore<T, TActions extends StoreActionMap>(
|
|
27
|
+
valueOrFn: T | ((prev?: T) => T),
|
|
28
|
+
...rest: [actions?: StoreActionsFactory<T, TActions>, slot?: symbol]
|
|
29
|
+
): Store<T, TActions> | Store<T> | ReadonlyStore<T> {
|
|
30
|
+
const [user, slot] = splitSlot(rest);
|
|
31
|
+
const actions = user[0] as StoreActionsFactory<T, TActions> | undefined;
|
|
32
|
+
const [store] = useState<Store<T, TActions> | Store<T> | ReadonlyStore<T>>(() => {
|
|
33
|
+
if (typeof valueOrFn === 'function') {
|
|
34
|
+
return createStore(valueOrFn as (prev?: NoInfer<T>) => T);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (actions) {
|
|
38
|
+
return createStore(valueOrFn as NonFunction<T>, actions);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return createStore(valueOrFn);
|
|
42
|
+
}, slot);
|
|
43
|
+
|
|
44
|
+
return store;
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback } from 'octane';
|
|
2
|
+
import { splitSlot, subSlot } from './internal';
|
|
3
|
+
import { useSyncExternalStoreWithSelector } from './useSyncExternalStoreWithSelector';
|
|
4
|
+
|
|
5
|
+
export interface UseSelectorOptions<TSelected> {
|
|
6
|
+
compare?: (a: TSelected, b: TSelected) => boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SyncExternalStoreSubscribe = Parameters<typeof useSyncExternalStoreWithSelector>[0];
|
|
10
|
+
|
|
11
|
+
type SelectionSource<T> = {
|
|
12
|
+
get: () => T;
|
|
13
|
+
subscribe: (listener: (value: T) => void) => {
|
|
14
|
+
unsubscribe: () => void;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function defaultCompare<T>(a: T, b: T) {
|
|
19
|
+
return a === b;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Selects a slice of state from an atom or store and subscribes the component
|
|
24
|
+
* to that selection.
|
|
25
|
+
*
|
|
26
|
+
* This is the primary Octane read hook for TanStack Store. It works with any
|
|
27
|
+
* source that exposes `get()` and `subscribe()`, including atoms, readonly
|
|
28
|
+
* atoms, stores, and readonly stores.
|
|
29
|
+
*
|
|
30
|
+
* Omit the selector to subscribe to the whole value.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* const count = useSelector(counterStore, (state) => state.count)
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const value = useSelector(countAtom)
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function useSelector<TSource, TSelected = NoInfer<TSource>>(
|
|
43
|
+
source: SelectionSource<TSource>,
|
|
44
|
+
selector?: (snapshot: TSource) => TSelected,
|
|
45
|
+
options?: UseSelectorOptions<TSelected>,
|
|
46
|
+
): TSelected;
|
|
47
|
+
export function useSelector<TSource, TSelected = NoInfer<TSource>>(
|
|
48
|
+
source: SelectionSource<TSource>,
|
|
49
|
+
...rest: [
|
|
50
|
+
selector?: (snapshot: TSource) => TSelected,
|
|
51
|
+
options?: UseSelectorOptions<TSelected>,
|
|
52
|
+
slot?: symbol,
|
|
53
|
+
]
|
|
54
|
+
): TSelected {
|
|
55
|
+
const [user, slot] = splitSlot(rest);
|
|
56
|
+
const selector =
|
|
57
|
+
(user[0] as ((snapshot: TSource) => TSelected) | undefined) ??
|
|
58
|
+
((snapshot: TSource) => snapshot as unknown as TSelected);
|
|
59
|
+
const options = user[1] as UseSelectorOptions<TSelected> | undefined;
|
|
60
|
+
const compare = options?.compare ?? defaultCompare;
|
|
61
|
+
|
|
62
|
+
const subscribe: SyncExternalStoreSubscribe = useCallback(
|
|
63
|
+
(handleStoreChange) => {
|
|
64
|
+
const { unsubscribe } = source.subscribe(handleStoreChange);
|
|
65
|
+
return unsubscribe;
|
|
66
|
+
},
|
|
67
|
+
[source],
|
|
68
|
+
subSlot(slot, 'selector:subscribe'),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const getSnapshot = useCallback(() => source.get(), [source], subSlot(slot, 'selector:snapshot'));
|
|
72
|
+
|
|
73
|
+
return useSyncExternalStoreWithSelector(
|
|
74
|
+
subscribe,
|
|
75
|
+
getSnapshot,
|
|
76
|
+
getSnapshot,
|
|
77
|
+
selector,
|
|
78
|
+
compare,
|
|
79
|
+
subSlot(slot, 'selector:external-store'),
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/useStore.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useSelector } from './useSelector';
|
|
2
|
+
import { splitSlot } from './internal';
|
|
3
|
+
import type { UseSelectorOptions } from './useSelector';
|
|
4
|
+
|
|
5
|
+
type SelectionSource<T> = {
|
|
6
|
+
get: () => T;
|
|
7
|
+
subscribe: (listener: (value: T) => void) => {
|
|
8
|
+
unsubscribe: () => void;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deprecated alias for {@link useSelector}.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const count = useStore(counterStore, (state) => state.count)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @deprecated Use `useSelector` instead.
|
|
21
|
+
*/
|
|
22
|
+
export function useStore<TSource, TSelected = NoInfer<TSource>>(
|
|
23
|
+
source: SelectionSource<TSource>,
|
|
24
|
+
selector?: (snapshot: TSource) => TSelected,
|
|
25
|
+
compare?: (a: TSelected, b: TSelected) => boolean,
|
|
26
|
+
): TSelected;
|
|
27
|
+
export function useStore<TSource, TSelected = NoInfer<TSource>>(
|
|
28
|
+
source: SelectionSource<TSource>,
|
|
29
|
+
...rest: [
|
|
30
|
+
selector?: (snapshot: TSource) => TSelected,
|
|
31
|
+
compare?: (a: TSelected, b: TSelected) => boolean,
|
|
32
|
+
slot?: symbol,
|
|
33
|
+
]
|
|
34
|
+
): TSelected {
|
|
35
|
+
const [user, slot] = splitSlot(rest);
|
|
36
|
+
const selector = user[0] as ((snapshot: TSource) => TSelected) | undefined;
|
|
37
|
+
const compare = user[1] as ((a: TSelected, b: TSelected) => boolean) | undefined;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
useSelector as (
|
|
41
|
+
source: SelectionSource<TSource>,
|
|
42
|
+
selector: ((snapshot: TSource) => TSelected) | undefined,
|
|
43
|
+
options: UseSelectorOptions<TSelected>,
|
|
44
|
+
slot?: symbol,
|
|
45
|
+
) => TSelected
|
|
46
|
+
)(source, selector, { compare }, slot);
|
|
47
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Port of React's `use-sync-external-store/with-selector` shim onto Octane's
|
|
2
|
+
// native useSyncExternalStore. The selection is recomputed only when the
|
|
3
|
+
// snapshot changes; when `isEqual` accepts the next selection, the previous
|
|
4
|
+
// reference is retained so the external-store hook does not re-render.
|
|
5
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'octane';
|
|
6
|
+
import { subSlot } from './internal';
|
|
7
|
+
|
|
8
|
+
const objectIs: (x: unknown, y: unknown) => boolean =
|
|
9
|
+
typeof Object.is === 'function'
|
|
10
|
+
? Object.is
|
|
11
|
+
: (x: any, y: any) => (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
|
|
12
|
+
|
|
13
|
+
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
|
|
14
|
+
subscribe: (onStoreChange: () => void) => () => void,
|
|
15
|
+
getSnapshot: () => Snapshot,
|
|
16
|
+
getServerSnapshot: undefined | null | (() => Snapshot),
|
|
17
|
+
selector: (snapshot: Snapshot) => Selection,
|
|
18
|
+
isEqual?: (a: Selection, b: Selection) => boolean,
|
|
19
|
+
slot?: symbol,
|
|
20
|
+
): Selection {
|
|
21
|
+
const instRef = useRef<{ hasValue: boolean; value: Selection | null } | null>(
|
|
22
|
+
null,
|
|
23
|
+
subSlot(slot, 'ws:inst'),
|
|
24
|
+
);
|
|
25
|
+
let inst: { hasValue: boolean; value: Selection | null };
|
|
26
|
+
if (instRef.current === null) {
|
|
27
|
+
inst = { hasValue: false, value: null };
|
|
28
|
+
instRef.current = inst;
|
|
29
|
+
} else {
|
|
30
|
+
inst = instRef.current;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [getSelection, getServerSelection] = useMemo(
|
|
34
|
+
() => {
|
|
35
|
+
let hasMemo = false;
|
|
36
|
+
let memoizedSnapshot: Snapshot;
|
|
37
|
+
let memoizedSelection: Selection;
|
|
38
|
+
const memoizedSelector = (nextSnapshot: Snapshot): Selection => {
|
|
39
|
+
if (!hasMemo) {
|
|
40
|
+
hasMemo = true;
|
|
41
|
+
memoizedSnapshot = nextSnapshot;
|
|
42
|
+
const nextSelection = selector(nextSnapshot);
|
|
43
|
+
if (isEqual !== undefined && inst.hasValue) {
|
|
44
|
+
const currentSelection = inst.value as Selection;
|
|
45
|
+
if (isEqual(currentSelection, nextSelection)) {
|
|
46
|
+
return (memoizedSelection = currentSelection);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return (memoizedSelection = nextSelection);
|
|
50
|
+
}
|
|
51
|
+
const currentSelection = memoizedSelection;
|
|
52
|
+
if (objectIs(memoizedSnapshot, nextSnapshot)) return currentSelection;
|
|
53
|
+
const nextSelection = selector(nextSnapshot);
|
|
54
|
+
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
|
|
55
|
+
memoizedSnapshot = nextSnapshot;
|
|
56
|
+
return currentSelection;
|
|
57
|
+
}
|
|
58
|
+
memoizedSnapshot = nextSnapshot;
|
|
59
|
+
return (memoizedSelection = nextSelection);
|
|
60
|
+
};
|
|
61
|
+
const maybeGetServerSnapshot = getServerSnapshot === undefined ? null : getServerSnapshot;
|
|
62
|
+
return [
|
|
63
|
+
() => memoizedSelector(getSnapshot()),
|
|
64
|
+
maybeGetServerSnapshot === null
|
|
65
|
+
? undefined
|
|
66
|
+
: () => memoizedSelector(maybeGetServerSnapshot!()),
|
|
67
|
+
] as const;
|
|
68
|
+
},
|
|
69
|
+
[getSnapshot, getServerSnapshot, selector, isEqual],
|
|
70
|
+
subSlot(slot, 'ws:memo'),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const value = useSyncExternalStore(
|
|
74
|
+
subscribe,
|
|
75
|
+
getSelection,
|
|
76
|
+
getServerSelection ?? getSelection,
|
|
77
|
+
subSlot(slot, 'ws:uses'),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
useEffect(
|
|
81
|
+
() => {
|
|
82
|
+
inst.hasValue = true;
|
|
83
|
+
inst.value = value;
|
|
84
|
+
},
|
|
85
|
+
[value],
|
|
86
|
+
subSlot(slot, 'ws:eff'),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return value;
|
|
90
|
+
}
|