@jqhtml/core 2.3.10 → 2.3.13

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.cjs CHANGED
@@ -1126,6 +1126,15 @@ class Jqhtml_Component {
1126
1126
  this.__data_frozen = false; // Track if this.data is currently frozen
1127
1127
  this.next_reload_force_refresh = null; // State machine for reload()/refresh() debounce precedence
1128
1128
  this.__lifecycle_authorized = false; // Flag for lifecycle method protection
1129
+ // Cache mode properties
1130
+ this._cache_key = null; // Cache key for caching
1131
+ // 'html' mode caching
1132
+ this._cached_html = null; // Cached HTML to inject on first render
1133
+ this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
1134
+ this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
1135
+ this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
1136
+ // on_render synchronization (HTML cache mode)
1137
+ this._on_render_complete = false; // True after on_render() has been called post-on_load
1129
1138
  this._cid = this._generate_cid();
1130
1139
  this._lifecycle_manager = LifecycleManager.get_instance();
1131
1140
  // Create or wrap element
@@ -1356,6 +1365,40 @@ class Jqhtml_Component {
1356
1365
  `The framework will automatically re-render if this.data changes during on_load().`);
1357
1366
  }
1358
1367
  this._log_lifecycle('render', 'start');
1368
+ // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
1369
+ if (this._cached_html !== null) {
1370
+ if (window.jqhtml?.debug?.verbose) {
1371
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
1372
+ }
1373
+ // Inject cached HTML directly
1374
+ this.$[0].innerHTML = this._cached_html;
1375
+ // Mark that we used cached HTML (forces re-render after on_load)
1376
+ this._used_cached_html = true;
1377
+ // Clear cached HTML so next render uses template
1378
+ this._cached_html = null;
1379
+ // Mark first render complete
1380
+ this._did_first_render = true;
1381
+ this._log_lifecycle('render', 'complete (cached HTML)');
1382
+ // NEW ARCHITECTURE: Call on_render() even after cache inject
1383
+ // This ensures consistent behavior - on_render() always runs after DOM update
1384
+ // Note: this.data has defaults from on_create(), not fresh data yet
1385
+ const cacheRenderResult = this._call_lifecycle_sync('on_render');
1386
+ if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
1387
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
1388
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
1389
+ }
1390
+ // Emit lifecycle event
1391
+ this.trigger('render');
1392
+ // Store args/data snapshots for later comparison
1393
+ try {
1394
+ this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
1395
+ }
1396
+ catch (error) {
1397
+ this._args_on_last_render = null;
1398
+ }
1399
+ this._data_on_last_render = JSON.stringify(this.data);
1400
+ return current_render_id;
1401
+ }
1359
1402
  // Determine child-finding strategy: If component is off-DOM, children can't register
1360
1403
  // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
1361
1404
  // If attached to DOM, children register normally and we can use the fast _dom_children path.
@@ -1523,6 +1566,12 @@ class Jqhtml_Component {
1523
1566
  this._log_lifecycle('render', 'complete');
1524
1567
  // Call on_render() with authorization (sync) immediately after render completes
1525
1568
  // This ensures event handlers are always re-attached after DOM updates
1569
+ //
1570
+ // NEW ARCHITECTURE: on_render() can now access this.data normally
1571
+ // The HTML cache mode now properly synchronizes - on_render() runs after both:
1572
+ // 1. Cache inject (with on_create() defaults)
1573
+ // 2. Second render (with fresh data from on_load())
1574
+ // Since on_render() always runs with appropriate data, no proxy restriction needed
1526
1575
  const renderResult = this._call_lifecycle_sync('on_render');
1527
1576
  if (renderResult && typeof renderResult.then === 'function') {
1528
1577
  console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
@@ -1543,6 +1592,12 @@ class Jqhtml_Component {
1543
1592
  }
1544
1593
  // Store data snapshot for refresh() comparison
1545
1594
  this._data_on_last_render = JSON.stringify(this.data);
1595
+ // HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
1596
+ // This signals to parent components that this component's DOM is fully rendered
1597
+ // with fresh data and ready for HTML snapshot
1598
+ if (this._ready_state >= 2) {
1599
+ this._on_render_complete = true;
1600
+ }
1546
1601
  // Return the render ID so callers can check if this render is still current
1547
1602
  return current_render_id;
1548
1603
  }
@@ -1616,8 +1671,8 @@ class Jqhtml_Component {
1616
1671
  `on_create() must be synchronous code. Remove 'async' from the function declaration.`);
1617
1672
  await result; // Still wait for it to avoid breaking existing code
1618
1673
  }
1619
- // CACHE READ - Hydrate this.data from cache BEFORE first render
1620
- // This happens after on_create() but before render, allowing instant first render with cached data
1674
+ // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
1675
+ // This happens after on_create() but before render, allowing instant first render
1621
1676
  const { Load_Coordinator } = await Promise.resolve().then(function () { return loadCoordinator; });
1622
1677
  const { Jqhtml_Local_Storage } = await Promise.resolve().then(function () { return localStorage$1; });
1623
1678
  // Check if component implements cache_id() for custom cache key
@@ -1648,21 +1703,48 @@ class Jqhtml_Component {
1648
1703
  if (window.jqhtml?.debug?.verbose) {
1649
1704
  console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
1650
1705
  }
1651
- return; // Skip cache check
1652
- }
1653
- if (window.jqhtml?.debug?.verbose) {
1654
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) checking cache in create()`, { cache_key, has_cache_key_set: Jqhtml_Local_Storage.has_cache_key() });
1655
- }
1656
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
1657
- if (cached_data !== null) {
1658
- this.data = cached_data;
1659
- if (window.jqhtml?.debug?.verbose) {
1660
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) hydrated from cache in create()`, { cache_key, data: this.data });
1661
- }
1706
+ // Don't return - continue to snapshot this.data for on_load restoration
1662
1707
  }
