@blac/core 2.0.0-rc-13 → 2.0.0-rc-16

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.
Files changed (54) hide show
  1. package/package.json +10 -15
  2. package/src/Blac.ts +38 -71
  3. package/src/Bloc.ts +8 -9
  4. package/src/BlocBase.ts +246 -540
  5. package/src/Cubit.ts +1 -16
  6. package/src/__tests__/Blac.config.test.ts +10 -17
  7. package/src/__tests__/Bloc.event.test.ts +27 -52
  8. package/src/__tests__/BlocBase.subscription.test.ts +349 -0
  9. package/src/__tests__/Cubit.test.ts +39 -50
  10. package/src/__tests__/memory-leaks.test.ts +383 -0
  11. package/src/__tests__/plugins.test.ts +107 -97
  12. package/src/adapter/BlacAdapter.ts +347 -207
  13. package/src/adapter/ProxyFactory.ts +196 -255
  14. package/src/adapter/__tests__/BlacAdapter.test.ts +71 -77
  15. package/src/adapter/__tests__/ProxyFactory.test.ts +1 -1
  16. package/src/adapter/index.ts +0 -1
  17. package/src/errors/BlacError.ts +69 -0
  18. package/src/errors/ErrorManager.ts +101 -0
  19. package/src/errors/handleError.ts +114 -0
  20. package/src/errors/index.ts +13 -0
  21. package/src/events.ts +7 -1
  22. package/src/index.ts +7 -1
  23. package/src/lifecycle/BlocLifecycle.ts +156 -0
  24. package/src/lifecycle/index.ts +1 -0
  25. package/src/plugins/BlocPluginRegistry.ts +71 -51
  26. package/src/plugins/SystemPluginRegistry.ts +63 -13
  27. package/src/plugins/__tests__/BlocPluginRegistry.test.ts +450 -0
  28. package/src/plugins/__tests__/SystemPluginRegistry.test.ts +404 -0
  29. package/src/plugins/types.ts +32 -3
  30. package/src/subscription/README.md +132 -0
  31. package/src/subscription/SubscriptionManager.ts +395 -0
  32. package/src/subscription/__tests__/SubscriptionManager.test.ts +355 -0
  33. package/src/subscription/index.ts +2 -0
  34. package/src/subscription/types.ts +90 -0
  35. package/src/testing.ts +31 -37
  36. package/src/types.ts +4 -21
  37. package/src/utils/BatchingManager.ts +59 -0
  38. package/src/utils/RerenderLogger.ts +265 -0
  39. package/src/utils/__tests__/generateInstanceId.test.ts +104 -0
  40. package/src/utils/__tests__/shallowEqual.test.ts +171 -0
  41. package/src/utils/__tests__/uuid.test.ts +144 -0
  42. package/src/utils/generateInstanceId.ts +54 -0
  43. package/src/utils/shallowEqual.ts +1 -1
  44. package/README.md +0 -329
  45. package/dist/index.cjs.js +0 -11
  46. package/dist/index.cjs.js.map +0 -1
  47. package/dist/index.d.ts +0 -1078
  48. package/dist/index.es.js +0 -1992
  49. package/dist/index.es.js.map +0 -1
  50. package/src/BlacObserver.ts +0 -189
  51. package/src/__tests__/BlocBase.lifecycle.test.ts +0 -441
  52. package/src/__tests__/props.test.ts +0 -256
  53. package/src/adapter/ConsumerTracker.ts +0 -241
  54. package/src/adapter/__tests__/ConsumerTracker.test.ts +0 -562
