@jqhtml/core 2.3.50 → 2.3.59
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 +3 -1
- package/dist/boot.d.ts +0 -0
- package/dist/boot.d.ts.map +0 -0
- package/dist/boot.js +130 -0
- package/dist/boot.js.map +1 -0
- package/dist/cache-key-serializer.d.ts +47 -0
- package/dist/cache-key-serializer.d.ts.map +1 -0
- package/dist/cache-key-serializer.js +140 -0
- package/dist/cache-key-serializer.js.map +1 -0
- package/dist/component-cache.d.ts +1 -0
- package/dist/component-cache.d.ts.map +1 -1
- package/dist/component-cache.js +270 -0
- package/dist/component-cache.js.map +1 -0
- package/dist/component-events.d.ts +0 -0
- package/dist/component-events.d.ts.map +1 -1
- package/dist/component-events.js +141 -0
- package/dist/component-events.js.map +1 -0
- package/dist/component-queue.d.ts +0 -0
- package/dist/component-queue.d.ts.map +0 -0
- package/dist/component-queue.js +118 -0
- package/dist/component-queue.js.map +1 -0
- package/dist/component-registry.d.ts +0 -0
- package/dist/component-registry.d.ts.map +0 -0
- package/dist/component-registry.js +298 -0
- package/dist/component-registry.js.map +1 -0
- package/dist/component.d.ts +26 -0
- package/dist/component.d.ts.map +1 -1
- package/dist/component.js +1892 -0
- package/dist/component.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/data-proxy.d.ts +0 -0
- package/dist/data-proxy.d.ts.map +0 -0
- package/dist/data-proxy.js +223 -0
- package/dist/data-proxy.js.map +1 -0
- package/dist/debug-entry.d.ts +0 -0
- package/dist/debug-entry.d.ts.map +0 -0
- package/dist/debug-entry.js +8 -0
- package/dist/debug-entry.js.map +1 -0
- package/dist/debug.d.ts +0 -0
- package/dist/debug.d.ts.map +0 -0
- package/dist/debug.js +203 -0
- package/dist/debug.js.map +1 -0
- package/dist/index.cjs +520 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +519 -47
- package/dist/index.js.map +1 -1
- package/dist/instruction-processor.d.ts +0 -0
- package/dist/instruction-processor.d.ts.map +1 -1
- package/dist/instruction-processor.js +563 -0
- package/dist/instruction-processor.js.map +1 -0
- package/dist/jqhtml-core.esm.js +520 -48
- package/dist/jqhtml-core.esm.js.map +1 -1
- package/dist/jqhtml-debug.esm.js +0 -0
- package/dist/jqhtml-debug.esm.js.map +0 -0
- package/dist/jquery-plugin.d.ts +0 -0
- package/dist/jquery-plugin.d.ts.map +1 -1
- package/dist/jquery-plugin.js +472 -0
- package/dist/jquery-plugin.js.map +1 -0
- package/dist/lifecycle-manager.d.ts +0 -0
- package/dist/lifecycle-manager.d.ts.map +0 -0
- package/dist/lifecycle-manager.js +201 -0
- package/dist/lifecycle-manager.js.map +1 -0
- package/dist/load-coordinator.d.ts +18 -1
- package/dist/load-coordinator.d.ts.map +1 -1
- package/dist/load-coordinator.js +309 -0
- package/dist/load-coordinator.js.map +1 -0
- package/dist/local-storage.d.ts +2 -0
- package/dist/local-storage.d.ts.map +1 -1
- package/dist/local-storage.js +699 -0
- package/dist/local-storage.js.map +1 -0
- package/dist/preload-data.d.ts +0 -0
- package/dist/preload-data.d.ts.map +0 -0
- package/dist/preload-data.js +136 -0
- package/dist/preload-data.js.map +1 -0
- package/dist/template-renderer.d.ts +0 -0
- package/dist/template-renderer.d.ts.map +0 -0
- package/dist/template-renderer.js +278 -0
- package/dist/template-renderer.js.map +1 -0
- package/dist/viewport.d.ts +36 -0
- package/dist/viewport.d.ts.map +1 -0
- package/dist/viewport.js +69 -0
- package/dist/viewport.js.map +1 -0
- package/package.json +7 -2
|
@@ -0,0 +1,1892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JQHTML v2 Component Base Class
|
|
3
|
+
*
|
|
4
|
+
* Core component implementation following v2 specification:
|
|
5
|
+
* - 5-stage lifecycle coordinated by LifecycleManager
|
|
6
|
+
* - Direct jQuery manipulation (no virtual DOM)
|
|
7
|
+
* - Scoped IDs using _cid pattern
|
|
8
|
+
* - Event emission and CSS class hierarchy
|
|
9
|
+
*/
|
|
10
|
+
import { LifecycleManager } from './lifecycle-manager.js';
|
|
11
|
+
import { get_template_by_class, get_template } from './component-registry.js';
|
|
12
|
+
import { process_instructions, uid } from './instruction-processor.js';
|
|
13
|
+
import { logLifecycle, applyDebugDelay } from './debug.js';
|
|
14
|
+
import { Load_Coordinator } from './load-coordinator.js';
|
|
15
|
+
import { Jqhtml_Local_Storage } from './local-storage.js';
|
|
16
|
+
import { event_on, event_once, event_trigger, event_on_registered, event_invalidate } from './component-events.js';
|
|
17
|
+
import { setup_data_property, execute_on_load_detached } from './data-proxy.js';
|
|
18
|
+
import { read_cache_in_create, check_cache_on_reload, write_html_cache_snapshot, write_cache_on_loaded } from './component-cache.js';
|
|
19
|
+
import { debug_attributes_enabled } from './config.js';
|
|
20
|
+
import { Component_Queue } from './component-queue.js';
|
|
21
|
+
import { capture_component_data, is_capture_enabled, consume_preload_data, has_preload_data } from './preload-data.js';
|
|
22
|
+
import { get_viewport_width } from './viewport.js';
|
|
23
|
+
// WeakMap storage for protected lifecycle method implementations (Option 2)
|
|
24
|
+
// See docs/internal/lifecycle-method-protection.md for design details
|
|
25
|
+
const lifecycle_impls = new WeakMap();
|
|
26
|
+
export class Jqhtml_Component {
|
|
27
|
+
constructor(element, args = {}) {
|
|
28
|
+
this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
|
|
29
|
+
this._instantiator = null; // Component that instantiated this one
|
|
30
|
+
this._dom_parent = null; // Closest component in DOM tree (for lifecycle)
|
|
31
|
+
this._dom_children = new Set(); // Components in DOM subtree (for lifecycle)
|
|
32
|
+
this._use_dom_fallback = false; // If true, use find() fallback instead of _dom_children optimization
|
|
33
|
+
this._stopped = false;
|
|
34
|
+
this._booted = false; // Guard to prevent double boot() calls
|
|
35
|
+
this._data_before_render = null; // Store data state before initial render
|
|
36
|
+
this._lifecycle_callbacks = new Map();
|
|
37
|
+
this._lifecycle_states = new Map(); // Track which lifecycle events have occurred and their data
|
|
38
|
+
this._did_first_render = false; // Track if component has rendered at least once
|
|
39
|
+
this._render_count = 0; // Incremented each time _render() is called, used to detect stale renders
|
|
40
|
+
this._args_on_last_render = null; // Args snapshot from last render (for reload() comparison)
|
|
41
|
+
this._data_on_last_render = null; // Data snapshot from last render (for refresh() comparison)
|
|
42
|
+
this.__initial_data_snapshot = null; // Snapshot of this.data after on_create() for restoration before on_load()
|
|
43
|
+
this.__data_frozen = false; // Track if this.data is currently frozen
|
|
44
|
+
this._queue = new Component_Queue(); // Lifecycle operation queue (render/reload/refresh)
|
|
45
|
+
this.next_reload_force_refresh = null; // State machine for reload()/refresh() debounce precedence
|
|
46
|
+
this.__lifecycle_authorized = false; // Flag for lifecycle method protection
|
|
47
|
+
// Cache mode properties
|
|
48
|
+
this._cache_key = null; // Cache key for caching
|
|
49
|
+
// 'html' mode caching
|
|
50
|
+
this._cached_html = null; // Cached HTML to inject on first render
|
|
51
|
+
this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
|
|
52
|
+
this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
|
|
53
|
+
this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
|
|
54
|
+
// on_render synchronization (HTML cache mode)
|
|
55
|
+
this._on_render_complete = false; // True after on_render() has been called post-on_load
|
|
56
|
+
// use_cached_data feature - skip on_load() when cache hit occurs
|
|
57
|
+
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
|
|
58
|
+
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
|
|
59
|
+
this._load_only = false;
|
|
60
|
+
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
|
|
61
|
+
this._load_render_only = false;
|
|
62
|
+
// Detached optimization: true when element is not in the DOM at boot time.
|
|
63
|
+
// Skips initial render and cache read — just on_load then render.
|
|
64
|
+
// Override with _force_initial_render to keep normal double-render behavior.
|
|
65
|
+
this._is_detached = false;
|
|
66
|
+
// _force_initial_render: override detached optimization — render even when not in DOM.
|
|
67
|
+
// Use when you need the loading spinner visible immediately after appending.
|
|
68
|
+
this._force_initial_render = false;
|
|
69
|
+
// rendered event - fires once after the synchronous render chain completes
|
|
70
|
+
// (after on_load's re-render if applicable, or after first render if no on_load)
|
|
71
|
+
this._has_rendered = false;
|
|
72
|
+
// Sequential queue for setting this.data after on_load completes
|
|
73
|
+
// Multiple _load calls can run on_load in parallel, but setting this.data is serialized
|
|
74
|
+
this._load_queue = Promise.resolve();
|
|
75
|
+
// Cached flag: true if this component has a custom on_load() override
|
|
76
|
+
// Set in constructor (prototype chain is established before constructor runs)
|
|
77
|
+
// Used to skip expensive cache/load operations for static components
|
|
78
|
+
this.__has_custom_on_load = false;
|
|
79
|
+
// gate_load() — "load gates" registered during on_create() that must settle
|
|
80
|
+
// before the component's FIRST on_load() runs. See gate_load() / _await_load_gates().
|
|
81
|
+
this.__load_gates = []; // Pending gates (cleared once the first load begins)
|
|
82
|
+
this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
|
|
83
|
+
this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
|
|
84
|
+
// Detect custom on_load() override immediately
|
|
85
|
+
// Prototype chain is set up before constructor runs, so this.on_load
|
|
86
|
+
// correctly resolves to child class override if one exists
|
|
87
|
+
this.__has_custom_on_load = this.on_load !== Jqhtml_Component.prototype.on_load;
|
|
88
|
+
this._cid = this._generate_cid();
|
|
89
|
+
this._lifecycle_manager = LifecycleManager.get_instance();
|
|
90
|
+
// Create or wrap element
|
|
91
|
+
if (element) {
|
|
92
|
+
this.$ = $(element);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Use native createElement for better performance
|
|
96
|
+
const div = document.createElement('div');
|
|
97
|
+
this.$ = $(div);
|
|
98
|
+
}
|
|
99
|
+
// Extract $ attributes from element and merge with args
|
|
100
|
+
// $ attributes are stored via jQuery .data() (not as data-* attributes in DOM)
|
|
101
|
+
const dataAttrs = {};
|
|
102
|
+
// Get all jQuery .data() values from the element
|
|
103
|
+
if (this.$.length > 0) {
|
|
104
|
+
// Get all data via jQuery .data() without arguments (returns all data as object)
|
|
105
|
+
const allData = this.$.data() || {};
|
|
106
|
+
for (const key in allData) {
|
|
107
|
+
// Skip internal attributes
|
|
108
|
+
if (key !== 'cid' && key !== 'tid' && key !== 'componentName' && key !== 'readyState' &&
|
|
109
|
+
key !== '_lifecycleState' && !key.startsWith('_')) {
|
|
110
|
+
dataAttrs[key] = allData[key];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Get template to check for defineArgs
|
|
115
|
+
let template_for_args;
|
|
116
|
+
if (args._component_name) {
|
|
117
|
+
template_for_args = get_template(args._component_name);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
template_for_args = get_template_by_class(this.constructor);
|
|
121
|
+
}
|
|
122
|
+
// Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
|
|
123
|
+
const defineArgs = template_for_args?.defineArgs || {};
|
|
124
|
+
this.args = { ...defineArgs, ...dataAttrs, ...args };
|
|
125
|
+
// Set lifecycle truncation flags from args
|
|
126
|
+
if (this.args._load_only === true) {
|
|
127
|
+
this._load_only = true;
|
|
128
|
+
}
|
|
129
|
+
if (this.args._load_render_only === true) {
|
|
130
|
+
this._load_render_only = true;
|
|
131
|
+
}
|
|
132
|
+
if (this.args._force_initial_render === true) {
|
|
133
|
+
this._force_initial_render = true;
|
|
134
|
+
}
|
|
135
|
+
// Attach component to element
|
|
136
|
+
this.$.data('_component', this);
|
|
137
|
+
// Apply CSS classes and attributes
|
|
138
|
+
this._apply_css_classes();
|
|
139
|
+
this._apply_default_attributes(); // Apply defaultAttributes from template
|
|
140
|
+
this._set_attributes();
|
|
141
|
+
// Find DOM parent component (for lifecycle coordination)
|
|
142
|
+
this._find_dom_parent();
|
|
143
|
+
// Setup data property with freeze enforcement using Proxy
|
|
144
|
+
// @see data-proxy.ts for full implementation
|
|
145
|
+
setup_data_property(this);
|
|
146
|
+
// Initialize this.state as an empty object (convention for arbitrary component state)
|
|
147
|
+
// No special meaning to framework - mutable anywhere, not cached, not frozen
|
|
148
|
+
this.state = {};
|
|
149
|
+
this._log_lifecycle('construct', 'complete');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Protect lifecycle methods from manual invocation
|
|
153
|
+
* Stores original implementations in WeakMap, replaces with guarded wrappers
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_protect_lifecycle_methods() {
|
|
157
|
+
const methods = {
|
|
158
|
+
on_create: 'Called automatically during creation.',
|
|
159
|
+
on_render: 'Use render() to trigger a re-render.',
|
|
160
|
+
on_load: 'Use reload() to refresh data.',
|
|
161
|
+
on_ready: 'Called automatically when ready.',
|
|
162
|
+
on_stop: 'Use stop() to stop the component.'
|
|
163
|
+
};
|
|
164
|
+
const impls = {};
|
|
165
|
+
const self = this;
|
|
166
|
+
for (const [name, help] of Object.entries(methods)) {
|
|
167
|
+
const original = this[name];
|
|
168
|
+
// Skip if using base class default (empty method)
|
|
169
|
+
if (original === Jqhtml_Component.prototype[name])
|
|
170
|
+
continue;
|
|
171
|
+
impls[name] = original;
|
|
172
|
+
// Create wrapper with same function name (for stack traces)
|
|
173
|
+
this[name] = {
|
|
174
|
+
[name](...args) {
|
|
175
|
+
if (!self.__lifecycle_authorized) {
|
|
176
|
+
throw new Error(`[JQHTML] ${name}() cannot be called manually. ${help}\n` +
|
|
177
|
+
`Component: ${self.component_name()} (_cid: ${self._cid})`);
|
|
178
|
+
}
|
|
179
|
+
return lifecycle_impls.get(self)[name].apply(self, args);
|
|
180
|
+
}
|
|
181
|
+
}[name];
|
|
182
|
+
}
|
|
183
|
+
lifecycle_impls.set(this, impls);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Call a lifecycle method with authorization (async)
|
|
187
|
+
* Framework calls this to invoke lifecycle methods, bypassing the protection wrapper.
|
|
188
|
+
* The flag is set momentarily for the wrapper check, then reset BEFORE user code runs.
|
|
189
|
+
* This ensures any nested lifecycle calls from user code will fail the flag check.
|
|
190
|
+
* @private
|
|
191
|
+
*/
|
|
192
|
+
async _call_lifecycle(name, context) {
|
|
193
|
+
// Get the original implementation (bypasses wrapper entirely)
|
|
194
|
+
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
195
|
+
// Call original directly - no flag needed since we bypass the wrapper
|
|
196
|
+
return await impl.call(context || this);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Call a lifecycle method with authorization (sync version for sync methods like on_stop)
|
|
200
|
+
* @private
|
|
201
|
+
*/
|
|
202
|
+
_call_lifecycle_sync(name) {
|
|
203
|
+
// Get the original implementation (bypasses wrapper entirely)
|
|
204
|
+
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
205
|
+
// Call original directly - no flag needed since we bypass the wrapper
|
|
206
|
+
return impl.call(this);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Fire on_viewport_resize() with the current viewport width.
|
|
210
|
+
* Called after every on_render() and on_ready(); the debounced window
|
|
211
|
+
* listener in viewport.ts drives it from then on. Errors are isolated so a
|
|
212
|
+
* bad handler cannot break the lifecycle.
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
_fire_viewport_resize() {
|
|
216
|
+
try {
|
|
217
|
+
this.on_viewport_resize(get_viewport_width());
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
console.error(`[JQHTML] Error in on_viewport_resize() for ${this.component_name()}:`, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Check if on_load() is overridden in a subclass
|
|
225
|
+
* Used to skip the load phase entirely for components that don't fetch data
|
|
226
|
+
* Returns cached value set in _boot() for O(1) performance
|
|
227
|
+
* @private
|
|
228
|
+
*/
|
|
229
|
+
_has_on_load() {
|
|
230
|
+
return this.__has_custom_on_load;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Boot - Start the full component lifecycle
|
|
234
|
+
* Called immediately after construction by instruction processor
|
|
235
|
+
*/
|
|
236
|
+
/**
|
|
237
|
+
* Internal boot method - starts component lifecycle
|
|
238
|
+
* @private
|
|
239
|
+
*/
|
|
240
|
+
async _boot() {
|
|
241
|
+
// Guard: prevent double boot() calls
|
|
242
|
+
if (this._booted)
|
|
243
|
+
return;
|
|
244
|
+
this._booted = true;
|
|
245
|
+
// Protect lifecycle methods from manual invocation (must happen after subclass constructor)
|
|
246
|
+
// Note: __has_custom_on_load is already set in constructor
|
|
247
|
+
this._protect_lifecycle_methods();
|
|
248
|
+
await this._lifecycle_manager.boot_component(this);
|
|
249
|
+
}
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
// Lifecycle Methods (called by LifecycleManager)
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
/**
|
|
254
|
+
* Internal render phase - Create DOM structure
|
|
255
|
+
* Called top-down (parent before children) when part of lifecycle
|
|
256
|
+
* This is an internal method - users should call render() instead
|
|
257
|
+
*
|
|
258
|
+
* @param id Optional scoped ID - if provided, delegates to child component's _render()
|
|
259
|
+
* @returns The current _render_count after incrementing (used to detect stale renders)
|
|
260
|
+
* @private
|
|
261
|
+
*/
|
|
262
|
+
_render(id = null, options = {}) {
|
|
263
|
+
// Increment render count to track this specific render
|
|
264
|
+
this._render_count++;
|
|
265
|
+
const current_render_id = this._render_count;
|
|
266
|
+
if (this._stopped)
|
|
267
|
+
return current_render_id;
|
|
268
|
+
// If id provided, delegate to child component
|
|
269
|
+
if (id) {
|
|
270
|
+
// First check if element with scoped ID exists
|
|
271
|
+
const $element = this.$sid(id);
|
|
272
|
+
if ($element.length === 0) {
|
|
273
|
+
throw new Error(`[JQHTML] render("${id}") - no such id.\n` +
|
|
274
|
+
`Component "${this.component_name()}" has no child element with $sid="${id}".`);
|
|
275
|
+
}
|
|
276
|
+
// Element exists, check if it's a component
|
|
277
|
+
const child = $element.data('_component');
|
|
278
|
+
if (!child) {
|
|
279
|
+
throw new Error(`[JQHTML] render("${id}") - element is not a component or does not have $redrawable attribute set.\n` +
|
|
280
|
+
`Element with $sid="${id}" exists but is not initialized as a component.\n` +
|
|
281
|
+
`Add $redrawable attribute or make it a proper component.`);
|
|
282
|
+
}
|
|
283
|
+
return child._render(null, options);
|
|
284
|
+
}
|
|
285
|
+
this._log_lifecycle('render', 'start');
|
|
286
|
+
// HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
|
|
287
|
+
if (this._cached_html !== null) {
|
|
288
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
289
|
+
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
|
|
290
|
+
}
|
|
291
|
+
// Inject cached HTML directly
|
|
292
|
+
this.$[0].innerHTML = this._cached_html;
|
|
293
|
+
// Mark that we used cached HTML (forces re-render after on_load)
|
|
294
|
+
this._used_cached_html = true;
|
|
295
|
+
// Clear cached HTML so next render uses template
|
|
296
|
+
this._cached_html = null;
|
|
297
|
+
// Mark first render complete
|
|
298
|
+
this._did_first_render = true;
|
|
299
|
+
this._log_lifecycle('render', 'complete (cached HTML)');
|
|
300
|
+
// Call on_render() after cache inject (unless suppressed by _load_render_only)
|
|
301
|
+
if (!options.skip_on_render) {
|
|
302
|
+
const cacheRenderResult = this._call_lifecycle_sync('on_render');
|
|
303
|
+
if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
|
|
304
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
305
|
+
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
306
|
+
}
|
|
307
|
+
this._fire_viewport_resize();
|
|
308
|
+
}
|
|
309
|
+
// Emit lifecycle event
|
|
310
|
+
this.trigger('render');
|
|
311
|
+
// Store args/data snapshots for later comparison
|
|
312
|
+
try {
|
|
313
|
+
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this._args_on_last_render = null;
|
|
317
|
+
}
|
|
318
|
+
this._data_on_last_render = JSON.stringify(this.data);
|
|
319
|
+
return current_render_id;
|
|
320
|
+
}
|
|
321
|
+
// Determine child-finding strategy: If component is off-DOM, children can't register
|
|
322
|
+
// via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
|
|
323
|
+
// If attached to DOM, children register normally and we can use the fast _dom_children path.
|
|
324
|
+
// Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
|
|
325
|
+
if (!$.contains(document.documentElement, this.$[0])) {
|
|
326
|
+
this._use_dom_fallback = true;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
this._use_dom_fallback = false;
|
|
330
|
+
}
|
|
331
|
+
// If this is not the first render, stop child components and clear DOM
|
|
332
|
+
if (this._did_first_render) {
|
|
333
|
+
// Stop all child components before clearing DOM
|
|
334
|
+
this.$.find('.Component').each(function () {
|
|
335
|
+
const child = $(this).data('_component');
|
|
336
|
+
if (child && !child._stopped) {
|
|
337
|
+
child._stop(); // Stop just the component, DOM will be cleared next
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
// Clear the DOM
|
|
341
|
+
this.$[0].innerHTML = '';
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
this._did_first_render = true;
|
|
345
|
+
}
|
|
346
|
+
// Remove _Component_Stopped class if present (allows re-render after stop)
|
|
347
|
+
this.$.removeClass('_Component_Stopped');
|
|
348
|
+
// Capture data state before first render for comparison later
|
|
349
|
+
if (this._data_before_render === null) {
|
|
350
|
+
this._data_before_render = JSON.stringify(this.data);
|
|
351
|
+
}
|
|
352
|
+
// Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
|
|
353
|
+
this._dom_children.clear();
|
|
354
|
+
// Get template and render it
|
|
355
|
+
let template_def;
|
|
356
|
+
// If we have a component name, try looking up by name first (for unregistered components)
|
|
357
|
+
if (this.args._component_name) {
|
|
358
|
+
template_def = get_template(this.args._component_name);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
// Otherwise look up by class
|
|
362
|
+
template_def = get_template_by_class(this.constructor);
|
|
363
|
+
}
|
|
364
|
+
if (template_def && template_def.render) {
|
|
365
|
+
// Create jqhtml utilities object
|
|
366
|
+
const jqhtml = {
|
|
367
|
+
escape_html: (str) => {
|
|
368
|
+
const div = document.createElement('div');
|
|
369
|
+
div.textContent = String(str);
|
|
370
|
+
return div.innerHTML;
|
|
371
|
+
},
|
|
372
|
+
escape_html_nl2br: (str) => {
|
|
373
|
+
const div = document.createElement('div');
|
|
374
|
+
div.textContent = String(str);
|
|
375
|
+
// Escape HTML first, then replace newlines with <br />
|
|
376
|
+
return div.innerHTML.replace(/\n/g, '<br />');
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
// Execute template function
|
|
380
|
+
// Commented out debug logging
|
|
381
|
+
// console.log('[Component] About to call render, this:', this);
|
|
382
|
+
// console.log('[Component] this.constructor.name:', this.constructor.name);
|
|
383
|
+
// console.log('[Component] this.data:', this.data);
|
|
384
|
+
// console.log('[Component] this.args:', this.args);
|
|
385
|
+
// Create content function that handles both default content and named slots
|
|
386
|
+
const createContentFunction = () => {
|
|
387
|
+
const defaultContentFn = this.args._innerhtml_function;
|
|
388
|
+
const slotsObj = this.args._slots;
|
|
389
|
+
// Return a function that handles both slot names and default content
|
|
390
|
+
return (slotName, ...slotArgs) => {
|
|
391
|
+
// If slot name provided and slots object exists, try to use named slot
|
|
392
|
+
if (slotName && slotsObj && slotsObj[slotName]) {
|
|
393
|
+
// Call the slot function - it returns [instructions, context]
|
|
394
|
+
return slotsObj[slotName](...slotArgs);
|
|
395
|
+
}
|
|
396
|
+
// If slot name provided but not found, return empty
|
|
397
|
+
else if (slotName) {
|
|
398
|
+
return '';
|
|
399
|
+
}
|
|
400
|
+
// No slot name = default content
|
|
401
|
+
else if (defaultContentFn) {
|
|
402
|
+
return defaultContentFn(this);
|
|
403
|
+
}
|
|
404
|
+
// No content at all
|
|
405
|
+
else {
|
|
406
|
+
return '';
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
const contentFunction = createContentFunction();
|
|
411
|
+
let [instructions, context] = template_def.render.bind(this)(this.data, this.args, contentFunction, // Content function with slot support
|
|
412
|
+
jqhtml // Utilities object
|
|
413
|
+
);
|
|
414
|
+
// Check for template inheritance (slot-only OR explicit extends)
|
|
415
|
+
// If instructions is {_slots: {...}}, find parent template and invoke it
|
|
416
|
+
if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
|
|
417
|
+
const componentName = template_def.name || this.args._component_name || this.constructor.name;
|
|
418
|
+
console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
|
|
419
|
+
let parentTemplate = null;
|
|
420
|
+
let parentTemplateName = null;
|
|
421
|
+
// First check for explicit extends attribute in template metadata
|
|
422
|
+
if (template_def.extends) {
|
|
423
|
+
console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
|
|
424
|
+
parentTemplate = get_template(template_def.extends);
|
|
425
|
+
parentTemplateName = template_def.extends;
|
|
426
|
+
}
|
|
427
|
+
// If no explicit extends, walk the prototype chain to find parent class with template
|
|
428
|
+
if (!parentTemplate) {
|
|
429
|
+
let currentClass = Object.getPrototypeOf(this.constructor);
|
|
430
|
+
while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
|
|
431
|
+
const className = currentClass.name;
|
|
432
|
+
console.log(`[JQHTML] Checking parent: ${className}`);
|
|
433
|
+
try {
|
|
434
|
+
const classTemplate = get_template(className);
|
|
435
|
+
if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
|
|
436
|
+
console.log(`[JQHTML] Found parent template: ${className}`);
|
|
437
|
+
parentTemplate = classTemplate;
|
|
438
|
+
parentTemplateName = className;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
|
|
444
|
+
}
|
|
445
|
+
currentClass = Object.getPrototypeOf(currentClass);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// If we found a parent template, invoke it with child's slots
|
|
449
|
+
if (parentTemplate) {
|
|
450
|
+
try {
|
|
451
|
+
// Create a content function that invokes child slots
|
|
452
|
+
// When parent calls content('slotName'), it invokes the child's slot function
|
|
453
|
+
const childSlots = instructions._slots;
|
|
454
|
+
const contentFunction = (slotName, data) => {
|
|
455
|
+
if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
|
|
456
|
+
// Invoke the slot function with data parameter
|
|
457
|
+
const [slotInstructions, slotContext] = childSlots[slotName](data);
|
|
458
|
+
// Return in render function format: [instructions, context]
|
|
459
|
+
// The template expression handler expects this format
|
|
460
|
+
return [slotInstructions, slotContext];
|
|
461
|
+
}
|
|
462
|
+
// Slot not found, return empty
|
|
463
|
+
return '';
|
|
464
|
+
};
|
|
465
|
+
// Invoke parent template with child's slots as content function
|
|
466
|
+
const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
|
|
467
|
+
jqhtml);
|
|
468
|
+
console.log(`[JQHTML] Parent template invoked successfully`);
|
|
469
|
+
instructions = parentInstructions;
|
|
470
|
+
context = parentContext;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
|
|
474
|
+
instructions = [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
|
|
479
|
+
instructions = [];
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Flatten any nested content instructions before processing
|
|
483
|
+
// Instructions may contain ['_content', [...]] markers from content() calls
|
|
484
|
+
const flattenedInstructions = this._flatten_instructions(instructions);
|
|
485
|
+
// Process instructions to generate DOM
|
|
486
|
+
// This kicks off child component boots but doesn't wait for them
|
|
487
|
+
process_instructions(flattenedInstructions, this.$, this);
|
|
488
|
+
}
|
|
489
|
+
// Don't update ready state here - let phases complete in order
|
|
490
|
+
this._update_debug_attrs();
|
|
491
|
+
this._log_lifecycle('render', 'complete');
|
|
492
|
+
// Call on_render() after render completes (unless suppressed by _load_render_only)
|
|
493
|
+
if (!options.skip_on_render) {
|
|
494
|
+
const renderResult = this._call_lifecycle_sync('on_render');
|
|
495
|
+
if (renderResult && typeof renderResult.then === 'function') {
|
|
496
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
497
|
+
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
498
|
+
}
|
|
499
|
+
this._fire_viewport_resize();
|
|
500
|
+
}
|
|
501
|
+
// Emit lifecycle event
|
|
502
|
+
this.trigger('render');
|
|
503
|
+
// Apply debug delay after render
|
|
504
|
+
const isRerender = this._ready_state >= 3; // Already rendered once
|
|
505
|
+
applyDebugDelay(isRerender ? 'rerender' : 'render');
|
|
506
|
+
// Store args snapshot for reload() comparison (skip if non-serializable)
|
|
507
|
+
try {
|
|
508
|
+
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
// Args contain circular references - skip snapshot (reload comparison will be disabled)
|
|
512
|
+
this._args_on_last_render = null;
|
|
513
|
+
}
|
|
514
|
+
// Store data snapshot for refresh() comparison
|
|
515
|
+
this._data_on_last_render = JSON.stringify(this.data);
|
|
516
|
+
// HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
|
|
517
|
+
// This signals to parent components that this component's DOM is fully rendered
|
|
518
|
+
// with fresh data and ready for HTML snapshot
|
|
519
|
+
if (this._ready_state >= 2) {
|
|
520
|
+
this._on_render_complete = true;
|
|
521
|
+
}
|
|
522
|
+
// Return the render ID so callers can check if this render is still current
|
|
523
|
+
return current_render_id;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Public render method - re-renders component and completes lifecycle
|
|
527
|
+
* This is what users should call when they want to update a component.
|
|
528
|
+
*
|
|
529
|
+
* Lifecycle sequence (serialized via component queue):
|
|
530
|
+
* 1. _render() - Updates DOM synchronously, calls on_render(), fires 'render' event
|
|
531
|
+
* 2. _wait_for_children_ready() - Waits for all children to reach ready state
|
|
532
|
+
* 3. on_ready() - Calls user's ready hook
|
|
533
|
+
* 4. trigger('ready') - Fires ready event
|
|
534
|
+
*
|
|
535
|
+
* Goes through the component queue to prevent concurrent lifecycle operations.
|
|
536
|
+
* Returns a Promise that resolves when the full render lifecycle completes.
|
|
537
|
+
*/
|
|
538
|
+
render(id = null) {
|
|
539
|
+
if (this._stopped)
|
|
540
|
+
return Promise.resolve();
|
|
541
|
+
// Invalidate ready event so new handlers wait for this render cycle to complete
|
|
542
|
+
// This prevents .on('ready') handlers from firing immediately based on previous lifecycle
|
|
543
|
+
this.invalidate('ready');
|
|
544
|
+
// If id provided, delegate to child component
|
|
545
|
+
if (id) {
|
|
546
|
+
const $element = this.$sid(id);
|
|
547
|
+
if ($element.length === 0) {
|
|
548
|
+
throw new Error(`[JQHTML] render("${id}") - no such id.\n` +
|
|
549
|
+
`Component "${this.component_name()}" has no child element with $sid="${id}".`);
|
|
550
|
+
}
|
|
551
|
+
const child = $element.data('_component');
|
|
552
|
+
if (!child) {
|
|
553
|
+
throw new Error(`[JQHTML] render("${id}") - element is not a component or does not have $redrawable attribute set.\n` +
|
|
554
|
+
`Element with $sid="${id}" exists but is not initialized as a component.\n` +
|
|
555
|
+
`Add $redrawable attribute or make it a proper component.`);
|
|
556
|
+
}
|
|
557
|
+
return child.render();
|
|
558
|
+
}
|
|
559
|
+
// Enqueue the full render lifecycle through the component queue
|
|
560
|
+
// Return the queue promise so `await this.render()` resolves when the
|
|
561
|
+
// full render lifecycle (DOM update, children ready, on_ready) completes
|
|
562
|
+
return this._queue.enqueue('render', async () => {
|
|
563
|
+
// Execute render phase synchronously and capture render ID
|
|
564
|
+
const render_id = this._render();
|
|
565
|
+
// Wait for all child components to be ready
|
|
566
|
+
await this._wait_for_children_ready();
|
|
567
|
+
// Check if this render is still current before calling on_ready
|
|
568
|
+
// If _render_count changed, another render happened and we should skip on_ready
|
|
569
|
+
if (this._render_count !== render_id) {
|
|
570
|
+
return; // Stale render, don't call on_ready
|
|
571
|
+
}
|
|
572
|
+
// Call on_ready hook with authorization
|
|
573
|
+
await this._call_lifecycle('on_ready');
|
|
574
|
+
this._fire_viewport_resize();
|
|
575
|
+
// Trigger ready event
|
|
576
|
+
this.trigger('ready');
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Alias for render() - re-renders component with current data
|
|
581
|
+
* Provided for API consistency and clarity
|
|
582
|
+
*/
|
|
583
|
+
redraw(id = null) {
|
|
584
|
+
return this.render(id);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Re-fetch data by executing on_load() without triggering re-render or lifecycle.
|
|
588
|
+
*
|
|
589
|
+
* Runs on_load() on a detached proxy (same restrictions as during boot):
|
|
590
|
+
* - this.data is restored to on_create() snapshot, then unfrozen for writing
|
|
591
|
+
* - this.args is read-only
|
|
592
|
+
* - All other properties (this.$, this.$sid, etc.) are blocked
|
|
593
|
+
*
|
|
594
|
+
* After on_load() completes, this.data is atomically updated and re-frozen.
|
|
595
|
+
* The developer decides what to do next (e.g., call this.render()).
|
|
596
|
+
*
|
|
597
|
+
* Pattern: load() invokes on_load(), just as render() invokes on_render().
|
|
598
|
+
*
|
|
599
|
+
* @returns true if this.data changed, false if unchanged
|
|
600
|
+
*/
|
|
601
|
+
async load() {
|
|
602
|
+
if (this._stopped)
|
|
603
|
+
return false;
|
|
604
|
+
// Capture result through queue closure
|
|
605
|
+
let data_changed = false;
|
|
606
|
+
await this._queue.enqueue('load', async () => {
|
|
607
|
+
// Snapshot current data for change detection
|
|
608
|
+
const data_before = JSON.stringify(this.data);
|
|
609
|
+
// Execute on_load() on detached proxy (restores data to on_create snapshot)
|
|
610
|
+
const { data: result_data } = await this._execute_on_load_detached(false);
|
|
611
|
+
// Atomically update this.data: unfreeze → assign → normalize → refreeze
|
|
612
|
+
this.__data_frozen = false;
|
|
613
|
+
this.data = result_data;
|
|
614
|
+
const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
|
|
615
|
+
if (cache_mode === 'data') {
|
|
616
|
+
const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
|
|
617
|
+
this.data = normalized;
|
|
618
|
+
}
|
|
619
|
+
this.__data_frozen = true;
|
|
620
|
+
// Detect if data changed
|
|
621
|
+
const data_after = JSON.stringify(this.data);
|
|
622
|
+
data_changed = data_before !== data_after;
|
|
623
|
+
// Update cache with fresh data if changed
|
|
624
|
+
if (data_changed) {
|
|
625
|
+
// Regenerate cache key (args may have changed since boot)
|
|
626
|
+
let cache_key = null;
|
|
627
|
+
if (typeof this.cache_id === 'function') {
|
|
628
|
+
try {
|
|
629
|
+
cache_key = `${this.component_name()}::${String(this.cache_id())}`;
|
|
630
|
+
}
|
|
631
|
+
catch { /* cache_id() threw - skip caching */ }
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
|
|
635
|
+
cache_key = result.key;
|
|
636
|
+
}
|
|
637
|
+
if (cache_key && cache_mode !== 'html') {
|
|
638
|
+
Jqhtml_Local_Storage.set(cache_key, this.data);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
|
|
642
|
+
// Suppressed by _load_only and _load_render_only flags (preloading mode)
|
|
643
|
+
if (!this._load_only && !this._load_render_only) {
|
|
644
|
+
await this._call_lifecycle('on_loaded');
|
|
645
|
+
this.trigger('loaded');
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
return data_changed;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Create phase - Quick setup, prepare UI
|
|
652
|
+
* SYNCHRONOUS - no awaits, no yields
|
|
653
|
+
*/
|
|
654
|
+
create() {
|
|
655
|
+
if (this._stopped || this._ready_state >= 1)
|
|
656
|
+
return;
|
|
657
|
+
this._log_lifecycle('create', 'start');
|
|
658
|
+
// Call on_create() synchronously - it MUST be synchronous
|
|
659
|
+
const result = this._call_lifecycle_sync('on_create');
|
|
660
|
+
if (result && typeof result.then === 'function') {
|
|
661
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_create(). ` +
|
|
662
|
+
`on_create() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
663
|
+
// Don't await - on_create MUST be sync. The warning is enough.
|
|
664
|
+
}
|
|
665
|
+
// Detect detached elements — skip cache and initial render for elements not in DOM
|
|
666
|
+
// _force_initial_render overrides this optimization
|
|
667
|
+
this._is_detached = !this.$[0].isConnected && !this._force_initial_render;
|
|
668
|
+
// OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
|
|
669
|
+
// Components without on_load() don't fetch data, so nothing to cache or restore
|
|
670
|
+
if (this.__has_custom_on_load) {
|
|
671
|
+
// CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
|
|
672
|
+
// Skip cache for detached elements — no point hydrating from cache when not in DOM
|
|
673
|
+
// @see component-cache.ts for full implementation
|
|
674
|
+
if (!this._is_detached) {
|
|
675
|
+
read_cache_in_create(this);
|
|
676
|
+
}
|
|
677
|
+
// Snapshot this.data after on_create() completes
|
|
678
|
+
// This will be restored before each on_load() execution to reset state
|
|
679
|
+
this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
|
|
680
|
+
}
|
|
681
|
+
// Freeze this.data after on_create() - only on_load() can modify it now
|
|
682
|
+
this.__data_frozen = true;
|
|
683
|
+
this._ready_state = 1;
|
|
684
|
+
this._update_debug_attrs();
|
|
685
|
+
this._log_lifecycle('create', 'complete');
|
|
686
|
+
// Emit lifecycle event
|
|
687
|
+
this.trigger('create');
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Register a "load gate" — a promise that must settle before this component's
|
|
691
|
+
* FIRST on_load() runs.
|
|
692
|
+
*
|
|
693
|
+
* Register gates during on_create(). Multiple calls accumulate; all gates are
|
|
694
|
+
* awaited together (via Promise.allSettled) at the seam between create and the
|
|
695
|
+
* first load. A REJECTED gate does NOT block or abort the load — it is an
|
|
696
|
+
* ordering hint, not a data dependency, so loading proceeds after settlement
|
|
697
|
+
* (rejections are logged through the debug channel).
|
|
698
|
+
*
|
|
699
|
+
* Gates are ONE-SHOT: they delay the first on_load() only. Once that load
|
|
700
|
+
* begins the gate list is cleared, and reload()/refresh() never re-await gates.
|
|
701
|
+
* Calling reload()/refresh() while a gate is still holding the first load
|
|
702
|
+
* releases the wait immediately and resumes the lifecycle (a later gate
|
|
703
|
+
* settlement then does nothing).
|
|
704
|
+
*
|
|
705
|
+
* Gates delay ONLY the on_load phase — never create(), the initial render,
|
|
706
|
+
* on_render(), or the cached-content first paint (stale-while-revalidate is
|
|
707
|
+
* preserved). Components with no custom on_load() ignore gates entirely.
|
|
708
|
+
*
|
|
709
|
+
* No-op during SSR (the server cannot await arbitrary client promises).
|
|
710
|
+
*
|
|
711
|
+
* jqhtml has no knowledge of WHAT is being awaited — the caller supplies the
|
|
712
|
+
* promise, and policy such as timeouts is the caller's responsibility (bake it
|
|
713
|
+
* into the promise you pass).
|
|
714
|
+
*
|
|
715
|
+
* @param promise The promise to await before the first on_load().
|
|
716
|
+
* @throws If called after the component's first load has already started.
|
|
717
|
+
*/
|
|
718
|
+
gate_load(promise) {
|
|
719
|
+
if (this.__first_load_started) {
|
|
720
|
+
throw new Error(`[JQHTML] gate_load() may only be called before the component's first load - ` +
|
|
721
|
+
`register gates in on_create(). ` +
|
|
722
|
+
`Component: ${this.component_name()} (_cid: ${this._cid})`);
|
|
723
|
+
}
|
|
724
|
+
this.__load_gates.push(promise);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Await all registered load gates before the first on_load().
|
|
728
|
+
*
|
|
729
|
+
* Called exactly once, at the boot seam immediately before the load phase
|
|
730
|
+
* (only for components with a custom on_load()). Latches "first load started"
|
|
731
|
+
* so any later gate_load() throws and reload()/refresh() never re-await.
|
|
732
|
+
*
|
|
733
|
+
* Returns a promise to await when there are gates to wait on, or `null` when
|
|
734
|
+
* there is nothing to wait for (no gates, or SSR). Returning `null` keeps the
|
|
735
|
+
* common no-gate path free of an extra microtask hop, so it does not shift the
|
|
736
|
+
* timing of ungated components.
|
|
737
|
+
*
|
|
738
|
+
* The returned promise resolves as soon as any of the following happens
|
|
739
|
+
* (whichever is first — the rest become no-ops):
|
|
740
|
+
* - all gates settle (Promise.allSettled),
|
|
741
|
+
* - reload()/refresh() is called while gated (they resolve __gate_resume),
|
|
742
|
+
* - the component is stopped (stop() resolves __gate_resume).
|
|
743
|
+
*
|
|
744
|
+
* @private
|
|
745
|
+
*/
|
|
746
|
+
_await_load_gates() {
|
|
747
|
+
// Latch (synchronous): from here on, gate_load() throws and this runs at
|
|
748
|
+
// most once — set even when there are no gates so point 2 always holds.
|
|
749
|
+
this.__first_load_started = true;
|
|
750
|
+
const gates = this.__load_gates;
|
|
751
|
+
this.__load_gates = [];
|
|
752
|
+
// Nothing to gate, or SSR (cannot await arbitrary client promises) — proceed now.
|
|
753
|
+
if (gates.length === 0 || is_capture_enabled())
|
|
754
|
+
return null;
|
|
755
|
+
// Promise.allSettled never rejects; rejected gates are logged (debug channel)
|
|
756
|
+
// whenever they settle, independent of whether the wait was resumed early.
|
|
757
|
+
const settled = Promise.allSettled(gates);
|
|
758
|
+
settled.then((results) => {
|
|
759
|
+
for (const result of results) {
|
|
760
|
+
if (result.status === 'rejected' && window.jqhtml?.debug?.verbose) {
|
|
761
|
+
console.log(`[JQHTML gate_load] Component ${this._cid} (${this.component_name()}) gate rejected (ignored, load proceeds)`, result.reason);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
// Race gate settlement against an early-resume signal (reload/refresh/stop while gated).
|
|
766
|
+
return new Promise((resolve) => {
|
|
767
|
+
this.__gate_resume = resolve;
|
|
768
|
+
settled.then(() => resolve());
|
|
769
|
+
}).then(() => {
|
|
770
|
+
this.__gate_resume = null;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Load phase - Fetch asynchronous data
|
|
775
|
+
* Called bottom-up, fully parallel
|
|
776
|
+
* NO DOM MODIFICATIONS ALLOWED IN THIS PHASE
|
|
777
|
+
*
|
|
778
|
+
* Key design: on_load() runs on a DETACHED proxy with a clone of this.data.
|
|
779
|
+
* The real component's this.data is not touched during on_load execution.
|
|
780
|
+
* After on_load completes, the result is applied via a sequential queue.
|
|
781
|
+
*
|
|
782
|
+
* @private - Internal lifecycle method, not for external use
|
|
783
|
+
*/
|
|
784
|
+
async _load() {
|
|
785
|
+
if (this._stopped || this._ready_state >= 2)
|
|
786
|
+
return;
|
|
787
|
+
this._log_lifecycle('load', 'start');
|
|
788
|
+
// use_cached_data feature: If cache hit occurred and use_cached_data=true, skip on_load() entirely
|
|
789
|
+
// The cached data is already in this.data from create() phase
|
|
790
|
+
if (this._use_cached_data_hit) {
|
|
791
|
+
this._ready_state = 2;
|
|
792
|
+
this._update_debug_attrs();
|
|
793
|
+
this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
|
|
794
|
+
this.trigger('load');
|
|
795
|
+
if (!this._load_only && !this._load_render_only) {
|
|
796
|
+
await this._call_lifecycle('on_loaded');
|
|
797
|
+
this.trigger('loaded');
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// TWO identities, deliberately computed separately.
|
|
802
|
+
//
|
|
803
|
+
// cache_key - may key a plain-data object arg by deterministic CONTENT, and may come
|
|
804
|
+
// from the author's cache_id(). A wrong key here is corrected by
|
|
805
|
+
// stale-while-revalidate.
|
|
806
|
+
// dedup_key - args only, no content serialization, no cache_id(). A deduplicated
|
|
807
|
+
// follower never runs on_load() and adopts the leader's data with NO
|
|
808
|
+
// revalidation, so a wrong key here is permanently wrong data. Redundant
|
|
809
|
+
// concurrent requests are the cheaper failure.
|
|
810
|
+
let cache_key = null;
|
|
811
|
+
let uncacheable_property;
|
|
812
|
+
let uncacheable_reason;
|
|
813
|
+
if (typeof this.cache_id === 'function') {
|
|
814
|
+
try {
|
|
815
|
+
const custom_cache_id = this.cache_id();
|
|
816
|
+
cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
// cache_id() threw error - disable caching
|
|
820
|
+
uncacheable_property = 'cache_id()';
|
|
821
|
+
uncacheable_reason = 'cache-id-threw';
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
|
|
826
|
+
cache_key = result.key;
|
|
827
|
+
uncacheable_property = result.uncacheable_property;
|
|
828
|
+
uncacheable_reason = result.uncacheable_reason;
|
|
829
|
+
}
|
|
830
|
+
const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
|
|
831
|
+
// SSR preload check: if preloaded data exists for this component+args, use it
|
|
832
|
+
if (cache_key !== null && has_preload_data()) {
|
|
833
|
+
const preloaded_data = consume_preload_data(cache_key);
|
|
834
|
+
if (preloaded_data !== null) {
|
|
835
|
+
this._cache_key = cache_key;
|
|
836
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
837
|
+
console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
|
|
838
|
+
}
|
|
839
|
+
const data_before_load = JSON.stringify(this.data);
|
|
840
|
+
await this._apply_load_result(preloaded_data, data_before_load);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Store cache key for later use
|
|
845
|
+
this._cache_key = cache_key;
|
|
846
|
+
// Caching disabled - mark the element with the offending arg AND why
|
|
847
|
+
if (cache_key === null && uncacheable_property) {
|
|
848
|
+
this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
|
|
849
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
850
|
+
console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-keyable args - caching disabled`, { uncacheable_property, uncacheable_reason });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Store "before" snapshot for comparison after on_load()
|
|
854
|
+
const data_before_load = JSON.stringify(this.data);
|
|
855
|
+
// No dedup identity - run on_load() alone rather than risk sharing a load
|
|
856
|
+
if (dedup_key === null) {
|
|
857
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
858
|
+
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
|
|
859
|
+
}
|
|
860
|
+
const { data: result_data } = await this._execute_on_load_detached();
|
|
861
|
+
await this._apply_load_result(result_data, data_before_load);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
// Check if this component should execute on_load() or wait for existing request
|
|
865
|
+
const should_execute = Load_Coordinator.should_execute_on_load(this);
|
|
866
|
+
if (!should_execute) {
|
|
867
|
+
// This component is a follower - wait for leader to complete
|
|
868
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
869
|
+
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
|
|
870
|
+
}
|
|
871
|
+
const coordination_promise = Load_Coordinator.get_coordination_promise(this);
|
|
872
|
+
if (coordination_promise) {
|
|
873
|
+
try {
|
|
874
|
+
// Wait for leader to complete
|
|
875
|
+
await coordination_promise;
|
|
876
|
+
// Retrieve leader's data from coordinator
|
|
877
|
+
const leader_data = Load_Coordinator.get_leader_data(this);
|
|
878
|
+
if (leader_data !== null) {
|
|
879
|
+
// Apply leader's data using the same method as leader
|
|
880
|
+
// This properly handles freeze/unfreeze and normalization
|
|
881
|
+
await this._apply_load_result(leader_data, data_before_load);
|
|
882
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
883
|
+
console.log(`[Load Deduplication] Component ${this._cid} applied data from leader`, { data: this.data });
|
|
884
|
+
}
|
|
885
|
+
// _apply_load_result already sets ready_state and triggers 'load'
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
// Leader failed - error will propagate naturally
|
|
891
|
+
console.error(`[Load Deduplication] Component ${this._cid} failed due to leader error:`, error);
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Fallback: if we couldn't get leader data, just mark as complete
|
|
896
|
+
this._ready_state = 2;
|
|
897
|
+
this._update_debug_attrs();
|
|
898
|
+
this._log_lifecycle('load', 'complete (follower)');
|
|
899
|
+
this.trigger('load');
|
|
900
|
+
if (!this._load_only && !this._load_render_only) {
|
|
901
|
+
await this._call_lifecycle('on_loaded');
|
|
902
|
+
this.trigger('loaded');
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// This component is a leader - execute on_load() on detached proxy
|
|
907
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
908
|
+
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
|
|
909
|
+
}
|
|
910
|
+
// Execute on_load on detached proxy with Load_Coordinator registration
|
|
911
|
+
const { data: result_data, complete_coordination } = await this._execute_on_load_detached(true);
|
|
912
|
+
// Apply result via sequential queue
|
|
913
|
+
await this._apply_load_result(result_data, data_before_load);
|
|
914
|
+
// Complete coordination AFTER this.data is updated
|
|
915
|
+
// This ensures followers receive the correct final data
|
|
916
|
+
if (complete_coordination) {
|
|
917
|
+
complete_coordination(this.data);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Execute on_load() on a fully detached proxy.
|
|
922
|
+
* @see data-proxy.ts for full implementation
|
|
923
|
+
* @private
|
|
924
|
+
*/
|
|
925
|
+
async _execute_on_load_detached(use_load_coordinator = false) {
|
|
926
|
+
return execute_on_load_detached(this, use_load_coordinator);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Apply the result of on_load() to this.data via the sequential queue.
|
|
930
|
+
*
|
|
931
|
+
* Multiple _load calls can execute on_load in parallel, but this method
|
|
932
|
+
* ensures that setting this.data happens in FIFO order.
|
|
933
|
+
*
|
|
934
|
+
* @param result_data - The data returned from _execute_on_load_detached
|
|
935
|
+
* @param data_before_load - JSON string of this.data before on_load (for change detection), or null
|
|
936
|
+
* @private
|
|
937
|
+
*/
|
|
938
|
+
async _apply_load_result(result_data, data_before_load) {
|
|
939
|
+
// Queue this operation - wait for any earlier _load calls to finish setting data
|
|
940
|
+
const my_turn = this._load_queue;
|
|
941
|
+
let resolve_my_turn;
|
|
942
|
+
this._load_queue = new Promise((resolve) => {
|
|
943
|
+
resolve_my_turn = resolve;
|
|
944
|
+
});
|
|
945
|
+
// Wait for our turn
|
|
946
|
+
await my_turn;
|
|
947
|
+
try {
|
|
948
|
+
// Now it's our turn to set this.data
|
|
949
|
+
// Unfreeze, set data, freeze
|
|
950
|
+
this.__data_frozen = false;
|
|
951
|
+
this.data = result_data;
|
|
952
|
+
// DATA MODE: Normalize this.data through serialize/deserialize round-trip
|
|
953
|
+
// This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
|
|
954
|
+
const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
|
|
955
|
+
if (cache_mode === 'data') {
|
|
956
|
+
const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
|
|
957
|
+
this.data = normalized;
|
|
958
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
959
|
+
console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Freeze this.data
|
|
963
|
+
this.__data_frozen = true;
|
|
964
|
+
// SSR data capture: record this component's data for preloading
|
|
965
|
+
if (is_capture_enabled() && this._has_on_load()) {
|
|
966
|
+
capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
|
|
967
|
+
}
|
|
968
|
+
// Calculate if data changed
|
|
969
|
+
const data_after_load = JSON.stringify(this.data);
|
|
970
|
+
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
|
|
971
|
+
// Track if component is "dynamic" (this.data changed during on_load)
|
|
972
|
+
// Used by HTML cache mode for synchronization - static parents don't block children
|
|
973
|
+
this._is_dynamic = data_changed && data_after_load !== '{}';
|
|
974
|
+
// CACHE WRITE - @see component-cache.ts
|
|
975
|
+
write_cache_on_loaded(this, this._is_dynamic);
|
|
976
|
+
this._ready_state = 2;
|
|
977
|
+
this._update_debug_attrs();
|
|
978
|
+
this._log_lifecycle('load', 'complete');
|
|
979
|
+
// Emit lifecycle event
|
|
980
|
+
this.trigger('load');
|
|
981
|
+
// Call on_loaded() - runs on the REAL component (not detached proxy)
|
|
982
|
+
// this.data is frozen (read-only), but this.$, this.state, this.args are accessible
|
|
983
|
+
// Suppressed by _load_only and _load_render_only flags (preloading mode)
|
|
984
|
+
if (!this._load_only && !this._load_render_only) {
|
|
985
|
+
await this._call_lifecycle('on_loaded');
|
|
986
|
+
this.trigger('loaded');
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
// Signal next in queue
|
|
991
|
+
resolve_my_turn();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Ready phase - Component fully initialized
|
|
996
|
+
* Called bottom-up (children before parent)
|
|
997
|
+
* @private
|
|
998
|
+
*/
|
|
999
|
+
async _ready() {
|
|
1000
|
+
if (this._stopped || this._ready_state >= 4)
|
|
1001
|
+
return;
|
|
1002
|
+
this._log_lifecycle('ready', 'start');
|
|
1003
|
+
// HTML CACHE MODE - Wait for children on_render, then snapshot
|
|
1004
|
+
// @see component-cache.ts for write_html_cache_snapshot implementation
|
|
1005
|
+
if (this._should_cache_html_after_ready && this._cache_key) {
|
|
1006
|
+
await this._wait_for_children_on_render();
|
|
1007
|
+
write_html_cache_snapshot(this);
|
|
1008
|
+
}
|
|
1009
|
+
// Wait for all children to reach ready state (bottom-up execution)
|
|
1010
|
+
await this._wait_for_children_ready();
|
|
1011
|
+
await this._call_lifecycle('on_ready');
|
|
1012
|
+
this._fire_viewport_resize();
|
|
1013
|
+
this._ready_state = 4;
|
|
1014
|
+
this._update_debug_attrs();
|
|
1015
|
+
this._log_lifecycle('ready', 'complete');
|
|
1016
|
+
// Emit lifecycle event
|
|
1017
|
+
this.trigger('ready');
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Public API: Wait for component to be fully ready
|
|
1021
|
+
* Returns a promise that resolves when the component reaches ready state.
|
|
1022
|
+
* Optionally accepts a callback that executes when ready.
|
|
1023
|
+
*
|
|
1024
|
+
* @example
|
|
1025
|
+
* // Promise pattern
|
|
1026
|
+
* await component.ready();
|
|
1027
|
+
*
|
|
1028
|
+
* @example
|
|
1029
|
+
* // Callback pattern
|
|
1030
|
+
* component.ready(() => {
|
|
1031
|
+
* console.log('Component is ready!');
|
|
1032
|
+
* });
|
|
1033
|
+
*
|
|
1034
|
+
* @example
|
|
1035
|
+
* // Both patterns work together
|
|
1036
|
+
* await component.ready(() => console.log('Callback fired'));
|
|
1037
|
+
*
|
|
1038
|
+
* @param callback Optional callback to execute when ready
|
|
1039
|
+
*/
|
|
1040
|
+
ready(callback) {
|
|
1041
|
+
// If already ready AND the ready event hasn't been invalidated, resolve immediately
|
|
1042
|
+
// Both conditions must be true: _ready_state >= 4 means we reached ready once,
|
|
1043
|
+
// _lifecycle_states.has('ready') means we haven't started a new reload/render cycle
|
|
1044
|
+
if (this._ready_state >= 4 && this._lifecycle_states.has('ready')) {
|
|
1045
|
+
if (callback)
|
|
1046
|
+
callback();
|
|
1047
|
+
return Promise.resolve();
|
|
1048
|
+
}
|
|
1049
|
+
// Return promise that resolves when ready event fires
|
|
1050
|
+
return new Promise((resolve) => {
|
|
1051
|
+
this.on('ready', () => {
|
|
1052
|
+
if (callback)
|
|
1053
|
+
callback();
|
|
1054
|
+
resolve();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Returns a promise that resolves when the component has completed its
|
|
1060
|
+
* synchronous render chain (after on_load's re-render if applicable,
|
|
1061
|
+
* or after first render if no on_load override).
|
|
1062
|
+
*
|
|
1063
|
+
* This fires BEFORE on_ready() and before children are waited on.
|
|
1064
|
+
* Use this when you need to know the component's DOM is stable but
|
|
1065
|
+
* don't need to wait for the full async ready phase.
|
|
1066
|
+
*
|
|
1067
|
+
* The 'rendered' event fires exactly once per component lifecycle.
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* await component.rendered();
|
|
1071
|
+
* // DOM is now stable, on_render has completed
|
|
1072
|
+
*
|
|
1073
|
+
* @param callback Optional callback to execute when rendered
|
|
1074
|
+
*/
|
|
1075
|
+
rendered(callback) {
|
|
1076
|
+
return new Promise((resolve) => {
|
|
1077
|
+
this.on('rendered', () => {
|
|
1078
|
+
if (callback)
|
|
1079
|
+
callback();
|
|
1080
|
+
resolve();
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Wait for all child components to reach ready state
|
|
1086
|
+
* Ensures bottom-up ordering (children ready before parent)
|
|
1087
|
+
* @private
|
|
1088
|
+
*/
|
|
1089
|
+
async _wait_for_children_ready() {
|
|
1090
|
+
// Server-rendered components (created via jqhtml.boot()) may have children
|
|
1091
|
+
// that were hydrated asynchronously during the 'render' event callback.
|
|
1092
|
+
// Those children couldn't register via _find_dom_parent() because they were
|
|
1093
|
+
// created after the parent's lifecycle started. Force DOM traversal fallback
|
|
1094
|
+
// to reliably discover all children, including boot-hydrated ones.
|
|
1095
|
+
if (this.args._inner_html !== undefined) {
|
|
1096
|
+
this._use_dom_fallback = true;
|
|
1097
|
+
}
|
|
1098
|
+
const children = this._get_dom_children();
|
|
1099
|
+
if (children.length === 0) {
|
|
1100
|
+
return; // No children, nothing to wait for
|
|
1101
|
+
}
|
|
1102
|
+
// Create promises for each child that hasn't reached ready yet
|
|
1103
|
+
const ready_promises = [];
|
|
1104
|
+
for (const child of children) {
|
|
1105
|
+
// If child already ready, skip
|
|
1106
|
+
if (child._ready_state >= 4) {
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
// Create promise that resolves when child reaches ready
|
|
1110
|
+
const ready_promise = new Promise((resolve) => {
|
|
1111
|
+
child.on('ready', () => resolve());
|
|
1112
|
+
});
|
|
1113
|
+
ready_promises.push(ready_promise);
|
|
1114
|
+
}
|
|
1115
|
+
// Wait for all children to be ready
|
|
1116
|
+
await Promise.all(ready_promises);
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Wait for all child components to complete on_render (post-on_load)
|
|
1120
|
+
* Used by HTML cache mode to ensure DOM is fully rendered before taking snapshot
|
|
1121
|
+
*
|
|
1122
|
+
* HTML CACHE ARCHITECTURE:
|
|
1123
|
+
* - Parent waits for all children to complete their on_render after on_load
|
|
1124
|
+
* - This ensures the HTML snapshot captures fully rendered DOM
|
|
1125
|
+
* - Static parents (is_dynamic=false) don't block - they immediately let children proceed
|
|
1126
|
+
*
|
|
1127
|
+
* @private
|
|
1128
|
+
*/
|
|
1129
|
+
async _wait_for_children_on_render() {
|
|
1130
|
+
const children = this._get_dom_children();
|
|
1131
|
+
if (children.length === 0) {
|
|
1132
|
+
return; // No children, nothing to wait for
|
|
1133
|
+
}
|
|
1134
|
+
// Create promises for each child that hasn't completed on_render yet
|
|
1135
|
+
const render_promises = [];
|
|
1136
|
+
for (const child of children) {
|
|
1137
|
+
// If child already completed on_render post-on_load, skip
|
|
1138
|
+
if (child._on_render_complete) {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
// Create promise that resolves when child completes on_render
|
|
1142
|
+
const render_promise = new Promise((resolve) => {
|
|
1143
|
+
// Poll for completion (simple approach - could use events for more efficiency)
|
|
1144
|
+
const check = () => {
|
|
1145
|
+
if (child._on_render_complete || child._stopped) {
|
|
1146
|
+
resolve();
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
setTimeout(check, 10);
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
check();
|
|
1153
|
+
});
|
|
1154
|
+
render_promises.push(render_promise);
|
|
1155
|
+
}
|
|
1156
|
+
// Wait for all children to complete on_render
|
|
1157
|
+
await Promise.all(render_promises);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Reload component - re-fetch data and re-render (debounced)
|
|
1161
|
+
*
|
|
1162
|
+
* This is the public API that automatically debounces calls to _reload()
|
|
1163
|
+
* Multiple rapid calls to reload() will be coalesced into a single execution
|
|
1164
|
+
*
|
|
1165
|
+
* @param always_render - If true (default), always re-render after on_load().
|
|
1166
|
+
* If false, only re-render if data actually changed.
|
|
1167
|
+
*/
|
|
1168
|
+
async reload(always_render) {
|
|
1169
|
+
// Load-gate short-circuit: if a gate is currently holding the first on_load,
|
|
1170
|
+
// reload()/refresh() resumes the paused boot lifecycle instead of running its
|
|
1171
|
+
// normal debounced re-fetch. The boot lifecycle then proceeds to run on_load
|
|
1172
|
+
// (the fresh fetch) exactly as reload() would want. Any later gate settlement
|
|
1173
|
+
// becomes a no-op. Return a promise that resolves when the component is ready.
|
|
1174
|
+
if (this.__gate_resume) {
|
|
1175
|
+
const resume = this.__gate_resume;
|
|
1176
|
+
this.__gate_resume = null;
|
|
1177
|
+
resume();
|
|
1178
|
+
return this.ready();
|
|
1179
|
+
}
|
|
1180
|
+
// Default to true if undefined
|
|
1181
|
+
const force_refresh = always_render !== undefined ? always_render : true;
|
|
1182
|
+
// State machine: reload(true) takes precedence over reload(false)
|
|
1183
|
+
if (force_refresh) {
|
|
1184
|
+
this.next_reload_force_refresh = true;
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
// Only set to false if not already true
|
|
1188
|
+
if (this.next_reload_force_refresh !== true) {
|
|
1189
|
+
this.next_reload_force_refresh = false;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Enqueue through component queue (replaces _create_debounced_function)
|
|
1193
|
+
// Same-type pending operations collapse (multiple reload() calls share one execution)
|
|
1194
|
+
return this._queue.enqueue('reload', () => this._reload());
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Refresh component - re-fetch data and re-render only if data changed (debounced)
|
|
1198
|
+
*
|
|
1199
|
+
* Similar to reload() but only re-renders if the data actually changed after on_load().
|
|
1200
|
+
* Useful for checking server for updates without forcing unnecessary re-renders.
|
|
1201
|
+
*
|
|
1202
|
+
* Uses the same debouncing as reload() and plays nice with it - if reload() is called
|
|
1203
|
+
* while refresh() is queued, reload() takes precedence and will always render.
|
|
1204
|
+
*/
|
|
1205
|
+
async refresh() {
|
|
1206
|
+
return this.reload(false);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Internal reload implementation - re-fetch data and re-render
|
|
1210
|
+
*
|
|
1211
|
+
* COMPLETE RELOAD PROCESS (Source of Truth):
|
|
1212
|
+
*
|
|
1213
|
+
* STEP 1: Cache check (if args changed since last render)
|
|
1214
|
+
* - Generate cache key from component name + current args
|
|
1215
|
+
* - If cached data exists and is non-empty:
|
|
1216
|
+
* - Unfreeze this.data (temporarily)
|
|
1217
|
+
* - Hydrate this.data with cached data
|
|
1218
|
+
* - Re-freeze this.data
|
|
1219
|
+
* - Render immediately (stale-while-revalidate)
|
|
1220
|
+
* - Set rendered_from_cache flag
|
|
1221
|
+
*
|
|
1222
|
+
* STEP 2: Call on_load() to fetch fresh data
|
|
1223
|
+
* - Unfreeze this.data
|
|
1224
|
+
* - Restore this.data to on_create() snapshot
|
|
1225
|
+
* - Call on_load() directly (no _load() wrapper)
|
|
1226
|
+
* - Freeze this.data after completion
|
|
1227
|
+
* - If data changed and non-empty: write to cache
|
|
1228
|
+
*
|
|
1229
|
+
* STEP 3: Conditionally re-render
|
|
1230
|
+
* - If didn't render from cache yet, OR data changed after on_load():
|
|
1231
|
+
* - Call render() to update DOM
|
|
1232
|
+
*
|
|
1233
|
+
* STEP 3.5: Wait for all children to be ready
|
|
1234
|
+
* - Bottom-up ordering (children ready before parent)
|
|
1235
|
+
* - Same as main lifecycle
|
|
1236
|
+
*
|
|
1237
|
+
* STEP 4: Call on_ready()
|
|
1238
|
+
* - Always call on_ready() after reload completes
|
|
1239
|
+
*
|
|
1240
|
+
* @private - Use reload() instead (debounced wrapper)
|
|
1241
|
+
*/
|
|
1242
|
+
async _reload() {
|
|
1243
|
+
if (this._stopped)
|
|
1244
|
+
return;
|
|
1245
|
+
// Invalidate ready event so new handlers wait for this reload to complete
|
|
1246
|
+
// This prevents .on('ready') handlers from firing immediately based on previous lifecycle
|
|
1247
|
+
this.invalidate('ready');
|
|
1248
|
+
this._log_lifecycle('reload', 'start');
|
|
1249
|
+
// OPTIMIZATION: If no custom on_load(), skip data fetching entirely
|
|
1250
|
+
// Just re-render with current data and call on_ready
|
|
1251
|
+
if (!this.__has_custom_on_load) {
|
|
1252
|
+
// Reset state machine
|
|
1253
|
+
this.next_reload_force_refresh = null;
|
|
1254
|
+
this._render();
|
|
1255
|
+
await this._wait_for_children_ready();
|
|
1256
|
+
await this._call_lifecycle('on_ready');
|
|
1257
|
+
this._fire_viewport_resize();
|
|
1258
|
+
this.trigger('ready');
|
|
1259
|
+
this._log_lifecycle('reload', 'complete (no on_load)');
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
// STEP 1: Cache check (if args changed)
|
|
1263
|
+
let rendered_from_cache = false;
|
|
1264
|
+
let data_before_load = null;
|
|
1265
|
+
// Check if args changed (skip if args are non-serializable)
|
|
1266
|
+
let args_changed = false;
|
|
1267
|
+
if (this._args_on_last_render) {
|
|
1268
|
+
try {
|
|
1269
|
+
args_changed = JSON.stringify(this.args) !== JSON.stringify(this._args_on_last_render);
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
// Args contain circular references - cannot detect change, assume changed
|
|
1273
|
+
args_changed = true;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (args_changed) {
|
|
1277
|
+
// @see component-cache.ts for full implementation
|
|
1278
|
+
rendered_from_cache = check_cache_on_reload(this);
|
|
1279
|
+
}
|
|
1280
|
+
// Capture data state before on_load for comparison
|
|
1281
|
+
data_before_load = JSON.stringify(this.data);
|
|
1282
|
+
// STEP 2: Call on_load() on detached proxy
|
|
1283
|
+
// This uses the same detached execution as _load() - on_load runs isolated
|
|
1284
|
+
// and result is applied via sequential queue
|
|
1285
|
+
const { data: result_data } = await this._execute_on_load_detached(false); // false = don't use Load_Coordinator
|
|
1286
|
+
// Apply result via sequential queue
|
|
1287
|
+
// This handles normalization, caching, and setting _is_dynamic
|
|
1288
|
+
await this._apply_load_result(result_data, data_before_load);
|
|
1289
|
+
// Re-read data state after apply (for render decision below)
|
|
1290
|
+
const data_after_load = JSON.stringify(this.data);
|
|
1291
|
+
const data_changed = data_after_load !== data_before_load;
|
|
1292
|
+
// STEP 3: Conditionally re-render (with refresh() support)
|
|
1293
|
+
// Read force_refresh from state machine (default true)
|
|
1294
|
+
const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
|
|
1295
|
+
// Track if we need to render
|
|
1296
|
+
let should_render = false;
|
|
1297
|
+
if (force_refresh) {
|
|
1298
|
+
// reload(true) or reload() - always render if we haven't yet, OR data changed
|
|
1299
|
+
should_render = !rendered_from_cache || data_changed;
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
// refresh() / reload(false) - only render if data changed from last render
|
|
1303
|
+
if (rendered_from_cache) {
|
|
1304
|
+
// First render was called (args changed, cache available)
|
|
1305
|
+
// Compare data after on_load() vs data used in that first render
|
|
1306
|
+
const data_from_first_render = this._data_on_last_render;
|
|
1307
|
+
should_render = data_after_load !== data_from_first_render;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
// First render was NOT called (args same or no cache)
|
|
1311
|
+
// Compare data after on_load() vs last recorded render
|
|
1312
|
+
const last_rendered_data = this._data_on_last_render;
|
|
1313
|
+
should_render = data_after_load !== last_rendered_data;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// STEP 3: Perform second render if needed
|
|
1317
|
+
if (should_render) {
|
|
1318
|
+
this._render();
|
|
1319
|
+
}
|
|
1320
|
+
// Reset state machine based on what we just executed
|
|
1321
|
+
if (force_refresh === false && this.next_reload_force_refresh === false) {
|
|
1322
|
+
this.next_reload_force_refresh = null;
|
|
1323
|
+
}
|
|
1324
|
+
else if (force_refresh === true && this.next_reload_force_refresh === true) {
|
|
1325
|
+
this.next_reload_force_refresh = null;
|
|
1326
|
+
}
|
|
1327
|
+
// If they don't match, another call was queued - leave it
|
|
1328
|
+
// STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
|
|
1329
|
+
if (rendered_from_cache || should_render) {
|
|
1330
|
+
await this._wait_for_children_ready();
|
|
1331
|
+
await this._call_lifecycle('on_ready');
|
|
1332
|
+
this._fire_viewport_resize();
|
|
1333
|
+
// Trigger ready event so parent callbacks fire on subsequent reloads
|
|
1334
|
+
this.trigger('ready');
|
|
1335
|
+
}
|
|
1336
|
+
this._log_lifecycle('reload', 'complete');
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Destroy the component and cleanup
|
|
1340
|
+
* Called automatically by MutationObserver when component is removed from DOM
|
|
1341
|
+
* Can also be called manually for explicit cleanup
|
|
1342
|
+
*/
|
|
1343
|
+
/**
|
|
1344
|
+
* Internal stop method - stops just this component (no children)
|
|
1345
|
+
* Sets stopped flag, calls lifecycle hooks, but leaves DOM intact
|
|
1346
|
+
* @private
|
|
1347
|
+
*/
|
|
1348
|
+
_stop() {
|
|
1349
|
+
// Guard: prevent double _stop() calls
|
|
1350
|
+
if (this._stopped)
|
|
1351
|
+
return;
|
|
1352
|
+
this._stopped = true;
|
|
1353
|
+
// If a load gate is holding the first on_load, release the wait so the boot
|
|
1354
|
+
// lifecycle unwinds cleanly (it will see _stopped and abandon the load).
|
|
1355
|
+
if (this.__gate_resume) {
|
|
1356
|
+
const resume = this.__gate_resume;
|
|
1357
|
+
this.__gate_resume = null;
|
|
1358
|
+
resume();
|
|
1359
|
+
}
|
|
1360
|
+
// Early bailout: skip expensive cleanup if no handlers registered
|
|
1361
|
+
// Only matters for aborting boot() lifecycle - minimal cleanup sufficient
|
|
1362
|
+
const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
|
|
1363
|
+
const has_stop_callbacks = this._on_registered('stop');
|
|
1364
|
+
if (!has_custom_stop && !has_stop_callbacks) {
|
|
1365
|
+
// Fast path: no cleanup logic defined, just mark as stopped
|
|
1366
|
+
this._lifecycle_manager.unregister_component(this);
|
|
1367
|
+
this._ready_state = 99;
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
// Full cleanup path: component has custom stop logic
|
|
1371
|
+
this._log_lifecycle('destroy', 'start');
|
|
1372
|
+
this.$.addClass('_Component_Stopped');
|
|
1373
|
+
// Unregister from lifecycle manager
|
|
1374
|
+
this._lifecycle_manager.unregister_component(this);
|
|
1375
|
+
// Call user's on_stop() hook with authorization (sync)
|
|
1376
|
+
const stopResult = this._call_lifecycle_sync('on_stop');
|
|
1377
|
+
if (stopResult && typeof stopResult.then === 'function') {
|
|
1378
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
|
|
1379
|
+
`on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
1380
|
+
}
|
|
1381
|
+
// Fire registered stop callbacks
|
|
1382
|
+
this.trigger('stop');
|
|
1383
|
+
// Remove from DOM parent's children
|
|
1384
|
+
if (this._dom_parent) {
|
|
1385
|
+
this._dom_parent._dom_children.delete(this);
|
|
1386
|
+
}
|
|
1387
|
+
this._ready_state = 99;
|
|
1388
|
+
this._update_debug_attrs();
|
|
1389
|
+
this._log_lifecycle('destroy', 'complete');
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Stop component lifecycle - stops all descendant components then self
|
|
1393
|
+
* Leaves DOM intact, just stops lifecycle engine and fires cleanup hooks
|
|
1394
|
+
*/
|
|
1395
|
+
stop() {
|
|
1396
|
+
// Stop all descendant components (flat iteration, no recursion needed)
|
|
1397
|
+
this.$.find('.Component').each(function () {
|
|
1398
|
+
const child = $(this).data('_component');
|
|
1399
|
+
if (child && !child._stopped) {
|
|
1400
|
+
child._stop(); // Not recursive - we already have all descendants
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
// Then stop self
|
|
1404
|
+
this._stop();
|
|
1405
|
+
}
|
|
1406
|
+
// -------------------------------------------------------------------------
|
|
1407
|
+
// Overridable Lifecycle Hooks
|
|
1408
|
+
// -------------------------------------------------------------------------
|
|
1409
|
+
on_render() { }
|
|
1410
|
+
on_create() { }
|
|
1411
|
+
on_load() { } // Override to fetch data asynchronously
|
|
1412
|
+
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
|
|
1413
|
+
async on_ready() { }
|
|
1414
|
+
on_stop() { }
|
|
1415
|
+
/**
|
|
1416
|
+
* Called with the viewport width in CSS pixels:
|
|
1417
|
+
* - after every on_render()
|
|
1418
|
+
* - after every on_ready()
|
|
1419
|
+
* - on window resize, debounced 30ms, for every component in the document
|
|
1420
|
+
*
|
|
1421
|
+
* MUST be synchronous. Prefer CSS media/container queries for pure styling -
|
|
1422
|
+
* override this only when layout requires imperative measurement.
|
|
1423
|
+
*/
|
|
1424
|
+
on_viewport_resize(viewport_width) { }
|
|
1425
|
+
/**
|
|
1426
|
+
* Should component re-render after load?
|
|
1427
|
+
* By default, only re-renders if data has changed
|
|
1428
|
+
* Override to control re-rendering behavior
|
|
1429
|
+
*/
|
|
1430
|
+
/**
|
|
1431
|
+
* Internal method to determine if component should re-render after on_load()
|
|
1432
|
+
* @private
|
|
1433
|
+
*/
|
|
1434
|
+
_should_rerender() {
|
|
1435
|
+
// HTML CACHE MODE - If we used cached HTML, always re-render to get live components
|
|
1436
|
+
if (this._used_cached_html) {
|
|
1437
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
1438
|
+
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) forcing re-render after cached HTML`);
|
|
1439
|
+
}
|
|
1440
|
+
// Clear the flag
|
|
1441
|
+
this._used_cached_html = false;
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
// OPTIMIZATION: If no custom on_load(), data cannot change, skip serialization
|
|
1445
|
+
// Data is frozen after on_create() and only unfrozen during on_load()
|
|
1446
|
+
if (!this.__has_custom_on_load) {
|
|
1447
|
+
return false;
|
|
1448
|
+
}
|
|
1449
|
+
// Compare current data state with data state before initial render
|
|
1450
|
+
const currentDataState = JSON.stringify(this.data);
|
|
1451
|
+
const dataChanged = this._data_before_render !== currentDataState;
|
|
1452
|
+
// Update stored state for next comparison
|
|
1453
|
+
if (dataChanged) {
|
|
1454
|
+
this._data_before_render = currentDataState;
|
|
1455
|
+
}
|
|
1456
|
+
return dataChanged;
|
|
1457
|
+
}
|
|
1458
|
+
// -------------------------------------------------------------------------
|
|
1459
|
+
// Public API
|
|
1460
|
+
// -------------------------------------------------------------------------
|
|
1461
|
+
/**
|
|
1462
|
+
* Get component name for debugging
|
|
1463
|
+
*/
|
|
1464
|
+
component_name() {
|
|
1465
|
+
return this.constructor.name;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Register event callback - delegates to component-events.ts
|
|
1469
|
+
* @see component-events.ts for full documentation
|
|
1470
|
+
*/
|
|
1471
|
+
on(event_name, callback) {
|
|
1472
|
+
return event_on(this, event_name, callback);
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Register a callback that fires exactly once - delegates to component-events.ts
|
|
1476
|
+
* If the event already occurred, fires immediately and does not register.
|
|
1477
|
+
* @see component-events.ts for full documentation
|
|
1478
|
+
*/
|
|
1479
|
+
once(event_name, callback) {
|
|
1480
|
+
return event_once(this, event_name, callback);
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Trigger a lifecycle event - delegates to component-events.ts
|
|
1484
|
+
* @see component-events.ts for full documentation
|
|
1485
|
+
*/
|
|
1486
|
+
trigger(event_name, data) {
|
|
1487
|
+
event_trigger(this, event_name, data);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Check if any callbacks are registered for a given event
|
|
1491
|
+
*/
|
|
1492
|
+
_on_registered(event_name) {
|
|
1493
|
+
return event_on_registered(this, event_name);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Invalidate a lifecycle event - delegates to component-events.ts
|
|
1497
|
+
* @see component-events.ts for full documentation
|
|
1498
|
+
*/
|
|
1499
|
+
invalidate(event_name) {
|
|
1500
|
+
event_invalidate(this, event_name);
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Find element by scoped ID
|
|
1504
|
+
*
|
|
1505
|
+
* Searches for elements with id="local_id:THIS_COMPONENT_CID"
|
|
1506
|
+
*
|
|
1507
|
+
* Example:
|
|
1508
|
+
* Template: <button $sid="save_btn">Save</button>
|
|
1509
|
+
* Rendered: <button id="save_btn:abc123" data-sid="save_btn">Save</button>
|
|
1510
|
+
* Access: this.$sid('save_btn') // Returns jQuery element
|
|
1511
|
+
*
|
|
1512
|
+
* Performance: Uses native document.getElementById() when component is in DOM,
|
|
1513
|
+
* falls back to jQuery.find() for components not yet attached to DOM.
|
|
1514
|
+
*
|
|
1515
|
+
* @param local_id The local ID (without _cid suffix)
|
|
1516
|
+
* @returns jQuery element with id="local_id:_cid", or empty jQuery object if not found
|
|
1517
|
+
*/
|
|
1518
|
+
$sid(local_id) {
|
|
1519
|
+
const scopedId = `${local_id}:${this._cid}`;
|
|
1520
|
+
// Try getElementById first (fast path - works when component is in DOM)
|
|
1521
|
+
const el = document.getElementById(scopedId);
|
|
1522
|
+
if (el) {
|
|
1523
|
+
return $(el);
|
|
1524
|
+
}
|
|
1525
|
+
// Fallback: component not in DOM yet, search within component subtree
|
|
1526
|
+
// This allows $sid() to work on components before they're appended to body
|
|
1527
|
+
// Must escape the ID because it contains ':' which jQuery treats as a pseudo-selector
|
|
1528
|
+
return this.$.find(`#${$.escapeSelector(scopedId)}`);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Get component instance by scoped ID
|
|
1532
|
+
*
|
|
1533
|
+
* Convenience method that finds element by scoped ID and returns the component instance.
|
|
1534
|
+
*
|
|
1535
|
+
* Example:
|
|
1536
|
+
* Template: <User_Card $sid="active_user" />
|
|
1537
|
+
* Access: const user = this.sid('active_user'); // Returns User_Card instance
|
|
1538
|
+
* user.data.name // Access component's data
|
|
1539
|
+
*
|
|
1540
|
+
* To get the scoped ID string itself:
|
|
1541
|
+
* this.$sid('active_user').attr('id') // Returns "active_user:abc123xyz"
|
|
1542
|
+
*
|
|
1543
|
+
* @param local_id The local ID (without _cid suffix)
|
|
1544
|
+
* @returns Component instance or null if not found or not a component
|
|
1545
|
+
*/
|
|
1546
|
+
sid(local_id) {
|
|
1547
|
+
const element = this.$sid(local_id);
|
|
1548
|
+
const component = element.data('_component');
|
|
1549
|
+
// If no component found but element exists, warn developer
|
|
1550
|
+
if (!component && element.length > 0) {
|
|
1551
|
+
console.warn(`Component ${this.constructor.name} tried to call .sid('${local_id}') - ` +
|
|
1552
|
+
`${local_id} exists, however, it is not a component or $redrawable. ` +
|
|
1553
|
+
`Did you forget to add $redrawable to the tag?`);
|
|
1554
|
+
}
|
|
1555
|
+
return component || null;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Get the component that instantiated this component (rendered it in their template)
|
|
1559
|
+
* Returns null if component was created programmatically via $().component()
|
|
1560
|
+
*/
|
|
1561
|
+
instantiator() {
|
|
1562
|
+
return this._instantiator;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Find descendant components by CSS selector
|
|
1566
|
+
*/
|
|
1567
|
+
find(selector) {
|
|
1568
|
+
const components = [];
|
|
1569
|
+
this.$.find(selector).each((_, el) => {
|
|
1570
|
+
const comp = $(el).data('_component');
|
|
1571
|
+
if (comp instanceof Jqhtml_Component) {
|
|
1572
|
+
components.push(comp);
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
return components;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Find closest ancestor component matching selector
|
|
1579
|
+
*
|
|
1580
|
+
* A bare component name - a plain identifier starting with a capital letter,
|
|
1581
|
+
* such as 'Parent_Dashboard' - is treated as the component-name class
|
|
1582
|
+
* selector '.Parent_Dashboard'. Components carry a CSS class for every name
|
|
1583
|
+
* in their prototype chain, so a base class name matches its subclasses too.
|
|
1584
|
+
* Every other selector ('.Foo', '#id', '[attr]', 'div > .Component') is
|
|
1585
|
+
* passed to jQuery unchanged.
|
|
1586
|
+
*/
|
|
1587
|
+
closest(selector) {
|
|
1588
|
+
const search = Jqhtml_Component.COMPONENT_NAME_PATTERN.test(selector)
|
|
1589
|
+
? `.${selector}`
|
|
1590
|
+
: selector;
|
|
1591
|
+
let current = this.$.parent();
|
|
1592
|
+
while (current.length > 0) {
|
|
1593
|
+
if (current.is(search)) {
|
|
1594
|
+
const comp = current.data('_component');
|
|
1595
|
+
if (comp instanceof Jqhtml_Component) {
|
|
1596
|
+
return comp;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
current = current.parent();
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
// -------------------------------------------------------------------------
|
|
1604
|
+
// Static Methods
|
|
1605
|
+
// -------------------------------------------------------------------------
|
|
1606
|
+
/**
|
|
1607
|
+
* Get CSS class hierarchy for this component type
|
|
1608
|
+
*/
|
|
1609
|
+
static get_class_hierarchy() {
|
|
1610
|
+
// v2.2.13 - Fixed to handle undefined values in prototype chain
|
|
1611
|
+
const classes = [];
|
|
1612
|
+
let ctor = this;
|
|
1613
|
+
while (ctor) {
|
|
1614
|
+
// Check if constructor has a valid name property
|
|
1615
|
+
if (!ctor.name || typeof ctor.name !== 'string') {
|
|
1616
|
+
// console.warn('[JQHTML v2.2.13] Invalid constructor name in hierarchy:', ctor);
|
|
1617
|
+
break;
|
|
1618
|
+
}
|
|
1619
|
+
// Only add valid class names (non-empty strings, not 'Object')
|
|
1620
|
+
if (ctor.name !== 'Object' && ctor.name !== '') {
|
|
1621
|
+
// Normalize base class names - handle different import patterns
|
|
1622
|
+
let normalizedName = ctor.name;
|
|
1623
|
+
if (normalizedName === '_Jqhtml_Component' || normalizedName === '_Base_Jqhtml_Component') {
|
|
1624
|
+
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
1625
|
+
}
|
|
1626
|
+
else if (normalizedName === 'Jqhtml_Component') {
|
|
1627
|
+
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
1628
|
+
}
|
|
1629
|
+
classes.push(normalizedName);
|
|
1630
|
+
}
|
|
1631
|
+
// Get the next prototype
|
|
1632
|
+
const nextProto = Object.getPrototypeOf(ctor);
|
|
1633
|
+
// Stop if we've reached the end of the chain
|
|
1634
|
+
if (!nextProto || nextProto === Object.prototype || nextProto.constructor === Object) {
|
|
1635
|
+
break;
|
|
1636
|
+
}
|
|
1637
|
+
ctor = nextProto;
|
|
1638
|
+
}
|
|
1639
|
+
return classes;
|
|
1640
|
+
}
|
|
1641
|
+
// -------------------------------------------------------------------------
|
|
1642
|
+
// Private Implementation
|
|
1643
|
+
// -------------------------------------------------------------------------
|
|
1644
|
+
_generate_cid() {
|
|
1645
|
+
return uid();
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Flatten instruction array - converts ['_content', [...]] markers to flat array
|
|
1649
|
+
* Recursively flattens nested content from content() calls
|
|
1650
|
+
*/
|
|
1651
|
+
_flatten_instructions(instructions) {
|
|
1652
|
+
const result = [];
|
|
1653
|
+
for (const instruction of instructions) {
|
|
1654
|
+
// Check if this is a _content marker: ['_content', [...]]
|
|
1655
|
+
if (Array.isArray(instruction) && instruction[0] === '_content' && Array.isArray(instruction[1])) {
|
|
1656
|
+
// Recursively flatten the content instructions
|
|
1657
|
+
const contentInstructions = this._flatten_instructions(instruction[1]);
|
|
1658
|
+
result.push(...contentInstructions);
|
|
1659
|
+
}
|
|
1660
|
+
else {
|
|
1661
|
+
// Regular instruction - keep as is
|
|
1662
|
+
result.push(instruction);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return result;
|
|
1666
|
+
}
|
|
1667
|
+
_apply_css_classes() {
|
|
1668
|
+
const hierarchy = this.constructor.get_class_hierarchy();
|
|
1669
|
+
// If component name differs from class name, add component name to hierarchy
|
|
1670
|
+
// This happens when:
|
|
1671
|
+
// 1. Using base Jqhtml_Component with _component_name
|
|
1672
|
+
// 2. Using parent class via extends chain (e.g., Contacts_DataGrid using DataGrid_Abstract class)
|
|
1673
|
+
const classesToAdd = [...hierarchy];
|
|
1674
|
+
if (this.args._component_name && this.args._component_name !== this.constructor.name) {
|
|
1675
|
+
// Add component name at the beginning (most specific)
|
|
1676
|
+
classesToAdd.unshift(this.args._component_name);
|
|
1677
|
+
}
|
|
1678
|
+
// Filter out private classes (starting with _) and invalid class names
|
|
1679
|
+
const publicClasses = classesToAdd.filter(className => {
|
|
1680
|
+
// Guard against undefined, null, or non-string values
|
|
1681
|
+
if (!className || typeof className !== 'string') {
|
|
1682
|
+
console.warn('[JQHTML] Filtered out invalid class name:', className);
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
return !className.startsWith('_');
|
|
1686
|
+
});
|
|
1687
|
+
if (publicClasses.length > 0) {
|
|
1688
|
+
this.$.addClass(publicClasses.join(' '));
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
_apply_default_attributes() {
|
|
1692
|
+
// Get template using same logic as render() method
|
|
1693
|
+
let template;
|
|
1694
|
+
// If we have a component name, try looking up by name first (for unregistered components)
|
|
1695
|
+
if (this.args._component_name) {
|
|
1696
|
+
template = get_template(this.args._component_name);
|
|
1697
|
+
}
|
|
1698
|
+
else {
|
|
1699
|
+
// Otherwise look up by class
|
|
1700
|
+
template = get_template_by_class(this.constructor);
|
|
1701
|
+
}
|
|
1702
|
+
if (!template)
|
|
1703
|
+
return;
|
|
1704
|
+
// Walk the extends chain to collect all defaultAttributes from parent templates
|
|
1705
|
+
// Apply from parent to child so child attributes take precedence
|
|
1706
|
+
const templateChain = [];
|
|
1707
|
+
let currentTemplate = template;
|
|
1708
|
+
// Build chain from child to parent
|
|
1709
|
+
while (currentTemplate) {
|
|
1710
|
+
templateChain.unshift(currentTemplate); // Add to front so parent comes first
|
|
1711
|
+
// Check if this template extends another
|
|
1712
|
+
if (currentTemplate.extends) {
|
|
1713
|
+
try {
|
|
1714
|
+
currentTemplate = get_template(currentTemplate.extends);
|
|
1715
|
+
}
|
|
1716
|
+
catch (error) {
|
|
1717
|
+
// Parent template not found, stop chain
|
|
1718
|
+
break;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
else {
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
// Apply defaultAttributes from each template in the chain (parent first, child last)
|
|
1726
|
+
for (const tmpl of templateChain) {
|
|
1727
|
+
if (!tmpl.defaultAttributes)
|
|
1728
|
+
continue;
|
|
1729
|
+
// Filter out 'tag' since it determines tag name, not an attribute
|
|
1730
|
+
const defineAttrs = { ...tmpl.defaultAttributes };
|
|
1731
|
+
delete defineAttrs.tag;
|
|
1732
|
+
// Debug logging
|
|
1733
|
+
if (window.jqhtml?.debug?.enabled) {
|
|
1734
|
+
const componentName = tmpl.name || this.args._component_name || this.constructor.name;
|
|
1735
|
+
console.log(`[Component] Applying defaultAttributes for ${componentName}:`, defineAttrs);
|
|
1736
|
+
}
|
|
1737
|
+
// Apply each default attribute
|
|
1738
|
+
for (const [key, value] of Object.entries(defineAttrs)) {
|
|
1739
|
+
if (key === 'class') {
|
|
1740
|
+
// Special handling for class - merge with existing
|
|
1741
|
+
const existingClasses = this.$.attr('class');
|
|
1742
|
+
if (existingClasses) {
|
|
1743
|
+
const existing = existingClasses.split(/\s+/).filter(c => c);
|
|
1744
|
+
const newClasses = String(value).split(/\s+/).filter(c => c);
|
|
1745
|
+
for (const newClass of newClasses) {
|
|
1746
|
+
if (!existing.includes(newClass)) {
|
|
1747
|
+
existing.push(newClass);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
this.$.attr('class', existing.join(' '));
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
this.$.attr('class', value);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
else if (key === 'style') {
|
|
1757
|
+
// Special handling for style - merge with existing
|
|
1758
|
+
// Define attributes are applied AFTER invocation attributes
|
|
1759
|
+
// So we only add Define style properties that don't already exist
|
|
1760
|
+
// This ensures invocation wins conflicts (invocation overrides Define)
|
|
1761
|
+
const existingStyle = this.$.attr('style');
|
|
1762
|
+
if (existingStyle) {
|
|
1763
|
+
// Parse existing styles (from invocation)
|
|
1764
|
+
const existingRules = new Map();
|
|
1765
|
+
existingStyle.split(';').forEach(rule => {
|
|
1766
|
+
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
1767
|
+
if (prop && val)
|
|
1768
|
+
existingRules.set(prop, val);
|
|
1769
|
+
});
|
|
1770
|
+
// Parse Define styles and only add if not already present
|
|
1771
|
+
String(value).split(';').forEach(rule => {
|
|
1772
|
+
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
1773
|
+
if (prop && val && !existingRules.has(prop)) {
|
|
1774
|
+
// Only add if property doesn't exist (invocation wins)
|
|
1775
|
+
existingRules.set(prop, val);
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
// Build merged style string
|
|
1779
|
+
const merged = Array.from(existingRules.entries())
|
|
1780
|
+
.map(([prop, val]) => `${prop}: ${val}`)
|
|
1781
|
+
.join('; ');
|
|
1782
|
+
this.$.attr('style', merged);
|
|
1783
|
+
}
|
|
1784
|
+
else {
|
|
1785
|
+
this.$.attr('style', value);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
else if (key.startsWith('$') || key.startsWith('data-')) {
|
|
1789
|
+
// Data attributes - also update args
|
|
1790
|
+
const dataKey = key.startsWith('$') ? key.substring(1) :
|
|
1791
|
+
key.startsWith('data-') ? key.substring(5) : key;
|
|
1792
|
+
// Only apply if not already set in args
|
|
1793
|
+
if (!(dataKey in this.args)) {
|
|
1794
|
+
this.args[dataKey] = value;
|
|
1795
|
+
this.$.data(dataKey, value);
|
|
1796
|
+
this.$.attr(key.startsWith('$') ? `data-${dataKey}` : key, String(value));
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
else {
|
|
1800
|
+
// Regular attributes - apply directly if not already set
|
|
1801
|
+
if (!this.$.attr(key)) {
|
|
1802
|
+
this.$.attr(key, value);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
_set_attributes() {
|
|
1809
|
+
// data-cid on the rendered element is debug-only: it mirrors this._cid so component
|
|
1810
|
+
// boundaries are legible in DevTools. The runtime never reads it - scoping uses the
|
|
1811
|
+
// _cid property, and the transient data-cid placeholder the instruction processor
|
|
1812
|
+
// matches on is written and removed before this ever runs.
|
|
1813
|
+
if (debug_attributes_enabled()) {
|
|
1814
|
+
this.$.attr('data-cid', this._cid);
|
|
1815
|
+
}
|
|
1816
|
+
// Only set lifecycle state attribute if verbose mode enabled
|
|
1817
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
1818
|
+
this.$.attr('data-_lifecycle-state', this._ready_state.toString());
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
_update_debug_attrs() {
|
|
1822
|
+
// Only update lifecycle state attribute if verbose mode enabled
|
|
1823
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
1824
|
+
this.$.attr('data-_lifecycle-state', this._ready_state.toString());
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
_find_dom_parent() {
|
|
1828
|
+
let current = this.$.parent();
|
|
1829
|
+
while (current.length > 0) {
|
|
1830
|
+
const parent = current.data('_component');
|
|
1831
|
+
if (parent instanceof Jqhtml_Component) {
|
|
1832
|
+
this._dom_parent = parent;
|
|
1833
|
+
parent._dom_children.add(this);
|
|
1834
|
+
break;
|
|
1835
|
+
}
|
|
1836
|
+
current = current.parent();
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Get DOM children (components in DOM subtree)
|
|
1841
|
+
* Uses fast _dom_children registry when possible, falls back to DOM traversal for off-DOM components
|
|
1842
|
+
* @private - Used internally for lifecycle coordination
|
|
1843
|
+
*/
|
|
1844
|
+
_get_dom_children() {
|
|
1845
|
+
// If component was off-DOM during render, children couldn't register via _find_dom_parent()
|
|
1846
|
+
// Use DOM traversal to find direct children (works off-DOM, slightly slower)
|
|
1847
|
+
if (this._use_dom_fallback) {
|
|
1848
|
+
const directChildren = [];
|
|
1849
|
+
this.$.find('.Component').each((_, el) => {
|
|
1850
|
+
const $el = $(el);
|
|
1851
|
+
const comp = $el.data('_component');
|
|
1852
|
+
if (comp instanceof Jqhtml_Component) {
|
|
1853
|
+
// Only include if this component is the direct parent (not a grandparent)
|
|
1854
|
+
// Check: closest parent component is us (or no parent = we're the root)
|
|
1855
|
+
const closestParent = $el.parent().closest('.Component');
|
|
1856
|
+
if (closestParent.length === 0 || closestParent.data('_component') === this) {
|
|
1857
|
+
directChildren.push(comp);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
return directChildren;
|
|
1862
|
+
}
|
|
1863
|
+
// Fast path: component was in-DOM during render, children registered normally
|
|
1864
|
+
// Filter out any that have since been removed from DOM (destroyed/replaced)
|
|
1865
|
+
const children = Array.from(this._dom_children);
|
|
1866
|
+
return children.filter(child => {
|
|
1867
|
+
return $.contains(document.documentElement, child.$[0]);
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
_log_lifecycle(phase, status) {
|
|
1871
|
+
// Use new debug module
|
|
1872
|
+
logLifecycle(this, phase, status);
|
|
1873
|
+
// Keep legacy support for window.JQHTML_DEBUG
|
|
1874
|
+
if (typeof window !== 'undefined' && window.JQHTML_DEBUG) {
|
|
1875
|
+
window.JQHTML_DEBUG.log(this.component_name(), phase, status, {
|
|
1876
|
+
cid: this._cid,
|
|
1877
|
+
ready_state: this._ready_state,
|
|
1878
|
+
args: this.args
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
_log_debug(action, ...args) {
|
|
1883
|
+
if (typeof window !== 'undefined' && window.JQHTML_DEBUG) {
|
|
1884
|
+
window.JQHTML_DEBUG.log(this.component_name(), 'debug', `${action}: ${args.map(a => JSON.stringify(a)).join(', ')}`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
// Static properties
|
|
1889
|
+
Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() detection
|
|
1890
|
+
// A bare component name, as accepted by closest() in place of a class selector
|
|
1891
|
+
Jqhtml_Component.COMPONENT_NAME_PATTERN = /^[A-Z][A-Za-z0-9_]*$/;
|
|
1892
|
+
//# sourceMappingURL=component.js.map
|