@jqhtml/core 2.3.68 → 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.
package/dist/index.cjs CHANGED
@@ -1509,309 +1509,6 @@ function serialize_for_cache_key(value) {
1509
1509
  }
1510
1510
  }
1511
1511
 
1512
- /**
1513
- * Load Coordinator - Request deduplication for component on_load() calls
1514
- *
1515
- * Coordinates parallel component loading to prevent duplicate requests.
1516
- * When multiple components with identical names and args load simultaneously,
1517
- * only the first (leader) executes on_load(). Others (followers) wait for
1518
- * the leader's result.
1519
- *
1520
- * Key Concepts:
1521
- * - **INVOCATION_KEY**: Unique identifier for component name + args combination
1522
- * - **Leader**: First component to reach on_load() for a given INVOCATION_KEY
1523
- * - **Follower**: Subsequent components that wait for leader's result
1524
- *
1525
- * Lifecycle:
1526
- * 1. Leader reaches on_load() → create coordination entry
1527
- * 2. Followers reach on_load() → join waiting queue
1528
- * 3. Leader completes → distribute data to all followers
1529
- * 4. Clear coordination entry (no caching)
1530
- *
1531
- * @internal This class is not exposed in the public API
1532
- */
1533
- class Load_Coordinator {
1534
- /**
1535
- * Generate INVOCATION_KEY from component name and args
1536
- * Uses deterministic JSON serialization (sorted keys)
1537
- * Excludes internal properties (those starting with _)
1538
- *
1539
- * For functions/objects:
1540
- * - Checks for ._jqhtml_cache_id property (assigned by RSpade)
1541
- * - Falls back to .jqhtml_cache_id() method if property doesn't exist
1542
- * - If neither exists, marks property as uncacheable
1543
- *
1544
- * Returns object with:
1545
- * - key: Cache key string, or null if uncacheable
1546
- * - uncacheable_property: Name of first property that prevented caching (for debugging)
1547
- */
1548
- static generate_invocation_key(component_name, args, options = {}) {
1549
- let uncacheable_property;
1550
- let uncacheable_reason;
1551
- // Filter out internal properties (starting with _) and serialize args
1552
- const serializable_args = {};
1553
- for (const key of Object.keys(args).sort()) {
1554
- if (key.startsWith('_')) {
1555
- continue; // Skip internal properties
1556
- }
1557
- // Skip framework properties that shouldn't affect cache identity
1558
- // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
1559
- if (key === 'use_cached_data') {
1560
- continue;
1561
- }
1562
- const value = args[key];
1563
- const value_type = typeof value;
1564
- // Handle primitives (string, number, boolean, null, undefined)
1565
- if (value === null || value === undefined ||
1566
- value_type === 'string' || value_type === 'number' ||
1567
- value_type === 'boolean') {
1568
- serializable_args[key] = value;
1569
- continue;
1570
- }
1571
- // Handle functions and objects
1572
- if (value_type === 'function' || value_type === 'object') {
1573
- // Check for ._jqhtml_cache_id property (fastest, what RSpade assigns)
1574
- if (value._jqhtml_cache_id !== undefined) {
1575
- serializable_args[key] = `__JQHTML_CACHE_ID__:${String(value._jqhtml_cache_id)}`;
1576
- continue;
1577
- }
1578
- // Check for .jqhtml_cache_id() method (more flexible, for custom objects)
1579
- if (typeof value.jqhtml_cache_id === 'function') {
1580
- try {
1581
- const cache_id = value.jqhtml_cache_id();
1582
- serializable_args[key] = `__JQHTML_CACHE_ID__:${String(cache_id)}`;
1583
- continue;
1584
- }
1585
- catch (error) {
1586
- // Method threw error - treat as uncacheable
1587
- if (!uncacheable_property) {
1588
- uncacheable_property = key;
1589
- uncacheable_reason = 'cache-id-threw';
1590
- }
1591
- return { key: null, uncacheable_property, uncacheable_reason };
1592
- }
1593
- }
1594
- // No author-supplied id. The cache may fall back to keying by
1595
- // deterministic content; deduplication deliberately may not.
1596
- if (options.allow_content_serialization) {
1597
- const serialized = serialize_for_cache_key(value);
1598
- if (serialized.ok) {
1599
- serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
1600
- continue;
1601
- }
1602
- if (!uncacheable_property) {
1603
- uncacheable_property = key;
1604
- // This package compiles with strictNullChecks off, and without it
1605
- // TypeScript will not narrow a discriminated union by its boolean
1606
- // discriminant - so `serialized` is still the full Serialize_Result
1607
- // here even though `ok` has been ruled out above. The cast states
1608
- // what the control flow already guarantees.
1609
- uncacheable_reason = serialized.reason;
1610
- }
1611
- return { key: null, uncacheable_property, uncacheable_reason };
1612
- }
1613
- if (!uncacheable_property) {
1614
- uncacheable_property = key;
1615
- uncacheable_reason = value_type === 'function' ? 'function' : 'object';
1616
- }
1617
- return { key: null, uncacheable_property, uncacheable_reason };
1618
- }
1619
- // Unknown type (symbol, bigint, etc.) - uncacheable
1620
- if (!uncacheable_property) {
1621
- uncacheable_property = key;
1622
- uncacheable_reason = value_type;
1623
- }
1624
- return { key: null, uncacheable_property, uncacheable_reason };
1625
- }
1626
- // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
1627
- try {
1628
- const sorted_args = JSON.stringify(serializable_args);
1629
- return { key: `${component_name}::${sorted_args}` };
1630
- }
1631
- catch (error) {
1632
- // Serialization failed unexpectedly
1633
- return { key: null, uncacheable_property };
1634
- }
1635
- }
1636
- /**
1637
- * Check if a component should execute on_load() or wait for existing request
1638
- * Returns true if component should execute (is leader), false if should wait (is follower)
1639
- *
1640
- * @param key - The dedup key the caller captured for this load. Every coordinator
1641
- * method takes it, because args may legally change during on_load()
1642
- * and a recomputed key would address a different (or no) entry.
1643
- */
1644
- static should_execute_on_load(component, key) {
1645
- const entry = this._registry.get(key);
1646
- if (!entry) {
1647
- // No existing request - this component becomes the leader
1648
- return true;
1649
- }
1650
- if (entry.status === 'loading') {
1651
- // Request in progress - this component becomes a follower
1652
- entry.waiting.push(component);
1653
- return false;
1654
- }
1655
- // Entry exists but completed/failed - should have been cleaned up
1656
- // Treat as new leader
1657
- return true;
1658
- }
1659
- /**
1660
- * Register a leader component that will execute on_load()
1661
- * Creates coordination entry and returns a function to call when data is ready
1662
- *
1663
- * @param key - The dedup key captured by the caller (see should_execute_on_load)
1664
- * @returns A function that accepts the final data and completes coordination
1665
- */
1666
- static register_leader(component, key) {
1667
- // Create a promise that we control - it settles when coordination completes or fails
1668
- let resolve_promise;
1669
- let reject_promise;
1670
- const coordination_promise = new Promise((resolve, reject) => {
1671
- resolve_promise = resolve;
1672
- reject_promise = reject;
1673
- });
1674
- // A leader with no followers still has to be able to reject this promise on
1675
- // error; without a handler that rejection surfaces as an unhandled rejection.
1676
- coordination_promise.catch(() => { });
1677
- const entry = {
1678
- status: 'loading',
1679
- promise: coordination_promise, // Followers await THIS promise, not on_load's
1680
- resolve_promise,
1681
- reject_promise,
1682
- leader_component: component,
1683
- leader_data: null,
1684
- waiting: []
1685
- };
1686
- this._registry.set(key, entry);
1687
- // Return cleanup function that accepts the final data
1688
- return (final_data) => this._complete_coordination(key, component, final_data);
1689
- }
1690
- /**
1691
- * Get the coordination promise for a follower component
1692
- * Returns a promise that resolves when the leader completes, or rejects if it failed
1693
- */
1694
- static get_coordination_promise(key) {
1695
- const entry = this._registry.get(key);
1696
- if (!entry || entry.status !== 'loading') {
1697
- return null;
1698
- }
1699
- return entry.promise;
1700
- }
1701
- /**
1702
- * Complete coordination after leader's on_load() finishes
1703
- * Stores leader's data, marks entry as completed, and resolves the promise.
1704
- * Followers retrieve the data themselves via get_leader_data().
1705
- *
1706
- * @param key - The coordination key captured at registration
1707
- * @param leader - The leader component
1708
- * @param final_data - The final data after on_load and normalization
1709
- * @private
1710
- */
1711
- static _complete_coordination(key, leader, final_data) {
1712
- const entry = this._registry.get(key);
1713
- if (!entry) {
1714
- return;
1715
- }
1716
- // Stored as TEXT so each follower parses its own object graph - sharing one
1717
- // decoded object would let one component's mutation reach every other.
1718
- try {
1719
- entry.leader_data = JSON.stringify(final_data);
1720
- }
1721
- catch (error) {
1722
- // Unserializable data cannot be shared; followers load independently.
1723
- entry.leader_data = null;
1724
- }
1725
- entry.status = 'completed';
1726
- if (window.jqhtml?.debug?.verbose) {
1727
- console.log(`[Load Coordinator] Leader ${leader._cid} completed, data available for ${entry.waiting.length} followers`, { key, data: entry.leader_data });
1728
- }
1729
- // Resolve the promise - followers waiting on it will now wake up
1730
- entry.resolve_promise();
1731
- // Nobody is waiting, so nothing will ever come back to collect the data.
1732
- // Without this the registry grows once per distinct name+args, forever.
1733
- if (entry.waiting.length === 0) {
1734
- this._registry.delete(key);
1735
- }
1736
- }
1737
- /**
1738
- * Get leader's data for a follower component
1739
- * Called by follower after coordination promise resolves
1740
- * Returns a freshly parsed copy and cleans up if this is the last follower
1741
- *
1742
- * @param key - The dedup key captured by the follower before it began waiting
1743
- */
1744
- static get_leader_data(component, key) {
1745
- const entry = this._registry.get(key);
1746
- if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1747
- return null;
1748
- }
1749
- // Remove this follower from the waiting list
1750
- const follower_index = entry.waiting.indexOf(component);
1751
- if (follower_index !== -1) {
1752
- entry.waiting.splice(follower_index, 1);
1753
- }
1754
- // One parse per follower: each gets its OWN object, never a shared reference.
1755
- const data = JSON.parse(entry.leader_data);
1756
- if (window.jqhtml?.debug?.verbose) {
1757
- console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1758
- }
1759
- // If this was the last follower, clean up the entry
1760
- if (entry.waiting.length === 0) {
1761
- this._registry.delete(key);
1762
- if (window.jqhtml?.debug?.verbose) {
1763
- console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1764
- }
1765
- }
1766
- return data;
1767
- }
1768
- /**
1769
- * Handle leader on_load() error
1770
- * Rejects every waiting follower and clears the entry so the next component retries
1771
- *
1772
- * @param key - The dedup key captured at registration
1773
- */
1774
- static handle_leader_error(key, error) {
1775
- const entry = this._registry.get(key);
1776
- if (!entry) {
1777
- return;
1778
- }
1779
- entry.status = 'failed';
1780
- console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
1781
- // Reject FIRST: followers are parked on this promise and their catch in _load()
1782
- // propagates the failure exactly as if their own on_load() had thrown. Deleting
1783
- // without rejecting left them awaiting a promise nobody could ever settle.
1784
- entry.reject_promise(error);
1785
- // Clear coordination entry so future requests can retry
1786
- this._registry.delete(key);
1787
- if (window.jqhtml?.debug?.verbose) {
1788
- console.log(`[Load Coordinator] Cleared failed coordination for key: ${key}`, { followers_count: entry.waiting.length });
1789
- }
1790
- }
1791
- /**
1792
- * Get current registry state (for debugging)
1793
- */
1794
- static get_registry_state() {
1795
- const state = {};
1796
- for (const [key, entry] of this._registry.entries()) {
1797
- state[key] = {
1798
- status: entry.status,
1799
- leader_cid: entry.leader_component._cid,
1800
- waiting_count: entry.waiting.length,
1801
- waiting_cids: entry.waiting.map(c => c._cid)
1802
- };
1803
- }
1804
- return state;
1805
- }
1806
- /**
1807
- * Clear all coordination entries (for testing/debugging)
1808
- */
1809
- static clear_all() {
1810
- this._registry.clear();
1811
- }
1812
- }
1813
- Load_Coordinator._registry = new Map();
1814
-
1815
1512
  /**
1816
1513
  * Jqhtml_Local_Storage - Scoped local storage for JQHTML component caching
1817
1514
  *
@@ -1830,7 +1527,19 @@ Load_Coordinator._registry = new Map();
1830
1527
  * ES6 class instances can be serialized and deserialized if registered via
1831
1528
  * register_cache_class(). Classes are wrapped as {__jqhtml_class__: "Name", __jqhtml_props__: {...}}
1832
1529
  * and restored to proper instances on retrieval. Nested class instances in objects
1833
- * and arrays are handled recursively.
1530
+ * and arrays are handled recursively. A class whose wire form is not its own
1531
+ * properties (one that defines toJSON(), or keeps state in private fields)
1532
+ * registers a {to_plain, from_plain} pair and is rebuilt through it. Date, Map and
1533
+ * Set are pre-registered the same way - there is no second mechanism.
1534
+ *
1535
+ * ONE SERIALIZER. Every copy of on_load() data the framework makes goes through
1536
+ * this file: the localStorage cache (set()/get()), the normalization of every
1537
+ * on_load() result into this.data (normalize_for_cache()), and the copy a
1538
+ * deduplicated follower takes of its leader's data (serialize()/deserialize(),
1539
+ * used by load-coordinator.ts). Anything that survives one of those survives all
1540
+ * of them, and anything dropped is dropped identically on all of them. A plain
1541
+ * JSON.stringify anywhere on those paths would consult toJSON() and hand out a
1542
+ * wire form nothing knows how to restore.
1834
1543
  *
1835
1544
  * Scoping Strategy:
1836
1545
  * Storage is scoped by a user-provided cache key (typically a session identifier,
@@ -1906,14 +1615,11 @@ Load_Coordinator._registry = new Map();
1906
1615
  * silently. Neither ever throws.
1907
1616
  * ---------------------------------------------------------------------------
1908
1617
  */
