@jqhtml/core 2.3.35 → 2.3.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -34,11 +34,9 @@ class LifecycleManager {
34
34
  * Boot a component - run its full lifecycle
35
35
  * Called when component is created
36
36
  *
37
- * Supports lifecycle skip flags:
38
- * - skip_render_and_ready: Skip first render/on_render and skip on_ready entirely.
39
- * Component reports ready after on_load completes.
40
- * - skip_ready: Skip on_ready only.
41
- * Component reports ready after on_render completes (after potential re-render from on_load).
37
+ * Supports lifecycle truncation flags:
38
+ * - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
39
+ * - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
42
40
  */
43
41
  async boot_component(component) {
44
42
  this.active_components.add(component);
@@ -50,20 +48,25 @@ class LifecycleManager {
50
48
  return;
51
49
  // Trigger create event
52
50
  component.trigger('create');
53
- // Check for skip_render_and_ready flag
54
- const skip_render_and_ready = component._skip_render_and_ready;
55
- const skip_ready = component._skip_ready;
51
+ // Check for lifecycle truncation flags
52
+ const load_only = component._load_only;
53
+ const load_render_only = component._load_render_only;
54
+ const is_detached = component._is_detached;
56
55
  let render_id;
57
- if (skip_render_and_ready) {
58
- // Skip the first render/on_render before on_load
59
- // Still need to set render_id to 0 for tracking
56
+ if (load_only || is_detached) {
57
+ // _load_only or detached: skip initial render entirely
60
58
  render_id = 0;
61
59
  component._render_count = 0;
62
60
  }
61
+ else if (load_render_only) {
62
+ // _load_render_only: render to create children, but skip on_render()
63
+ render_id = component._render(null, { skip_on_render: true });
64
+ // Check if stopped during render
65
+ if (component._stopped)
66
+ return;
67
+ }
63
68
  else {
64
- // Render phase - SYNCHRONOUS, creates DOM and child components
65
- // Note: _render() now calls on_render() internally after DOM update
66
- // Capture render ID to detect if another render happens before ready
69
+ // Normal: render with on_render()
67
70
  render_id = component._render();
68
71
  // Check if stopped during render
69
72
  if (component._stopped)
@@ -72,30 +75,39 @@ class LifecycleManager {
72
75
  // Load phase - ASYNC, but only if on_load() is overridden
73
76
  // Skip entirely if component uses default stub to avoid unnecessary yield
74
77
  if (component._has_on_load()) {
78
+ // Load gates: await any promises registered via gate_load() during
79
+ // on_create() before the FIRST on_load(). Gates delay ONLY this load
80
+ // phase — create/render/on_render/cached-paint already happened above.
81
+ // Released early by reload()/refresh()/stop() while gated (see component).
82
+ // Returns null (no await) for the common no-gate case to keep timing
83
+ // identical for ungated components.
84
+ const gate_wait = component._await_load_gates();
85
+ if (gate_wait) {
86
+ await gate_wait;
87
+ // Check if stopped while awaiting gates
88
+ if (component._stopped)
89
+ return;
90
+ }
75
91
  await component._load();
76
92
  // Yield to microtask queue after on_load completes
77
93
  // This ensures async queue tasks always run before the on_load process completes,
78
94
  // even if on_load() itself completes instantly (deterministic behavior)
79
95
  await Promise.resolve();
80
96
  }
81
- // Trigger 'loaded' event - fires after on_load completes (or would have completed)
82
- // This happens before the render/on_render second step
83
- component.trigger('loaded');
84
97
  // Check if stopped during load
85
98
  if (component._stopped)
86
99
  return;
87
- // If skip_render_and_ready, mark component as ready now and return
88
- if (skip_render_and_ready) {
89
- // Set ready state and trigger event
100
+ // If _load_only, mark component as ready now and return
101
+ if (load_only) {
90
102
  component._ready_state = 4;
91
103
  component._update_debug_attrs();
92
- component._log_lifecycle('ready', 'complete (skip_render_and_ready)');
104
+ component._log_lifecycle('ready', 'complete (_load_only)');
93
105
  component.trigger('ready');
94
106
  return;
95
107
  }
96
- // If data changed during load, re-render
97
- // Note: _render() now calls on_render() internally after DOM update
98
- if (component._should_rerender()) {
108
+ // Detached elements always render here (initial render was skipped)
109
+ // Normal elements re-render only if data changed during on_load
110
+ if (is_detached || component._should_rerender()) {
99
111
  // Disable animations during re-render to prevent visual artifacts
100
112
  // from CSS transitions/animations firing on newly rendered elements
101
113
  const $el = component.$;
@@ -112,11 +124,25 @@ class LifecycleManager {
112
124
  }, 5);
113
125
  });
114
126
  }
115
- render_id = component._render();
127
+ // _load_render_only: re-render with skip_on_render to suppress DOM hooks
128
+ if (load_render_only) {
129
+ render_id = component._render(null, { skip_on_render: true });
130
+ }
131
+ else {
132
+ render_id = component._render();
133
+ }
116
134
  // Check if stopped during re-render
117
135
  if (component._stopped)
118
136
  return;
119
137
  }
138
+ // _load_render_only: skip waiting for children, on_ready — mark ready and return
139
+ if (load_render_only) {
140
+ component._ready_state = 4;
141
+ component._update_debug_attrs();
142
+ component._log_lifecycle('ready', 'complete (_load_render_only)');
143
+ component.trigger('ready');
144
+ return;
145
+ }
120
146
  // Fire 'rendered' event - component has completed its synchronous render chain
121
147
  // This happens once, after the final on_render (whether from first render or re-render after on_load)
122
148
  if (!component._has_rendered) {
@@ -140,15 +166,6 @@ class LifecycleManager {
140
166
  if (component._render_count !== render_id) {
141
167
  return; // Stale render, don't call ready
142
168
  }
143
- // If skip_ready, mark component as ready now without calling _ready()
144
- if (skip_ready) {
145
- // Set ready state and trigger event
146
- component._ready_state = 4;
147
- component._update_debug_attrs();
148
- component._log_lifecycle('ready', 'complete (skip_ready)');
149
- component.trigger('ready');
150
- return;
151
- }
152
169
  // Ready phase - waits for children, then calls on_ready()
153
170
  await component._ready();
154
171
  // Check if stopped during ready
@@ -654,20 +671,26 @@ function process_component_to_html(instruction, html, components, context) {
654
671
  // pass this behavior to all child components rendered in its template
655
672
  let props = originalProps;
656
673
  const parentArgs = context.args;
657
- // Check if we need to propagate any flags
658
- const propagate_use_cached_data = parentArgs?.use_cached_data === true && props.use_cached_data === undefined;
659
- const propagate_skip_render_and_ready = parentArgs?.skip_render_and_ready === true && props.skip_render_and_ready === undefined;
660
- const propagate_skip_ready = parentArgs?.skip_ready === true && props.skip_ready === undefined;
661
- if (propagate_use_cached_data || propagate_skip_render_and_ready || propagate_skip_ready) {
674
+ // Check if we need to propagate any flags.
675
+ // Template-authored flags arrive with the '$' still on the key ($_load_only=false
676
+ // compiles to props['$_load_only']) - honor both spellings so a child template can
677
+ // opt out of a cascading parent flag with an explicit false.
678
+ const own_use_cached_data = props.use_cached_data !== undefined ? props.use_cached_data : props['$use_cached_data'];
679
+ const own_load_only = props._load_only !== undefined ? props._load_only : props['$_load_only'];
680
+ const own_load_render_only = props._load_render_only !== undefined ? props._load_render_only : props['$_load_render_only'];
681
+ const propagate_use_cached_data = parentArgs?.use_cached_data === true && own_use_cached_data === undefined;
682
+ const propagate_load_only = parentArgs?._load_only === true && own_load_only === undefined;
683
+ const propagate_load_render_only = parentArgs?._load_render_only === true && own_load_render_only === undefined;
684
+ if (propagate_use_cached_data || propagate_load_only || propagate_load_render_only) {
662
685
  props = { ...originalProps };
663
686
  if (propagate_use_cached_data) {
664
687
  props.use_cached_data = true;
665
688
  }
666
- if (propagate_skip_render_and_ready) {
667
- props.skip_render_and_ready = true;
689
+ if (propagate_load_only) {
690
+ props._load_only = true;
668
691
  }
669
- if (propagate_skip_ready) {
670
- props.skip_ready = true;
692
+ if (propagate_load_render_only) {
693
+ props._load_render_only = true;
671
694
  }
672
695
  }
673
696
  // Determine if third parameter is a function (default content) or object (named slots)
@@ -983,6 +1006,14 @@ async function initialize_component(element, compData) {
983
1006
  if (ComponentClass.name !== name) {
984
1007
  options._component_name = name;
985
1008
  }
1009
+ // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1010
+ // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1011
+ if (props._load_only === true || props['$_load_only'] === true) {
1012
+ options._load_only = true;
1013
+ }
1014
+ if (props._load_render_only === true || props['$_load_render_only'] === true) {
1015
+ options._load_render_only = true;
1016
+ }
986
1017
  // Create component instance - element first, then options
987
1018
  const instance = new ComponentClass(element, options);
988
1019
  // Set the instantiator (the component that rendered this one in their template)
@@ -1240,7 +1271,8 @@ class Load_Coordinator {
1240
1271
  continue; // Skip internal properties
1241
1272
  }
1242
1273
  // Skip framework properties that shouldn't affect cache identity
1243
- if (key === 'use_cached_data' || key === 'skip_render_and_ready' || key === 'skip_ready') {
1274
+ // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
1275
+ if (key === 'use_cached_data') {
1244
1276
  continue;
1245
1277
  }
1246
1278
  const value = args[key];
@@ -1618,7 +1650,13 @@ function process_for_serialization(value, verbose, seen) {
1618
1650
  // Return undefined to omit this value (not fail entire serialization)
1619
1651
  return undefined;
1620
1652
  }
1621
- // Handle circular references
1653
+ // Handle circular references.
1654
+ // `seen` tracks only the current ancestor path (the DFS stack): an object is
1655
+ // added on the way down and removed on the way back up (see finally below).
1656
+ // This flags a genuine cycle (a back-edge to an ancestor) while allowing a
1657
+ // shared reference in a DAG to be serialized independently on each visit.
1658
+ // JSON round-trip semantics do not preserve identity anyway, so emitting a
1659
+ // copy per reference is correct; silently omitting the property is not.
1622
1660
  if (seen.has(value)) {
1623
1661
  if (verbose) {
1624
1662
  console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
@@ -1626,69 +1664,89 @@ function process_for_serialization(value, verbose, seen) {
1626
1664
  return undefined; // Fail entire serialization
1627
1665
  }
1628
1666
  seen.add(value);
1629
- // Handle arrays
1630
- if (Array.isArray(value)) {
1631
- const result = [];
1632
- for (let i = 0; i < value.length; i++) {
1633
- const item = value[i];
1634
- const processed = process_for_serialization(item, verbose, seen);
1635
- // For arrays, we keep undefined as null to preserve indices
1636
- result.push(processed === undefined ? null : processed);
1667
+ try {
1668
+ // Handle arrays
1669
+ if (Array.isArray(value)) {
1670
+ const result = [];
1671
+ for (let i = 0; i < value.length; i++) {
1672
+ const item = value[i];
1673
+ const processed = process_for_serialization(item, verbose, seen);
1674
+ // For arrays, we keep undefined as null to preserve indices
1675
+ result.push(processed === undefined ? null : processed);
1676
+ }
1677
+ return result;
1637
1678
  }
1638
- return result;
1639
- }
1640
- // Handle Date objects specially
1641
- if (value instanceof Date) {
1642
- return {
1643
- [CLASS_MARKER]: 'Date',
1644
- [PROPS_MARKER]: value.toISOString()
1645
- };
1646
- }
1647
- // Handle Map objects
1648
- if (value instanceof Map) {
1649
- const entries = [];
1650
- for (const [k, v] of value) {
1651
- const processedKey = process_for_serialization(k, verbose, seen);
1652
- const processedValue = process_for_serialization(v, verbose, seen);
1653
- entries.push([processedKey, processedValue]);
1654
- }
1655
- return {
1656
- [CLASS_MARKER]: 'Map',
1657
- [PROPS_MARKER]: entries
1658
- };
1659
- }
1660
- // Handle Set objects
1661
- if (value instanceof Set) {
1662
- const items = [];
1663
- for (const item of value) {
1664
- items.push(process_for_serialization(item, verbose, seen));
1679
+ // Handle Date objects specially
1680
+ if (value instanceof Date) {
1681
+ return {
1682
+ [CLASS_MARKER]: 'Date',
1683
+ [PROPS_MARKER]: value.toISOString()
1684
+ };
1665
1685
  }
1666
- return {
1667
- [CLASS_MARKER]: 'Set',
1668
- [PROPS_MARKER]: items
1669
- };
1670
- }
1671
- // Get the constructor
1672
- const ctor = value.constructor;
1673
- // Handle registered class instances
1674
- if (ctor && ctor.name && class_registry[ctor.name]) {
1675
- const props = {};
1676
- // Extract own enumerable properties (bypass any toJSON method)
1677
- for (const key of Object.keys(value)) {
1678
- const propValue = value[key];
1679
- const processed = process_for_serialization(propValue, verbose, seen);
1680
- // Only include if not undefined (failed serialization)
1681
- if (processed !== undefined || propValue === undefined) {
1682
- props[key] = processed;
1686
+ // Handle Map objects
1687
+ if (value instanceof Map) {
1688
+ const entries = [];
1689
+ for (const [k, v] of value) {
1690
+ const processedKey = process_for_serialization(k, verbose, seen);
1691
+ const processedValue = process_for_serialization(v, verbose, seen);
1692
+ entries.push([processedKey, processedValue]);
1693
+ }
1694
+ return {
1695
+ [CLASS_MARKER]: 'Map',
1696
+ [PROPS_MARKER]: entries
1697
+ };
1698
+ }
1699
+ // Handle Set objects
1700
+ if (value instanceof Set) {
1701
+ const items = [];
1702
+ for (const item of value) {
1703
+ items.push(process_for_serialization(item, verbose, seen));
1683
1704
  }
1705
+ return {
1706
+ [CLASS_MARKER]: 'Set',
1707
+ [PROPS_MARKER]: items
1708
+ };
1684
1709
  }
1685
- return {
1686
- [CLASS_MARKER]: ctor.name,
1687
- [PROPS_MARKER]: props
1688
- };
1689
- }
1690
- // Handle plain objects (constructor is Object or no constructor)
1691
- if (ctor === Object || ctor === undefined || ctor === null) {
1710
+ // Get the constructor
1711
+ const ctor = value.constructor;
1712
+ // Handle registered class instances
1713
+ if (ctor && ctor.name && class_registry[ctor.name]) {
1714
+ const props = {};
1715
+ // Extract own enumerable properties (bypass any toJSON method)
1716
+ for (const key of Object.keys(value)) {
1717
+ const propValue = value[key];
1718
+ const processed = process_for_serialization(propValue, verbose, seen);
1719
+ // Only include if not undefined (failed serialization)
1720
+ if (processed !== undefined || propValue === undefined) {
1721
+ props[key] = processed;
1722
+ }
1723
+ }
1724
+ return {
1725
+ [CLASS_MARKER]: ctor.name,
1726
+ [PROPS_MARKER]: props
1727
+ };
1728
+ }
1729
+ // Handle plain objects (constructor is Object or no constructor)
1730
+ if (ctor === Object || ctor === undefined || ctor === null) {
1731
+ const result = {};
1732
+ for (const key of Object.keys(value)) {
1733
+ const propValue = value[key];
1734
+ const processed = process_for_serialization(propValue, verbose, seen);
1735
+ // Only include if not undefined (unless original was undefined)
1736
+ if (processed !== undefined || propValue === undefined) {
1737
+ result[key] = processed;
1738
+ }
1739
+ }
1740
+ return result;
1741
+ }
1742
+ // Unregistered class instance - convert to plain object for hot/cold parity
1743
+ // This ensures developers catch missing class registrations during development
1744
+ // (methods will be lost, only properties preserved)
1745
+ if (verbose) {
1746
+ console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
1747
+ `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
1748
+ }
1749
+ // Convert to plain object - properties are preserved, prototype methods are lost
1692
1750
  const result = {};
1693
1751
  for (const key of Object.keys(value)) {
1694
1752
  const propValue = value[key];
@@ -1700,24 +1758,10 @@ function process_for_serialization(value, verbose, seen) {
1700
1758
  }
1701
1759
  return result;
1702
1760
  }
1703
- // Unregistered class instance - convert to plain object for hot/cold parity
1704
- // This ensures developers catch missing class registrations during development
1705
- // (methods will be lost, only properties preserved)
1706
- if (verbose) {
1707
- console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
1708
- `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
1709
- }
1710
- // Convert to plain object - properties are preserved, prototype methods are lost
1711
- const result = {};
1712
- for (const key of Object.keys(value)) {
1713
- const propValue = value[key];
1714
- const processed = process_for_serialization(propValue, verbose, seen);
1715
- // Only include if not undefined (unless original was undefined)
1716
- if (processed !== undefined || propValue === undefined) {
1717
- result[key] = processed;
1718
- }
1761
+ finally {
1762
+ // Pop this object off the ancestor path now that its subtree is done.
1763
+ seen.delete(value);
1719
1764
  }
1720
- return result;
1721
1765
  }
1722
1766
  // ============================================================================
1723
1767
  // Deserialization (String → Object)
@@ -2136,10 +2180,825 @@ class Jqhtml_Local_Storage {
2136
2180
  }
2137
2181
  }
2138
2182
  }
2139
- Jqhtml_Local_Storage._cache_key = null;
2140
- Jqhtml_Local_Storage._cache_mode = 'data';
2141
- Jqhtml_Local_Storage._storage_available = null;
2142
- Jqhtml_Local_Storage._initialized = false;
2183
+ Jqhtml_Local_Storage._cache_key = null;
2184
+ Jqhtml_Local_Storage._cache_mode = 'data';
2185
+ Jqhtml_Local_Storage._storage_available = null;
2186
+ Jqhtml_Local_Storage._initialized = false;
2187
+
2188
+ /**
2189
+ * JQHTML Component Event System
2190
+ *
2191
+ * Extracted from component.ts - handles lifecycle event registration,
2192
+ * triggering, and state tracking.
2193
+ *
2194
+ * Events have "sticky" semantics for lifecycle events: if an event has already
2195
+ * occurred, new .on() handlers fire immediately with the stored data.
2196
+ */
2197
+ /**
2198
+ * Register a callback for an event.
2199
+ *
2200
+ * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2201
+ * If a lifecycle event has already occurred, the callback fires immediately
2202
+ * AND registers for future occurrences.
2203
+ *
2204
+ * Custom events only fire when explicitly triggered via trigger().
2205
+ *
2206
+ * @param component - The component instance
2207
+ * @param event_name - Name of the event
2208
+ * @param callback - Callback: (component, data?) => void
2209
+ */
2210
+ function event_on(component, event_name, callback) {
2211
+ // Initialize callback array for this event if needed
2212
+ if (!component._lifecycle_callbacks.has(event_name)) {
2213
+ component._lifecycle_callbacks.set(event_name, []);
2214
+ }
2215
+ // Add callback to queue
2216
+ component._lifecycle_callbacks.get(event_name).push(callback);
2217
+ // If this lifecycle event has already occurred, fire the callback immediately
2218
+ // with the stored data from when trigger() was called
2219
+ if (component._lifecycle_states.has(event_name)) {
2220
+ try {
2221
+ const stored_data = component._lifecycle_states.get(event_name);
2222
+ callback(component, stored_data);
2223
+ }
2224
+ catch (error) {
2225
+ console.error(`[JQHTML] Error in ${event_name} callback:`, error);
2226
+ }
2227
+ }
2228
+ return component;
2229
+ }
2230
+ /**
2231
+ * Register a callback that fires exactly once.
2232
+ *
2233
+ * - If the event already occurred (sticky), fires immediately and does NOT register.
2234
+ * - If the event has not occurred, registers and auto-deregisters after first fire.
2235
+ *
2236
+ * @param component - The component instance
2237
+ * @param event_name - Name of the event
2238
+ * @param callback - Callback: (component, data?) => void
2239
+ */
2240
+ function event_once(component, event_name, callback) {
2241
+ // If event already occurred, fire immediately and we're done
2242
+ if (component._lifecycle_states.has(event_name)) {
2243
+ try {
2244
+ const stored_data = component._lifecycle_states.get(event_name);
2245
+ callback(component, stored_data);
2246
+ }
2247
+ catch (error) {
2248
+ console.error(`[JQHTML] Error in ${event_name} once callback:`, error);
2249
+ }
2250
+ return component;
2251
+ }
2252
+ // Wrap callback to auto-deregister after first fire
2253
+ const wrapper = (comp, data) => {
2254
+ // Remove ourselves from the callback list
2255
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2256
+ if (callbacks) {
2257
+ const idx = callbacks.indexOf(wrapper);
2258
+ if (idx !== -1)
2259
+ callbacks.splice(idx, 1);
2260
+ }
2261
+ callback(comp, data);
2262
+ };
2263
+ // Initialize callback array for this event if needed
2264
+ if (!component._lifecycle_callbacks.has(event_name)) {
2265
+ component._lifecycle_callbacks.set(event_name, []);
2266
+ }
2267
+ component._lifecycle_callbacks.get(event_name).push(wrapper);
2268
+ return component;
2269
+ }
2270
+ /**
2271
+ * Trigger an event - fires all registered callbacks.
2272
+ * Marks event as occurred so future .on() calls fire immediately.
2273
+ *
2274
+ * @param component - The component instance
2275
+ * @param event_name - Name of the event to trigger
2276
+ * @param data - Optional data to pass to callbacks as second parameter
2277
+ */
2278
+ function event_trigger(component, event_name, data) {
2279
+ // Mark this event as occurred and store the data for late subscribers
2280
+ component._lifecycle_states.set(event_name, data);
2281
+ // Fire all registered callbacks for this event
2282
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2283
+ if (callbacks) {
2284
+ for (const callback of callbacks) {
2285
+ try {
2286
+ callback.bind(component)(component, data);
2287
+ }
2288
+ catch (error) {
2289
+ console.error(`[JQHTML] Error in ${event_name} callback:`, error);
2290
+ }
2291
+ }
2292
+ }
2293
+ }
2294
+ /**
2295
+ * Check if any callbacks are registered for a given event.
2296
+ *
2297
+ * @param component - The component instance
2298
+ * @param event_name - Name of the event to check
2299
+ */
2300
+ function event_on_registered(component, event_name) {
2301
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2302
+ return !!(callbacks && callbacks.length > 0);
2303
+ }
2304
+ /**
2305
+ * Invalidate a lifecycle event - removes the "already occurred" marker.
2306
+ *
2307
+ * After invalidate() is called:
2308
+ * - New .on() handlers will NOT fire immediately
2309
+ * - The ready() promise will NOT resolve immediately
2310
+ * - Handlers wait for the next trigger() call
2311
+ *
2312
+ * Existing registered callbacks are NOT removed - they'll fire on next trigger().
2313
+ *
2314
+ * Use case: Call invalidate('ready') at the start of reload() or render()
2315
+ * so that any new .on('ready') handlers wait for the reload/render to complete.
2316
+ *
2317
+ * @param component - The component instance
2318
+ * @param event_name - Name of the event to invalidate
2319
+ */
2320
+ function event_invalidate(component, event_name) {
2321
+ component._lifecycle_states.delete(event_name);
2322
+ }
2323
+
2324
+ /**
2325
+ * JQHTML Data Proxy System
2326
+ *
2327
+ * Extracted from component.ts - handles:
2328
+ * - Data freeze/unfreeze enforcement via Proxy
2329
+ * - Detached on_load() execution with restricted access
2330
+ */
2331
+ /**
2332
+ * Set up the `this.data` property on a component using Object.defineProperty
2333
+ * with a Proxy that enforces freeze/unfreeze semantics.
2334
+ *
2335
+ * After setup:
2336
+ * - `this.data` reads/writes go through a Proxy
2337
+ * - When `component.__data_frozen === true`, writes throw errors
2338
+ * - When frozen is false, writes pass through normally
2339
+ *
2340
+ * @param component - The component instance to set up data on
2341
+ */
2342
+ function setup_data_property(component) {
2343
+ let _data = {};
2344
+ // Helper to create frozen proxy for data object
2345
+ const create_proxy = (obj) => {
2346
+ return new Proxy(obj, {
2347
+ set: (target, prop, value) => {
2348
+ if (component.__data_frozen) {
2349
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2350
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2351
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2352
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2353
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2354
+ `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2355
+ ` ❌ In on_ready(): this.data.${String(prop)} = ${JSON.stringify(value)};\n` +
2356
+ ` ✅ In on_create(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Set default\n` +
2357
+ ` ✅ In on_load(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Fetch from API\n` +
2358
+ ` ✅ For component state: this.args.${String(prop)} = ${JSON.stringify(value)}; (accessible in on_load)`);
2359
+ throw new Error(`[JQHTML] Cannot modify this.data.${String(prop)} outside of on_create() or on_load(). ` +
2360
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2361
+ }
2362
+ target[prop] = value;
2363
+ return true;
2364
+ },
2365
+ deleteProperty: (target, prop) => {
2366
+ if (component.__data_frozen) {
2367
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to delete this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2368
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2369
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2370
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2371
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.`);
2372
+ throw new Error(`[JQHTML] Cannot delete this.data.${String(prop)} outside of on_create() or on_load(). ` +
2373
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2374
+ }
2375
+ delete target[prop];
2376
+ return true;
2377
+ }
2378
+ });
2379
+ };
2380
+ // Create initial proxied data object
2381
+ _data = create_proxy({});
2382
+ Object.defineProperty(component, 'data', {
2383
+ get: () => _data,
2384
+ set: (value) => {
2385
+ if (component.__data_frozen) {
2386
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to reassign this.data outside of on_create() or on_load().\n\n` +
2387
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2388
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2389
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2390
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2391
+ `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2392
+ ` ❌ In on_ready(): this.data = {...};\n` +
2393
+ ` ✅ In on_create(): this.data.count = 0; // Set default\n` +
2394
+ ` ✅ In on_load(): this.data = await fetch(...); // Fetch from API\n` +
2395
+ ` ✅ For component state: this.args.count = 5; (accessible in on_load)`);
2396
+ throw new Error(`[JQHTML] Cannot reassign this.data outside of on_create() or on_load(). ` +
2397
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2398
+ }
2399
+ // When setting, wrap the new value in a proxy too
2400
+ _data = create_proxy(value);
2401
+ },
2402
+ enumerable: true,
2403
+ configurable: false
2404
+ });
2405
+ }
2406
+ /**
2407
+ * Execute on_load() in a detached context with restricted access.
2408
+ *
2409
+ * Creates a sandboxed environment where on_load() can only access:
2410
+ * - this.args (read-only)
2411
+ * - this.data (read/write, cloned from __initial_data_snapshot)
2412
+ *
2413
+ * All other property access (this.$, this.$sid, etc.) throws errors.
2414
+ *
2415
+ * @param component - The component instance
2416
+ * @param use_load_coordinator - Whether to use Load_Coordinator for deduplication
2417
+ * @returns The resulting data and optional coordination completion function
2418
+ */
2419
+ async function execute_on_load_detached(component, use_load_coordinator = false) {
2420
+ // Clone this.data from the snapshot captured after on_create()
2421
+ const data_clone = component.__initial_data_snapshot
2422
+ ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
2423
+ : {};
2424
+ // Create a read-only proxy for args that blocks all modifications
2425
+ // Can't use JSON clone because args may contain functions (_slots, callbacks)
2426
+ const component_name = component.component_name();
2427
+ const create_readonly_proxy = (obj, path = 'this.args') => {
2428
+ if (obj === null || typeof obj !== 'object')
2429
+ return obj;
2430
+ return new Proxy(obj, {
2431
+ get(target, prop) {
2432
+ const value = target[prop];
2433
+ // Recursively wrap nested objects (but not functions)
2434
+ if (value !== null && typeof value === 'object' && typeof value !== 'function') {
2435
+ return create_readonly_proxy(value, `${path}.${String(prop)}`);
2436
+ }
2437
+ return value;
2438
+ },
2439
+ set(_target, prop, value) {
2440
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify ${path}.${String(prop)} during on_load().\n\n` +
2441
+ `RESTRICTION: this.args is READ-ONLY during on_load().\n\n` +
2442
+ `WHY: this.args configures what data to fetch. Modifying it during on_load() creates circular dependencies.\n\n` +
2443
+ `FIX: Modify this.args BEFORE on_load() runs (in on_create() or before calling reload()):\n` +
2444
+ ` ❌ Inside on_load(): ${path}.${String(prop)} = ${JSON.stringify(value)};\n` +
2445
+ ` ✅ In on_create(): this.args.${String(prop)} = defaultValue;`);
2446
+ throw new Error(`[JQHTML] Cannot modify ${path}.${String(prop)} during on_load(). ` +
2447
+ `this.args is read-only in on_load().`);
2448
+ },
2449
+ deleteProperty(_target, prop) {
2450
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to delete ${path}.${String(prop)} during on_load().\n\n` +
2451
+ `RESTRICTION: this.args is READ-ONLY during on_load().`);
2452
+ throw new Error(`[JQHTML] Cannot delete ${path}.${String(prop)} during on_load(). ` +
2453
+ `this.args is read-only in on_load().`);
2454
+ }
2455
+ });
2456
+ };
2457
+ // Create a detached context object that on_load will operate on
2458
+ const detached_context = {
2459
+ args: create_readonly_proxy(component.args), // Read-only proxy wrapping real args
2460
+ data: data_clone // Cloned data - modifications stay isolated
2461
+ };
2462
+ // Create restricted proxy that operates on the detached context
2463
+ const restricted_this = new Proxy(detached_context, {
2464
+ get(target, prop) {
2465
+ // Only allow access to args and data
2466
+ if (prop === 'args') {
2467
+ return target.args;
2468
+ }
2469
+ if (prop === 'data') {
2470
+ return target.data;
2471
+ }
2472
+ // Block everything else
2473
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to access this.${String(prop)} during on_load().\n\n` +
2474
+ `RESTRICTION: on_load() may ONLY access:\n` +
2475
+ ` - this.args (read-only)\n` +
2476
+ ` - this.data (read/write)\n\n` +
2477
+ `WHY: on_load() is for data fetching only. All other component functionality should happen in other lifecycle methods.\n\n` +
2478
+ `FIX:\n` +
2479
+ ` - DOM manipulation → use on_render() or on_ready()\n` +
2480
+ ` - Component methods → call them before/after on_load(), not inside it\n` +
2481
+ ` - Other properties → restructure code to only use this.args and this.data in on_load()`);
2482
+ throw new Error(`[JQHTML] Cannot access this.${String(prop)} during on_load(). ` +
2483
+ `on_load() may only access this.args and this.data.`);
2484
+ },
2485
+ set(target, prop, value) {
2486
+ // Only allow setting data
2487
+ if (prop === 'data') {
2488
+ target.data = value;
2489
+ return true;
2490
+ }
2491
+ // Block setting args
2492
+ if (prop === 'args') {
2493
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.args during on_load().\n\n` +
2494
+ `RESTRICTION: on_load() may ONLY modify:\n` +
2495
+ ` - this.data (read/write)\n\n` +
2496
+ `WHY: this.args is component state that on_load() depends on. Modifying it inside on_load() creates circular dependencies.\n\n` +
2497
+ `FIX: Modify this.args BEFORE calling on_load() (in on_create() or other lifecycle methods), not inside on_load():\n` +
2498
+ ` ❌ Inside on_load(): this.args.filter = 'new_value';\n` +
2499
+ ` ✅ In on_create(): this.args.filter = this.args.initial_filter || 'default';`);
2500
+ throw new Error(`[JQHTML] Cannot modify this.args during on_load(). ` +
2501
+ `Modify this.args in other lifecycle methods, not inside on_load().`);
2502
+ }
2503
+ // Block setting any other properties
2504
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.${String(prop)} during on_load().\n\n` +
2505
+ `RESTRICTION: on_load() may ONLY modify:\n` +
2506
+ ` - this.data (read/write)\n\n` +
2507
+ `WHY: on_load() is for data fetching only. Setting properties on the component instance should happen in other lifecycle methods.\n\n` +
2508
+ `FIX: Store your data in this.data instead:\n` +
2509
+ ` ❌ this.${String(prop)} = value;\n` +
2510
+ ` ✅ this.data.${String(prop)} = value;`);
2511
+ throw new Error(`[JQHTML] Cannot modify this.${String(prop)} during on_load(). ` +
2512
+ `Only this.data can be modified in on_load().`);
2513
+ }
2514
+ });
2515
+ // Create promise for this on_load() call
2516
+ const on_load_promise = (async () => {
2517
+ try {
2518
+ await component._call_lifecycle('on_load', restricted_this);
2519
+ }
2520
+ catch (error) {
2521
+ if (use_load_coordinator) {
2522
+ // Handle error and notify coordinator
2523
+ Load_Coordinator.handle_leader_error(component, error);
2524
+ }
2525
+ throw error;
2526
+ }
2527
+ })();
2528
+ // If using Load_Coordinator, register as leader
2529
+ // Note: We store the completion function but DON'T call it here
2530
+ // It will be called by _load() after _apply_load_result() updates this.data
2531
+ let complete_coordination = null;
2532
+ if (use_load_coordinator) {
2533
+ complete_coordination = Load_Coordinator.register_leader(component, on_load_promise);
2534
+ }
2535
+ await on_load_promise;
2536
+ // Note: We don't validate args changes here because external code (like parent
2537
+ // components calling reload()) is allowed to modify component.args during on_load().
2538
+ // The read-only proxy only prevents code INSIDE on_load() from modifying args.
2539
+ // Return the data from the detached context AND the completion function
2540
+ return {
2541
+ data: detached_context.data,
2542
+ complete_coordination
2543
+ };
2544
+ }
2545
+
2546
+ /**
2547
+ * JQHTML Component Cache Integration
2548
+ *
2549
+ * Extracted from component.ts - handles cache key generation,
2550
+ * cache reads during create(), cache checks during reload(),
2551
+ * and HTML cache snapshots during ready().
2552
+ *
2553
+ * Uses Jqhtml_Local_Storage as the underlying storage layer.
2554
+ */
2555
+ /**
2556
+ * Generate a cache key for a component using its name + args.
2557
+ * Supports custom cache_id() override.
2558
+ *
2559
+ * @param component - The component instance
2560
+ * @returns Cache key string or null if caching is disabled
2561
+ */
2562
+ function generate_cache_key(component) {
2563
+ if (typeof component.cache_id === 'function') {
2564
+ try {
2565
+ const custom_cache_id = component.cache_id();
2566
+ return { cache_key: `${component.component_name()}::${String(custom_cache_id)}` };
2567
+ }
2568
+ catch (error) {
2569
+ return { cache_key: null, uncacheable_property: 'cache_id()' };
2570
+ }
2571
+ }
2572
+ // Use standard args-based cache key generation
2573
+ const result = Load_Coordinator.generate_invocation_key(component.component_name(), component.args);
2574
+ return { cache_key: result.key, uncacheable_property: result.uncacheable_property };
2575
+ }
2576
+ /**
2577
+ * Read cache during create() phase.
2578
+ * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
2579
+ *
2580
+ * @param component - The component instance
2581
+ */
2582
+ function read_cache_in_create(component) {
2583
+ const { cache_key, uncacheable_property } = generate_cache_key(component);
2584
+ // If cache_key is null, caching disabled
2585
+ if (cache_key === null) {
2586
+ // Set data-nocache attribute for debugging
2587
+ if (uncacheable_property) {
2588
+ component.$.attr('data-nocache', uncacheable_property);
2589
+ }
2590
+ if (window.jqhtml?.debug?.verbose) {
2591
+ console.log(`[Cache] Component ${component._cid} (${component.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2592
+ }
2593
+ return;
2594
+ }
2595
+ // Store cache key for later use
2596
+ component._cache_key = cache_key;
2597
+ // Get cache mode
2598
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2599
+ if (window.jqhtml?.debug?.verbose) {
2600
+ console.log(`[Cache ${cache_mode}] Component ${component._cid} (${component.component_name()}) checking cache in create()`, { cache_key, cache_mode, has_cache_key_set: Jqhtml_Local_Storage.has_cache_key() });
2601
+ }
2602
+ if (cache_mode === 'html') {
2603
+ // HTML cache mode - check for cached HTML to inject on first render
2604
+ const html_cache_key = `${cache_key}::html`;
2605
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2606
+ if (cached_html !== null && typeof cached_html === 'string') {
2607
+ component._cached_html = cached_html;
2608
+ if (window.jqhtml?.debug?.verbose) {
2609
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
2610
+ }
2611
+ }
2612
+ else {
2613
+ if (window.jqhtml?.debug?.verbose) {
2614
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) cache miss`, { cache_key: html_cache_key });
2615
+ }
2616
+ }
2617
+ // Warn if use_cached_data is set in html cache mode
2618
+ if (component.args.use_cached_data === true) {
2619
+ console.warn(`[JQHTML] Component "${component.component_name()}" has use_cached_data=true but cache mode is 'html'.\n` +
2620
+ `use_cached_data only applies to 'data' cache mode. In 'html' mode, the entire rendered HTML is cached.\n` +
2621
+ `The use_cached_data flag will be ignored.`);
2622
+ }
2623
+ }
2624
+ else {
2625
+ // Data cache mode (default) - check for cached data to hydrate this.data
2626
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
2627
+ if (cached_data !== null && typeof cached_data === 'object') {
2628
+ // Hydrate this.data with cached data
2629
+ component.data = cached_data;
2630
+ if (component.args.use_cached_data === true) {
2631
+ component._use_cached_data_hit = true;
2632
+ if (window.jqhtml?.debug?.verbose) {
2633
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) using cached data (use_cached_data=true, skipping on_load)`, { cache_key, data: cached_data });
2634
+ }
2635
+ }
2636
+ else {
2637
+ if (window.jqhtml?.debug?.verbose) {
2638
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) hydrated from cache`, { cache_key, data: cached_data });
2639
+ }
2640
+ }
2641
+ }
2642
+ else {
2643
+ if (window.jqhtml?.debug?.verbose) {
2644
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cache miss`, { cache_key });
2645
+ }
2646
+ }
2647
+ }
2648
+ }
2649
+ /**
2650
+ * Check cache during reload() when args have changed.
2651
+ * If cache hit, hydrates data/html and triggers an immediate render.
2652
+ *
2653
+ * @param component - The component instance
2654
+ * @returns true if rendered from cache, false otherwise
2655
+ */
2656
+ function check_cache_on_reload(component) {
2657
+ const { cache_key } = generate_cache_key(component);
2658
+ if (cache_key === null) {
2659
+ return false;
2660
+ }
2661
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2662
+ component._cache_key = cache_key;
2663
+ if (cache_mode === 'html') {
2664
+ // HTML cache mode - check for cached HTML
2665
+ const html_cache_key = `${cache_key}::html`;
2666
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2667
+ if (cached_html !== null && typeof cached_html === 'string') {
2668
+ if (window.jqhtml?.debug?.verbose) {
2669
+ console.log(`[Cache html] reload() - Component ${component._cid} (${component.component_name()}) found cached HTML (args changed)`, { cache_key: html_cache_key, html_length: cached_html.length });
2670
+ }
2671
+ component._cached_html = cached_html;
2672
+ // Call _render() directly (not render()) since _reload() manages the full lifecycle
2673
+ // Using render() would trigger a separate queue entry and interfere with _reload()'s flow
2674
+ component._render();
2675
+ return true;
2676
+ }
2677
+ }
2678
+ else {
2679
+ // Data cache mode (default) - check for cached data
2680
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
2681
+ if (cached_data !== null && typeof cached_data === 'object' && JSON.stringify(cached_data) !== '{}') {
2682
+ if (window.jqhtml?.debug?.verbose) {
2683
+ console.log(`[Cache data] reload() - Component ${component._cid} (${component.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
2684
+ }
2685
+ // Hydrate this.data with cached data
2686
+ component.__data_frozen = false;
2687
+ component.data = cached_data;
2688
+ component.__data_frozen = true;
2689
+ // Call _render() directly (not render()) since _reload() manages the full lifecycle
2690
+ component._render();
2691
+ return true;
2692
+ }
2693
+ }
2694
+ return false;
2695
+ }
2696
+ /**
2697
+ * Write HTML cache snapshot during ready() phase.
2698
+ * Only caches if the component is dynamic (data changed during on_load).
2699
+ *
2700
+ * @param component - The component instance
2701
+ */
2702
+ function write_html_cache_snapshot(component) {
2703
+ if (!component._should_cache_html_after_ready || !component._cache_key) {
2704
+ return;
2705
+ }
2706
+ if (component._is_dynamic) {
2707
+ component._should_cache_html_after_ready = false;
2708
+ const html = component.$.html();
2709
+ const html_cache_key = `${component._cache_key}::html`;
2710
+ Jqhtml_Local_Storage.set(html_cache_key, html);
2711
+ if (window.jqhtml?.debug?.verbose) {
2712
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) cached HTML after children on_render complete`, { cache_key: html_cache_key, html_length: html.length });
2713
+ }
2714
+ }
2715
+ else {
2716
+ component._should_cache_html_after_ready = false;
2717
+ if (window.jqhtml?.debug?.verbose) {
2718
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) is static (no data change) - skipping HTML cache`);
2719
+ }
2720
+ }
2721
+ }
2722
+ /**
2723
+ * Write data/HTML to cache after on_load() completes.
2724
+ * Called from _apply_load_result().
2725
+ *
2726
+ * @param component - The component instance
2727
+ * @param data_changed - Whether this.data changed during on_load
2728
+ */
2729
+ function write_cache_on_loaded(component, data_changed) {
2730
+ if (!data_changed || !component._cache_key) {
2731
+ return;
2732
+ }
2733
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2734
+ if (cache_mode === 'html') {
2735
+ // HTML cache mode - flag to cache HTML after children ready in _ready()
2736
+ component._should_cache_html_after_ready = true;
2737
+ if (window.jqhtml?.debug?.verbose) {
2738
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) will cache HTML after ready`, { cache_key: component._cache_key });
2739
+ }
2740
+ }
2741
+ else {
2742
+ // Data cache mode (default) - write this.data to cache
2743
+ Jqhtml_Local_Storage.set(component._cache_key, component.data);
2744
+ if (window.jqhtml?.debug?.verbose) {
2745
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cached data after on_load()`, { cache_key: component._cache_key, data: component.data });
2746
+ }
2747
+ }
2748
+ }
2749
+
2750
+ /**
2751
+ * JQHTML Component Lifecycle Queue
2752
+ *
2753
+ * Serializes lifecycle operations (render, reload, refresh) per component.
2754
+ * Replaces _create_debounced_function with a proper queue that:
2755
+ *
2756
+ * - At most one operation runs at a time per component
2757
+ * - At most one operation is pending (waiting for the current to finish)
2758
+ * - Same-type pending operations collapse (fan-in: all callers share one execution)
2759
+ * - Different-type pending operations: new replaces old (old callers get the new result)
2760
+ *
2761
+ * Boot bypasses this queue entirely - it runs the lifecycle directly.
2762
+ */
2763
+ class Component_Queue {
2764
+ constructor() {
2765
+ /** Currently executing operation */
2766
+ this._current = null;
2767
+ /** Next operation waiting to execute after current completes */
2768
+ this._pending = null;
2769
+ }
2770
+ /**
2771
+ * Enqueue a lifecycle operation.
2772
+ *
2773
+ * Behavior:
2774
+ * - Nothing running → execute immediately
2775
+ * - Something running, no pending → create pending entry
2776
+ * - Something running, same type pending → collapse (share pending promise)
2777
+ * - Something running, different type pending → replace pending (all callers get new result)
2778
+ *
2779
+ * @param type - Operation type ('render', 'reload', 'refresh', 'load')
2780
+ * @param executor - The async function to execute
2781
+ * @returns Promise that resolves when the operation completes
2782
+ */
2783
+ enqueue(type, executor) {
2784
+ // If nothing running, execute immediately
2785
+ if (!this._current) {
2786
+ return this._execute(type, executor);
2787
+ }
2788
+ // Something is running — add to pending
2789
+ if (this._pending) {
2790
+ if (this._pending.type === type) {
2791
+ // Same type pending → collapse (fan-in)
2792
+ // All callers share the same future execution
2793
+ return new Promise((resolve, reject) => {
2794
+ this._pending.resolvers.push(resolve);
2795
+ this._pending.rejecters.push(reject);
2796
+ });
2797
+ }
2798
+ else {
2799
+ // Different type pending → replace
2800
+ // Old pending callers join the new operation
2801
+ // (their operation was superseded by a different type)
2802
+ const old_resolvers = this._pending.resolvers;
2803
+ const old_rejecters = this._pending.rejecters;
2804
+ return new Promise((resolve, reject) => {
2805
+ this._pending = {
2806
+ type,
2807
+ executor,
2808
+ resolvers: [...old_resolvers, resolve],
2809
+ rejecters: [...old_rejecters, reject]
2810
+ };
2811
+ });
2812
+ }
2813
+ }
2814
+ // No pending entry yet — create one
2815
+ return new Promise((resolve, reject) => {
2816
+ this._pending = {
2817
+ type,
2818
+ executor,
2819
+ resolvers: [resolve],
2820
+ rejecters: [reject]
2821
+ };
2822
+ });
2823
+ }
2824
+ /**
2825
+ * Execute an operation and handle pending queue after completion.
2826
+ * @private
2827
+ */
2828
+ _execute(type, executor) {
2829
+ const promise = (async () => {
2830
+ try {
2831
+ await executor();
2832
+ }
2833
+ finally {
2834
+ this._current = null;
2835
+ // Run pending if exists
2836
+ if (this._pending) {
2837
+ const pending = this._pending;
2838
+ this._pending = null;
2839
+ try {
2840
+ await this._execute(pending.type, pending.executor);
2841
+ for (const resolve of pending.resolvers)
2842
+ resolve();
2843
+ }
2844
+ catch (err) {
2845
+ for (const reject of pending.rejecters)
2846
+ reject(err);
2847
+ }
2848
+ }
2849
+ }
2850
+ })();
2851
+ this._current = { type, promise };
2852
+ return promise;
2853
+ }
2854
+ /**
2855
+ * Check if any operation is currently running.
2856
+ */
2857
+ get is_busy() {
2858
+ return this._current !== null;
2859
+ }
2860
+ /**
2861
+ * Check if there's a pending operation waiting.
2862
+ */
2863
+ get has_pending() {
2864
+ return this._pending !== null;
2865
+ }
2866
+ }
2867
+
2868
+ /**
2869
+ * JQHTML SSR Preload Data System
2870
+ *
2871
+ * Captures component data during SSR rendering and replays it on the client
2872
+ * to skip redundant on_load() calls during hydration.
2873
+ *
2874
+ * Server-side: start_data_capture() → render → get_captured_data()
2875
+ * Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
2876
+ */
2877
+ // --- Capture (Server Side) ---
2878
+ let _capture_enabled = false;
2879
+ let _capture_buffer = [];
2880
+ // Track captured keys to avoid duplicates from Load Coordinator followers
2881
+ let _captured_keys = new Set();
2882
+ /**
2883
+ * Enable data capture mode. After this call, every component that completes
2884
+ * on_load() has its final this.data recorded.
2885
+ *
2886
+ * Idempotent — calling twice is fine.
2887
+ */
2888
+ function start_data_capture() {
2889
+ _capture_enabled = true;
2890
+ _capture_buffer = [];
2891
+ _captured_keys.clear();
2892
+ }
2893
+ /**
2894
+ * Record a component's data after on_load() completes.
2895
+ * Called internally from _apply_load_result().
2896
+ *
2897
+ * @param component_name - Component name
2898
+ * @param args - Component args at time of on_load()
2899
+ * @param data - this.data after on_load() completed
2900
+ * @param cache_key - The cache key for deduplication (null = always capture)
2901
+ */
2902
+ function capture_component_data(component_name, args, data, cache_key) {
2903
+ if (!_capture_enabled)
2904
+ return;
2905
+ // Deduplicate: if Load Coordinator already captured this key, skip
2906
+ if (cache_key !== null) {
2907
+ if (_captured_keys.has(cache_key))
2908
+ return;
2909
+ _captured_keys.add(cache_key);
2910
+ }
2911
+ // Deep clone args and data to plain objects (no proxies, no class instances)
2912
+ try {
2913
+ const cloned_args = JSON.parse(JSON.stringify(args));
2914
+ const cloned_data = JSON.parse(JSON.stringify(data));
2915
+ _capture_buffer.push({
2916
+ component: component_name,
2917
+ args: cloned_args,
2918
+ data: cloned_data,
2919
+ });
2920
+ }
2921
+ catch (error) {
2922
+ // Non-serializable data — skip capture for this component
2923
+ if (typeof console !== 'undefined') {
2924
+ console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
2925
+ }
2926
+ }
2927
+ }
2928
+ /**
2929
+ * Returns all captured component data and resets the capture buffer.
2930
+ * One-shot: calling clears the buffer.
2931
+ */
2932
+ function get_captured_data() {
2933
+ const result = _capture_buffer;
2934
+ _capture_buffer = [];
2935
+ _captured_keys.clear();
2936
+ return result;
2937
+ }
2938
+ /**
2939
+ * Check if data capture is currently enabled.
2940
+ */
2941
+ function is_capture_enabled() {
2942
+ return _capture_enabled;
2943
+ }
2944
+ /**
2945
+ * Stop data capture and clear all state.
2946
+ */
2947
+ function stop_data_capture() {
2948
+ _capture_enabled = false;
2949
+ _capture_buffer = [];
2950
+ _captured_keys.clear();
2951
+ }
2952
+ // --- Preload (Client Side) ---
2953
+ let _preload_cache = new Map();
2954
+ /**
2955
+ * Seed the preload cache with SSR-captured data.
2956
+ * Must be called before any component _load() runs.
2957
+ *
2958
+ * @param entries - Array of { component, args, data } entries from get_captured_data()
2959
+ */
2960
+ function set_preload_data(entries) {
2961
+ if (!entries || entries.length === 0)
2962
+ return;
2963
+ _preload_cache.clear();
2964
+ for (const entry of entries) {
2965
+ // Generate key using the same algorithm as Load Coordinator
2966
+ const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
2967
+ if (result.key !== null) {
2968
+ _preload_cache.set(result.key, entry.data);
2969
+ }
2970
+ }
2971
+ }
2972
+ /**
2973
+ * Check if preloaded data exists for a given cache key.
2974
+ * If found, returns the data and removes the entry (one-shot).
2975
+ *
2976
+ * @param cache_key - The cache key (same format as Load Coordinator)
2977
+ * @returns The preloaded data, or null if no match
2978
+ */
2979
+ function consume_preload_data(cache_key) {
2980
+ if (_preload_cache.size === 0)
2981
+ return null;
2982
+ const data = _preload_cache.get(cache_key);
2983
+ if (data !== undefined) {
2984
+ _preload_cache.delete(cache_key);
2985
+ return data;
2986
+ }
2987
+ return null;
2988
+ }
2989
+ /**
2990
+ * Clear any remaining unconsumed preload entries.
2991
+ * Call after initial page hydration is complete.
2992
+ */
2993
+ function clear_preload_data() {
2994
+ _preload_cache.clear();
2995
+ }
2996
+ /**
2997
+ * Check if any preload data is available (for fast early exit in _load).
2998
+ */
2999
+ function has_preload_data() {
3000
+ return _preload_cache.size > 0;
3001
+ }
2143
3002
 
2144
3003
  /**
2145
3004
  * JQHTML v2 Component Base Class
@@ -2171,6 +3030,7 @@ class Jqhtml_Component {
2171
3030
  this._data_on_last_render = null; // Data snapshot from last render (for refresh() comparison)
2172
3031
  this.__initial_data_snapshot = null; // Snapshot of this.data after on_create() for restoration before on_load()
2173
3032
  this.__data_frozen = false; // Track if this.data is currently frozen
3033
+ this._queue = new Component_Queue(); // Lifecycle operation queue (render/reload/refresh)
2174
3034
  this.next_reload_force_refresh = null; // State machine for reload()/refresh() debounce precedence
2175
3035
  this.__lifecycle_authorized = false; // Flag for lifecycle method protection
2176
3036
  // Cache mode properties
@@ -2184,12 +3044,17 @@ class Jqhtml_Component {
2184
3044
  this._on_render_complete = false; // True after on_render() has been called post-on_load
2185
3045
  // use_cached_data feature - skip on_load() when cache hit occurs
2186
3046
  this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
2187
- // skip_render_and_ready feature - skip first render/on_render and on_ready entirely
2188
- // Component reports ready after on_load completes
2189
- this._skip_render_and_ready = false;
2190
- // skip_ready feature - skip on_ready only
2191
- // Component reports ready after on_render completes
2192
- this._skip_ready = false;
3047
+ // _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
3048
+ this._load_only = false;
3049
+ // _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
3050
+ this._load_render_only = false;
3051
+ // Detached optimization: true when element is not in the DOM at boot time.
3052
+ // Skips initial render and cache read — just on_load then render.
3053
+ // Override with _force_initial_render to keep normal double-render behavior.
3054
+ this._is_detached = false;
3055
+ // _force_initial_render: override detached optimization — render even when not in DOM.
3056
+ // Use when you need the loading spinner visible immediately after appending.
3057
+ this._force_initial_render = false;
2193
3058
  // rendered event - fires once after the synchronous render chain completes
2194
3059
  // (after on_load's re-render if applicable, or after first render if no on_load)
2195
3060
  this._has_rendered = false;
@@ -2200,6 +3065,11 @@ class Jqhtml_Component {
2200
3065
  // Set in constructor (prototype chain is established before constructor runs)
2201
3066
  // Used to skip expensive cache/load operations for static components
2202
3067
  this.__has_custom_on_load = false;
3068
+ // gate_load() — "load gates" registered during on_create() that must settle
3069
+ // before the component's FIRST on_load() runs. See gate_load() / _await_load_gates().
3070
+ this.__load_gates = []; // Pending gates (cleared once the first load begins)
3071
+ this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
3072
+ this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
2203
3073
  // Detect custom on_load() override immediately
2204
3074
  // Prototype chain is set up before constructor runs, so this.on_load
2205
3075
  // correctly resolves to child class override if one exists
@@ -2241,12 +3111,15 @@ class Jqhtml_Component {
2241
3111
  // Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
2242
3112
  const defineArgs = template_for_args?.defineArgs || {};
2243
3113
  this.args = { ...defineArgs, ...dataAttrs, ...args };
2244
- // Set lifecycle skip flags from args
2245
- if (this.args.skip_render_and_ready === true) {
2246
- this._skip_render_and_ready = true;
3114
+ // Set lifecycle truncation flags from args
3115
+ if (this.args._load_only === true) {
3116
+ this._load_only = true;
3117
+ }
3118
+ if (this.args._load_render_only === true) {
3119
+ this._load_render_only = true;
2247
3120
  }
2248
- if (this.args.skip_ready === true) {
2249
- this._skip_ready = true;
3121
+ if (this.args._force_initial_render === true) {
3122
+ this._force_initial_render = true;
2250
3123
  }
2251
3124
  // Attach component to element
2252
3125
  this.$.data('_component', this);
@@ -2257,68 +3130,8 @@ class Jqhtml_Component {
2257
3130
  // Find DOM parent component (for lifecycle coordination)
2258
3131
  this._find_dom_parent();
2259
3132
  // Setup data property with freeze enforcement using Proxy
2260
- let _data = {};
2261
- // Helper to create frozen proxy for data object
2262
- const createDataProxy = (obj) => {
2263
- return new Proxy(obj, {
2264
- set: (target, prop, value) => {
2265
- if (this.__data_frozen) {
2266
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2267
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2268
- ` - on_create() (set initial defaults, synchronous only)\n` +
2269
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2270
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2271
- `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2272
- ` ❌ In on_ready(): this.data.${String(prop)} = ${JSON.stringify(value)};\n` +
2273
- ` ✅ In on_create(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Set default\n` +
2274
- ` ✅ In on_load(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Fetch from API\n` +
2275
- ` ✅ For component state: this.args.${String(prop)} = ${JSON.stringify(value)}; (accessible in on_load)`);
2276
- throw new Error(`[JQHTML] Cannot modify this.data.${String(prop)} outside of on_create() or on_load(). ` +
2277
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2278
- }
2279
- target[prop] = value;
2280
- return true;
2281
- },
2282
- deleteProperty: (target, prop) => {
2283
- if (this.__data_frozen) {
2284
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to delete this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2285
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2286
- ` - on_create() (set initial defaults, synchronous only)\n` +
2287
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2288
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.`);
2289
- throw new Error(`[JQHTML] Cannot delete this.data.${String(prop)} outside of on_create() or on_load(). ` +
2290
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2291
- }
2292
- delete target[prop];
2293
- return true;
2294
- }
2295
- });
2296
- };
2297
- // Create initial proxied data object
2298
- _data = createDataProxy({});
2299
- Object.defineProperty(this, 'data', {
2300
- get: () => _data,
2301
- set: (value) => {
2302
- if (this.__data_frozen) {
2303
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to reassign this.data outside of on_create() or on_load().\n\n` +
2304
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2305
- ` - on_create() (set initial defaults, synchronous only)\n` +
2306
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2307
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2308
- `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2309
- ` ❌ In on_ready(): this.data = {...};\n` +
2310
- ` ✅ In on_create(): this.data.count = 0; // Set default\n` +
2311
- ` ✅ In on_load(): this.data = await fetch(...); // Fetch from API\n` +
2312
- ` ✅ For component state: this.args.count = 5; (accessible in on_load)`);
2313
- throw new Error(`[JQHTML] Cannot reassign this.data outside of on_create() or on_load(). ` +
2314
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2315
- }
2316
- // When setting, wrap the new value in a proxy too
2317
- _data = createDataProxy(value);
2318
- },
2319
- enumerable: true,
2320
- configurable: false
2321
- });
3133
+ // @see data-proxy.ts for full implementation
3134
+ setup_data_property(this);
2322
3135
  // Initialize this.state as an empty object (convention for arbitrary component state)
2323
3136
  // No special meaning to framework - mutable anywhere, not cached, not frozen
2324
3137
  this.state = {};
@@ -2420,7 +3233,7 @@ class Jqhtml_Component {
2420
3233
  * @returns The current _render_count after incrementing (used to detect stale renders)
2421
3234
  * @private
2422
3235
  */
2423
- _render(id = null) {
3236
+ _render(id = null, options = {}) {
2424
3237
  // Increment render count to track this specific render
2425
3238
  this._render_count++;
2426
3239
  const current_render_id = this._render_count;
@@ -2441,7 +3254,7 @@ class Jqhtml_Component {
2441
3254
  `Element with $sid="${id}" exists but is not initialized as a component.\n` +
2442
3255
  `Add $redrawable attribute or make it a proper component.`);
2443
3256
  }
2444
- return child._render();
3257
+ return child._render(null, options);
2445
3258
  }
2446
3259
  this._log_lifecycle('render', 'start');
2447
3260
  // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
@@ -2458,13 +3271,13 @@ class Jqhtml_Component {
2458
3271
  // Mark first render complete
2459
3272
  this._did_first_render = true;
2460
3273
  this._log_lifecycle('render', 'complete (cached HTML)');
2461
- // NEW ARCHITECTURE: Call on_render() even after cache inject
2462
- // This ensures consistent behavior - on_render() always runs after DOM update
2463
- // Note: this.data has defaults from on_create(), not fresh data yet
2464
- const cacheRenderResult = this._call_lifecycle_sync('on_render');
2465
- if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
2466
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
2467
- `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3274
+ // Call on_render() after cache inject (unless suppressed by _load_render_only)
3275
+ if (!options.skip_on_render) {
3276
+ const cacheRenderResult = this._call_lifecycle_sync('on_render');
3277
+ if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
3278
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3279
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3280
+ }
2468
3281
  }
2469
3282
  // Emit lifecycle event
2470
3283
  this.trigger('render');
@@ -2649,18 +3462,13 @@ class Jqhtml_Component {
2649
3462
  // Don't update ready state here - let phases complete in order
2650
3463
  this._update_debug_attrs();
2651
3464
  this._log_lifecycle('render', 'complete');
2652
- // Call on_render() with authorization (sync) immediately after render completes
2653
- // This ensures event handlers are always re-attached after DOM updates
2654
- //
2655
- // NEW ARCHITECTURE: on_render() can now access this.data normally
2656
- // The HTML cache mode now properly synchronizes - on_render() runs after both:
2657
- // 1. Cache inject (with on_create() defaults)
2658
- // 2. Second render (with fresh data from on_load())
2659
- // Since on_render() always runs with appropriate data, no proxy restriction needed
2660
- const renderResult = this._call_lifecycle_sync('on_render');
2661
- if (renderResult && typeof renderResult.then === 'function') {
2662
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
2663
- `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3465
+ // Call on_render() after render completes (unless suppressed by _load_render_only)
3466
+ if (!options.skip_on_render) {
3467
+ const renderResult = this._call_lifecycle_sync('on_render');
3468
+ if (renderResult && typeof renderResult.then === 'function') {
3469
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3470
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3471
+ }
2664
3472
  }
2665
3473
  // Emit lifecycle event
2666
3474
  this.trigger('render');
@@ -2690,18 +3498,18 @@ class Jqhtml_Component {
2690
3498
  * Public render method - re-renders component and completes lifecycle
2691
3499
  * This is what users should call when they want to update a component.
2692
3500
  *
2693
- * Lifecycle sequence:
3501
+ * Lifecycle sequence (serialized via component queue):
2694
3502
  * 1. _render() - Updates DOM synchronously, calls on_render(), fires 'render' event
2695
- * 2. Async continuation (fire and forget):
2696
- * - _wait_for_children_ready() - Waits for all children to reach ready state
2697
- * - on_ready() - Calls user's ready hook
2698
- * - trigger('ready') - Fires ready event
3503
+ * 2. _wait_for_children_ready() - Waits for all children to reach ready state
3504
+ * 3. on_ready() - Calls user's ready hook
3505
+ * 4. trigger('ready') - Fires ready event
2699
3506
  *
2700
- * Returns immediately after _render() completes - does NOT wait for children
3507
+ * Goes through the component queue to prevent concurrent lifecycle operations.
3508
+ * Returns a Promise that resolves when the full render lifecycle completes.
2701
3509
  */
2702
3510
  render(id = null) {
2703
3511
  if (this._stopped)
2704
- return;
3512
+ return Promise.resolve();
2705
3513
  // Invalidate ready event so new handlers wait for this render cycle to complete
2706
3514
  // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
2707
3515
  this.invalidate('ready');
@@ -2720,10 +3528,12 @@ class Jqhtml_Component {
2720
3528
  }
2721
3529
  return child.render();
2722
3530
  }
2723
- // Execute render phase synchronously and capture render ID
2724
- const render_id = this._render();
2725
- // Fire off async lifecycle completion in background (don't await)
2726
- (async () => {
3531
+ // Enqueue the full render lifecycle through the component queue
3532
+ // Return the queue promise so `await this.render()` resolves when the
3533
+ // full render lifecycle (DOM update, children ready, on_ready) completes
3534
+ return this._queue.enqueue('render', async () => {
3535
+ // Execute render phase synchronously and capture render ID
3536
+ const render_id = this._render();
2727
3537
  // Wait for all child components to be ready
2728
3538
  await this._wait_for_children_ready();
2729
3539
  // Check if this render is still current before calling on_ready
@@ -2734,8 +3544,8 @@ class Jqhtml_Component {
2734
3544
  // Call on_ready hook with authorization
2735
3545
  await this._call_lifecycle('on_ready');
2736
3546
  // Trigger ready event
2737
- await this.trigger('ready');
2738
- })();
3547
+ this.trigger('ready');
3548
+ });
2739
3549
  }
2740
3550
  /**
2741
3551
  * Alias for render() - re-renders component with current data
@@ -2744,6 +3554,70 @@ class Jqhtml_Component {
2744
3554
  redraw(id = null) {
2745
3555
  return this.render(id);
2746
3556
  }
3557
+ /**
3558
+ * Re-fetch data by executing on_load() without triggering re-render or lifecycle.
3559
+ *
3560
+ * Runs on_load() on a detached proxy (same restrictions as during boot):
3561
+ * - this.data is restored to on_create() snapshot, then unfrozen for writing
3562
+ * - this.args is read-only
3563
+ * - All other properties (this.$, this.$sid, etc.) are blocked
3564
+ *
3565
+ * After on_load() completes, this.data is atomically updated and re-frozen.
3566
+ * The developer decides what to do next (e.g., call this.render()).
3567
+ *
3568
+ * Pattern: load() invokes on_load(), just as render() invokes on_render().
3569
+ *
3570
+ * @returns true if this.data changed, false if unchanged
3571
+ */
3572
+ async load() {
3573
+ if (this._stopped)
3574
+ return false;
3575
+ // Capture result through queue closure
3576
+ let data_changed = false;
3577
+ await this._queue.enqueue('load', async () => {
3578
+ // Snapshot current data for change detection
3579
+ const data_before = JSON.stringify(this.data);
3580
+ // Execute on_load() on detached proxy (restores data to on_create snapshot)
3581
+ const { data: result_data } = await this._execute_on_load_detached(false);
3582
+ // Atomically update this.data: unfreeze → assign → normalize → refreeze
3583
+ this.__data_frozen = false;
3584
+ this.data = result_data;
3585
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3586
+ if (cache_mode === 'data') {
3587
+ const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
3588
+ this.data = normalized;
3589
+ }
3590
+ this.__data_frozen = true;
3591
+ // Detect if data changed
3592
+ const data_after = JSON.stringify(this.data);
3593
+ data_changed = data_before !== data_after;
3594
+ // Update cache with fresh data if changed
3595
+ if (data_changed) {
3596
+ // Regenerate cache key (args may have changed since boot)
3597
+ let cache_key = null;
3598
+ if (typeof this.cache_id === 'function') {
3599
+ try {
3600
+ cache_key = `${this.component_name()}::${String(this.cache_id())}`;
3601
+ }
3602
+ catch { /* cache_id() threw - skip caching */ }
3603
+ }
3604
+ else {
3605
+ const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
3606
+ cache_key = result.key;
3607
+ }
3608
+ if (cache_key && cache_mode !== 'html') {
3609
+ Jqhtml_Local_Storage.set(cache_key, this.data);
3610
+ }
3611
+ }
3612
+ // Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
3613
+ // Suppressed by _load_only and _load_render_only flags (preloading mode)
3614
+ if (!this._load_only && !this._load_render_only) {
3615
+ await this._call_lifecycle('on_loaded');
3616
+ this.trigger('loaded');
3617
+ }
3618
+ });
3619
+ return data_changed;
3620
+ }
2747
3621
  /**
2748
3622
  * Create phase - Quick setup, prepare UI
2749
3623
  * SYNCHRONOUS - no awaits, no yields
@@ -2759,97 +3633,17 @@ class Jqhtml_Component {
2759
3633
  `on_create() must be synchronous code. Remove 'async' from the function declaration.`);
2760
3634
  // Don't await - on_create MUST be sync. The warning is enough.
2761
3635
  }
3636
+ // Detect detached elements — skip cache and initial render for elements not in DOM
3637
+ // _force_initial_render overrides this optimization
3638
+ this._is_detached = !this.$[0].isConnected && !this._force_initial_render;
2762
3639
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
2763
3640
  // Components without on_load() don't fetch data, so nothing to cache or restore
2764
3641
  if (this.__has_custom_on_load) {
2765
3642
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
2766
- // This happens after on_create() but before render, allowing instant first render
2767
- // Check if component implements cache_id() for custom cache key
2768
- let cache_key = null;
2769
- let uncacheable_property;
2770
- if (typeof this.cache_id === 'function') {
2771
- try {
2772
- const custom_cache_id = this.cache_id();
2773
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
2774
- }
2775
- catch (error) {
2776
- // cache_id() threw error - disable caching
2777
- uncacheable_property = 'cache_id()';
2778
- }
2779
- }
2780
- else {
2781
- // Use standard args-based cache key generation
2782
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
2783
- cache_key = result.key;
2784
- uncacheable_property = result.uncacheable_property;
2785
- }
2786
- // If cache_key is null, caching disabled
2787
- if (cache_key === null) {
2788
- // Set data-nocache attribute for debugging (shows which property prevented caching)
2789
- if (uncacheable_property) {
2790
- this.$.attr('data-nocache', uncacheable_property);
2791
- }
2792
- if (window.jqhtml?.debug?.verbose) {
2793
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2794
- }
2795
- // Don't return - continue to snapshot this.data for on_load restoration
2796
- }
2797
- else {
2798
- // Store cache key for later use
2799
- this._cache_key = cache_key;
2800
- // Get cache mode
2801
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2802
- if (window.jqhtml?.debug?.verbose) {
2803
- console.log(`[Cache ${cache_mode}] Component ${this._cid} (${this.component_name()}) checking cache in create()`, { cache_key, cache_mode, has_cache_key_set: Jqhtml_Local_Storage.has_cache_key() });
2804
- }
2805
- if (cache_mode === 'html') {
2806
- // HTML cache mode - check for cached HTML to inject on first render
2807
- const html_cache_key = `${cache_key}::html`;
2808
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2809
- if (cached_html !== null && typeof cached_html === 'string') {
2810
- // Store cached HTML for injection in _render()
2811
- this._cached_html = cached_html;
2812
- if (window.jqhtml?.debug?.verbose) {
2813
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
2814
- }
2815
- }
2816
- else {
2817
- if (window.jqhtml?.debug?.verbose) {
2818
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key: html_cache_key });
2819
- }
2820
- }
2821
- // Warn if use_cached_data is set in html cache mode - it has no effect
2822
- if (this.args.use_cached_data === true) {
2823
- console.warn(`[JQHTML] Component "${this.component_name()}" has use_cached_data=true but cache mode is 'html'.\n` +
2824
- `use_cached_data only applies to 'data' cache mode. In 'html' mode, the entire rendered HTML is cached.\n` +
2825
- `The use_cached_data flag will be ignored.`);
2826
- }
2827
- }
2828
- else {
2829
- // Data cache mode (default) - check for cached data to hydrate this.data
2830
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
2831
- if (cached_data !== null && typeof cached_data === 'object') {
2832
- // Hydrate this.data with cached data
2833
- this.data = cached_data;
2834
- // If use_cached_data=true, skip on_load() entirely - use cached data as final data
2835
- if (this.args.use_cached_data === true) {
2836
- this._use_cached_data_hit = true;
2837
- if (window.jqhtml?.debug?.verbose) {
2838
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) using cached data (use_cached_data=true, skipping on_load)`, { cache_key, data: cached_data });
2839
- }
2840
- }
2841
- else {
2842
- if (window.jqhtml?.debug?.verbose) {
2843
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) hydrated from cache`, { cache_key, data: cached_data });
2844
- }
2845
- }
2846
- }
2847
- else {
2848
- if (window.jqhtml?.debug?.verbose) {
2849
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key });
2850
- }
2851
- }
2852
- }
3643
+ // Skip cache for detached elements — no point hydrating from cache when not in DOM
3644
+ // @see component-cache.ts for full implementation
3645
+ if (!this._is_detached) {
3646
+ read_cache_in_create(this);
2853
3647
  }
2854
3648
  // Snapshot this.data after on_create() completes
2855
3649
  // This will be restored before each on_load() execution to reset state
@@ -2863,6 +3657,90 @@ class Jqhtml_Component {
2863
3657
  // Emit lifecycle event
2864
3658
  this.trigger('create');
2865
3659
  }
3660
+ /**
3661
+ * Register a "load gate" — a promise that must settle before this component's
3662
+ * FIRST on_load() runs.
3663
+ *
3664
+ * Register gates during on_create(). Multiple calls accumulate; all gates are
3665
+ * awaited together (via Promise.allSettled) at the seam between create and the
3666
+ * first load. A REJECTED gate does NOT block or abort the load — it is an
3667
+ * ordering hint, not a data dependency, so loading proceeds after settlement
3668
+ * (rejections are logged through the debug channel).
3669
+ *
3670
+ * Gates are ONE-SHOT: they delay the first on_load() only. Once that load
3671
+ * begins the gate list is cleared, and reload()/refresh() never re-await gates.
3672
+ * Calling reload()/refresh() while a gate is still holding the first load
3673
+ * releases the wait immediately and resumes the lifecycle (a later gate
3674
+ * settlement then does nothing).
3675
+ *
3676
+ * Gates delay ONLY the on_load phase — never create(), the initial render,
3677
+ * on_render(), or the cached-content first paint (stale-while-revalidate is
3678
+ * preserved). Components with no custom on_load() ignore gates entirely.
3679
+ *
3680
+ * No-op during SSR (the server cannot await arbitrary client promises).
3681
+ *
3682
+ * jqhtml has no knowledge of WHAT is being awaited — the caller supplies the
3683
+ * promise, and policy such as timeouts is the caller's responsibility (bake it
3684
+ * into the promise you pass).
3685
+ *
3686
+ * @param promise The promise to await before the first on_load().
3687
+ * @throws If called after the component's first load has already started.
3688
+ */
3689
+ gate_load(promise) {
3690
+ if (this.__first_load_started) {
3691
+ throw new Error(`[JQHTML] gate_load() may only be called before the component's first load - ` +
3692
+ `register gates in on_create(). ` +
3693
+ `Component: ${this.component_name()} (_cid: ${this._cid})`);
3694
+ }
3695
+ this.__load_gates.push(promise);
3696
+ }
3697
+ /**
3698
+ * Await all registered load gates before the first on_load().
3699
+ *
3700
+ * Called exactly once, at the boot seam immediately before the load phase
3701
+ * (only for components with a custom on_load()). Latches "first load started"
3702
+ * so any later gate_load() throws and reload()/refresh() never re-await.
3703
+ *
3704
+ * Returns a promise to await when there are gates to wait on, or `null` when
3705
+ * there is nothing to wait for (no gates, or SSR). Returning `null` keeps the
3706
+ * common no-gate path free of an extra microtask hop, so it does not shift the
3707
+ * timing of ungated components.
3708
+ *
3709
+ * The returned promise resolves as soon as any of the following happens
3710
+ * (whichever is first — the rest become no-ops):
3711
+ * - all gates settle (Promise.allSettled),
3712
+ * - reload()/refresh() is called while gated (they resolve __gate_resume),
3713
+ * - the component is stopped (stop() resolves __gate_resume).
3714
+ *
3715
+ * @private
3716
+ */
3717
+ _await_load_gates() {
3718
+ // Latch (synchronous): from here on, gate_load() throws and this runs at
3719
+ // most once — set even when there are no gates so point 2 always holds.
3720
+ this.__first_load_started = true;
3721
+ const gates = this.__load_gates;
3722
+ this.__load_gates = [];
3723
+ // Nothing to gate, or SSR (cannot await arbitrary client promises) — proceed now.
3724
+ if (gates.length === 0 || is_capture_enabled())
3725
+ return null;
3726
+ // Promise.allSettled never rejects; rejected gates are logged (debug channel)
3727
+ // whenever they settle, independent of whether the wait was resumed early.
3728
+ const settled = Promise.allSettled(gates);
3729
+ settled.then((results) => {
3730
+ for (const result of results) {
3731
+ if (result.status === 'rejected' && window.jqhtml?.debug?.verbose) {
3732
+ console.log(`[JQHTML gate_load] Component ${this._cid} (${this.component_name()}) gate rejected (ignored, load proceeds)`, result.reason);
3733
+ }
3734
+ }
3735
+ });
3736
+ // Race gate settlement against an early-resume signal (reload/refresh/stop while gated).
3737
+ return new Promise((resolve) => {
3738
+ this.__gate_resume = resolve;
3739
+ settled.then(() => resolve());
3740
+ }).then(() => {
3741
+ this.__gate_resume = null;
3742
+ });
3743
+ }
2866
3744
  /**
2867
3745
  * Load phase - Fetch asynchronous data
2868
3746
  * Called bottom-up, fully parallel
@@ -2885,9 +3763,13 @@ class Jqhtml_Component {
2885
3763
  this._update_debug_attrs();
2886
3764
  this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
2887
3765
  this.trigger('load');
3766
+ if (!this._load_only && !this._load_render_only) {
3767
+ await this._call_lifecycle('on_loaded');
3768
+ this.trigger('loaded');
3769
+ }
2888
3770
  return;
2889
3771
  }
2890
- // Check if component implements cache_id() for custom cache key
3772
+ // Generate cache key (needed for both preload check and load deduplication)
2891
3773
  let cache_key = null;
2892
3774
  let uncacheable_property;
2893
3775
  if (typeof this.cache_id === 'function') {
@@ -2906,6 +3788,19 @@ class Jqhtml_Component {
2906
3788
  cache_key = result.key;
2907
3789
  uncacheable_property = result.uncacheable_property;
2908
3790
  }
3791
+ // SSR preload check: if preloaded data exists for this component+args, use it
3792
+ if (cache_key !== null && has_preload_data()) {
3793
+ const preloaded_data = consume_preload_data(cache_key);
3794
+ if (preloaded_data !== null) {
3795
+ this._cache_key = cache_key;
3796
+ if (window.jqhtml?.debug?.verbose) {
3797
+ console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
3798
+ }
3799
+ const data_before_load = JSON.stringify(this.data);
3800
+ await this._apply_load_result(preloaded_data, data_before_load);
3801
+ return;
3802
+ }
3803
+ }
2909
3804
  // Store cache key for later use
2910
3805
  this._cache_key = cache_key;
2911
3806
  // If cache_key is null, args are not serializable - skip load deduplication and caching
@@ -2961,6 +3856,10 @@ class Jqhtml_Component {
2961
3856
  this._update_debug_attrs();
2962
3857
  this._log_lifecycle('load', 'complete (follower)');
2963
3858
  this.trigger('load');
3859
+ if (!this._load_only && !this._load_render_only) {
3860
+ await this._call_lifecycle('on_loaded');
3861
+ this.trigger('loaded');
3862
+ }
2964
3863
  return;
2965
3864
  }
2966
3865
  // This component is a leader - execute on_load() on detached proxy
@@ -2979,143 +3878,11 @@ class Jqhtml_Component {
2979
3878
  }
2980
3879
  /**
2981
3880
  * Execute on_load() on a fully detached proxy.
2982
- *
2983
- * The proxy has:
2984
- * - A CLONE of this.data (from __initial_data_snapshot)
2985
- * - Read-only access to this.args
2986
- * - No access to anything else (this.$, this.sid, etc.)
2987
- *
2988
- * This ensures on_load runs completely isolated from the component instance.
2989
- *
2990
- * @param use_load_coordinator - If true, register with Load_Coordinator for deduplication
2991
- * @returns The resulting data from the proxy after on_load completes
3881
+ * @see data-proxy.ts for full implementation
2992
3882
  * @private
2993
3883
  */
2994
3884
  async _execute_on_load_detached(use_load_coordinator = false) {
2995
- // Clone this.data from the snapshot captured after on_create()
2996
- const data_clone = this.__initial_data_snapshot
2997
- ? JSON.parse(JSON.stringify(this.__initial_data_snapshot))
2998
- : {};
2999
- // Create a read-only proxy for args that blocks all modifications
3000
- // Can't use JSON clone because args may contain functions (_slots, callbacks)
3001
- const create_readonly_proxy = (obj, path = 'this.args') => {
3002
- if (obj === null || typeof obj !== 'object')
3003
- return obj;
3004
- return new Proxy(obj, {
3005
- get(target, prop) {
3006
- const value = target[prop];
3007
- // Recursively wrap nested objects (but not functions)
3008
- if (value !== null && typeof value === 'object' && typeof value !== 'function') {
3009
- return create_readonly_proxy(value, `${path}.${String(prop)}`);
3010
- }
3011
- return value;
3012
- },
3013
- set(target, prop, value) {
3014
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify ${path}.${String(prop)} during on_load().\n\n` +
3015
- `RESTRICTION: this.args is READ-ONLY during on_load().\n\n` +
3016
- `WHY: this.args configures what data to fetch. Modifying it during on_load() creates circular dependencies.\n\n` +
3017
- `FIX: Modify this.args BEFORE on_load() runs (in on_create() or before calling reload()):\n` +
3018
- ` ❌ Inside on_load(): ${path}.${String(prop)} = ${JSON.stringify(value)};\n` +
3019
- ` ✅ In on_create(): this.args.${String(prop)} = defaultValue;`);
3020
- throw new Error(`[JQHTML] Cannot modify ${path}.${String(prop)} during on_load(). ` +
3021
- `this.args is read-only in on_load().`);
3022
- },
3023
- deleteProperty(target, prop) {
3024
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to delete ${path}.${String(prop)} during on_load().\n\n` +
3025
- `RESTRICTION: this.args is READ-ONLY during on_load().`);
3026
- throw new Error(`[JQHTML] Cannot delete ${path}.${String(prop)} during on_load(). ` +
3027
- `this.args is read-only in on_load().`);
3028
- }
3029
- });
3030
- };
3031
- // Create a detached context object that on_load will operate on
3032
- const detached_context = {
3033
- args: create_readonly_proxy(this.args), // Read-only proxy wrapping real args
3034
- data: data_clone // Cloned data - modifications stay isolated
3035
- };
3036
- // Create restricted proxy that operates on the detached context
3037
- const component_name = this.component_name();
3038
- const restricted_this = new Proxy(detached_context, {
3039
- get(target, prop) {
3040
- // Only allow access to args and data
3041
- if (prop === 'args') {
3042
- return target.args;
3043
- }
3044
- if (prop === 'data') {
3045
- return target.data;
3046
- }
3047
- // Block everything else
3048
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to access this.${String(prop)} during on_load().\n\n` +
3049
- `RESTRICTION: on_load() may ONLY access:\n` +
3050
- ` - this.args (read-only)\n` +
3051
- ` - this.data (read/write)\n\n` +
3052
- `WHY: on_load() is for data fetching only. All other component functionality should happen in other lifecycle methods.\n\n` +
3053
- `FIX:\n` +
3054
- ` - DOM manipulation → use on_render() or on_ready()\n` +
3055
- ` - Component methods → call them before/after on_load(), not inside it\n` +
3056
- ` - Other properties → restructure code to only use this.args and this.data in on_load()`);
3057
- throw new Error(`[JQHTML] Cannot access this.${String(prop)} during on_load(). ` +
3058
- `on_load() may only access this.args and this.data.`);
3059
- },
3060
- set(target, prop, value) {
3061
- // Only allow setting data
3062
- if (prop === 'data') {
3063
- target.data = value;
3064
- return true;
3065
- }
3066
- // Block setting args
3067
- if (prop === 'args') {
3068
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.args during on_load().\n\n` +
3069
- `RESTRICTION: on_load() may ONLY modify:\n` +
3070
- ` - this.data (read/write)\n\n` +
3071
- `WHY: this.args is component state that on_load() depends on. Modifying it inside on_load() creates circular dependencies.\n\n` +
3072
- `FIX: Modify this.args BEFORE calling on_load() (in on_create() or other lifecycle methods), not inside on_load():\n` +
3073
- ` ❌ Inside on_load(): this.args.filter = 'new_value';\n` +
3074
- ` ✅ In on_create(): this.args.filter = this.args.initial_filter || 'default';`);
3075
- throw new Error(`[JQHTML] Cannot modify this.args during on_load(). ` +
3076
- `Modify this.args in other lifecycle methods, not inside on_load().`);
3077
- }
3078
- // Block setting any other properties
3079
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.${String(prop)} during on_load().\n\n` +
3080
- `RESTRICTION: on_load() may ONLY modify:\n` +
3081
- ` - this.data (read/write)\n\n` +
3082
- `WHY: on_load() is for data fetching only. Setting properties on the component instance should happen in other lifecycle methods.\n\n` +
3083
- `FIX: Store your data in this.data instead:\n` +
3084
- ` ❌ this.${String(prop)} = value;\n` +
3085
- ` ✅ this.data.${String(prop)} = value;`);
3086
- throw new Error(`[JQHTML] Cannot modify this.${String(prop)} during on_load(). ` +
3087
- `Only this.data can be modified in on_load().`);
3088
- }
3089
- });
3090
- // Create promise for this on_load() call
3091
- const on_load_promise = (async () => {
3092
- try {
3093
- await this._call_lifecycle('on_load', restricted_this);
3094
- }
3095
- catch (error) {
3096
- if (use_load_coordinator) {
3097
- // Handle error and notify coordinator
3098
- Load_Coordinator.handle_leader_error(this, error);
3099
- }
3100
- throw error;
3101
- }
3102
- })();
3103
- // If using Load_Coordinator, register as leader
3104
- // Note: We store the completion function but DON'T call it here
3105
- // It will be called by _load() after _apply_load_result() updates this.data
3106
- let complete_coordination = null;
3107
- if (use_load_coordinator) {
3108
- complete_coordination = Load_Coordinator.register_leader(this, on_load_promise);
3109
- }
3110
- await on_load_promise;
3111
- // Note: We don't validate args changes here because external code (like parent
3112
- // components calling reload()) is allowed to modify component.args during on_load().
3113
- // The read-only proxy only prevents code INSIDE on_load() from modifying args.
3114
- // Return the data from the detached context AND the completion function
3115
- return {
3116
- data: detached_context.data,
3117
- complete_coordination
3118
- };
3885
+ return execute_on_load_detached(this, use_load_coordinator);
3119
3886
  }
3120
3887
  /**
3121
3888
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -3153,34 +3920,30 @@ class Jqhtml_Component {
3153
3920
  }
3154
3921
  // Freeze this.data
3155
3922
  this.__data_frozen = true;
3923
+ // SSR data capture: record this component's data for preloading
3924
+ if (is_capture_enabled() && this._has_on_load()) {
3925
+ capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
3926
+ }
3156
3927
  // Calculate if data changed
3157
3928
  const data_after_load = JSON.stringify(this.data);
3158
3929
  const data_changed = data_before_load !== null && data_after_load !== data_before_load;
3159
3930
  // Track if component is "dynamic" (this.data changed during on_load)
3160
3931
  // Used by HTML cache mode for synchronization - static parents don't block children
3161
3932
  this._is_dynamic = data_changed && data_after_load !== '{}';
3162
- // CACHE WRITE - If data changed during on_load(), write to cache based on mode
3163
- if (this._is_dynamic && this._cache_key) {
3164
- if (cache_mode === 'html') {
3165
- // HTML cache mode - flag to cache HTML after children ready in _ready()
3166
- this._should_cache_html_after_ready = true;
3167
- if (window.jqhtml?.debug?.verbose) {
3168
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) will cache HTML after ready`, { cache_key: this._cache_key });
3169
- }
3170
- }
3171
- else {
3172
- // Data cache mode (default) - write this.data to cache
3173
- Jqhtml_Local_Storage.set(this._cache_key, this.data);
3174
- if (window.jqhtml?.debug?.verbose) {
3175
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cached data after on_load()`, { cache_key: this._cache_key, data: this.data });
3176
- }
3177
- }
3178
- }
3933
+ // CACHE WRITE - @see component-cache.ts
3934
+ write_cache_on_loaded(this, this._is_dynamic);
3179
3935
  this._ready_state = 2;
3180
3936
  this._update_debug_attrs();
3181
3937
  this._log_lifecycle('load', 'complete');
3182
3938
  // Emit lifecycle event
3183
3939
  this.trigger('load');
3940
+ // Call on_loaded() - runs on the REAL component (not detached proxy)
3941
+ // this.data is frozen (read-only), but this.$, this.state, this.args are accessible
3942
+ // Suppressed by _load_only and _load_render_only flags (preloading mode)
3943
+ if (!this._load_only && !this._load_render_only) {
3944
+ await this._call_lifecycle('on_loaded');
3945
+ this.trigger('loaded');
3946
+ }
3184
3947
  }
3185
3948
  finally {
3186
3949
  // Signal next in queue
@@ -3196,32 +3959,11 @@ class Jqhtml_Component {
3196
3959
  if (this._stopped || this._ready_state >= 4)
3197
3960
  return;
3198
3961
  this._log_lifecycle('ready', 'start');
3199
- // HTML CACHE MODE - New synchronization architecture:
3200
- // 1. Wait for all children to complete on_render (post-on_load)
3201
- // 2. Take HTML snapshot BEFORE waiting for children ready
3202
- // 3. This ensures we capture the DOM after on_render but before on_ready manipulations
3962
+ // HTML CACHE MODE - Wait for children on_render, then snapshot
3963
+ // @see component-cache.ts for write_html_cache_snapshot implementation
3203
3964
  if (this._should_cache_html_after_ready && this._cache_key) {
3204
- // Wait for all children to complete their on_render
3205
3965
  await this._wait_for_children_on_render();
3206
- // Only cache if this component is dynamic (data changed during on_load)
3207
- // Static parents don't cache - they just let children proceed
3208
- if (this._is_dynamic) {
3209
- this._should_cache_html_after_ready = false;
3210
- // Cache the rendered HTML
3211
- const html = this.$.html();
3212
- const html_cache_key = `${this._cache_key}::html`;
3213
- Jqhtml_Local_Storage.set(html_cache_key, html);
3214
- if (window.jqhtml?.debug?.verbose) {
3215
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) cached HTML after children on_render complete`, { cache_key: html_cache_key, html_length: html.length });
3216
- }
3217
- }
3218
- else {
3219
- // Static component - just clear the flag, don't cache
3220
- this._should_cache_html_after_ready = false;
3221
- if (window.jqhtml?.debug?.verbose) {
3222
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) is static (no data change) - skipping HTML cache`);
3223
- }
3224
- }
3966
+ write_html_cache_snapshot(this);
3225
3967
  }
3226
3968
  // Wait for all children to reach ready state (bottom-up execution)
3227
3969
  await this._wait_for_children_ready();
@@ -3382,6 +4124,17 @@ class Jqhtml_Component {
3382
4124
  * If false, only re-render if data actually changed.
3383
4125
  */
3384
4126
  async reload(always_render) {
4127
+ // Load-gate short-circuit: if a gate is currently holding the first on_load,
4128
+ // reload()/refresh() resumes the paused boot lifecycle instead of running its
4129
+ // normal debounced re-fetch. The boot lifecycle then proceeds to run on_load
4130
+ // (the fresh fetch) exactly as reload() would want. Any later gate settlement
4131
+ // becomes a no-op. Return a promise that resolves when the component is ready.
4132
+ if (this.__gate_resume) {
4133
+ const resume = this.__gate_resume;
4134
+ this.__gate_resume = null;
4135
+ resume();
4136
+ return this.ready();
4137
+ }
3385
4138
  // Default to true if undefined
3386
4139
  const force_refresh = always_render !== undefined ? always_render : true;
3387
4140
  // State machine: reload(true) takes precedence over reload(false)
@@ -3394,11 +4147,9 @@ class Jqhtml_Component {
3394
4147
  this.next_reload_force_refresh = false;
3395
4148
  }
3396
4149
  }
3397
- // Lazy-create debounced _reload function on first use
3398
- if (!this._reload_debounced) {
3399
- this._reload_debounced = this._create_debounced_function(this._reload.bind(this), 0);
3400
- }
3401
- return this._reload_debounced();
4150
+ // Enqueue through component queue (replaces _create_debounced_function)
4151
+ // Same-type pending operations collapse (multiple reload() calls share one execution)
4152
+ return this._queue.enqueue('reload', () => this._reload());
3402
4153
  }
3403
4154
  /**
3404
4155
  * Refresh component - re-fetch data and re-render only if data changed (debounced)
@@ -3480,57 +4231,8 @@ class Jqhtml_Component {
3480
4231
  }
3481
4232
  }
3482
4233
  if (args_changed) {
3483
- // Check if component implements cache_id() for custom cache key
3484
- let cache_key = null;
3485
- if (typeof this.cache_id === 'function') {
3486
- try {
3487
- const custom_cache_id = this.cache_id();
3488
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
3489
- }
3490
- catch (error) {
3491
- // cache_id() threw error - disable caching
3492
- cache_key = null;
3493
- }
3494
- }
3495
- else {
3496
- // Use standard args-based cache key generation
3497
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
3498
- cache_key = result.key;
3499
- }
3500
- // Check for cached data/html when args changed
3501
- if (cache_key !== null) {
3502
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3503
- this._cache_key = cache_key;
3504
- if (cache_mode === 'html') {
3505
- // HTML cache mode - check for cached HTML
3506
- const html_cache_key = `${cache_key}::html`;
3507
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3508
- if (cached_html !== null && typeof cached_html === 'string') {
3509
- if (window.jqhtml?.debug?.verbose) {
3510
- console.log(`[Cache html] reload() - Component ${this._cid} (${this.component_name()}) found cached HTML (args changed)`, { cache_key: html_cache_key, html_length: cached_html.length });
3511
- }
3512
- // Store cached HTML for injection in _render()
3513
- this._cached_html = cached_html;
3514
- this.render();
3515
- rendered_from_cache = true;
3516
- }
3517
- }
3518
- else {
3519
- // Data cache mode (default) - check for cached data
3520
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
3521
- if (cached_data !== null && typeof cached_data === 'object' && JSON.stringify(cached_data) !== '{}') {
3522
- if (window.jqhtml?.debug?.verbose) {
3523
- console.log(`[Cache data] reload() - Component ${this._cid} (${this.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
3524
- }
3525
- // Hydrate this.data with cached data
3526
- this.__data_frozen = false;
3527
- this.data = cached_data;
3528
- this.__data_frozen = true;
3529
- this.render();
3530
- rendered_from_cache = true;
3531
- }
3532
- }
3533
- }
4234
+ // @see component-cache.ts for full implementation
4235
+ rendered_from_cache = check_cache_on_reload(this);
3534
4236
  }
3535
4237
  // Capture data state before on_load for comparison
3536
4238
  data_before_load = JSON.stringify(this.data);
@@ -3604,11 +4306,18 @@ class Jqhtml_Component {
3604
4306
  if (this._stopped)
3605
4307
  return;
3606
4308
  this._stopped = true;
4309
+ // If a load gate is holding the first on_load, release the wait so the boot
4310
+ // lifecycle unwinds cleanly (it will see _stopped and abandon the load).
4311
+ if (this.__gate_resume) {
4312
+ const resume = this.__gate_resume;
4313
+ this.__gate_resume = null;
4314
+ resume();
4315
+ }
3607
4316
  // Early bailout: skip expensive cleanup if no handlers registered
3608
4317
  // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
3609
4318
  const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
3610
- const has_destroy_callbacks = this._on_registered('destroy');
3611
- if (!has_custom_stop && !has_destroy_callbacks) {
4319
+ const has_stop_callbacks = this._on_registered('stop');
4320
+ if (!has_custom_stop && !has_stop_callbacks) {
3612
4321
  // Fast path: no cleanup logic defined, just mark as stopped
3613
4322
  this._lifecycle_manager.unregister_component(this);
3614
4323
  this._ready_state = 99;
@@ -3656,6 +4365,7 @@ class Jqhtml_Component {
3656
4365
  on_render() { }
3657
4366
  on_create() { }
3658
4367
  on_load() { } // Override to fetch data asynchronously
4368
+ on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
3659
4369
  async on_ready() { }
3660
4370
  on_stop() { }
3661
4371
  /**
@@ -3701,85 +4411,39 @@ class Jqhtml_Component {
3701
4411
  return this.constructor.name;
3702
4412
  }
3703
4413
  /**
3704
- * Register event callback
3705
- * Supports lifecycle events ('render', 'create', 'load', 'ready', 'stop') and custom events
3706
- * Lifecycle event callbacks fire after the lifecycle method completes
3707
- * If a lifecycle event has already occurred, the callback fires immediately AND registers for future occurrences
3708
- * Custom events only fire when explicitly triggered via .trigger()
3709
- *
3710
- * Callback signature: (component, data?) => void
3711
- * - component: The component instance that triggered the event
3712
- * - data: Optional data passed as second parameter to trigger()
4414
+ * Register event callback - delegates to component-events.ts
4415
+ * @see component-events.ts for full documentation
3713
4416
  */
3714
4417
  on(event_name, callback) {
3715
- // Initialize callback array for this event if needed
3716
- if (!this._lifecycle_callbacks.has(event_name)) {
3717
- this._lifecycle_callbacks.set(event_name, []);
3718
- }
3719
- // Add callback to queue
3720
- this._lifecycle_callbacks.get(event_name).push(callback);
3721
- // If this lifecycle event has already occurred, fire the callback immediately
3722
- // with the stored data from when trigger() was called
3723
- if (this._lifecycle_states.has(event_name)) {
3724
- try {
3725
- const stored_data = this._lifecycle_states.get(event_name);
3726
- callback(this, stored_data);
3727
- }
3728
- catch (error) {
3729
- console.error(`[JQHTML] Error in ${event_name} callback:`, error);
3730
- }
3731
- }
3732
- return this;
4418
+ return event_on(this, event_name, callback);
3733
4419
  }
3734
4420
  /**
3735
- * Trigger a lifecycle event - fires all registered callbacks
3736
- * Marks event as occurred so future .on() calls fire immediately
3737
- *
3738
- * @param event_name - Name of the event to trigger
3739
- * @param data - Optional data to pass to callbacks as second parameter
4421
+ * Register a callback that fires exactly once - delegates to component-events.ts
4422
+ * If the event already occurred, fires immediately and does not register.
4423
+ * @see component-events.ts for full documentation
4424
+ */
4425
+ once(event_name, callback) {
4426
+ return event_once(this, event_name, callback);
4427
+ }
4428
+ /**
4429
+ * Trigger a lifecycle event - delegates to component-events.ts
4430
+ * @see component-events.ts for full documentation
3740
4431
  */
3741
4432
  trigger(event_name, data) {
3742
- // Mark this event as occurred and store the data for late subscribers
3743
- this._lifecycle_states.set(event_name, data);
3744
- // Fire all registered callbacks for this event
3745
- const callbacks = this._lifecycle_callbacks.get(event_name);
3746
- if (callbacks) {
3747
- for (const callback of callbacks) {
3748
- try {
3749
- callback.bind(this)(this, data);
3750
- }
3751
- catch (error) {
3752
- console.error(`[JQHTML] Error in ${event_name} callback:`, error);
3753
- }
3754
- }
3755
- }
4433
+ event_trigger(this, event_name, data);
3756
4434
  }
3757
4435
  /**
3758
4436
  * Check if any callbacks are registered for a given event
3759
- * Used to determine if cleanup logic needs to run
3760
4437
  */
3761
4438
  _on_registered(event_name) {
3762
- const callbacks = this._lifecycle_callbacks.get(event_name);
3763
- return !!(callbacks && callbacks.length > 0);
4439
+ return event_on_registered(this, event_name);
3764
4440
  }
3765
4441
  /**
3766
- * Invalidate a lifecycle event - removes the "already occurred" marker
3767
- *
3768
- * This is the opposite of trigger(). After invalidate() is called:
3769
- * - New .on() handlers will NOT fire immediately
3770
- * - The ready() promise will NOT resolve immediately
3771
- * - Handlers wait for the next trigger() call
3772
- *
3773
- * Existing registered callbacks are NOT removed - they'll fire on next trigger().
3774
- *
3775
- * Use case: Call invalidate('ready') at the start of reload() or render()
3776
- * so that any new .on('ready') handlers wait for the reload/render to complete
3777
- * rather than firing immediately based on the previous lifecycle's state.
3778
- *
3779
- * @param event_name - Name of the event to invalidate
4442
+ * Invalidate a lifecycle event - delegates to component-events.ts
4443
+ * @see component-events.ts for full documentation
3780
4444
  */
3781
4445
  invalidate(event_name) {
3782
- this._lifecycle_states.delete(event_name);
4446
+ event_invalidate(this, event_name);
3783
4447
  }
3784
4448
  /**
3785
4449
  * Find element by scoped ID
@@ -4151,84 +4815,6 @@ class Jqhtml_Component {
4151
4815
  window.JQHTML_DEBUG.log(this.component_name(), 'debug', `${action}: ${args.map(a => JSON.stringify(a)).join(', ')}`);
4152
4816
  }
4153
4817
  }
4154
- /**
4155
- * Creates a debounced function with exclusivity and promise fan-in
4156
- *
4157
- * When invoked, immediately runs the callback exclusively.
4158
- * For subsequent invocations, applies a delay before running the callback exclusively again.
4159
- * The delay starts after the current asynchronous operation resolves.
4160
- *
4161
- * If delay is 0, the function only prevents enqueueing multiple executions,
4162
- * but will still run them immediately in an exclusive sequential manner.
4163
- *
4164
- * The most recent invocation's parameters are used when the function executes.
4165
- * Returns a promise that resolves when the next exclusive execution completes.
4166
- *
4167
- * @private
4168
- */
4169
- _create_debounced_function(callback, delay) {
4170
- let running = false;
4171
- let queued = false;
4172
- let last_end_time = 0; // timestamp of last completed run
4173
- let timer = null;
4174
- let next_args = [];
4175
- let resolve_queue = [];
4176
- let reject_queue = [];
4177
- const run_function = async () => {
4178
- const these_resolves = resolve_queue;
4179
- const these_rejects = reject_queue;
4180
- const args = next_args;
4181
- resolve_queue = [];
4182
- reject_queue = [];
4183
- next_args = [];
4184
- queued = false;
4185
- running = true;
4186
- try {
4187
- const result = await callback(...args);
4188
- for (const resolve of these_resolves)
4189
- resolve(result);
4190
- }
4191
- catch (err) {
4192
- for (const reject of these_rejects)
4193
- reject(err);
4194
- }
4195
- finally {
4196
- running = false;
4197
- last_end_time = Date.now();
4198
- if (queued) {
4199
- clearTimeout(timer);
4200
- timer = setTimeout(run_function, Math.max(delay, 0));
4201
- }
4202
- else {
4203
- timer = null;
4204
- }
4205
- }
4206
- };
4207
- return function (...args) {
4208
- next_args = args;
4209
- return new Promise((resolve, reject) => {
4210
- resolve_queue.push(resolve);
4211
- reject_queue.push(reject);
4212
- // Nothing running and nothing scheduled
4213
- if (!running && !timer) {
4214
- const first_call = last_end_time === 0;
4215
- const since = first_call ? Infinity : Date.now() - last_end_time;
4216
- if (since >= delay) {
4217
- run_function();
4218
- }
4219
- else {
4220
- const wait = Math.max(delay - since, 0);
4221
- clearTimeout(timer);
4222
- timer = setTimeout(run_function, wait);
4223
- }
4224
- return;
4225
- }
4226
- // If already running or timer exists, just mark queued
4227
- // The finally{} of run_function handles scheduling after full delay
4228
- queued = true;
4229
- });
4230
- };
4231
- }
4232
4818
  }
4233
4819
  // Static properties
4234
4820
  Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() detection
@@ -4884,6 +5470,61 @@ function init_jquery_plugin(jQuery) {
4884
5470
  const originalEmpty = jQuery.fn.empty;
4885
5471
  const originalHtml = jQuery.fn.html;
4886
5472
  const originalText = jQuery.fn.text;
5473
+ // ---------------------------------------------------------------------------
5474
+ // Guard against hand-creating elements with data-sid via jQuery.
5475
+ //
5476
+ // $sid / data-sid is provisioned ONLY by the jqhtml template renderer, which
5477
+ // ALWAYS emits data-sid paired with a scoped id ("<sid>:<component_cid>").
5478
+ // That scoped id is what binds the element to its component; data-sid on its
5479
+ // own does nothing. So an element whose data-sid is NOT paired with such an id
5480
+ // was hand-written (e.g. .html('<div data-sid="x">')) and the framework never
5481
+ // provisioned it - this.$sid('x') can never find it. We detect exactly that.
5482
+ //
5483
+ // The framework's own rendering injects data-sid via native innerHTML, never
5484
+ // through these jQuery methods, and the one internal jQuery .html() copy
5485
+ // (the .component() tag-mismatch path) carries the provisioned id - so neither
5486
+ // trips this guard.
5487
+ // ---------------------------------------------------------------------------
5488
+ // Return the offending data-sid value if `html` contains a hand-written
5489
+ // data-sid element (data-sid without the scoped "<sid>:<cid>" id), else null.
5490
+ const find_manual_sid = (html) => {
5491
+ if (typeof html !== 'string' || html.indexOf('data-sid') === -1)
5492
+ return null;
5493
+ if (typeof document === 'undefined')
5494
+ return null; // no DOM to parse (SSR)
5495
+ const tpl = document.createElement('template');
5496
+ tpl.innerHTML = html; // native parse - does not route back through jQuery
5497
+ const candidates = tpl.content.querySelectorAll('[data-sid]');
5498
+ for (let i = 0; i < candidates.length; i++) {
5499
+ const el = candidates[i];
5500
+ const sid = el.getAttribute('data-sid') || '';
5501
+ const id = el.getAttribute('id') || '';
5502
+ // Provisioned elements have id="<sid>:<cid>". Anything else is hand-written.
5503
+ if (id.indexOf(sid + ':') !== 0)
5504
+ return sid;
5505
+ }
5506
+ return null;
5507
+ };
5508
+ const assert_no_manual_sid = (value, where) => {
5509
+ const sid = find_manual_sid(value);
5510
+ if (sid === null)
5511
+ return;
5512
+ throw new Error(`[JQHTML] Manually creating an element with data-sid="${sid}" via jQuery (${where}) is not allowed.\n\n` +
5513
+ `data-sid is NOT a normal attribute you can hand-write. It is provisioned by the jqhtml template\n` +
5514
+ `renderer, which scopes the element to its component by pairing data-sid with a unique id\n` +
5515
+ `("${sid}:<component_cid>"). An element you create yourself has no such scoped id, so\n` +
5516
+ `this.$sid('${sid}') / this.sid('${sid}') can never find it - scoping to a component is the entire\n` +
5517
+ `point of data-sid.\n\n` +
5518
+ `CORRECT USAGE:\n` +
5519
+ ` • Put the element in your component template with $sid:\n` +
5520
+ ` <div $sid="${sid}"></div>\n` +
5521
+ ` then access it from the component: this.$sid('${sid}')\n` +
5522
+ ` • Need it created dynamically? Use a child component or a $redrawable element in the template\n` +
5523
+ ` and render() it, instead of injecting raw HTML.\n\n` +
5524
+ `If you only need to select or mark a plain element, use a normal attribute or class instead:\n` +
5525
+ ` • <div data-role="${sid}"> → .find('[data-role="${sid}"]')\n` +
5526
+ ` • <div class="MyComponent__${sid}"> → .find('.MyComponent__${sid}')`);
5527
+ };
4887
5528
  /**
4888
5529
  * Override jQuery.fn.empty() to stop child components before clearing DOM
4889
5530
  * This ensures component cleanup hooks fire when DOM is cleared
@@ -4910,6 +5551,8 @@ function init_jquery_plugin(jQuery) {
4910
5551
  if (arguments.length === 0) {
4911
5552
  return originalHtml.call(this);
4912
5553
  }
5554
+ // Reject hand-written data-sid before mutating the DOM
5555
+ assert_no_manual_sid(value, '.html()');
4913
5556
  // Setter - empty first (which stops components), then set content
4914
5557
  return this.each(function () {
4915
5558
  jQuery(this).empty();
@@ -5012,6 +5655,33 @@ function init_jquery_plugin(jQuery) {
5012
5655
  // Pass through to original .find()
5013
5656
  return originalFind.call(this, selector);
5014
5657
  };
5658
+ // Guard the content-insertion methods that build elements from HTML strings,
5659
+ // plus $() construction. Wrapped once so repeated init() calls don't re-wrap.
5660
+ if (!jQuery.fn.__jqhtml_sid_guarded) {
5661
+ jQuery.fn.__jqhtml_sid_guarded = true;
5662
+ // .append('<div data-sid>'), .prepend, .before, .after, .replaceWith
5663
+ for (const name of ['append', 'prepend', 'before', 'after', 'replaceWith']) {
5664
+ const original = jQuery.fn[name];
5665
+ if (typeof original !== 'function')
5666
+ continue;
5667
+ jQuery.fn[name] = function (...args) {
5668
+ for (const arg of args)
5669
+ assert_no_manual_sid(arg, '.' + name + '()');
5670
+ return original.apply(this, args);
5671
+ };
5672
+ }
5673
+ // $('<div data-sid="x">') - catch the element at construction time. A
5674
+ // selector like $('[data-sid="x"]') parses to no data-sid element, so it is
5675
+ // not flagged here (selector misuse is handled by .find()/.on()).
5676
+ const originalInit = jQuery.fn.init;
5677
+ const PatchedInit = function (selector, context, root) {
5678
+ if (typeof selector === 'string')
5679
+ assert_no_manual_sid(selector, '$()');
5680
+ return new originalInit(selector, context, root);
5681
+ };
5682
+ PatchedInit.prototype = jQuery.fn;
5683
+ jQuery.fn.init = PatchedInit;
5684
+ }
5015
5685
  }
5016
5686
  // Try to auto-initialize if global jQuery exists
5017
5687
  if (typeof window !== 'undefined' && window.jQuery) {
@@ -5039,7 +5709,7 @@ function init(jQuery) {
5039
5709
  }
5040
5710
  }
5041
5711
  // Version - will be replaced during build with actual version from package.json
5042
- const version = '2.3.35';
5712
+ const version = '2.3.50';
5043
5713
  // Default export with all functionality
5044
5714
  const jqhtml = {
5045
5715
  // Core
@@ -5136,7 +5806,13 @@ const jqhtml = {
5136
5806
  // Required for ES6 class instances to be stored and restored from localStorage
5137
5807
  register_cache_class,
5138
5808
  // Boot - hydrate server-rendered component placeholders
5139
- boot
5809
+ boot,
5810
+ // SSR Preload - capture data during SSR, replay on client to skip on_load()
5811
+ start_data_capture,
5812
+ get_captured_data,
5813
+ stop_data_capture,
5814
+ set_preload_data,
5815
+ clear_preload_data
5140
5816
  };
5141
5817
  // Auto-register on window for browser environments
5142
5818
  // This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -5166,5 +5842,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
5166
5842
  }
5167
5843
  }
5168
5844
 
5169
- export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, version };
5845
+ export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, clear_preload_data, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
5170
5846
  //# sourceMappingURL=index.js.map