@@ -0,0 +1,395 @@
1
+ import { BlocBase } from '../BlocBase';
2
+ import { Blac } from '../Blac';
3
+ import { generateUUID } from '../utils/uuid';
4
+ import {
5
+ Subscription,
6
+ SubscriptionOptions,
7
+ SubscriptionManagerStats,
8
+ } from './types';
9
+
10
+ /**
11
+ * Unified subscription manager that replaces the dual consumer/observer system.
12
+ * Handles all state change subscriptions with a single, consistent interface.
13
+ */
14
+ export class SubscriptionManager<S = unknown> {
15
+ private subscriptions = new Map<string, Subscription<S>>();
16
+ private pathToSubscriptions = new Map<string, Set<string>>();
17
+ private weakRefCleanupScheduled = false;
18
+ private totalNotifications = 0;
19
+
20
+ constructor(private bloc: BlocBase<S>) {}
21
+
22
+ /**
23
+ * Subscribe to state changes
24
+ */
25
+ subscribe(options: SubscriptionOptions<S>): () => void {
26
+ const id = `${options.type}-${generateUUID()}`;
27
+
28
+ const subscription: Subscription<S> = {
29
+ id,
30
+ type: options.type,
31
+ selector: options.selector,
32
+ equalityFn: options.equalityFn || Object.is,
33
+ notify: options.notify,
34
+ priority: options.priority ?? 0,
35
+ weakRef: options.weakRef,
36
+ dependencies: new Set(),
37
+ metadata: {
38
+ lastNotified: Date.now(),
39
+ hasRendered: false,
40
+ accessCount: 0,
41
+ firstAccessTime: Date.now(),
42
+ },
43
+ };
44
+
45
+ // Initialize selector value if provided
46
+ if (subscription.selector) {
47
+ try {
48
+ subscription.lastValue = subscription.selector(
49
+ this.bloc.state,
50
+ this.bloc,
51
+ );
52
+ } catch (error) {
53
+ Blac.error(`SubscriptionManager: Error in selector for ${id}:`, error);
54
+ }
55
+ }
56
+
57
+ this.subscriptions.set(id, subscription);
58
+
59
+ Blac.log(
60
+ `[${this.bloc._name}:${this.bloc._id}] Subscription added: ${id}. Total: ${this.subscriptions.size}`,
61
+ );
62
+
63
+ // Cancel disposal if bloc is in disposal_requested state
64
+ (this.bloc as any)._cancelDisposalIfRequested();
65
+
66
+ // Return unsubscribe function
67
+ return () => this.unsubscribe(id);
68
+ }
69
+
70
+ /**
71
+ * Unsubscribe by ID
72
+ */
73
+ unsubscribe(id: string): void {
74
+ const subscription = this.subscriptions.get(id);
75
+ if (!subscription) return;
76
+
77
+ // Remove from path dependencies
78
+ if (subscription.dependencies) {
79
+ for (const path of subscription.dependencies) {
80
+ const subs = this.pathToSubscriptions.get(path);
81
+ if (subs) {
82
+ subs.delete(id);
83
+ if (subs.size === 0) {
84
+ this.pathToSubscriptions.delete(path);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ this.subscriptions.delete(id);
91
+
92
+ Blac.log(
93
+ `[${this.bloc._name}:${this.bloc._id}] Subscription removed: ${id}. Remaining: ${this.subscriptions.size}`,
94
+ );
95
+
96
+ // Check if bloc should be disposed
97
+ this.bloc.checkDisposal();
98
+ }
99
+
100
+ /**
101
+ * Notify all subscriptions of state change
102
+ */
103
+ notify(newState: S, oldState: S, action?: unknown): void {
104
+ // Clean up dead weak references if needed
105
+ this.cleanupDeadReferences();
106
+
107
+ // Sort subscriptions by priority (descending)
108
+ const sortedSubscriptions = Array.from(this.subscriptions.values()).sort(
109
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
110
+ );
111
+
112
+ for (const subscription of sortedSubscriptions) {
113
+ // Check if WeakRef is still alive
114
+ if (subscription.weakRef && !subscription.weakRef.deref()) {
115
+ this.scheduleWeakRefCleanup();
116
+ continue;
117
+ }
118
+
119
+ let shouldNotify = false;
120
+ let newValue: unknown;
121
+ let oldValue: unknown;
122
+
123
+ if (subscription.selector) {
124
+ // Use selector to determine if notification is needed
125
+ try {
126
+ newValue = subscription.selector(newState, this.bloc);
127
+ oldValue = subscription.lastValue;
128
+
129
+ const equalityFn = subscription.equalityFn || Object.is;
130
+ shouldNotify = !equalityFn(oldValue, newValue);
131
+
132
+ if (shouldNotify) {
133
+ subscription.lastValue = newValue;
134
+ }
135
+ } catch (error) {
136
+ Blac.error(
137
+ `SubscriptionManager: Error in selector for ${subscription.id}:`,
138
+ error,
139
+ );
140
+ continue;
141
+ }
142
+ } else {
143
+ // No selector - check if tracked dependencies changed
144
+ if (subscription.dependencies && subscription.dependencies.size > 0) {
145
+ // Check which paths changed between old and new state
146
+ const changedPaths = this.getChangedPaths(oldState, newState);
147
+ shouldNotify = this.shouldNotifyForPaths(
148
+ subscription.id,
149
+ changedPaths,
150
+ );
151
+ if ((Blac.config as any).logLevel === 'debug') {
152
+ Blac.log(
153
+ `[SubscriptionManager] Subscription ${subscription.id} dependencies:`,
154
+ Array.from(subscription.dependencies),
155
+ );
156
+ Blac.log(
157
+ `[SubscriptionManager] Changed paths:`,
158
+ Array.from(changedPaths),
159
+ );
160
+ Blac.log(`[SubscriptionManager] Should notify:`, shouldNotify);
161
+ }
162
+ } else {
163
+ // No tracked dependencies, always notify
164
+ shouldNotify = true;
165
+ }
166
+ newValue = newState;
167
+ oldValue = oldState;
168
+ }
169
+
170
+ if (shouldNotify) {
171
+ try {
172
+ subscription.notify(newValue, oldValue, action);
173
+ this.totalNotifications++;
174
+
175
+ if (subscription.metadata) {
176
+ subscription.metadata.lastNotified = Date.now();
177
+ subscription.metadata.hasRendered = true;
178
+ }
179
+ } catch (error) {
180
+ Blac.error(
181
+ `SubscriptionManager: Error in notify for ${subscription.id}:`,
182
+ error,
183
+ );
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Track a path access for a subscription
191
+ */
192
+ trackAccess(subscriptionId: string, path: string, value?: unknown): void {
193
+ const subscription = this.subscriptions.get(subscriptionId);
194
+ if (!subscription) return;
195
+
196
+ // Update dependencies
197
+ if (!subscription.dependencies) {
198
+ subscription.dependencies = new Set();
199
+ }
200
+ subscription.dependencies.add(path);
201
+
202
+ // Update path-to-subscription mapping
203
+ let subs = this.pathToSubscriptions.get(path);
204
+ if (!subs) {
205
+ subs = new Set();
206
+ this.pathToSubscriptions.set(path, subs);
207
+ }
208
+ subs.add(subscriptionId);
209
+
210
+ // Update metadata
211
+ if (subscription.metadata) {
212
+ subscription.metadata.accessCount =
213
+ (subscription.metadata.accessCount || 0) + 1;
214
+ subscription.metadata.lastAccessTime = Date.now();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get the paths that changed between two states
220
+ */
221
+ private getChangedPaths(
222
+ oldState: any,
223
+ newState: any,
224
+ path = '',
225
+ ): Set<string> {
226
+ const changedPaths = new Set<string>();
227
+
228
+ if (oldState === newState) return changedPaths;
229
+
230
+ // Handle primitives
231
+ if (
232
+ typeof oldState !== 'object' ||
233
+ typeof newState !== 'object' ||
234
+ oldState === null ||
235
+ newState === null
236
+ ) {
237
+ if (path) changedPaths.add(path);
238
+ return changedPaths;
239
+ }
240
+
241
+ // Get all keys from both objects
242
+ const allKeys = new Set([
243
+ ...Object.keys(oldState),
244
+ ...Object.keys(newState),
245
+ ]);
246
+
247
+ for (const key of allKeys) {
248
+ const fullPath = path ? `${path}.${key}` : key;
249
+ const oldValue = oldState[key];
250
+ const newValue = newState[key];
251
+
252
+ if (oldValue !== newValue) {
253
+ changedPaths.add(fullPath);
254
+
255
+ // For nested objects, get all nested changed paths
256
+ if (
257
+ typeof oldValue === 'object' &&
258
+ typeof newValue === 'object' &&
259
+ oldValue !== null &&
260
+ newValue !== null
261
+ ) {
262
+ const nestedChanges = this.getChangedPaths(
263
+ oldValue,
264
+ newValue,
265
+ fullPath,
266
+ );
267
+ nestedChanges.forEach((p) => changedPaths.add(p));
268
+ }
269
+ }
270
+ }
271
+
272
+ return changedPaths;
273
+ }
274
+
275
+ /**
276
+ * Check if a subscription should be notified based on changed paths
277
+ */
278
+ shouldNotifyForPaths(
279
+ subscriptionId: string,
280
+ changedPaths: Set<string>,
281
+ ): boolean {
282
+ const subscription = this.subscriptions.get(subscriptionId);
283
+ if (!subscription || !subscription.dependencies) return true;
284
+
285
+ // Check if any tracked dependencies match changed paths
286
+ for (const trackedPath of subscription.dependencies) {
287
+ // Handle class getter dependencies (_class.propertyName)
288
+ if (trackedPath.startsWith('_class.')) {
289
+ // For class getters, we need to notify on any state change
290
+ // since we can't determine which state properties the getter depends on
291
+ // This is conservative but ensures correctness
292
+ if (changedPaths.size > 0) {
293
+ return true;
294
+ }
295
+ continue;
296
+ }
297
+
298
+ // Check direct path matches
299
+ if (changedPaths.has(trackedPath)) return true;
300
+
301
+ // Check nested paths
302
+ for (const changedPath of changedPaths) {
303
+ if (
304
+ changedPath.startsWith(trackedPath + '.') ||
305
+ trackedPath.startsWith(changedPath + '.')
306
+ ) {
307
+ return true;
308
+ }
309
+ }
310
+ }
311
+
312
+ return false;
313
+ }
314
+
315
+ /**
316
+ * Get statistics about subscriptions
317
+ */
318
+ getStats(): SubscriptionManagerStats {
319
+ let consumerCount = 0;
320
+ let observerCount = 0;
321
+
322
+ for (const sub of this.subscriptions.values()) {
323
+ if (sub.type === 'consumer') consumerCount++;
324
+ else observerCount++;
325
+ }
326
+
327
+ return {
328
+ activeSubscriptions: this.subscriptions.size,
329
+ consumerCount,
330
+ observerCount,
331
+ totalNotifications: this.totalNotifications,
332
+ trackedDependencies: this.pathToSubscriptions.size,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Clear all subscriptions
338
+ */
339
+ clear(): void {
340
+ this.subscriptions.clear();
341
+ this.pathToSubscriptions.clear();
342
+ }
343
+
344
+ /**
345
+ * Get subscription by ID
346
+ */
347
+ getSubscription(id: string): Subscription<S> | undefined {
348
+ return this.subscriptions.get(id);
349
+ }
350
+
351
+ /**
352
+ * Check if there are any active subscriptions
353
+ */
354
+ get hasSubscriptions(): boolean {
355
+ return this.subscriptions.size > 0;
356
+ }
357
+
358
+ /**
359
+ * Get count of active subscriptions
360
+ */
361
+ get size(): number {
362
+ return this.subscriptions.size;
363
+ }
364
+
365
+ /**
366
+ * Clean up subscriptions with dead weak references
367
+ */
368
+ private cleanupDeadReferences(): void {
369
+ if (!this.weakRefCleanupScheduled) return;
370
+
371
+ const deadIds: string[] = [];
372
+
373
+ for (const [id, subscription] of this.subscriptions) {
374
+ if (subscription.weakRef && !subscription.weakRef.deref()) {
375
+ deadIds.push(id);
376
+ }
377
+ }
378
+
379
+ for (const id of deadIds) {
380
+ this.unsubscribe(id);
381
+ }
382
+
383
+ this.weakRefCleanupScheduled = false;
384
+ }
385
+
386
+ /**
387
+ * Schedule weak reference cleanup for next tick
388
+ */
389
+ private scheduleWeakRefCleanup(): void {
390
+ if (this.weakRefCleanupScheduled) return;
391
+
392
+ this.weakRefCleanupScheduled = true;
393
+ queueMicrotask(() => this.cleanupDeadReferences());
394
+ }
395
+ }