1909
- // ============================================================================
1910
- // Class Registry for Serialization
1911
- // ============================================================================
1912
- // Registry mapping class names to constructors
1618
+ // Registry mapping class names to constructors and their restore hooks
1913
1619
  const class_registry = Object.create(null);
1914
1620
  // Markers for serialized class instances (unique to avoid collisions with user data)
1915
1621
  // Replaced at build time by rollup (see rollup.config.js).
1916
- const CORE_VERSION = '2.3.68';
1622
+ const CORE_VERSION = '2.3.71';
1917
1623
  const CLASS_MARKER = '__jqhtml_class__';
1918
1624
  const PROPS_MARKER = '__jqhtml_props__';
1919
1625
  /**
@@ -1921,13 +1627,56 @@ const PROPS_MARKER = '__jqhtml_props__';
1921
1627
  * Must be called before attempting to cache instances of this class.
1922
1628
  *
1923
1629
  * @param klass - The class constructor to register
1924
- * @throws Error if klass is not a named function/class
1630
+ * @param options - How to convert instances to and from their plain form; see
1631
+ * Cache_Class_Options. Omit for a class whose state is its own
1632
+ * enumerable properties.
1633
+ * @throws Error if klass is not a named function/class, or if to_plain is given
1634
+ * without the from_plain that undoes it
1925
1635
  */