1663
1708
  else {
1709
+ // Store cache key for later use
1710
+ this._cache_key = cache_key;
1711
+ // Get cache mode
1712
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
1664
1713
  if (window.jqhtml?.debug?.verbose) {
1665
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) cache miss in create()`, { cache_key });
1714
+ 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() });
1715
+ }
1716
+ if (cache_mode === 'html') {
1717
+ // HTML cache mode - check for cached HTML to inject on first render
1718
+ const html_cache_key = `${cache_key}::html`;
1719
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
1720
+ if (cached_html !== null && typeof cached_html === 'string') {
1721
+ // Store cached HTML for injection in _render()
1722
+ this._cached_html = cached_html;
1723
+ if (window.jqhtml?.debug?.verbose) {
1724
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
1725
+ }
1726
+ }
1727
+ else {
1728
+ if (window.jqhtml?.debug?.verbose) {
1729
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key: html_cache_key });
1730
+ }
1731
+ }
1732
+ }
1733
+ else {
1734
+ // Data cache mode (default) - check for cached data to hydrate this.data
1735
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
1736
+ if (cached_data !== null && typeof cached_data === 'object') {
1737
+ // Hydrate this.data with cached data
1738
+ this.data = cached_data;
1739
+ if (window.jqhtml?.debug?.verbose) {
1740
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) hydrated from cache`, { cache_key, data: cached_data });
1741
+ }
1742
+ }
1743
+ else {
1744
+ if (window.jqhtml?.debug?.verbose) {
1745
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key });
1746
+ }
1747
+ }
1666
1748
  }
1667
1749
  }
1668
1750
  // Snapshot this.data after on_create() completes
@@ -1852,8 +1934,7 @@ class Jqhtml_Component {
1852
1934
  // Always clear loading flag and complete coordination
1853
1935
  this.__loading = false;
1854
1936
  complete_coordination();
1855
- // Freeze this.data after on_load() completes
1856
- this.__data_frozen = true;
1937
+ // Note: this.data stays unfrozen until after normalization below
1857
1938
  }
1858
1939
  // Validate that on_load() only modified this.data
1859
1940
  let argsAfterLoad = null;
@@ -1882,12 +1963,43 @@ class Jqhtml_Component {
1882
1963
  ` ❌ this.${newProperties[0]} = value;\n` +
1883
1964
  ` ✅ this.data.${newProperties[0]} = value;`);
1884
1965
  }
1885
- // Check if data changed during on_load() - if so, update cache (but not if empty)
1886
- const data_after_load = JSON.stringify(this.data);
1887
- if (data_after_load !== data_before_load && data_after_load !== '{}') {
1888
- Jqhtml_Local_Storage.set(cache_key, this.data);
1966
+ // DATA MODE: Normalize this.data through serialize/deserialize round-trip
1967
+ // This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
1968
+ // (restored from cache). Unregistered class instances become plain objects immediately,
1969
+ // so developers catch missing class registrations during development rather than
1970
+ // only after a page reload when data comes from cache.
1971
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
1972
+ if (cache_mode === 'data') {
1973
+ const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
1974
+ this.data = normalized;
1889
1975
  if (window.jqhtml?.debug?.verbose) {
1890
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) updated cache after on_load()`, { cache_key, data: this.data });
1976
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
1977
+ }
1978
+ }
1979
+ // Freeze this.data after normalization completes
1980
+ this.__data_frozen = true;
1981
+ // CACHE WRITE - If data changed during on_load(), write to cache based on mode
1982
+ const data_after_load = JSON.stringify(this.data);
1983
+ const data_changed = data_after_load !== data_before_load;
1984
+ // Track if component is "dynamic" (this.data changed during on_load)
1985
+ // Used by HTML cache mode for synchronization - static parents don't block children
1986
+ this._is_dynamic = data_changed && data_after_load !== '{}';
1987
+ if (this._is_dynamic) {
1988
+ if (this._cache_key) {
1989
+ if (cache_mode === 'html') {
1990
+ // HTML cache mode - flag to cache HTML after children ready in _ready()
1991
+ this._should_cache_html_after_ready = true;
1992
+ if (window.jqhtml?.debug?.verbose) {
1993
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) will cache HTML after ready`, { cache_key: this._cache_key });
1994
+ }
1995
+ }
1996
+ else {
1997
+ // Data cache mode (default) - write this.data to cache
1998
+ Jqhtml_Local_Storage.set(this._cache_key, this.data);
1999
+ if (window.jqhtml?.debug?.verbose) {
2000
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cached data after on_load()`, { cache_key: this._cache_key, data: this.data });
2001
+ }
2002
+ }
1891
2003
  }
1892
2004
  }
1893
2005
  this._ready_state = 2;
@@ -1905,6 +2017,34 @@ class Jqhtml_Component {
1905
2017
  if (this._stopped || this._ready_state >= 4)
1906
2018
  return;
1907
2019
  this._log_lifecycle('ready', 'start');
2020
+ // HTML CACHE MODE - New synchronization architecture:
2021
+ // 1. Wait for all children to complete on_render (post-on_load)
2022
+ // 2. Take HTML snapshot BEFORE waiting for children ready
2023
+ // 3. This ensures we capture the DOM after on_render but before on_ready manipulations
2024
+ if (this._should_cache_html_after_ready && this._cache_key) {
2025
+ // Wait for all children to complete their on_render
2026
+ await this._wait_for_children_on_render();
2027
+ // Only cache if this component is dynamic (data changed during on_load)
2028
+ // Static parents don't cache - they just let children proceed
2029
+ if (this._is_dynamic) {
2030
+ this._should_cache_html_after_ready = false;
2031
+ // Cache the rendered HTML (async import to avoid circular dependency)
2032
+ const { Jqhtml_Local_Storage } = await Promise.resolve().then(function () { return localStorage$1; });
2033
+ const html = this.$.html();
2034
+ const html_cache_key = `${this._cache_key}::html`;
2035
+ Jqhtml_Local_Storage.set(html_cache_key, html);
2036
+ if (window.jqhtml?.debug?.verbose) {
2037
+ 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 });
2038
+ }
2039
+ }
2040
+ else {
2041
+ // Static component - just clear the flag, don't cache
2042
+ this._should_cache_html_after_ready = false;
2043
+ if (window.jqhtml?.debug?.verbose) {
2044
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) is static (no data change) - skipping HTML cache`);
2045
+ }
2046
+ }
2047
+ }
1908
2048
  // Wait for all children to reach ready state (bottom-up execution)
