@jqhtml/core 2.3.70 → 2.3.71

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JQHTML Core v2.3.70
2
+ * JQHTML Core v2.3.71
3
3
  * (c) 2025 JQHTML Team
4
4
  * Released under the MIT License
5
5
  */
@@ -1510,309 +1510,6 @@ function serialize_for_cache_key(value) {
1510
1510
  }
1511
1511
  }
1512
1512
 
1513
- /**
1514
- * Load Coordinator - Request deduplication for component on_load() calls
1515
- *
1516
- * Coordinates parallel component loading to prevent duplicate requests.
1517
- * When multiple components with identical names and args load simultaneously,
1518
- * only the first (leader) executes on_load(). Others (followers) wait for
1519
- * the leader's result.
1520
- *
1521
- * Key Concepts:
1522
- * - **INVOCATION_KEY**: Unique identifier for component name + args combination
1523
- * - **Leader**: First component to reach on_load() for a given INVOCATION_KEY
1524
- * - **Follower**: Subsequent components that wait for leader's result
1525
- *
1526
- * Lifecycle:
1527
- * 1. Leader reaches on_load() → create coordination entry
1528
- * 2. Followers reach on_load() → join waiting queue
1529
- * 3. Leader completes → distribute data to all followers
1530
- * 4. Clear coordination entry (no caching)
1531
- *
1532
- * @internal This class is not exposed in the public API
1533
- */
1534
- class Load_Coordinator {
1535
- /**
1536
- * Generate INVOCATION_KEY from component name and args
1537
- * Uses deterministic JSON serialization (sorted keys)
1538
- * Excludes internal properties (those starting with _)
1539
- *
1540
- * For functions/objects:
1541
- * - Checks for ._jqhtml_cache_id property (assigned by RSpade)
1542
- * - Falls back to .jqhtml_cache_id() method if property doesn't exist
1543
- * - If neither exists, marks property as uncacheable
1544
- *
1545
- * Returns object with:
1546
- * - key: Cache key string, or null if uncacheable
1547
- * - uncacheable_property: Name of first property that prevented caching (for debugging)
1548
- */
1549
- static generate_invocation_key(component_name, args, options = {}) {
1550
- let uncacheable_property;
1551
- let uncacheable_reason;
1552
- // Filter out internal properties (starting with _) and serialize args
1553
- const serializable_args = {};
1554
- for (const key of Object.keys(args).sort()) {
1555
- if (key.startsWith('_')) {
1556
- continue; // Skip internal properties
1557
- }
1558
- // Skip framework properties that shouldn't affect cache identity
1559
- // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
1560
- if (key === 'use_cached_data') {
1561
- continue;
1562
- }
1563
- const value = args[key];
1564
- const value_type = typeof value;
1565
- // Handle primitives (string, number, boolean, null, undefined)
1566
- if (value === null || value === undefined ||
1567
- value_type === 'string' || value_type === 'number' ||
1568
- value_type === 'boolean') {
1569
- serializable_args[key] = value;
1570
- continue;
1571
- }
1572
- // Handle functions and objects
1573
- if (value_type === 'function' || value_type === 'object') {
1574
- // Check for ._jqhtml_cache_id property (fastest, what RSpade assigns)
1575
- if (value._jqhtml_cache_id !== undefined) {
1576
- serializable_args[key] = `__JQHTML_CACHE_ID__:${String(value._jqhtml_cache_id)}`;
1577
- continue;
1578
- }
1579
- // Check for .jqhtml_cache_id() method (more flexible, for custom objects)
1580
- if (typeof value.jqhtml_cache_id === 'function') {
1581
- try {
1582
- const cache_id = value.jqhtml_cache_id();
1583
- serializable_args[key] = `__JQHTML_CACHE_ID__:${String(cache_id)}`;
1584
- continue;
1585
- }
1586
- catch (error) {
1587
- // Method threw error - treat as uncacheable
1588
- if (!uncacheable_property) {
1589
- uncacheable_property = key;
1590
- uncacheable_reason = 'cache-id-threw';
1591
- }
1592
- return { key: null, uncacheable_property, uncacheable_reason };
1593
- }
1594
- }
1595
- // No author-supplied id. The cache may fall back to keying by
1596
- // deterministic content; deduplication deliberately may not.
1597
- if (options.allow_content_serialization) {
1598
- const serialized = serialize_for_cache_key(value);
1599
- if (serialized.ok) {
1600
- serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
1601
- continue;
1602
- }
1603
- if (!uncacheable_property) {
1604
- uncacheable_property = key;
1605
- // This package compiles with strictNullChecks off, and without it
1606
- // TypeScript will not narrow a discriminated union by its boolean
1607
- // discriminant - so `serialized` is still the full Serialize_Result
1608
- // here even though `ok` has been ruled out above. The cast states
1609
- // what the control flow already guarantees.
1610
- uncacheable_reason = serialized.reason;
1611
- }
1612
- return { key: null, uncacheable_property, uncacheable_reason };
1613
- }
1614
- if (!uncacheable_property) {
1615
- uncacheable_property = key;
1616
- uncacheable_reason = value_type === 'function' ? 'function' : 'object';
1617
- }
1618
- return { key: null, uncacheable_property, uncacheable_reason };
1619
- }
1620
- // Unknown type (symbol, bigint, etc.) - uncacheable
1621
- if (!uncacheable_property) {
1622
- uncacheable_property = key;
1623
- uncacheable_reason = value_type;
1624
- }
1625
- return { key: null, uncacheable_property, uncacheable_reason };
1626
- }
1627
- // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
1628
- try {
1629
- const sorted_args = JSON.stringify(serializable_args);
1630
- return { key: `${component_name}::${sorted_args}` };
1631
- }
1632
- catch (error) {
1633
- // Serialization failed unexpectedly
1634
- return { key: null, uncacheable_property };
1635
- }
1636
- }
1637
- /**
1638
- * Check if a component should execute on_load() or wait for existing request
1639
- * Returns true if component should execute (is leader), false if should wait (is follower)
1640
- *
1641
- * @param key - The dedup key the caller captured for this load. Every coordinator
1642
- * method takes it, because args may legally change during on_load()
1643
- * and a recomputed key would address a different (or no) entry.
1644
- */
1645
- static should_execute_on_load(component, key) {
1646
- const entry = this._registry.get(key);
1647
- if (!entry) {
1648
- // No existing request - this component becomes the leader
1649
- return true;
1650
- }
1651
- if (entry.status === 'loading') {
1652
- // Request in progress - this component becomes a follower
1653
- entry.waiting.push(component);
1654
- return false;
1655
- }
1656
- // Entry exists but completed/failed - should have been cleaned up
1657
- // Treat as new leader
1658
- return true;
1659
- }
1660
- /**
1661
- * Register a leader component that will execute on_load()
1662
- * Creates coordination entry and returns a function to call when data is ready
1663
- *
1664
- * @param key - The dedup key captured by the caller (see should_execute_on_load)
1665
- * @returns A function that accepts the final data and completes coordination
1666
- */
1667
- static register_leader(component, key) {
1668
- // Create a promise that we control - it settles when coordination completes or fails
1669
- let resolve_promise;
1670
- let reject_promise;
1671
- const coordination_promise = new Promise((resolve, reject) => {
1672
- resolve_promise = resolve;
1673
- reject_promise = reject;
1674
- });
1675
- // A leader with no followers still has to be able to reject this promise on
1676
- // error; without a handler that rejection surfaces as an unhandled rejection.
1677
- coordination_promise.catch(() => { });
1678
- const entry = {
1679
- status: 'loading',
1680
- promise: coordination_promise, // Followers await THIS promise, not on_load's
1681
- resolve_promise,
1682
- reject_promise,
1683
- leader_component: component,
1684
- leader_data: null,
1685
- waiting: []
1686
- };
1687
- this._registry.set(key, entry);
1688
- // Return cleanup function that accepts the final data
1689
- return (final_data) => this._complete_coordination(key, component, final_data);
1690
- }
1691
- /**
1692
- * Get the coordination promise for a follower component
1693
- * Returns a promise that resolves when the leader completes, or rejects if it failed
1694
- */
1695
- static get_coordination_promise(key) {
1696
- const entry = this._registry.get(key);
1697
- if (!entry || entry.status !== 'loading') {
1698
- return null;
1699
- }
1700
- return entry.promise;
1701
- }
1702
- /**
1703
- * Complete coordination after leader's on_load() finishes
1704
- * Stores leader's data, marks entry as completed, and resolves the promise.
1705
- * Followers retrieve the data themselves via get_leader_data().
1706
- *
1707
- * @param key - The coordination key captured at registration
1708
- * @param leader - The leader component
1709
- * @param final_data - The final data after on_load and normalization
1710
- * @private
1711
- */
1712
- static _complete_coordination(key, leader, final_data) {
1713
- const entry = this._registry.get(key);
1714
- if (!entry) {
1715
- return;
1716
- }
1717
- // Stored as TEXT so each follower parses its own object graph - sharing one
1718
- // decoded object would let one component's mutation reach every other.
1719
- try {
1720
- entry.leader_data = JSON.stringify(final_data);
1721
- }
1722
- catch (error) {
1723
- // Unserializable data cannot be shared; followers load independently.
1724
- entry.leader_data = null;
1725
- }
1726
- entry.status = 'completed';
1727
- if (window.jqhtml?.debug?.verbose) {
1728
- console.log(`[Load Coordinator] Leader ${leader._cid} completed, data available for ${entry.waiting.length} followers`, { key, data: entry.leader_data });
1729
- }
1730
- // Resolve the promise - followers waiting on it will now wake up
1731
- entry.resolve_promise();
1732
- // Nobody is waiting, so nothing will ever come back to collect the data.
1733
- // Without this the registry grows once per distinct name+args, forever.
1734
- if (entry.waiting.length === 0) {
1735
- this._registry.delete(key);
1736
- }
1737
- }
1738
- /**
1739
- * Get leader's data for a follower component
1740
- * Called by follower after coordination promise resolves
1741
- * Returns a freshly parsed copy and cleans up if this is the last follower
1742
- *
1743
- * @param key - The dedup key captured by the follower before it began waiting
1744
- */
1745
- static get_leader_data(component, key) {
1746
- const entry = this._registry.get(key);
1747
- if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1748
- return null;
1749
- }
1750
- // Remove this follower from the waiting list
1751
- const follower_index = entry.waiting.indexOf(component);
1752
- if (follower_index !== -1) {
1753
- entry.waiting.splice(follower_index, 1);
1754
- }
1755
- // One parse per follower: each gets its OWN object, never a shared reference.
1756
- const data = JSON.parse(entry.leader_data);
1757
- if (window.jqhtml?.debug?.verbose) {
1758
- console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1759
- }
1760
- // If this was the last follower, clean up the entry
1761
- if (entry.waiting.length === 0) {
1762
- this._registry.delete(key);
1763
- if (window.jqhtml?.debug?.verbose) {
1764
- console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1765
- }
1766
- }
1767
- return data;
1768
- }
1769
- /**
1770
- * Handle leader on_load() error
1771
- * Rejects every waiting follower and clears the entry so the next component retries
1772
- *
1773
- * @param key - The dedup key captured at registration
1774
- */
1775
- static handle_leader_error(key, error) {
1776
- const entry = this._registry.get(key);
1777
- if (!entry) {
1778
- return;
1779
- }
1780
- entry.status = 'failed';
1781
- console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
1782
- // Reject FIRST: followers are parked on this promise and their catch in _load()
1783
- // propagates the failure exactly as if their own on_load() had thrown. Deleting
1784
- // without rejecting left them awaiting a promise nobody could ever settle.
1785
- entry.reject_promise(error);
1786
- // Clear coordination entry so future requests can retry
1787
- this._registry.delete(key);
1788
- if (window.jqhtml?.debug?.verbose) {
1789
- console.log(`[Load Coordinator] Cleared failed coordination for key: ${key}`, { followers_count: entry.waiting.length });
1790
- }
1791
- }
1792
- /**
1793
- * Get current registry state (for debugging)
1794
- */
1795
- static get_registry_state() {
1796
- const state = {};
1797
- for (const [key, entry] of this._registry.entries()) {
1798
- state[key] = {
1799
- status: entry.status,
1800
- leader_cid: entry.leader_component._cid,
1801
- waiting_count: entry.waiting.length,
1802
- waiting_cids: entry.waiting.map(c => c._cid)
1803
- };
1804
- }
1805
- return state;
1806
- }
1807
- /**
1808
- * Clear all coordination entries (for testing/debugging)
1809
- */
1810
- static clear_all() {
1811
- this._registry.clear();
1812
- }
1813
- }
1814
- Load_Coordinator._registry = new Map();
1815
-
1816
1513
  /**
1817
1514
  * Jqhtml_Local_Storage - Scoped local storage for JQHTML component caching
1818
1515
  *
@@ -1831,7 +1528,19 @@ Load_Coordinator._registry = new Map();
1831
1528
  * ES6 class instances can be serialized and deserialized if registered via
1832
1529
  * register_cache_class(). Classes are wrapped as {__jqhtml_class__: "Name", __jqhtml_props__: {...}}
1833
1530
  * and restored to proper instances on retrieval. Nested class instances in objects
1834
- * and arrays are handled recursively.
1531
+ * and arrays are handled recursively. A class whose wire form is not its own
1532
+ * properties (one that defines toJSON(), or keeps state in private fields)
1533
+ * registers a {to_plain, from_plain} pair and is rebuilt through it. Date, Map and
1534
+ * Set are pre-registered the same way - there is no second mechanism.
1535
+ *
1536
+ * ONE SERIALIZER. Every copy of on_load() data the framework makes goes through
1537
+ * this file: the localStorage cache (set()/get()), the normalization of every
1538
+ * on_load() result into this.data (normalize_for_cache()), and the copy a
1539
+ * deduplicated follower takes of its leader's data (serialize()/deserialize(),
1540
+ * used by load-coordinator.ts). Anything that survives one of those survives all
1541
+ * of them, and anything dropped is dropped identically on all of them. A plain
1542
+ * JSON.stringify anywhere on those paths would consult toJSON() and hand out a
1543
+ * wire form nothing knows how to restore.
1835
1544
  *
1836
1545
  * Scoping Strategy:
1837
1546
  * Storage is scoped by a user-provided cache key (typically a session identifier,
@@ -1907,14 +1616,11 @@ Load_Coordinator._registry = new Map();
1907
1616
  * silently. Neither ever throws.
1908
1617
  * ---------------------------------------------------------------------------
1909
1618
  */