1926
- function register_cache_class(klass) {
1636
+ function register_cache_class(klass, options = {}) {
1927
1637
  if (typeof klass !== 'function' || !klass.name) {
1928
1638
  throw new Error('[JQHTML Cache] register_cache_class requires a named class constructor');
1929
1639
  }
1930
- class_registry[klass.name] = klass;
1640
+ const to_plain = typeof options.to_plain === 'function' ? options.to_plain : null;
1641
+ const from_plain = typeof options.from_plain === 'function' ? options.from_plain : null;
1642
+ if (to_plain && !from_plain) {
1643
+ throw new Error(`[JQHTML Cache] register_cache_class(${klass.name}): to_plain needs a from_plain. ` +
1644
+ `The serializer cannot rebuild an instance from a custom wire form on its own.`);
1645
+ }
1646
+ class_registry[klass.name] = { klass, to_plain, from_plain };
1647
+ }
1648
+ // Built-ins travel through the same hook as user classes. Their wire forms are
1649
+ // the ones the cache has always stored (ISO string, entry pairs, item list), so
1650
+ // entries written before this existed still restore.
1651
+ register_cache_class(Date, {
1652
+ to_plain: (date) => date.toISOString(),
1653
+ from_plain: (iso) => new Date(iso)
1654
+ });
1655
+ register_cache_class(Map, {
1656
+ to_plain: (map) => Array.from(map.entries()),
1657
+ from_plain: (entries) => new Map(entries)
1658
+ });
1659
+ register_cache_class(Set, {
1660
+ to_plain: (set) => Array.from(set),
1661
+ from_plain: (items) => new Set(items)
1662
+ });
1663
+ /**
1664
+ * Find the registry entry for a live value: by constructor name first, then by
1665
+ * instanceof for the built-ins so a subclass of Date/Map/Set (whose name is not
1666
+ * registered) still serializes as the built-in it is.
1667
+ */
1668
+ function lookup_registered(value) {
1669
+ const ctor = value.constructor;
1670
+ if (ctor && ctor.name && class_registry[ctor.name]) {
1671
+ return { name: ctor.name, entry: class_registry[ctor.name] };
1672
+ }
1673
+ if (value instanceof Date)
1674
+ return { name: 'Date', entry: class_registry['Date'] };
1675
+ if (value instanceof Map)
1676
+ return { name: 'Map', entry: class_registry['Map'] };
1677
+ if (value instanceof Set)
1678
+ return { name: 'Set', entry: class_registry['Set'] };
1679
+ return null;
1931
1680
  }
1932
1681
  /**
1933
1682
  * Serialize a value to JSON string, handling ES6 class instances recursively.
@@ -2054,48 +1803,20 @@ function process_for_serialization(value, verbose, seen, path = '', collector) {
2054
1803
  }
2055
1804
  return result;
2056
1805
  }
2057
- // Handle Date objects specially
2058
- if (value instanceof Date) {
2059
- return {
2060
- [CLASS_MARKER]: 'Date',
2061
- [PROPS_MARKER]: value.toISOString()
2062
- };
2063
- }
2064
- // Handle Map objects
2065
- if (value instanceof Map) {
2066
- const entries = [];
2067
- for (const [k, v] of value) {
2068
- const entry_path = collector ? `${path}.get(${String(k)})` : '';
2069
- const processedKey = process_for_serialization(k, verbose, seen, entry_path, collector);
2070
- const processedValue = process_for_serialization(v, verbose, seen, entry_path, collector);
2071
- entries.push([processedKey, processedValue]);
2072
- }
2073
- return {
2074
- [CLASS_MARKER]: 'Map',
2075
- [PROPS_MARKER]: entries
2076
- };
2077
- }
2078
- // Handle Set objects
2079
- if (value instanceof Set) {
2080
- const items = [];
2081
- let index = 0;
2082
- for (const item of value) {
2083
- items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
1806
+ // Registered classes - Date, Map and Set included - go out as
1807
+ // {__jqhtml_class__, __jqhtml_props__} through their restore hook.
1808
+ const registered = lookup_registered(value);
1809
+ if (registered) {
1810
+ const plain = registered_plain_form(registered, value, verbose, seen, path, collector);
1811
+ if (plain === undefined) {
1812
+ return undefined; // to_plain threw; the value is dropped, and reported
2084
1813
  }
2085
1814
  return {
2086
- [CLASS_MARKER]: 'Set',
2087
- [PROPS_MARKER]: items
1815
+ [CLASS_MARKER]: registered.name,
1816
+ [PROPS_MARKER]: plain
2088
1817
  };
2089
1818
  }
2090
- // Get the constructor
2091
1819
  const ctor = value.constructor;
2092
- // Handle registered class instances
2093
- if (ctor && ctor.name && class_registry[ctor.name]) {
2094
- return {
2095
- [CLASS_MARKER]: ctor.name,
2096
- [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2097
- };
2098
- }
2099
1820
  // Handle plain objects (constructor is Object or no constructor)
2100
1821
  if (ctor === Object || ctor === undefined || ctor === null) {
2101
1822
  return process_own_properties(value, verbose, seen, path, collector);
@@ -2116,6 +1837,48 @@ function process_for_serialization(value, verbose, seen, path = '', collector) {
2116
1837
  seen.delete(value);
2117
1838
  }
2118
1839
  }
1840
+ /**
1841
+ * The plain value stored for a registered instance.
1842
+ *
1843
+ * No from_plain: own enumerable properties (toJSON() is deliberately bypassed,
1844
+ * because Object.assign is the only way back and it needs the properties).
1845
+ * With from_plain: to_plain(instance), or toJSON() when the class defines one,
1846
+ * or own properties as the last resort. Whatever comes back is walked by the
1847
+ * serializer like any other value.
1848
+ *
1849
+ * Returns undefined when to_plain threw - the caller drops the value.
1850
+ */
1851
+ function registered_plain_form(registered, value, verbose, seen, path, collector) {
1852
+ const { name, entry } = registered;
1853
+ if (!entry.from_plain) {
1854
+ return process_own_properties(value, verbose, seen, path, collector);
1855
+ }
1856
+ let plain;
1857
+ try {
1858
+ if (entry.to_plain) {
1859
+ plain = entry.to_plain(value);
1860
+ }
1861
+ else if (typeof value.toJSON === 'function') {
1862
+ plain = value.toJSON();
1863
+ }
1864
+ else {
1865
+ return process_own_properties(value, verbose, seen, path, collector);
1866
+ }
1867
+ }
1868
+ catch (e) {
1869
+ collector?.(path, 'to-plain-threw', name);
1870
+ if (verbose) {
1871
+ console.warn(`[JQHTML Cache] to_plain for class "${name}" threw - value will be omitted:`, e);
1872
+ }
1873
+ return undefined;
1874
+ }
1875
+ // A toJSON() that returns `this` has no separate wire form; own properties it is.
1876
+ if (plain === value) {
1877
+ return process_own_properties(value, verbose, seen, path, collector);
1878
+ }
1879
+ const processed = process_for_serialization(plain, verbose, seen, path, collector);
1880
+ return processed === undefined ? null : processed;
1881
+ }
2119
1882
  /**
2120
1883
  * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
2121
1884
  * object. Shared by plain objects, registered class instances and degraded
@@ -2171,7 +1934,11 @@ function strip_advice(kind, detail) {
2171
1934
  case 'unregistered-class':
2172
1935
  return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2173
1936
  `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2174
- `to get real instances back.`;
1937
+ `to get real instances back - with { from_plain } if the class defines toJSON() or keeps ` +
1938
+ `state outside its own properties.`;
1939
+ case 'to-plain-threw':
1940
+ return `the to_plain hook registered for class "${detail}" threw, so the value was dropped. ` +
1941
+ `Make to_plain total over every instance the class can produce.`;
2175
1942
  }
2176
1943
  }
2177
1944
  /**
@@ -2243,27 +2010,9 @@ function process_for_deserialization(value, verbose) {
2243
2010
  if (value[CLASS_MARKER] !== undefined && value[PROPS_MARKER] !== undefined) {
2244
2011
  const class_name = value[CLASS_MARKER];
2245
2012
  const props = value[PROPS_MARKER];
2246
- // Handle built-in types
2247
- if (class_name === 'Date') {
2248
- return new Date(props);
2249
- }
2250
- if (class_name === 'Map') {
2251
- const map = new Map();
2252
- for (const [k, v] of props) {
2253
- map.set(process_for_deserialization(k, verbose), process_for_deserialization(v, verbose));
2254
- }
2255
- return map;
2256
- }
2257
- if (class_name === 'Set') {
2258
- const set = new Set();
2259
- for (const item of props) {
2260
- set.add(process_for_deserialization(item, verbose));
2261
- }
2262
- return set;
2263
- }
2264
- // Look up registered class
2265
- const klass = class_registry[class_name];
2266
- if (!klass) {
2013
+ // Look up registered class (Date, Map and Set are registered like any other)
2014
+ const entry = class_registry[class_name];
2015
+ if (!entry) {
2267
2016
  if (verbose) {
2268
2017
  console.warn(`[JQHTML Cache] Cannot restore class "${class_name}" - not registered. ` +
2269
2018
  `Data will be returned as plain object. ` +
@@ -2272,10 +2021,14 @@ function process_for_deserialization(value, verbose) {
2272
2021
  // Return as plain object rather than failing completely
2273
2022
  return process_for_deserialization(props, verbose);
2274
2023
  }
2275
- // Create instance without calling constructor and assign properties
2024
+ // Restore through from_plain when the class has one; otherwise create an
2025
+ // instance without calling the constructor and assign the properties back.
2276
2026
  try {
2277
- const instance = Object.create(klass.prototype);
2278
2027
  const processed_props = process_for_deserialization(props, verbose);
2028
+ if (entry.from_plain) {
2029
+ return entry.from_plain(processed_props);
2030
+ }
2031
+ const instance = Object.create(entry.klass.prototype);
2279
2032
  Object.assign(instance, processed_props);
2280
2033
  return instance;
2281
2034
  }
@@ -2433,205 +2186,539 @@ class Jqhtml_Local_Storage {
2433
2186
  * @returns {string}
2434
2187
  * @private
2435
2188
  */
2436
- static _build_key(key) {
2437
- return `jqhtml::${key}::${this._cache_key}`;
2189
+ static _build_key(key) {
2190
+ return `jqhtml::${key}::${this._cache_key}`;
2191
+ }
2192
+ /**
2193
+ * Check if storage is ready for use
2194
+ * @returns {boolean}
2195
+ * @private
2196
+ */
2197
+ static _is_ready() {
2198
+ return this._storage_available === true && this._cache_key !== null && this._initialized;
2199
+ }
2200
+ /**
2201
+ * Set item in localStorage with class-aware serialization.
2202
+ *
2203
+ * If serialization fails (e.g., unregistered class instances, circular refs),
2204
+ * the existing cache entry is removed and nothing is stored.
2205
+ *
2206
+ * @param {string} key - Storage key
2207
+ * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2208
+ */
2209
+ static set(key, value) {
2210
+ if (!this._is_ready()) {
2211
+ return;
2212
+ }
2213
+ const verbose = this._is_verbose();
2214
+ const scoped_key = this._build_key(key);
2215
+ // Serialize with class-aware processing
2216
+ const serialized = serialize_value(value, verbose);
2217
+ if (serialized === null) {
2218
+ // Serialization failed - remove any existing cache entry
2219
+ if (verbose) {
2220
+ console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
2221
+ `Removing existing cache entry if present.`);
2222
+ }
2223
+ try {
2224
+ localStorage.removeItem(scoped_key);
2225
+ }
2226
+ catch (e) {
2227
+ // Ignore removal errors
2228
+ }
2229
+ return;
2230
+ }
2231
+ // Check size before attempting to store (1MB limit)
2232
+ const size_bytes = new Blob([serialized]).size;
2233
+ const size_mb = size_bytes / (1024 * 1024);
2234
+ if (size_mb > 1) {
2235
+ if (verbose) {
2236
+ console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
2237
+ }
2238
+ // Remove existing cache entry since we can't update it
2239
+ try {
2240
+ localStorage.removeItem(scoped_key);
2241
+ }
2242
+ catch (e) {
2243
+ // Ignore removal errors
2244
+ }
2245
+ return;
2246
+ }
2247
+ this._set_item(key, serialized);
2248
+ }
2249
+ /**
2250
+ * Get item from localStorage with class-aware deserialization.
2251
+ *
2252
+ * If deserialization fails, returns null (as if no cache exists).
2253
+ *
2254
+ * @param {string} key - Storage key
2255
+ * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2256
+ */
2257
+ static get(key) {
2258
+ if (!this._is_ready()) {
2259
+ return null;
2260
+ }
2261
+ const verbose = this._is_verbose();
2262
+ const scoped_key = this._build_key(key);
2263
+ try {
2264
+ const serialized = localStorage.getItem(scoped_key);
2265
+ if (serialized === null) {
2266
+ return null;
2267
+ }
2268
+ const result = deserialize_value(serialized, verbose);
2269
+ if (result === null) {
2270
+ // Deserialization failed - treat as cache miss
2271
+ if (verbose) {
2272
+ console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
2273
+ `Treating as cache miss.`);
2274
+ }
2275
+ return null;
2276
+ }
2277
+ return result;
2278
+ }
2279
+ catch (e) {
2280
+ if (verbose) {
2281
+ console.warn('[JQHTML Cache] Failed to get item:', e);
2282
+ }
2283
+ return null;
2284
+ }
2285
+ }
2286
+ /**
2287
+ * Remove item from localStorage
2288
+ * @param {string} key - Storage key
2289
+ */
2290
+ static remove(key) {
2291
+ if (!this._is_ready()) {
2292
+ return;
2293
+ }
2294
+ this._remove_item(key);
2295
+ }
2296
+ /**
2297
+ * Serialize a value to its class-aware TEXT form - the same text set() stores.
2298
+ *
2299
+ * This is the copy-by-value primitive the rest of the framework uses whenever
2300
+ * data has to cross from one component to another (load-coordinator.ts hands a
2301
+ * leader's data to its followers through it). Plain JSON.stringify is not a
2302
+ * substitute: it consults toJSON() and produces a wire form that deserialize()
2303
+ * cannot recognise, so a registered class would come back as a plain object.
2304
+ *
2305
+ * @returns The text, or null when the value as a whole cannot be expressed.
2306
+ */
2307
+ static serialize(value) {
2308
+ return serialize_value(value, this._is_verbose());
2309
+ }
2310
+ /**
2311
+ * Rebuild a value from text produced by serialize(), restoring Date, Map, Set
2312
+ * and every register_cache_class() class. Returns null when the text is not
2313
+ * restorable (a registered from_plain threw at the root) - the same signal
2314
+ * get() gives for a corrupt entry.
2315
+ */
2316
+ static deserialize(text) {
2317
+ return deserialize_value(text, this._is_verbose());
2318
+ }
2319
+ /**
2320
+ * Perform a serialize/deserialize round-trip on a value.
2321
+ *
2322
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2323
+ * is aliased through, and a value the serializer cannot express is dropped
2324
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2325
+ * came from on_load() or from a cache hit - the old "on failure return the
2326
+ * original" branch broke exactly that promise, and only on the hard cases.
2327
+ *
2328
+ * @param {any} value - The value to normalize
2329
+ * @param {string} [component_name] - Names the component in development warnings.
2330
+ * Omit to convert silently (the unit tests and non-component callers).
2331
+ * @returns {any} The round-tripped value. Never the original object.
2332
+ */
2333
+ static normalize_for_cache(value, component_name) {
2334
+ const verbose = this._is_verbose();
2335
+ const collector = make_strip_collector(component_name);
2336
+ // Serialize to JSON string
2337
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2338
+ if (serialized === null) {
2339
+ // The value as a WHOLE could not be expressed (it was itself a function,
2340
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2341
+ // returning the original would put an uncacheable object into this.data.
2342
+ return {};
2343
+ }
2344
+ // Deserialize back to object
2345
+ const deserialized = deserialize_value(serialized, verbose);
2346
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2347
+ // that legitimately serialized to the text `null` also comes back as null. The
2348
+ // serialized text disambiguates.
2349
+ if (deserialized === null && serialized !== 'null') {
2350
+ // Only reachable when a registered class constructor blew up on restore.
2351
+ return {};
2352
+ }
2353
+ return deserialized;
2354
+ }
2355
+ /**
2356
+ * Internal set implementation with scope validation and quota handling
2357
+ * @param {string} key
2358
+ * @param {string} serialized - Pre-serialized JSON string
2359
+ * @private
2360
+ */
2361
+ static _set_item(key, serialized) {
2362
+ // Validate scope before every write
2363
+ this._validate_scope();
2364
+ const scoped_key = this._build_key(key);
2365
+ try {
2366
+ localStorage.setItem(scoped_key, serialized);
2367
+ }
2368
+ catch (e) {
2369
+ // Check if quota exceeded
2370
+ if (e.name === 'QuotaExceededError' || e.code === 22) {
2371
+ console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2372
+ // Clear only JQHTML keys and retry once
2373
+ this._clear_jqhtml_keys();
2374
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2375
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2376
+ // made the very next write look like a scope change and wipe the cache again.
2377
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2378
+ try {
2379
+ localStorage.setItem(scoped_key, serialized);
2380
+ }
2381
+ catch (retry_error) {
2382
+ console.error('[JQHTML Local Storage] Failed to set item after clearing JQHTML keys:', retry_error);
2383
+ }
2384
+ }
2385
+ else {
2386
+ console.error('[JQHTML Local Storage] Failed to set item:', e);
2387
+ }
2388
+ }
2438
2389
  }
