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