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