2439
2390
  /**
2440
- * Check if storage is ready for use
2441
- * @returns {boolean}
2391
+ * Internal remove implementation
2392
+ * @param {string} key
2442
2393
  * @private
2443
2394
  */
2444
- static _is_ready() {
2445
- return this._storage_available === true && this._cache_key !== null && this._initialized;
2395
+ static _remove_item(key) {
2396
+ const scoped_key = this._build_key(key);
2397
+ try {
2398
+ localStorage.removeItem(scoped_key);
2399
+ }
2400
+ catch (e) {
2401
+ console.error('[JQHTML Local Storage] Failed to remove item:', e);
2402
+ }
2446
2403
  }
2404
+ }
2405
+ Jqhtml_Local_Storage._cache_key = null;
2406
+ /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2407
+ Jqhtml_Local_Storage._scope_marker = null;
2408
+ Jqhtml_Local_Storage._cache_mode = 'data';
2409
+ Jqhtml_Local_Storage._storage_available = null;
2410
+ Jqhtml_Local_Storage._initialized = false;
2411
+
2412
+ /**
2413
+ * Load Coordinator - Request deduplication for component on_load() calls
2414
+ *
2415
+ * Coordinates parallel component loading to prevent duplicate requests.
2416
+ * When multiple components with identical names and args load simultaneously,
2417
+ * only the first (leader) executes on_load(). Others (followers) wait for
2418
+ * the leader's result.
2419
+ *
2420
+ * Key Concepts:
2421
+ * - **INVOCATION_KEY**: Unique identifier for component name + args combination
2422
+ * - **Leader**: First component to reach on_load() for a given INVOCATION_KEY
2423
+ * - **Follower**: Subsequent components that wait for leader's result
2424
+ *
2425
+ * Lifecycle:
2426
+ * 1. Leader reaches on_load() → create coordination entry
2427
+ * 2. Followers reach on_load() → join waiting queue
2428
+ * 3. Leader completes → distribute data to all followers
2429
+ * 4. Clear coordination entry (no caching)
2430
+ *
2431
+ * The hand-off is a copy, and it is made by the cache serializer
2432
+ * (Jqhtml_Local_Storage.serialize()/deserialize()), never by JSON.stringify.
2433
+ * A follower never runs on_load() and never revalidates, so whatever it is
2434
+ * handed is final: the copy has to rebuild Date, Map, Set and every
2435
+ * register_cache_class() instance exactly as the leader's this.data holds them.
2436
+ * JSON.stringify would consult toJSON() and hand out a wire form that
2437
+ * normalize_for_cache() cannot recognise - the follower would then render
2438
+ * against plain objects while the leader renders against instances.
2439
+ *
2440
+ * @internal This class is not exposed in the public API
2441
+ */
2442
+ class Load_Coordinator {
2447
2443
  /**
2448
- * Set item in localStorage with class-aware serialization.
2444
+ * Generate INVOCATION_KEY from component name and args
2445
+ * Uses deterministic JSON serialization (sorted keys)
2446
+ * Excludes internal properties (those starting with _)
2449
2447
  *
2450
- * If serialization fails (e.g., unregistered class instances, circular refs),
2451
- * the existing cache entry is removed and nothing is stored.
2448
+ * For functions/objects:
2449
+ * - Checks for ._jqhtml_cache_id property (assigned by RSpade)
2450
+ * - Falls back to .jqhtml_cache_id() method if property doesn't exist
2451
+ * - If neither exists, marks property as uncacheable
2452
2452
  *
2453
- * @param {string} key - Storage key
2454
- * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2453
+ * Returns object with:
2454
+ * - key: Cache key string, or null if uncacheable
2455
+ * - uncacheable_property: Name of first property that prevented caching (for debugging)
2455
2456
  */
2456
- static set(key, value) {
2457
- if (!this._is_ready()) {
2458
- return;
2459
- }
2460
- const verbose = this._is_verbose();
2461
- const scoped_key = this._build_key(key);
2462
- // Serialize with class-aware processing
2463
- const serialized = serialize_value(value, verbose);
2464
- if (serialized === null) {
2465
- // Serialization failed - remove any existing cache entry
2466
- if (verbose) {
2467
- console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
2468
- `Removing existing cache entry if present.`);
2469
- }
2470
- try {
2471
- localStorage.removeItem(scoped_key);
2457
+ static generate_invocation_key(component_name, args, options = {}) {
2458
+ let uncacheable_property;
2459
+ let uncacheable_reason;
2460
+ // Filter out internal properties (starting with _) and serialize args
2461
+ const serializable_args = {};
2462
+ for (const key of Object.keys(args).sort()) {
2463
+ if (key.startsWith('_')) {
2464
+ continue; // Skip internal properties
2472
2465
  }
2473
- catch (e) {
2474
- // Ignore removal errors
2466
+ // Skip framework properties that shouldn't affect cache identity
2467
+ // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
2468
+ if (key === 'use_cached_data') {
2469
+ continue;
2475
2470
  }
2476
- return;
2477
- }
2478
- // Check size before attempting to store (1MB limit)
2479
- const size_bytes = new Blob([serialized]).size;
2480
- const size_mb = size_bytes / (1024 * 1024);
2481
- if (size_mb > 1) {
2482
- if (verbose) {
2483
- console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
2471
+ const value = args[key];
2472
+ const value_type = typeof value;
2473
+ // Handle primitives (string, number, boolean, null, undefined)
2474
+ if (value === null || value === undefined ||
2475
+ value_type === 'string' || value_type === 'number' ||
2476
+ value_type === 'boolean') {
2477
+ serializable_args[key] = value;
2478
+ continue;
2484
2479
  }
2485
- // Remove existing cache entry since we can't update it
2486
- try {
2487
- localStorage.removeItem(scoped_key);
2480
+ // Handle functions and objects
2481
+ if (value_type === 'function' || value_type === 'object') {
2482
+ // Check for ._jqhtml_cache_id property (fastest, what RSpade assigns)
2483
+ if (value._jqhtml_cache_id !== undefined) {
2484
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(value._jqhtml_cache_id)}`;
2485
+ continue;
2486
+ }
2487
+ // Check for .jqhtml_cache_id() method (more flexible, for custom objects)
2488
+ if (typeof value.jqhtml_cache_id === 'function') {
2489
+ try {
2490
+ const cache_id = value.jqhtml_cache_id();
2491
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(cache_id)}`;
2492
+ continue;
2493
+ }
2494
+ catch (error) {
2495
+ // Method threw error - treat as uncacheable
2496
+ if (!uncacheable_property) {
2497
+ uncacheable_property = key;
2498
+ uncacheable_reason = 'cache-id-threw';
2499
+ }
2500
+ return { key: null, uncacheable_property, uncacheable_reason };
2501
+ }
2502
+ }
2503
+ // No author-supplied id. The cache may fall back to keying by
2504
+ // deterministic content; deduplication deliberately may not.
2505
+ if (options.allow_content_serialization) {
2506
+ const serialized = serialize_for_cache_key(value);
2507
+ if (serialized.ok) {
2508
+ serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
2509
+ continue;
2510
+ }
2511
+ if (!uncacheable_property) {
2512
+ uncacheable_property = key;
2513
+ // This package compiles with strictNullChecks off, and without it
2514
+ // TypeScript will not narrow a discriminated union by its boolean
2515
+ // discriminant - so `serialized` is still the full Serialize_Result
2516
+ // here even though `ok` has been ruled out above. The cast states
2517
+ // what the control flow already guarantees.
2518
+ uncacheable_reason = serialized.reason;
2519
+ }
2520
+ return { key: null, uncacheable_property, uncacheable_reason };
2521
+ }
2522
+ if (!uncacheable_property) {
2523
+ uncacheable_property = key;
2524
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
2525
+ }
2526
+ return { key: null, uncacheable_property, uncacheable_reason };
2488
2527
  }
2489
- catch (e) {
2490
- // Ignore removal errors
2528
+ // Unknown type (symbol, bigint, etc.) - uncacheable
2529
+ if (!uncacheable_property) {
2530
+ uncacheable_property = key;
2531
+ uncacheable_reason = value_type;
2491
2532
  }
2492
- return;
2533
+ return { key: null, uncacheable_property, uncacheable_reason };
2493
2534
  }
2494
- this._set_item(key, serialized);
2535
+ // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
2536
+ try {
2537
+ const sorted_args = JSON.stringify(serializable_args);
2538
+ return { key: `${component_name}::${sorted_args}` };
2539
+ }
2540
+ catch (error) {
2541
+ // Serialization failed unexpectedly
2542
+ return { key: null, uncacheable_property };
2543
+ }
2544
+ }
2545
+ /**
2546
+ * Check if a component should execute on_load() or wait for existing request
2547
+ * Returns true if component should execute (is leader), false if should wait (is follower)
2548
+ *
2549
+ * @param key - The dedup key the caller captured for this load. Every coordinator
2550
+ * method takes it, because args may legally change during on_load()
2551
+ * and a recomputed key would address a different (or no) entry.
2552
+ */
2553
+ static should_execute_on_load(component, key) {
2554
+ const entry = this._registry.get(key);
2555
+ if (!entry) {
2556
+ // No existing request - this component becomes the leader
2557
+ return true;
2558
+ }
2559
+ if (entry.status === 'loading') {
2560
+ // Request in progress - this component becomes a follower
2561
+ entry.waiting.push(component);
2562
+ return false;
2563
+ }
2564
+ // Entry exists but completed/failed - should have been cleaned up
2565
+ // Treat as new leader
2566
+ return true;
2567
+ }
2568
+ /**
2569
+ * Register a leader component that will execute on_load()
2570
+ * Creates coordination entry and returns a function to call when data is ready
2571
+ *
2572
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
2573
+ * @returns A function that accepts the final data and completes coordination
2574
+ */
2575
+ static register_leader(component, key) {
2576
+ // Create a promise that we control - it settles when coordination completes or fails
2577
+ let resolve_promise;
2578
+ let reject_promise;
2579
+ const coordination_promise = new Promise((resolve, reject) => {
2580
+ resolve_promise = resolve;
2581
+ reject_promise = reject;
2582
+ });
2583
+ // A leader with no followers still has to be able to reject this promise on
2584
+ // error; without a handler that rejection surfaces as an unhandled rejection.
2585
+ coordination_promise.catch(() => { });
2586
+ const entry = {
2587
+ status: 'loading',
2588
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
2589
+ resolve_promise,
2590
+ reject_promise,
2591
+ leader_component: component,
2592
+ leader_data: null,
2593
+ waiting: []
2594
+ };
2595
+ this._registry.set(key, entry);
2596
+ // Return cleanup function that accepts the final data
2597
+ return (final_data) => this._complete_coordination(key, component, final_data);
2495
2598
  }
2496
2599
  /**
2497
- * Get item from localStorage with class-aware deserialization.
2498
- *
2499
- * If deserialization fails, returns null (as if no cache exists).
2500
- *
2501
- * @param {string} key - Storage key
2502
- * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2600
+ * Get the coordination promise for a follower component
2601
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
2503
2602
  */
2504
- static get(key) {
2505
- if (!this._is_ready()) {
2506
- return null;
2507
- }
2508
- const verbose = this._is_verbose();
2509
- const scoped_key = this._build_key(key);
2510
- try {
2511
- const serialized = localStorage.getItem(scoped_key);
2512
- if (serialized === null) {
2513
- return null;
2514
- }
2515
- const result = deserialize_value(serialized, verbose);
2516
- if (result === null) {
2517
- // Deserialization failed - treat as cache miss
2518
- if (verbose) {
2519
- console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
2520
- `Treating as cache miss.`);
2521
- }
2522
- return null;
2523
- }
2524
- return result;
2525
- }
2526
- catch (e) {
2527
- if (verbose) {
2528
- console.warn('[JQHTML Cache] Failed to get item:', e);
2529
- }
2603
+ static get_coordination_promise(key) {
2604
+ const entry = this._registry.get(key);
2605
+ if (!entry || entry.status !== 'loading') {
2530
2606
  return null;
2531
2607
  }
2608
+ return entry.promise;
2532
2609
  }
2533
2610
  /**
2534
- * Remove item from localStorage
2535
- * @param {string} key - Storage key
2611
+ * Complete coordination after leader's on_load() finishes
2612
+ * Stores leader's data, marks entry as completed, and resolves the promise.
2613
+ * Followers retrieve the data themselves via get_leader_data().
2614
+ *
2615
+ * @param key - The coordination key captured at registration
2616
+ * @param leader - The leader component
2617
+ * @param final_data - The final data after on_load and normalization
2618
+ * @private
2536
2619
  */
2537
- static remove(key) {
2538
- if (!this._is_ready()) {
2620
+ static _complete_coordination(key, leader, final_data) {
2621
+ const entry = this._registry.get(key);
2622
+ if (!entry) {
2539
2623
  return;
2540
2624
  }
2541
- this._remove_item(key);
2625
+ // Stored as TEXT so each follower rebuilds its own object graph - sharing one
2626
+ // decoded object would let one component's mutation reach every other.
2627
+ // The cache serializer, not JSON.stringify: this.data holds live Date/Map/Set
2628
+ // and register_cache_class() instances, and only the serializer that made
2629
+ // them knows how to carry them across. null means the data as a whole could
2630
+ // not be expressed; followers then load independently.
2631
+ entry.leader_data = Jqhtml_Local_Storage.serialize(final_data);
2632
+ entry.status = 'completed';
2633
+ if (window.jqhtml?.debug?.verbose) {
2634
+ console.log(`[Load Coordinator] Leader ${leader._cid} completed, data available for ${entry.waiting.length} followers`, { key, data: entry.leader_data });
2635
+ }
2636
+ // Resolve the promise - followers waiting on it will now wake up
2637
+ entry.resolve_promise();
2638
+ // Nobody is waiting, so nothing will ever come back to collect the data.
2639
+ // Without this the registry grows once per distinct name+args, forever.
2640
+ if (entry.waiting.length === 0) {
2641
+ this._registry.delete(key);
2642
+ }
2542
2643
  }
2543
2644
  /**
2544
- * Perform a serialize/deserialize round-trip on a value.
2545
- *
2546
- * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2547
- * is aliased through, and a value the serializer cannot express is dropped
2548
- * rather than smuggled out. That is what makes `this.data` identical whether it
2549
- * came from on_load() or from a cache hit - the old "on failure return the
2550
- * original" branch broke exactly that promise, and only on the hard cases.
2645
+ * Get leader's data for a follower component
2646
+ * Called by follower after coordination promise resolves
2647
+ * Returns a freshly parsed copy and cleans up if this is the last follower
2551
2648
  *
2552
- * @param {any} value - The value to normalize
2553
- * @param {string} [component_name] - Names the component in development warnings.
2554
- * Omit to convert silently (the unit tests and non-component callers).
2555
- * @returns {any} The round-tripped value. Never the original object.
2649
+ * @param key - The dedup key captured by the follower before it began waiting
2556
2650
  */
2557
- static normalize_for_cache(value, component_name) {
2558
- const verbose = this._is_verbose();
2559
- const collector = make_strip_collector(component_name);
2560
- // Serialize to JSON string
2561
- const serialized = serialize_value(value, verbose, 'this.data', collector);
2562
- if (serialized === null) {
2563
- // The value as a WHOLE could not be expressed (it was itself a function,
2564
- // a promise, a DOM node...). The round trip of such a value is nothing;
2565
- // returning the original would put an uncacheable object into this.data.
2566
- return {};
2651
+ static get_leader_data(component, key) {
2652
+ const entry = this._registry.get(key);
2653
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
2654
+ return null;
2567
2655
  }
2568
- // Deserialize back to object
2569
- const deserialized = deserialize_value(serialized, verbose);
2570
- // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2571
- // that legitimately serialized to the text `null` also comes back as null. The
2572
- // serialized text disambiguates.
2573
- if (deserialized === null && serialized !== 'null') {
2574
- // Only reachable when a registered class constructor blew up on restore.
2575
- return {};
2656
+ // Remove this follower from the waiting list
2657
+ const follower_index = entry.waiting.indexOf(component);
2658
+ if (follower_index !== -1) {
2659
+ entry.waiting.splice(follower_index, 1);
2576
2660
  }
2577
- return deserialized;
2661
+ // One deserialize per follower: each gets its OWN object graph, never a
2662
+ // shared reference, with the same instances the leader's this.data holds.
2663
+ const data = Jqhtml_Local_Storage.deserialize(entry.leader_data);
2664
+ if (window.jqhtml?.debug?.verbose) {
2665
+ console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
2666
+ }
2667
+ // If this was the last follower, clean up the entry
2668
+ if (entry.waiting.length === 0) {
2669
+ this._registry.delete(key);
2670
+ if (window.jqhtml?.debug?.verbose) {
2671
+ console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
2672
+ }
2673
+ }
2674
+ return data;
2578
2675
  }
2579
2676
  /**
2580
- * Internal set implementation with scope validation and quota handling
2581
- * @param {string} key
2582
- * @param {string} serialized - Pre-serialized JSON string
2583
- * @private
2677
+ * Handle leader on_load() error
2678
+ * Rejects every waiting follower and clears the entry so the next component retries
2679
+ *
2680
+ * @param key - The dedup key captured at registration
2584
2681
  */
2585
- static _set_item(key, serialized) {
2586
- // Validate scope before every write
2587
- this._validate_scope();
2588
- const scoped_key = this._build_key(key);
2589
- try {
2590
- localStorage.setItem(scoped_key, serialized);
2682
+ static handle_leader_error(key, error) {
2683
+ const entry = this._registry.get(key);
2684
+ if (!entry) {
2685
+ return;
2591
2686
  }
2592
- catch (e) {
2593
- // Check if quota exceeded
2594
- if (e.name === 'QuotaExceededError' || e.code === 22) {
2595
- console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2596
- // Clear only JQHTML keys and retry once
2597
- this._clear_jqhtml_keys();
2598
- // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2599
- // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2600
- // made the very next write look like a scope change and wipe the cache again.
2601
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2602
- try {
2603
- localStorage.setItem(scoped_key, serialized);
2604
- }
2605
- catch (retry_error) {
2606
- console.error('[JQHTML Local Storage] Failed to set item after clearing JQHTML keys:', retry_error);
2607
- }
2608
- }
2609
- else {
2610
- console.error('[JQHTML Local Storage] Failed to set item:', e);
2611
- }
2687
+ entry.status = 'failed';
2688
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
2689
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
2690
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
2691
+ // without rejecting left them awaiting a promise nobody could ever settle.
2692
+ entry.reject_promise(error);
2693
+ // Clear coordination entry so future requests can retry
2694
+ this._registry.delete(key);
2695
+ if (window.jqhtml?.debug?.verbose) {
2696
+ console.log(`[Load Coordinator] Cleared failed coordination for key: ${key}`, { followers_count: entry.waiting.length });
2612
2697
  }
2613
2698
  }
2614
2699
  /**
2615
- * Internal remove implementation
2616
- * @param {string} key
2617
- * @private
2700
+ * Get current registry state (for debugging)
2618
2701
  */
2619
- static _remove_item(key) {
2620
- const scoped_key = this._build_key(key);
2621
- try {
2622
- localStorage.removeItem(scoped_key);
2623
- }
2624
- catch (e) {
2625
- console.error('[JQHTML Local Storage] Failed to remove item:', e);
2702
+ static get_registry_state() {
2703
+ const state = {};
2704
+ for (const [key, entry] of this._registry.entries()) {
2705
+ state[key] = {
2706
+ status: entry.status,
2707
+ leader_cid: entry.leader_component._cid,
2708
+ waiting_count: entry.waiting.length,
2709
+ waiting_cids: entry.waiting.map(c => c._cid)
2710
+ };
2626
2711
  }
2712
+ return state;
2713
+ }
2714
+ /**
2715
+ * Clear all coordination entries (for testing/debugging)
2716
+ */
2717
+ static clear_all() {
2718
+ this._registry.clear();
2627
2719
  }
2628
2720
  }
2629
- Jqhtml_Local_Storage._cache_key = null;
2630
- /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2631
- Jqhtml_Local_Storage._scope_marker = null;
2632
- Jqhtml_Local_Storage._cache_mode = 'data';
2633
- Jqhtml_Local_Storage._storage_available = null;
2634
- Jqhtml_Local_Storage._initialized = false;
2721
+ Load_Coordinator._registry = new Map();
2635
2722
 
2636
2723
  /**
2637
2724
  * JQHTML Component Event System
@@ -3942,6 +4029,7 @@ function resolve_parent_template(template_def, ctor) {
3942
4029
  class Jqhtml_Component {
3943
4030
  constructor(element, args = {}) {
3944
4031
  this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
4032
+ this._debug_no_data = false; // Set by _reload_without_data(); cleared by any real load
3945
4033
  this._instantiator = null; // Component that instantiated this one
3946
4034
  this._dom_parent = null; // Closest component in DOM tree (for lifecycle)
3947
4035
  this._dom_children = new Set(); // Components in DOM subtree (for lifecycle)
@@ -4554,6 +4642,8 @@ class Jqhtml_Component {
4554
4642
  // The queued executor can start long after load() was called - re-check.
4555
4643
  if (this._stopped)
4556
4644
  return false;
4645
+ // Any real load returns the component to normal, whatever the debug tool did.
4646
+ this._debug_no_data = false;
4557
4647
  // Snapshot current data for change detection
4558
4648
  const data_before = JSON.stringify(this.data);
4559
4649
  // Execute on_load() on detached proxy (restores data to on_create snapshot)
@@ -4743,6 +4833,8 @@ class Jqhtml_Component {
4743
4833
  async _load() {
4744
4834
  if (this._stopped || this._ready_state >= 2)
4745
4835
  return;
4836
+ // Any real load returns the component to normal, whatever the debug tool did.
4837
+ this._debug_no_data = false;
4746
4838
  this._log_lifecycle('load', 'start');
4747
4839
  // use_cached_data feature: If cache hit occurred and use_cached_data=true, skip on_load() entirely
4748
4840
  // The cached data is already in this.data from create() phase
@@ -5175,6 +5267,51 @@ class Jqhtml_Component {
5175
5267
  async refresh() {
5176
5268
  return this.reload(false);
5177
5269
  }
5270
+ /**
5271
+ * Re-render this component from its on_create() state with its cache dropped and
5272
+ * WITHOUT running on_load()/on_loaded()/on_ready().
5273
+ *
5274
+ * @internal debug tool. Used by the debug overlay's lifecycle footer to see what a
5275
+ * component looks like with no loaded data at all. Not public API, not documented,
5276
+ * and no downstream code should call it: reload() or refresh() afterwards returns
5277
+ * the component to normal (the snapshot is intact and the cache is gone, so the
5278
+ * next load is a genuine fresh fetch).
5279
+ */
5280
+ _reload_without_data() {
5281
+ if (this._stopped)
5282
+ return Promise.resolve();
5283
+ // Same marker invalidation reload()/render() do: later ready() waiters must wait
5284
+ // for THIS cycle rather than resolve off the previous one.
5285
+ this.invalidate('ready');
5286
+ // Serialised with reload()/refresh() so it can never interleave with a real load.
5287
+ return this._queue.enqueue('reload', async () => {
5288
+ if (this._stopped)
5289
+ return;
5290
+ // Drop this component's own cache entries (data and html snapshot), so a later
5291
+ // reload() genuinely re-fetches instead of hydrating from what is on screen now.
5292
+ const { cache_key } = generate_cache_key(this);
5293
+ if (cache_key !== null) {
5294
+ Jqhtml_Local_Storage.remove(cache_key);
5295
+ Jqhtml_Local_Storage.remove(`${cache_key}::html`);
5296
+ }
5297
+ // Back to the on_create() state. Components with no custom on_load() take no
5298
+ // snapshot (nothing can have changed this.data), so they simply re-render.
5299
+ if (this.__initial_data_snapshot !== null) {
5300
+ this.__data_frozen = false;
5301
+ this.data = JSON.parse(JSON.stringify(this.__initial_data_snapshot));
5302
+ this.__data_frozen = true;
5303
+ }
5304
+ // Read by the debug overlay's lifecycle row; cleared by _reload()/load()/_load().
5305
+ this._debug_no_data = true;
5306
+ this._render();
5307
+ await this._wait_for_children_ready();
5308
+ if (this._stopped)
5309
+ return;
5310
+ this._ready_state = 4;
5311
+ this._update_debug_attrs();
5312
+ this.trigger('ready');
5313
+ });
5314
+ }
5178
5315
  /**
5179
5316
  * Internal reload implementation - re-fetch data and re-render
5180
5317
  *
@@ -5212,6 +5349,8 @@ class Jqhtml_Component {
5212
5349
  async _reload() {
5213
5350
  if (this._stopped)
5214
5351
  return;
5352
+ // Any real load returns the component to normal, whatever the debug tool did.
5353
+ this._debug_no_data = false;
5215
5354
  // Invalidate ready event so new handlers wait for this reload to complete
5216
5355
  // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
5217
5356
  this.invalidate('ready');
@@ -5950,9 +6089,9 @@ Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() det
5950
6089
  // A bare component name, as accepted by closest() in place of a class selector
5951
6090
  Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
5952
6091
 
5953
- var light_css = "html[data-jqhtml-debug] .jqhtml-debug-hit {\n outline: 2px solid #e5322d;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-0 {\n outline-color: #e5322d;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-1 {\n outline-color: #2f6fe4;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-2 {\n outline-color: #1f9d55;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-3 {\n outline-color: #d97706;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-4 {\n outline-color: #7c3aed;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-5 {\n outline-color: #0e7490;\n}";
6092
+ var light_css = "html[data-jqhtml-debug] .jqhtml-debug-selected {\n outline: 2px solid #b45309;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit {\n outline: 2px solid #e5322d;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-0 {\n outline-color: #e5322d;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-1 {\n outline-color: #2f6fe4;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-2 {\n outline-color: #1f9d55;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-3 {\n outline-color: #d97706;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-4 {\n outline-color: #7c3aed;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-5 {\n outline-color: #0e7490;\n}";
5954
6093
 
5955
- var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed;\n box-sizing: border-box;\n top: 0;\n left: 0;\n width: 100vw;\n height: 100vh;\n z-index: 2147483647;\n pointer-events: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.4;\n color: #1c1c1c;\n text-align: left;\n text-transform: none;\n text-indent: 0;\n text-shadow: none;\n letter-spacing: normal;\n word-spacing: normal;\n white-space: normal;\n word-break: normal;\n overflow-wrap: anywhere;\n hyphens: none;\n tab-size: 4;\n direction: ltr;\n visibility: visible;\n cursor: default;\n list-style: none;\n quotes: none;\n}\n\n.jqhtml-debug-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label {\n position: absolute;\n box-sizing: border-box;\n display: block;\n padding: 1px 5px;\n min-height: 18px;\n color: #ffffff;\n background: #e5322d;\n border-radius: 0 0 3px 0;\n font-size: 11px;\n line-height: 16px;\n white-space: normal;\n overflow-wrap: anywhere;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-0 {\n background: #e5322d;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-1 {\n background: #2f6fe4;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-2 {\n background: #1f9d55;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-3 {\n background: #d97706;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-4 {\n background: #7c3aed;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-5 {\n background: #0e7490;\n}\n\n.jqhtml-debug-label-name {\n font-weight: bold;\n}\n\n.jqhtml-debug-label-args {\n opacity: 0.9;\n margin-left: 6px;\n}\n\n.jqhtml-debug-modal {\n display: none;\n position: absolute;\n box-sizing: border-box;\n top: 24px;\n right: 24px;\n width: 520px;\n max-width: calc(100% - 48px);\n max-height: calc(100% - 48px);\n overflow: auto;\n padding: 0 0 12px 0;\n background: #ffffff;\n color: #1c1c1c;\n border: 1px solid #d0d0d0;\n border-radius: 6px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n pointer-events: auto;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-open {\n display: block;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n position: sticky;\n top: 0;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-title-name {\n flex: 1 1 auto;\n font-size: 13px;\n}\n\n.jqhtml-debug-button {\n all: unset;\n display: inline-block;\n box-sizing: border-box;\n padding: 2px 8px;\n border: 1px solid rgba(255, 255, 255, 0.6);\n border-radius: 3px;\n color: #ffffff;\n font-size: 11px;\n line-height: 16px;\n cursor: pointer;\n user-select: none;\n}\n\n.jqhtml-debug-button:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.jqhtml-debug-section {\n padding: 8px 12px 0 12px;\n}\n\n.jqhtml-debug-h {\n margin: 4px 0;\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-table {\n display: grid;\n grid-template-columns: max-content max-content 1fr;\n column-gap: 12px;\n row-gap: 2px;\n align-items: baseline;\n}\n\n.jqhtml-debug-key {\n color: #005cc5;\n white-space: nowrap;\n}\n\n.jqhtml-debug-type {\n color: #666666;\n font-size: 11px;\n white-space: nowrap;\n}\n\n.jqhtml-debug-value {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.jqhtml-debug-empty {\n color: #666666;\n font-style: italic;\n}\n\n.jqhtml-debug-list {\n display: block;\n margin: 0;\n padding: 0;\n}\n\n.jqhtml-debug-link {\n display: block;\n padding: 2px 4px;\n margin: 0 -4px;\n border-radius: 3px;\n cursor: pointer;\n}\n\n.jqhtml-debug-link:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-link-cid {\n color: #666666;\n margin-left: 6px;\n}\n\n.jqhtml-debug-note {\n margin-top: 4px;\n color: #92400e;\n font-size: 11px;\n}";
6094
+ var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed;\n box-sizing: border-box;\n top: 0;\n left: 0;\n width: 100vw;\n height: 100vh;\n z-index: 2147483647;\n pointer-events: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.4;\n color: #1c1c1c;\n text-align: left;\n text-transform: none;\n text-indent: 0;\n text-shadow: none;\n letter-spacing: normal;\n word-spacing: normal;\n white-space: normal;\n word-break: normal;\n overflow-wrap: anywhere;\n hyphens: none;\n tab-size: 4;\n direction: ltr;\n visibility: visible;\n cursor: default;\n list-style: none;\n quotes: none;\n}\n\n.jqhtml-debug-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label {\n position: absolute;\n box-sizing: border-box;\n display: block;\n padding: 1px 5px;\n min-height: 18px;\n color: #ffffff;\n background: #e5322d;\n border-radius: 0 0 3px 0;\n font-size: 11px;\n line-height: 16px;\n white-space: normal;\n overflow-wrap: anywhere;\n overflow: hidden;\n pointer-events: auto;\n cursor: pointer;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-0 {\n background: #e5322d;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-1 {\n background: #2f6fe4;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-2 {\n background: #1f9d55;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-3 {\n background: #d97706;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-4 {\n background: #7c3aed;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-5 {\n background: #0e7490;\n}\n\n.jqhtml-debug-label-name {\n font-weight: bold;\n}\n\n.jqhtml-debug-label-args {\n opacity: 0.9;\n margin-left: 6px;\n}\n\n.jqhtml-debug-modal {\n display: none;\n position: absolute;\n box-sizing: border-box;\n top: 24px;\n right: 24px;\n width: 520px;\n max-width: calc(100% - 48px);\n overflow: hidden;\n padding: 0;\n background: #ffffff;\n color: #1c1c1c;\n border: 1px solid #d0d0d0;\n border-radius: 6px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n pointer-events: auto;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-open {\n display: block;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-left {\n left: 24px;\n right: auto;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-body {\n display: block;\n box-sizing: border-box;\n overflow-y: scroll;\n min-height: 300px;\n max-height: min(600px, 100vh - 200px);\n padding: 0 0 12px 0;\n}\n\n.jqhtml-debug-footer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: #ffffff;\n border-top: 1px solid #d0d0d0;\n}\n\n.jqhtml-debug-footer-label {\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-title-name {\n flex: 1 1 auto;\n font-size: 13px;\n}\n\n.jqhtml-debug-button {\n all: unset;\n display: inline-block;\n box-sizing: border-box;\n padding: 2px 8px;\n border: 1px solid rgba(255, 255, 255, 0.6);\n border-radius: 3px;\n color: #ffffff;\n font-size: 11px;\n line-height: 16px;\n cursor: pointer;\n user-select: none;\n}\n\n.jqhtml-debug-button:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.jqhtml-debug-button.jqhtml-debug-footer-button {\n color: #1c1c1c;\n border-color: #d0d0d0;\n}\n\n.jqhtml-debug-button.jqhtml-debug-footer-button:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-section {\n padding: 8px 12px 0 12px;\n}\n\n.jqhtml-debug-h {\n margin: 4px 0;\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-table {\n display: grid;\n grid-template-columns: max-content max-content 1fr;\n column-gap: 12px;\n row-gap: 2px;\n align-items: baseline;\n}\n\n.jqhtml-debug-key {\n color: #005cc5;\n white-space: nowrap;\n}\n\n.jqhtml-debug-type {\n color: #666666;\n font-size: 11px;\n white-space: nowrap;\n}\n\n.jqhtml-debug-value {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.jqhtml-debug-empty {\n color: #666666;\n font-style: italic;\n}\n\n.jqhtml-debug-list {\n display: block;\n margin: 0;\n padding: 0;\n}\n\n.jqhtml-debug-link {\n display: block;\n padding: 2px 4px;\n margin: 0 -4px;\n border-radius: 3px;\n cursor: pointer;\n}\n\n.jqhtml-debug-link:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-link-cid {\n color: #666666;\n margin-left: 6px;\n}\n\n.jqhtml-debug-note {\n margin-top: 4px;\n color: #92400e;\n font-size: 11px;\n}";
5956
6095
 
5957
6096
  /**
5958
6097
  * Component debug overlay.
@@ -5964,6 +6103,12 @@ var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed
5964
6103
  * data, state, ancestry, instantiator) instead of firing the click. Alt+click
5965
6104
  * passes through. `disable()` puts everything back.
5966
6105
  *
6106
+ * The labels are part of the interaction, not just decoration: the pointer can
6107
+ * move from a component onto one of its tabs without the hover set collapsing,
6108
+ * and clicking a tab inspects that tab's component. The modal keeps an amber
6109
+ * outline on whatever it is showing, and its title bar carries Parent / Back so
6110
+ * the DOM chain can be walked without going back to the page.
6111
+ *
5967
6112
  * Non-invasiveness, the design goal (details in ./CLAUDE.md):
5968
6113
  * - The overlay's own UI lives in a shadow root on a host element appended to
5969
6114
  * <body>, styled by shadow.scss. Page CSS cannot select into it and :host
@@ -5982,8 +6127,11 @@ const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
5982
6127
  const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
5983
6128
  const HIT_CLASS = `${PREFIX}hit`;
5984
6129
  const DEPTH_CLASS = `${PREFIX}depth-`;
6130
+ const SELECTED_CLASS = `${PREFIX}selected`; // on the element the modal is showing
5985
6131
  const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
5986
6132
  const LABEL_HEIGHT = 18;
6133
+ const LABEL_MIN_WIDTH = 400; // a narrow component still gets a readable tab
6134
+ const VIEWPORT_MARGIN = 5; // gap kept between a label's right edge and the viewport
5987
6135
  const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
5988
6136
  const state = {
5989
6137
  enabled: false,
@@ -5993,7 +6141,10 @@ const state = {
5993
6141
  modal: null,
5994
6142
  chain: [],
5995
6143
  hit_elements: [],
6144
+ label_hovered: false,
5996
6145
  modal_component: null,
6146
+ nav_stack: [],
6147
+ selected_element: null,
5997
6148
  };
5998
6149
  // ---------------------------------------------------------------------------
5999
6150
  // Public API
@@ -6050,7 +6201,7 @@ const debug_overlay = {
6050
6201
  : chain_for(target && target.jquery ? target[0] : target)[0];
6051
6202
  if (!component)
6052
6203
  return false;
6053
- open_modal(component);
6204
+ select(component);
6054
6205
  return true;
6055
6206
  },
6056
6207
  };
@@ -6105,9 +6256,18 @@ function is_ours(event) {
6105
6256
  // ---------------------------------------------------------------------------
6106
6257
  // Hover
6107
6258
  // ---------------------------------------------------------------------------
6259
+ /**
6260
+ * A hover on the page replaces the hover set - except while the pointer is on
6261
+ * our own UI. An event from inside the shadow host (a label, the modal) never
6262
+ * clears or changes the hover, and neither does a page hover raised while the
6263
+ * pointer is parked on a label: that is what lets a label be reached and clicked
6264
+ * without the outlines it belongs to vanishing on the way.
6265
+ */
6108
6266
  function on_mouseover(event) {
6109
6267
  if (is_ours(event))
6110
6268
  return;
6269
+ if (state.label_hovered)
6270
+ return;
6111
6271
  const chain = chain_for(event.target);
6112
6272
  if (chain[0] === state.chain[0])
6113
6273
  return;
@@ -6117,6 +6277,32 @@ function on_mouseout(event) {
6117
6277
  if (!event.relatedTarget)
6118
6278
  clear_hover(); // pointer left the document
6119
6279
  }
6280
+ /**
6281
+ * The pointer left a label. If it landed back inside the component the label
6282
+ * belongs to (the usual case - the label sits on the component's own corner),
6283
+ * the hover set stays; anywhere else it goes. `elementFromPoint` reports our
6284
+ * shadow host for a point covered by another label, so the host is skipped and
6285
+ * the page element underneath is used.
6286
+ */
6287
+ function on_label_leave(event) {
6288
+ state.label_hovered = false;
6289
+ const innermost = state.chain[0];
6290
+ if (!innermost)
6291
+ return;
6292
+ const under = element_under(event.clientX, event.clientY);
6293
+ if (!under || !innermost.$[0].contains(under))
6294
+ clear_hover();
6295
+ }
6296
+ function element_under(x, y) {
6297
+ const all = document.elementsFromPoint;
6298
+ const candidates = all
6299
+ ? all.call(document, x, y)
6300
+ : [document.elementFromPoint(x, y)].filter(Boolean);
6301
+ for (const node of candidates)
6302
+ if (node !== state.host)
6303
+ return node;
6304
+ return null;
6305
+ }
6120
6306
  function on_reposition() {
6121
6307
  if (state.chain.length)
6122
6308
  render_labels();
@@ -6139,18 +6325,23 @@ function clear_hover() {
6139
6325
  }
6140
6326
  state.hit_elements = [];
6141
6327
  state.chain = [];
6328
+ state.label_hovered = false;
6142
6329
  if (state.layer)
6143
6330
  state.layer.textContent = '';
6144
6331
  }
6145
6332
  /**
6146
6333
  * One tab per component in the chain, pinned to the component's top-left
6147
- * corner and no wider than the component. Outermost first so the innermost
6148
- * paints on top; tabs that share a corner (a child filling its parent, the
6149
- * usual case) stack downward instead of hiding each other.
6334
+ * corner. Width: the component's, but a narrow component still gets up to
6335
+ * LABEL_MIN_WIDTH so its name and args stay legible; a label that would then
6336
+ * run off the right edge is slid back so its right edge sits at the margin.
6337
+ * Outermost first so the innermost paints on top; tabs that share a corner (a
6338
+ * child filling its parent, the usual case) stack downward instead of hiding
6339
+ * each other.
6150
6340
  */
6151
6341
  function render_labels() {
6152
6342
  const layer = state.layer;
6153
6343
  layer.textContent = '';
6344
+ state.label_hovered = false; // the element the pointer was on is gone
6154
6345
  const taken = new Map();
6155
6346
  for (let depth = state.chain.length - 1; depth >= 0; depth--) {
6156
6347
  const component = state.chain[depth];
@@ -6163,14 +6354,33 @@ function render_labels() {
6163
6354
  const stacked = taken.get(key) || 0;
6164
6355
  taken.set(key, stacked + 1);
6165
6356
  const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
6166
- label.style.left = `${left}px`;
6357
+ // Laid out at the left edge FIRST, then moved. An absolutely positioned box
6358
+ // shrink-to-fits into the room its own `left` leaves, so measuring it at its
6359
+ // final position would report the squeezed width, and moving it would let it
6360
+ // re-expand - the two chase each other. At left 0 it has the whole viewport,
6361
+ // so the measurement below is the width it will actually keep.
6362
+ label.style.left = '0px';
6167
6363
  label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
6168
- label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
6364
+ label.style.maxWidth = `${rect.width < LABEL_MIN_WIDTH ? LABEL_MIN_WIDTH : Math.round(rect.width)}px`;
6169
6365
  label.appendChild(el('span', 'label-name', component.component_name()));
6170
6366
  const args = simple_args(component);
6171
6367
  if (args)
6172
6368
  label.appendChild(el('span', 'label-args', args));
6369
+ // Each label knows its own component: it is a hover target that holds the set
6370
+ // in place, and a click target that inspects THAT component, not the innermost.
6371
+ label.addEventListener('mouseenter', () => { state.label_hovered = true; });
6372
+ label.addEventListener('mouseleave', on_label_leave);
6373
+ label.addEventListener('click', (event) => {
6374
+ event.stopPropagation(); // the document handler already skips is_ours events
6375
+ select(component);
6376
+ });
6173
6377
  layer.appendChild(label);
6378
+ // A label on a narrow component may be wider than the component, so a component
6379
+ // near the right edge can push its label off-screen. Slide it back until its
6380
+ // right edge sits on the margin, never past the left edge.
6381
+ const width = label.getBoundingClientRect().width || label.offsetWidth;
6382
+ const limit = window.innerWidth - VIEWPORT_MARGIN;
6383
+ label.style.left = `${left + width > limit ? Math.max(0, Math.round(limit - width)) : left}px`;
6174
6384
  }
6175
6385
  }
6176
6386
  /** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
@@ -6200,7 +6410,7 @@ function on_click(event) {
6200
6410
  close_modal();
6201
6411
  return;
6202
6412
  }
6203
- open_modal(chain[0]);
6413
+ select(chain[0]);
6204
6414
  }
6205
6415
  function on_keydown(event) {
6206
6416
  if (event.key === 'Escape' && state.modal_component) {
@@ -6214,17 +6424,82 @@ function close_modal() {
6214
6424
  state.modal.classList.remove(`${PREFIX}modal-open`);
6215
6425
  state.modal.textContent = '';
6216
6426
  state.modal_component = null;
6427
+ state.nav_stack = [];
6428
+ set_selected(null);
6429
+ }
6430
+ // ---------------------------------------------------------------------------
6431
+ // Selection and navigation
6432
+ //
6433
+ // Three ways in, and they differ only in what they do to the navigation stack:
6434
+ // select() - a FRESH selection (page click, label click, inspect()): the stack
6435
+ // is dropped, so Back disappears.
6436
+ // navigate() - a step along the chain (Parent, an Ancestry entry, the
6437
+ // Instantiator link): the component being left is pushed.
6438
+ // show() - render only, stack untouched; what Back and the lifecycle
6439
+ // buttons re-open with.
6440
+ // ---------------------------------------------------------------------------
6441
+ function select(component) {
6442
+ state.nav_stack = [];
6443
+ show(component);
6444
+ }
6445
+ function navigate(component) {
6446
+ if (state.modal_component && state.modal_component !== component) {
6447
+ state.nav_stack.push(state.modal_component);
6448
+ }
6449
+ show(component);
6450
+ }
6451
+ /** Pop to the last component still alive; a stopped one is skipped, not shown. */
6452
+ function go_back() {
6453
+ while (state.nav_stack.length) {
6454
+ const previous = state.nav_stack.pop();
6455
+ if (previous.$.hasClass('_Component_Stopped'))
6456
+ continue;
6457
+ show(previous);
6458
+ return;
6459
+ }
6460
+ if (state.modal_component)
6461
+ show(state.modal_component); // nothing left: drop Back
6462
+ }
6463
+ /** Move SELECTED_CLASS to `element`, or take it off the page entirely for null. */
6464
+ function set_selected(element) {
6465
+ if (state.selected_element && state.selected_element !== element) {
6466
+ state.selected_element.classList.remove(SELECTED_CLASS);
6467
+ }
6468
+ state.selected_element = element;
6469
+ if (element)
6470
+ element.classList.add(SELECTED_CLASS);
6217
6471
  }
6218
- function open_modal(component) {
6472
+ /**
6473
+ * The inspector. Three children: a fixed title bar, a scrolling body holding every
6474
+ * section, and a fixed footer of lifecycle buttons. The modal sits top-right, but
6475
+ * moves to the left when the inspected component is itself in the right half of the
6476
+ * viewport - otherwise the panel covers the thing being inspected.
6477
+ */
6478
+ function show(component) {
6219
6479
  const modal = state.modal;
6220
6480
  modal.textContent = '';
6221
6481
  state.modal_component = component;
6222
6482
  const chain = chain_for(component.$[0]);
6223
6483
  const ancestors = chain.slice(1);
6224
6484
  const element = component.$[0];
6225
- // Title bar
6485
+ set_selected(element);
6486
+ modal.classList.toggle(`${PREFIX}modal-left`, element.getBoundingClientRect().left > window.innerWidth / 2);
6487
+ // Title bar: the name, then [Back] [Parent] [Log to console] [Close]. Back is
6488
+ // there only while something was navigated away from, Parent only while the
6489
+ // component has a DOM parent component - the first Ancestry entry.
6226
6490
  const title = el('div', 'title');
6227
6491
  title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
6492
+ if (state.nav_stack.length) {
6493
+ const back = el('button', 'button', 'Back');
6494
+ back.addEventListener('click', go_back);
6495
+ title.appendChild(back);
6496
+ }
6497
+ const parent = ancestors[0];
6498
+ if (parent) {
6499
+ const up = el('button', 'button', 'Parent');
6500
+ up.addEventListener('click', () => navigate(parent));
6501
+ title.appendChild(up);
6502
+ }
6228
6503
  const log = el('button', 'button', 'Log to console');
6229
6504
  log.addEventListener('click', () => {
6230
6505
  console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
@@ -6234,6 +6509,9 @@ function open_modal(component) {
6234
6509
  close.addEventListener('click', close_modal);
6235
6510
  title.appendChild(close);
6236
6511
  modal.appendChild(title);
6512
+ // The only scrolling element: the title bar and the footer stay put.
6513
+ const body = el('div', 'body');
6514
+ modal.appendChild(body);
6237
6515
  // Identity
6238
6516
  const ctor = component.constructor;
6239
6517
  const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
@@ -6242,15 +6520,18 @@ function open_modal(component) {
6242
6520
  row(identity, 'class', '', hierarchy.join(' → '));
6243
6521
  row(identity, 'element', '', describe_element(element));
6244
6522
  row(identity, '_cid', '', String(component._cid));
6245
- row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
6523
+ const lifecycle = READY_STATES[component._ready_state] || String(component._ready_state);
6524
+ row(identity, 'lifecycle', '', component._debug_no_data
6525
+ ? `${lifecycle} (no data - reload w/o data)`
6526
+ : lifecycle);
6246
6527
  const nocache = element.getAttribute('data-nocache');
6247
6528
  if (nocache)
6248
6529
  row(identity, 'cache', '', `declined: ${nocache}`);
6249
- modal.appendChild(section('Identity', identity));
6530
+ body.appendChild(section('Identity', identity));
6250
6531
  // Args / data / state
6251
- modal.appendChild(section('Args', object_table(component.args)));
6252
- modal.appendChild(section('Data', object_table(component.data)));
6253
- modal.appendChild(section('State', object_table(component.state)));
6532
+ body.appendChild(section('Args', object_table(component.args)));
6533
+ body.appendChild(section('Data', object_table(component.data)));
6534
+ body.appendChild(section('State', object_table(component.state)));
6254
6535
  // Ancestry
6255
6536
  const list = el('div', 'list');
6256
6537
  if (!ancestors.length)
@@ -6259,10 +6540,10 @@ function open_modal(component) {
6259
6540
  const link = el('div', 'link');
6260
6541
  link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
6261
6542
  link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
6262
- link.addEventListener('click', () => open_modal(ancestor));
6543
+ link.addEventListener('click', () => navigate(ancestor));
6263
6544
  list.appendChild(link);
6264
6545
  }
6265
- modal.appendChild(section('Ancestry (DOM, nearest first)', list));
6546
+ body.appendChild(section('Ancestry (DOM, nearest first)', list));
6266
6547
  // Instantiator - the component whose template wrote this tag. Differs from
6267
6548
  // the DOM parent for markup written in a slot body or default content.
6268
6549
  const instantiator = component.instantiator ? component.instantiator() : null;
@@ -6271,7 +6552,7 @@ function open_modal(component) {
6271
6552
  const link = el('div', 'link');
6272
6553
  link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
6273
6554
  link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
6274
- link.addEventListener('click', () => open_modal(instantiator));
6555
+ link.addEventListener('click', () => navigate(instantiator));
6275
6556
  inst.appendChild(link);
6276
6557
  if (ancestors[0] && instantiator !== ancestors[0]) {
6277
6558
  inst.appendChild(el('div', 'note', 'Written in this component\'s template but rendered inside a different parent (slot or default content): handlers and $sid bind here.'));
@@ -6280,9 +6561,49 @@ function open_modal(component) {
6280
6561
  else {
6281
6562
  inst.appendChild(el('div', 'empty', 'none - created directly'));
6282
6563
  }
6283
- modal.appendChild(section('Instantiator', inst));
6564
+ body.appendChild(section('Instantiator', inst));
6565
+ // Footer: lifecycle controls. Clicks land inside the shadow host, so the
6566
+ // overlay's own click hijack lets them through (is_ours).
6567
+ const footer = el('div', 'footer');
6568
+ footer.appendChild(el('span', 'footer-label', 'Lifecycle:'));
6569
+ footer.appendChild(lifecycle_button(component, 'Reload', (c) => c.reload()));
6570
+ footer.appendChild(lifecycle_button(component, 'Refresh', (c) => c.refresh()));
6571
+ footer.appendChild(lifecycle_button(component, 'Rerender', (c) => c.render()));
6572
+ footer.appendChild(lifecycle_button(component, 'Reload w/o data', (c) => c._reload_without_data()));
6573
+ modal.appendChild(footer);
6284
6574
  modal.classList.add(`${PREFIX}modal-open`);
6285
6575
  }
6576
+ /**
6577
+ * One footer button. Runs its lifecycle call on a live component, logs whatever it
6578
+ * throws or rejects with, and re-opens the modal once the call settles so the panel
6579
+ * (lifecycle row above all) describes the state the button just produced.
6580
+ */
6581
+ function lifecycle_button(component, label, run) {
6582
+ const button = el('button', 'button footer-button', label);
6583
+ button.addEventListener('click', () => {
6584
+ if (component.$.hasClass('_Component_Stopped'))
6585
+ return; // dead component: nothing to do
6586
+ let result;
6587
+ try {
6588
+ result = run(component);
6589
+ }
6590
+ catch (error) {
6591
+ console.error(`[JQHTML debug] ${label} threw on <${component.component_name()}>`, error);
6592
+ return;
6593
+ }
6594
+ Promise.resolve(result)
6595
+ .catch((error) => {
6596
+ console.error(`[JQHTML debug] ${label} rejected on <${component.component_name()}>`, error);
6597
+ })
6598
+ .then(() => {
6599
+ // `show`, not `select`: the lifecycle buttons re-render the panel in place
6600
+ // and must not throw away the navigation stack behind it.
6601
+ if (state.enabled && state.modal_component === component)
6602
+ show(component);
6603
+ });
6604
+ });
6605
+ return button;
6606
+ }
6286
6607
  // ---------------------------------------------------------------------------
6287
6608
  // DOM helpers (shadow side)
6288
6609
  // ---------------------------------------------------------------------------
@@ -7013,7 +7334,7 @@ function init(jQuery, config) {
7013
7334
  }
7014
7335
  }
7015
7336
  // Version - will be replaced during build with actual version from package.json
7016
- const version = '2.3.68';
7337
+ const version = '2.3.71';
7017
7338
  // Default export with all functionality
7018
7339
  const jqhtml = {
7019
7340
  // Core