onelaraveljs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/docs/integration_analysis.md +116 -0
- package/docs/onejs_analysis.md +108 -0
- package/docs/optimization_implementation_group2.md +458 -0
- package/docs/optimization_plan.md +130 -0
- package/index.js +16 -0
- package/package.json +13 -0
- package/src/app.js +61 -0
- package/src/core/API.js +72 -0
- package/src/core/ChildrenRegistry.js +410 -0
- package/src/core/DOMBatcher.js +207 -0
- package/src/core/ErrorBoundary.js +226 -0
- package/src/core/EventDelegator.js +416 -0
- package/src/core/Helper.js +817 -0
- package/src/core/LoopContext.js +97 -0
- package/src/core/OneDOM.js +246 -0
- package/src/core/OneMarkup.js +444 -0
- package/src/core/Router.js +996 -0
- package/src/core/SEOConfig.js +321 -0
- package/src/core/SectionEngine.js +75 -0
- package/src/core/TemplateEngine.js +83 -0
- package/src/core/View.js +273 -0
- package/src/core/ViewConfig.js +229 -0
- package/src/core/ViewController.js +1410 -0
- package/src/core/ViewControllerOptimized.js +164 -0
- package/src/core/ViewIdentifier.js +361 -0
- package/src/core/ViewLoader.js +272 -0
- package/src/core/ViewManager.js +1962 -0
- package/src/core/ViewState.js +761 -0
- package/src/core/ViewSystem.js +301 -0
- package/src/core/ViewTemplate.js +4 -0
- package/src/core/helpers/BindingHelper.js +239 -0
- package/src/core/helpers/ConfigHelper.js +37 -0
- package/src/core/helpers/EventHelper.js +172 -0
- package/src/core/helpers/LifecycleHelper.js +17 -0
- package/src/core/helpers/ReactiveHelper.js +169 -0
- package/src/core/helpers/RenderHelper.js +15 -0
- package/src/core/helpers/ResourceHelper.js +89 -0
- package/src/core/helpers/TemplateHelper.js +11 -0
- package/src/core/managers/BindingManager.js +671 -0
- package/src/core/managers/ConfigurationManager.js +136 -0
- package/src/core/managers/EventManager.js +309 -0
- package/src/core/managers/LifecycleManager.js +356 -0
- package/src/core/managers/ReactiveManager.js +334 -0
- package/src/core/managers/RenderEngine.js +292 -0
- package/src/core/managers/ResourceManager.js +441 -0
- package/src/core/managers/ViewHierarchyManager.js +258 -0
- package/src/core/managers/ViewTemplateManager.js +127 -0
- package/src/core/reactive/ReactiveComponent.js +592 -0
- package/src/core/services/EventService.js +418 -0
- package/src/core/services/HttpService.js +106 -0
- package/src/core/services/LoggerService.js +57 -0
- package/src/core/services/StateService.js +512 -0
- package/src/core/services/StorageService.js +856 -0
- package/src/core/services/StoreService.js +258 -0
- package/src/core/services/TemplateDetectorService.js +361 -0
- package/src/core/services/Test.js +18 -0
- package/src/helpers/devWarnings.js +205 -0
- package/src/helpers/performance.js +226 -0
- package/src/helpers/utils.js +287 -0
- package/src/init.js +343 -0
- package/src/plugins/auto-plugin.js +34 -0
- package/src/services/Test.js +18 -0
- package/src/types/index.js +193 -0
- package/src/utils/date-helper.js +51 -0
- package/src/utils/helpers.js +39 -0
- package/src/utils/validation.js +32 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import OneDOM from '../OneDOM.js';
|
|
2
|
+
import logger from '../services/LoggerService.js';
|
|
3
|
+
import DOMBatcher from '../DOMBatcher.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BindingManager
|
|
7
|
+
* Quản lý tất cả các loại binding trong view:
|
|
8
|
+
* - Input bindings (two-way data binding)
|
|
9
|
+
* - Attribute bindings (reactive HTML attributes)
|
|
10
|
+
* - Class bindings (reactive CSS classes)
|
|
11
|
+
* - Thông báo thay đổi state
|
|
12
|
+
*
|
|
13
|
+
* Quản lý bộ nhớ:
|
|
14
|
+
* - Sử dụng WeakMap cho element references (tự động garbage collection)
|
|
15
|
+
* - Dọn dẹp đúng cách trong method destroy()
|
|
16
|
+
* - Validate element.isConnected trước khi thao tác
|
|
17
|
+
*
|
|
18
|
+
* @class BindingManager
|
|
19
|
+
* @since 2.0.0 - Sửa memory leak và cải thiện hiệu suất
|
|
20
|
+
*/
|
|
21
|
+
export class BindingManager {
|
|
22
|
+
constructor(controller) {
|
|
23
|
+
this.controller = controller;
|
|
24
|
+
|
|
25
|
+
// Input binding (two-way data binding)
|
|
26
|
+
this.bindingEventListeners = [];
|
|
27
|
+
this.isBindingEventListenerStarted = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* WeakMap lưu các cờ cập nhật riêng cho từng element
|
|
31
|
+
* Ngăn cập nhật vòng tròn trong two-way binding
|
|
32
|
+
* Tự động garbage collected khi element bị xóa
|
|
33
|
+
* @type {WeakMap<HTMLElement, {pushing: boolean, syncing: boolean}>}
|
|
34
|
+
*/
|
|
35
|
+
this.elementUpdateFlags = new WeakMap();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* WeakMap lưu mapping element-to-listener
|
|
39
|
+
* Để dọn dẹp và tra cứu hiệu quả
|
|
40
|
+
* @type {WeakMap<HTMLElement, {eventType: string, handler: Function, stateKey: string}>}
|
|
41
|
+
*/
|
|
42
|
+
this.elementListenerMap = new WeakMap();
|
|
43
|
+
|
|
44
|
+
// Attribute binding (reactive attributes)
|
|
45
|
+
this.attributeConfigs = [];
|
|
46
|
+
this.attributeListeners = [];
|
|
47
|
+
this.attributeIndex = 0;
|
|
48
|
+
|
|
49
|
+
// Class binding (reactive CSS classes)
|
|
50
|
+
this.classBindingConfigs = [];
|
|
51
|
+
this.classBindingListeners = [];
|
|
52
|
+
this.isClassBindingReadyToListen = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reset trạng thái binding manager
|
|
57
|
+
* Xóa listeners nhưng KHÔNG xóa event listeners khỏi DOM
|
|
58
|
+
* Dùng destroy() để dọn dẹp hoàn toàn
|
|
59
|
+
*/
|
|
60
|
+
reset() {
|
|
61
|
+
this.bindingEventListeners = [];
|
|
62
|
+
this.attributeListeners = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bắt đầu two-way data binding event listeners
|
|
67
|
+
*
|
|
68
|
+
* Tính năng:
|
|
69
|
+
* - Chỉ xử lý các DOM element đã kết nối
|
|
70
|
+
* - Sử dụng WeakMap để quản lý bộ nhớ tự động
|
|
71
|
+
* - Ngăn listener trùng lặp trên cùng element
|
|
72
|
+
* - Đồng bộ state ban đầu vào element
|
|
73
|
+
* - Có thể gọi nhiều lần an toàn (idempotent)
|
|
74
|
+
*
|
|
75
|
+
* @returns {void}
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Trong mounted lifecycle
|
|
79
|
+
* this._bindingManager.startBindingEventListener();
|
|
80
|
+
*/
|
|
81
|
+
startBindingEventListener() {
|
|
82
|
+
const selector = `[data-binding][data-view-id="${this.controller.id}"]`;
|
|
83
|
+
const inputs = document.querySelectorAll(selector);
|
|
84
|
+
|
|
85
|
+
if (inputs.length === 0) {
|
|
86
|
+
logger.log(`[BindingManager] No binding inputs found for view: ${this.controller.id}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let attachedCount = 0;
|
|
91
|
+
let skippedCount = 0;
|
|
92
|
+
|
|
93
|
+
inputs.forEach(input => {
|
|
94
|
+
// Validate element vẫn kết nối với DOM
|
|
95
|
+
if (!input.isConnected) {
|
|
96
|
+
logger.warn('[BindingManager] Skipping disconnected element', input);
|
|
97
|
+
skippedCount++;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Kiểm tra listener đã tồn tại cho element này chưa
|
|
102
|
+
if (this.elementListenerMap.has(input)) {
|
|
103
|
+
// Đã bind, bỏ qua
|
|
104
|
+
skippedCount++;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stateKey = input.getAttribute('data-binding');
|
|
109
|
+
if (!stateKey) {
|
|
110
|
+
logger.warn('[BindingManager] Missing data-binding attribute', input);
|
|
111
|
+
skippedCount++;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Trích xuất root key cho state change subscription
|
|
116
|
+
// ví dụ: 'formData.name' -> 'formData'
|
|
117
|
+
const rootStateKey = stateKey.split('.')[0];
|
|
118
|
+
const tag = input.tagName.toLowerCase();
|
|
119
|
+
const eventType = tag === 'select' ? 'change' : 'input';
|
|
120
|
+
|
|
121
|
+
// Đồng bộ ban đầu: state → element
|
|
122
|
+
this.syncStateToElement(input, stateKey);
|
|
123
|
+
|
|
124
|
+
// Tạo event handler
|
|
125
|
+
const handler = (event) => this.pushElementToState(input, stateKey);
|
|
126
|
+
|
|
127
|
+
// Gắn listener
|
|
128
|
+
input.addEventListener(eventType, handler);
|
|
129
|
+
|
|
130
|
+
// Lưu trong WeakMap để tra cứu hiệu quả và tự động dọn dẹp
|
|
131
|
+
this.elementListenerMap.set(input, {
|
|
132
|
+
eventType,
|
|
133
|
+
handler,
|
|
134
|
+
stateKey
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Lưu trong array để tương thích (sẽ được dọn dẹp trong destroy)
|
|
138
|
+
// Lưu ý: Lưu đầy đủ stateKey (ví dụ: 'formData.name') không chỉ rootKey
|
|
139
|
+
this.bindingEventListeners.push({
|
|
140
|
+
element: input,
|
|
141
|
+
key: rootStateKey, // Cho subscription cấp root
|
|
142
|
+
stateKey: stateKey, // Đường dẫn đầy đủ cho nested sync
|
|
143
|
+
eventType,
|
|
144
|
+
handler
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
attachedCount++;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.isBindingEventListenerStarted = true;
|
|
151
|
+
|
|
152
|
+
if (attachedCount > 0) {
|
|
153
|
+
logger.log(`[BindingManager] Attached ${attachedCount} new binding listeners for view: ${this.controller.id}`);
|
|
154
|
+
}
|
|
155
|
+
if (skippedCount > 0) {
|
|
156
|
+
logger.log(`[BindingManager] Skipped ${skippedCount} already-bound elements for view: ${this.controller.id}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Dừng two-way data binding event listeners
|
|
162
|
+
* Xóa tất cả event listeners và xóa cấu trúc theo dõi
|
|
163
|
+
*
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // Trong beforeDestroy lifecycle
|
|
168
|
+
* this._bindingManager.stopBindingEventListener();
|
|
169
|
+
*/
|
|
170
|
+
stopBindingEventListener() {
|
|
171
|
+
if (!this.isBindingEventListenerStarted) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let removedCount = 0;
|
|
176
|
+
this.bindingEventListeners.forEach(({ element, eventType, handler }) => {
|
|
177
|
+
try {
|
|
178
|
+
// Xóa listener an toàn ngay cả khi element đã ngắt kết nối
|
|
179
|
+
element.removeEventListener(eventType, handler);
|
|
180
|
+
|
|
181
|
+
// Xóa khỏi WeakMap tracking
|
|
182
|
+
if (this.elementListenerMap.has(element)) {
|
|
183
|
+
this.elementListenerMap.delete(element);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
removedCount++;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.error('[BindingManager] Error removing event listener', error);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Xóa mảng
|
|
193
|
+
this.bindingEventListeners = [];
|
|
194
|
+
|
|
195
|
+
// Lưu ý: Các entry trong WeakMap sẽ được garbage collected tự động
|
|
196
|
+
// khi elements bị xóa khỏi DOM
|
|
197
|
+
|
|
198
|
+
this.isBindingEventListenerStarted = false;
|
|
199
|
+
logger.log(`[BindingManager] Removed ${removedCount} binding listeners`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Đẩy giá trị element vào state (element → state)
|
|
204
|
+
* Sử dụng trong two-way binding khi giá trị element thay đổi
|
|
205
|
+
*
|
|
206
|
+
* Tính năng:
|
|
207
|
+
* - Ngăn cập nhật vòng tròn dùng cờ WeakMap
|
|
208
|
+
* - Validate element đang kết nối
|
|
209
|
+
* - Xóa cờ async để hiệu suất tốt hơn
|
|
210
|
+
*
|
|
211
|
+
* @param {HTMLElement} element - Input element
|
|
212
|
+
* @param {string} stateKey - State key (hỗ trợ nested keys như 'user.name')
|
|
213
|
+
* @returns {void}
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* // Tự động gọi bởi event listener
|
|
217
|
+
* input.addEventListener('input', (e) => {
|
|
218
|
+
* this.pushElementToState(e.target, 'username');
|
|
219
|
+
* });
|
|
220
|
+
*/
|
|
221
|
+
pushElementToState(element, stateKey) {
|
|
222
|
+
// Validate element đang kết nối
|
|
223
|
+
if (!element || !element.isConnected) {
|
|
224
|
+
logger.warn('[BindingManager] Cannot push from disconnected element');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Lấy hoặc khởi tạo cờ cho element này
|
|
229
|
+
let flags = this.elementUpdateFlags.get(element);
|
|
230
|
+
if (!flags) {
|
|
231
|
+
flags = { pushing: false, syncing: false };
|
|
232
|
+
this.elementUpdateFlags.set(element, flags);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Ngăn cập nhật vòng tròn: nếu đang syncing state→element, bỏ qua
|
|
236
|
+
if (flags.syncing) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Ngăn thao tác push trùng lặp
|
|
241
|
+
if (flags.pushing) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Đặt cờ pushing
|
|
246
|
+
flags.pushing = true;
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Lấy giá trị từ element
|
|
250
|
+
const value = OneDOM.getInputValue(element);
|
|
251
|
+
|
|
252
|
+
// Cập nhật state (sẽ kích hoạt state change notifications)
|
|
253
|
+
this.controller.states.__.updateStateAddressKey(stateKey, value);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.error('[BindingManager] Error pushing element to state', error);
|
|
256
|
+
} finally {
|
|
257
|
+
// Xóa cờ async để cho phép hoàn tất cập nhật state
|
|
258
|
+
Promise.resolve().then(() => {
|
|
259
|
+
const currentFlags = this.elementUpdateFlags.get(element);
|
|
260
|
+
if (currentFlags) {
|
|
261
|
+
currentFlags.pushing = false;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Đồng bộ giá trị state vào element (state → element)
|
|
269
|
+
* Sử dụng trong two-way binding khi state thay đổi
|
|
270
|
+
*
|
|
271
|
+
* Tính năng:
|
|
272
|
+
* - Ngăn cập nhật vòng tròn dùng cờ WeakMap
|
|
273
|
+
* - Chỉ cập nhật nếu giá trị thực sự thay đổi
|
|
274
|
+
* - Validate element đang kết nối
|
|
275
|
+
*
|
|
276
|
+
* @param {HTMLElement} element - Input element
|
|
277
|
+
* @param {string} stateKey - State key (hỗ trợ nested keys như 'user.name')
|
|
278
|
+
* @returns {void}
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* // Tự động gọi khi subscribed state thay đổi
|
|
282
|
+
* this.states.subscribe('username', () => {
|
|
283
|
+
* this.syncStateToElement(inputElement, 'username');
|
|
284
|
+
* });
|
|
285
|
+
*/
|
|
286
|
+
syncStateToElement(element, stateKey) {
|
|
287
|
+
// Validate element đang kết nối
|
|
288
|
+
if (!element || !element.isConnected) {
|
|
289
|
+
logger.warn('[BindingManager] Cannot sync to disconnected element');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Lấy hoặc khởi tạo cờ cho element này
|
|
294
|
+
let flags = this.elementUpdateFlags.get(element);
|
|
295
|
+
if (!flags) {
|
|
296
|
+
flags = { pushing: false, syncing: false };
|
|
297
|
+
this.elementUpdateFlags.set(element, flags);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Ngăn cập nhật vòng tròn: nếu đang pushing element→state, bỏ qua
|
|
301
|
+
if (flags.pushing) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Ngăn thao tác sync trùng lặp
|
|
306
|
+
if (flags.syncing) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Lấy giá trị state
|
|
311
|
+
const stateValue = this.controller.states.__.getStateByAddressKey(stateKey);
|
|
312
|
+
const currentValue = OneDOM.getInputValue(element);
|
|
313
|
+
|
|
314
|
+
// Bỏ qua nếu giá trị không thay đổi (so sánh lỏng để tương thích number/string)
|
|
315
|
+
if (currentValue == stateValue) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Đặt cờ syncing
|
|
320
|
+
flags.syncing = true;
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// Cập nhật giá trị element
|
|
324
|
+
OneDOM.setInputValue(element, stateValue);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
logger.error('[BindingManager] Error syncing state to element', error);
|
|
327
|
+
} finally {
|
|
328
|
+
// Xóa cờ ngay lập tức (thao tác sync là đồng bộ)
|
|
329
|
+
flags.syncing = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
startClassBindingEventListener() {
|
|
334
|
+
if (this.isClassBindingReadyToListen || !this.classBindingConfigs || this.classBindingConfigs.length === 0) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const removeClassBindingIDs = [];
|
|
339
|
+
this.classBindingConfigs.forEach(binding => {
|
|
340
|
+
const { id, config, states, initialiClass = '' } = binding;
|
|
341
|
+
let element = null;
|
|
342
|
+
if ((element && !element.isConnected) || element == null || typeof element === 'undefined') {
|
|
343
|
+
const selector = `[data-one-class-id="${id}"]`;
|
|
344
|
+
element = document.querySelector(selector);
|
|
345
|
+
if (element == null) {
|
|
346
|
+
removeClassBindingIDs.push(id);
|
|
347
|
+
logger.warn(`⚠️ BindingManager.startClassBindingListener: No elements found for selector ${selector}`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
binding.element = element;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.classBindingListeners.push({ id, states, config, element, initialiClass });
|
|
354
|
+
|
|
355
|
+
// Batch class operations using DOMBatcher
|
|
356
|
+
const classOperations = [];
|
|
357
|
+
|
|
358
|
+
String(initialiClass).split(' ').forEach(cls => {
|
|
359
|
+
if (cls.trim() !== '') {
|
|
360
|
+
classOperations.push({ element, action: 'add', className: cls });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
Object.entries(config).forEach(([className, classConfig]) => {
|
|
365
|
+
const { states: classStates, checker } = classConfig;
|
|
366
|
+
if (typeof checker !== 'function') {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const shouldAdd = checker();
|
|
370
|
+
classOperations.push({
|
|
371
|
+
element,
|
|
372
|
+
action: shouldAdd ? 'add' : 'remove',
|
|
373
|
+
className
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Apply all class operations in one batch
|
|
378
|
+
if (classOperations.length > 0) {
|
|
379
|
+
DOMBatcher.write(() => {
|
|
380
|
+
classOperations.forEach(({ element, action, className }) => {
|
|
381
|
+
element.classList[action](className);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (removeClassBindingIDs.length > 0) {
|
|
388
|
+
this.classBindingConfigs = this.classBindingConfigs.filter(binding => !removeClassBindingIDs.includes(binding.id));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.isClassBindingReadyToListen = true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
stopClassBindingEventListener() {
|
|
395
|
+
this.isClassBindingReadyToListen = false;
|
|
396
|
+
if (!this.classBindingConfigs || this.classBindingConfigs.length === 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
notifyStateChanges(changedKeys) {
|
|
402
|
+
if (this.controller.isRefreshing || !this.controller.isReadyToStateChangeListen) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (this.controller.subscribeStates) {
|
|
407
|
+
if (this.controller.subscribeStates === true) {
|
|
408
|
+
this.controller.refresh();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (Array.isArray(this.controller.subscribeStates)) {
|
|
412
|
+
const shouldRefresh = changedKeys.filter(key => this.controller.subscribeStates.includes(key)).length > 0;
|
|
413
|
+
if (shouldRefresh) {
|
|
414
|
+
return this.controller.refresh();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (typeof this.controller.subscribeStates === 'function') {
|
|
418
|
+
this.controller.subscribeStates(changedKeys);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.notifyAttributeBindings(changedKeys);
|
|
423
|
+
this.notifyInputBindings(changedKeys);
|
|
424
|
+
this.notifyClassBindings(changedKeys);
|
|
425
|
+
|
|
426
|
+
if (this.controller.children && this.controller.children.length > 0) {
|
|
427
|
+
this.controller.children.forEach(childScope => {
|
|
428
|
+
if (childScope.subscribe && Array.isArray(childScope.subscribe)) {
|
|
429
|
+
const shouldRefresh = changedKeys.some(key => childScope.subscribe.includes(key));
|
|
430
|
+
if (shouldRefresh && childScope.view && childScope.view.__._bindingManager) {
|
|
431
|
+
childScope.view.__._bindingManager.notifyStateChanges(changedKeys);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
notifyAttributeBindings(changedKeys) {
|
|
439
|
+
if (!Array.isArray(changedKeys) || changedKeys.length === 0) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!this.attributeListeners || this.attributeListeners.length === 0) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const listenersToRemove = [];
|
|
447
|
+
|
|
448
|
+
this.attributeListeners.forEach((listener, index) => {
|
|
449
|
+
const { id, stateKeys, attrs } = listener;
|
|
450
|
+
let element = listener.element;
|
|
451
|
+
if (!element) {
|
|
452
|
+
element = document.querySelector(`[data-one-attribute-id="${id}"]`);
|
|
453
|
+
if (!element) {
|
|
454
|
+
listenersToRemove.push(id);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
listener.element = element;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const shouldUpdate = stateKeys.filter(key => changedKeys.includes(key)).length > 0;
|
|
461
|
+
if (!attrs || !shouldUpdate) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
Object.entries(attrs).forEach(([attrKey, config]) => {
|
|
466
|
+
const { states: stateKeys, render } = config;
|
|
467
|
+
const shouldRender = stateKeys.filter(key => changedKeys.includes(key)).length > 0;
|
|
468
|
+
if (!shouldRender) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (element && typeof render === 'function') {
|
|
472
|
+
const newValue = render.call(this.controller.view, this.controller.states);
|
|
473
|
+
if(['disabled', 'checked', 'selected'].includes(attrKey)) {
|
|
474
|
+
if (newValue && !['', 'false', '0'].includes(String(newValue).toLowerCase())) {
|
|
475
|
+
element.setAttribute(attrKey, attrKey);
|
|
476
|
+
} else {
|
|
477
|
+
element.removeAttribute(attrKey);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else if (newValue === null || typeof newValue === 'undefined' || newValue === false) {
|
|
481
|
+
element.removeAttribute(attrKey);
|
|
482
|
+
} else {
|
|
483
|
+
element.setAttribute(attrKey, newValue);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
this.attributeListeners = this.attributeListeners.filter(listener => !listenersToRemove.includes(listener.id));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
notifyInputBindings(changedKeys) {
|
|
493
|
+
if (!Array.isArray(changedKeys) || changedKeys.length === 0) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (this.bindingEventListeners.length === 0) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// For each binding, check if root key changed
|
|
501
|
+
// and sync using full stateKey (supports nested like 'formData.name')
|
|
502
|
+
this.bindingEventListeners.forEach(({ element, key, stateKey }) => {
|
|
503
|
+
if (changedKeys.includes(key)) {
|
|
504
|
+
this.syncStateToElement(element, stateKey || key);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
notifyClassBindings(changedKeys) {
|
|
510
|
+
if (!this.isClassBindingReadyToListen) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!Array.isArray(changedKeys) || changedKeys.length === 0) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!this.classBindingListeners || this.classBindingListeners.length === 0) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const removeClassBindingIDs = [];
|
|
521
|
+
const classOperations = []; // Batch all class changes
|
|
522
|
+
|
|
523
|
+
this.classBindingListeners.forEach(listener => {
|
|
524
|
+
const { id, states: stateKeys, config, element } = listener;
|
|
525
|
+
if (!element || !(element instanceof Element) || !element.isConnected) {
|
|
526
|
+
removeClassBindingIDs.push(id);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const shouldUpdate = stateKeys.filter(key => changedKeys.includes(key)).length > 0;
|
|
531
|
+
if (!shouldUpdate) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
Object.entries(config).forEach(([className, classConfig]) => {
|
|
536
|
+
const { states: classStates, checker } = classConfig;
|
|
537
|
+
if (classStates.filter(key => changedKeys.includes(key)).length === 0) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!(element instanceof Element)) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const shouldAdd = checker();
|
|
544
|
+
classOperations.push({
|
|
545
|
+
element,
|
|
546
|
+
action: shouldAdd ? 'add' : 'remove',
|
|
547
|
+
className
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Apply all class operations in one batch to prevent layout thrashing
|
|
553
|
+
if (classOperations.length > 0) {
|
|
554
|
+
DOMBatcher.write(() => {
|
|
555
|
+
classOperations.forEach(({ element, action, className }) => {
|
|
556
|
+
element.classList[action](className);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (removeClassBindingIDs.length > 0) {
|
|
562
|
+
this.classBindingListeners = this.classBindingListeners.filter(listener => !removeClassBindingIDs.includes(listener.id));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Handle state change event from ViewState
|
|
568
|
+
* Batches changes using microtask queue for optimal performance
|
|
569
|
+
*
|
|
570
|
+
* @param {string} key - State key that changed
|
|
571
|
+
* @param {*} value - New value
|
|
572
|
+
* @param {*} oldValue - Old value
|
|
573
|
+
*/
|
|
574
|
+
onStateChange(key, value, oldValue) {
|
|
575
|
+
// Use Set to automatically handle duplicates
|
|
576
|
+
this.controller.changedStateKeys.add(key);
|
|
577
|
+
|
|
578
|
+
// Increment queue count (for tracking/statistics if needed)
|
|
579
|
+
this.controller.changeStateQueueCount++;
|
|
580
|
+
|
|
581
|
+
// Schedule batch processing using Promise for async execution
|
|
582
|
+
// Promise.resolve().then() runs in microtask queue, faster than setTimeout
|
|
583
|
+
// and works even when tab is hidden (unlike requestAnimationFrame)
|
|
584
|
+
// The _stateChangePending flag ensures only one Promise is scheduled at a time
|
|
585
|
+
// All subsequent changes will be batched into the Set
|
|
586
|
+
if (!this.controller._stateChangePending) {
|
|
587
|
+
this.controller._stateChangePending = true;
|
|
588
|
+
|
|
589
|
+
// Use Promise.resolve().then() for microtask queue (fastest, works in background)
|
|
590
|
+
Promise.resolve().then(() => {
|
|
591
|
+
// Clear pending flag before processing
|
|
592
|
+
this.controller._stateChangePending = false;
|
|
593
|
+
this.processStateChanges();
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Process batched state changes efficiently
|
|
600
|
+
* Called from microtask queue after batching period
|
|
601
|
+
* @private
|
|
602
|
+
*/
|
|
603
|
+
processStateChanges() {
|
|
604
|
+
// Skip if view is destroyed
|
|
605
|
+
if (this.controller.isDestroyed) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Get all changed keys as array for processing
|
|
610
|
+
const changedKeys = Array.from(this.controller.changedStateKeys);
|
|
611
|
+
// Reset collections
|
|
612
|
+
this.controller.changedStateKeys.clear();
|
|
613
|
+
this.controller.changeStateQueueCount = 0;
|
|
614
|
+
|
|
615
|
+
// If no keys changed, skip processing
|
|
616
|
+
if (changedKeys.length === 0) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Notify all binding listeners
|
|
621
|
+
this.notifyStateChanges(changedKeys);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Destroy binding manager and cleanup all resources
|
|
626
|
+
* Prevents memory leaks by removing all event listeners and clearing references
|
|
627
|
+
*
|
|
628
|
+
* Call this in view's beforeDestroy lifecycle
|
|
629
|
+
*
|
|
630
|
+
* @returns {void}
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* // In ViewController destroy method
|
|
634
|
+
* beforeDestroy() {
|
|
635
|
+
* this._bindingManager.destroy();
|
|
636
|
+
* }
|
|
637
|
+
*/
|
|
638
|
+
destroy() {
|
|
639
|
+
// Stop all event listeners
|
|
640
|
+
this.stopBindingEventListener();
|
|
641
|
+
this.stopClassBindingEventListener();
|
|
642
|
+
|
|
643
|
+
// Clear all arrays and configs
|
|
644
|
+
this.bindingEventListeners = [];
|
|
645
|
+
this.attributeConfigs = [];
|
|
646
|
+
this.attributeListeners = [];
|
|
647
|
+
this.classBindingConfigs = [];
|
|
648
|
+
this.classBindingListeners = [];
|
|
649
|
+
|
|
650
|
+
// WeakMaps will be garbage collected automatically
|
|
651
|
+
// No need to explicitly clear them
|
|
652
|
+
|
|
653
|
+
// Reset flags
|
|
654
|
+
this.isBindingEventListenerStarted = false;
|
|
655
|
+
this.isClassBindingReadyToListen = false;
|
|
656
|
+
this.attributeIndex = 0;
|
|
657
|
+
|
|
658
|
+
// Nullify controller reference to break circular reference
|
|
659
|
+
this.controller = null;
|
|
660
|
+
|
|
661
|
+
logger.log('[BindingManager] Destroyed successfully');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
get App() {
|
|
665
|
+
return this.controller?.App;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
set App(value) {
|
|
669
|
+
devLog('BindingManager.App is read-only.');
|
|
670
|
+
}
|
|
671
|
+
}
|