@violetflux/kerros 0.1.8 → 0.2.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/README.md CHANGED
@@ -88,14 +88,11 @@ function App() {
88
88
 
89
89
  ### Use the Store
90
90
 
91
- Pass a selector that returns the fields used by the component:
91
+ Read the Store directly. Kerros automatically tracks the properties read while this component renders:
92
92
 
93
93
  ```tsx
94
94
  function TaskList() {
95
- const { tasks, finishTask } = useTask(s => ({
96
- tasks: s.tasks,
97
- finishTask: s.finishTask,
98
- }))
95
+ const { tasks, finishTask } = useTask()
99
96
 
100
97
  return (
101
98
  <ul>
@@ -110,7 +107,7 @@ function TaskList() {
110
107
  }
111
108
  ```
112
109
 
113
- Kerros shallowly compares the selector object's top-level fields. When those selected fields stay equal, other Store updates do not rerender `TaskList`. The selector can stay inline and does not need `useCallback`.
110
+ Changing an unread field does not rerender `TaskList`. Property tracking follows object, array, and nested reads; it does not perform deep equality over the whole Store.
114
111
 
115
112
  ## Install
116
113
 
@@ -128,7 +125,7 @@ React 17, React 18, and React 19 are supported.
128
125
  - **Almost nothing new to learn** — reuse the React knowledge you already have; if you can write a custom Hook, you can write a Store
129
126
  - **Designed for flexible refactoring** — Stores and components use the same Hook API, so local state can become shared state with very little work
130
127
  - **Local and application-wide state** — Provider placement determines the Store scope, balancing flexibility with simplicity
131
- - **Avoid Context-wide rerenders** — Context carries a stable container and components rerender only when their selector result changes
128
+ - **Avoid Context-wide rerenders** — Context carries a stable container and components rerender only when an observed value changes
132
129
  - **TypeScript support** — Store and selector types are inferred without duplicate declarations
133
130
 
134
131
  ## From state management to state sharing
@@ -141,7 +138,23 @@ Passing `value` and `onChange` through layer after layer damages component bound
141
138
 
142
139
  Sharing frequently changing state through React Context directly also causes repeated work: every Context value change rerenders all consumers. Kerros keeps Provider scoping and multiple instances, but Context carries only a stable container. Components subscribe through selectors and rerender only when their selected result changes.
143
140
 
144
- Kerros stays simple, lightweight, and reliable. Write local state as an ordinary Hook, share it only when necessary, use a Provider to set its scope, and use selectors to choose what each component observes.
141
+ Kerros stays simple, lightweight, and reliable. Write local state as an ordinary Hook, share it only when necessary, use a Provider to set its scope, and let automatic tracking observe what each component reads.
142
+
143
+ ## Subscription modes
144
+
145
+ The selector-free form is the default and usually the best starting point:
146
+
147
+ ```tsx
148
+ const { count, setCount } = useCounter()
149
+ ```
150
+
151
+ - `useStore()` automatically tracks object, array, and nested properties read during render.
152
+ - `useStore(selector)` is the advanced path for derived values and measured hot spots. Its returned object's top-level fields are shallowly compared with `Object.is`.
153
+ - `createStore(model, { tracking: false })` and `bindStore({ tracking: false })` make selector-free reads compare the complete Store at the top level instead.
154
+
155
+ Primitive snapshots use `Object.is`. `Map`, `Set`, class instances, and other non-plain objects are treated as atomic references. Store and external Store snapshots must be immutable: publish a new reference for every observable change.
156
+
157
+ Do not save, return, spread, serialize, or pass the complete selector-free result around. Read properties immediately, normally by destructuring. Effects and `useEffectEvent` may perform imperative reads from `useInstance()`, but rendered state must use the subscribed Store Hook; never expose an Effect Event as a public Store action.
145
158
 
146
159
  ## Multiple instances
147
160
 
@@ -167,7 +180,7 @@ A Store may call another Store directly. For example, a task Store can read the
167
180
 
168
181
  ```tsx
169
182
  function useTaskModel() {
170
- const { user } = useAccount(s => ({ user: s.user }))
183
+ const { user } = useAccount()
171
184
  const [tasks, setTasks] = useState<Task[]>([])
172
185
 
173
186
  const addTask = (title: string) => {
@@ -227,12 +240,13 @@ const [useCounter, CounterProvider] = createStore(useCounterModel)
227
240
  ```ts
228
241
  function createStore<TStore, TProps = Record<never, never>>(
229
242
  useModel: (props: TProps) => TStore,
243
+ options?: { tracking?: boolean },
230
244
  ): readonly [StoreHook<TStore>, StoreProvider<TProps>]
231
245
  ```
232
246
 
233
247
  - `useModel` follows the Rules of Hooks
234
248
  - Provider props, excluding `children`, are passed to `useModel`
235
- - the returned Store Hook requires an object-returning selector
249
+ - the returned Store Hook accepts either no argument for automatic tracking or an object-returning selector
236
250
  - using the Store Hook outside its matching Provider throws a clear error
237
251
  - Provider instances work with Strict Mode and server rendering
238
252
 
@@ -258,6 +272,24 @@ If the state begins in `useState`, `useReducer`, an SDK Hook, or another custom
258
272
 
259
273
  Kerros uses the official `use-sync-external-store` shim for React 17 and prefers React's native implementation in React 18 and 19. React Compiler is optional.
260
274
 
275
+ ## ESLint guardrails
276
+
277
+ Install the separate type-aware plugin for the safest default usage:
278
+
279
+ ```sh
280
+ npm install --save-dev @violetflux/eslint-plugin-kerros @typescript-eslint/parser
281
+ ```
282
+
283
+ ```js
284
+ import kerros from '@violetflux/eslint-plugin-kerros'
285
+
286
+ export default [kerros.configs.recommendedTypeChecked]
287
+ ```
288
+
289
+ `recommendedTypeChecked` enables all 17 rules as errors and uses TypeScript `projectService`. Very large repositories may use `kerros.configs.fastTypeChecked`, which keeps type-aware Store recognition but disables the most expensive whole-program and deep analyses. See the [measured ESLint benchmark](https://github.com/violetflux/kerros/blob/main/benchmarks/eslint/RESULTS.md); the fast profile is a tradeoff, not an untyped fallback. The plugin analyzes complete TS/TSX files, not incomplete Markdown snippets.
290
+
291
+ For maintainers, npm Trusted Publisher entries must be configured for both `@violetflux/kerros` and `@violetflux/eslint-plugin-kerros`. That npm-side configuration is the only release step outside this repository; CI checks and publishes the runtime first, then the plugin.
292
+
261
293
  ## Documentation
262
294
 
263
295
  - [Introduction](https://violetflux.github.io/kerros/guide/introduction)
package/README.zh-CN.md CHANGED
@@ -80,14 +80,11 @@ function App() {
80
80
 
81
81
  ### 使用 Store
82
82
 
83
- `useTask` 传入 selector,只返回组件使用的字段:
83
+ 直接读取 Store。Kerros 会自动追踪组件渲染期间访问的属性:
84
84
 
85
85
  ```tsx
86
86
  function TaskList() {
87
- const { tasks, finishTask } = useTask(s => ({
88
- tasks: s.tasks,
89
- finishTask: s.finishTask,
90
- }))
87
+ const { tasks, finishTask } = useTask()
91
88
 
92
89
  return (
93
90
  <ul>
@@ -102,7 +99,7 @@ function TaskList() {
102
99
  }
103
100
  ```
104
101
 
105
- Kerros 会浅比较 selector 返回对象的顶层字段。只要这些选中字段保持不变,Store 的其他更新就不会让 `TaskList` 重渲染。selector 可以直接写在调用位置,不需要 `useCallback`。
102
+ 没有读取的字段发生变化时,`TaskList` 不会重渲染。自动追踪支持对象、数组和深层属性访问,不会对完整 Store 做深比较。
106
103
 
107
104
  ## 安装
108
105
 
@@ -120,7 +117,7 @@ Kerros 会浅比较 selector 返回对象的顶层字段。只要这些选中字
120
117
  - **几乎没有学习成本**:直接复用已有的 React 知识,你怎么写 custom Hook,就可以怎么写 Store
121
118
  - **为灵活重构而设计**:Store 和组件使用同一套 Hook API,可以近乎零成本地把组件局部状态转换成组件间共享状态
122
119
  - **同时支持局部状态和全局状态**:Provider 决定 Store 的作用域,在灵活和简单之间取得平衡
123
- - **解决 Context 的重复渲染问题**:Context 只传递稳定容器,selector 选择结果不变的组件不会重渲染
120
+ - **解决 Context 的重复渲染问题**:Context 只传递稳定容器,组件观察到的值不变时不会重渲染
124
121
  - **优秀的 TypeScript 支持**:Store 和 selector 类型自动推断,不需要重复声明
125
122
 
126
123
  ## 从状态管理到状态共享
@@ -133,7 +130,23 @@ Kerros 想解决的问题更小,也更直接。它不发明新的数据结构
133
130
 
134
131
  直接用 React Context 共享变化频繁的状态也会带来重复渲染:Context value 每次变化,所有消费者都会更新。Kerros 保留 Provider 的作用域和多实例能力,但 Context 只传递稳定容器;组件通过 selector 订阅数据,只有选择结果变化时才重渲染。
135
132
 
136
- Kerros 简单、轻量、可靠。先把状态写成普通 Hook,需要共享时再交给 `createStore`;Provider 决定状态共享到哪里,selector 决定每个组件订阅什么。
133
+ Kerros 简单、轻量、可靠。先把状态写成普通 Hook,需要共享时再交给 `createStore`;Provider 决定状态共享到哪里,自动追踪决定每个组件订阅什么。
134
+
135
+ ## 三种订阅模式
136
+
137
+ 不传 selector 是默认用法,也是多数场景的起点:
138
+
139
+ ```tsx
140
+ const { count, setCount } = useCounter()
141
+ ```
142
+
143
+ - `useStore()`:自动追踪渲染期间读取的对象、数组和深层属性。
144
+ - `useStore(selector)`:用于高级派生值和经过测量的性能热点;Kerros 用 `Object.is` 浅比较 selector 返回对象的顶层字段。
145
+ - `createStore(model, { tracking: false })` 或 `bindStore({ tracking: false })`:关闭自动追踪,无 selector 读取改为完整 Store 顶层浅比较。
146
+
147
+ 基础类型快照使用 `Object.is`。`Map`、`Set`、类实例及其他非普通对象按整体引用处理。Store 和 External Store 快照必须保持不可变:每次可观察变化都发布新引用。
148
+
149
+ 不要保存、返回、展开、序列化或传递无 selector 的完整结果;应立即读取属性,通常直接解构。Effect 与 `useEffectEvent` 可以通过 `useInstance()` 做命令式读取,但参与渲染的状态必须使用订阅 Hook;也不要把 Effect Event 暴露成公共 Store action。
137
150
 
138
151
  ## 多个实例
139
152
 
@@ -159,7 +172,7 @@ Kerros 简单、轻量、可靠。先把状态写成普通 Hook,需要共享
159
172
 
160
173
  ```tsx
161
174
  function useTaskModel() {
162
- const { user } = useAccount(s => ({ user: s.user }))
175
+ const { user } = useAccount()
163
176
  const [tasks, setTasks] = useState<Task[]>([])
164
177
 
165
178
  const addTask = (title: string) => {
@@ -219,12 +232,13 @@ const [useCounter, CounterProvider] = createStore(useCounterModel)
219
232
  ```ts
220
233
  function createStore<TStore, TProps = Record<never, never>>(
221
234
  useModel: (props: TProps) => TStore,
235
+ options?: { tracking?: boolean },
222
236
  ): readonly [StoreHook<TStore>, StoreProvider<TProps>]
223
237
  ```
224
238
 
225
239
  - `useModel` 必须遵守 Hooks 规则
226
240
  - 除 `children` 外的 Provider props 会传给 `useModel`
227
- - Store Hook 必须接收一个返回对象的 selector
241
+ - Store Hook 可不传参数使用自动追踪,也可传入返回对象的 selector
228
242
  - 在对应 Provider 外调用会抛出明确错误
229
243
  - 支持 Strict Mode、服务端渲染和 Provider 多实例
230
244
 
@@ -250,6 +264,24 @@ Provider 的 Context 只保存原 Store 实例,组件直接订阅它;Kerros
250
264
 
251
265
  React 17 使用官方 `use-sync-external-store` shim;React 18 和 19 可用时优先使用 React 原生实现。React Compiler 不是必需项。
252
266
 
267
+ ## ESLint 防护规则
268
+
269
+ 建议安装独立的类型感知插件,并默认使用最严格配置:
270
+
271
+ ```sh
272
+ npm install --save-dev @violetflux/eslint-plugin-kerros @typescript-eslint/parser
273
+ ```
274
+
275
+ ```js
276
+ import kerros from '@violetflux/eslint-plugin-kerros'
277
+
278
+ export default [kerros.configs.recommendedTypeChecked]
279
+ ```
280
+
281
+ `recommendedTypeChecked` 把全部 17 条规则设为 error,并启用 TypeScript `projectService`。超大型仓库可改用 `kerros.configs.fastTypeChecked`:它仍然通过类型识别真实 Kerros Hook,只关闭最昂贵的全程序与深层分析。请参考[真实 ESLint 压测](https://github.com/violetflux/kerros/blob/main/benchmarks/eslint/RESULTS.md);fast 是性能取舍,不是不可靠的命名降级。插件首版只分析完整 TS/TSX 文件,不分析不完整 Markdown 代码块。
282
+
283
+ 维护者还需要分别为 `@violetflux/kerros` 和 `@violetflux/eslint-plugin-kerros` 配置 npm Trusted Publisher。这是唯一的仓库外发布步骤;仓库内工作流会先检查并发布运行库,再发布插件。
284
+
253
285
  ## 文档
254
286
 
255
287
  - [介绍](https://violetflux.github.io/kerros/zh/guide/introduction)
package/dist/index.cjs CHANGED
@@ -1,14 +1,79 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let react = require("react");
3
+ let proxy_compare = require("proxy-compare");
3
4
  let use_sync_external_store_shim_with_selector = require("use-sync-external-store/shim/with-selector");
5
+ //#region src/tracking.ts
6
+ const useStoreLayoutEffect$1 = typeof window === "undefined" ? react.useEffect : react.useLayoutEffect;
7
+ /**
8
+ * Subscribe through either an explicit selector, shallow snapshots, or render access tracking
9
+ */
10
+ function useStoreValue(store, selector, tracking) {
11
+ const committedTracking = (0, react.useRef)(void 0);
12
+ const [proxyCache] = (0, react.useState)(() => /* @__PURE__ */ new WeakMap());
13
+ const [calibration, calibrate] = (0, react.useReducer)((previous, snapshot) => ({
14
+ snapshot,
15
+ version: (previous?.version ?? 0) + 1
16
+ }), void 0);
17
+ const selectSnapshot = (0, react.useCallback)((snapshot) => {
18
+ const currentSnapshot = calibration && Object.is(calibration.snapshot, snapshot) ? calibration.snapshot : snapshot;
19
+ return selector ? selector(currentSnapshot) : currentSnapshot;
20
+ }, [calibration, selector]);
21
+ const compareSelections = (0, react.useCallback)((previous, next) => {
22
+ if (selector || !tracking) return shallowEqual(previous, next);
23
+ const committed = committedTracking.current;
24
+ if (!committed) return Object.is(previous, next);
25
+ return !(0, proxy_compare.isChanged)(committed.snapshot, next, committed.affected, /* @__PURE__ */ new WeakMap());
26
+ }, [selector, tracking]);
27
+ const snapshot = (0, use_sync_external_store_shim_with_selector.useSyncExternalStoreWithSelector)(store.subscribe, store.getSnapshot, store.getSnapshot, selectSnapshot, compareSelections);
28
+ const affected = /* @__PURE__ */ new WeakMap();
29
+ const shouldTrack = !selector && tracking;
30
+ const value = shouldTrack ? (0, proxy_compare.createProxy)(snapshot, affected, proxyCache) : snapshot;
31
+ useStoreLayoutEffect$1(() => {
32
+ if (shouldTrack) {
33
+ const renderedSnapshot = snapshot;
34
+ committedTracking.current = {
35
+ affected,
36
+ snapshot: renderedSnapshot
37
+ };
38
+ const currentSnapshot = store.getSnapshot();
39
+ if ((0, proxy_compare.isChanged)(renderedSnapshot, currentSnapshot, affected, /* @__PURE__ */ new WeakMap())) calibrate(currentSnapshot);
40
+ }
41
+ }, [
42
+ affected,
43
+ shouldTrack,
44
+ snapshot,
45
+ store
46
+ ]);
47
+ return value;
48
+ }
49
+ /**
50
+ * Compare values by identity first and enumerable top-level fields second
51
+ */
52
+ function shallowEqual(left, right) {
53
+ if (Object.is(left, right)) return true;
54
+ if (!isShallowComparable(left) || !isShallowComparable(right)) return false;
55
+ const leftKeys = Object.keys(left);
56
+ if (leftKeys.length !== Object.keys(right).length) return false;
57
+ return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && Object.is(left[key], right[key]));
58
+ }
59
+ /**
60
+ * Narrow values before enumerable field comparison
61
+ */
62
+ function isShallowComparable(value) {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const prototype = Object.getPrototypeOf(value);
65
+ return prototype === Object.prototype || prototype === Array.prototype;
66
+ }
67
+ //#endregion
4
68
  //#region src/index.tsx
5
69
  const useStoreLayoutEffect = typeof window === "undefined" ? react.useEffect : react.useLayoutEffect;
6
70
  /**
7
- * Create a selector-first React Store and its matching Provider
71
+ * Create a React Store with automatic tracking and explicit selector support
8
72
  */
9
- function createStore(useModel) {
73
+ function createStore(useModel, options) {
10
74
  const StoreContext = (0, react.createContext)(void 0);
11
75
  const storeName = useModel.name || "KerrosStore";
76
+ const tracking = options?.tracking ?? true;
12
77
  /** Run the model Hook and publish its committed snapshot */
13
78
  const StoreProvider = (props) => {
14
79
  const { children, ...storeProps } = props;
@@ -20,29 +85,28 @@ function createStore(useModel) {
20
85
  StoreProvider.displayName = `${storeName}Provider`;
21
86
  StoreContext.displayName = `${storeName}Context`;
22
87
  /** Select Store fields through the stable Provider container */
23
- const useStore = (selector) => {
24
- return useStoreSelector(useStoreContext(StoreContext), selector);
25
- };
88
+ const useStore = ((selector) => {
89
+ return useStoreValue(useStoreContext(StoreContext), selector, tracking);
90
+ });
26
91
  return [useStore, StoreProvider];
27
92
  }
28
- /**
29
- * Bind existing external Store instances to scoped React consumers
30
- */
31
- function bindStore(name = "KerrosExternalStore") {
93
+ function bindStore(nameOrOptions = "KerrosExternalStore", inputOptions) {
94
+ const name = typeof nameOrOptions === "string" ? nameOrOptions : "KerrosExternalStore";
95
+ const tracking = (typeof nameOrOptions === "string" ? inputOptions : nameOrOptions)?.tracking ?? true;
32
96
  const StoreContext = (0, react.createContext)(void 0);
33
97
  /** Provide one existing Store instance without copying its snapshot */
34
- const StoreProvider = (props) => {
98
+ const StoreProvider = ((props) => {
35
99
  const { children, store } = props;
36
100
  return (0, react.createElement)(StoreContext.Provider, { value: store }, children);
37
- };
101
+ });
38
102
  StoreProvider.displayName = `${name}Provider`;
39
103
  StoreContext.displayName = `${name}Context`;
40
104
  /** Select snapshot fields directly from the bound external Store */
41
- const useStore = (selector) => {
42
- return useStoreSelector(useStoreContext(StoreContext), selector);
43
- };
105
+ const useStore = ((selector) => {
106
+ return useStoreValue(useStoreContext(StoreContext), selector, tracking);
107
+ });
44
108
  /** Read the exact Store instance bound to the current Provider */
45
- const useInstance = () => useStoreContext(StoreContext);
109
+ const useInstance = (() => useStoreContext(StoreContext));
46
110
  return [
47
111
  useStore,
48
112
  StoreProvider,
@@ -76,21 +140,6 @@ function useStoreContext(context) {
76
140
  if (!store) throw new Error("Kerros store hook must be used within its matching Provider");
77
141
  return store;
78
142
  }
79
- /**
80
- * Select fields directly from an external Store subscription
81
- */
82
- function useStoreSelector(store, selector) {
83
- return (0, use_sync_external_store_shim_with_selector.useSyncExternalStoreWithSelector)(store.subscribe, store.getSnapshot, store.getSnapshot, selector, shallowEqual);
84
- }
85
- /**
86
- * Compare selector objects by their enumerable top-level fields
87
- */
88
- function shallowEqual(left, right) {
89
- if (Object.is(left, right)) return true;
90
- const leftKeys = Object.keys(left);
91
- if (leftKeys.length !== Object.keys(right).length) return false;
92
- return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && Object.is(left[key], right[key]));
93
- }
94
143
  //#endregion
95
144
  exports.bindStore = bindStore;
96
145
  exports.createStore = createStore;
package/dist/index.d.cts CHANGED
@@ -1,13 +1,37 @@
1
1
  import { FC, PropsWithChildren } from "react";
2
2
  //#region src/index.d.ts
3
+ declare const storeHookMarker: unique symbol;
4
+ declare const storeInstanceHookMarker: unique symbol;
5
+ declare const externalStoreProviderMarker: unique symbol;
6
+ /** Store behavior options */
7
+ interface StoreOptions {
8
+ /** Automatically track properties read by selector-free Store hooks */
9
+ tracking?: boolean;
10
+ }
3
11
  /** Store selector returning an object compared with shallow equality */
4
12
  type StoreSelector<TStore, TSelection extends object> = (store: TStore) => TSelection;
5
13
  /** Hook used by consumers to select Store fields */
6
14
  interface StoreHook<TStore> {
15
+ /** Type-only Store hook identity */
16
+ readonly [storeHookMarker]: TStore;
17
+ (): TStore;
7
18
  <TSelection extends object>(selector: StoreSelector<TStore, TSelection>): TSelection;
8
19
  }
9
20
  /** Provider created for a Store hook */
10
21
  type StoreProvider<TProps> = FC<PropsWithChildren<TProps>>;
22
+ /** Hook returning the exact external Store instance */
23
+ interface StoreInstanceHook<TStore> {
24
+ /** Type-only Store instance hook identity */
25
+ readonly [storeInstanceHookMarker]: TStore;
26
+ (): TStore;
27
+ }
28
+ /** Provider carrying an existing external Store instance */
29
+ type ExternalStoreProvider<TStore> = StoreProvider<{
30
+ store: TStore;
31
+ }> & {
32
+ /** Type-only external Store Provider identity */
33
+ readonly [externalStoreProviderMarker]: TStore;
34
+ };
11
35
  /** Existing external Store contract supported by bindStore */
12
36
  interface ExternalStore<TSnapshot> {
13
37
  /** Read the current immutable snapshot */
@@ -18,16 +42,15 @@ interface ExternalStore<TSnapshot> {
18
42
  /** Extract the snapshot exposed by an external Store */
19
43
  type ExternalStoreSnapshot<TStore> = TStore extends ExternalStore<infer TSnapshot> ? TSnapshot : never;
20
44
  /** React bindings created for an existing external Store type */
21
- type StoreBinding<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>> = readonly [StoreHook<TSnapshot>, StoreProvider<{
22
- store: TStore;
23
- }>, () => TStore];
45
+ type StoreBinding<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>> = readonly [StoreHook<TSnapshot>, ExternalStoreProvider<TStore>, StoreInstanceHook<TStore>];
24
46
  /**
25
- * Create a selector-first React Store and its matching Provider
47
+ * Create a React Store with automatic tracking and explicit selector support
26
48
  */
27
- declare function createStore<TStore, TProps = Record<never, never>>(useModel: (props: TProps) => TStore): readonly [StoreHook<TStore>, StoreProvider<TProps>];
49
+ declare function createStore<TStore, TProps = Record<never, never>>(useModel: (props: TProps) => TStore, options?: StoreOptions): readonly [StoreHook<TStore>, StoreProvider<TProps>];
28
50
  /**
29
51
  * Bind existing external Store instances to scoped React consumers
30
52
  */
31
- declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(name?: string): StoreBinding<TStore, TSnapshot>;
53
+ declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(options?: StoreOptions): StoreBinding<TStore, TSnapshot>;
54
+ declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(name?: string, options?: StoreOptions): StoreBinding<TStore, TSnapshot>;
32
55
  //#endregion
33
- export { ExternalStore, ExternalStoreSnapshot, StoreBinding, StoreHook, StoreProvider, StoreSelector, bindStore, createStore };
56
+ export { ExternalStore, ExternalStoreSnapshot, StoreBinding, StoreHook, StoreOptions, StoreProvider, StoreSelector, bindStore, createStore };
package/dist/index.d.mts CHANGED
@@ -1,13 +1,37 @@
1
1
  import { FC, PropsWithChildren } from "react";
2
2
  //#region src/index.d.ts
3
+ declare const storeHookMarker: unique symbol;
4
+ declare const storeInstanceHookMarker: unique symbol;
5
+ declare const externalStoreProviderMarker: unique symbol;
6
+ /** Store behavior options */
7
+ interface StoreOptions {
8
+ /** Automatically track properties read by selector-free Store hooks */
9
+ tracking?: boolean;
10
+ }
3
11
  /** Store selector returning an object compared with shallow equality */
4
12
  type StoreSelector<TStore, TSelection extends object> = (store: TStore) => TSelection;
5
13
  /** Hook used by consumers to select Store fields */
6
14
  interface StoreHook<TStore> {
15
+ /** Type-only Store hook identity */
16
+ readonly [storeHookMarker]: TStore;
17
+ (): TStore;
7
18
  <TSelection extends object>(selector: StoreSelector<TStore, TSelection>): TSelection;
8
19
  }
9
20
  /** Provider created for a Store hook */
10
21
  type StoreProvider<TProps> = FC<PropsWithChildren<TProps>>;
22
+ /** Hook returning the exact external Store instance */
23
+ interface StoreInstanceHook<TStore> {
24
+ /** Type-only Store instance hook identity */
25
+ readonly [storeInstanceHookMarker]: TStore;
26
+ (): TStore;
27
+ }
28
+ /** Provider carrying an existing external Store instance */
29
+ type ExternalStoreProvider<TStore> = StoreProvider<{
30
+ store: TStore;
31
+ }> & {
32
+ /** Type-only external Store Provider identity */
33
+ readonly [externalStoreProviderMarker]: TStore;
34
+ };
11
35
  /** Existing external Store contract supported by bindStore */
12
36
  interface ExternalStore<TSnapshot> {
13
37
  /** Read the current immutable snapshot */
@@ -18,16 +42,15 @@ interface ExternalStore<TSnapshot> {
18
42
  /** Extract the snapshot exposed by an external Store */
19
43
  type ExternalStoreSnapshot<TStore> = TStore extends ExternalStore<infer TSnapshot> ? TSnapshot : never;
20
44
  /** React bindings created for an existing external Store type */
21
- type StoreBinding<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>> = readonly [StoreHook<TSnapshot>, StoreProvider<{
22
- store: TStore;
23
- }>, () => TStore];
45
+ type StoreBinding<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>> = readonly [StoreHook<TSnapshot>, ExternalStoreProvider<TStore>, StoreInstanceHook<TStore>];
24
46
  /**
25
- * Create a selector-first React Store and its matching Provider
47
+ * Create a React Store with automatic tracking and explicit selector support
26
48
  */
27
- declare function createStore<TStore, TProps = Record<never, never>>(useModel: (props: TProps) => TStore): readonly [StoreHook<TStore>, StoreProvider<TProps>];
49
+ declare function createStore<TStore, TProps = Record<never, never>>(useModel: (props: TProps) => TStore, options?: StoreOptions): readonly [StoreHook<TStore>, StoreProvider<TProps>];
28
50
  /**
29
51
  * Bind existing external Store instances to scoped React consumers
30
52
  */
31
- declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(name?: string): StoreBinding<TStore, TSnapshot>;
53
+ declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(options?: StoreOptions): StoreBinding<TStore, TSnapshot>;
54
+ declare function bindStore<TStore extends ExternalStore<TSnapshot>, TSnapshot = ExternalStoreSnapshot<TStore>>(name?: string, options?: StoreOptions): StoreBinding<TStore, TSnapshot>;
32
55
  //#endregion
33
- export { ExternalStore, ExternalStoreSnapshot, StoreBinding, StoreHook, StoreProvider, StoreSelector, bindStore, createStore };
56
+ export { ExternalStore, ExternalStoreSnapshot, StoreBinding, StoreHook, StoreOptions, StoreProvider, StoreSelector, bindStore, createStore };
package/dist/index.mjs CHANGED
@@ -1,13 +1,78 @@
1
- import { createContext, createElement, useContext, useEffect, useLayoutEffect, useState } from "react";
1
+ import { createContext, createElement, useCallback, useContext, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react";
2
+ import { createProxy, isChanged } from "proxy-compare";
2
3
  import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
4
+ //#region src/tracking.ts
5
+ const useStoreLayoutEffect$1 = typeof window === "undefined" ? useEffect : useLayoutEffect;
6
+ /**
7
+ * Subscribe through either an explicit selector, shallow snapshots, or render access tracking
8
+ */
9
+ function useStoreValue(store, selector, tracking) {
10
+ const committedTracking = useRef(void 0);
11
+ const [proxyCache] = useState(() => /* @__PURE__ */ new WeakMap());
12
+ const [calibration, calibrate] = useReducer((previous, snapshot) => ({
13
+ snapshot,
14
+ version: (previous?.version ?? 0) + 1
15
+ }), void 0);
16
+ const selectSnapshot = useCallback((snapshot) => {
17
+ const currentSnapshot = calibration && Object.is(calibration.snapshot, snapshot) ? calibration.snapshot : snapshot;
18
+ return selector ? selector(currentSnapshot) : currentSnapshot;
19
+ }, [calibration, selector]);
20
+ const compareSelections = useCallback((previous, next) => {
21
+ if (selector || !tracking) return shallowEqual(previous, next);
22
+ const committed = committedTracking.current;
23
+ if (!committed) return Object.is(previous, next);
24
+ return !isChanged(committed.snapshot, next, committed.affected, /* @__PURE__ */ new WeakMap());
25
+ }, [selector, tracking]);
26
+ const snapshot = useSyncExternalStoreWithSelector(store.subscribe, store.getSnapshot, store.getSnapshot, selectSnapshot, compareSelections);
27
+ const affected = /* @__PURE__ */ new WeakMap();
28
+ const shouldTrack = !selector && tracking;
29
+ const value = shouldTrack ? createProxy(snapshot, affected, proxyCache) : snapshot;
30
+ useStoreLayoutEffect$1(() => {
31
+ if (shouldTrack) {
32
+ const renderedSnapshot = snapshot;
33
+ committedTracking.current = {
34
+ affected,
35
+ snapshot: renderedSnapshot
36
+ };
37
+ const currentSnapshot = store.getSnapshot();
38
+ if (isChanged(renderedSnapshot, currentSnapshot, affected, /* @__PURE__ */ new WeakMap())) calibrate(currentSnapshot);
39
+ }
40
+ }, [
41
+ affected,
42
+ shouldTrack,
43
+ snapshot,
44
+ store
45
+ ]);
46
+ return value;
47
+ }
48
+ /**
49
+ * Compare values by identity first and enumerable top-level fields second
50
+ */
51
+ function shallowEqual(left, right) {
52
+ if (Object.is(left, right)) return true;
53
+ if (!isShallowComparable(left) || !isShallowComparable(right)) return false;
54
+ const leftKeys = Object.keys(left);
55
+ if (leftKeys.length !== Object.keys(right).length) return false;
56
+ return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && Object.is(left[key], right[key]));
57
+ }
58
+ /**
59
+ * Narrow values before enumerable field comparison
60
+ */
61
+ function isShallowComparable(value) {
62
+ if (typeof value !== "object" || value === null) return false;
63
+ const prototype = Object.getPrototypeOf(value);
64
+ return prototype === Object.prototype || prototype === Array.prototype;
65
+ }
66
+ //#endregion
3
67
  //#region src/index.tsx
4
68
  const useStoreLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
69
  /**
6
- * Create a selector-first React Store and its matching Provider
70
+ * Create a React Store with automatic tracking and explicit selector support
7
71
  */
8
- function createStore(useModel) {
72
+ function createStore(useModel, options) {
9
73
  const StoreContext = createContext(void 0);
10
74
  const storeName = useModel.name || "KerrosStore";
75
+ const tracking = options?.tracking ?? true;
11
76
  /** Run the model Hook and publish its committed snapshot */
12
77
  const StoreProvider = (props) => {
13
78
  const { children, ...storeProps } = props;
@@ -19,29 +84,28 @@ function createStore(useModel) {
19
84
  StoreProvider.displayName = `${storeName}Provider`;
20
85
  StoreContext.displayName = `${storeName}Context`;
21
86
  /** Select Store fields through the stable Provider container */
22
- const useStore = (selector) => {
23
- return useStoreSelector(useStoreContext(StoreContext), selector);
24
- };
87
+ const useStore = ((selector) => {
88
+ return useStoreValue(useStoreContext(StoreContext), selector, tracking);
89
+ });
25
90
  return [useStore, StoreProvider];
26
91
  }
27
- /**
28
- * Bind existing external Store instances to scoped React consumers
29
- */
30
- function bindStore(name = "KerrosExternalStore") {
92
+ function bindStore(nameOrOptions = "KerrosExternalStore", inputOptions) {
93
+ const name = typeof nameOrOptions === "string" ? nameOrOptions : "KerrosExternalStore";
94
+ const tracking = (typeof nameOrOptions === "string" ? inputOptions : nameOrOptions)?.tracking ?? true;
31
95
  const StoreContext = createContext(void 0);
32
96
  /** Provide one existing Store instance without copying its snapshot */
33
- const StoreProvider = (props) => {
97
+ const StoreProvider = ((props) => {
34
98
  const { children, store } = props;
35
99
  return createElement(StoreContext.Provider, { value: store }, children);
36
- };
100
+ });
37
101
  StoreProvider.displayName = `${name}Provider`;
38
102
  StoreContext.displayName = `${name}Context`;
39
103
  /** Select snapshot fields directly from the bound external Store */
40
- const useStore = (selector) => {
41
- return useStoreSelector(useStoreContext(StoreContext), selector);
42
- };
104
+ const useStore = ((selector) => {
105
+ return useStoreValue(useStoreContext(StoreContext), selector, tracking);
106
+ });
43
107
  /** Read the exact Store instance bound to the current Provider */
44
- const useInstance = () => useStoreContext(StoreContext);
108
+ const useInstance = (() => useStoreContext(StoreContext));
45
109
  return [
46
110
  useStore,
47
111
  StoreProvider,
@@ -75,20 +139,5 @@ function useStoreContext(context) {
75
139
  if (!store) throw new Error("Kerros store hook must be used within its matching Provider");
76
140
  return store;
77
141
  }
78
- /**
79
- * Select fields directly from an external Store subscription
80
- */
81
- function useStoreSelector(store, selector) {
82
- return useSyncExternalStoreWithSelector(store.subscribe, store.getSnapshot, store.getSnapshot, selector, shallowEqual);
83
- }
84
- /**
85
- * Compare selector objects by their enumerable top-level fields
86
- */
87
- function shallowEqual(left, right) {
88
- if (Object.is(left, right)) return true;
89
- const leftKeys = Object.keys(left);
90
- if (leftKeys.length !== Object.keys(right).length) return false;
91
- return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && Object.is(left[key], right[key]));
92
- }
93
142
  //#endregion
94
143
  export { bindStore, createStore };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@violetflux/kerros",
3
- "version": "0.1.8",
4
- "description": "Hook-native state sharing for React with focused selector subscriptions.",
3
+ "version": "0.2.0",
4
+ "description": "Hook-native state sharing for React with automatic access tracking and focused selectors.",
5
5
  "keywords": [
6
6
  "react",
7
7
  "react-hooks",
@@ -26,6 +26,9 @@
26
26
  "license": "MIT",
27
27
  "author": "Violetflux",
28
28
  "type": "module",
29
+ "workspaces": [
30
+ "packages/*"
31
+ ],
29
32
  "sideEffects": false,
30
33
  "files": [
31
34
  "dist",
@@ -47,17 +50,27 @@
47
50
  }
48
51
  },
49
52
  "scripts": {
50
- "build": "tsdown",
53
+ "build": "bun run build:runtime && bun run build:plugin",
54
+ "build:runtime": "tsdown",
55
+ "build:plugin": "bun run --filter @violetflux/eslint-plugin-kerros build",
56
+ "benchmark:eslint": "bun benchmarks/eslint/run.ts",
57
+ "benchmark:tracking": "bun benchmarks/tracking/runtime.tsx",
51
58
  "dev": "rspress dev",
52
59
  "docs:build": "rspress build",
53
60
  "docs:preview": "rspress preview",
54
61
  "docs:check": "bun scripts/check-docs.ts",
62
+ "quality:check": "bun scripts/check-selectors.ts",
55
63
  "lint": "eslint .",
56
- "typecheck": "tsc --noEmit",
57
- "test": "vitest run",
64
+ "typecheck": "bun run typecheck:runtime && bun run typecheck:plugin",
65
+ "typecheck:runtime": "tsc --noEmit",
66
+ "typecheck:plugin": "bun run --filter @violetflux/eslint-plugin-kerros typecheck",
67
+ "test": "bun run test:runtime && bun run test:plugin",
68
+ "test:runtime": "vitest run",
69
+ "test:plugin": "bun run --filter @violetflux/eslint-plugin-kerros test",
70
+ "test:benchmarks": "vitest run --config benchmarks/vitest.config.ts",
58
71
  "test:watch": "vitest",
59
- "check": "bun run lint && bun run typecheck && bun run test && bun run build && bun run docs:check && bun run docs:build",
60
- "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build"
72
+ "check": "bun run quality:check && bun run lint && bun run typecheck && bun run test && bun run build && bun run docs:check && bun run docs:build",
73
+ "prepublishOnly": "bun run quality:check && bun run lint && bun run typecheck && bun run test && bun run build"
61
74
  },
62
75
  "publishConfig": {
63
76
  "access": "public"
@@ -66,11 +79,13 @@
66
79
  "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
67
80
  },
68
81
  "dependencies": {
82
+ "proxy-compare": "3.0.1",
69
83
  "use-sync-external-store": "1.6.0"
70
84
  },
71
85
  "devDependencies": {
72
86
  "@eslint/js": "10.0.1",
73
87
  "@rspress/core": "2.0.17",
88
+ "@types/jsdom": "28.0.3",
74
89
  "@types/node": "26.1.1",
75
90
  "@types/react": "19.2.17",
76
91
  "@types/react-dom": "19.2.3",
@@ -81,6 +96,7 @@
81
96
  "jsdom": "29.1.1",
82
97
  "react": "19.2.7",
83
98
  "react-dom": "19.2.7",
99
+ "tinybench": "6.1.2",
84
100
  "tsdown": "0.22.9",
85
101
  "typescript": "5.9.3",
86
102
  "typescript-eslint": "8.64.0",