1910
- // ============================================================================
1911
- // Class Registry for Serialization
1912
- // ============================================================================
1913
- // Registry mapping class names to constructors
1619
+ // Registry mapping class names to constructors and their restore hooks
1914
1620
  const class_registry = Object.create(null);
1915
1621
  // Markers for serialized class instances (unique to avoid collisions with user data)
1916
1622
  // Replaced at build time by rollup (see rollup.config.js).
1917
- const CORE_VERSION = '2.3.70';
1623
+ const CORE_VERSION = '2.3.71';
1918
1624
  const CLASS_MARKER = '__jqhtml_class__';
1919
1625
  const PROPS_MARKER = '__jqhtml_props__';
1920
1626
  /**
@@ -1922,13 +1628,56 @@ const PROPS_MARKER = '__jqhtml_props__';
1922
1628
  * Must be called before attempting to cache instances of this class.
1923
1629
  *
1924
1630
  * @param klass - The class constructor to register
1925
- * @throws Error if klass is not a named function/class
1631
+ * @param options - How to convert instances to and from their plain form; see
1632
+ * Cache_Class_Options. Omit for a class whose state is its own
1633
+ * enumerable properties.
1634
+ * @throws Error if klass is not a named function/class, or if to_plain is given
1635
+ * without the from_plain that undoes it
1926
1636
  */
