mvc-kit 2.12.4 → 2.13.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/agent-config/bin/postinstall.mjs +4 -3
- package/agent-config/bin/setup.mjs +5 -1
- package/agent-config/claude-code/agents/mvc-kit-architect.md +11 -8
- package/agent-config/claude-code/skills/guide/SKILL.md +20 -7
- package/agent-config/claude-code/skills/guide/patterns.md +12 -0
- package/agent-config/claude-code/skills/guide/recipes.md +510 -0
- package/agent-config/claude-code/skills/guide/testing.md +297 -0
- package/agent-config/claude-code/skills/review/SKILL.md +3 -13
- package/agent-config/claude-code/skills/review/checklist.md +30 -5
- package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
- package/agent-config/lib/install-claude.mjs +84 -25
- package/dist/Channel.cjs +276 -300
- package/dist/Channel.cjs.map +1 -1
- package/dist/Channel.js +275 -299
- package/dist/Channel.js.map +1 -1
- package/dist/Collection.cjs +424 -504
- package/dist/Collection.cjs.map +1 -1
- package/dist/Collection.js +423 -503
- package/dist/Collection.js.map +1 -1
- package/dist/Controller.cjs +70 -67
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.js +69 -66
- package/dist/Controller.js.map +1 -1
- package/dist/EventBus.cjs +77 -88
- package/dist/EventBus.cjs.map +1 -1
- package/dist/EventBus.js +76 -87
- package/dist/EventBus.js.map +1 -1
- package/dist/Feed.cjs +81 -77
- package/dist/Feed.cjs.map +1 -1
- package/dist/Feed.js +80 -76
- package/dist/Feed.js.map +1 -1
- package/dist/Model.cjs +181 -207
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.js +179 -205
- package/dist/Model.js.map +1 -1
- package/dist/Pagination.cjs +75 -73
- package/dist/Pagination.cjs.map +1 -1
- package/dist/Pagination.js +74 -72
- package/dist/Pagination.js.map +1 -1
- package/dist/Pending.cjs +255 -287
- package/dist/Pending.cjs.map +1 -1
- package/dist/Pending.js +253 -285
- package/dist/Pending.js.map +1 -1
- package/dist/PersistentCollection.cjs +242 -285
- package/dist/PersistentCollection.cjs.map +1 -1
- package/dist/PersistentCollection.js +241 -284
- package/dist/PersistentCollection.js.map +1 -1
- package/dist/Resource.cjs +166 -174
- package/dist/Resource.cjs.map +1 -1
- package/dist/Resource.js +164 -172
- package/dist/Resource.js.map +1 -1
- package/dist/Selection.cjs +84 -94
- package/dist/Selection.cjs.map +1 -1
- package/dist/Selection.js +83 -93
- package/dist/Selection.js.map +1 -1
- package/dist/Service.cjs +54 -55
- package/dist/Service.cjs.map +1 -1
- package/dist/Service.js +53 -54
- package/dist/Service.js.map +1 -1
- package/dist/Sorting.cjs +102 -101
- package/dist/Sorting.cjs.map +1 -1
- package/dist/Sorting.js +102 -101
- package/dist/Sorting.js.map +1 -1
- package/dist/Trackable.cjs +112 -80
- package/dist/Trackable.cjs.map +1 -1
- package/dist/Trackable.js +111 -79
- package/dist/Trackable.js.map +1 -1
- package/dist/ViewModel.cjs +528 -576
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.js +525 -573
- package/dist/ViewModel.js.map +1 -1
- package/dist/bindPublicMethods.cjs +43 -24
- package/dist/bindPublicMethods.cjs.map +1 -1
- package/dist/bindPublicMethods.js +43 -24
- package/dist/bindPublicMethods.js.map +1 -1
- package/dist/errors.cjs +67 -68
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.js +68 -71
- package/dist/errors.js.map +1 -1
- package/dist/mvc-kit.cjs +44 -46
- package/dist/mvc-kit.js +5 -32
- package/dist/produceDraft.cjs +105 -95
- package/dist/produceDraft.cjs.map +1 -1
- package/dist/produceDraft.js +106 -97
- package/dist/produceDraft.js.map +1 -1
- package/dist/react/components/CardList.cjs +30 -40
- package/dist/react/components/CardList.cjs.map +1 -1
- package/dist/react/components/CardList.js +31 -41
- package/dist/react/components/CardList.js.map +1 -1
- package/dist/react/components/DataTable.cjs +146 -169
- package/dist/react/components/DataTable.cjs.map +1 -1
- package/dist/react/components/DataTable.js +147 -170
- package/dist/react/components/DataTable.js.map +1 -1
- package/dist/react/components/InfiniteScroll.cjs +51 -42
- package/dist/react/components/InfiniteScroll.cjs.map +1 -1
- package/dist/react/components/InfiniteScroll.js +52 -43
- package/dist/react/components/InfiniteScroll.js.map +1 -1
- package/dist/react/components/types.cjs +10 -6
- package/dist/react/components/types.cjs.map +1 -1
- package/dist/react/components/types.js +11 -9
- package/dist/react/components/types.js.map +1 -1
- package/dist/react/guards.cjs +10 -6
- package/dist/react/guards.cjs.map +1 -1
- package/dist/react/guards.js +11 -9
- package/dist/react/guards.js.map +1 -1
- package/dist/react/provider.cjs +23 -20
- package/dist/react/provider.cjs.map +1 -1
- package/dist/react/provider.js +23 -21
- package/dist/react/provider.js.map +1 -1
- package/dist/react/use-event-bus.cjs +24 -20
- package/dist/react/use-event-bus.cjs.map +1 -1
- package/dist/react/use-event-bus.js +24 -21
- package/dist/react/use-event-bus.js.map +1 -1
- package/dist/react/use-instance.cjs +43 -36
- package/dist/react/use-instance.cjs.map +1 -1
- package/dist/react/use-instance.js +43 -36
- package/dist/react/use-instance.js.map +1 -1
- package/dist/react/use-local.cjs +48 -64
- package/dist/react/use-local.cjs.map +1 -1
- package/dist/react/use-local.js +47 -63
- package/dist/react/use-local.js.map +1 -1
- package/dist/react/use-model.cjs +84 -98
- package/dist/react/use-model.cjs.map +1 -1
- package/dist/react/use-model.js +84 -100
- package/dist/react/use-model.js.map +1 -1
- package/dist/react/use-singleton.cjs +19 -23
- package/dist/react/use-singleton.cjs.map +1 -1
- package/dist/react/use-singleton.js +16 -20
- package/dist/react/use-singleton.js.map +1 -1
- package/dist/react/use-subscribe-only.cjs +28 -22
- package/dist/react/use-subscribe-only.cjs.map +1 -1
- package/dist/react/use-subscribe-only.js +28 -22
- package/dist/react/use-subscribe-only.js.map +1 -1
- package/dist/react/use-teardown.cjs +20 -19
- package/dist/react/use-teardown.cjs.map +1 -1
- package/dist/react/use-teardown.js +20 -19
- package/dist/react/use-teardown.js.map +1 -1
- package/dist/react-native/NativeCollection.cjs +98 -78
- package/dist/react-native/NativeCollection.cjs.map +1 -1
- package/dist/react-native/NativeCollection.js +97 -77
- package/dist/react-native/NativeCollection.js.map +1 -1
- package/dist/react-native.cjs +2 -4
- package/dist/react-native.js +1 -4
- package/dist/react.cjs +24 -26
- package/dist/react.js +1 -17
- package/dist/singleton.cjs +28 -22
- package/dist/singleton.cjs.map +1 -1
- package/dist/singleton.js +29 -26
- package/dist/singleton.js.map +1 -1
- package/dist/walkPrototypeChain.cjs +20 -12
- package/dist/walkPrototypeChain.cjs.map +1 -1
- package/dist/walkPrototypeChain.js +21 -13
- package/dist/walkPrototypeChain.js.map +1 -1
- package/dist/web/IndexedDBCollection.cjs +53 -36
- package/dist/web/IndexedDBCollection.cjs.map +1 -1
- package/dist/web/IndexedDBCollection.js +52 -35
- package/dist/web/IndexedDBCollection.js.map +1 -1
- package/dist/web/WebStorageCollection.cjs +82 -84
- package/dist/web/WebStorageCollection.cjs.map +1 -1
- package/dist/web/WebStorageCollection.js +81 -83
- package/dist/web/WebStorageCollection.js.map +1 -1
- package/dist/web/idb.cjs +107 -99
- package/dist/web/idb.cjs.map +1 -1
- package/dist/web/idb.js +108 -105
- package/dist/web/idb.js.map +1 -1
- package/dist/web.cjs +4 -6
- package/dist/web.js +1 -5
- package/dist/wrapAsyncMethods.cjs +141 -168
- package/dist/wrapAsyncMethods.cjs.map +1 -1
- package/dist/wrapAsyncMethods.js +141 -168
- package/dist/wrapAsyncMethods.js.map +1 -1
- package/package.json +8 -8
- package/src/Pending.test.ts +1 -2
- package/src/Sorting.test.ts +1 -1
- package/src/produceDraft.test.ts +3 -3
- package/src/react/components/CardList.test.tsx +1 -1
- package/src/react/components/DataTable.test.tsx +1 -1
- package/src/react/components/InfiniteScroll.test.tsx +5 -5
- package/dist/mvc-kit.cjs.map +0 -1
- package/dist/mvc-kit.js.map +0 -1
- package/dist/react-native.cjs.map +0 -1
- package/dist/react-native.js.map +0 -1
- package/dist/react.cjs.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/web.cjs.map +0 -1
- package/dist/web.js.map +0 -1
package/dist/ViewModel.js
CHANGED
|
@@ -1,583 +1,535 @@
|
|
|
1
|
-
import { EventBus } from "./EventBus.js";
|
|
2
1
|
import { walkPrototypeChain } from "./walkPrototypeChain.js";
|
|
2
|
+
import { EventBus } from "./EventBus.js";
|
|
3
3
|
import { wrapAsyncMethods } from "./wrapAsyncMethods.js";
|
|
4
4
|
import { resolveDraftUpdater } from "./produceDraft.js";
|
|
5
|
-
|
|
5
|
+
//#region src/ViewModel.ts
|
|
6
|
+
var __DEV__ = typeof __MVC_KIT_DEV__ !== "undefined" && __MVC_KIT_DEV__;
|
|
6
7
|
function freeze(obj) {
|
|
7
|
-
|
|
8
|
+
return __DEV__ ? Object.freeze(obj) : obj;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
+
var classMembers = /* @__PURE__ */ new WeakMap();
|
|
10
11
|
function getClassMemberInfo(instance, stopPrototype, reservedKeys, lifecycleHooks) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
12
|
+
const ctor = instance.constructor;
|
|
13
|
+
let info = classMembers.get(ctor);
|
|
14
|
+
if (info) return info;
|
|
15
|
+
const getters = [];
|
|
16
|
+
const methods = [];
|
|
17
|
+
const found = [];
|
|
18
|
+
const processedGetters = /* @__PURE__ */ new Set();
|
|
19
|
+
const processedMethods = /* @__PURE__ */ new Set();
|
|
20
|
+
walkPrototypeChain(instance, stopPrototype, (key, desc) => {
|
|
21
|
+
if (reservedKeys.includes(key)) found.push(key);
|
|
22
|
+
if (desc.get && !processedGetters.has(key)) {
|
|
23
|
+
processedGetters.add(key);
|
|
24
|
+
getters.push({
|
|
25
|
+
key,
|
|
26
|
+
get: desc.get
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (!desc.get && !desc.set && typeof desc.value === "function" && !key.startsWith("_") && !lifecycleHooks.has(key) && !processedMethods.has(key)) {
|
|
30
|
+
processedMethods.add(key);
|
|
31
|
+
methods.push({
|
|
32
|
+
key,
|
|
33
|
+
fn: desc.value
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
info = {
|
|
38
|
+
getters,
|
|
39
|
+
methods,
|
|
40
|
+
reservedKeys: found
|
|
41
|
+
};
|
|
42
|
+
classMembers.set(ctor, info);
|
|
43
|
+
return info;
|
|
35
44
|
}
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
var _activeStateTracking = null;
|
|
46
|
+
var _activeSourceTracking = null;
|
|
38
47
|
function isAutoTrackable(value) {
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
const DEFAULT_TASK_STATE = Object.freeze({ loading: false, error: null, errorCode: null });
|
|
42
|
-
const RESERVED_ASYNC_KEYS = ["async", "subscribeAsync"];
|
|
43
|
-
const LIFECYCLE_HOOKS = /* @__PURE__ */ new Set(["onInit", "onSet", "onDispose"]);
|
|
44
|
-
class ViewModel {
|
|
45
|
-
_state;
|
|
46
|
-
_initialState;
|
|
47
|
-
_disposed = false;
|
|
48
|
-
_initialized = false;
|
|
49
|
-
_listeners = /* @__PURE__ */ new Set();
|
|
50
|
-
_abortController = null;
|
|
51
|
-
_cleanups = null;
|
|
52
|
-
_subscriptionCleanups = null;
|
|
53
|
-
_eventBus = null;
|
|
54
|
-
// ── Reactive derived state (RFC 1) ─────────────────────────────
|
|
55
|
-
_revision = 0;
|
|
56
|
-
_trackedSources = /* @__PURE__ */ new Map();
|
|
57
|
-
// ── Async tracking (RFC 2) ──────────────────────────────────────
|
|
58
|
-
// Lazily allocated on first async method wrap to keep construction fast.
|
|
59
|
-
_asyncStates = null;
|
|
60
|
-
_asyncSnapshots = null;
|
|
61
|
-
_asyncListeners = null;
|
|
62
|
-
_asyncProxy = null;
|
|
63
|
-
_activeOps = null;
|
|
64
|
-
/** DEV-only timeout (ms) for detecting ghost async operations after dispose. */
|
|
65
|
-
static GHOST_TIMEOUT = 3e3;
|
|
66
|
-
constructor(...args) {
|
|
67
|
-
const initialState = args[0] ?? {};
|
|
68
|
-
this._state = freeze({ ...initialState });
|
|
69
|
-
this._initialState = this._state;
|
|
70
|
-
this._guardAndBind();
|
|
71
|
-
}
|
|
72
|
-
/** Current frozen state object. */
|
|
73
|
-
get state() {
|
|
74
|
-
return this._state;
|
|
75
|
-
}
|
|
76
|
-
/** Whether this instance has been disposed. */
|
|
77
|
-
get disposed() {
|
|
78
|
-
return this._disposed;
|
|
79
|
-
}
|
|
80
|
-
/** Whether init() has been called. */
|
|
81
|
-
get initialized() {
|
|
82
|
-
return this._initialized;
|
|
83
|
-
}
|
|
84
|
-
/** AbortSignal that fires when this instance is disposed. Lazily created. */
|
|
85
|
-
get disposeSignal() {
|
|
86
|
-
if (!this._abortController) {
|
|
87
|
-
this._abortController = new AbortController();
|
|
88
|
-
}
|
|
89
|
-
return this._abortController.signal;
|
|
90
|
-
}
|
|
91
|
-
/** Lazily-created typed EventBus for emitting and subscribing to events. */
|
|
92
|
-
get events() {
|
|
93
|
-
if (!this._eventBus) {
|
|
94
|
-
this._eventBus = new EventBus();
|
|
95
|
-
}
|
|
96
|
-
return this._eventBus;
|
|
97
|
-
}
|
|
98
|
-
/** Initializes the instance. Called automatically by React hooks after mount. */
|
|
99
|
-
init() {
|
|
100
|
-
if (this._initialized || this._disposed) return;
|
|
101
|
-
this._initialized = true;
|
|
102
|
-
this._trackSubscribables();
|
|
103
|
-
this._installStateProxy();
|
|
104
|
-
this._processMembers();
|
|
105
|
-
return this.onInit?.();
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Merges partial state into current state. If no values actually
|
|
109
|
-
* changed by reference, this is a no-op.
|
|
110
|
-
*
|
|
111
|
-
* Triggers React re-render via listener notification. Called when:
|
|
112
|
-
* - User code calls set() to update source state
|
|
113
|
-
*
|
|
114
|
-
* NOT called for subscribable member notifications — those use
|
|
115
|
-
* a separate notification path (see _trackSubscribables).
|
|
116
|
-
*/
|
|
117
|
-
set(partialOrUpdater) {
|
|
118
|
-
if (this._disposed) return;
|
|
119
|
-
if (__DEV__ && _activeStateTracking) {
|
|
120
|
-
console.error(
|
|
121
|
-
"[mvc-kit] set() called inside a getter. Getters must be pure — they read state and return a value. They must never call set(), which would cause an infinite render loop. Move this logic to an action method."
|
|
122
|
-
);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
let partial;
|
|
126
|
-
if (typeof partialOrUpdater === "function") {
|
|
127
|
-
const result = resolveDraftUpdater(this._state, partialOrUpdater);
|
|
128
|
-
if (!result) return;
|
|
129
|
-
partial = result;
|
|
130
|
-
} else {
|
|
131
|
-
partial = partialOrUpdater;
|
|
132
|
-
}
|
|
133
|
-
let hasChanges = false;
|
|
134
|
-
const current = this._state;
|
|
135
|
-
for (const key in partial) {
|
|
136
|
-
if (partial[key] !== current[key]) {
|
|
137
|
-
hasChanges = true;
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (!hasChanges) {
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
const prev = this._state;
|
|
145
|
-
const next = freeze({ ...prev, ...partial });
|
|
146
|
-
this._state = next;
|
|
147
|
-
this._revision++;
|
|
148
|
-
this.onSet?.(prev, next);
|
|
149
|
-
for (const listener of this._listeners) {
|
|
150
|
-
listener(next, prev);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Emits a typed event via the internal EventBus.
|
|
155
|
-
* Safe to call during dispose cleanup callbacks.
|
|
156
|
-
* @protected
|
|
157
|
-
*/
|
|
158
|
-
emit(event, payload) {
|
|
159
|
-
if (this._eventBus?.disposed ?? this._disposed) return;
|
|
160
|
-
this.events.emit(event, payload);
|
|
161
|
-
}
|
|
162
|
-
/** Subscribes to state changes. Returns an unsubscribe function. */
|
|
163
|
-
subscribe(listener) {
|
|
164
|
-
if (this._disposed) {
|
|
165
|
-
return () => {
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
this._listeners.add(listener);
|
|
169
|
-
return () => {
|
|
170
|
-
this._listeners.delete(listener);
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
/** Tears down the instance, releasing all subscriptions and resources. */
|
|
174
|
-
dispose() {
|
|
175
|
-
if (this._disposed) {
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
this._disposed = true;
|
|
179
|
-
this._teardownSubscriptions();
|
|
180
|
-
this._abortController?.abort();
|
|
181
|
-
if (this._cleanups) {
|
|
182
|
-
for (const fn of this._cleanups) fn();
|
|
183
|
-
this._cleanups = null;
|
|
184
|
-
}
|
|
185
|
-
this._eventBus?.dispose();
|
|
186
|
-
this.onDispose?.();
|
|
187
|
-
this._listeners.clear();
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Resets state to initial values (or provided state), aborts in-flight work,
|
|
191
|
-
* clears async tracking, and re-runs onInit().
|
|
192
|
-
*/
|
|
193
|
-
reset(newState) {
|
|
194
|
-
if (this._disposed) return;
|
|
195
|
-
this._abortController?.abort();
|
|
196
|
-
this._abortController = null;
|
|
197
|
-
this._teardownSubscriptions();
|
|
198
|
-
this._state = newState ? freeze({ ...newState }) : this._initialState;
|
|
199
|
-
this._revision++;
|
|
200
|
-
this._asyncStates?.clear();
|
|
201
|
-
this._asyncSnapshots?.clear();
|
|
202
|
-
this._notifyAsync();
|
|
203
|
-
this._trackSubscribables();
|
|
204
|
-
for (const listener of this._listeners) {
|
|
205
|
-
listener(this._state, this._state);
|
|
206
|
-
}
|
|
207
|
-
return this.onInit?.();
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Registers a cleanup function to be called on dispose. Used internally for things like method wrapper
|
|
211
|
-
* cleanup and event bus disposal, but can also be used by subclasses for custom cleanup logic.
|
|
212
|
-
* @param fn
|
|
213
|
-
* @protected
|
|
214
|
-
*/
|
|
215
|
-
addCleanup(fn) {
|
|
216
|
-
if (!this._cleanups) {
|
|
217
|
-
this._cleanups = [];
|
|
218
|
-
}
|
|
219
|
-
this._cleanups.push(fn);
|
|
220
|
-
}
|
|
221
|
-
/** Subscribes to an external Subscribable with automatic cleanup on dispose. @protected */
|
|
222
|
-
subscribeTo(source, listener) {
|
|
223
|
-
const unsubscribe = source.subscribe(listener);
|
|
224
|
-
if (!this._subscriptionCleanups) this._subscriptionCleanups = [];
|
|
225
|
-
this._subscriptionCleanups.push(unsubscribe);
|
|
226
|
-
return unsubscribe;
|
|
227
|
-
}
|
|
228
|
-
/** Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose and reset. @protected */
|
|
229
|
-
listenTo(source, event, handler) {
|
|
230
|
-
const unsubscribe = source.on(event, handler);
|
|
231
|
-
if (!this._subscriptionCleanups) this._subscriptionCleanups = [];
|
|
232
|
-
this._subscriptionCleanups.push(unsubscribe);
|
|
233
|
-
return unsubscribe;
|
|
234
|
-
}
|
|
235
|
-
/** Pipes a Channel event into a Collection via upsert. Calls channel.init() and registers auto-cleanup on dispose and reset. @protected */
|
|
236
|
-
pipeChannel(channel, type, target) {
|
|
237
|
-
channel.init();
|
|
238
|
-
return this.listenTo(channel, type, (payload) => {
|
|
239
|
-
target.upsert(payload);
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
// ── Async tracking API ──────────────────────────────────────────
|
|
243
|
-
/** Proxy providing `TaskState` (loading, error, errorCode) per async method. */
|
|
244
|
-
get async() {
|
|
245
|
-
if (!this._asyncProxy) {
|
|
246
|
-
const self = this;
|
|
247
|
-
this._asyncProxy = new Proxy({}, {
|
|
248
|
-
get(_, prop) {
|
|
249
|
-
return self._asyncSnapshots?.get(prop) ?? DEFAULT_TASK_STATE;
|
|
250
|
-
},
|
|
251
|
-
has(_, prop) {
|
|
252
|
-
return self._asyncSnapshots?.has(prop) ?? false;
|
|
253
|
-
},
|
|
254
|
-
ownKeys() {
|
|
255
|
-
return self._asyncSnapshots ? Array.from(self._asyncSnapshots.keys()) : [];
|
|
256
|
-
},
|
|
257
|
-
getOwnPropertyDescriptor(_, prop) {
|
|
258
|
-
if (self._asyncSnapshots?.has(prop)) {
|
|
259
|
-
return { configurable: true, enumerable: true, value: self._asyncSnapshots.get(prop) };
|
|
260
|
-
}
|
|
261
|
-
return void 0;
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
return this._asyncProxy;
|
|
266
|
-
}
|
|
267
|
-
/** Subscribes to async state changes. Used for React integration. */
|
|
268
|
-
subscribeAsync(listener) {
|
|
269
|
-
if (this._disposed) return () => {
|
|
270
|
-
};
|
|
271
|
-
if (!this._asyncListeners) this._asyncListeners = /* @__PURE__ */ new Set();
|
|
272
|
-
this._asyncListeners.add(listener);
|
|
273
|
-
return () => {
|
|
274
|
-
this._asyncListeners.delete(listener);
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
_notifyAsync() {
|
|
278
|
-
if (!this._asyncListeners) return;
|
|
279
|
-
for (const listener of this._asyncListeners) {
|
|
280
|
-
listener();
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
// ── Async tracking internals ────────────────────────────────────
|
|
284
|
-
_teardownSubscriptions() {
|
|
285
|
-
for (const tracked of this._trackedSources.values()) tracked.unsubscribe();
|
|
286
|
-
this._trackedSources.clear();
|
|
287
|
-
if (this._subscriptionCleanups) {
|
|
288
|
-
for (const fn of this._subscriptionCleanups) fn();
|
|
289
|
-
this._subscriptionCleanups = null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Guards reserved keys and auto-binds subclass methods in a single pass
|
|
294
|
-
* using the cached class metadata from getClassMemberInfo.
|
|
295
|
-
*/
|
|
296
|
-
_guardAndBind() {
|
|
297
|
-
const info = getClassMemberInfo(this, ViewModel.prototype, RESERVED_ASYNC_KEYS, LIFECYCLE_HOOKS);
|
|
298
|
-
if (info.reservedKeys.length > 0) {
|
|
299
|
-
throw new Error(
|
|
300
|
-
`[mvc-kit] "${info.reservedKeys[0]}" is a reserved property on ViewModel and cannot be overridden.`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
for (let i = 0; i < info.methods.length; i++) {
|
|
304
|
-
this[info.methods[i].key] = info.methods[i].fn.bind(this);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
// ── Member processing (merged getter memoization + async method wrapping) ──
|
|
308
|
-
/**
|
|
309
|
-
* Uses cached class metadata to memoize getters (RFC 1) and delegates
|
|
310
|
-
* async method wrapping to the shared wrapAsyncMethods helper (RFC 2).
|
|
311
|
-
* Class metadata is computed once per class via getClassMemberInfo() and reused
|
|
312
|
-
* across all instances — avoids repeated prototype walks.
|
|
313
|
-
*/
|
|
314
|
-
_processMembers() {
|
|
315
|
-
const info = getClassMemberInfo(this, ViewModel.prototype, RESERVED_ASYNC_KEYS, LIFECYCLE_HOOKS);
|
|
316
|
-
for (let i = 0; i < info.getters.length; i++) {
|
|
317
|
-
this._wrapGetter(info.getters[i].key, info.getters[i].get);
|
|
318
|
-
}
|
|
319
|
-
if (__DEV__) {
|
|
320
|
-
for (const key of RESERVED_ASYNC_KEYS) {
|
|
321
|
-
if (Object.getOwnPropertyDescriptor(this, key)?.value !== void 0) {
|
|
322
|
-
throw new Error(
|
|
323
|
-
`[mvc-kit] "${key}" is a reserved property on ViewModel and cannot be overridden.`
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
if (info.methods.length === 0) return;
|
|
329
|
-
if (!this._asyncStates) this._asyncStates = /* @__PURE__ */ new Map();
|
|
330
|
-
if (!this._asyncSnapshots) this._asyncSnapshots = /* @__PURE__ */ new Map();
|
|
331
|
-
if (!this._asyncListeners) this._asyncListeners = /* @__PURE__ */ new Set();
|
|
332
|
-
if (__DEV__) {
|
|
333
|
-
this._activeOps = /* @__PURE__ */ new Map();
|
|
334
|
-
}
|
|
335
|
-
wrapAsyncMethods({
|
|
336
|
-
instance: this,
|
|
337
|
-
stopPrototype: ViewModel.prototype,
|
|
338
|
-
reservedKeys: RESERVED_ASYNC_KEYS,
|
|
339
|
-
lifecycleHooks: LIFECYCLE_HOOKS,
|
|
340
|
-
isDisposed: () => this._disposed,
|
|
341
|
-
isInitialized: () => this._initialized,
|
|
342
|
-
asyncStates: this._asyncStates,
|
|
343
|
-
asyncSnapshots: this._asyncSnapshots,
|
|
344
|
-
asyncListeners: this._asyncListeners,
|
|
345
|
-
notifyAsync: () => this._notifyAsync(),
|
|
346
|
-
addCleanup: (fn) => this.addCleanup(fn),
|
|
347
|
-
ghostTimeout: this.constructor.GHOST_TIMEOUT,
|
|
348
|
-
className: "ViewModel",
|
|
349
|
-
activeOps: this._activeOps,
|
|
350
|
-
methods: info.methods
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
// ── Auto-tracking internals ────────────────────────────────────
|
|
354
|
-
/**
|
|
355
|
-
* Installs a context-sensitive state getter on the instance.
|
|
356
|
-
*
|
|
357
|
-
* During getter tracking (_activeStateTracking is active): returns a Proxy
|
|
358
|
-
* that records which state properties are accessed. The Proxy is created
|
|
359
|
-
* lazily on first tracking access to keep init() fast.
|
|
360
|
-
*
|
|
361
|
-
* Otherwise: returns the frozen state object directly. This is critical
|
|
362
|
-
* for React's useSyncExternalStore — it needs a changing reference to
|
|
363
|
-
* detect state updates and trigger re-renders.
|
|
364
|
-
*/
|
|
365
|
-
_installStateProxy() {
|
|
366
|
-
let stateProxy = null;
|
|
367
|
-
Object.defineProperty(this, "state", {
|
|
368
|
-
get: () => {
|
|
369
|
-
if (_activeStateTracking) {
|
|
370
|
-
if (!stateProxy) {
|
|
371
|
-
stateProxy = new Proxy({}, {
|
|
372
|
-
get: (_, prop) => {
|
|
373
|
-
_activeStateTracking?.add(prop);
|
|
374
|
-
return this._state[prop];
|
|
375
|
-
},
|
|
376
|
-
ownKeys: () => Reflect.ownKeys(this._state),
|
|
377
|
-
getOwnPropertyDescriptor: (_, prop) => Reflect.getOwnPropertyDescriptor(this._state, prop),
|
|
378
|
-
set: () => {
|
|
379
|
-
throw new Error("Cannot mutate state directly. Use set() instead.");
|
|
380
|
-
},
|
|
381
|
-
has: (_, prop) => prop in this._state
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
return stateProxy;
|
|
385
|
-
}
|
|
386
|
-
return this._state;
|
|
387
|
-
},
|
|
388
|
-
configurable: true,
|
|
389
|
-
enumerable: true
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* Scans own instance properties for Subscribable objects and sets up
|
|
394
|
-
* automatic dependency tracking for each one found.
|
|
395
|
-
*
|
|
396
|
-
* For each subscribable member:
|
|
397
|
-
* 1. Subscribe to it. On notification: bump its tracked revision
|
|
398
|
-
* AND the VM's global revision, then force a new state reference
|
|
399
|
-
* and notify listeners so React re-renders.
|
|
400
|
-
* 2. Replace the instance property with a getter that participates
|
|
401
|
-
* in dependency tracking.
|
|
402
|
-
* 3. Register unsubscribe in the dispose chain.
|
|
403
|
-
*
|
|
404
|
-
* Called during init(), AFTER all subclass property initializers
|
|
405
|
-
* have run (they execute during the constructor, before init()).
|
|
406
|
-
*/
|
|
407
|
-
_trackSubscribables() {
|
|
408
|
-
for (const key of Object.getOwnPropertyNames(this)) {
|
|
409
|
-
const value = this[key];
|
|
410
|
-
if (!isAutoTrackable(value)) continue;
|
|
411
|
-
let tracked;
|
|
412
|
-
const onSourceNotify = () => {
|
|
413
|
-
if (this._disposed) return;
|
|
414
|
-
tracked.revision++;
|
|
415
|
-
this._revision++;
|
|
416
|
-
for (const listener of this._listeners) {
|
|
417
|
-
listener(this._state, this._state);
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
const unsubState = value.subscribe(onSourceNotify);
|
|
421
|
-
const unsubAsync = typeof value.subscribeAsync === "function" ? value.subscribeAsync(onSourceNotify) : void 0;
|
|
422
|
-
tracked = {
|
|
423
|
-
source: value,
|
|
424
|
-
revision: 0,
|
|
425
|
-
unsubscribe: unsubAsync ? () => {
|
|
426
|
-
unsubState();
|
|
427
|
-
unsubAsync();
|
|
428
|
-
} : unsubState
|
|
429
|
-
};
|
|
430
|
-
this._trackedSources.set(key, tracked);
|
|
431
|
-
Object.defineProperty(this, key, {
|
|
432
|
-
get: () => {
|
|
433
|
-
_activeSourceTracking?.set(key, tracked);
|
|
434
|
-
return value;
|
|
435
|
-
},
|
|
436
|
-
configurable: true,
|
|
437
|
-
enumerable: false
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* Bubbles cached dependency records to the active parent tracking context.
|
|
443
|
-
* Called from Tier 1/Tier 2 cache hits during nested getter composition
|
|
444
|
-
* so the parent getter records the full transitive dependency set.
|
|
445
|
-
* Extracted to keep the getter closure small for V8 inlining.
|
|
446
|
-
*/
|
|
447
|
-
_bubbleDeps(stateDepKeys, sourceDepKeys) {
|
|
448
|
-
const st = _activeStateTracking;
|
|
449
|
-
if (stateDepKeys) {
|
|
450
|
-
for (let i = 0; i < stateDepKeys.length; i++) st.add(stateDepKeys[i]);
|
|
451
|
-
}
|
|
452
|
-
if (sourceDepKeys) {
|
|
453
|
-
const srt = _activeSourceTracking;
|
|
454
|
-
for (let i = 0; i < sourceDepKeys.length; i++) {
|
|
455
|
-
const ts = this._trackedSources.get(sourceDepKeys[i]);
|
|
456
|
-
if (ts) srt.set(sourceDepKeys[i], ts);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* Replaces a single prototype getter with a memoized version on this
|
|
462
|
-
* instance. The memoized getter tracks both state dependencies and
|
|
463
|
-
* subscribable member dependencies, caching its result and
|
|
464
|
-
* revalidating through a three-tier strategy:
|
|
465
|
-
*
|
|
466
|
-
* Tier 1 (fast): revision unchanged → return cached (1 int compare)
|
|
467
|
-
* Tier 2 (medium): revision changed but this getter's deps didn't → return cached
|
|
468
|
-
* Tier 3 (slow): at least one dep changed → full recompute with tracking
|
|
469
|
-
*/
|
|
470
|
-
_wrapGetter(key, original) {
|
|
471
|
-
let cached;
|
|
472
|
-
let validatedAtRevision = -1;
|
|
473
|
-
let stateDepKeys;
|
|
474
|
-
let stateDepValues;
|
|
475
|
-
let sourceDepKeys;
|
|
476
|
-
let sourceDepRevisions;
|
|
477
|
-
let trackingSet;
|
|
478
|
-
let trackingMap;
|
|
479
|
-
Object.defineProperty(this, key, {
|
|
480
|
-
get: () => {
|
|
481
|
-
if (validatedAtRevision === this._revision) {
|
|
482
|
-
if (_activeStateTracking) this._bubbleDeps(stateDepKeys, sourceDepKeys);
|
|
483
|
-
return cached;
|
|
484
|
-
}
|
|
485
|
-
if (this._disposed) return cached;
|
|
486
|
-
if (stateDepKeys !== void 0) {
|
|
487
|
-
let fresh = true;
|
|
488
|
-
const state = this._state;
|
|
489
|
-
for (let i = 0; i < stateDepKeys.length; i++) {
|
|
490
|
-
if (state[stateDepKeys[i]] !== stateDepValues[i]) {
|
|
491
|
-
fresh = false;
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
if (fresh && sourceDepKeys !== void 0 && sourceDepKeys.length > 0) {
|
|
496
|
-
for (let i = 0; i < sourceDepKeys.length; i++) {
|
|
497
|
-
const ts = this._trackedSources.get(sourceDepKeys[i]);
|
|
498
|
-
if (ts && ts.revision !== sourceDepRevisions[i]) {
|
|
499
|
-
fresh = false;
|
|
500
|
-
break;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (fresh) {
|
|
505
|
-
if (_activeStateTracking) this._bubbleDeps(stateDepKeys, sourceDepKeys);
|
|
506
|
-
validatedAtRevision = this._revision;
|
|
507
|
-
return cached;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
const parentStateTracking = _activeStateTracking;
|
|
511
|
-
const parentSourceTracking = _activeSourceTracking;
|
|
512
|
-
if (trackingSet) {
|
|
513
|
-
trackingSet.clear();
|
|
514
|
-
} else {
|
|
515
|
-
trackingSet = /* @__PURE__ */ new Set();
|
|
516
|
-
}
|
|
517
|
-
if (trackingMap) {
|
|
518
|
-
trackingMap.clear();
|
|
519
|
-
} else {
|
|
520
|
-
trackingMap = /* @__PURE__ */ new Map();
|
|
521
|
-
}
|
|
522
|
-
_activeStateTracking = trackingSet;
|
|
523
|
-
_activeSourceTracking = trackingMap;
|
|
524
|
-
try {
|
|
525
|
-
cached = original.call(this);
|
|
526
|
-
} catch (e) {
|
|
527
|
-
_activeStateTracking = parentStateTracking;
|
|
528
|
-
_activeSourceTracking = parentSourceTracking;
|
|
529
|
-
throw e;
|
|
530
|
-
}
|
|
531
|
-
_activeStateTracking = parentStateTracking;
|
|
532
|
-
_activeSourceTracking = parentSourceTracking;
|
|
533
|
-
if (parentStateTracking) {
|
|
534
|
-
for (const d of trackingSet) parentStateTracking.add(d);
|
|
535
|
-
}
|
|
536
|
-
if (parentSourceTracking) {
|
|
537
|
-
for (const [k, v] of trackingMap) {
|
|
538
|
-
parentSourceTracking.set(k, v);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
const depCount = trackingSet.size;
|
|
542
|
-
if (!stateDepKeys || stateDepKeys.length !== depCount) {
|
|
543
|
-
stateDepKeys = new Array(depCount);
|
|
544
|
-
stateDepValues = new Array(depCount);
|
|
545
|
-
}
|
|
546
|
-
{
|
|
547
|
-
let i = 0;
|
|
548
|
-
const state = this._state;
|
|
549
|
-
for (const d of trackingSet) {
|
|
550
|
-
stateDepKeys[i] = d;
|
|
551
|
-
stateDepValues[i] = state[d];
|
|
552
|
-
i++;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
const sourceCount = trackingMap.size;
|
|
556
|
-
if (sourceCount > 0) {
|
|
557
|
-
if (!sourceDepKeys || sourceDepKeys.length !== sourceCount) {
|
|
558
|
-
sourceDepKeys = new Array(sourceCount);
|
|
559
|
-
sourceDepRevisions = new Array(sourceCount);
|
|
560
|
-
}
|
|
561
|
-
let i = 0;
|
|
562
|
-
for (const [memberKey, tracked] of trackingMap) {
|
|
563
|
-
sourceDepKeys[i] = memberKey;
|
|
564
|
-
sourceDepRevisions[i] = tracked.revision;
|
|
565
|
-
i++;
|
|
566
|
-
}
|
|
567
|
-
} else {
|
|
568
|
-
sourceDepKeys = void 0;
|
|
569
|
-
sourceDepRevisions = void 0;
|
|
570
|
-
}
|
|
571
|
-
validatedAtRevision = this._revision;
|
|
572
|
-
return cached;
|
|
573
|
-
},
|
|
574
|
-
configurable: true,
|
|
575
|
-
enumerable: true
|
|
576
|
-
});
|
|
577
|
-
}
|
|
48
|
+
return value !== null && typeof value === "object" && typeof value.subscribe === "function";
|
|
578
49
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
50
|
+
var DEFAULT_TASK_STATE = Object.freeze({
|
|
51
|
+
loading: false,
|
|
52
|
+
error: null,
|
|
53
|
+
errorCode: null
|
|
54
|
+
});
|
|
55
|
+
var RESERVED_ASYNC_KEYS = ["async", "subscribeAsync"];
|
|
56
|
+
var LIFECYCLE_HOOKS = new Set([
|
|
57
|
+
"onInit",
|
|
58
|
+
"onSet",
|
|
59
|
+
"onDispose"
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* Reactive state container with computed getters, automatic async tracking, and typed events.
|
|
63
|
+
* Subclass and define state shape, getters, and action methods. Use with `useLocal` in React.
|
|
64
|
+
*/
|
|
65
|
+
var ViewModel = class ViewModel {
|
|
66
|
+
_state;
|
|
67
|
+
_initialState;
|
|
68
|
+
_disposed = false;
|
|
69
|
+
_initialized = false;
|
|
70
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
71
|
+
_abortController = null;
|
|
72
|
+
_cleanups = null;
|
|
73
|
+
_subscriptionCleanups = null;
|
|
74
|
+
_eventBus = null;
|
|
75
|
+
_revision = 0;
|
|
76
|
+
_trackedSources = /* @__PURE__ */ new Map();
|
|
77
|
+
_asyncStates = null;
|
|
78
|
+
_asyncSnapshots = null;
|
|
79
|
+
_asyncListeners = null;
|
|
80
|
+
_asyncProxy = null;
|
|
81
|
+
_activeOps = null;
|
|
82
|
+
/** DEV-only timeout (ms) for detecting ghost async operations after dispose. */
|
|
83
|
+
static GHOST_TIMEOUT = 3e3;
|
|
84
|
+
constructor(...args) {
|
|
85
|
+
this._state = freeze({ ...args[0] ?? {} });
|
|
86
|
+
this._initialState = this._state;
|
|
87
|
+
this._guardAndBind();
|
|
88
|
+
}
|
|
89
|
+
/** Current frozen state object. */
|
|
90
|
+
get state() {
|
|
91
|
+
return this._state;
|
|
92
|
+
}
|
|
93
|
+
/** Whether this instance has been disposed. */
|
|
94
|
+
get disposed() {
|
|
95
|
+
return this._disposed;
|
|
96
|
+
}
|
|
97
|
+
/** Whether init() has been called. */
|
|
98
|
+
get initialized() {
|
|
99
|
+
return this._initialized;
|
|
100
|
+
}
|
|
101
|
+
/** AbortSignal that fires when this instance is disposed. Lazily created. */
|
|
102
|
+
get disposeSignal() {
|
|
103
|
+
if (!this._abortController) this._abortController = new AbortController();
|
|
104
|
+
return this._abortController.signal;
|
|
105
|
+
}
|
|
106
|
+
/** Lazily-created typed EventBus for emitting and subscribing to events. */
|
|
107
|
+
get events() {
|
|
108
|
+
if (!this._eventBus) this._eventBus = new EventBus();
|
|
109
|
+
return this._eventBus;
|
|
110
|
+
}
|
|
111
|
+
/** Initializes the instance. Called automatically by React hooks after mount. */
|
|
112
|
+
init() {
|
|
113
|
+
if (this._initialized || this._disposed) return;
|
|
114
|
+
this._initialized = true;
|
|
115
|
+
this._trackSubscribables();
|
|
116
|
+
this._installStateProxy();
|
|
117
|
+
this._processMembers();
|
|
118
|
+
return this.onInit?.();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Merges partial state into current state. If no values actually
|
|
122
|
+
* changed by reference, this is a no-op.
|
|
123
|
+
*
|
|
124
|
+
* Triggers React re-render via listener notification. Called when:
|
|
125
|
+
* - User code calls set() to update source state
|
|
126
|
+
*
|
|
127
|
+
* NOT called for subscribable member notifications — those use
|
|
128
|
+
* a separate notification path (see _trackSubscribables).
|
|
129
|
+
*/
|
|
130
|
+
set(partialOrUpdater) {
|
|
131
|
+
if (this._disposed) return;
|
|
132
|
+
if (__DEV__ && _activeStateTracking) {
|
|
133
|
+
console.error("[mvc-kit] set() called inside a getter. Getters must be pure — they read state and return a value. They must never call set(), which would cause an infinite render loop. Move this logic to an action method.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let partial;
|
|
137
|
+
if (typeof partialOrUpdater === "function") {
|
|
138
|
+
const result = resolveDraftUpdater(this._state, partialOrUpdater);
|
|
139
|
+
if (!result) return;
|
|
140
|
+
partial = result;
|
|
141
|
+
} else partial = partialOrUpdater;
|
|
142
|
+
let hasChanges = false;
|
|
143
|
+
const current = this._state;
|
|
144
|
+
for (const key in partial) if (partial[key] !== current[key]) {
|
|
145
|
+
hasChanges = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (!hasChanges) return;
|
|
149
|
+
const prev = this._state;
|
|
150
|
+
const next = freeze({
|
|
151
|
+
...prev,
|
|
152
|
+
...partial
|
|
153
|
+
});
|
|
154
|
+
this._state = next;
|
|
155
|
+
this._revision++;
|
|
156
|
+
this.onSet?.(prev, next);
|
|
157
|
+
for (const listener of this._listeners) listener(next, prev);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Emits a typed event via the internal EventBus.
|
|
161
|
+
* Safe to call during dispose cleanup callbacks.
|
|
162
|
+
* @protected
|
|
163
|
+
*/
|
|
164
|
+
emit(event, payload) {
|
|
165
|
+
if (this._eventBus?.disposed ?? this._disposed) return;
|
|
166
|
+
this.events.emit(event, payload);
|
|
167
|
+
}
|
|
168
|
+
/** Subscribes to state changes. Returns an unsubscribe function. */
|
|
169
|
+
subscribe(listener) {
|
|
170
|
+
if (this._disposed) return () => {};
|
|
171
|
+
this._listeners.add(listener);
|
|
172
|
+
return () => {
|
|
173
|
+
this._listeners.delete(listener);
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Tears down the instance, releasing all subscriptions and resources. */
|
|
177
|
+
dispose() {
|
|
178
|
+
if (this._disposed) return;
|
|
179
|
+
this._disposed = true;
|
|
180
|
+
this._teardownSubscriptions();
|
|
181
|
+
this._abortController?.abort();
|
|
182
|
+
if (this._cleanups) {
|
|
183
|
+
for (const fn of this._cleanups) fn();
|
|
184
|
+
this._cleanups = null;
|
|
185
|
+
}
|
|
186
|
+
this._eventBus?.dispose();
|
|
187
|
+
this.onDispose?.();
|
|
188
|
+
this._listeners.clear();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resets state to initial values (or provided state), aborts in-flight work,
|
|
192
|
+
* clears async tracking, and re-runs onInit().
|
|
193
|
+
*/
|
|
194
|
+
reset(newState) {
|
|
195
|
+
if (this._disposed) return;
|
|
196
|
+
this._abortController?.abort();
|
|
197
|
+
this._abortController = null;
|
|
198
|
+
this._teardownSubscriptions();
|
|
199
|
+
this._state = newState ? freeze({ ...newState }) : this._initialState;
|
|
200
|
+
this._revision++;
|
|
201
|
+
this._asyncStates?.clear();
|
|
202
|
+
this._asyncSnapshots?.clear();
|
|
203
|
+
this._notifyAsync();
|
|
204
|
+
this._trackSubscribables();
|
|
205
|
+
for (const listener of this._listeners) listener(this._state, this._state);
|
|
206
|
+
return this.onInit?.();
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Registers a cleanup function to be called on dispose. Used internally for things like method wrapper
|
|
210
|
+
* cleanup and event bus disposal, but can also be used by subclasses for custom cleanup logic.
|
|
211
|
+
* @param fn
|
|
212
|
+
* @protected
|
|
213
|
+
*/
|
|
214
|
+
addCleanup(fn) {
|
|
215
|
+
if (!this._cleanups) this._cleanups = [];
|
|
216
|
+
this._cleanups.push(fn);
|
|
217
|
+
}
|
|
218
|
+
/** Subscribes to an external Subscribable with automatic cleanup on dispose. @protected */
|
|
219
|
+
subscribeTo(source, listener) {
|
|
220
|
+
const unsubscribe = source.subscribe(listener);
|
|
221
|
+
if (!this._subscriptionCleanups) this._subscriptionCleanups = [];
|
|
222
|
+
this._subscriptionCleanups.push(unsubscribe);
|
|
223
|
+
return unsubscribe;
|
|
224
|
+
}
|
|
225
|
+
/** Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose and reset. @protected */
|
|
226
|
+
listenTo(source, event, handler) {
|
|
227
|
+
const unsubscribe = source.on(event, handler);
|
|
228
|
+
if (!this._subscriptionCleanups) this._subscriptionCleanups = [];
|
|
229
|
+
this._subscriptionCleanups.push(unsubscribe);
|
|
230
|
+
return unsubscribe;
|
|
231
|
+
}
|
|
232
|
+
/** Pipes a Channel event into a Collection via upsert. Calls channel.init() and registers auto-cleanup on dispose and reset. @protected */
|
|
233
|
+
pipeChannel(channel, type, target) {
|
|
234
|
+
channel.init();
|
|
235
|
+
return this.listenTo(channel, type, (payload) => {
|
|
236
|
+
target.upsert(payload);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Proxy providing `TaskState` (loading, error, errorCode) per async method. */
|
|
240
|
+
get async() {
|
|
241
|
+
if (!this._asyncProxy) {
|
|
242
|
+
const self = this;
|
|
243
|
+
this._asyncProxy = new Proxy({}, {
|
|
244
|
+
get(_, prop) {
|
|
245
|
+
return self._asyncSnapshots?.get(prop) ?? DEFAULT_TASK_STATE;
|
|
246
|
+
},
|
|
247
|
+
has(_, prop) {
|
|
248
|
+
return self._asyncSnapshots?.has(prop) ?? false;
|
|
249
|
+
},
|
|
250
|
+
ownKeys() {
|
|
251
|
+
return self._asyncSnapshots ? Array.from(self._asyncSnapshots.keys()) : [];
|
|
252
|
+
},
|
|
253
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
254
|
+
if (self._asyncSnapshots?.has(prop)) return {
|
|
255
|
+
configurable: true,
|
|
256
|
+
enumerable: true,
|
|
257
|
+
value: self._asyncSnapshots.get(prop)
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return this._asyncProxy;
|
|
263
|
+
}
|
|
264
|
+
/** Subscribes to async state changes. Used for React integration. */
|
|
265
|
+
subscribeAsync(listener) {
|
|
266
|
+
if (this._disposed) return () => {};
|
|
267
|
+
if (!this._asyncListeners) this._asyncListeners = /* @__PURE__ */ new Set();
|
|
268
|
+
this._asyncListeners.add(listener);
|
|
269
|
+
return () => {
|
|
270
|
+
this._asyncListeners.delete(listener);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
_notifyAsync() {
|
|
274
|
+
if (!this._asyncListeners) return;
|
|
275
|
+
for (const listener of this._asyncListeners) listener();
|
|
276
|
+
}
|
|
277
|
+
_teardownSubscriptions() {
|
|
278
|
+
for (const tracked of this._trackedSources.values()) tracked.unsubscribe();
|
|
279
|
+
this._trackedSources.clear();
|
|
280
|
+
if (this._subscriptionCleanups) {
|
|
281
|
+
for (const fn of this._subscriptionCleanups) fn();
|
|
282
|
+
this._subscriptionCleanups = null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Guards reserved keys and auto-binds subclass methods in a single pass
|
|
287
|
+
* using the cached class metadata from getClassMemberInfo.
|
|
288
|
+
*/
|
|
289
|
+
_guardAndBind() {
|
|
290
|
+
const info = getClassMemberInfo(this, ViewModel.prototype, RESERVED_ASYNC_KEYS, LIFECYCLE_HOOKS);
|
|
291
|
+
if (info.reservedKeys.length > 0) throw new Error(`[mvc-kit] "${info.reservedKeys[0]}" is a reserved property on ViewModel and cannot be overridden.`);
|
|
292
|
+
for (let i = 0; i < info.methods.length; i++) this[info.methods[i].key] = info.methods[i].fn.bind(this);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Uses cached class metadata to memoize getters (RFC 1) and delegates
|
|
296
|
+
* async method wrapping to the shared wrapAsyncMethods helper (RFC 2).
|
|
297
|
+
* Class metadata is computed once per class via getClassMemberInfo() and reused
|
|
298
|
+
* across all instances — avoids repeated prototype walks.
|
|
299
|
+
*/
|
|
300
|
+
_processMembers() {
|
|
301
|
+
const info = getClassMemberInfo(this, ViewModel.prototype, RESERVED_ASYNC_KEYS, LIFECYCLE_HOOKS);
|
|
302
|
+
for (let i = 0; i < info.getters.length; i++) this._wrapGetter(info.getters[i].key, info.getters[i].get);
|
|
303
|
+
if (__DEV__) {
|
|
304
|
+
for (const key of RESERVED_ASYNC_KEYS) if (Object.getOwnPropertyDescriptor(this, key)?.value !== void 0) throw new Error(`[mvc-kit] "${key}" is a reserved property on ViewModel and cannot be overridden.`);
|
|
305
|
+
}
|
|
306
|
+
if (info.methods.length === 0) return;
|
|
307
|
+
if (!this._asyncStates) this._asyncStates = /* @__PURE__ */ new Map();
|
|
308
|
+
if (!this._asyncSnapshots) this._asyncSnapshots = /* @__PURE__ */ new Map();
|
|
309
|
+
if (!this._asyncListeners) this._asyncListeners = /* @__PURE__ */ new Set();
|
|
310
|
+
if (__DEV__) this._activeOps = /* @__PURE__ */ new Map();
|
|
311
|
+
wrapAsyncMethods({
|
|
312
|
+
instance: this,
|
|
313
|
+
stopPrototype: ViewModel.prototype,
|
|
314
|
+
reservedKeys: RESERVED_ASYNC_KEYS,
|
|
315
|
+
lifecycleHooks: LIFECYCLE_HOOKS,
|
|
316
|
+
isDisposed: () => this._disposed,
|
|
317
|
+
isInitialized: () => this._initialized,
|
|
318
|
+
asyncStates: this._asyncStates,
|
|
319
|
+
asyncSnapshots: this._asyncSnapshots,
|
|
320
|
+
asyncListeners: this._asyncListeners,
|
|
321
|
+
notifyAsync: () => this._notifyAsync(),
|
|
322
|
+
addCleanup: (fn) => this.addCleanup(fn),
|
|
323
|
+
ghostTimeout: this.constructor.GHOST_TIMEOUT,
|
|
324
|
+
className: "ViewModel",
|
|
325
|
+
activeOps: this._activeOps,
|
|
326
|
+
methods: info.methods
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Installs a context-sensitive state getter on the instance.
|
|
331
|
+
*
|
|
332
|
+
* During getter tracking (_activeStateTracking is active): returns a Proxy
|
|
333
|
+
* that records which state properties are accessed. The Proxy is created
|
|
334
|
+
* lazily on first tracking access to keep init() fast.
|
|
335
|
+
*
|
|
336
|
+
* Otherwise: returns the frozen state object directly. This is critical
|
|
337
|
+
* for React's useSyncExternalStore — it needs a changing reference to
|
|
338
|
+
* detect state updates and trigger re-renders.
|
|
339
|
+
*/
|
|
340
|
+
_installStateProxy() {
|
|
341
|
+
let stateProxy = null;
|
|
342
|
+
Object.defineProperty(this, "state", {
|
|
343
|
+
get: () => {
|
|
344
|
+
if (_activeStateTracking) {
|
|
345
|
+
if (!stateProxy) stateProxy = new Proxy({}, {
|
|
346
|
+
get: (_, prop) => {
|
|
347
|
+
_activeStateTracking?.add(prop);
|
|
348
|
+
return this._state[prop];
|
|
349
|
+
},
|
|
350
|
+
ownKeys: () => Reflect.ownKeys(this._state),
|
|
351
|
+
getOwnPropertyDescriptor: (_, prop) => Reflect.getOwnPropertyDescriptor(this._state, prop),
|
|
352
|
+
set: () => {
|
|
353
|
+
throw new Error("Cannot mutate state directly. Use set() instead.");
|
|
354
|
+
},
|
|
355
|
+
has: (_, prop) => prop in this._state
|
|
356
|
+
});
|
|
357
|
+
return stateProxy;
|
|
358
|
+
}
|
|
359
|
+
return this._state;
|
|
360
|
+
},
|
|
361
|
+
configurable: true,
|
|
362
|
+
enumerable: true
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Scans own instance properties for Subscribable objects and sets up
|
|
367
|
+
* automatic dependency tracking for each one found.
|
|
368
|
+
*
|
|
369
|
+
* For each subscribable member:
|
|
370
|
+
* 1. Subscribe to it. On notification: bump its tracked revision
|
|
371
|
+
* AND the VM's global revision, then force a new state reference
|
|
372
|
+
* and notify listeners so React re-renders.
|
|
373
|
+
* 2. Replace the instance property with a getter that participates
|
|
374
|
+
* in dependency tracking.
|
|
375
|
+
* 3. Register unsubscribe in the dispose chain.
|
|
376
|
+
*
|
|
377
|
+
* Called during init(), AFTER all subclass property initializers
|
|
378
|
+
* have run (they execute during the constructor, before init()).
|
|
379
|
+
*/
|
|
380
|
+
_trackSubscribables() {
|
|
381
|
+
for (const key of Object.getOwnPropertyNames(this)) {
|
|
382
|
+
const value = this[key];
|
|
383
|
+
if (!isAutoTrackable(value)) continue;
|
|
384
|
+
let tracked;
|
|
385
|
+
const onSourceNotify = () => {
|
|
386
|
+
if (this._disposed) return;
|
|
387
|
+
tracked.revision++;
|
|
388
|
+
this._revision++;
|
|
389
|
+
for (const listener of this._listeners) listener(this._state, this._state);
|
|
390
|
+
};
|
|
391
|
+
const unsubState = value.subscribe(onSourceNotify);
|
|
392
|
+
const unsubAsync = typeof value.subscribeAsync === "function" ? value.subscribeAsync(onSourceNotify) : void 0;
|
|
393
|
+
tracked = {
|
|
394
|
+
source: value,
|
|
395
|
+
revision: 0,
|
|
396
|
+
unsubscribe: unsubAsync ? () => {
|
|
397
|
+
unsubState();
|
|
398
|
+
unsubAsync();
|
|
399
|
+
} : unsubState
|
|
400
|
+
};
|
|
401
|
+
this._trackedSources.set(key, tracked);
|
|
402
|
+
Object.defineProperty(this, key, {
|
|
403
|
+
get: () => {
|
|
404
|
+
_activeSourceTracking?.set(key, tracked);
|
|
405
|
+
return value;
|
|
406
|
+
},
|
|
407
|
+
configurable: true,
|
|
408
|
+
enumerable: false
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Bubbles cached dependency records to the active parent tracking context.
|
|
414
|
+
* Called from Tier 1/Tier 2 cache hits during nested getter composition
|
|
415
|
+
* so the parent getter records the full transitive dependency set.
|
|
416
|
+
* Extracted to keep the getter closure small for V8 inlining.
|
|
417
|
+
*/
|
|
418
|
+
_bubbleDeps(stateDepKeys, sourceDepKeys) {
|
|
419
|
+
const st = _activeStateTracking;
|
|
420
|
+
if (stateDepKeys) for (let i = 0; i < stateDepKeys.length; i++) st.add(stateDepKeys[i]);
|
|
421
|
+
if (sourceDepKeys) {
|
|
422
|
+
const srt = _activeSourceTracking;
|
|
423
|
+
for (let i = 0; i < sourceDepKeys.length; i++) {
|
|
424
|
+
const ts = this._trackedSources.get(sourceDepKeys[i]);
|
|
425
|
+
if (ts) srt.set(sourceDepKeys[i], ts);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Replaces a single prototype getter with a memoized version on this
|
|
431
|
+
* instance. The memoized getter tracks both state dependencies and
|
|
432
|
+
* subscribable member dependencies, caching its result and
|
|
433
|
+
* revalidating through a three-tier strategy:
|
|
434
|
+
*
|
|
435
|
+
* Tier 1 (fast): revision unchanged → return cached (1 int compare)
|
|
436
|
+
* Tier 2 (medium): revision changed but this getter's deps didn't → return cached
|
|
437
|
+
* Tier 3 (slow): at least one dep changed → full recompute with tracking
|
|
438
|
+
*/
|
|
439
|
+
_wrapGetter(key, original) {
|
|
440
|
+
let cached;
|
|
441
|
+
let validatedAtRevision = -1;
|
|
442
|
+
let stateDepKeys;
|
|
443
|
+
let stateDepValues;
|
|
444
|
+
let sourceDepKeys;
|
|
445
|
+
let sourceDepRevisions;
|
|
446
|
+
let trackingSet;
|
|
447
|
+
let trackingMap;
|
|
448
|
+
Object.defineProperty(this, key, {
|
|
449
|
+
get: () => {
|
|
450
|
+
if (validatedAtRevision === this._revision) {
|
|
451
|
+
if (_activeStateTracking) this._bubbleDeps(stateDepKeys, sourceDepKeys);
|
|
452
|
+
return cached;
|
|
453
|
+
}
|
|
454
|
+
if (this._disposed) return cached;
|
|
455
|
+
if (stateDepKeys !== void 0) {
|
|
456
|
+
let fresh = true;
|
|
457
|
+
const state = this._state;
|
|
458
|
+
for (let i = 0; i < stateDepKeys.length; i++) if (state[stateDepKeys[i]] !== stateDepValues[i]) {
|
|
459
|
+
fresh = false;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
if (fresh && sourceDepKeys !== void 0 && sourceDepKeys.length > 0) for (let i = 0; i < sourceDepKeys.length; i++) {
|
|
463
|
+
const ts = this._trackedSources.get(sourceDepKeys[i]);
|
|
464
|
+
if (ts && ts.revision !== sourceDepRevisions[i]) {
|
|
465
|
+
fresh = false;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (fresh) {
|
|
470
|
+
if (_activeStateTracking) this._bubbleDeps(stateDepKeys, sourceDepKeys);
|
|
471
|
+
validatedAtRevision = this._revision;
|
|
472
|
+
return cached;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const parentStateTracking = _activeStateTracking;
|
|
476
|
+
const parentSourceTracking = _activeSourceTracking;
|
|
477
|
+
if (trackingSet) trackingSet.clear();
|
|
478
|
+
else trackingSet = /* @__PURE__ */ new Set();
|
|
479
|
+
if (trackingMap) trackingMap.clear();
|
|
480
|
+
else trackingMap = /* @__PURE__ */ new Map();
|
|
481
|
+
_activeStateTracking = trackingSet;
|
|
482
|
+
_activeSourceTracking = trackingMap;
|
|
483
|
+
try {
|
|
484
|
+
cached = original.call(this);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
_activeStateTracking = parentStateTracking;
|
|
487
|
+
_activeSourceTracking = parentSourceTracking;
|
|
488
|
+
throw e;
|
|
489
|
+
}
|
|
490
|
+
_activeStateTracking = parentStateTracking;
|
|
491
|
+
_activeSourceTracking = parentSourceTracking;
|
|
492
|
+
if (parentStateTracking) for (const d of trackingSet) parentStateTracking.add(d);
|
|
493
|
+
if (parentSourceTracking) for (const [k, v] of trackingMap) parentSourceTracking.set(k, v);
|
|
494
|
+
const depCount = trackingSet.size;
|
|
495
|
+
if (!stateDepKeys || stateDepKeys.length !== depCount) {
|
|
496
|
+
stateDepKeys = new Array(depCount);
|
|
497
|
+
stateDepValues = new Array(depCount);
|
|
498
|
+
}
|
|
499
|
+
{
|
|
500
|
+
let i = 0;
|
|
501
|
+
const state = this._state;
|
|
502
|
+
for (const d of trackingSet) {
|
|
503
|
+
stateDepKeys[i] = d;
|
|
504
|
+
stateDepValues[i] = state[d];
|
|
505
|
+
i++;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const sourceCount = trackingMap.size;
|
|
509
|
+
if (sourceCount > 0) {
|
|
510
|
+
if (!sourceDepKeys || sourceDepKeys.length !== sourceCount) {
|
|
511
|
+
sourceDepKeys = new Array(sourceCount);
|
|
512
|
+
sourceDepRevisions = new Array(sourceCount);
|
|
513
|
+
}
|
|
514
|
+
let i = 0;
|
|
515
|
+
for (const [memberKey, tracked] of trackingMap) {
|
|
516
|
+
sourceDepKeys[i] = memberKey;
|
|
517
|
+
sourceDepRevisions[i] = tracked.revision;
|
|
518
|
+
i++;
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
sourceDepKeys = void 0;
|
|
522
|
+
sourceDepRevisions = void 0;
|
|
523
|
+
}
|
|
524
|
+
validatedAtRevision = this._revision;
|
|
525
|
+
return cached;
|
|
526
|
+
},
|
|
527
|
+
configurable: true,
|
|
528
|
+
enumerable: true
|
|
529
|
+
});
|
|
530
|
+
}
|
|
582
531
|
};
|
|
583
|
-
//#
|
|
532
|
+
//#endregion
|
|
533
|
+
export { ViewModel };
|
|
534
|
+
|
|
535
|
+
//# sourceMappingURL=ViewModel.js.map
|