1909
2049
  await this._wait_for_children_ready();
1910
2050
  await this._call_lifecycle('on_ready');
@@ -1985,6 +2125,47 @@ class Jqhtml_Component {
1985
2125
  // Wait for all children to be ready
1986
2126
  await Promise.all(ready_promises);
1987
2127
  }
2128
+ /**
2129
+ * Wait for all child components to complete on_render (post-on_load)
2130
+ * Used by HTML cache mode to ensure DOM is fully rendered before taking snapshot
2131
+ *
2132
+ * HTML CACHE ARCHITECTURE:
2133
+ * - Parent waits for all children to complete their on_render after on_load
2134
+ * - This ensures the HTML snapshot captures fully rendered DOM
2135
+ * - Static parents (is_dynamic=false) don't block - they immediately let children proceed
2136
+ *
2137
+ * @private
2138
+ */
2139
+ async _wait_for_children_on_render() {
2140
+ const children = this._get_dom_children();
2141
+ if (children.length === 0) {
2142
+ return; // No children, nothing to wait for
2143
+ }
2144
+ // Create promises for each child that hasn't completed on_render yet
2145
+ const render_promises = [];
2146
+ for (const child of children) {
2147
+ // If child already completed on_render post-on_load, skip
2148
+ if (child._on_render_complete) {
2149
+ continue;
2150
+ }
2151
+ // Create promise that resolves when child completes on_render
2152
+ const render_promise = new Promise((resolve) => {
2153
+ // Poll for completion (simple approach - could use events for more efficiency)
2154
+ const check = () => {
2155
+ if (child._on_render_complete || child._stopped) {
2156
+ resolve();
2157
+ }
2158
+ else {
2159
+ setTimeout(check, 10);
2160
+ }
2161
+ };
2162
+ check();
2163
+ });
2164
+ render_promises.push(render_promise);
2165
+ }
2166
+ // Wait for all children to complete on_render
2167
+ await Promise.all(render_promises);
2168
+ }
1988
2169
  /**
1989
2170
  * Reload component - re-fetch data and re-render (debounced)
1990
2171
  *
@@ -2097,19 +2278,38 @@ class Jqhtml_Component {
2097
2278
  const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
2098
2279
  cache_key = result.key;
2099
2280
  }
2100
- // Only use cache if args are serializable
2281
+ // Check for cached data/html when args changed
2101
2282
  if (cache_key !== null) {
2102
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
2103
- if (cached_data !== null && JSON.stringify(cached_data) !== '{}') {
2104
- if (window.jqhtml?.debug?.verbose) {
2105
- console.log(`[Cache] reload() - Component ${this._cid} (${this.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
2283
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2284
+ this._cache_key = cache_key;
2285
+ if (cache_mode === 'html') {
2286
+ // HTML cache mode - check for cached HTML
2287
+ const html_cache_key = `${cache_key}::html`;
2288
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2289
+ if (cached_html !== null && typeof cached_html === 'string') {
2290
+ if (window.jqhtml?.debug?.verbose) {
2291
+ 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 });
2292
+ }
2293
+ // Store cached HTML for injection in _render()
2294
+ this._cached_html = cached_html;
2295
+ this.render();
2296
+ rendered_from_cache = true;
2297
+ }
2298
+ }
2299
+ else {
2300
+ // Data cache mode (default) - check for cached data
2301
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
2302
+ if (cached_data !== null && typeof cached_data === 'object' && JSON.stringify(cached_data) !== '{}') {
2303
+ if (window.jqhtml?.debug?.verbose) {
2304
+ console.log(`[Cache data] reload() - Component ${this._cid} (${this.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
2305
+ }
2306
+ // Hydrate this.data with cached data
2307
+ this.__data_frozen = false;
2308
+ this.data = cached_data;
2309
+ this.__data_frozen = true;
2310
+ this.render();
2311
+ rendered_from_cache = true;
2106
2312
  }
2107
- // Unfreeze to set cached data, then re-freeze
2108
- this.__data_frozen = false;
2109
- this.data = cached_data;
2110
- this.__data_frozen = true;
2111
- await this.render();
2112
- rendered_from_cache = true;
2113
2313
  }
2114
2314
  }
2115
2315
  }
@@ -2129,12 +2329,25 @@ class Jqhtml_Component {
2129
2329
  // Freeze this.data after on_load() completes
2130
2330
  this.__data_frozen = true;
2131
2331
  }
2332
+ // Import for cache operations
2333
+ const { Load_Coordinator } = await Promise.resolve().then(function () { return loadCoordinator; });
2334
+ const { Jqhtml_Local_Storage } = await Promise.resolve().then(function () { return localStorage$1; });
2335
+ // DATA MODE: Normalize this.data through serialize/deserialize round-trip
2336
+ // (Same as in _load() - ensures hot/cold cache parity)
2337
+ const reload_cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2338
+ if (reload_cache_mode === 'data') {
2339
+ this.__data_frozen = false;
2340
+ const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
2341
+ this.data = normalized;
2342
+ this.__data_frozen = true;
2343
+ if (window.jqhtml?.debug?.verbose) {
2344
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load() in reload()`, { data: this.data });
2345
+ }
2346
+ }
2132
2347
  // Check if data changed during on_load() - if so, update cache (but not if empty)
2133
2348
  const data_after_load = JSON.stringify(this.data);
2134
2349
  const data_changed = data_after_load !== data_before_load;
2135
2350
  if (data_changed && data_after_load !== '{}') {
2136
- const { Load_Coordinator } = await Promise.resolve().then(function () { return loadCoordinator; });
2137
- const { Jqhtml_Local_Storage } = await Promise.resolve().then(function () { return localStorage$1; });
2138
2351
  // Check if component implements cache_id() for custom cache key
2139
2352
  let cache_key = null;
2140
2353
  if (typeof this.cache_id === 'function') {
@@ -2152,11 +2365,22 @@ class Jqhtml_Component {
2152
2365
  const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
2153
2366
  cache_key = result.key;
2154
2367
  }
2155
- // Only update cache if args are serializable
2368
+ // Write to cache based on mode
2156
2369
  if (cache_key !== null) {
2157
- Jqhtml_Local_Storage.set(cache_key, this.data);
2158
- if (window.jqhtml?.debug?.verbose) {
2159
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) updated cache after on_load() in reload()`, { cache_key, data: this.data });
2370
+ this._cache_key = cache_key;
2371
+ if (reload_cache_mode === 'html') {
2372
+ // HTML cache mode - flag to cache HTML after children ready in _ready()
2373
+ this._should_cache_html_after_ready = true;
2374
+ if (window.jqhtml?.debug?.verbose) {
2375
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) will cache HTML after ready in reload()`, { cache_key });
2376
+ }
2377
+ }
2378
+ else {
2379
+ // Data cache mode (default) - write this.data to cache
2380
+ Jqhtml_Local_Storage.set(cache_key, this.data);
2381
+ if (window.jqhtml?.debug?.verbose) {
2382
+ console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cached data after on_load() in reload()`, { cache_key, data: this.data });
2383
+ }
2160
2384
  }
2161
2385
  }
2162
2386
  }
@@ -2282,6 +2506,15 @@ class Jqhtml_Component {
2282
2506
  * @private
2283
2507
  */