1927
- function register_cache_class(klass) {
1637
+ function register_cache_class(klass, options = {}) {
1928
1638
  if (typeof klass !== 'function' || !klass.name) {
1929
1639
  throw new Error('[JQHTML Cache] register_cache_class requires a named class constructor');
1930
1640
  }
1931
- class_registry[klass.name] = klass;
1641
+ const to_plain = typeof options.to_plain === 'function' ? options.to_plain : null;
1642
+ const from_plain = typeof options.from_plain === 'function' ? options.from_plain : null;
1643
+ if (to_plain && !from_plain) {
1644
+ throw new Error(`[JQHTML Cache] register_cache_class(${klass.name}): to_plain needs a from_plain. ` +
1645
+ `The serializer cannot rebuild an instance from a custom wire form on its own.`);
1646
+ }
1647
+ class_registry[klass.name] = { klass, to_plain, from_plain };
1648
+ }
1649
+ // Built-ins travel through the same hook as user classes. Their wire forms are
1650
+ // the ones the cache has always stored (ISO string, entry pairs, item list), so
1651
+ // entries written before this existed still restore.
1652
+ register_cache_class(Date, {
1653
+ to_plain: (date) => date.toISOString(),
1654
+ from_plain: (iso) => new Date(iso)
1655
+ });
1656
+ register_cache_class(Map, {
1657
+ to_plain: (map) => Array.from(map.entries()),
1658
+ from_plain: (entries) => new Map(entries)
1659
+ });
1660
+ register_cache_class(Set, {
1661
+ to_plain: (set) => Array.from(set),
1662
+ from_plain: (items) => new Set(items)
1663
+ });
1664
+ /**
1665
+ * Find the registry entry for a live value: by constructor name first, then by
1666
+ * instanceof for the built-ins so a subclass of Date/Map/Set (whose name is not
1667
+ * registered) still serializes as the built-in it is.
1668
+ */
1669
+ function lookup_registered(value) {
1670
+ const ctor = value.constructor;
1671
+ if (ctor && ctor.name && class_registry[ctor.name]) {
1672
+ return { name: ctor.name, entry: class_registry[ctor.name] };
1673
+ }
1674
+ if (value instanceof Date)
1675
+ return { name: 'Date', entry: class_registry['Date'] };
1676
+ if (value instanceof Map)
1677
+ return { name: 'Map', entry: class_registry['Map'] };
1678
+ if (value instanceof Set)
1679
+ return { name: 'Set', entry: class_registry['Set'] };
1680
+ return null;
1932
1681
  }
1933
1682
  /**
1934
1683
  * Serialize a value to JSON string, handling ES6 class instances recursively.
@@ -2055,48 +1804,20 @@ function process_for_serialization(value, verbose, seen, path = '', collector) {
2055
1804
  }
2056
1805
  return result;
2057
1806
  }
2058
- // Handle Date objects specially
2059
- if (value instanceof Date) {
2060
- return {
2061
- [CLASS_MARKER]: 'Date',
2062
- [PROPS_MARKER]: value.toISOString()
2063
- };
2064
- }
2065
- // Handle Map objects
2066
- if (value instanceof Map) {
2067
- const entries = [];
2068
- for (const [k, v] of value) {
2069
- const entry_path = collector ? `${path}.get(${String(k)})` : '';
2070
- const processedKey = process_for_serialization(k, verbose, seen, entry_path, collector);
2071
- const processedValue = process_for_serialization(v, verbose, seen, entry_path, collector);
2072
- entries.push([processedKey, processedValue]);
2073
- }
2074
- return {
2075
- [CLASS_MARKER]: 'Map',
2076
- [PROPS_MARKER]: entries
2077
- };
2078
- }
2079
- // Handle Set objects
2080
- if (value instanceof Set) {
2081
- const items = [];
2082
- let index = 0;
2083
- for (const item of value) {
2084
- items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
1807
+ // Registered classes - Date, Map and Set included - go out as
1808
+ // {__jqhtml_class__, __jqhtml_props__} through their restore hook.
1809
+ const registered = lookup_registered(value);
1810
+ if (registered) {
1811
+ const plain = registered_plain_form(registered, value, verbose, seen, path, collector);
1812
+ if (plain === undefined) {
1813
+ return undefined; // to_plain threw; the value is dropped, and reported
2085
1814
  }
2086
1815
  return {
2087
- [CLASS_MARKER]: 'Set',
2088
- [PROPS_MARKER]: items
1816
+ [CLASS_MARKER]: registered.name,
1817
+ [PROPS_MARKER]: plain
2089
1818
  };
2090
1819
  }
2091
- // Get the constructor
2092
1820
  const ctor = value.constructor;
2093
- // Handle registered class instances
2094
- if (ctor && ctor.name && class_registry[ctor.name]) {
2095
- return {
2096
- [CLASS_MARKER]: ctor.name,
2097
- [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2098
- };
2099
- }
2100
1821
  // Handle plain objects (constructor is Object or no constructor)
2101
1822
  if (ctor === Object || ctor === undefined || ctor === null) {
2102
1823
  return process_own_properties(value, verbose, seen, path, collector);
@@ -2110,12 +1831,54 @@ function process_for_serialization(value, verbose, seen, path = '', collector) {
2110
1831
  console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2111
1832
  `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2112
1833
  }
2113
- return process_own_properties(value, verbose, seen, path, collector);
1834
+ return process_own_properties(value, verbose, seen, path, collector);
1835
+ }
1836
+ finally {
1837
+ // Pop this object off the ancestor path now that its subtree is done.
1838
+ seen.delete(value);
1839
+ }
1840
+ }
1841
+ /**
1842
+ * The plain value stored for a registered instance.
1843
+ *
1844
+ * No from_plain: own enumerable properties (toJSON() is deliberately bypassed,
1845
+ * because Object.assign is the only way back and it needs the properties).
1846
+ * With from_plain: to_plain(instance), or toJSON() when the class defines one,
1847
+ * or own properties as the last resort. Whatever comes back is walked by the
1848
+ * serializer like any other value.
1849
+ *
1850
+ * Returns undefined when to_plain threw - the caller drops the value.
1851
+ */
1852
+ function registered_plain_form(registered, value, verbose, seen, path, collector) {
1853
+ const { name, entry } = registered;
1854
+ if (!entry.from_plain) {
1855
+ return process_own_properties(value, verbose, seen, path, collector);
1856
+ }
1857
+ let plain;
1858
+ try {
1859
+ if (entry.to_plain) {
1860
+ plain = entry.to_plain(value);
1861
+ }
1862
+ else if (typeof value.toJSON === 'function') {
1863
+ plain = value.toJSON();
1864
+ }
1865
+ else {
1866
+ return process_own_properties(value, verbose, seen, path, collector);
1867
+ }
1868
+ }
1869
+ catch (e) {
1870
+ collector?.(path, 'to-plain-threw', name);
1871
+ if (verbose) {
1872
+ console.warn(`[JQHTML Cache] to_plain for class "${name}" threw - value will be omitted:`, e);
1873
+ }
1874
+ return undefined;
2114
1875
  }
2115
- finally {
2116
- // Pop this object off the ancestor path now that its subtree is done.
2117
- seen.delete(value);
1876
+ // A toJSON() that returns `this` has no separate wire form; own properties it is.
1877
+ if (plain === value) {
1878
+ return process_own_properties(value, verbose, seen, path, collector);
2118
1879
  }
1880
+ const processed = process_for_serialization(plain, verbose, seen, path, collector);
1881
+ return processed === undefined ? null : processed;
2119
1882
  }
2120
1883
  /**
2121
1884
  * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
@@ -2172,7 +1935,11 @@ function strip_advice(kind, detail) {
2172
1935
  case 'unregistered-class':
2173
1936
  return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2174
1937
  `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2175
- `to get real instances back.`;
1938
+ `to get real instances back - with { from_plain } if the class defines toJSON() or keeps ` +
1939
+ `state outside its own properties.`;
1940
+ case 'to-plain-threw':
1941
+ return `the to_plain hook registered for class "${detail}" threw, so the value was dropped. ` +
1942
+ `Make to_plain total over every instance the class can produce.`;
2176
1943
  }
2177
1944
  }
2178
1945
  /**
@@ -2244,27 +2011,9 @@ function process_for_deserialization(value, verbose) {
2244
2011
  if (value[CLASS_MARKER] !== undefined && value[PROPS_MARKER] !== undefined) {
2245
2012
  const class_name = value[CLASS_MARKER];
2246
2013
  const props = value[PROPS_MARKER];
2247
- // Handle built-in types
2248
- if (class_name === 'Date') {
2249
- return new Date(props);
2250
- }
2251
- if (class_name === 'Map') {
2252
- const map = new Map();
2253
- for (const [k, v] of props) {
2254
- map.set(process_for_deserialization(k, verbose), process_for_deserialization(v, verbose));
2255
- }
2256
- return map;
2257
- }
2258
- if (class_name === 'Set') {
2259
- const set = new Set();
2260
- for (const item of props) {
2261
- set.add(process_for_deserialization(item, verbose));
2262
- }
2263
- return set;
2264
- }
2265
- // Look up registered class
2266
- const klass = class_registry[class_name];
2267
- if (!klass) {
2014
+ // Look up registered class (Date, Map and Set are registered like any other)
2015
+ const entry = class_registry[class_name];
2016
+ if (!entry) {
2268
2017
  if (verbose) {
2269
2018
  console.warn(`[JQHTML Cache] Cannot restore class "${class_name}" - not registered. ` +
2270
2019
  `Data will be returned as plain object. ` +
@@ -2273,10 +2022,14 @@ function process_for_deserialization(value, verbose) {
2273
2022
  // Return as plain object rather than failing completely
2274
2023
  return process_for_deserialization(props, verbose);
2275
2024
  }
2276
- // Create instance without calling constructor and assign properties
2025
+ // Restore through from_plain when the class has one; otherwise create an
2026
+ // instance without calling the constructor and assign the properties back.
2277
2027
  try {
2278
- const instance = Object.create(klass.prototype);
2279
2028
  const processed_props = process_for_deserialization(props, verbose);
2029
+ if (entry.from_plain) {
2030
+ return entry.from_plain(processed_props);
2031
+ }
2032
+ const instance = Object.create(entry.klass.prototype);
2280
2033
  Object.assign(instance, processed_props);
2281
2034
  return instance;
2282
2035
  }
@@ -2349,290 +2102,624 @@ class Jqhtml_Local_Storage {
2349
2102
  * @returns {boolean}
2350
2103
  * @private
2351
2104
  */
2352
- static _is_storage_available() {
2105
+ static _is_storage_available() {
2106
+ try {
2107
+ const storage = window.localStorage;
2108
+ const test = '__jqhtml_storage_test__';
2109
+ storage.setItem(test, test);
2110
+ storage.removeItem(test);
2111
+ return true;
2112
+ }
2113
+ catch (e) {
2114
+ return false;
2115
+ }
2116
+ }
2117
+ /**
2118
+ * Check if verbose mode is enabled
2119
+ * @private
2120
+ */
2121
+ static _is_verbose() {
2122
+ return window.jqhtml?.debug?.verbose === true;
2123
+ }
2124
+ /**
2125
+ * Validate storage scope and clear JQHTML keys if cache key changed
2126
+ * Only clears keys prefixed with 'jqhtml::' to preserve other libraries' data
2127
+ * @private
2128
+ */
2129
+ static _validate_scope() {
2130
+ if (!this._storage_available) {
2131
+ return;
2132
+ }
2133
+ try {
2134
+ const stored_key = localStorage.getItem('_jqhtml_cache_key');
2135
+ // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2136
+ if (stored_key !== null && stored_key !== this._scope_marker) {
2137
+ console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
2138
+ old_key: stored_key,
2139
+ new_key: this._scope_marker,
2140
+ });
2141
+ this._clear_jqhtml_keys();
2142
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2143
+ }
2144
+ else if (stored_key === null) {
2145
+ // First time JQHTML is using this storage - just set the key, don't clear
2146
+ console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2147
+ new_key: this._scope_marker,
2148
+ });
2149
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2150
+ }
2151
+ }
2152
+ catch (e) {
2153
+ console.error('[JQHTML Local Storage] Failed to validate scope:', e);
2154
+ }
2155
+ }
2156
+ /**
2157
+ * Clear only JQHTML keys from storage (keys starting with 'jqhtml::')
2158
+ * Preserves keys from other libraries
2159
+ * @private
2160
+ */
2161
+ static _clear_jqhtml_keys() {
2162
+ if (!this._storage_available) {
2163
+ return;
2164
+ }
2165
+ const keys_to_remove = [];
2166
+ // Collect all JQHTML keys
2167
+ for (let i = 0; i < localStorage.length; i++) {
2168
+ const key = localStorage.key(i);
2169
+ if (key && key.startsWith('jqhtml::')) {
2170
+ keys_to_remove.push(key);
2171
+ }
2172
+ }
2173
+ // Remove collected keys
2174
+ keys_to_remove.forEach(key => {
2175
+ try {
2176
+ localStorage.removeItem(key);
2177
+ }
2178
+ catch (e) {
2179
+ console.error('[JQHTML Local Storage] Failed to remove key:', key, e);
2180
+ }
2181
+ });
2182
+ console.log(`[JQHTML Local Storage] Cleared ${keys_to_remove.length} JQHTML keys`);
2183
+ }
2184
+ /**
2185
+ * Build scoped key with JQHTML namespace prefix
2186
+ * @param {string} key - Developer-provided key
2187
+ * @returns {string}
2188
+ * @private
2189
+ */
2190
+ static _build_key(key) {
2191
+ return `jqhtml::${key}::${this._cache_key}`;
2192
+ }
2193
+ /**
2194
+ * Check if storage is ready for use
2195
+ * @returns {boolean}
2196
+ * @private
2197
+ */
2198
+ static _is_ready() {
2199
+ return this._storage_available === true && this._cache_key !== null && this._initialized;
2200
+ }
2201
+ /**
2202
+ * Set item in localStorage with class-aware serialization.
2203
+ *
2204
+ * If serialization fails (e.g., unregistered class instances, circular refs),
2205
+ * the existing cache entry is removed and nothing is stored.
2206
+ *
2207
+ * @param {string} key - Storage key
2208
+ * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2209
+ */
2210
+ static set(key, value) {
2211
+ if (!this._is_ready()) {
2212
+ return;
2213
+ }
2214
+ const verbose = this._is_verbose();
2215
+ const scoped_key = this._build_key(key);
2216
+ // Serialize with class-aware processing
2217
+ const serialized = serialize_value(value, verbose);
2218
+ if (serialized === null) {
2219
+ // Serialization failed - remove any existing cache entry
2220
+ if (verbose) {
2221
+ console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
2222
+ `Removing existing cache entry if present.`);
2223
+ }
2224
+ try {
2225
+ localStorage.removeItem(scoped_key);
2226
+ }
2227
+ catch (e) {
2228
+ // Ignore removal errors
2229
+ }
2230
+ return;
2231
+ }
2232
+ // Check size before attempting to store (1MB limit)
2233
+ const size_bytes = new Blob([serialized]).size;
2234
+ const size_mb = size_bytes / (1024 * 1024);
2235
+ if (size_mb > 1) {
2236
+ if (verbose) {
2237
+ console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
2238
+ }
2239
+ // Remove existing cache entry since we can't update it
2240
+ try {
2241
+ localStorage.removeItem(scoped_key);
2242
+ }
2243
+ catch (e) {
2244
+ // Ignore removal errors
2245
+ }
2246
+ return;
2247
+ }
2248
+ this._set_item(key, serialized);
2249
+ }
2250
+ /**
2251
+ * Get item from localStorage with class-aware deserialization.
2252
+ *
2253
+ * If deserialization fails, returns null (as if no cache exists).
2254
+ *
2255
+ * @param {string} key - Storage key
2256
+ * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2257
+ */
2258
+ static get(key) {
2259
+ if (!this._is_ready()) {
2260
+ return null;
2261
+ }
2262
+ const verbose = this._is_verbose();
2263
+ const scoped_key = this._build_key(key);
2264
+ try {
2265
+ const serialized = localStorage.getItem(scoped_key);
2266
+ if (serialized === null) {
2267
+ return null;
2268
+ }
2269
+ const result = deserialize_value(serialized, verbose);
2270
+ if (result === null) {
2271
+ // Deserialization failed - treat as cache miss
2272
+ if (verbose) {
2273
+ console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
2274
+ `Treating as cache miss.`);
2275
+ }
2276
+ return null;
2277
+ }
2278
+ return result;
2279
+ }
2280
+ catch (e) {
2281
+ if (verbose) {
2282
+ console.warn('[JQHTML Cache] Failed to get item:', e);
2283
+ }
2284
+ return null;
2285
+ }
2286
+ }
2287
+ /**
2288
+ * Remove item from localStorage
2289
+ * @param {string} key - Storage key
2290
+ */
2291
+ static remove(key) {
2292
+ if (!this._is_ready()) {
2293
+ return;
2294
+ }
2295
+ this._remove_item(key);
2296
+ }
2297
+ /**
2298
+ * Serialize a value to its class-aware TEXT form - the same text set() stores.
2299
+ *
2300
+ * This is the copy-by-value primitive the rest of the framework uses whenever
2301
+ * data has to cross from one component to another (load-coordinator.ts hands a
2302
+ * leader's data to its followers through it). Plain JSON.stringify is not a
2303
+ * substitute: it consults toJSON() and produces a wire form that deserialize()
2304
+ * cannot recognise, so a registered class would come back as a plain object.
2305
+ *
2306
+ * @returns The text, or null when the value as a whole cannot be expressed.
2307
+ */
2308
+ static serialize(value) {
2309
+ return serialize_value(value, this._is_verbose());
2310
+ }
2311
+ /**
2312
+ * Rebuild a value from text produced by serialize(), restoring Date, Map, Set
2313
+ * and every register_cache_class() class. Returns null when the text is not
2314
+ * restorable (a registered from_plain threw at the root) - the same signal
2315
+ * get() gives for a corrupt entry.
2316
+ */
2317
+ static deserialize(text) {
2318
+ return deserialize_value(text, this._is_verbose());
2319
+ }
2320
+ /**
2321
+ * Perform a serialize/deserialize round-trip on a value.
2322
+ *
2323
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2324
+ * is aliased through, and a value the serializer cannot express is dropped
2325
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2326
+ * came from on_load() or from a cache hit - the old "on failure return the
2327
+ * original" branch broke exactly that promise, and only on the hard cases.
2328
+ *
2329
+ * @param {any} value - The value to normalize
2330
+ * @param {string} [component_name] - Names the component in development warnings.
2331
+ * Omit to convert silently (the unit tests and non-component callers).
2332
+ * @returns {any} The round-tripped value. Never the original object.
2333
+ */
2334
+ static normalize_for_cache(value, component_name) {
2335
+ const verbose = this._is_verbose();
2336
+ const collector = make_strip_collector(component_name);
2337
+ // Serialize to JSON string
2338
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2339
+ if (serialized === null) {
2340
+ // The value as a WHOLE could not be expressed (it was itself a function,
2341
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2342
+ // returning the original would put an uncacheable object into this.data.
2343
+ return {};
2344
+ }
2345
+ // Deserialize back to object
2346
+ const deserialized = deserialize_value(serialized, verbose);
2347
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2348
+ // that legitimately serialized to the text `null` also comes back as null. The
2349
+ // serialized text disambiguates.
2350
+ if (deserialized === null && serialized !== 'null') {
2351
+ // Only reachable when a registered class constructor blew up on restore.
2352
+ return {};
2353
+ }
2354
+ return deserialized;
2355
+ }
2356
+ /**
2357
+ * Internal set implementation with scope validation and quota handling
2358
+ * @param {string} key
2359
+ * @param {string} serialized - Pre-serialized JSON string
2360
+ * @private
2361
+ */
2362
+ static _set_item(key, serialized) {
2363
+ // Validate scope before every write
2364
+ this._validate_scope();
2365
+ const scoped_key = this._build_key(key);
2353
2366
  try {
2354
- const storage = window.localStorage;
2355
- const test = '__jqhtml_storage_test__';
2356
- storage.setItem(test, test);
2357
- storage.removeItem(test);
2358
- return true;
2367
+ localStorage.setItem(scoped_key, serialized);
2359
2368
  }
2360
2369
  catch (e) {
2361
- return false;
2370
+ // Check if quota exceeded
2371
+ if (e.name === 'QuotaExceededError' || e.code === 22) {
2372
+ console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2373
+ // Clear only JQHTML keys and retry once
2374
+ this._clear_jqhtml_keys();
2375
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2376
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2377
+ // made the very next write look like a scope change and wipe the cache again.
2378
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2379
+ try {
2380
+ localStorage.setItem(scoped_key, serialized);
2381
+ }
2382
+ catch (retry_error) {
2383
+ console.error('[JQHTML Local Storage] Failed to set item after clearing JQHTML keys:', retry_error);
2384
+ }
2385
+ }
2386
+ else {
2387
+ console.error('[JQHTML Local Storage] Failed to set item:', e);
2388
+ }
2362
2389
  }
2363
2390
  }
