@virentia/react 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/README.md +94 -0
- package/dist/index.cjs +275 -0
- package/dist/index.d.cts +73 -0
- package/dist/index.d.mts +73 -0
- package/dist/index.mjs +269 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @virentia/react
|
|
2
|
+
|
|
3
|
+
React bindings for Virentia core models.
|
|
4
|
+
|
|
5
|
+
Keep business logic in `@virentia/core`; use this package at the rendering boundary. Stores become render values, events and effects become callbacks bound to the provided scope.
|
|
6
|
+
|
|
7
|
+
## Links
|
|
8
|
+
|
|
9
|
+
- Documentation: [movpushmov.dev/virentia/react](https://movpushmov.dev/virentia/react/)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add @virentia/react
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## ScopeProvider
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { scope } from "@virentia/core";
|
|
21
|
+
import { ScopeProvider } from "@virentia/react";
|
|
22
|
+
|
|
23
|
+
const appScope = scope();
|
|
24
|
+
|
|
25
|
+
export function App() {
|
|
26
|
+
return (
|
|
27
|
+
<ScopeProvider scope={appScope}>
|
|
28
|
+
<Routes />
|
|
29
|
+
</ScopeProvider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## useUnit
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useUnit } from "@virentia/react";
|
|
38
|
+
import { counterModel } from "./counter.model";
|
|
39
|
+
|
|
40
|
+
export function CounterButton() {
|
|
41
|
+
const count = useUnit(counterModel.count);
|
|
42
|
+
const incremented = useUnit(counterModel.incremented);
|
|
43
|
+
|
|
44
|
+
return <button onClick={() => incremented(1)}>{count}</button>;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## component
|
|
49
|
+
|
|
50
|
+
`component` pairs a model factory with a view. The model receives lifecycle units and props through `ModelContext`; the view receives an unwrapped model.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { event, reaction, store } from "@virentia/core";
|
|
54
|
+
import { component, type ModelContext } from "@virentia/react";
|
|
55
|
+
|
|
56
|
+
function createCounterModel({ props }: ModelContext<{ step: number }>) {
|
|
57
|
+
const clicked = event<void>();
|
|
58
|
+
const count = store(0);
|
|
59
|
+
|
|
60
|
+
reaction({
|
|
61
|
+
on: clicked,
|
|
62
|
+
run() {
|
|
63
|
+
count.value += props.step;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { clicked, count };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const Counter = component({
|
|
71
|
+
model: createCounterModel,
|
|
72
|
+
view({ model }) {
|
|
73
|
+
return <button onClick={() => model.clicked()}>{model.count}</button>;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Model Caches
|
|
79
|
+
|
|
80
|
+
Use `createModelCache` when a model should survive unmount and be reused by key: chats, tabs, detail screens, media players, or previews.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { createModelCache } from "@virentia/react";
|
|
84
|
+
|
|
85
|
+
const chatCache = createModelCache<string, ChatProps, ChatModel>();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Main API
|
|
89
|
+
|
|
90
|
+
`ScopeProvider`, `useProvidedScope`, `useUnit`, `useModel`, `component`, `createModelCache`.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT © 2026 movpushmov
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let react = require("react");
|
|
3
|
+
let _virentia_core = require("@virentia/core");
|
|
4
|
+
//#region lib/model-cache.ts
|
|
5
|
+
function createModelCache() {
|
|
6
|
+
const byScope = /* @__PURE__ */ new WeakMap();
|
|
7
|
+
const maps = /* @__PURE__ */ new Set();
|
|
8
|
+
const getScopeMap = (scope) => {
|
|
9
|
+
let map = byScope.get(scope);
|
|
10
|
+
if (!map) {
|
|
11
|
+
map = /* @__PURE__ */ new Map();
|
|
12
|
+
byScope.set(scope, map);
|
|
13
|
+
maps.add(map);
|
|
14
|
+
}
|
|
15
|
+
return map;
|
|
16
|
+
};
|
|
17
|
+
const find = (key, scope) => {
|
|
18
|
+
if (scope) return byScope.get(scope)?.get(key);
|
|
19
|
+
for (const map of maps) {
|
|
20
|
+
const instance = map.get(key);
|
|
21
|
+
if (instance) return instance;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
has(key, scope) {
|
|
26
|
+
return Boolean(find(key, scope));
|
|
27
|
+
},
|
|
28
|
+
get(key, scope) {
|
|
29
|
+
return find(key, scope)?.model;
|
|
30
|
+
},
|
|
31
|
+
getInstance(key, scope) {
|
|
32
|
+
return find(key, scope);
|
|
33
|
+
},
|
|
34
|
+
delete(key, scope) {
|
|
35
|
+
if (scope) {
|
|
36
|
+
const map = byScope.get(scope);
|
|
37
|
+
const instance = map?.get(key);
|
|
38
|
+
if (!instance) return false;
|
|
39
|
+
instance.dispose();
|
|
40
|
+
map?.delete(key);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
let deleted = false;
|
|
44
|
+
for (const map of maps) {
|
|
45
|
+
const instance = map.get(key);
|
|
46
|
+
if (!instance) continue;
|
|
47
|
+
instance.dispose();
|
|
48
|
+
map.delete(key);
|
|
49
|
+
deleted = true;
|
|
50
|
+
}
|
|
51
|
+
return deleted;
|
|
52
|
+
},
|
|
53
|
+
clear(scope) {
|
|
54
|
+
if (scope) {
|
|
55
|
+
const map = byScope.get(scope);
|
|
56
|
+
if (!map) return;
|
|
57
|
+
for (const instance of map.values()) instance.dispose();
|
|
58
|
+
map.clear();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const map of maps) {
|
|
62
|
+
for (const instance of map.values()) instance.dispose();
|
|
63
|
+
map.clear();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[modelCacheInternal](scope, key, create) {
|
|
67
|
+
const map = getScopeMap(scope);
|
|
68
|
+
let instance = map.get(key);
|
|
69
|
+
if (!instance) {
|
|
70
|
+
instance = create();
|
|
71
|
+
map.set(key, instance);
|
|
72
|
+
}
|
|
73
|
+
return instance;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function getOrCreateCachedInstance(cache, scope, key, create) {
|
|
78
|
+
const internal = cache[modelCacheInternal];
|
|
79
|
+
if (!internal) throw new Error("[useModel] Unsupported model cache. Use createModelCache().");
|
|
80
|
+
return internal(scope, key, create);
|
|
81
|
+
}
|
|
82
|
+
const modelCacheInternal = Symbol("virentia.react.modelCacheInternal");
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region lib/scope.ts
|
|
85
|
+
const ScopeContext = (0, react.createContext)(null);
|
|
86
|
+
function ScopeProvider(props) {
|
|
87
|
+
return (0, react.createElement)(ScopeContext.Provider, { value: props.scope }, props.children);
|
|
88
|
+
}
|
|
89
|
+
function useProvidedScope() {
|
|
90
|
+
const scope = (0, react.useContext)(ScopeContext);
|
|
91
|
+
if (!scope) throw new Error("[useProvidedScope] Scope is not provided. Wrap your tree with ScopeProvider.");
|
|
92
|
+
return scope;
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region lib/utils.ts
|
|
96
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? react.useEffect : react.useLayoutEffect;
|
|
97
|
+
function readStore(unit, scope) {
|
|
98
|
+
return (0, _virentia_core.scoped)(scope, () => {
|
|
99
|
+
const keys = Reflect.ownKeys(unit).filter((key) => !nativeStoreKeys.has(key));
|
|
100
|
+
if (keys.length === 1 && keys[0] === "value") return Reflect.get(unit, "value");
|
|
101
|
+
if (isArraySnapshot(unit, keys)) {
|
|
102
|
+
const length = Reflect.get(unit, "length");
|
|
103
|
+
return Array.from({ length }, (_value, index) => Reflect.get(unit, String(index)));
|
|
104
|
+
}
|
|
105
|
+
return Object.fromEntries(keys.map((key) => [key, Reflect.get(unit, key)]));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function writeStore(unit, value, scope) {
|
|
109
|
+
(0, _virentia_core.run)({
|
|
110
|
+
unit: unit.node,
|
|
111
|
+
payload: value,
|
|
112
|
+
scope
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function isUnitLike(value) {
|
|
116
|
+
return isStoreUnit(value) || isCallableUnit(value);
|
|
117
|
+
}
|
|
118
|
+
function isStoreUnit(value) {
|
|
119
|
+
return Boolean(value && (typeof value === "object" || typeof value === "function") && "node" in value && "subscribe" in value && typeof value.subscribe === "function");
|
|
120
|
+
}
|
|
121
|
+
function isCallableUnit(value) {
|
|
122
|
+
return Boolean(typeof value === "function" && "node" in value);
|
|
123
|
+
}
|
|
124
|
+
function isPlainObject(value) {
|
|
125
|
+
if (!value || typeof value !== "object") return false;
|
|
126
|
+
const prototype = Object.getPrototypeOf(value);
|
|
127
|
+
return prototype === Object.prototype || prototype === null;
|
|
128
|
+
}
|
|
129
|
+
function getComponentName(view) {
|
|
130
|
+
return `Virentia(${view.displayName ?? view.name ?? "Component"})`;
|
|
131
|
+
}
|
|
132
|
+
function isArraySnapshot(unit, keys) {
|
|
133
|
+
return keys.includes("length") && keys.every((key) => key === "length" || typeof key === "string" && arrayIndexPattern.test(key)) && typeof Reflect.get(unit, "length") === "number";
|
|
134
|
+
}
|
|
135
|
+
const arrayIndexPattern = /^(0|[1-9]\d*)$/;
|
|
136
|
+
const nativeStoreKeys = new Set([
|
|
137
|
+
"node",
|
|
138
|
+
"writable",
|
|
139
|
+
"subscribe",
|
|
140
|
+
"map",
|
|
141
|
+
"filter",
|
|
142
|
+
"filterMap"
|
|
143
|
+
]);
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region lib/use-unit.ts
|
|
146
|
+
function useUnit(input) {
|
|
147
|
+
return useUnitWithScope(input, useProvidedScope());
|
|
148
|
+
}
|
|
149
|
+
function useUnitWithScope(input, scope) {
|
|
150
|
+
if (Array.isArray(input)) return input.map((unit) => useSingleUnit(unit, scope));
|
|
151
|
+
if (isUnitLike(input)) return useSingleUnit(input, scope);
|
|
152
|
+
const result = {};
|
|
153
|
+
for (const key of Object.keys(input)) result[key] = useSingleUnit(input[key], scope);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
function useSingleUnit(unit, scope) {
|
|
157
|
+
if (isStoreUnit(unit)) return useStoreUnit(unit, scope);
|
|
158
|
+
return (0, react.useCallback)((...args) => (0, _virentia_core.scoped)(scope, () => unit(...args)), [scope, unit]);
|
|
159
|
+
}
|
|
160
|
+
function useStoreUnit(unit, scope) {
|
|
161
|
+
const snapshotRef = (0, react.useRef)(readStore(unit, scope));
|
|
162
|
+
snapshotRef.current = readStore(unit, scope);
|
|
163
|
+
const subscribe = (0, react.useCallback)((notify) => {
|
|
164
|
+
const unsubscribe = unit.subscribe((_value, nextScope) => {
|
|
165
|
+
if (nextScope !== scope) return;
|
|
166
|
+
snapshotRef.current = readStore(unit, scope);
|
|
167
|
+
notify();
|
|
168
|
+
});
|
|
169
|
+
snapshotRef.current = readStore(unit, scope);
|
|
170
|
+
return unsubscribe;
|
|
171
|
+
}, [scope, unit]);
|
|
172
|
+
const getSnapshot = (0, react.useCallback)(() => snapshotRef.current, []);
|
|
173
|
+
return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region lib/use-model.ts
|
|
177
|
+
function useModel(modelOrFactory, props, options) {
|
|
178
|
+
const scope = useProvidedScope();
|
|
179
|
+
if (typeof modelOrFactory !== "function") return useReactiveModel(modelOrFactory, scope);
|
|
180
|
+
return useReactiveModel(useModelInstance(modelOrFactory, props, scope, options).model, scope);
|
|
181
|
+
}
|
|
182
|
+
function useModelInstance(factory, props, scope, options) {
|
|
183
|
+
const cache = options?.cache;
|
|
184
|
+
const key = options?.key;
|
|
185
|
+
const cached = Boolean(cache);
|
|
186
|
+
const instance = (0, react.useMemo)(() => {
|
|
187
|
+
if (cache) return getOrCreateCachedInstance(cache, scope, key, () => createModelInstance(factory, props, scope, key));
|
|
188
|
+
return createModelInstance(factory, props, scope, void 0);
|
|
189
|
+
}, [
|
|
190
|
+
cache,
|
|
191
|
+
key,
|
|
192
|
+
scope
|
|
193
|
+
]);
|
|
194
|
+
useIsomorphicLayoutEffect(() => {
|
|
195
|
+
writeStore(instance.props, props, scope);
|
|
196
|
+
}, [
|
|
197
|
+
instance,
|
|
198
|
+
props,
|
|
199
|
+
scope
|
|
200
|
+
]);
|
|
201
|
+
(0, react.useEffect)(() => {
|
|
202
|
+
(0, _virentia_core.scoped)(scope, () => {
|
|
203
|
+
instance.mounts.value += 1;
|
|
204
|
+
instance.mounted();
|
|
205
|
+
});
|
|
206
|
+
return () => {
|
|
207
|
+
(0, _virentia_core.scoped)(scope, () => {
|
|
208
|
+
instance.mounts.value = Math.max(0, instance.mounts.value - 1);
|
|
209
|
+
instance.unmounted();
|
|
210
|
+
});
|
|
211
|
+
if (!cached) instance.dispose();
|
|
212
|
+
};
|
|
213
|
+
}, [
|
|
214
|
+
cached,
|
|
215
|
+
instance,
|
|
216
|
+
scope
|
|
217
|
+
]);
|
|
218
|
+
return instance;
|
|
219
|
+
}
|
|
220
|
+
function createModelInstance(factory, props, scope, key) {
|
|
221
|
+
return (0, _virentia_core.owner)((dispose, modelOwner) => {
|
|
222
|
+
const context = {
|
|
223
|
+
scope,
|
|
224
|
+
owner: modelOwner,
|
|
225
|
+
props: (0, _virentia_core.store)(props),
|
|
226
|
+
mounted: (0, _virentia_core.event)(),
|
|
227
|
+
unmounted: (0, _virentia_core.event)(),
|
|
228
|
+
mounts: (0, _virentia_core.store)(0),
|
|
229
|
+
key
|
|
230
|
+
};
|
|
231
|
+
const model = (0, _virentia_core.scoped)(scope, () => factory(context));
|
|
232
|
+
return {
|
|
233
|
+
...context,
|
|
234
|
+
model,
|
|
235
|
+
dispose
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function useReactiveModel(model, scope) {
|
|
240
|
+
const result = {};
|
|
241
|
+
for (const key of Reflect.ownKeys(model)) {
|
|
242
|
+
if (key === "dispose" || key === disposeSymbol) continue;
|
|
243
|
+
result[key] = useModelValue(Reflect.get(model, key), scope);
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
const disposeSymbol = typeof Symbol.dispose === "symbol" ? Symbol.dispose : Symbol.for("Symbol.dispose");
|
|
248
|
+
function useModelValue(value, scope) {
|
|
249
|
+
if (isUnitLike(value)) return useUnitWithScope(value, scope);
|
|
250
|
+
if (isPlainObject(value)) return useReactiveModel(value, scope);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region lib/component.ts
|
|
255
|
+
function component(config) {
|
|
256
|
+
const VirentiaComponent = (props) => {
|
|
257
|
+
const model = "cache" in config ? useModel(config.model, props, {
|
|
258
|
+
cache: config.cache,
|
|
259
|
+
key: config.key(props)
|
|
260
|
+
}) : useModel(config.model, props);
|
|
261
|
+
return (0, react.createElement)(config.view, {
|
|
262
|
+
...props,
|
|
263
|
+
model
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
VirentiaComponent.displayName = getComponentName(config.view);
|
|
267
|
+
return VirentiaComponent;
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
exports.ScopeProvider = ScopeProvider;
|
|
271
|
+
exports.component = component;
|
|
272
|
+
exports.createModelCache = createModelCache;
|
|
273
|
+
exports.useModel = useModel;
|
|
274
|
+
exports.useProvidedScope = useProvidedScope;
|
|
275
|
+
exports.useUnit = useUnit;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ComponentType, FC, ReactNode } from "react";
|
|
2
|
+
import { Effect, EffectCallArgs, EventCallable, EventPayload, Owner, Scope, Store, StoreWritable } from "@virentia/core";
|
|
3
|
+
|
|
4
|
+
//#region lib/types.d.ts
|
|
5
|
+
type UnitLike = Store<any> | StoreWritable<any> | EventCallable<any> | Effect<any, any, any>;
|
|
6
|
+
type UnitValue<Unit> = Unit extends Store<infer State> | StoreWritable<infer State> ? State : Unit extends EventCallable<infer Payload> ? (...payload: EventPayload<Payload>) => Promise<void> : Unit extends Effect<infer Params, infer Done, any> ? (...args: EffectCallArgs<Params>) => Promise<Done> : never;
|
|
7
|
+
type ReactiveModel<Model> = { readonly [Key in keyof Model as Key extends "dispose" ? never : Key]: Model[Key] extends UnitLike ? UnitValue<Model[Key]> : Model[Key] extends ((...args: any[]) => any) ? Model[Key] : Model[Key] extends object ? ReactiveModel<Model[Key]> : Model[Key] };
|
|
8
|
+
interface ModelContext<Props, Key = undefined> {
|
|
9
|
+
readonly scope: Scope;
|
|
10
|
+
readonly owner: Owner;
|
|
11
|
+
readonly props: StoreWritable<Props>;
|
|
12
|
+
readonly mounted: EventCallable<void>;
|
|
13
|
+
readonly unmounted: EventCallable<void>;
|
|
14
|
+
readonly mounts: StoreWritable<number>;
|
|
15
|
+
readonly key: Key;
|
|
16
|
+
}
|
|
17
|
+
type ModelFactory<Props, Model extends object, Key = undefined> = (context: ModelContext<Props, Key>) => Model;
|
|
18
|
+
interface ModelInstance<Props, Model extends object, Key = undefined> extends ModelContext<Props, Key> {
|
|
19
|
+
readonly model: Model;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
interface ModelCache<Key, Props, Model extends object> {
|
|
23
|
+
has(key: Key, scope?: Scope): boolean;
|
|
24
|
+
get(key: Key, scope?: Scope): Model | undefined;
|
|
25
|
+
getInstance(key: Key, scope?: Scope): ModelInstance<Props, Model, Key> | undefined;
|
|
26
|
+
delete(key: Key, scope?: Scope): boolean;
|
|
27
|
+
clear(scope?: Scope): void;
|
|
28
|
+
}
|
|
29
|
+
type UnitShape<Shape> = Shape extends readonly UnitLike[] ? { readonly [Key in keyof Shape]: UnitValue<Shape[Key]> } : Shape extends Record<string, UnitLike> ? { readonly [Key in keyof Shape]: UnitValue<Shape[Key]> } : never;
|
|
30
|
+
type CacheOptions<Props, Key, Model extends object> = {
|
|
31
|
+
readonly cache: ModelCache<Key, Props, Model>;
|
|
32
|
+
readonly key: Key;
|
|
33
|
+
};
|
|
34
|
+
type ComponentConfig<Props, Model extends object> = {
|
|
35
|
+
readonly model: ModelFactory<Props, Model>;
|
|
36
|
+
readonly view: ComponentType<Props & {
|
|
37
|
+
model: ReactiveModel<Model>;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
type CachedComponentConfig<Props, Key, Model extends object> = {
|
|
41
|
+
readonly key: (props: Props) => Key;
|
|
42
|
+
readonly cache: ModelCache<Key, Props, Model>;
|
|
43
|
+
readonly model: ModelFactory<Props, Model, Key>;
|
|
44
|
+
readonly view: ComponentType<Props & {
|
|
45
|
+
model: ReactiveModel<Model>;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region lib/component.d.ts
|
|
50
|
+
declare function component<Props, Model extends object>(config: ComponentConfig<Props, Model>): FC<Props>;
|
|
51
|
+
declare function component<Props, Key, Model extends object>(config: CachedComponentConfig<Props, Key, Model>): FC<Props>;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region lib/model-cache.d.ts
|
|
54
|
+
declare function createModelCache<Key, Props, Model extends object>(): ModelCache<Key, Props, Model>;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region lib/scope.d.ts
|
|
57
|
+
declare function ScopeProvider(props: {
|
|
58
|
+
scope: Scope;
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): ReactNode;
|
|
61
|
+
declare function useProvidedScope(): Scope;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region lib/use-model.d.ts
|
|
64
|
+
declare function useModel<Model extends object>(model: Model): ReactiveModel<Model>;
|
|
65
|
+
declare function useModel<Props, Model extends object>(factory: ModelFactory<Props, Model>, props: Props): ReactiveModel<Model>;
|
|
66
|
+
declare function useModel<Props, Key, Model extends object>(factory: ModelFactory<Props, Model, Key>, props: Props, options: CacheOptions<Props, Key, Model>): ReactiveModel<Model>;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region lib/use-unit.d.ts
|
|
69
|
+
declare function useUnit<Unit extends UnitLike>(unit: Unit): UnitValue<Unit>;
|
|
70
|
+
declare function useUnit<Shape extends readonly UnitLike[]>(shape: Shape): UnitShape<Shape>;
|
|
71
|
+
declare function useUnit<Shape extends Record<string, UnitLike>>(shape: Shape): UnitShape<Shape>;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { type ModelCache, type ModelContext, type ModelFactory, type ModelInstance, type ReactiveModel, ScopeProvider, type UnitLike, type UnitValue, component, createModelCache, useModel, useProvidedScope, useUnit };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ComponentType, FC, ReactNode } from "react";
|
|
2
|
+
import { Effect, EffectCallArgs, EventCallable, EventPayload, Owner, Scope, Store, StoreWritable } from "@virentia/core";
|
|
3
|
+
|
|
4
|
+
//#region lib/types.d.ts
|
|
5
|
+
type UnitLike = Store<any> | StoreWritable<any> | EventCallable<any> | Effect<any, any, any>;
|
|
6
|
+
type UnitValue<Unit> = Unit extends Store<infer State> | StoreWritable<infer State> ? State : Unit extends EventCallable<infer Payload> ? (...payload: EventPayload<Payload>) => Promise<void> : Unit extends Effect<infer Params, infer Done, any> ? (...args: EffectCallArgs<Params>) => Promise<Done> : never;
|
|
7
|
+
type ReactiveModel<Model> = { readonly [Key in keyof Model as Key extends "dispose" ? never : Key]: Model[Key] extends UnitLike ? UnitValue<Model[Key]> : Model[Key] extends ((...args: any[]) => any) ? Model[Key] : Model[Key] extends object ? ReactiveModel<Model[Key]> : Model[Key] };
|
|
8
|
+
interface ModelContext<Props, Key = undefined> {
|
|
9
|
+
readonly scope: Scope;
|
|
10
|
+
readonly owner: Owner;
|
|
11
|
+
readonly props: StoreWritable<Props>;
|
|
12
|
+
readonly mounted: EventCallable<void>;
|
|
13
|
+
readonly unmounted: EventCallable<void>;
|
|
14
|
+
readonly mounts: StoreWritable<number>;
|
|
15
|
+
readonly key: Key;
|
|
16
|
+
}
|
|
17
|
+
type ModelFactory<Props, Model extends object, Key = undefined> = (context: ModelContext<Props, Key>) => Model;
|
|
18
|
+
interface ModelInstance<Props, Model extends object, Key = undefined> extends ModelContext<Props, Key> {
|
|
19
|
+
readonly model: Model;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
interface ModelCache<Key, Props, Model extends object> {
|
|
23
|
+
has(key: Key, scope?: Scope): boolean;
|
|
24
|
+
get(key: Key, scope?: Scope): Model | undefined;
|
|
25
|
+
getInstance(key: Key, scope?: Scope): ModelInstance<Props, Model, Key> | undefined;
|
|
26
|
+
delete(key: Key, scope?: Scope): boolean;
|
|
27
|
+
clear(scope?: Scope): void;
|
|
28
|
+
}
|
|
29
|
+
type UnitShape<Shape> = Shape extends readonly UnitLike[] ? { readonly [Key in keyof Shape]: UnitValue<Shape[Key]> } : Shape extends Record<string, UnitLike> ? { readonly [Key in keyof Shape]: UnitValue<Shape[Key]> } : never;
|
|
30
|
+
type CacheOptions<Props, Key, Model extends object> = {
|
|
31
|
+
readonly cache: ModelCache<Key, Props, Model>;
|
|
32
|
+
readonly key: Key;
|
|
33
|
+
};
|
|
34
|
+
type ComponentConfig<Props, Model extends object> = {
|
|
35
|
+
readonly model: ModelFactory<Props, Model>;
|
|
36
|
+
readonly view: ComponentType<Props & {
|
|
37
|
+
model: ReactiveModel<Model>;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
type CachedComponentConfig<Props, Key, Model extends object> = {
|
|
41
|
+
readonly key: (props: Props) => Key;
|
|
42
|
+
readonly cache: ModelCache<Key, Props, Model>;
|
|
43
|
+
readonly model: ModelFactory<Props, Model, Key>;
|
|
44
|
+
readonly view: ComponentType<Props & {
|
|
45
|
+
model: ReactiveModel<Model>;
|
|
46
|
+
}>;
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region lib/component.d.ts
|
|
50
|
+
declare function component<Props, Model extends object>(config: ComponentConfig<Props, Model>): FC<Props>;
|
|
51
|
+
declare function component<Props, Key, Model extends object>(config: CachedComponentConfig<Props, Key, Model>): FC<Props>;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region lib/model-cache.d.ts
|
|
54
|
+
declare function createModelCache<Key, Props, Model extends object>(): ModelCache<Key, Props, Model>;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region lib/scope.d.ts
|
|
57
|
+
declare function ScopeProvider(props: {
|
|
58
|
+
scope: Scope;
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): ReactNode;
|
|
61
|
+
declare function useProvidedScope(): Scope;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region lib/use-model.d.ts
|
|
64
|
+
declare function useModel<Model extends object>(model: Model): ReactiveModel<Model>;
|
|
65
|
+
declare function useModel<Props, Model extends object>(factory: ModelFactory<Props, Model>, props: Props): ReactiveModel<Model>;
|
|
66
|
+
declare function useModel<Props, Key, Model extends object>(factory: ModelFactory<Props, Model, Key>, props: Props, options: CacheOptions<Props, Key, Model>): ReactiveModel<Model>;
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region lib/use-unit.d.ts
|
|
69
|
+
declare function useUnit<Unit extends UnitLike>(unit: Unit): UnitValue<Unit>;
|
|
70
|
+
declare function useUnit<Shape extends readonly UnitLike[]>(shape: Shape): UnitShape<Shape>;
|
|
71
|
+
declare function useUnit<Shape extends Record<string, UnitLike>>(shape: Shape): UnitShape<Shape>;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { type ModelCache, type ModelContext, type ModelFactory, type ModelInstance, type ReactiveModel, ScopeProvider, type UnitLike, type UnitValue, component, createModelCache, useModel, useProvidedScope, useUnit };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { event, owner, run, scoped, store } from "@virentia/core";
|
|
3
|
+
//#region lib/model-cache.ts
|
|
4
|
+
function createModelCache() {
|
|
5
|
+
const byScope = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
const maps = /* @__PURE__ */ new Set();
|
|
7
|
+
const getScopeMap = (scope) => {
|
|
8
|
+
let map = byScope.get(scope);
|
|
9
|
+
if (!map) {
|
|
10
|
+
map = /* @__PURE__ */ new Map();
|
|
11
|
+
byScope.set(scope, map);
|
|
12
|
+
maps.add(map);
|
|
13
|
+
}
|
|
14
|
+
return map;
|
|
15
|
+
};
|
|
16
|
+
const find = (key, scope) => {
|
|
17
|
+
if (scope) return byScope.get(scope)?.get(key);
|
|
18
|
+
for (const map of maps) {
|
|
19
|
+
const instance = map.get(key);
|
|
20
|
+
if (instance) return instance;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
has(key, scope) {
|
|
25
|
+
return Boolean(find(key, scope));
|
|
26
|
+
},
|
|
27
|
+
get(key, scope) {
|
|
28
|
+
return find(key, scope)?.model;
|
|
29
|
+
},
|
|
30
|
+
getInstance(key, scope) {
|
|
31
|
+
return find(key, scope);
|
|
32
|
+
},
|
|
33
|
+
delete(key, scope) {
|
|
34
|
+
if (scope) {
|
|
35
|
+
const map = byScope.get(scope);
|
|
36
|
+
const instance = map?.get(key);
|
|
37
|
+
if (!instance) return false;
|
|
38
|
+
instance.dispose();
|
|
39
|
+
map?.delete(key);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
let deleted = false;
|
|
43
|
+
for (const map of maps) {
|
|
44
|
+
const instance = map.get(key);
|
|
45
|
+
if (!instance) continue;
|
|
46
|
+
instance.dispose();
|
|
47
|
+
map.delete(key);
|
|
48
|
+
deleted = true;
|
|
49
|
+
}
|
|
50
|
+
return deleted;
|
|
51
|
+
},
|
|
52
|
+
clear(scope) {
|
|
53
|
+
if (scope) {
|
|
54
|
+
const map = byScope.get(scope);
|
|
55
|
+
if (!map) return;
|
|
56
|
+
for (const instance of map.values()) instance.dispose();
|
|
57
|
+
map.clear();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const map of maps) {
|
|
61
|
+
for (const instance of map.values()) instance.dispose();
|
|
62
|
+
map.clear();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[modelCacheInternal](scope, key, create) {
|
|
66
|
+
const map = getScopeMap(scope);
|
|
67
|
+
let instance = map.get(key);
|
|
68
|
+
if (!instance) {
|
|
69
|
+
instance = create();
|
|
70
|
+
map.set(key, instance);
|
|
71
|
+
}
|
|
72
|
+
return instance;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function getOrCreateCachedInstance(cache, scope, key, create) {
|
|
77
|
+
const internal = cache[modelCacheInternal];
|
|
78
|
+
if (!internal) throw new Error("[useModel] Unsupported model cache. Use createModelCache().");
|
|
79
|
+
return internal(scope, key, create);
|
|
80
|
+
}
|
|
81
|
+
const modelCacheInternal = Symbol("virentia.react.modelCacheInternal");
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region lib/scope.ts
|
|
84
|
+
const ScopeContext = createContext(null);
|
|
85
|
+
function ScopeProvider(props) {
|
|
86
|
+
return createElement(ScopeContext.Provider, { value: props.scope }, props.children);
|
|
87
|
+
}
|
|
88
|
+
function useProvidedScope() {
|
|
89
|
+
const scope = useContext(ScopeContext);
|
|
90
|
+
if (!scope) throw new Error("[useProvidedScope] Scope is not provided. Wrap your tree with ScopeProvider.");
|
|
91
|
+
return scope;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region lib/utils.ts
|
|
95
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
96
|
+
function readStore(unit, scope) {
|
|
97
|
+
return scoped(scope, () => {
|
|
98
|
+
const keys = Reflect.ownKeys(unit).filter((key) => !nativeStoreKeys.has(key));
|
|
99
|
+
if (keys.length === 1 && keys[0] === "value") return Reflect.get(unit, "value");
|
|
100
|
+
if (isArraySnapshot(unit, keys)) {
|
|
101
|
+
const length = Reflect.get(unit, "length");
|
|
102
|
+
return Array.from({ length }, (_value, index) => Reflect.get(unit, String(index)));
|
|
103
|
+
}
|
|
104
|
+
return Object.fromEntries(keys.map((key) => [key, Reflect.get(unit, key)]));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function writeStore(unit, value, scope) {
|
|
108
|
+
run({
|
|
109
|
+
unit: unit.node,
|
|
110
|
+
payload: value,
|
|
111
|
+
scope
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function isUnitLike(value) {
|
|
115
|
+
return isStoreUnit(value) || isCallableUnit(value);
|
|
116
|
+
}
|
|
117
|
+
function isStoreUnit(value) {
|
|
118
|
+
return Boolean(value && (typeof value === "object" || typeof value === "function") && "node" in value && "subscribe" in value && typeof value.subscribe === "function");
|
|
119
|
+
}
|
|
120
|
+
function isCallableUnit(value) {
|
|
121
|
+
return Boolean(typeof value === "function" && "node" in value);
|
|
122
|
+
}
|
|
123
|
+
function isPlainObject(value) {
|
|
124
|
+
if (!value || typeof value !== "object") return false;
|
|
125
|
+
const prototype = Object.getPrototypeOf(value);
|
|
126
|
+
return prototype === Object.prototype || prototype === null;
|
|
127
|
+
}
|
|
128
|
+
function getComponentName(view) {
|
|
129
|
+
return `Virentia(${view.displayName ?? view.name ?? "Component"})`;
|
|
130
|
+
}
|
|
131
|
+
function isArraySnapshot(unit, keys) {
|
|
132
|
+
return keys.includes("length") && keys.every((key) => key === "length" || typeof key === "string" && arrayIndexPattern.test(key)) && typeof Reflect.get(unit, "length") === "number";
|
|
133
|
+
}
|
|
134
|
+
const arrayIndexPattern = /^(0|[1-9]\d*)$/;
|
|
135
|
+
const nativeStoreKeys = new Set([
|
|
136
|
+
"node",
|
|
137
|
+
"writable",
|
|
138
|
+
"subscribe",
|
|
139
|
+
"map",
|
|
140
|
+
"filter",
|
|
141
|
+
"filterMap"
|
|
142
|
+
]);
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region lib/use-unit.ts
|
|
145
|
+
function useUnit(input) {
|
|
146
|
+
return useUnitWithScope(input, useProvidedScope());
|
|
147
|
+
}
|
|
148
|
+
function useUnitWithScope(input, scope) {
|
|
149
|
+
if (Array.isArray(input)) return input.map((unit) => useSingleUnit(unit, scope));
|
|
150
|
+
if (isUnitLike(input)) return useSingleUnit(input, scope);
|
|
151
|
+
const result = {};
|
|
152
|
+
for (const key of Object.keys(input)) result[key] = useSingleUnit(input[key], scope);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
function useSingleUnit(unit, scope) {
|
|
156
|
+
if (isStoreUnit(unit)) return useStoreUnit(unit, scope);
|
|
157
|
+
return useCallback((...args) => scoped(scope, () => unit(...args)), [scope, unit]);
|
|
158
|
+
}
|
|
159
|
+
function useStoreUnit(unit, scope) {
|
|
160
|
+
const snapshotRef = useRef(readStore(unit, scope));
|
|
161
|
+
snapshotRef.current = readStore(unit, scope);
|
|
162
|
+
const subscribe = useCallback((notify) => {
|
|
163
|
+
const unsubscribe = unit.subscribe((_value, nextScope) => {
|
|
164
|
+
if (nextScope !== scope) return;
|
|
165
|
+
snapshotRef.current = readStore(unit, scope);
|
|
166
|
+
notify();
|
|
167
|
+
});
|
|
168
|
+
snapshotRef.current = readStore(unit, scope);
|
|
169
|
+
return unsubscribe;
|
|
170
|
+
}, [scope, unit]);
|
|
171
|
+
const getSnapshot = useCallback(() => snapshotRef.current, []);
|
|
172
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region lib/use-model.ts
|
|
176
|
+
function useModel(modelOrFactory, props, options) {
|
|
177
|
+
const scope = useProvidedScope();
|
|
178
|
+
if (typeof modelOrFactory !== "function") return useReactiveModel(modelOrFactory, scope);
|
|
179
|
+
return useReactiveModel(useModelInstance(modelOrFactory, props, scope, options).model, scope);
|
|
180
|
+
}
|
|
181
|
+
function useModelInstance(factory, props, scope, options) {
|
|
182
|
+
const cache = options?.cache;
|
|
183
|
+
const key = options?.key;
|
|
184
|
+
const cached = Boolean(cache);
|
|
185
|
+
const instance = useMemo(() => {
|
|
186
|
+
if (cache) return getOrCreateCachedInstance(cache, scope, key, () => createModelInstance(factory, props, scope, key));
|
|
187
|
+
return createModelInstance(factory, props, scope, void 0);
|
|
188
|
+
}, [
|
|
189
|
+
cache,
|
|
190
|
+
key,
|
|
191
|
+
scope
|
|
192
|
+
]);
|
|
193
|
+
useIsomorphicLayoutEffect(() => {
|
|
194
|
+
writeStore(instance.props, props, scope);
|
|
195
|
+
}, [
|
|
196
|
+
instance,
|
|
197
|
+
props,
|
|
198
|
+
scope
|
|
199
|
+
]);
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
scoped(scope, () => {
|
|
202
|
+
instance.mounts.value += 1;
|
|
203
|
+
instance.mounted();
|
|
204
|
+
});
|
|
205
|
+
return () => {
|
|
206
|
+
scoped(scope, () => {
|
|
207
|
+
instance.mounts.value = Math.max(0, instance.mounts.value - 1);
|
|
208
|
+
instance.unmounted();
|
|
209
|
+
});
|
|
210
|
+
if (!cached) instance.dispose();
|
|
211
|
+
};
|
|
212
|
+
}, [
|
|
213
|
+
cached,
|
|
214
|
+
instance,
|
|
215
|
+
scope
|
|
216
|
+
]);
|
|
217
|
+
return instance;
|
|
218
|
+
}
|
|
219
|
+
function createModelInstance(factory, props, scope, key) {
|
|
220
|
+
return owner((dispose, modelOwner) => {
|
|
221
|
+
const context = {
|
|
222
|
+
scope,
|
|
223
|
+
owner: modelOwner,
|
|
224
|
+
props: store(props),
|
|
225
|
+
mounted: event(),
|
|
226
|
+
unmounted: event(),
|
|
227
|
+
mounts: store(0),
|
|
228
|
+
key
|
|
229
|
+
};
|
|
230
|
+
const model = scoped(scope, () => factory(context));
|
|
231
|
+
return {
|
|
232
|
+
...context,
|
|
233
|
+
model,
|
|
234
|
+
dispose
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function useReactiveModel(model, scope) {
|
|
239
|
+
const result = {};
|
|
240
|
+
for (const key of Reflect.ownKeys(model)) {
|
|
241
|
+
if (key === "dispose" || key === disposeSymbol) continue;
|
|
242
|
+
result[key] = useModelValue(Reflect.get(model, key), scope);
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
const disposeSymbol = typeof Symbol.dispose === "symbol" ? Symbol.dispose : Symbol.for("Symbol.dispose");
|
|
247
|
+
function useModelValue(value, scope) {
|
|
248
|
+
if (isUnitLike(value)) return useUnitWithScope(value, scope);
|
|
249
|
+
if (isPlainObject(value)) return useReactiveModel(value, scope);
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region lib/component.ts
|
|
254
|
+
function component(config) {
|
|
255
|
+
const VirentiaComponent = (props) => {
|
|
256
|
+
const model = "cache" in config ? useModel(config.model, props, {
|
|
257
|
+
cache: config.cache,
|
|
258
|
+
key: config.key(props)
|
|
259
|
+
}) : useModel(config.model, props);
|
|
260
|
+
return createElement(config.view, {
|
|
261
|
+
...props,
|
|
262
|
+
model
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
VirentiaComponent.displayName = getComponentName(config.view);
|
|
266
|
+
return VirentiaComponent;
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
export { ScopeProvider, component, createModelCache, useModel, useProvidedScope, useUnit };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@virentia/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"keywords": [],
|
|
6
|
+
"homepage": "https://movpushmov.dev/virentia/react/",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/movpushmov/virentia/issues"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "movpushmov",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/movpushmov/virentia.git",
|
|
15
|
+
"directory": "packages/react"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.cjs",
|
|
22
|
+
"module": "./dist/index.mjs",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"require": "./dist/index.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@virentia/core": "0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "^19.2.7",
|
|
39
|
+
"react": "^19.2.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsdown"
|
|
46
|
+
}
|
|
47
|
+
}
|