2284
2508
  _should_rerender() {
2509
+ // HTML CACHE MODE - If we used cached HTML, always re-render to get live components
2510
+ if (this._used_cached_html) {
2511
+ if (window.jqhtml?.debug?.verbose) {
2512
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) forcing re-render after cached HTML`);
2513
+ }
2514
+ // Clear the flag
2515
+ this._used_cached_html = false;
2516
+ return true;
2517
+ }
2285
2518
  // Compare current data state with data state before initial render
2286
2519
  const currentDataState = JSON.stringify(this.data);
2287
2520
  const dataChanged = this._data_before_render !== currentDataState;
@@ -3509,6 +3742,13 @@ if (typeof window !== 'undefined' && window.jQuery) {
3509
3742
  * - **Quota management**: Auto-clears storage when full and retries operation
3510
3743
  * - **Scope validation**: Clears storage when cache key changes
3511
3744
  * - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
3745
+ * - **Class-aware serialization**: ES6 class instances serialize and restore properly
3746
+ *
3747
+ * Class-Aware Serialization:
3748
+ * ES6 class instances can be serialized and deserialized if registered via
3749
+ * register_cache_class(). Classes are wrapped as {__jqhtml_class__: "Name", __jqhtml_props__: {...}}
3750
+ * and restored to proper instances on retrieval. Nested class instances in objects
3751
+ * and arrays are handled recursively.
3512
3752
  *
3513
3753
  * Scoping Strategy:
3514
3754
  * Storage is scoped by a user-provided cache key (typically a session identifier,
@@ -3530,13 +3770,20 @@ if (typeof window !== 'undefined' && window.jQuery) {
3530
3770
  * once. This ensures the application continues functioning even when storage is full.
3531
3771
  *
3532
3772
  * Usage:
3773
+ * // Register classes that need to be cached (call once at startup)
3774
+ * jqhtml.register_cache_class(Contact_Model);
3775
+ * jqhtml.register_cache_class(User_Profile);
3776
+ *
3533
3777
  * // Must set cache key first (typically done once on page load)
3534
3778
  * Jqhtml_Local_Storage.set_cache_key('user_123');
3535
3779
  *
3536
- * // Then use storage normally
3537
- * Jqhtml_Local_Storage.set('cached_component', {html: '...', timestamp: Date.now()});
3780
+ * // Then use storage normally - ES6 classes serialize automatically
3781
+ * Jqhtml_Local_Storage.set('cached_component', {
3782
+ * contact: new Contact_Model(),
3783
+ * timestamp: Date.now()
3784
+ * });
3538
3785
  * const cached = Jqhtml_Local_Storage.get('cached_component');
3539
- * Jqhtml_Local_Storage.remove('cached_component');
3786
+ * // cached.contact instanceof Contact_Model === true
3540
3787
  *
3541
3788
  * IMPORTANT - Volatile Storage:
3542
3789
  * Storage can be cleared at any time due to:
@@ -3552,14 +3799,290 @@ if (typeof window !== 'undefined' && window.jQuery) {
3552
3799
  *
3553
3800
  * @internal This class is not exposed in the public API
3554
3801
  */
3802
+ // ============================================================================
3803
+ // Class Registry for Serialization
3804
+ // ============================================================================
3805
+ // Registry mapping class names to constructors
3806
+ const class_registry = Object.create(null);
3807
+ // Markers for serialized class instances (unique to avoid collisions with user data)
3808
+ const CLASS_MARKER = '__jqhtml_class__';
3809
+ const PROPS_MARKER = '__jqhtml_props__';
3810
+ /**
3811
+ * Register a class for cache serialization/deserialization.
3812
+ * Must be called before attempting to cache instances of this class.
3813
+ *
3814
+ * @param klass - The class constructor to register
3815
+ * @throws Error if klass is not a named function/class
3816
+ */
3817
+ function register_cache_class(klass) {
3818
+ if (typeof klass !== 'function' || !klass.name) {
3819
+ throw new Error('[JQHTML Cache] register_cache_class requires a named class constructor');
3820
+ }
3821
+ class_registry[klass.name] = klass;
3822
+ }
3823
+ // ============================================================================
3824
+ // Serialization (Object → String)
3825
+ // ============================================================================
3826
+ /**
3827
+ * Serialize a value to JSON string, handling ES6 class instances recursively.
3828
+ * Returns null if serialization fails for any reason.
3829
+ *
3830
+ * @param value - The value to serialize
3831
+ * @param verbose - Whether to log warnings
3832
+ * @returns Serialized JSON string, or null if serialization failed
3833
+ */
3834
+ function serialize_value(value, verbose) {
3835
+ try {
3836
+ const seen = new WeakSet();
3837
+ const processed = process_for_serialization(value, verbose, seen);
3838
+ if (processed === undefined) {
3839
+ // Serialization failed - undefined signals failure
3840
+ return null;
3841
+ }
3842
+ return JSON.stringify(processed);
3843
+ }
3844
+ catch (e) {
3845
+ if (verbose) {
3846
+ console.warn('[JQHTML Cache] Serialization failed:', e);
3847
+ }
3848
+ return null;
3849
+ }
3850
+ }
3851
+ /**
3852
+ * Recursively process a value for serialization.
3853
+ * Returns undefined if serialization should fail (unserializable value encountered).
3854
+ *
3855
+ * @param value - The value to process
3856
+ * @param verbose - Whether to log warnings
3857
+ * @param seen - WeakSet to detect circular references
3858
+ * @returns Processed value ready for JSON.stringify, or undefined if failed
3859
+ */
3860
+ function process_for_serialization(value, verbose, seen) {
3861
+ // Handle null and undefined
3862
+ if (value === null) {
3863
+ return null;
3864
+ }
3865
+ if (value === undefined) {
3866
+ return undefined; // JSON.stringify will omit this property
3867
+ }
3868
+ // Handle primitives
3869
+ if (typeof value !== 'object') {
3870
+ // string, number, boolean are fine
3871
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
3872
+ return value;
3873
+ }
3874
+ // bigint, symbol, function cannot be serialized
3875
+ if (verbose) {
3876
+ console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
3877
+ }
3878
+ // Return undefined to omit this value (not fail entire serialization)
3879
+ return undefined;
3880
+ }
3881
+ // Handle circular references
3882
+ if (seen.has(value)) {
3883
+ if (verbose) {
3884
+ console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
3885
+ }
3886
+ return undefined; // Fail entire serialization
3887
+ }
3888
+ seen.add(value);
3889
+ // Handle arrays
3890
+ if (Array.isArray(value)) {
3891
+ const result = [];
3892
+ for (let i = 0; i < value.length; i++) {
3893
+ const item = value[i];
3894
+ const processed = process_for_serialization(item, verbose, seen);
3895
+ // For arrays, we keep undefined as null to preserve indices
3896
+ result.push(processed === undefined ? null : processed);
3897
+ }
3898
+ return result;
3899
+ }
3900
+ // Handle Date objects specially
3901
+ if (value instanceof Date) {
3902
+ return {
3903
+ [CLASS_MARKER]: 'Date',
3904
+ [PROPS_MARKER]: value.toISOString()
3905
+ };
3906
+ }
3907
+ // Handle Map objects
3908
+ if (value instanceof Map) {
3909
+ const entries = [];
3910
+ for (const [k, v] of value) {
3911
+ const processedKey = process_for_serialization(k, verbose, seen);
3912
+ const processedValue = process_for_serialization(v, verbose, seen);
3913
+ entries.push([processedKey, processedValue]);
3914
+ }
3915
+ return {
3916
+ [CLASS_MARKER]: 'Map',
3917
+ [PROPS_MARKER]: entries
3918
+ };
3919
+ }
3920
+ // Handle Set objects
3921
+ if (value instanceof Set) {
3922
+ const items = [];
3923
+ for (const item of value) {
3924
+ items.push(process_for_serialization(item, verbose, seen));
3925
+ }
3926
+ return {
3927
+ [CLASS_MARKER]: 'Set',
3928
+ [PROPS_MARKER]: items
3929
+ };
3930
+ }
3931
+ // Get the constructor
3932
+ const ctor = value.constructor;
3933
+ // Handle registered class instances
3934
+ if (ctor && ctor.name && class_registry[ctor.name]) {
3935
+ const props = {};
3936
+ // Extract own enumerable properties (bypass any toJSON method)
3937
+ for (const key of Object.keys(value)) {
3938
+ const propValue = value[key];
3939
+ const processed = process_for_serialization(propValue, verbose, seen);
3940
+ // Only include if not undefined (failed serialization)
3941
+ if (processed !== undefined || propValue === undefined) {
3942
+ props[key] = processed;
3943
+ }
3944
+ }
3945
+ return {
3946
+ [CLASS_MARKER]: ctor.name,
3947
+ [PROPS_MARKER]: props
3948
+ };
3949
+ }
3950
+ // Handle plain objects (constructor is Object or no constructor)
3951
+ if (ctor === Object || ctor === undefined || ctor === null) {
3952
+ const result = {};
3953
+ for (const key of Object.keys(value)) {
3954
+ const propValue = value[key];
3955
+ const processed = process_for_serialization(propValue, verbose, seen);
3956
+ // Only include if not undefined (unless original was undefined)
3957
+ if (processed !== undefined || propValue === undefined) {
3958
+ result[key] = processed;
3959
+ }
3960
+ }
3961
+ return result;
3962
+ }
3963
+ // Unregistered class instance - convert to plain object for hot/cold parity
3964
+ // This ensures developers catch missing class registrations during development
3965
+ // (methods will be lost, only properties preserved)
3966
+ if (verbose) {
3967
+ console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
3968
+ `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
3969
+ }
3970
+ // Convert to plain object - properties are preserved, prototype methods are lost
3971
+ const result = {};
3972
+ for (const key of Object.keys(value)) {
3973
+ const propValue = value[key];
3974
+ const processed = process_for_serialization(propValue, verbose, seen);
3975
+ // Only include if not undefined (unless original was undefined)
3976
+ if (processed !== undefined || propValue === undefined) {
3977
+ result[key] = processed;
3978
+ }
3979
+ }
3980
+ return result;
3981
+ }
3982
+ // ============================================================================
3983
+ // Deserialization (String → Object)
3984
+ // ============================================================================
3985
+ /**
3986
+ * Deserialize a JSON string, restoring ES6 class instances.
3987
+ * Returns null if deserialization fails.
3988
+ *
3989
+ * @param str - The JSON string to deserialize
3990
+ * @param verbose - Whether to log warnings
3991
+ * @returns Deserialized value with class instances restored, or null if failed
3992
+ */
3993
+ function deserialize_value(str, verbose) {
3994
+ try {
3995
+ const parsed = JSON.parse(str);
3996
+ return process_for_deserialization(parsed, verbose);
3997
+ }
3998
+ catch (e) {
3999
+ if (verbose) {
4000
+ console.warn('[JQHTML Cache] Deserialization failed:', e);
4001
+ }
4002
+ return null;
4003
+ }
4004
+ }
4005
+ /**
4006
+ * Recursively process a parsed value, restoring class instances.
4007
+ *
4008
+ * @param value - The parsed value to process
4009
+ * @param verbose - Whether to log warnings
4010
+ * @returns Processed value with class instances restored
4011
+ */
4012
+ function process_for_deserialization(value, verbose) {
4013
+ // Handle primitives and null
4014
+ if (value === null || value === undefined || typeof value !== 'object') {
4015
+ return value;
4016
+ }
4017
+ // Handle arrays
4018
+ if (Array.isArray(value)) {
4019
+ return value.map(item => process_for_deserialization(item, verbose));
4020
+ }
4021
+ // Check for class marker
4022
+ if (value[CLASS_MARKER] !== undefined && value[PROPS_MARKER] !== undefined) {
4023
+ const class_name = value[CLASS_MARKER];
4024
+ const props = value[PROPS_MARKER];
4025
+ // Handle built-in types
4026
+ if (class_name === 'Date') {
4027
+ return new Date(props);
4028
+ }
4029
+ if (class_name === 'Map') {
4030
+ const map = new Map();
4031
+ for (const [k, v] of props) {
4032
+ map.set(process_for_deserialization(k, verbose), process_for_deserialization(v, verbose));
4033
+ }
4034
+ return map;
4035
+ }
4036
+ if (class_name === 'Set') {
4037
+ const set = new Set();
4038
+ for (const item of props) {
4039
+ set.add(process_for_deserialization(item, verbose));
4040
+ }
4041
+ return set;
4042
+ }
4043
+ // Look up registered class
4044
+ const klass = class_registry[class_name];
4045
+ if (!klass) {
4046
+ if (verbose) {
4047
+ console.warn(`[JQHTML Cache] Cannot restore class "${class_name}" - not registered. ` +
4048
+ `Data will be returned as plain object. ` +
4049
+ `Call jqhtml.register_cache_class(${class_name}) to restore class instances.`);
4050
+ }
4051
+ // Return as plain object rather than failing completely
4052
+ return process_for_deserialization(props, verbose);
4053
+ }
4054
+ // Create instance without calling constructor and assign properties
4055
+ try {
4056
+ const instance = Object.create(klass.prototype);
4057
+ const processed_props = process_for_deserialization(props, verbose);
4058
+ Object.assign(instance, processed_props);
4059
+ return instance;
4060
+ }
4061
+ catch (e) {
4062
+ if (verbose) {
4063
+ console.warn(`[JQHTML Cache] Failed to restore class "${class_name}":`, e);
4064
+ }
4065
+ // Return null to signal cache should be treated as miss
4066
+ return null;
4067
+ }
4068
+ }
4069
+ // Plain object - process recursively
4070
+ const result = {};
4071
+ for (const key of Object.keys(value)) {
4072
+ result[key] = process_for_deserialization(value[key], verbose);
4073
+ }
4074
+ return result;
4075
+ }
3555
4076
  class Jqhtml_Local_Storage {
3556
4077
  /**
3557
4078
  * Set the cache key and initialize storage
3558
4079
  * Must be called before any get/set operations
3559
4080
  * @param {string} cache_key - Unique identifier for this cache scope
4081
+ * @param {CacheMode} cache_mode - Cache strategy: 'data' (default, recommended) or 'html'
3560
4082
  */
3561
- static set_cache_key(cache_key) {
4083
+ static set_cache_key(cache_key, cache_mode = 'data') {
3562
4084
  this._cache_key = cache_key;
4085
+ this._cache_mode = cache_mode;
3563
4086
  this._init();
3564
4087
  }
3565
4088
  /**
@@ -3569,6 +4092,13 @@ class Jqhtml_Local_Storage {
3569
4092
  static has_cache_key() {
3570
4093
  return this._cache_key !== null;
3571
4094
  }
4095
+ /**
4096
+ * Get the current cache mode
4097
+ * @returns {CacheMode} Current cache mode ('data' or 'html')
4098
+ */
4099
+ static get_cache_mode() {
4100
+ return this._cache_mode;
4101
+ }
3572
4102
  /**
3573
4103
  * Initialize storage system and validate scope
3574
4104
  * Called automatically after cache key is set
@@ -3603,6 +4133,13 @@ class Jqhtml_Local_Storage {
3603
4133
  return false;
3604
4134
  }
3605
4135
  }
4136
+ /**
4137
+ * Check if verbose mode is enabled
4138
+ * @private
4139
+ */
4140
+ static _is_verbose() {
4141
+ return window.jqhtml?.debug?.verbose === true;
4142
+ }
3606
4143
  /**
3607
4144
  * Validate storage scope and clear JQHTML keys if cache key changed
3608
4145
  * Only clears keys prefixed with 'jqhtml::' to preserve other libraries' data
@@ -3681,34 +4218,90 @@ class Jqhtml_Local_Storage {
3681
4218
  return this._storage_available === true && this._cache_key !== null && this._initialized;
3682
4219
  }
3683
4220
  /**
3684
- * Set item in localStorage
4221
+ * Set item in localStorage with class-aware serialization.
4222
+ *
4223
+ * If serialization fails (e.g., unregistered class instances, circular refs),
4224
+ * the existing cache entry is removed and nothing is stored.
4225
+ *
3685
4226
  * @param {string} key - Storage key
3686
- * @param {*} value - Value to store (will be JSON serialized)
4227
+ * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
3687
4228
  */
3688
4229
  static set(key, value) {
3689
4230
  if (!this._is_ready()) {
3690
4231
  return;
3691
4232
  }
4233
+ const verbose = this._is_verbose();
4234
+ const scoped_key = this._build_key(key);
4235
+ // Serialize with class-aware processing
4236
+ const serialized = serialize_value(value, verbose);
4237
+ if (serialized === null) {
4238
+ // Serialization failed - remove any existing cache entry
4239
+ if (verbose) {
4240
+ console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
4241
+ `Removing existing cache entry if present.`);
4242
+ }
4243
+ try {
4244
+ localStorage.removeItem(scoped_key);
4245
+ }
4246
+ catch (e) {
4247
+ // Ignore removal errors
4248
+ }
4249
+ return;
4250
+ }
3692
4251
  // Check size before attempting to store (1MB limit)
3693
- const serialized = JSON.stringify(value);
3694
4252
  const size_bytes = new Blob([serialized]).size;
3695
4253
  const size_mb = size_bytes / (1024 * 1024);
3696
4254
  if (size_mb > 1) {
3697
- console.warn(`[JQHTML Local Storage] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
4255
+ if (verbose) {
4256
+ console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
4257
+ }
4258
+ // Remove existing cache entry since we can't update it
4259
+ try {
4260
+ localStorage.removeItem(scoped_key);
4261
+ }
4262
+ catch (e) {
4263
+ // Ignore removal errors
4264
+ }
3698
4265
  return;
3699
4266
  }
3700
- this._set_item(key, value, serialized);
4267
+ this._set_item(key, serialized);
3701
4268
  }
3702
4269
  /**
3703
- * Get item from localStorage
4270
+ * Get item from localStorage with class-aware deserialization.
4271
+ *
4272
+ * If deserialization fails, returns null (as if no cache exists).
4273
+ *
3704
4274
  * @param {string} key - Storage key
3705
- * @returns {*|null} Parsed value or null if not found/unavailable
4275
+ * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
3706
4276
  */
3707
4277
  static get(key) {
3708
4278
  if (!this._is_ready()) {
3709
4279
  return null;
3710
4280
  }
3711
- return this._get_item(key);
4281
+ const verbose = this._is_verbose();
4282
+ const scoped_key = this._build_key(key);
4283
+ try {
4284
+ const serialized = localStorage.getItem(scoped_key);
4285
+ if (serialized === null) {
4286
+ return null;
4287
+ }
4288
+ const result = deserialize_value(serialized, verbose);
4289
+ if (result === null) {
4290
+ // Deserialization failed - treat as cache miss
4291
+ if (verbose) {
4292
+ console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
4293
+ `Treating as cache miss.`);
4294
+ }
4295
+ return null;
4296
+ }
4297
+ return result;
4298
+ }
4299
+ catch (e) {
4300
+ if (verbose) {
4301
+ console.warn('[JQHTML Cache] Failed to get item:', e);
4302
+ }
4303
+ return null;
4304
+ }
3712
4305
  }
3713
4306
  /**
3714
4307
  * Remove item from localStorage
@@ -3720,14 +4313,49 @@ class Jqhtml_Local_Storage {
3720
4313
  }
3721
4314
  this._remove_item(key);
3722
4315
  }
4316
+ /**
4317
+ * Perform a serialize/deserialize round-trip on a value.
4318
+ *
4319
+ * This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
4320
+ * (restored from cache). Unregistered class instances will be converted to plain
4321
+ * objects, exactly as they would be if restored from cache.
4322
+ *
4323
+ * Use this to normalize data after on_load() so developers catch missing class
4324
+ * registrations immediately rather than only after a page reload.
4325
+ *
4326
+ * @param {any} value - The value to normalize
4327
+ * @returns {any} The value after serialize/deserialize round-trip, or original if serialization fails
4328
+ */
4329
+ static normalize_for_cache(value) {
4330
+ const verbose = this._is_verbose();
4331
+ // Serialize to JSON string
4332
+ const serialized = serialize_value(value, verbose);
4333
+ if (serialized === null) {
4334
+ // Serialization failed completely - return original
4335
+ // (This happens with circular references or other fatal issues)
4336
+ if (verbose) {
4337
+ console.warn('[JQHTML Cache] normalize_for_cache: Serialization failed, returning original value');
4338
+ }
4339
+ return value;
4340
+ }
4341
+ // Deserialize back to object
4342
+ const deserialized = deserialize_value(serialized, verbose);
4343
+ if (deserialized === null) {
4344
+ // Deserialization failed - return original
4345
+ if (verbose) {
4346
+ console.warn('[JQHTML Cache] normalize_for_cache: Deserialization failed, returning original value');
4347
+ }
4348
+ return value;
4349
+ }
4350
+ return deserialized;
4351
+ }
3723
4352
  /**
3724
4353
  * Internal set implementation with scope validation and quota handling
3725
4354
  * @param {string} key
3726
- * @param {*} value - Original value (not used, kept for clarity)
3727
4355
  * @param {string} serialized - Pre-serialized JSON string
3728
4356
  * @private
3729
4357
  */
3730
- static _set_item(key, value, serialized) {
4358
+ static _set_item(key, serialized) {
3731
4359
  // Validate scope before every write
3732
4360
  this._validate_scope();
3733
4361
  const scoped_key = this._build_key(key);
@@ -3753,26 +4381,6 @@ class Jqhtml_Local_Storage {
3753
4381
  }
3754
4382
  }
3755
4383
  }
3756
- /**
3757
- * Internal get implementation
3758
- * @param {string} key
3759
- * @returns {*|null}
3760
- * @private
3761
- */
3762
- static _get_item(key) {
3763
- const scoped_key = this._build_key(key);
3764
- try {
3765
- const serialized = localStorage.getItem(scoped_key);
3766
- if (serialized === null) {
3767
- return null;
3768
- }
3769
- return JSON.parse(serialized);
3770
- }
3771
- catch (e) {
3772
- console.error('[JQHTML Local Storage] Failed to get item:', e);
3773
- return null;
3774
- }
3775
- }
3776
4384
  /**
3777
4385
  * Internal remove implementation
3778
4386
  * @param {string} key
@@ -3789,12 +4397,14 @@ class Jqhtml_Local_Storage {
3789
4397
  }
3790
4398
  }
3791
4399
  Jqhtml_Local_Storage._cache_key = null;
4400
+ Jqhtml_Local_Storage._cache_mode = 'data';
3792
4401
  Jqhtml_Local_Storage._storage_available = null;
3793
4402
  Jqhtml_Local_Storage._initialized = false;
3794
4403
 
3795
4404
  var localStorage$1 = /*#__PURE__*/Object.freeze({
3796
4405
  __proto__: null,
3797
- Jqhtml_Local_Storage: Jqhtml_Local_Storage
4406
+ Jqhtml_Local_Storage: Jqhtml_Local_Storage,
4407
+ register_cache_class: register_cache_class
3798
4408
  });
3799
4409
 
3800
4410
  /**
@@ -4057,7 +4667,7 @@ function init(jQuery) {
4057
4667
  }
4058
4668
  }
4059
4669
  // Version - will be replaced during build with actual version from package.json
4060
- const version = '2.3.10';
4670
+ const version = '2.3.13';
4061
4671
  // Default export with all functionality
4062
4672
  const jqhtml = {
4063
4673
  // Core
@@ -4141,9 +4751,17 @@ const jqhtml = {
4141
4751
  return version;
4142
4752
  },
4143
4753
  // Cache key setter - enables component caching via local storage
4144
- set_cache_key(cache_key) {
4145
- Jqhtml_Local_Storage.set_cache_key(cache_key);
4754
+ // cache_mode: 'data' (default, recommended) or 'html'
4755
+ set_cache_key(cache_key, cache_mode = 'data') {
4756
+ Jqhtml_Local_Storage.set_cache_key(cache_key, cache_mode);
4757
+ },
4758
+ // Get current cache mode
4759
+ get_cache_mode() {
4760
+ return Jqhtml_Local_Storage.get_cache_mode();
4146
4761
  },
4762
+ // Register a class for cache serialization/deserialization
4763
+ // Required for ES6 class instances to be stored and restored from localStorage
4764
+ register_cache_class,
4147
4765
  // Boot - hydrate server-rendered component placeholders
4148
4766
  boot
4149
4767
  };
@@ -4192,6 +4810,7 @@ exports.logInstruction = logInstruction;
4192
4810
  exports.logLifecycle = logLifecycle;
4193
4811
  exports.process_instructions = process_instructions;
4194
4812
  exports.register = register;
4813
+ exports.register_cache_class = register_cache_class;
4195
4814
  exports.register_component = register_component;
4196
4815
  exports.register_template = register_template;
4197
4816
  exports.render_template = render_template;