2364
2391
  /**
2365
- * Check if verbose mode is enabled
2392
+ * Internal remove implementation
2393
+ * @param {string} key
2366
2394
  * @private
2367
2395
  */
2368
- static _is_verbose() {
2369
- return window.jqhtml?.debug?.verbose === true;
2396
+ static _remove_item(key) {
2397
+ const scoped_key = this._build_key(key);
2398
+ try {
2399
+ localStorage.removeItem(scoped_key);
2400
+ }
2401
+ catch (e) {
2402
+ console.error('[JQHTML Local Storage] Failed to remove item:', e);
2403
+ }
2370
2404
  }
2405
+ }
2406
+ Jqhtml_Local_Storage._cache_key = null;
2407
+ /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2408
+ Jqhtml_Local_Storage._scope_marker = null;
2409
+ Jqhtml_Local_Storage._cache_mode = 'data';
2410
+ Jqhtml_Local_Storage._storage_available = null;
2411
+ Jqhtml_Local_Storage._initialized = false;
2412
+
2413
+ /**
2414
+ * Load Coordinator - Request deduplication for component on_load() calls
2415
+ *
2416
+ * Coordinates parallel component loading to prevent duplicate requests.
2417
+ * When multiple components with identical names and args load simultaneously,
2418
+ * only the first (leader) executes on_load(). Others (followers) wait for
2419
+ * the leader's result.
2420
+ *
2421
+ * Key Concepts:
2422
+ * - **INVOCATION_KEY**: Unique identifier for component name + args combination
2423
+ * - **Leader**: First component to reach on_load() for a given INVOCATION_KEY
2424
+ * - **Follower**: Subsequent components that wait for leader's result
2425
+ *
2426
+ * Lifecycle:
2427
+ * 1. Leader reaches on_load() → create coordination entry
2428
+ * 2. Followers reach on_load() → join waiting queue
2429
+ * 3. Leader completes → distribute data to all followers
2430
+ * 4. Clear coordination entry (no caching)
2431
+ *
2432
+ * The hand-off is a copy, and it is made by the cache serializer
2433
+ * (Jqhtml_Local_Storage.serialize()/deserialize()), never by JSON.stringify.
2434
+ * A follower never runs on_load() and never revalidates, so whatever it is
2435
+ * handed is final: the copy has to rebuild Date, Map, Set and every
2436
+ * register_cache_class() instance exactly as the leader's this.data holds them.
2437
+ * JSON.stringify would consult toJSON() and hand out a wire form that
2438
+ * normalize_for_cache() cannot recognise - the follower would then render
2439
+ * against plain objects while the leader renders against instances.
2440
+ *
2441
+ * @internal This class is not exposed in the public API
2442
+ */
2443
+ class Load_Coordinator {
2371
2444
  /**
2372
- * Validate storage scope and clear JQHTML keys if cache key changed
2373
- * Only clears keys prefixed with 'jqhtml::' to preserve other libraries' data
2374
- * @private
2445
+ * Generate INVOCATION_KEY from component name and args
2446
+ * Uses deterministic JSON serialization (sorted keys)
2447
+ * Excludes internal properties (those starting with _)
2448
+ *
2449
+ * For functions/objects:
2450
+ * - Checks for ._jqhtml_cache_id property (assigned by RSpade)
2451
+ * - Falls back to .jqhtml_cache_id() method if property doesn't exist
2452
+ * - If neither exists, marks property as uncacheable
2453
+ *
2454
+ * Returns object with:
2455
+ * - key: Cache key string, or null if uncacheable
2456
+ * - uncacheable_property: Name of first property that prevented caching (for debugging)
2375
2457
  */
2376
- static _validate_scope() {
2377
- if (!this._storage_available) {
2378
- return;
2379
- }
2380
- try {
2381
- const stored_key = localStorage.getItem('_jqhtml_cache_key');
2382
- // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2383
- if (stored_key !== null && stored_key !== this._scope_marker) {
2384
- console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
2385
- old_key: stored_key,
2386
- new_key: this._scope_marker,
2387
- });
2388
- this._clear_jqhtml_keys();
2389
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2458
+ static generate_invocation_key(component_name, args, options = {}) {
2459
+ let uncacheable_property;
2460
+ let uncacheable_reason;
2461
+ // Filter out internal properties (starting with _) and serialize args
2462
+ const serializable_args = {};
2463
+ for (const key of Object.keys(args).sort()) {
2464
+ if (key.startsWith('_')) {
2465
+ continue; // Skip internal properties
2390
2466
  }
2391
- else if (stored_key === null) {
2392
- // First time JQHTML is using this storage - just set the key, don't clear
2393
- console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2394
- new_key: this._scope_marker,
2395
- });
2396
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2467
+ // Skip framework properties that shouldn't affect cache identity
2468
+ // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
2469
+ if (key === 'use_cached_data') {
2470
+ continue;
2471
+ }
2472
+ const value = args[key];
2473
+ const value_type = typeof value;
2474
+ // Handle primitives (string, number, boolean, null, undefined)
2475
+ if (value === null || value === undefined ||
2476
+ value_type === 'string' || value_type === 'number' ||
2477
+ value_type === 'boolean') {
2478
+ serializable_args[key] = value;
2479
+ continue;
2480
+ }
2481
+ // Handle functions and objects
2482
+ if (value_type === 'function' || value_type === 'object') {
2483
+ // Check for ._jqhtml_cache_id property (fastest, what RSpade assigns)
2484
+ if (value._jqhtml_cache_id !== undefined) {
2485
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(value._jqhtml_cache_id)}`;
2486
+ continue;
2487
+ }
2488
+ // Check for .jqhtml_cache_id() method (more flexible, for custom objects)
2489
+ if (typeof value.jqhtml_cache_id === 'function') {
2490
+ try {
2491
+ const cache_id = value.jqhtml_cache_id();
2492
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(cache_id)}`;
2493
+ continue;
2494
+ }
2495
+ catch (error) {
2496
+ // Method threw error - treat as uncacheable
2497
+ if (!uncacheable_property) {
2498
+ uncacheable_property = key;
2499
+ uncacheable_reason = 'cache-id-threw';
2500
+ }
2501
+ return { key: null, uncacheable_property, uncacheable_reason };
2502
+ }
2503
+ }
2504
+ // No author-supplied id. The cache may fall back to keying by
2505
+ // deterministic content; deduplication deliberately may not.
2506
+ if (options.allow_content_serialization) {
2507
+ const serialized = serialize_for_cache_key(value);
2508
+ if (serialized.ok) {
2509
+ serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
2510
+ continue;
2511
+ }
2512
+ if (!uncacheable_property) {
2513
+ uncacheable_property = key;
2514
+ // This package compiles with strictNullChecks off, and without it
2515
+ // TypeScript will not narrow a discriminated union by its boolean
2516
+ // discriminant - so `serialized` is still the full Serialize_Result
2517
+ // here even though `ok` has been ruled out above. The cast states
2518
+ // what the control flow already guarantees.
2519
+ uncacheable_reason = serialized.reason;
2520
+ }
2521
+ return { key: null, uncacheable_property, uncacheable_reason };
2522
+ }
2523
+ if (!uncacheable_property) {
2524
+ uncacheable_property = key;
2525
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
2526
+ }
2527
+ return { key: null, uncacheable_property, uncacheable_reason };
2528
+ }
2529
+ // Unknown type (symbol, bigint, etc.) - uncacheable
2530
+ if (!uncacheable_property) {
2531
+ uncacheable_property = key;
2532
+ uncacheable_reason = value_type;
2397
2533
  }
2534
+ return { key: null, uncacheable_property, uncacheable_reason };
2398
2535
  }
