@octanejs/redux 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/package.json +48 -0
- package/src/Context.ts +33 -0
- package/src/Provider.tsrx +40 -0
- package/src/Provider.tsrx.d.ts +10 -0
- package/src/hooks.ts +162 -0
- package/src/index.ts +44 -0
- package/src/internal.ts +37 -0
- package/src/utils/Subscription.ts +166 -0
- package/src/utils/shallowEqual.ts +26 -0
- package/src/utils/useSyncExternalStoreWithSelector.ts +91 -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/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/redux",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "React Redux for the octane renderer — the Provider/hooks binding layer reimplemented on octane's useSyncExternalStore; works with any Redux store (Redux Toolkit included) by changing the import.",
|
|
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/redux"
|
|
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
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"octane": "0.1.3"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"redux": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@reduxjs/toolkit": "^2.9.0",
|
|
37
|
+
"@tsrx/react": "^0.2.37",
|
|
38
|
+
"esbuild": "^0.28.1",
|
|
39
|
+
"react": "^19.2.0",
|
|
40
|
+
"react-dom": "^19.2.0",
|
|
41
|
+
"react-redux": "9.3.0",
|
|
42
|
+
"redux": "^5.0.1",
|
|
43
|
+
"vitest": "^4.1.9"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/Context.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The default react-redux context — port of components/Context.ts. Upstream
|
|
2
|
+
// dedupes the context instance across multiple copies of the module via a
|
|
3
|
+
// globalThis map keyed by React's createContext; the same hazard exists under
|
|
4
|
+
// Vite dev (duplicate module instances — see the repo's ?import lesson), so the
|
|
5
|
+
// port keeps the mechanism with octane's createContext as the key.
|
|
6
|
+
import { createContext } from 'octane';
|
|
7
|
+
import type { Store, Action, UnknownAction } from 'redux';
|
|
8
|
+
import type { Subscription } from './utils/Subscription';
|
|
9
|
+
|
|
10
|
+
export interface ReactReduxContextValue<SS = any, A extends Action<string> = UnknownAction> {
|
|
11
|
+
store: Store<SS, A>;
|
|
12
|
+
subscription: Subscription;
|
|
13
|
+
getServerState?: () => SS;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Namespaced for octane — the export NAME stays `ReactReduxContext` for
|
|
17
|
+
// drop-in import compatibility, but the dedupe key (and everything else
|
|
18
|
+
// non-public in this package) is octane-branded.
|
|
19
|
+
const ContextKey = Symbol.for('octane-react-redux-context');
|
|
20
|
+
const gT: any = typeof globalThis !== 'undefined' ? globalThis : {};
|
|
21
|
+
|
|
22
|
+
function getContext() {
|
|
23
|
+
const contextMap: Map<typeof createContext, any> = (gT[ContextKey] ??= new Map());
|
|
24
|
+
let realContext = contextMap.get(createContext);
|
|
25
|
+
if (!realContext) {
|
|
26
|
+
realContext = createContext<ReactReduxContextValue | null>(null);
|
|
27
|
+
contextMap.set(createContext, realContext);
|
|
28
|
+
}
|
|
29
|
+
return realContext as ReturnType<typeof createContext<ReactReduxContextValue | null>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ReactReduxContext = getContext();
|
|
33
|
+
export type ReactReduxContextInstance = typeof ReactReduxContext;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Provider — port of react-redux's components/Provider.tsx. Builds the context
|
|
2
|
+
// value ({ store, subscription, getServerState? }) once per store, wires the
|
|
3
|
+
// subscription in a layout effect (subscribes to the store, re-notifies nested
|
|
4
|
+
// subs if the state moved between render and effect), and supports the
|
|
5
|
+
// `context` prop for library-owned stores (recharts-style custom contexts).
|
|
6
|
+
import { useMemo, useLayoutEffect } from 'octane';
|
|
7
|
+
import { ReactReduxContext } from './Context.ts';
|
|
8
|
+
import { createSubscription } from './utils/Subscription.ts';
|
|
9
|
+
|
|
10
|
+
export function Provider(props) @{
|
|
11
|
+
const { store, context, serverState, children } = props;
|
|
12
|
+
|
|
13
|
+
const contextValue = useMemo(() => {
|
|
14
|
+
const subscription = createSubscription(store);
|
|
15
|
+
return {
|
|
16
|
+
store,
|
|
17
|
+
subscription,
|
|
18
|
+
getServerState: serverState ? () => serverState : undefined,
|
|
19
|
+
};
|
|
20
|
+
}, [store, serverState]);
|
|
21
|
+
|
|
22
|
+
const previousState = useMemo(() => store.getState(), [store]);
|
|
23
|
+
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const { subscription } = contextValue;
|
|
26
|
+
subscription.onStateChange = subscription.notifyNestedSubs;
|
|
27
|
+
subscription.trySubscribe();
|
|
28
|
+
if (previousState !== store.getState()) {
|
|
29
|
+
subscription.notifyNestedSubs();
|
|
30
|
+
}
|
|
31
|
+
return () => {
|
|
32
|
+
subscription.tryUnsubscribe();
|
|
33
|
+
subscription.onStateChange = undefined;
|
|
34
|
+
};
|
|
35
|
+
}, [contextValue, previousState]);
|
|
36
|
+
|
|
37
|
+
const Context = context || ReactReduxContext;
|
|
38
|
+
|
|
39
|
+
<Context.Provider value={contextValue}>{children}</Context.Provider>
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Type declaration for the .tsrx component (resolved by relative path).
|
|
2
|
+
import type { Action, Store, UnknownAction } from 'redux';
|
|
3
|
+
import type { ReactReduxContextInstance } from './Context';
|
|
4
|
+
|
|
5
|
+
export declare const Provider: <A extends Action<string> = UnknownAction, S = unknown>(props: {
|
|
6
|
+
store: Store<S, A>;
|
|
7
|
+
children?: unknown;
|
|
8
|
+
context?: ReactReduxContextInstance | null;
|
|
9
|
+
serverState?: S;
|
|
10
|
+
}) => unknown;
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The hooks layer — port of react-redux's hooks/{useReduxContext,useStore,
|
|
2
|
+
// useDispatch,useSelector}.ts, including the context-parameterized factory
|
|
3
|
+
// forms (createStoreHook/createDispatchHook/createSelectorHook) that libraries
|
|
4
|
+
// use to run an isolated store on a custom context (recharts does exactly
|
|
5
|
+
// this). Dev checks (missing selector / non-function equality / no Provider)
|
|
6
|
+
// are kept; upstream's stability/identity-function dev warnings are kept in
|
|
7
|
+
// their "once" form.
|
|
8
|
+
import { useContext, useCallback, useRef } from 'octane';
|
|
9
|
+
import type { Action, Store, UnknownAction } from 'redux';
|
|
10
|
+
import { ReactReduxContext } from './Context';
|
|
11
|
+
import type { ReactReduxContextValue } from './Context';
|
|
12
|
+
import { useSyncExternalStoreWithSelector } from './utils/useSyncExternalStoreWithSelector';
|
|
13
|
+
import { splitSlot, subSlot } from './internal';
|
|
14
|
+
|
|
15
|
+
const refEquality = (a: unknown, b: unknown) => a === b;
|
|
16
|
+
|
|
17
|
+
// useReduxContext — throws without a <Provider> (upstream parity). Context
|
|
18
|
+
// reads are keyed by context identity in octane, so no slot is needed.
|
|
19
|
+
export function createReduxContextHook(context = ReactReduxContext) {
|
|
20
|
+
return function useReduxContext(): ReactReduxContextValue {
|
|
21
|
+
const contextValue = useContext(context);
|
|
22
|
+
if (process.env.NODE_ENV !== 'production' && !contextValue) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'could not find @octanejs/redux context value; please ensure the component is wrapped in a <Provider>',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return contextValue as ReactReduxContextValue;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const useReduxContext = /* @__PURE__ */ createReduxContextHook();
|
|
32
|
+
|
|
33
|
+
export function createStoreHook<S = unknown, A extends Action<string> = UnknownAction>(
|
|
34
|
+
context = ReactReduxContext,
|
|
35
|
+
) {
|
|
36
|
+
const useReduxContextLocal =
|
|
37
|
+
context === ReactReduxContext ? useReduxContext : createReduxContextHook(context);
|
|
38
|
+
const useStore = () => {
|
|
39
|
+
const { store } = useReduxContextLocal();
|
|
40
|
+
return store as Store<S, A>;
|
|
41
|
+
};
|
|
42
|
+
Object.assign(useStore, { withTypes: () => useStore });
|
|
43
|
+
return useStore;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const useStore = /* @__PURE__ */ createStoreHook();
|
|
47
|
+
|
|
48
|
+
export function createDispatchHook<S = unknown, A extends Action<string> = UnknownAction>(
|
|
49
|
+
context = ReactReduxContext,
|
|
50
|
+
) {
|
|
51
|
+
const useStoreLocal = context === ReactReduxContext ? useStore : createStoreHook<S, A>(context);
|
|
52
|
+
const useDispatch = () => {
|
|
53
|
+
const store = useStoreLocal();
|
|
54
|
+
return store.dispatch;
|
|
55
|
+
};
|
|
56
|
+
Object.assign(useDispatch, { withTypes: () => useDispatch });
|
|
57
|
+
return useDispatch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const useDispatch = /* @__PURE__ */ createDispatchHook();
|
|
61
|
+
|
|
62
|
+
interface UseSelectorOptions<Selected> {
|
|
63
|
+
equalityFn?: (a: Selected, b: Selected) => boolean;
|
|
64
|
+
devModeChecks?: {
|
|
65
|
+
stabilityCheck?: 'never' | 'once' | 'always';
|
|
66
|
+
identityFunctionCheck?: 'never' | 'once' | 'always';
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createSelectorHook(context = ReactReduxContext) {
|
|
71
|
+
const useReduxContextLocal =
|
|
72
|
+
context === ReactReduxContext ? useReduxContext : createReduxContextHook(context);
|
|
73
|
+
|
|
74
|
+
function useSelector<TState, Selected>(
|
|
75
|
+
selector: (state: TState) => Selected,
|
|
76
|
+
...rest: any[]
|
|
77
|
+
): Selected {
|
|
78
|
+
const [user, slot] = splitSlot(rest);
|
|
79
|
+
const equalityFnOrOptions = (user[0] ?? {}) as
|
|
80
|
+
| ((a: Selected, b: Selected) => boolean)
|
|
81
|
+
| UseSelectorOptions<Selected>;
|
|
82
|
+
const { equalityFn = refEquality } =
|
|
83
|
+
typeof equalityFnOrOptions === 'function'
|
|
84
|
+
? { equalityFn: equalityFnOrOptions }
|
|
85
|
+
: equalityFnOrOptions;
|
|
86
|
+
|
|
87
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
88
|
+
if (!selector) {
|
|
89
|
+
throw new Error(`You must pass a selector to useSelector`);
|
|
90
|
+
}
|
|
91
|
+
if (typeof selector !== 'function') {
|
|
92
|
+
throw new Error(`You must pass a function as a selector to useSelector`);
|
|
93
|
+
}
|
|
94
|
+
if (typeof equalityFn !== 'function') {
|
|
95
|
+
throw new Error(`You must pass a function as an equality function to useSelector`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { store, subscription, getServerState } = useReduxContextLocal();
|
|
100
|
+
|
|
101
|
+
const firstRun = useRef(true, subSlot(slot, 'us:first'));
|
|
102
|
+
|
|
103
|
+
const wrappedSelector = useCallback(
|
|
104
|
+
(state: TState): Selected => {
|
|
105
|
+
const selected = selector(state);
|
|
106
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
107
|
+
const { devModeChecks = {} } =
|
|
108
|
+
typeof equalityFnOrOptions === 'function' ? {} : (equalityFnOrOptions as any);
|
|
109
|
+
const {
|
|
110
|
+
stabilityCheck = 'once',
|
|
111
|
+
identityFunctionCheck = 'once',
|
|
112
|
+
}: NonNullable<UseSelectorOptions<Selected>['devModeChecks']> = devModeChecks;
|
|
113
|
+
if (stabilityCheck === 'always' || (stabilityCheck === 'once' && firstRun.current)) {
|
|
114
|
+
const toCompare = selector(state);
|
|
115
|
+
if (!equalityFn(selected, toCompare)) {
|
|
116
|
+
console.warn(
|
|
117
|
+
'Selector ' +
|
|
118
|
+
(selector.name || 'unknown') +
|
|
119
|
+
' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' +
|
|
120
|
+
'\nSelectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization',
|
|
121
|
+
{ state, selected, selected2: toCompare },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
identityFunctionCheck === 'always' ||
|
|
127
|
+
(identityFunctionCheck === 'once' && firstRun.current)
|
|
128
|
+
) {
|
|
129
|
+
if ((selected as unknown) === state) {
|
|
130
|
+
console.warn(
|
|
131
|
+
'Selector ' +
|
|
132
|
+
(selector.name || 'unknown') +
|
|
133
|
+
' returned the root state when called. This can lead to unnecessary rerenders.' +
|
|
134
|
+
'\nSelectors that return the entire state are almost certainly a mistake, as they will cause a rerender whenever *anything* in state changes.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (firstRun.current) firstRun.current = false;
|
|
139
|
+
}
|
|
140
|
+
return selected;
|
|
141
|
+
},
|
|
142
|
+
[selector],
|
|
143
|
+
subSlot(slot, 'us:sel'),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const selectedState = useSyncExternalStoreWithSelector(
|
|
147
|
+
subscription.addNestedSub,
|
|
148
|
+
store.getState as () => TState,
|
|
149
|
+
(getServerState as (() => TState) | undefined) || (store.getState as () => TState),
|
|
150
|
+
wrappedSelector,
|
|
151
|
+
equalityFn,
|
|
152
|
+
subSlot(slot, 'us:ws'),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return selectedState;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
Object.assign(useSelector, { withTypes: () => useSelector });
|
|
159
|
+
return useSelector;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const useSelector = /* @__PURE__ */ createSelectorHook();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @octanejs/redux — React Redux for the octane renderer.
|
|
2
|
+
//
|
|
3
|
+
// The binding layer (Provider + hooks + Subscription) reimplemented on octane's
|
|
4
|
+
// native useSyncExternalStore; works with any Redux store — Redux Toolkit
|
|
5
|
+
// included — by changing the import. The store, reducers, middleware, and
|
|
6
|
+
// reselect selectors are framework-agnostic and unchanged.
|
|
7
|
+
//
|
|
8
|
+
// Surface: everything react-redux 9 exports EXCEPT `connect`/`legacy_connect`
|
|
9
|
+
// (the class-era HOC — importable for compatibility, throws with guidance when
|
|
10
|
+
// called; the hooks API is the supported path).
|
|
11
|
+
export { Provider } from './Provider.tsrx';
|
|
12
|
+
export { ReactReduxContext } from './Context';
|
|
13
|
+
export type { ReactReduxContextValue, ReactReduxContextInstance } from './Context';
|
|
14
|
+
export {
|
|
15
|
+
createReduxContextHook,
|
|
16
|
+
useReduxContext,
|
|
17
|
+
createStoreHook,
|
|
18
|
+
useStore,
|
|
19
|
+
createDispatchHook,
|
|
20
|
+
useDispatch,
|
|
21
|
+
createSelectorHook,
|
|
22
|
+
useSelector,
|
|
23
|
+
} from './hooks';
|
|
24
|
+
export { shallowEqual } from './utils/shallowEqual';
|
|
25
|
+
export { createSubscription } from './utils/Subscription';
|
|
26
|
+
export type { Subscription } from './utils/Subscription';
|
|
27
|
+
export { useSyncExternalStoreWithSelector } from './utils/useSyncExternalStoreWithSelector';
|
|
28
|
+
|
|
29
|
+
// `batch` is a no-op passthrough in react-redux 9 (React 18+ auto-batches;
|
|
30
|
+
// octane batches renders in a microtask the same way).
|
|
31
|
+
export function batch(callback: () => void): void {
|
|
32
|
+
callback();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The legacy HOC is not ported — octane has no class components and the hooks
|
|
36
|
+
// API covers the same ground. Kept as a throwing export so imports stay
|
|
37
|
+
// compatible and the failure is actionable.
|
|
38
|
+
export function connect(): never {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'@octanejs/redux does not port `connect` — use the hooks API ' +
|
|
41
|
+
'(useSelector/useDispatch) instead.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
export const legacy_connect = connect;
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Slot mechanics shared by the binding's plain-`.ts` hooks (same helper as
|
|
2
|
+
// @octanejs/tanstack-query). The octane compiler injects a per-call-site Symbol slot into
|
|
3
|
+
// every hook call in compiled files; these binding files are NOT compiled, so a
|
|
4
|
+
// hook here receives the caller's slot as its trailing argument and derives a
|
|
5
|
+
// distinct sub-slot for each base hook it composes.
|
|
6
|
+
|
|
7
|
+
// Memoized: subSlot runs on EVERY hook call every render; the cache returns the
|
|
8
|
+
// identical Symbol.for-interned value without the concat + registry lookup.
|
|
9
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
10
|
+
// Tag-only symbols for the slotless-caller case (see below).
|
|
11
|
+
const bareTagCache = new Map<string, symbol>();
|
|
12
|
+
|
|
13
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
14
|
+
// No inherited slot (the caller was NOT compiled — e.g. a vendored wrapper
|
|
15
|
+
// hook): return a stable TAG-ONLY symbol rather than undefined. The runtime
|
|
16
|
+
// combines it with the ambient withSlot path, so sibling base hooks inside
|
|
17
|
+
// one composed hook stay DISTINCT per tag. Returning undefined here made
|
|
18
|
+
// them all resolve to the bare path — one shared slot, state collision.
|
|
19
|
+
if (slot === undefined) {
|
|
20
|
+
let bare = bareTagCache.get(tag);
|
|
21
|
+
if (bare === undefined) bareTagCache.set(tag, (bare = Symbol.for(':' + tag)));
|
|
22
|
+
return bare;
|
|
23
|
+
}
|
|
24
|
+
let byTag = subSlotCache.get(slot);
|
|
25
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
26
|
+
let sym = byTag.get(tag);
|
|
27
|
+
if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
28
|
+
return sym;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Split the compiler-injected trailing slot off a hook's runtime args, returning
|
|
32
|
+
// the user args (everything before it) and the slot.
|
|
33
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
34
|
+
const tail = args[args.length - 1];
|
|
35
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
36
|
+
return [slot !== undefined ? args.slice(0, -1) : args, slot];
|
|
37
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Verbatim port of react-redux's utils/Subscription.ts (framework-agnostic).
|
|
2
|
+
// The Subscription coordinates the Provider's single store subscription with
|
|
3
|
+
// nested per-component subscriptions, notifying top-down (ancestors before
|
|
4
|
+
// descendants) — the ordering `connect` relies on and hooks benefit from.
|
|
5
|
+
import type { Store } from 'redux';
|
|
6
|
+
|
|
7
|
+
type VoidFunc = () => void;
|
|
8
|
+
|
|
9
|
+
interface Listener {
|
|
10
|
+
callback: VoidFunc;
|
|
11
|
+
next: Listener | null;
|
|
12
|
+
prev: Listener | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createListenerCollection() {
|
|
16
|
+
let first: Listener | null = null;
|
|
17
|
+
let last: Listener | null = null;
|
|
18
|
+
return {
|
|
19
|
+
clear() {
|
|
20
|
+
first = null;
|
|
21
|
+
last = null;
|
|
22
|
+
},
|
|
23
|
+
notify() {
|
|
24
|
+
let listener = first;
|
|
25
|
+
while (listener) {
|
|
26
|
+
listener.callback();
|
|
27
|
+
listener = listener.next;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
get() {
|
|
31
|
+
const listeners: Listener[] = [];
|
|
32
|
+
let listener = first;
|
|
33
|
+
while (listener) {
|
|
34
|
+
listeners.push(listener);
|
|
35
|
+
listener = listener.next;
|
|
36
|
+
}
|
|
37
|
+
return listeners;
|
|
38
|
+
},
|
|
39
|
+
subscribe(callback: VoidFunc) {
|
|
40
|
+
let isSubscribed = true;
|
|
41
|
+
const listener: Listener = (last = {
|
|
42
|
+
callback,
|
|
43
|
+
next: null,
|
|
44
|
+
prev: last,
|
|
45
|
+
});
|
|
46
|
+
if (listener.prev) {
|
|
47
|
+
listener.prev.next = listener;
|
|
48
|
+
} else {
|
|
49
|
+
first = listener;
|
|
50
|
+
}
|
|
51
|
+
return function unsubscribe() {
|
|
52
|
+
if (!isSubscribed || first === null) return;
|
|
53
|
+
isSubscribed = false;
|
|
54
|
+
if (listener.next) {
|
|
55
|
+
listener.next.prev = listener.prev;
|
|
56
|
+
} else {
|
|
57
|
+
last = listener.prev;
|
|
58
|
+
}
|
|
59
|
+
if (listener.prev) {
|
|
60
|
+
listener.prev.next = listener.next;
|
|
61
|
+
} else {
|
|
62
|
+
first = listener.next;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ListenerCollection = ReturnType<typeof createListenerCollection>;
|
|
70
|
+
|
|
71
|
+
export interface Subscription {
|
|
72
|
+
addNestedSub: (listener: VoidFunc) => VoidFunc;
|
|
73
|
+
notifyNestedSubs: VoidFunc;
|
|
74
|
+
handleChangeWrapper: VoidFunc;
|
|
75
|
+
isSubscribed: () => boolean;
|
|
76
|
+
onStateChange?: VoidFunc | null;
|
|
77
|
+
trySubscribe: VoidFunc;
|
|
78
|
+
tryUnsubscribe: VoidFunc;
|
|
79
|
+
getListeners: () => Pick<ListenerCollection, 'notify' | 'get'>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nullListeners = {
|
|
83
|
+
notify() {},
|
|
84
|
+
get: () => [] as Listener[],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function createSubscription(store: Store, parentSub?: Subscription): Subscription {
|
|
88
|
+
let unsubscribe: VoidFunc | undefined;
|
|
89
|
+
let listeners: Pick<ListenerCollection, 'notify' | 'get'> & Partial<ListenerCollection> =
|
|
90
|
+
nullListeners;
|
|
91
|
+
let subscriptionsAmount = 0;
|
|
92
|
+
let selfSubscribed = false;
|
|
93
|
+
|
|
94
|
+
function addNestedSub(listener: VoidFunc) {
|
|
95
|
+
trySubscribe();
|
|
96
|
+
const cleanupListener = (listeners as ListenerCollection).subscribe(listener);
|
|
97
|
+
let removed = false;
|
|
98
|
+
return () => {
|
|
99
|
+
if (!removed) {
|
|
100
|
+
removed = true;
|
|
101
|
+
cleanupListener();
|
|
102
|
+
tryUnsubscribe();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function notifyNestedSubs() {
|
|
108
|
+
listeners.notify();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleChangeWrapper() {
|
|
112
|
+
if (subscription.onStateChange) {
|
|
113
|
+
subscription.onStateChange();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isSubscribed() {
|
|
118
|
+
return selfSubscribed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function trySubscribe() {
|
|
122
|
+
subscriptionsAmount++;
|
|
123
|
+
if (!unsubscribe) {
|
|
124
|
+
unsubscribe = parentSub
|
|
125
|
+
? parentSub.addNestedSub(handleChangeWrapper)
|
|
126
|
+
: store.subscribe(handleChangeWrapper);
|
|
127
|
+
listeners = createListenerCollection();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function tryUnsubscribe() {
|
|
132
|
+
subscriptionsAmount--;
|
|
133
|
+
if (unsubscribe && subscriptionsAmount === 0) {
|
|
134
|
+
unsubscribe();
|
|
135
|
+
unsubscribe = undefined;
|
|
136
|
+
(listeners as ListenerCollection).clear();
|
|
137
|
+
listeners = nullListeners;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function trySubscribeSelf() {
|
|
142
|
+
if (!selfSubscribed) {
|
|
143
|
+
selfSubscribed = true;
|
|
144
|
+
trySubscribe();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tryUnsubscribeSelf() {
|
|
149
|
+
if (selfSubscribed) {
|
|
150
|
+
selfSubscribed = false;
|
|
151
|
+
tryUnsubscribe();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const subscription: Subscription = {
|
|
156
|
+
addNestedSub,
|
|
157
|
+
notifyNestedSubs,
|
|
158
|
+
handleChangeWrapper,
|
|
159
|
+
isSubscribed,
|
|
160
|
+
trySubscribe: trySubscribeSelf,
|
|
161
|
+
tryUnsubscribe: tryUnsubscribeSelf,
|
|
162
|
+
getListeners: () => listeners,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return subscription;
|
|
166
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Verbatim port of react-redux's utils/shallowEqual.ts.
|
|
2
|
+
function is(x: unknown, y: unknown): boolean {
|
|
3
|
+
if (x === y) {
|
|
4
|
+
return x !== 0 || y !== 0 || 1 / (x as number) === 1 / (y as number);
|
|
5
|
+
}
|
|
6
|
+
return x !== x && y !== y;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shallowEqual(objA: any, objB: any): boolean {
|
|
10
|
+
if (is(objA, objB)) return true;
|
|
11
|
+
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const keysA = Object.keys(objA);
|
|
15
|
+
const keysB = Object.keys(objB);
|
|
16
|
+
if (keysA.length !== keysB.length) return false;
|
|
17
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
18
|
+
if (
|
|
19
|
+
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
|
|
20
|
+
!is(objA[keysA[i]], objB[keysA[i]])
|
|
21
|
+
) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Port of React's `use-sync-external-store/with-selector` shim onto octane's
|
|
2
|
+
// native useSyncExternalStore — the exact memoization algorithm: the selection
|
|
3
|
+
// is recomputed only when the SNAPSHOT changes; when `isEqual` says the new
|
|
4
|
+
// selection matches the previous one, the previous REFERENCE is kept so the
|
|
5
|
+
// store's uSES doesn't re-render.
|
|
6
|
+
import { useSyncExternalStore, useRef, useMemo, useEffect } from 'octane';
|
|
7
|
+
import { subSlot } from '../internal';
|
|
8
|
+
|
|
9
|
+
const objectIs: (x: unknown, y: unknown) => boolean =
|
|
10
|
+
typeof Object.is === 'function'
|
|
11
|
+
? Object.is
|
|
12
|
+
: (x: any, y: any) => (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
|
|
13
|
+
|
|
14
|
+
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
|
|
15
|
+
subscribe: (onStoreChange: () => void) => () => void,
|
|
16
|
+
getSnapshot: () => Snapshot,
|
|
17
|
+
getServerSnapshot: undefined | null | (() => Snapshot),
|
|
18
|
+
selector: (snapshot: Snapshot) => Selection,
|
|
19
|
+
isEqual?: (a: Selection, b: Selection) => boolean,
|
|
20
|
+
slot?: symbol,
|
|
21
|
+
): Selection {
|
|
22
|
+
const instRef = useRef<{ hasValue: boolean; value: Selection | null } | null>(
|
|
23
|
+
null,
|
|
24
|
+
subSlot(slot, 'ws:inst'),
|
|
25
|
+
);
|
|
26
|
+
let inst: { hasValue: boolean; value: Selection | null };
|
|
27
|
+
if (instRef.current === null) {
|
|
28
|
+
inst = { hasValue: false, value: null };
|
|
29
|
+
instRef.current = inst;
|
|
30
|
+
} else {
|
|
31
|
+
inst = instRef.current;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [getSelection, getServerSelection] = useMemo(
|
|
35
|
+
() => {
|
|
36
|
+
let hasMemo = false;
|
|
37
|
+
let memoizedSnapshot: Snapshot;
|
|
38
|
+
let memoizedSelection: Selection;
|
|
39
|
+
const memoizedSelector = (nextSnapshot: Snapshot): Selection => {
|
|
40
|
+
if (!hasMemo) {
|
|
41
|
+
hasMemo = true;
|
|
42
|
+
memoizedSnapshot = nextSnapshot;
|
|
43
|
+
const nextSelection = selector(nextSnapshot);
|
|
44
|
+
if (isEqual !== undefined && inst.hasValue) {
|
|
45
|
+
const currentSelection = inst.value as Selection;
|
|
46
|
+
if (isEqual(currentSelection, nextSelection)) {
|
|
47
|
+
return (memoizedSelection = currentSelection);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return (memoizedSelection = nextSelection);
|
|
51
|
+
}
|
|
52
|
+
const currentSelection = memoizedSelection;
|
|
53
|
+
if (objectIs(memoizedSnapshot, nextSnapshot)) return currentSelection;
|
|
54
|
+
const nextSelection = selector(nextSnapshot);
|
|
55
|
+
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
|
|
56
|
+
memoizedSnapshot = nextSnapshot;
|
|
57
|
+
return currentSelection;
|
|
58
|
+
}
|
|
59
|
+
memoizedSnapshot = nextSnapshot;
|
|
60
|
+
return (memoizedSelection = nextSelection);
|
|
61
|
+
};
|
|
62
|
+
const maybeGetServerSnapshot = getServerSnapshot === undefined ? null : getServerSnapshot;
|
|
63
|
+
return [
|
|
64
|
+
() => memoizedSelector(getSnapshot()),
|
|
65
|
+
maybeGetServerSnapshot === null
|
|
66
|
+
? undefined
|
|
67
|
+
: () => memoizedSelector(maybeGetServerSnapshot!()),
|
|
68
|
+
] as const;
|
|
69
|
+
},
|
|
70
|
+
[getSnapshot, getServerSnapshot, selector, isEqual],
|
|
71
|
+
subSlot(slot, 'ws:memo'),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const value = useSyncExternalStore(
|
|
75
|
+
subscribe,
|
|
76
|
+
getSelection,
|
|
77
|
+
getServerSelection ?? getSelection,
|
|
78
|
+
subSlot(slot, 'ws:uses'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
useEffect(
|
|
82
|
+
() => {
|
|
83
|
+
inst.hasValue = true;
|
|
84
|
+
inst.value = value;
|
|
85
|
+
},
|
|
86
|
+
[value],
|
|
87
|
+
subSlot(slot, 'ws:eff'),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return value;
|
|
91
|
+
}
|