2399
- catch (e) {
2400
- console.error('[JQHTML Local Storage] Failed to validate scope:', e);
2536
+ // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
2537
+ try {
2538
+ const sorted_args = JSON.stringify(serializable_args);
2539
+ return { key: `${component_name}::${sorted_args}` };
2540
+ }
2541
+ catch (error) {
2542
+ // Serialization failed unexpectedly
2543
+ return { key: null, uncacheable_property };
2401
2544
  }
2402
2545
  }
2403
2546
  /**
2404
- * Clear only JQHTML keys from storage (keys starting with 'jqhtml::')
2405
- * Preserves keys from other libraries
2406
- * @private
2547
+ * Check if a component should execute on_load() or wait for existing request
2548
+ * Returns true if component should execute (is leader), false if should wait (is follower)
2549
+ *
2550
+ * @param key - The dedup key the caller captured for this load. Every coordinator
2551
+ * method takes it, because args may legally change during on_load()
2552
+ * and a recomputed key would address a different (or no) entry.
2407
2553
  */
2408
- static _clear_jqhtml_keys() {
2409
- if (!this._storage_available) {
2410
- return;
2554
+ static should_execute_on_load(component, key) {
2555
+ const entry = this._registry.get(key);
2556
+ if (!entry) {
2557
+ // No existing request - this component becomes the leader
2558
+ return true;
2411
2559
  }
2412
- const keys_to_remove = [];
2413
- // Collect all JQHTML keys
2414
- for (let i = 0; i < localStorage.length; i++) {
2415
- const key = localStorage.key(i);
2416
- if (key && key.startsWith('jqhtml::')) {
2417
- keys_to_remove.push(key);
2418
- }
2560
+ if (entry.status === 'loading') {
2561
+ // Request in progress - this component becomes a follower
2562
+ entry.waiting.push(component);
2563
+ return false;
2419
2564
  }
2420
- // Remove collected keys
2421
- keys_to_remove.forEach(key => {
2422
- try {
2423
- localStorage.removeItem(key);
2424
- }
2425
- catch (e) {
2426
- console.error('[JQHTML Local Storage] Failed to remove key:', key, e);
2427
- }
2428
- });
2429
- console.log(`[JQHTML Local Storage] Cleared ${keys_to_remove.length} JQHTML keys`);
2565
+ // Entry exists but completed/failed - should have been cleaned up
2566
+ // Treat as new leader
2567
+ return true;
2430
2568
  }
2431
2569
  /**
2432
- * Build scoped key with JQHTML namespace prefix
2433
- * @param {string} key - Developer-provided key
2434
- * @returns {string}
2435
- * @private
2570
+ * Register a leader component that will execute on_load()
2571
+ * Creates coordination entry and returns a function to call when data is ready
2572
+ *
2573
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
2574
+ * @returns A function that accepts the final data and completes coordination
2436
2575
  */
2437
- static _build_key(key) {
2438
- return `jqhtml::${key}::${this._cache_key}`;
2576
+ static register_leader(component, key) {
2577
+ // Create a promise that we control - it settles when coordination completes or fails
2578
+ let resolve_promise;
2579
+ let reject_promise;
2580
+ const coordination_promise = new Promise((resolve, reject) => {
2581
+ resolve_promise = resolve;
2582
+ reject_promise = reject;
2583
+ });
2584
+ // A leader with no followers still has to be able to reject this promise on
2585
+ // error; without a handler that rejection surfaces as an unhandled rejection.
2586
+ coordination_promise.catch(() => { });
2587
+ const entry = {
2588
+ status: 'loading',
2589
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
2590
+ resolve_promise,
2591
+ reject_promise,
2592
+ leader_component: component,
2593
+ leader_data: null,
2594
+ waiting: []
2595
+ };
2596
+ this._registry.set(key, entry);
2597
+ // Return cleanup function that accepts the final data
2598
+ return (final_data) => this._complete_coordination(key, component, final_data);
2439
2599
  }
2440
2600
  /**
2441
- * Check if storage is ready for use
2442
- * @returns {boolean}
2443
- * @private
2601
+ * Get the coordination promise for a follower component
2602
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
2444
2603
  */
2445
- static _is_ready() {
2446
- return this._storage_available === true && this._cache_key !== null && this._initialized;
2604
+ static get_coordination_promise(key) {
2605
+ const entry = this._registry.get(key);
2606
+ if (!entry || entry.status !== 'loading') {
2607
+ return null;
2608
+ }
2609
+ return entry.promise;
2447
2610
  }
2448
2611
  /**
2449
- * Set item in localStorage with class-aware serialization.
2450
- *
2451
- * If serialization fails (e.g., unregistered class instances, circular refs),
2452
- * the existing cache entry is removed and nothing is stored.
2612
+ * Complete coordination after leader's on_load() finishes
2613
+ * Stores leader's data, marks entry as completed, and resolves the promise.
2614
+ * Followers retrieve the data themselves via get_leader_data().
2453
2615
  *
2454
- * @param {string} key - Storage key
2455
- * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2616
+ * @param key - The coordination key captured at registration
2617
+ * @param leader - The leader component
2618
+ * @param final_data - The final data after on_load and normalization
2619
+ * @private
2456
2620
  */
2457
- static set(key, value) {
2458
- if (!this._is_ready()) {
2621
+ static _complete_coordination(key, leader, final_data) {
2622
+ const entry = this._registry.get(key);
2623
+ if (!entry) {
2459
2624
  return;
2460
2625
  }
2461
- const verbose = this._is_verbose();
2462
- const scoped_key = this._build_key(key);
2463
- // Serialize with class-aware processing
2464
- const serialized = serialize_value(value, verbose);
2465
- if (serialized === null) {
2466
- // Serialization failed - remove any existing cache entry
2467
- if (verbose) {
2468
- console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
2469
- `Removing existing cache entry if present.`);
2470
- }
2471
- try {
2472
- localStorage.removeItem(scoped_key);
2473
- }
2474
- catch (e) {
2475
- // Ignore removal errors
2476
- }
2477
- return;
2626
+ // Stored as TEXT so each follower rebuilds its own object graph - sharing one
2627
+ // decoded object would let one component's mutation reach every other.
2628
+ // The cache serializer, not JSON.stringify: this.data holds live Date/Map/Set
2629
+ // and register_cache_class() instances, and only the serializer that made
2630
+ // them knows how to carry them across. null means the data as a whole could
2631
+ // not be expressed; followers then load independently.
2632
+ entry.leader_data = Jqhtml_Local_Storage.serialize(final_data);
2633
+ entry.status = 'completed';
2634
+ if (window.jqhtml?.debug?.verbose) {
2635
+ console.log(`[Load Coordinator] Leader ${leader._cid} completed, data available for ${entry.waiting.length} followers`, { key, data: entry.leader_data });
2478
2636
  }
2479
- // Check size before attempting to store (1MB limit)
2480
- const size_bytes = new Blob([serialized]).size;
2481
- const size_mb = size_bytes / (1024 * 1024);
2482
- if (size_mb > 1) {
2483
- if (verbose) {
2484
- console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
2485
- }
2486
- // Remove existing cache entry since we can't update it
2487
- try {
2488
- localStorage.removeItem(scoped_key);
2489
- }
2490
- catch (e) {
2491
- // Ignore removal errors
2492
- }
2493
- return;
2637
+ // Resolve the promise - followers waiting on it will now wake up
2638
+ entry.resolve_promise();
2639
+ // Nobody is waiting, so nothing will ever come back to collect the data.
2640
+ // Without this the registry grows once per distinct name+args, forever.
2641
+ if (entry.waiting.length === 0) {
2642
+ this._registry.delete(key);
2494
2643
  }
2495
- this._set_item(key, serialized);
2496
2644
  }
2497
2645
  /**
2498
- * Get item from localStorage with class-aware deserialization.
2499
- *
2500
- * If deserialization fails, returns null (as if no cache exists).
2646
+ * Get leader's data for a follower component
2647
+ * Called by follower after coordination promise resolves
2648
+ * Returns a freshly parsed copy and cleans up if this is the last follower
2501
2649
  *
2502
- * @param {string} key - Storage key
2503
- * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2650
+ * @param key - The dedup key captured by the follower before it began waiting
2504
2651
  */
2505
- static get(key) {
2506
- if (!this._is_ready()) {
2652
+ static get_leader_data(component, key) {
2653
+ const entry = this._registry.get(key);
2654
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
2507
2655
  return null;
2508
2656
  }
2509
- const verbose = this._is_verbose();
2510
- const scoped_key = this._build_key(key);
2511
- try {
2512
- const serialized = localStorage.getItem(scoped_key);
2513
- if (serialized === null) {
2514
- return null;
2515
- }
2516
- const result = deserialize_value(serialized, verbose);
2517
- if (result === null) {
2518
- // Deserialization failed - treat as cache miss
2519
- if (verbose) {
2520
- console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
2521
- `Treating as cache miss.`);
2522
- }
2523
- return null;
2524
- }
2525
- return result;
2657
+ // Remove this follower from the waiting list
2658
+ const follower_index = entry.waiting.indexOf(component);
2659
+ if (follower_index !== -1) {
2660
+ entry.waiting.splice(follower_index, 1);
2526
2661
  }
2527
- catch (e) {
2528
- if (verbose) {
2529
- console.warn('[JQHTML Cache] Failed to get item:', e);
2530
- }
2531
- return null;
2662
+ // One deserialize per follower: each gets its OWN object graph, never a
2663
+ // shared reference, with the same instances the leader's this.data holds.
2664
+ const data = Jqhtml_Local_Storage.deserialize(entry.leader_data);
2665
+ if (window.jqhtml?.debug?.verbose) {
2666
+ console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
2532
2667
  }
2533
- }
2534
- /**
2535
- * Remove item from localStorage
2536
- * @param {string} key - Storage key
2537
- */
2538
- static remove(key) {
2539
- if (!this._is_ready()) {
2540
- return;
2668
+ // If this was the last follower, clean up the entry
2669
+ if (entry.waiting.length === 0) {
2670
+ this._registry.delete(key);
2671
+ if (window.jqhtml?.debug?.verbose) {
2672
+ console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
2673
+ }
2541
2674
  }
2542
- this._remove_item(key);
2675
+ return data;
2543
2676
  }
2544
2677
  /**
2545
- * Perform a serialize/deserialize round-trip on a value.
2546
- *
2547
- * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2548
- * is aliased through, and a value the serializer cannot express is dropped
2549
- * rather than smuggled out. That is what makes `this.data` identical whether it
2550
- * came from on_load() or from a cache hit - the old "on failure return the
2551
- * original" branch broke exactly that promise, and only on the hard cases.
2678
+ * Handle leader on_load() error
2679
+ * Rejects every waiting follower and clears the entry so the next component retries
2552
2680
  *
2553
- * @param {any} value - The value to normalize
2554
- * @param {string} [component_name] - Names the component in development warnings.
2555
- * Omit to convert silently (the unit tests and non-component callers).
2556
- * @returns {any} The round-tripped value. Never the original object.
2681
+ * @param key - The dedup key captured at registration
2557
2682
  */
2558
- static normalize_for_cache(value, component_name) {
2559
- const verbose = this._is_verbose();
2560
- const collector = make_strip_collector(component_name);
2561
- // Serialize to JSON string
2562
- const serialized = serialize_value(value, verbose, 'this.data', collector);
2563
- if (serialized === null) {
2564
- // The value as a WHOLE could not be expressed (it was itself a function,
2565
- // a promise, a DOM node...). The round trip of such a value is nothing;
2566
- // returning the original would put an uncacheable object into this.data.
2567
- return {};
2683
+ static handle_leader_error(key, error) {
2684
+ const entry = this._registry.get(key);
2685
+ if (!entry) {
2686
+ return;
2568
2687
  }
2569
- // Deserialize back to object
2570
- const deserialized = deserialize_value(serialized, verbose);
2571
- // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2572
- // that legitimately serialized to the text `null` also comes back as null. The
2573
- // serialized text disambiguates.
2574
- if (deserialized === null && serialized !== 'null') {
2575
- // Only reachable when a registered class constructor blew up on restore.
2576
- return {};
2688
+ entry.status = 'failed';
2689
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
2690
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
2691
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
2692
+ // without rejecting left them awaiting a promise nobody could ever settle.
2693
+ entry.reject_promise(error);
2694
+ // Clear coordination entry so future requests can retry
2695
+ this._registry.delete(key);
2696
+ if (window.jqhtml?.debug?.verbose) {
2697
+ console.log(`[Load Coordinator] Cleared failed coordination for key: ${key}`, { followers_count: entry.waiting.length });
2577
2698
  }
2578
- return deserialized;
2579
2699
  }
2580
2700
  /**
2581
- * Internal set implementation with scope validation and quota handling
2582
- * @param {string} key
2583
- * @param {string} serialized - Pre-serialized JSON string
2584
- * @private
2701
+ * Get current registry state (for debugging)
2585
2702
  */
2586
- static _set_item(key, serialized) {
2587
- // Validate scope before every write
2588
- this._validate_scope();
2589
- const scoped_key = this._build_key(key);
2590
- try {
2591
- localStorage.setItem(scoped_key, serialized);
2592
- }
2593
- catch (e) {
2594
- // Check if quota exceeded
2595
- if (e.name === 'QuotaExceededError' || e.code === 22) {
2596
- console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2597
- // Clear only JQHTML keys and retry once
2598
- this._clear_jqhtml_keys();
2599
- // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2600
- // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2601
- // made the very next write look like a scope change and wipe the cache again.
2602
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2603
- try {
2604
- localStorage.setItem(scoped_key, serialized);
2605
- }
2606
- catch (retry_error) {
2607
- console.error('[JQHTML Local Storage] Failed to set item after clearing JQHTML keys:', retry_error);
2608
- }
2609
- }
2610
- else {
2611
- console.error('[JQHTML Local Storage] Failed to set item:', e);
2612
- }
2703
+ static get_registry_state() {
2704
+ const state = {};
2705
+ for (const [key, entry] of this._registry.entries()) {
2706
+ state[key] = {
2707
+ status: entry.status,
2708
+ leader_cid: entry.leader_component._cid,
2709
+ waiting_count: entry.waiting.length,
2710
+ waiting_cids: entry.waiting.map(c => c._cid)
2711
+ };
2613
2712
  }
2713
+ return state;
2614
2714
  }
2615
2715
  /**
2616
- * Internal remove implementation
2617
- * @param {string} key
2618
- * @private
2716
+ * Clear all coordination entries (for testing/debugging)
2619
2717
  */
2620
- static _remove_item(key) {
2621
- const scoped_key = this._build_key(key);
2622
- try {
2623
- localStorage.removeItem(scoped_key);
2624
- }
2625
- catch (e) {
2626
- console.error('[JQHTML Local Storage] Failed to remove item:', e);
2627
- }
2718
+ static clear_all() {
2719
+ this._registry.clear();
2628
2720
  }
2629
2721
  }
2630
- Jqhtml_Local_Storage._cache_key = null;
2631
- /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2632
- Jqhtml_Local_Storage._scope_marker = null;
2633
- Jqhtml_Local_Storage._cache_mode = 'data';
2634
- Jqhtml_Local_Storage._storage_available = null;
2635
- Jqhtml_Local_Storage._initialized = false;
2722
+ Load_Coordinator._registry = new Map();
2636
2723
 
2637
2724
  /**
2638
2725
  * JQHTML Component Event System
@@ -7248,7 +7335,7 @@ function init(jQuery, config) {
7248
7335
  }
7249
7336
  }
7250
7337
  // Version - will be replaced during build with actual version from package.json
7251
- const version = '2.3.70';
7338
+ const version = '2.3.71';
7252
7339
  // Default export with all functionality
7253
7340
  const jqhtml = {
7254
7341
  // Core