@jqhtml/core 2.3.70 → 2.3.72

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
@@ -218,12 +218,17 @@ class LifecycleManager {
218
218
  return LifecycleManager.instance;
219
219
  }
220
220
  constructor() {
221
- this.active_components = new Set();
222
- // No automatic cleanup on DOM removal - components stop explicitly via:
223
- // 1. Parent component re-render (calls .empty() which stops children)
224
- // 2. Manual .empty(), .html(), .text() calls (overridden to stop children)
221
+ // Components stop explicitly via:
222
+ // 1. Parent component re-render (stops descendants before clearing innerHTML)
223
+ // 2. Any jQuery removal that reaches jQuery.cleanData (.remove(), .empty(),
224
+ // .html(x), .text(x), .replaceWith(), .replaceAll()) - hooked in jquery-plugin.ts
225
225
  // 3. Explicit .stop() call
226
- // This allows components to be moved in DOM without lifecycle interruption
226
+ // Native DOM removal is NOT observed for stop; 'detach' (dom-attachment.ts) is the
227
+ // signal for that. Moving a component within the document never stops it.
228
+ //
229
+ // There is deliberately no registry of live components here. The DOM (via
230
+ // element.data('_component')) is the only owner; a registry would pin every
231
+ // component that left the document without being stopped.
227
232
  }
228
233
  /**
229
234
  * Boot a component - run its full lifecycle
@@ -234,7 +239,6 @@ class LifecycleManager {
234
239
  * - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
235
240
  */
236
241
  async boot_component(component) {
237
- this.active_components.add(component);
238
242
  try {
239
243
  // Create phase - SYNCHRONOUS, runs BEFORE first render
240
244
  component.create();
@@ -395,12 +399,6 @@ class LifecycleManager {
395
399
  }
396
400
  }
397
401
  }
398
- /**
399
- * Unregister a component (called on destroy)
400
- */
401
- unregister_component(component) {
402
- this.active_components.delete(component);
403
- }
404
402
  }
405
403
 
406
404
  /**
@@ -1510,760 +1508,509 @@ function serialize_for_cache_key(value) {
1510
1508
  }
1511
1509
 
1512
1510
  /**
1513
- * Load Coordinator - Request deduplication for component on_load() calls
1511
+ * Jqhtml_Local_Storage - Scoped local storage for JQHTML component caching
1514
1512
  *
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.
1513
+ * Provides safe, scoped access to localStorage with automatic handling of
1514
+ * unavailable storage, quota exceeded errors, and scope invalidation.
1519
1515
  *
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
1516
+ * Key Features:
1517
+ * - **Manual scoping**: Cache key must be set via set_cache_key() before use
1518
+ * - **Graceful degradation**: Returns null when storage unavailable or cache key not set
1519
+ * - **Quota management**: Auto-clears storage when full and retries operation
1520
+ * - **Scope validation**: Clears storage when cache key changes
1521
+ * - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
1522
+ * - **Class-aware serialization**: ES6 class instances serialize and restore properly
1524
1523
  *
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)
1524
+ * Class-Aware Serialization:
1525
+ * ES6 class instances can be serialized and deserialized if registered via
1526
+ * register_cache_class(). Classes are wrapped as {__jqhtml_class__: "Name", __jqhtml_props__: {...}}
1527
+ * and restored to proper instances on retrieval. Nested class instances in objects
1528
+ * and arrays are handled recursively. A class whose wire form is not its own
1529
+ * properties (one that defines toJSON(), or keeps state in private fields)
1530
+ * registers a {to_plain, from_plain} pair and is rebuilt through it. Date, Map and
1531
+ * Set are pre-registered the same way - there is no second mechanism.
1532
+ *
1533
+ * ONE SERIALIZER. Every copy of on_load() data the framework makes goes through
1534
+ * this file: the localStorage cache (set()/get()), the normalization of every
1535
+ * on_load() result into this.data (normalize_for_cache()), and the copy a
1536
+ * deduplicated follower takes of its leader's data (serialize()/deserialize(),
1537
+ * used by load-coordinator.ts). Anything that survives one of those survives all
1538
+ * of them, and anything dropped is dropped identically on all of them. A plain
1539
+ * JSON.stringify anywhere on those paths would consult toJSON() and hand out a
1540
+ * wire form nothing knows how to restore.
1541
+ *
1542
+ * Scoping Strategy:
1543
+ * Storage is scoped by a user-provided cache key (typically a session identifier,
1544
+ * user ID, or combination of relevant scope factors). This scope is stored in
1545
+ * `_jqhtml_cache_key`. If the cache key changes between page loads, all JQHTML
1546
+ * storage is cleared to prevent stale data from different sessions/users.
1547
+ *
1548
+ * Key Format:
1549
+ * Keys are stored as: `jqhtml::developer_key::cache_key`
1550
+ * Example: `jqhtml::User_Profile_data::user_123`
1551
+ *
1552
+ * The `jqhtml::` prefix identifies JQHTML keys, allowing safe clearing of only our keys
1553
+ * without affecting other JavaScript libraries. This enables transparent coexistence
1554
+ * with third-party libraries that also use browser storage.
1555
+ *
1556
+ * Quota Exceeded Handling:
1557
+ * When storage quota is exceeded during a set operation, only JQHTML keys (prefixed with
1558
+ * `jqhtml::`) are cleared, preserving other libraries' data. The operation is then retried
1559
+ * once. This ensures the application continues functioning even when storage is full.
1560
+ *
1561
+ * Usage:
1562
+ * // Register classes that need to be cached (call once at startup)
1563
+ * jqhtml.register_cache_class(Contact_Model);
1564
+ * jqhtml.register_cache_class(User_Profile);
1565
+ *
1566
+ * // Must set cache key first (typically done once on page load)
1567
+ * Jqhtml_Local_Storage.set_cache_key('user_123');
1568
+ *
1569
+ * // Then use storage normally - ES6 classes serialize automatically
1570
+ * Jqhtml_Local_Storage.set('cached_component', {
1571
+ * contact: new Contact_Model(),
1572
+ * timestamp: Date.now()
1573
+ * });
1574
+ * const cached = Jqhtml_Local_Storage.get('cached_component');
1575
+ * // cached.contact instanceof Contact_Model === true
1576
+ *
1577
+ * IMPORTANT - Volatile Storage:
1578
+ * Storage can be cleared at any time due to:
1579
+ * - User clearing browser data
1580
+ * - Private browsing mode restrictions
1581
+ * - Quota exceeded errors
1582
+ * - Cache key changes
1583
+ * - Browser storage unavailable
1584
+ *
1585
+ * Therefore, NEVER store critical data. Only use for:
1586
+ * - Cached component HTML (performance optimization)
1587
+ * - Transient UI state (convenience, not required)
1530
1588
  *
1531
1589
  * @internal This class is not exposed in the public API
1590
+ *
1591
+ * ---------------------------------------------------------------------------
1592
+ * REPORTING WHAT WAS DROPPED
1593
+ *
1594
+ * `this.data` is always the serialized copy this file produces - in EVERY cache
1595
+ * mode, including 'none' (see component.ts `_apply_load_result`). Whatever the
1596
+ * serializer cannot express is therefore not merely missing from the cache, it
1597
+ * is missing from `this.data`, and an author needs to be told once rather than
1598
+ * discover it on the next cache hit.
1599
+ *
1600
+ * Two small optional parameters carry that:
1601
+ *
1602
+ * process_for_serialization(value, verbose, seen, path, collector?)
1603
+ * `collector(path, kind)` is invoked for every value that is dropped or
1604
+ * degraded. `path` is a dotted/bracketed path from the root the caller
1605
+ * named; `kind` is one of Strip_Kind. Paths are only BUILT when a
1606
+ * collector is present, so the no-collector path costs nothing.
1607
+ *
1608
+ * Jqhtml_Local_Storage.normalize_for_cache(value, component_name?)
1609
+ * With a component name, and only in development mode, each dropped path
1610
+ * prints exactly ONE console.warn for the life of the page (keyed
1611
+ * `${component_name}:${path}`, the same once-only shape as
1612
+ * warn_uncacheable_args in component-cache.ts). Production converts
1613
+ * silently. Neither ever throws.
1614
+ * ---------------------------------------------------------------------------
1532
1615
  */
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}` };
1616
+ // Registry mapping class names to constructors and their restore hooks
1617
+ const class_registry = Object.create(null);
1618
+ // Markers for serialized class instances (unique to avoid collisions with user data)
1619
+ // Replaced at build time by rollup (see rollup.config.js).
1620
+ const CORE_VERSION = '2.3.72';
1621
+ const CLASS_MARKER = '__jqhtml_class__';
1622
+ const PROPS_MARKER = '__jqhtml_props__';
1623
+ /**
1624
+ * Register a class for cache serialization/deserialization.
1625
+ * Must be called before attempting to cache instances of this class.
1626
+ *
1627
+ * @param klass - The class constructor to register
1628
+ * @param options - How to convert instances to and from their plain form; see
1629
+ * Cache_Class_Options. Omit for a class whose state is its own
1630
+ * enumerable properties.
1631
+ * @throws Error if klass is not a named function/class, or if to_plain is given
1632
+ * without the from_plain that undoes it
1633
+ */
1634
+ function register_cache_class(klass, options = {}) {
1635
+ if (typeof klass !== 'function' || !klass.name) {
1636
+ throw new Error('[JQHTML Cache] register_cache_class requires a named class constructor');
1637
+ }
1638
+ const to_plain = typeof options.to_plain === 'function' ? options.to_plain : null;
1639
+ const from_plain = typeof options.from_plain === 'function' ? options.from_plain : null;
1640
+ if (to_plain && !from_plain) {
1641
+ throw new Error(`[JQHTML Cache] register_cache_class(${klass.name}): to_plain needs a from_plain. ` +
1642
+ `The serializer cannot rebuild an instance from a custom wire form on its own.`);
1643
+ }
1644
+ class_registry[klass.name] = { klass, to_plain, from_plain };
1645
+ }
1646
+ // Built-ins travel through the same hook as user classes. Their wire forms are
1647
+ // the ones the cache has always stored (ISO string, entry pairs, item list), so
1648
+ // entries written before this existed still restore.
1649
+ register_cache_class(Date, {
1650
+ to_plain: (date) => date.toISOString(),
1651
+ from_plain: (iso) => new Date(iso)
1652
+ });
1653
+ register_cache_class(Map, {
1654
+ to_plain: (map) => Array.from(map.entries()),
1655
+ from_plain: (entries) => new Map(entries)
1656
+ });
1657
+ register_cache_class(Set, {
1658
+ to_plain: (set) => Array.from(set),
1659
+ from_plain: (items) => new Set(items)
1660
+ });
1661
+ /**
1662
+ * Find the registry entry for a live value: by constructor name first, then by
1663
+ * instanceof for the built-ins so a subclass of Date/Map/Set (whose name is not
1664
+ * registered) still serializes as the built-in it is.
1665
+ */
1666
+ function lookup_registered(value) {
1667
+ const ctor = value.constructor;
1668
+ if (ctor && ctor.name && class_registry[ctor.name]) {
1669
+ return { name: ctor.name, entry: class_registry[ctor.name] };
1670
+ }
1671
+ if (value instanceof Date)
1672
+ return { name: 'Date', entry: class_registry['Date'] };
1673
+ if (value instanceof Map)
1674
+ return { name: 'Map', entry: class_registry['Map'] };
1675
+ if (value instanceof Set)
1676
+ return { name: 'Set', entry: class_registry['Set'] };
1677
+ return null;
1678
+ }
1679
+ /**
1680
+ * Serialize a value to JSON string, handling ES6 class instances recursively.
1681
+ * Returns null if serialization fails for any reason.
1682
+ *
1683
+ * @param value - The value to serialize
1684
+ * @param verbose - Whether to log warnings
1685
+ * @param path - Root path used when reporting drops (only used with a collector)
1686
+ * @param collector - Optional reporter, see the file header
1687
+ * @returns Serialized JSON string, or null if serialization failed
1688
+ */
1689
+ function serialize_value(value, verbose, path = '', collector) {
1690
+ try {
1691
+ const seen = new WeakSet();
1692
+ const processed = process_for_serialization(value, verbose, seen, path, collector);
1693
+ if (processed === undefined) {
1694
+ // Serialization failed - undefined signals failure
1695
+ return null;
1630
1696
  }
1631
- catch (error) {
1632
- // Serialization failed unexpectedly
1633
- return { key: null, uncacheable_property };
1697
+ return JSON.stringify(processed);
1698
+ }
1699
+ catch (e) {
1700
+ if (verbose) {
1701
+ console.warn('[JQHTML Cache] Serialization failed:', e);
1634
1702
  }
1703
+ return null;
1635
1704
  }
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);
1705
+ }
1706
+ /**
1707
+ * Recursively process a value for serialization.
1708
+ *
1709
+ * Values the serializer cannot express are DROPPED, never passed through: the
1710
+ * result of this function is what becomes `this.data`, so letting a function or
1711
+ * a DOM node survive here would make `this.data` differ from what the cache can
1712
+ * ever return. A dropped property is omitted from an object and becomes `null`
1713
+ * in an array - JSON's own semantics for the same situation.
1714
+ *
1715
+ * Returns undefined for a value that has to be dropped.
1716
+ *
1717
+ * @param value - The value to process
1718
+ * @param verbose - Whether to log warnings
1719
+ * @param seen - WeakSet to detect circular references
1720
+ * @param path - Dotted path of this value (only built when a collector is present)
1721
+ * @param collector - Optional reporter for dropped/degraded values
1722
+ * @returns Processed value ready for JSON.stringify, or undefined if dropped
1723
+ */
1724
+ function process_for_serialization(value, verbose, seen, path = '', collector) {
1725
+ // Handle null and undefined
1726
+ if (value === null) {
1727
+ return null;
1689
1728
  }
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;
1729
+ if (value === undefined) {
1730
+ return undefined; // JSON.stringify will omit this property
1700
1731
  }
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;
1732
+ // Handle primitives
1733
+ if (typeof value !== 'object') {
1734
+ // string, number, boolean are fine
1735
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
1736
+ return value;
1715
1737
  }
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);
1738
+ // bigint, symbol, function cannot be serialized
1739
+ if (typeof value === 'function') {
1740
+ collector?.(path, 'function');
1720
1741
  }
1721
- catch (error) {
1722
- // Unserializable data cannot be shared; followers load independently.
1723
- entry.leader_data = null;
1742
+ else if (typeof value === 'symbol') {
1743
+ collector?.(path, 'symbol');
1724
1744
  }
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 });
1745
+ else if (typeof value === 'bigint') {
1746
+ collector?.(path, 'bigint');
1728
1747
  }
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);
1748
+ if (verbose) {
1749
+ console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
1735
1750
  }
1751
+ // Return undefined to omit this value (not fail entire serialization)
1752
+ return undefined;
1736
1753
  }
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 });
1754
+ // Live objects that only LOOK like data. Each is checked before anything
1755
+ // treats it as a bag of properties, because walking one produces a
1756
+ // meaningless husk (a jQuery object becomes {0: ..., length: 1}) instead of
1757
+ // the drop the author needs to hear about.
1758
+ if (typeof value.nodeType === 'number') {
1759
+ collector?.(path, 'dom-node');
1760
+ return undefined;
1761
+ }
1762
+ if (value.jquery) {
1763
+ collector?.(path, 'jquery');
1764
+ return undefined;
1765
+ }
1766
+ // Duck-typed on purpose: importing Jqhtml_Component here would be a cycle
1767
+ // (component.ts imports this file). _cid + component_name() is the pair no
1768
+ // plain data object carries.
1769
+ if (value._cid !== undefined && typeof value.component_name === 'function') {
1770
+ collector?.(path, 'component');
1771
+ return undefined;
1772
+ }
1773
+ if (typeof value.then === 'function') {
1774
+ collector?.(path, 'promise');
1775
+ return undefined;
1776
+ }
1777
+ // Handle circular references.
1778
+ // `seen` tracks only the current ancestor path (the DFS stack): an object is
1779
+ // added on the way down and removed on the way back up (see finally below).
1780
+ // This flags a genuine cycle (a back-edge to an ancestor) while allowing a
1781
+ // shared reference in a DAG to be serialized independently on each visit.
1782
+ // JSON round-trip semantics do not preserve identity anyway, so emitting a
1783
+ // copy per reference is correct; silently omitting the property is not.
1784
+ if (seen.has(value)) {
1785
+ collector?.(path, 'circular');
1786
+ if (verbose) {
1787
+ console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
1758
1788
  }
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 });
1789
+ return undefined; // Cut the cycle here; the rest of the tree still serializes
1790
+ }
1791
+ seen.add(value);
1792
+ try {
1793
+ // Handle arrays
1794
+ if (Array.isArray(value)) {
1795
+ const result = [];
1796
+ for (let i = 0; i < value.length; i++) {
1797
+ const item = value[i];
1798
+ const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
1799
+ // For arrays, we keep undefined as null to preserve indices
1800
+ result.push(processed === undefined ? null : processed);
1764
1801
  }
1802
+ return result;
1765
1803
  }
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;
1804
+ // Registered classes - Date, Map and Set included - go out as
1805
+ // {__jqhtml_class__, __jqhtml_props__} through their restore hook.
1806
+ const registered = lookup_registered(value);
1807
+ if (registered) {
1808
+ const plain = registered_plain_form(registered, value, verbose, seen, path, collector);
1809
+ if (plain === undefined) {
1810
+ return undefined; // to_plain threw; the value is dropped, and reported
1811
+ }
1812
+ return {
1813
+ [CLASS_MARKER]: registered.name,
1814
+ [PROPS_MARKER]: plain
1815
+ };
1778
1816
  }
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 });
1817
+ const ctor = value.constructor;
1818
+ // Handle plain objects (constructor is Object or no constructor)
1819
+ if (ctor === Object || ctor === undefined || ctor === null) {
1820
+ return process_own_properties(value, verbose, seen, path, collector);
1789
1821
  }
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
- };
1822
+ // Unregistered class instance - convert to plain object for hot/cold parity.
1823
+ // Properties are preserved, prototype methods are lost, exactly as a cache
1824
+ // hit would produce, so the loss shows up on the first load rather than on
1825
+ // the next page view.
1826
+ collector?.(path, 'unregistered-class', ctor.name);
1827
+ if (verbose) {
1828
+ console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
1829
+ `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
1803
1830
  }
1804
- return state;
1831
+ return process_own_properties(value, verbose, seen, path, collector);
1805
1832
  }
1806
- /**
1807
- * Clear all coordination entries (for testing/debugging)
1808
- */
1809
- static clear_all() {
1810
- this._registry.clear();
1833
+ finally {
1834
+ // Pop this object off the ancestor path now that its subtree is done.
1835
+ seen.delete(value);
1811
1836
  }
1812
1837
  }
1813
- Load_Coordinator._registry = new Map();
1814
-
1815
1838
  /**
1816
- * Jqhtml_Local_Storage - Scoped local storage for JQHTML component caching
1839
+ * The plain value stored for a registered instance.
1817
1840
  *
1818
- * Provides safe, scoped access to localStorage with automatic handling of
1819
- * unavailable storage, quota exceeded errors, and scope invalidation.
1820
- *
1821
- * Key Features:
1822
- * - **Manual scoping**: Cache key must be set via set_cache_key() before use
1823
- * - **Graceful degradation**: Returns null when storage unavailable or cache key not set
1824
- * - **Quota management**: Auto-clears storage when full and retries operation
1825
- * - **Scope validation**: Clears storage when cache key changes
1826
- * - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
1827
- * - **Class-aware serialization**: ES6 class instances serialize and restore properly
1828
- *
1829
- * Class-Aware Serialization:
1830
- * ES6 class instances can be serialized and deserialized if registered via
1831
- * register_cache_class(). Classes are wrapped as {__jqhtml_class__: "Name", __jqhtml_props__: {...}}
1832
- * and restored to proper instances on retrieval. Nested class instances in objects
1833
- * and arrays are handled recursively.
1834
- *
1835
- * Scoping Strategy:
1836
- * Storage is scoped by a user-provided cache key (typically a session identifier,
1837
- * user ID, or combination of relevant scope factors). This scope is stored in
1838
- * `_jqhtml_cache_key`. If the cache key changes between page loads, all JQHTML
1839
- * storage is cleared to prevent stale data from different sessions/users.
1840
- *
1841
- * Key Format:
1842
- * Keys are stored as: `jqhtml::developer_key::cache_key`
1843
- * Example: `jqhtml::User_Profile_data::user_123`
1844
- *
1845
- * The `jqhtml::` prefix identifies JQHTML keys, allowing safe clearing of only our keys
1846
- * without affecting other JavaScript libraries. This enables transparent coexistence
1847
- * with third-party libraries that also use browser storage.
1848
- *
1849
- * Quota Exceeded Handling:
1850
- * When storage quota is exceeded during a set operation, only JQHTML keys (prefixed with
1851
- * `jqhtml::`) are cleared, preserving other libraries' data. The operation is then retried
1852
- * once. This ensures the application continues functioning even when storage is full.
1853
- *
1854
- * Usage:
1855
- * // Register classes that need to be cached (call once at startup)
1856
- * jqhtml.register_cache_class(Contact_Model);
1857
- * jqhtml.register_cache_class(User_Profile);
1858
- *
1859
- * // Must set cache key first (typically done once on page load)
1860
- * Jqhtml_Local_Storage.set_cache_key('user_123');
1861
- *
1862
- * // Then use storage normally - ES6 classes serialize automatically
1863
- * Jqhtml_Local_Storage.set('cached_component', {
1864
- * contact: new Contact_Model(),
1865
- * timestamp: Date.now()
1866
- * });
1867
- * const cached = Jqhtml_Local_Storage.get('cached_component');
1868
- * // cached.contact instanceof Contact_Model === true
1869
- *
1870
- * IMPORTANT - Volatile Storage:
1871
- * Storage can be cleared at any time due to:
1872
- * - User clearing browser data
1873
- * - Private browsing mode restrictions
1874
- * - Quota exceeded errors
1875
- * - Cache key changes
1876
- * - Browser storage unavailable
1877
- *
1878
- * Therefore, NEVER store critical data. Only use for:
1879
- * - Cached component HTML (performance optimization)
1880
- * - Transient UI state (convenience, not required)
1881
- *
1882
- * @internal This class is not exposed in the public API
1883
- *
1884
- * ---------------------------------------------------------------------------
1885
- * REPORTING WHAT WAS DROPPED
1886
- *
1887
- * `this.data` is always the serialized copy this file produces - in EVERY cache
1888
- * mode, including 'none' (see component.ts `_apply_load_result`). Whatever the
1889
- * serializer cannot express is therefore not merely missing from the cache, it
1890
- * is missing from `this.data`, and an author needs to be told once rather than
1891
- * discover it on the next cache hit.
1892
- *
1893
- * Two small optional parameters carry that:
1894
- *
1895
- * process_for_serialization(value, verbose, seen, path, collector?)
1896
- * `collector(path, kind)` is invoked for every value that is dropped or
1897
- * degraded. `path` is a dotted/bracketed path from the root the caller
1898
- * named; `kind` is one of Strip_Kind. Paths are only BUILT when a
1899
- * collector is present, so the no-collector path costs nothing.
1841
+ * No from_plain: own enumerable properties (toJSON() is deliberately bypassed,
1842
+ * because Object.assign is the only way back and it needs the properties).
1843
+ * With from_plain: to_plain(instance), or toJSON() when the class defines one,
1844
+ * or own properties as the last resort. Whatever comes back is walked by the
1845
+ * serializer like any other value.
1900
1846
  *
1901
- * Jqhtml_Local_Storage.normalize_for_cache(value, component_name?)
1902
- * With a component name, and only in development mode, each dropped path
1903
- * prints exactly ONE console.warn for the life of the page (keyed
1904
- * `${component_name}:${path}`, the same once-only shape as
1905
- * warn_uncacheable_args in component-cache.ts). Production converts
1906
- * silently. Neither ever throws.
1907
- * ---------------------------------------------------------------------------
1847
+ * Returns undefined when to_plain threw - the caller drops the value.
1848
+ */
1849
+ function registered_plain_form(registered, value, verbose, seen, path, collector) {
1850
+ const { name, entry } = registered;
1851
+ if (!entry.from_plain) {
1852
+ return process_own_properties(value, verbose, seen, path, collector);
1853
+ }
1854
+ let plain;
1855
+ try {
1856
+ if (entry.to_plain) {
1857
+ plain = entry.to_plain(value);
1858
+ }
1859
+ else if (typeof value.toJSON === 'function') {
1860
+ plain = value.toJSON();
1861
+ }
1862
+ else {
1863
+ return process_own_properties(value, verbose, seen, path, collector);
1864
+ }
1865
+ }
1866
+ catch (e) {
1867
+ collector?.(path, 'to-plain-threw', name);
1868
+ if (verbose) {
1869
+ console.warn(`[JQHTML Cache] to_plain for class "${name}" threw - value will be omitted:`, e);
1870
+ }
1871
+ return undefined;
1872
+ }
1873
+ // A toJSON() that returns `this` has no separate wire form; own properties it is.
1874
+ if (plain === value) {
1875
+ return process_own_properties(value, verbose, seen, path, collector);
1876
+ }
1877
+ const processed = process_for_serialization(plain, verbose, seen, path, collector);
1878
+ return processed === undefined ? null : processed;
1879
+ }
1880
+ /**
1881
+ * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
1882
+ * object. Shared by plain objects, registered class instances and degraded
1883
+ * unregistered instances - all three keep exactly the same property semantics.
1908
1884
  */
1885
+ function process_own_properties(value, verbose, seen, path, collector) {
1886
+ const result = {};
1887
+ for (const key of Object.keys(value)) {
1888
+ const propValue = value[key];
1889
+ const processed = process_for_serialization(propValue, verbose, seen, collector ? `${path}.${key}` : '', collector);
1890
+ // Only include if not undefined (unless original was undefined)
1891
+ if (processed !== undefined || propValue === undefined) {
1892
+ result[key] = processed;
1893
+ }
1894
+ }
1895
+ return result;
1896
+ }
1909
1897
  // ============================================================================
1910
- // Class Registry for Serialization
1898
+ // Development warnings for dropped values
1911
1899
  // ============================================================================
1912
- // Registry mapping class names to constructors
1913
- const class_registry = Object.create(null);
1914
- // Markers for serialized class instances (unique to avoid collisions with user data)
1915
- // Replaced at build time by rollup (see rollup.config.js).
1916
- const CORE_VERSION = '2.3.70';
1917
- const CLASS_MARKER = '__jqhtml_class__';
1918
- const PROPS_MARKER = '__jqhtml_props__';
1900
+ // One warning per component + path, for the life of the page. A list of 50 rows
1901
+ // each carrying an $on_select callback must print ONE warning, not 50, and must
1902
+ // not print it again on the next reload() or on the next instance of the same
1903
+ // component. Same shape as _warned_uncacheable in component-cache.ts.
1904
+ const _warned_stripped = new Set();
1919
1905
  /**
1920
- * Register a class for cache serialization/deserialization.
1921
- * Must be called before attempting to cache instances of this class.
1922
- *
1923
- * @param klass - The class constructor to register
1924
- * @throws Error if klass is not a named function/class
1906
+ * What was lost, and where the value actually belongs. Every message names a
1907
+ * destination, because "it was dropped" without "put it here instead" is not
1908
+ * actionable.
1925
1909
  */
1926
- function register_cache_class(klass) {
1927
- if (typeof klass !== 'function' || !klass.name) {
1928
- throw new Error('[JQHTML Cache] register_cache_class requires a named class constructor');
1910
+ function strip_advice(kind, detail) {
1911
+ switch (kind) {
1912
+ case 'function':
1913
+ return 'a function cannot be serialized. Callbacks are configuration, not loaded data - ' +
1914
+ 'pass it in this.args (from the parent template) and keep only ids in this.data.';
1915
+ case 'promise':
1916
+ return 'a promise cannot be serialized. Await it inside on_load() and store the RESULT, ' +
1917
+ 'or hold the pending promise in this.state.';
1918
+ case 'dom-node':
1919
+ return 'a DOM node cannot be serialized. Elements, timers, files and sockets belong in this.state.';
1920
+ case 'jquery':
1921
+ return 'a jQuery object cannot be serialized. Elements, timers, files and sockets belong in this.state.';
1922
+ case 'component':
1923
+ return 'a Jqhtml_Component cannot be serialized. Keep component references in this.state ' +
1924
+ 'and address children with this.sid().';
1925
+ case 'symbol':
1926
+ return 'a symbol cannot be serialized. Use a string, or keep it in this.state.';
1927
+ case 'bigint':
1928
+ return 'a bigint cannot be serialized. Convert it to a string in on_load(), or keep it in this.state.';
1929
+ case 'circular':
1930
+ return 'it points back to one of its own ancestors, and the cycle was cut. Store the graph ' +
1931
+ 'flat (ids instead of back-references) in this.data, or keep the linked form in this.state.';
1932
+ case 'unregistered-class':
1933
+ return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
1934
+ `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
1935
+ `to get real instances back - with { from_plain } if the class defines toJSON() or keeps ` +
1936
+ `state outside its own properties.`;
1937
+ case 'to-plain-threw':
1938
+ return `the to_plain hook registered for class "${detail}" threw, so the value was dropped. ` +
1939
+ `Make to_plain total over every instance the class can produce.`;
1929
1940
  }
1930
- class_registry[klass.name] = klass;
1931
1941
  }
1932
1942
  /**
1933
- * Serialize a value to JSON string, handling ES6 class instances recursively.
1934
- * Returns null if serialization fails for any reason.
1943
+ * Build the collector normalize_for_cache() hands to the serializer, or
1944
+ * `undefined` when nobody is listening - production, or a caller that named no
1945
+ * component. Returning undefined (rather than a no-op function) is what keeps
1946
+ * the serializer from building path strings at all on the hot path.
1947
+ */
1948
+ function make_strip_collector(component_name) {
1949
+ if (!component_name)
1950
+ return undefined;
1951
+ if (get_config().mode !== 'development')
1952
+ return undefined;
1953
+ return (path, kind, detail) => {
1954
+ try {
1955
+ const dedupe_key = `${component_name}:${path}`;
1956
+ if (_warned_stripped.has(dedupe_key))
1957
+ return;
1958
+ _warned_stripped.add(dedupe_key);
1959
+ console.warn(`[JQHTML] <${component_name}> dropped ${path} from this.data: ${strip_advice(kind, detail)}\n` +
1960
+ ` this.data is always the serialized copy jqhtml stores in and restores from cache, ` +
1961
+ `so only serializable values survive on_load() - in every cache mode.`, { component: component_name, path, kind });
1962
+ }
1963
+ catch {
1964
+ // A warning must never be able to break a load.
1965
+ }
1966
+ };
1967
+ }
1968
+ // ============================================================================
1969
+ // Deserialization (String → Object)
1970
+ // ============================================================================
1971
+ /**
1972
+ * Deserialize a JSON string, restoring ES6 class instances.
1973
+ * Returns null if deserialization fails.
1935
1974
  *
1936
- * @param value - The value to serialize
1975
+ * @param str - The JSON string to deserialize
1937
1976
  * @param verbose - Whether to log warnings
1938
- * @param path - Root path used when reporting drops (only used with a collector)
1939
- * @param collector - Optional reporter, see the file header
1940
- * @returns Serialized JSON string, or null if serialization failed
1977
+ * @returns Deserialized value with class instances restored, or null if failed
1941
1978
  */
1942
- function serialize_value(value, verbose, path = '', collector) {
1979
+ function deserialize_value(str, verbose) {
1943
1980
  try {
1944
- const seen = new WeakSet();
1945
- const processed = process_for_serialization(value, verbose, seen, path, collector);
1946
- if (processed === undefined) {
1947
- // Serialization failed - undefined signals failure
1948
- return null;
1949
- }
1950
- return JSON.stringify(processed);
1981
+ const parsed = JSON.parse(str);
1982
+ return process_for_deserialization(parsed, verbose);
1951
1983
  }
1952
1984
  catch (e) {
1953
1985
  if (verbose) {
1954
- console.warn('[JQHTML Cache] Serialization failed:', e);
1986
+ console.warn('[JQHTML Cache] Deserialization failed:', e);
1955
1987
  }
1956
1988
  return null;
1957
1989
  }
1958
1990
  }
1959
1991
  /**
1960
- * Recursively process a value for serialization.
1961
- *
1962
- * Values the serializer cannot express are DROPPED, never passed through: the
1963
- * result of this function is what becomes `this.data`, so letting a function or
1964
- * a DOM node survive here would make `this.data` differ from what the cache can
1965
- * ever return. A dropped property is omitted from an object and becomes `null`
1966
- * in an array - JSON's own semantics for the same situation.
1967
- *
1968
- * Returns undefined for a value that has to be dropped.
1992
+ * Recursively process a parsed value, restoring class instances.
1969
1993
  *
1970
- * @param value - The value to process
1994
+ * @param value - The parsed value to process
1971
1995
  * @param verbose - Whether to log warnings
1972
- * @param seen - WeakSet to detect circular references
1973
- * @param path - Dotted path of this value (only built when a collector is present)
1974
- * @param collector - Optional reporter for dropped/degraded values
1975
- * @returns Processed value ready for JSON.stringify, or undefined if dropped
1996
+ * @returns Processed value with class instances restored
1976
1997
  */
1977
- function process_for_serialization(value, verbose, seen, path = '', collector) {
1978
- // Handle null and undefined
1979
- if (value === null) {
1980
- return null;
1998
+ function process_for_deserialization(value, verbose) {
1999
+ // Handle primitives and null
2000
+ if (value === null || value === undefined || typeof value !== 'object') {
2001
+ return value;
1981
2002
  }
1982
- if (value === undefined) {
1983
- return undefined; // JSON.stringify will omit this property
1984
- }
1985
- // Handle primitives
1986
- if (typeof value !== 'object') {
1987
- // string, number, boolean are fine
1988
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
1989
- return value;
1990
- }
1991
- // bigint, symbol, function cannot be serialized
1992
- if (typeof value === 'function') {
1993
- collector?.(path, 'function');
1994
- }
1995
- else if (typeof value === 'symbol') {
1996
- collector?.(path, 'symbol');
1997
- }
1998
- else if (typeof value === 'bigint') {
1999
- collector?.(path, 'bigint');
2000
- }
2001
- if (verbose) {
2002
- console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
2003
- }
2004
- // Return undefined to omit this value (not fail entire serialization)
2005
- return undefined;
2006
- }
2007
- // Live objects that only LOOK like data. Each is checked before anything
2008
- // treats it as a bag of properties, because walking one produces a
2009
- // meaningless husk (a jQuery object becomes {0: ..., length: 1}) instead of
2010
- // the drop the author needs to hear about.
2011
- if (typeof value.nodeType === 'number') {
2012
- collector?.(path, 'dom-node');
2013
- return undefined;
2014
- }
2015
- if (value.jquery) {
2016
- collector?.(path, 'jquery');
2017
- return undefined;
2018
- }
2019
- // Duck-typed on purpose: importing Jqhtml_Component here would be a cycle
2020
- // (component.ts imports this file). _cid + component_name() is the pair no
2021
- // plain data object carries.
2022
- if (value._cid !== undefined && typeof value.component_name === 'function') {
2023
- collector?.(path, 'component');
2024
- return undefined;
2025
- }
2026
- if (typeof value.then === 'function') {
2027
- collector?.(path, 'promise');
2028
- return undefined;
2029
- }
2030
- // Handle circular references.
2031
- // `seen` tracks only the current ancestor path (the DFS stack): an object is
2032
- // added on the way down and removed on the way back up (see finally below).
2033
- // This flags a genuine cycle (a back-edge to an ancestor) while allowing a
2034
- // shared reference in a DAG to be serialized independently on each visit.
2035
- // JSON round-trip semantics do not preserve identity anyway, so emitting a
2036
- // copy per reference is correct; silently omitting the property is not.
2037
- if (seen.has(value)) {
2038
- collector?.(path, 'circular');
2039
- if (verbose) {
2040
- console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
2041
- }
2042
- return undefined; // Cut the cycle here; the rest of the tree still serializes
2043
- }
2044
- seen.add(value);
2045
- try {
2046
- // Handle arrays
2047
- if (Array.isArray(value)) {
2048
- const result = [];
2049
- for (let i = 0; i < value.length; i++) {
2050
- const item = value[i];
2051
- const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
2052
- // For arrays, we keep undefined as null to preserve indices
2053
- result.push(processed === undefined ? null : processed);
2054
- }
2055
- return result;
2056
- }
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));
2084
- }
2085
- return {
2086
- [CLASS_MARKER]: 'Set',
2087
- [PROPS_MARKER]: items
2088
- };
2089
- }
2090
- // Get the constructor
2091
- 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
- // Handle plain objects (constructor is Object or no constructor)
2100
- if (ctor === Object || ctor === undefined || ctor === null) {
2101
- return process_own_properties(value, verbose, seen, path, collector);
2102
- }
2103
- // Unregistered class instance - convert to plain object for hot/cold parity.
2104
- // Properties are preserved, prototype methods are lost, exactly as a cache
2105
- // hit would produce, so the loss shows up on the first load rather than on
2106
- // the next page view.
2107
- collector?.(path, 'unregistered-class', ctor.name);
2108
- if (verbose) {
2109
- console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2110
- `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2111
- }
2112
- return process_own_properties(value, verbose, seen, path, collector);
2113
- }
2114
- finally {
2115
- // Pop this object off the ancestor path now that its subtree is done.
2116
- seen.delete(value);
2117
- }
2118
- }
2119
- /**
2120
- * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
2121
- * object. Shared by plain objects, registered class instances and degraded
2122
- * unregistered instances - all three keep exactly the same property semantics.
2123
- */
2124
- function process_own_properties(value, verbose, seen, path, collector) {
2125
- const result = {};
2126
- for (const key of Object.keys(value)) {
2127
- const propValue = value[key];
2128
- const processed = process_for_serialization(propValue, verbose, seen, collector ? `${path}.${key}` : '', collector);
2129
- // Only include if not undefined (unless original was undefined)
2130
- if (processed !== undefined || propValue === undefined) {
2131
- result[key] = processed;
2132
- }
2133
- }
2134
- return result;
2135
- }
2136
- // ============================================================================
2137
- // Development warnings for dropped values
2138
- // ============================================================================
2139
- // One warning per component + path, for the life of the page. A list of 50 rows
2140
- // each carrying an $on_select callback must print ONE warning, not 50, and must
2141
- // not print it again on the next reload() or on the next instance of the same
2142
- // component. Same shape as _warned_uncacheable in component-cache.ts.
2143
- const _warned_stripped = new Set();
2144
- /**
2145
- * What was lost, and where the value actually belongs. Every message names a
2146
- * destination, because "it was dropped" without "put it here instead" is not
2147
- * actionable.
2148
- */
2149
- function strip_advice(kind, detail) {
2150
- switch (kind) {
2151
- case 'function':
2152
- return 'a function cannot be serialized. Callbacks are configuration, not loaded data - ' +
2153
- 'pass it in this.args (from the parent template) and keep only ids in this.data.';
2154
- case 'promise':
2155
- return 'a promise cannot be serialized. Await it inside on_load() and store the RESULT, ' +
2156
- 'or hold the pending promise in this.state.';
2157
- case 'dom-node':
2158
- return 'a DOM node cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2159
- case 'jquery':
2160
- return 'a jQuery object cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2161
- case 'component':
2162
- return 'a Jqhtml_Component cannot be serialized. Keep component references in this.state ' +
2163
- 'and address children with this.sid().';
2164
- case 'symbol':
2165
- return 'a symbol cannot be serialized. Use a string, or keep it in this.state.';
2166
- case 'bigint':
2167
- return 'a bigint cannot be serialized. Convert it to a string in on_load(), or keep it in this.state.';
2168
- case 'circular':
2169
- return 'it points back to one of its own ancestors, and the cycle was cut. Store the graph ' +
2170
- 'flat (ids instead of back-references) in this.data, or keep the linked form in this.state.';
2171
- case 'unregistered-class':
2172
- return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2173
- `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2174
- `to get real instances back.`;
2175
- }
2176
- }
2177
- /**
2178
- * Build the collector normalize_for_cache() hands to the serializer, or
2179
- * `undefined` when nobody is listening - production, or a caller that named no
2180
- * component. Returning undefined (rather than a no-op function) is what keeps
2181
- * the serializer from building path strings at all on the hot path.
2182
- */
2183
- function make_strip_collector(component_name) {
2184
- if (!component_name)
2185
- return undefined;
2186
- if (get_config().mode !== 'development')
2187
- return undefined;
2188
- return (path, kind, detail) => {
2189
- try {
2190
- const dedupe_key = `${component_name}:${path}`;
2191
- if (_warned_stripped.has(dedupe_key))
2192
- return;
2193
- _warned_stripped.add(dedupe_key);
2194
- console.warn(`[JQHTML] <${component_name}> dropped ${path} from this.data: ${strip_advice(kind, detail)}\n` +
2195
- ` this.data is always the serialized copy jqhtml stores in and restores from cache, ` +
2196
- `so only serializable values survive on_load() - in every cache mode.`, { component: component_name, path, kind });
2197
- }
2198
- catch {
2199
- // A warning must never be able to break a load.
2200
- }
2201
- };
2202
- }
2203
- // ============================================================================
2204
- // Deserialization (String → Object)
2205
- // ============================================================================
2206
- /**
2207
- * Deserialize a JSON string, restoring ES6 class instances.
2208
- * Returns null if deserialization fails.
2209
- *
2210
- * @param str - The JSON string to deserialize
2211
- * @param verbose - Whether to log warnings
2212
- * @returns Deserialized value with class instances restored, or null if failed
2213
- */
2214
- function deserialize_value(str, verbose) {
2215
- try {
2216
- const parsed = JSON.parse(str);
2217
- return process_for_deserialization(parsed, verbose);
2218
- }
2219
- catch (e) {
2220
- if (verbose) {
2221
- console.warn('[JQHTML Cache] Deserialization failed:', e);
2222
- }
2223
- return null;
2224
- }
2225
- }
2226
- /**
2227
- * Recursively process a parsed value, restoring class instances.
2228
- *
2229
- * @param value - The parsed value to process
2230
- * @param verbose - Whether to log warnings
2231
- * @returns Processed value with class instances restored
2232
- */
2233
- function process_for_deserialization(value, verbose) {
2234
- // Handle primitives and null
2235
- if (value === null || value === undefined || typeof value !== 'object') {
2236
- return value;
2237
- }
2238
- // Handle arrays
2239
- if (Array.isArray(value)) {
2240
- return value.map(item => process_for_deserialization(item, verbose));
2003
+ // Handle arrays
2004
+ if (Array.isArray(value)) {
2005
+ return value.map(item => process_for_deserialization(item, verbose));
2241
2006
  }
2242
2007
  // Check for class marker
2243
2008
  if (value[CLASS_MARKER] !== undefined && value[PROPS_MARKER] !== undefined) {
2244
2009
  const class_name = value[CLASS_MARKER];
2245
2010
  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) {
2011
+ // Look up registered class (Date, Map and Set are registered like any other)
2012
+ const entry = class_registry[class_name];
2013
+ if (!entry) {
2267
2014
  if (verbose) {
2268
2015
  console.warn(`[JQHTML Cache] Cannot restore class "${class_name}" - not registered. ` +
2269
2016
  `Data will be returned as plain object. ` +
@@ -2272,10 +2019,14 @@ function process_for_deserialization(value, verbose) {
2272
2019
  // Return as plain object rather than failing completely
2273
2020
  return process_for_deserialization(props, verbose);
2274
2021
  }
2275
- // Create instance without calling constructor and assign properties
2022
+ // Restore through from_plain when the class has one; otherwise create an
2023
+ // instance without calling the constructor and assign the properties back.
2276
2024
  try {
2277
- const instance = Object.create(klass.prototype);
2278
2025
  const processed_props = process_for_deserialization(props, verbose);
2026
+ if (entry.from_plain) {
2027
+ return entry.from_plain(processed_props);
2028
+ }
2029
+ const instance = Object.create(entry.klass.prototype);
2279
2030
  Object.assign(instance, processed_props);
2280
2031
  return instance;
2281
2032
  }
@@ -2283,355 +2034,689 @@ function process_for_deserialization(value, verbose) {
2283
2034
  if (verbose) {
2284
2035
  console.warn(`[JQHTML Cache] Failed to restore class "${class_name}":`, e);
2285
2036
  }
2286
- // Return null to signal cache should be treated as miss
2287
- return null;
2037
+ // Return null to signal cache should be treated as miss
2038
+ return null;
2039
+ }
2040
+ }
2041
+ // Plain object - process recursively
2042
+ const result = {};
2043
+ for (const key of Object.keys(value)) {
2044
+ result[key] = process_for_deserialization(value[key], verbose);
2045
+ }
2046
+ return result;
2047
+ }
2048
+ class Jqhtml_Local_Storage {
2049
+ /**
2050
+ * Set the cache key and initialize storage
2051
+ * Must be called before any get/set operations
2052
+ * @param {string} cache_key - Unique identifier for this cache scope
2053
+ * @param {CacheMode} cache_mode - Cache strategy: 'data' (default, recommended) or 'html'
2054
+ */
2055
+ static set_cache_key(cache_key, cache_mode = 'data') {
2056
+ this._cache_key = cache_key;
2057
+ // Key SHAPE is a function of the library (see cache-key-serializer.ts), so an
2058
+ // upgrade that changes it must invalidate everything persisted under the old shape.
2059
+ // Version the SCOPE MARKER rather than the key itself: _validate_scope() already
2060
+ // clears every jqhtml key when this value changes, and leaving the per-entry key
2061
+ // format alone keeps stored keys readable and avoids repeating the version in each.
2062
+ this._scope_marker = `${CORE_VERSION}::${cache_key}`;
2063
+ this._cache_mode = cache_mode;
2064
+ this._init();
2065
+ }
2066
+ /**
2067
+ * Check if cache key has been set
2068
+ * @returns {boolean} True if cache key is configured
2069
+ */
2070
+ static has_cache_key() {
2071
+ return this._cache_key !== null;
2072
+ }
2073
+ /**
2074
+ * Get the current cache mode
2075
+ * @returns {CacheMode} Current cache mode ('data' or 'html')
2076
+ */
2077
+ static get_cache_mode() {
2078
+ return this._cache_mode;
2079
+ }
2080
+ /**
2081
+ * Initialize storage system and validate scope
2082
+ * Called automatically after cache key is set
2083
+ * @private
2084
+ */
2085
+ static _init() {
2086
+ // Check storage availability
2087
+ if (this._storage_available === null) {
2088
+ this._storage_available = this._is_storage_available();
2089
+ }
2090
+ if (!this._storage_available || !this._cache_key) {
2091
+ return;
2092
+ }
2093
+ // Validate scope
2094
+ this._validate_scope();
2095
+ this._initialized = true;
2096
+ }
2097
+ /**
2098
+ * Check if localStorage is available
2099
+ * @returns {boolean}
2100
+ * @private
2101
+ */
2102
+ static _is_storage_available() {
2103
+ try {
2104
+ const storage = window.localStorage;
2105
+ const test = '__jqhtml_storage_test__';
2106
+ storage.setItem(test, test);
2107
+ storage.removeItem(test);
2108
+ return true;
2109
+ }
2110
+ catch (e) {
2111
+ return false;
2112
+ }
2113
+ }
2114
+ /**
2115
+ * Check if verbose mode is enabled
2116
+ * @private
2117
+ */
2118
+ static _is_verbose() {
2119
+ return window.jqhtml?.debug?.verbose === true;
2120
+ }
2121
+ /**
2122
+ * Validate storage scope and clear JQHTML keys if cache key changed
2123
+ * Only clears keys prefixed with 'jqhtml::' to preserve other libraries' data
2124
+ * @private
2125
+ */
2126
+ static _validate_scope() {
2127
+ if (!this._storage_available) {
2128
+ return;
2129
+ }
2130
+ try {
2131
+ const stored_key = localStorage.getItem('_jqhtml_cache_key');
2132
+ // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2133
+ if (stored_key !== null && stored_key !== this._scope_marker) {
2134
+ console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
2135
+ old_key: stored_key,
2136
+ new_key: this._scope_marker,
2137
+ });
2138
+ this._clear_jqhtml_keys();
2139
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2140
+ }
2141
+ else if (stored_key === null) {
2142
+ // First time JQHTML is using this storage - just set the key, don't clear
2143
+ console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2144
+ new_key: this._scope_marker,
2145
+ });
2146
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2147
+ }
2148
+ }
2149
+ catch (e) {
2150
+ console.error('[JQHTML Local Storage] Failed to validate scope:', e);
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Clear only JQHTML keys from storage (keys starting with 'jqhtml::')
2155
+ * Preserves keys from other libraries
2156
+ * @private
2157
+ */
2158
+ static _clear_jqhtml_keys() {
2159
+ if (!this._storage_available) {
2160
+ return;
2161
+ }
2162
+ const keys_to_remove = [];
2163
+ // Collect all JQHTML keys
2164
+ for (let i = 0; i < localStorage.length; i++) {
2165
+ const key = localStorage.key(i);
2166
+ if (key && key.startsWith('jqhtml::')) {
2167
+ keys_to_remove.push(key);
2168
+ }
2169
+ }
2170
+ // Remove collected keys
2171
+ keys_to_remove.forEach(key => {
2172
+ try {
2173
+ localStorage.removeItem(key);
2174
+ }
2175
+ catch (e) {
2176
+ console.error('[JQHTML Local Storage] Failed to remove key:', key, e);
2177
+ }
2178
+ });
2179
+ console.log(`[JQHTML Local Storage] Cleared ${keys_to_remove.length} JQHTML keys`);
2180
+ }
2181
+ /**
2182
+ * Build scoped key with JQHTML namespace prefix
2183
+ * @param {string} key - Developer-provided key
2184
+ * @returns {string}
2185
+ * @private
2186
+ */
2187
+ static _build_key(key) {
2188
+ return `jqhtml::${key}::${this._cache_key}`;
2189
+ }
2190
+ /**
2191
+ * Check if storage is ready for use
2192
+ * @returns {boolean}
2193
+ * @private
2194
+ */
2195
+ static _is_ready() {
2196
+ return this._storage_available === true && this._cache_key !== null && this._initialized;
2197
+ }
2198
+ /**
2199
+ * Set item in localStorage with class-aware serialization.
2200
+ *
2201
+ * If serialization fails (e.g., unregistered class instances, circular refs),
2202
+ * the existing cache entry is removed and nothing is stored.
2203
+ *
2204
+ * @param {string} key - Storage key
2205
+ * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2206
+ */
2207
+ static set(key, value) {
2208
+ if (!this._is_ready()) {
2209
+ return;
2210
+ }
2211
+ const verbose = this._is_verbose();
2212
+ const scoped_key = this._build_key(key);
2213
+ // Serialize with class-aware processing
2214
+ const serialized = serialize_value(value, verbose);
2215
+ if (serialized === null) {
2216
+ // Serialization failed - remove any existing cache entry
2217
+ if (verbose) {
2218
+ console.warn(`[JQHTML Cache] Failed to serialize value for key "${key}". ` +
2219
+ `Removing existing cache entry if present.`);
2220
+ }
2221
+ try {
2222
+ localStorage.removeItem(scoped_key);
2223
+ }
2224
+ catch (e) {
2225
+ // Ignore removal errors
2226
+ }
2227
+ return;
2228
+ }
2229
+ // Check size before attempting to store (1MB limit)
2230
+ const size_bytes = new Blob([serialized]).size;
2231
+ const size_mb = size_bytes / (1024 * 1024);
2232
+ if (size_mb > 1) {
2233
+ if (verbose) {
2234
+ console.warn(`[JQHTML Cache] Skipping set - value too large (${size_mb.toFixed(2)}MB > 1MB limit)`, { key, size_bytes, size_mb });
2235
+ }
2236
+ // Remove existing cache entry since we can't update it
2237
+ try {
2238
+ localStorage.removeItem(scoped_key);
2239
+ }
2240
+ catch (e) {
2241
+ // Ignore removal errors
2242
+ }
2243
+ return;
2244
+ }
2245
+ this._set_item(key, serialized);
2246
+ }
2247
+ /**
2248
+ * Get item from localStorage with class-aware deserialization.
2249
+ *
2250
+ * If deserialization fails, returns null (as if no cache exists).
2251
+ *
2252
+ * @param {string} key - Storage key
2253
+ * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2254
+ */
2255
+ static get(key) {
2256
+ if (!this._is_ready()) {
2257
+ return null;
2258
+ }
2259
+ const verbose = this._is_verbose();
2260
+ const scoped_key = this._build_key(key);
2261
+ try {
2262
+ const serialized = localStorage.getItem(scoped_key);
2263
+ if (serialized === null) {
2264
+ return null;
2265
+ }
2266
+ const result = deserialize_value(serialized, verbose);
2267
+ if (result === null) {
2268
+ // Deserialization failed - treat as cache miss
2269
+ if (verbose) {
2270
+ console.warn(`[JQHTML Cache] Failed to deserialize value for key "${key}". ` +
2271
+ `Treating as cache miss.`);
2272
+ }
2273
+ return null;
2274
+ }
2275
+ return result;
2276
+ }
2277
+ catch (e) {
2278
+ if (verbose) {
2279
+ console.warn('[JQHTML Cache] Failed to get item:', e);
2280
+ }
2281
+ return null;
2282
+ }
2283
+ }
2284
+ /**
2285
+ * Remove item from localStorage
2286
+ * @param {string} key - Storage key
2287
+ */
2288
+ static remove(key) {
2289
+ if (!this._is_ready()) {
2290
+ return;
2291
+ }
2292
+ this._remove_item(key);
2293
+ }
2294
+ /**
2295
+ * Serialize a value to its class-aware TEXT form - the same text set() stores.
2296
+ *
2297
+ * This is the copy-by-value primitive the rest of the framework uses whenever
2298
+ * data has to cross from one component to another (load-coordinator.ts hands a
2299
+ * leader's data to its followers through it). Plain JSON.stringify is not a
2300
+ * substitute: it consults toJSON() and produces a wire form that deserialize()
2301
+ * cannot recognise, so a registered class would come back as a plain object.
2302
+ *
2303
+ * @returns The text, or null when the value as a whole cannot be expressed.
2304
+ */
2305
+ static serialize(value) {
2306
+ return serialize_value(value, this._is_verbose());
2307
+ }
2308
+ /**
2309
+ * Rebuild a value from text produced by serialize(), restoring Date, Map, Set
2310
+ * and every register_cache_class() class. Returns null when the text is not
2311
+ * restorable (a registered from_plain threw at the root) - the same signal
2312
+ * get() gives for a corrupt entry.
2313
+ */
2314
+ static deserialize(text) {
2315
+ return deserialize_value(text, this._is_verbose());
2316
+ }
2317
+ /**
2318
+ * Perform a serialize/deserialize round-trip on a value.
2319
+ *
2320
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2321
+ * is aliased through, and a value the serializer cannot express is dropped
2322
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2323
+ * came from on_load() or from a cache hit - the old "on failure return the
2324
+ * original" branch broke exactly that promise, and only on the hard cases.
2325
+ *
2326
+ * @param {any} value - The value to normalize
2327
+ * @param {string} [component_name] - Names the component in development warnings.
2328
+ * Omit to convert silently (the unit tests and non-component callers).
2329
+ * @returns {any} The round-tripped value. Never the original object.
2330
+ */
2331
+ static normalize_for_cache(value, component_name) {
2332
+ const verbose = this._is_verbose();
2333
+ const collector = make_strip_collector(component_name);
2334
+ // Serialize to JSON string
2335
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2336
+ if (serialized === null) {
2337
+ // The value as a WHOLE could not be expressed (it was itself a function,
2338
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2339
+ // returning the original would put an uncacheable object into this.data.
2340
+ return {};
2341
+ }
2342
+ // Deserialize back to object
2343
+ const deserialized = deserialize_value(serialized, verbose);
2344
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2345
+ // that legitimately serialized to the text `null` also comes back as null. The
2346
+ // serialized text disambiguates.
2347
+ if (deserialized === null && serialized !== 'null') {
2348
+ // Only reachable when a registered class constructor blew up on restore.
2349
+ return {};
2350
+ }
2351
+ return deserialized;
2352
+ }
2353
+ /**
2354
+ * Internal set implementation with scope validation and quota handling
2355
+ * @param {string} key
2356
+ * @param {string} serialized - Pre-serialized JSON string
2357
+ * @private
2358
+ */
2359
+ static _set_item(key, serialized) {
2360
+ // Validate scope before every write
2361
+ this._validate_scope();
2362
+ const scoped_key = this._build_key(key);
2363
+ try {
2364
+ localStorage.setItem(scoped_key, serialized);
2365
+ }
2366
+ catch (e) {
2367
+ // Check if quota exceeded
2368
+ if (e.name === 'QuotaExceededError' || e.code === 22) {
2369
+ console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2370
+ // Clear only JQHTML keys and retry once
2371
+ this._clear_jqhtml_keys();
2372
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2373
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2374
+ // made the very next write look like a scope change and wipe the cache again.
2375
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2376
+ try {
2377
+ localStorage.setItem(scoped_key, serialized);
2378
+ }
2379
+ catch (retry_error) {
2380
+ console.error('[JQHTML Local Storage] Failed to set item after clearing JQHTML keys:', retry_error);
2381
+ }
2382
+ }
2383
+ else {
2384
+ console.error('[JQHTML Local Storage] Failed to set item:', e);
2385
+ }
2386
+ }
2387
+ }
2388
+ /**
2389
+ * Internal remove implementation
2390
+ * @param {string} key
2391
+ * @private
2392
+ */
2393
+ static _remove_item(key) {
2394
+ const scoped_key = this._build_key(key);
2395
+ try {
2396
+ localStorage.removeItem(scoped_key);
2397
+ }
2398
+ catch (e) {
2399
+ console.error('[JQHTML Local Storage] Failed to remove item:', e);
2400
+ }
2401
+ }
2402
+ }
2403
+ Jqhtml_Local_Storage._cache_key = null;
2404
+ /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2405
+ Jqhtml_Local_Storage._scope_marker = null;
2406
+ Jqhtml_Local_Storage._cache_mode = 'data';
2407
+ Jqhtml_Local_Storage._storage_available = null;
2408
+ Jqhtml_Local_Storage._initialized = false;
2409
+
2410
+ /**
2411
+ * Load Coordinator - Request deduplication for component on_load() calls
2412
+ *
2413
+ * Coordinates parallel component loading to prevent duplicate requests.
2414
+ * When multiple components with identical names and args load simultaneously,
2415
+ * only the first (leader) executes on_load(). Others (followers) wait for
2416
+ * the leader's result.
2417
+ *
2418
+ * Key Concepts:
2419
+ * - **INVOCATION_KEY**: Unique identifier for component name + args combination
2420
+ * - **Leader**: First component to reach on_load() for a given INVOCATION_KEY
2421
+ * - **Follower**: Subsequent components that wait for leader's result
2422
+ *
2423
+ * Lifecycle:
2424
+ * 1. Leader reaches on_load() → create coordination entry
2425
+ * 2. Followers reach on_load() → join waiting queue
2426
+ * 3. Leader completes → distribute data to all followers
2427
+ * 4. Clear coordination entry (no caching)
2428
+ *
2429
+ * The hand-off is a copy, and it is made by the cache serializer
2430
+ * (Jqhtml_Local_Storage.serialize()/deserialize()), never by JSON.stringify.
2431
+ * A follower never runs on_load() and never revalidates, so whatever it is
2432
+ * handed is final: the copy has to rebuild Date, Map, Set and every
2433
+ * register_cache_class() instance exactly as the leader's this.data holds them.
2434
+ * JSON.stringify would consult toJSON() and hand out a wire form that
2435
+ * normalize_for_cache() cannot recognise - the follower would then render
2436
+ * against plain objects while the leader renders against instances.
2437
+ *
2438
+ * @internal This class is not exposed in the public API
2439
+ */
2440
+ class Load_Coordinator {
2441
+ /**
2442
+ * Generate INVOCATION_KEY from component name and args
2443
+ * Uses deterministic JSON serialization (sorted keys)
2444
+ * Excludes internal properties (those starting with _)
2445
+ *
2446
+ * For functions/objects:
2447
+ * - Checks for ._jqhtml_cache_id property (assigned by RSpade)
2448
+ * - Falls back to .jqhtml_cache_id() method if property doesn't exist
2449
+ * - If neither exists, marks property as uncacheable
2450
+ *
2451
+ * Returns object with:
2452
+ * - key: Cache key string, or null if uncacheable
2453
+ * - uncacheable_property: Name of first property that prevented caching (for debugging)
2454
+ */
2455
+ static generate_invocation_key(component_name, args, options = {}) {
2456
+ let uncacheable_property;
2457
+ let uncacheable_reason;
2458
+ // Filter out internal properties (starting with _) and serialize args
2459
+ const serializable_args = {};
2460
+ for (const key of Object.keys(args).sort()) {
2461
+ if (key.startsWith('_')) {
2462
+ continue; // Skip internal properties
2463
+ }
2464
+ // Skip framework properties that shouldn't affect cache identity
2465
+ // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
2466
+ if (key === 'use_cached_data') {
2467
+ continue;
2468
+ }
2469
+ const value = args[key];
2470
+ const value_type = typeof value;
2471
+ // Handle primitives (string, number, boolean, null, undefined)
2472
+ if (value === null || value === undefined ||
2473
+ value_type === 'string' || value_type === 'number' ||
2474
+ value_type === 'boolean') {
2475
+ serializable_args[key] = value;
2476
+ continue;
2477
+ }
2478
+ // Handle functions and objects
2479
+ if (value_type === 'function' || value_type === 'object') {
2480
+ // Check for ._jqhtml_cache_id property (fastest, what RSpade assigns)
2481
+ if (value._jqhtml_cache_id !== undefined) {
2482
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(value._jqhtml_cache_id)}`;
2483
+ continue;
2484
+ }
2485
+ // Check for .jqhtml_cache_id() method (more flexible, for custom objects)
2486
+ if (typeof value.jqhtml_cache_id === 'function') {
2487
+ try {
2488
+ const cache_id = value.jqhtml_cache_id();
2489
+ serializable_args[key] = `__JQHTML_CACHE_ID__:${String(cache_id)}`;
2490
+ continue;
2491
+ }
2492
+ catch (error) {
2493
+ // Method threw error - treat as uncacheable
2494
+ if (!uncacheable_property) {
2495
+ uncacheable_property = key;
2496
+ uncacheable_reason = 'cache-id-threw';
2497
+ }
2498
+ return { key: null, uncacheable_property, uncacheable_reason };
2499
+ }
2500
+ }
2501
+ // No author-supplied id. The cache may fall back to keying by
2502
+ // deterministic content; deduplication deliberately may not.
2503
+ if (options.allow_content_serialization) {
2504
+ const serialized = serialize_for_cache_key(value);
2505
+ if (serialized.ok) {
2506
+ serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
2507
+ continue;
2508
+ }
2509
+ if (!uncacheable_property) {
2510
+ uncacheable_property = key;
2511
+ // This package compiles with strictNullChecks off, and without it
2512
+ // TypeScript will not narrow a discriminated union by its boolean
2513
+ // discriminant - so `serialized` is still the full Serialize_Result
2514
+ // here even though `ok` has been ruled out above. The cast states
2515
+ // what the control flow already guarantees.
2516
+ uncacheable_reason = serialized.reason;
2517
+ }
2518
+ return { key: null, uncacheable_property, uncacheable_reason };
2519
+ }
2520
+ if (!uncacheable_property) {
2521
+ uncacheable_property = key;
2522
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
2523
+ }
2524
+ return { key: null, uncacheable_property, uncacheable_reason };
2525
+ }
2526
+ // Unknown type (symbol, bigint, etc.) - uncacheable
2527
+ if (!uncacheable_property) {
2528
+ uncacheable_property = key;
2529
+ uncacheable_reason = value_type;
2530
+ }
2531
+ return { key: null, uncacheable_property, uncacheable_reason };
2288
2532
  }
2289
- }
2290
- // Plain object - process recursively
2291
- const result = {};
2292
- for (const key of Object.keys(value)) {
2293
- result[key] = process_for_deserialization(value[key], verbose);
2294
- }
2295
- return result;
2296
- }
2297
- class Jqhtml_Local_Storage {
2298
- /**
2299
- * Set the cache key and initialize storage
2300
- * Must be called before any get/set operations
2301
- * @param {string} cache_key - Unique identifier for this cache scope
2302
- * @param {CacheMode} cache_mode - Cache strategy: 'data' (default, recommended) or 'html'
2303
- */
2304
- static set_cache_key(cache_key, cache_mode = 'data') {
2305
- this._cache_key = cache_key;
2306
- // Key SHAPE is a function of the library (see cache-key-serializer.ts), so an
2307
- // upgrade that changes it must invalidate everything persisted under the old shape.
2308
- // Version the SCOPE MARKER rather than the key itself: _validate_scope() already
2309
- // clears every jqhtml key when this value changes, and leaving the per-entry key
2310
- // format alone keeps stored keys readable and avoids repeating the version in each.
2311
- this._scope_marker = `${CORE_VERSION}::${cache_key}`;
2312
- this._cache_mode = cache_mode;
2313
- this._init();
2314
- }
2315
- /**
2316
- * Check if cache key has been set
2317
- * @returns {boolean} True if cache key is configured
2318
- */
2319
- static has_cache_key() {
2320
- return this._cache_key !== null;
2321
- }
2322
- /**
2323
- * Get the current cache mode
2324
- * @returns {CacheMode} Current cache mode ('data' or 'html')
2325
- */
2326
- static get_cache_mode() {
2327
- return this._cache_mode;
2328
- }
2329
- /**
2330
- * Initialize storage system and validate scope
2331
- * Called automatically after cache key is set
2332
- * @private
2333
- */
2334
- static _init() {
2335
- // Check storage availability
2336
- if (this._storage_available === null) {
2337
- this._storage_available = this._is_storage_available();
2533
+ // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
2534
+ try {
2535
+ const sorted_args = JSON.stringify(serializable_args);
2536
+ return { key: `${component_name}::${sorted_args}` };
2338
2537
  }
2339
- if (!this._storage_available || !this._cache_key) {
2340
- return;
2538
+ catch (error) {
2539
+ // Serialization failed unexpectedly
2540
+ return { key: null, uncacheable_property };
2341
2541
  }
2342
- // Validate scope
2343
- this._validate_scope();
2344
- this._initialized = true;
2345
2542
  }
2346
2543
  /**
2347
- * Check if localStorage is available
2348
- * @returns {boolean}
2349
- * @private
2544
+ * Check if a component should execute on_load() or wait for existing request
2545
+ * Returns true if component should execute (is leader), false if should wait (is follower)
2546
+ *
2547
+ * @param key - The dedup key the caller captured for this load. Every coordinator
2548
+ * method takes it, because args may legally change during on_load()
2549
+ * and a recomputed key would address a different (or no) entry.
2350
2550
  */
2351
- static _is_storage_available() {
2352
- try {
2353
- const storage = window.localStorage;
2354
- const test = '__jqhtml_storage_test__';
2355
- storage.setItem(test, test);
2356
- storage.removeItem(test);
2551
+ static should_execute_on_load(component, key) {
2552
+ const entry = this._registry.get(key);
2553
+ if (!entry) {
2554
+ // No existing request - this component becomes the leader
2357
2555
  return true;
2358
2556
  }
2359
- catch (e) {
2557
+ if (entry.status === 'loading') {
2558
+ // Request in progress - this component becomes a follower
2559
+ entry.waiting.push(component);
2360
2560
  return false;
2361
2561
  }
2562
+ // Entry exists but completed/failed - should have been cleaned up
2563
+ // Treat as new leader
2564
+ return true;
2362
2565
  }
2363
2566
  /**
2364
- * Check if verbose mode is enabled
2365
- * @private
2366
- */
2367
- static _is_verbose() {
2368
- return window.jqhtml?.debug?.verbose === true;
2369
- }
2370
- /**
2371
- * Validate storage scope and clear JQHTML keys if cache key changed
2372
- * Only clears keys prefixed with 'jqhtml::' to preserve other libraries' data
2373
- * @private
2374
- */
2375
- static _validate_scope() {
2376
- if (!this._storage_available) {
2377
- return;
2378
- }
2379
- try {
2380
- const stored_key = localStorage.getItem('_jqhtml_cache_key');
2381
- // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2382
- if (stored_key !== null && stored_key !== this._scope_marker) {
2383
- console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
2384
- old_key: stored_key,
2385
- new_key: this._scope_marker,
2386
- });
2387
- this._clear_jqhtml_keys();
2388
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2389
- }
2390
- else if (stored_key === null) {
2391
- // First time JQHTML is using this storage - just set the key, don't clear
2392
- console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2393
- new_key: this._scope_marker,
2394
- });
2395
- localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2396
- }
2397
- }
2398
- catch (e) {
2399
- console.error('[JQHTML Local Storage] Failed to validate scope:', e);
2400
- }
2401
- }
2402
- /**
2403
- * Clear only JQHTML keys from storage (keys starting with 'jqhtml::')
2404
- * Preserves keys from other libraries
2405
- * @private
2567
+ * Register a leader component that will execute on_load()
2568
+ * Creates coordination entry and returns a function to call when data is ready
2569
+ *
2570
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
2571
+ * @returns A function that accepts the final data and completes coordination
2406
2572
  */
2407
- static _clear_jqhtml_keys() {
2408
- if (!this._storage_available) {
2409
- return;
2410
- }
2411
- const keys_to_remove = [];
2412
- // Collect all JQHTML keys
2413
- for (let i = 0; i < localStorage.length; i++) {
2414
- const key = localStorage.key(i);
2415
- if (key && key.startsWith('jqhtml::')) {
2416
- keys_to_remove.push(key);
2417
- }
2418
- }
2419
- // Remove collected keys
2420
- keys_to_remove.forEach(key => {
2421
- try {
2422
- localStorage.removeItem(key);
2423
- }
2424
- catch (e) {
2425
- console.error('[JQHTML Local Storage] Failed to remove key:', key, e);
2426
- }
2573
+ static register_leader(component, key) {
2574
+ // Create a promise that we control - it settles when coordination completes or fails
2575
+ let resolve_promise;
2576
+ let reject_promise;
2577
+ const coordination_promise = new Promise((resolve, reject) => {
2578
+ resolve_promise = resolve;
2579
+ reject_promise = reject;
2427
2580
  });
2428
- console.log(`[JQHTML Local Storage] Cleared ${keys_to_remove.length} JQHTML keys`);
2429
- }
2430
- /**
2431
- * Build scoped key with JQHTML namespace prefix
2432
- * @param {string} key - Developer-provided key
2433
- * @returns {string}
2434
- * @private
2435
- */
2436
- static _build_key(key) {
2437
- return `jqhtml::${key}::${this._cache_key}`;
2581
+ // A leader with no followers still has to be able to reject this promise on
2582
+ // error; without a handler that rejection surfaces as an unhandled rejection.
2583
+ coordination_promise.catch(() => { });
2584
+ const entry = {
2585
+ status: 'loading',
2586
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
2587
+ resolve_promise,
2588
+ reject_promise,
2589
+ leader_component: component,
2590
+ leader_data: null,
2591
+ waiting: []
2592
+ };
2593
+ this._registry.set(key, entry);
2594
+ // Return cleanup function that accepts the final data
2595
+ return (final_data) => this._complete_coordination(key, component, final_data);
2438
2596
  }
2439
2597
  /**
2440
- * Check if storage is ready for use
2441
- * @returns {boolean}
2442
- * @private
2598
+ * Get the coordination promise for a follower component
2599
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
2443
2600
  */
2444
- static _is_ready() {
2445
- return this._storage_available === true && this._cache_key !== null && this._initialized;
2601
+ static get_coordination_promise(key) {
2602
+ const entry = this._registry.get(key);
2603
+ if (!entry || entry.status !== 'loading') {
2604
+ return null;
2605
+ }
2606
+ return entry.promise;
2446
2607
  }
2447
2608
  /**
2448
- * Set item in localStorage with class-aware serialization.
2449
- *
2450
- * If serialization fails (e.g., unregistered class instances, circular refs),
2451
- * the existing cache entry is removed and nothing is stored.
2609
+ * Complete coordination after leader's on_load() finishes
2610
+ * Stores leader's data, marks entry as completed, and resolves the promise.
2611
+ * Followers retrieve the data themselves via get_leader_data().
2452
2612
  *
2453
- * @param {string} key - Storage key
2454
- * @param {*} value - Value to store (primitives, objects, arrays, or registered class instances)
2613
+ * @param key - The coordination key captured at registration
2614
+ * @param leader - The leader component
2615
+ * @param final_data - The final data after on_load and normalization
2616
+ * @private
2455
2617
  */
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);
2472
- }
2473
- catch (e) {
2474
- // Ignore removal errors
2475
- }
2618
+ static _complete_coordination(key, leader, final_data) {
2619
+ const entry = this._registry.get(key);
2620
+ if (!entry) {
2476
2621
  return;
2477
2622
  }
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 });
2484
- }
2485
- // Remove existing cache entry since we can't update it
2486
- try {
2487
- localStorage.removeItem(scoped_key);
2488
- }
2489
- catch (e) {
2490
- // Ignore removal errors
2491
- }
2492
- return;
2623
+ // Stored as TEXT so each follower rebuilds its own object graph - sharing one
2624
+ // decoded object would let one component's mutation reach every other.
2625
+ // The cache serializer, not JSON.stringify: this.data holds live Date/Map/Set
2626
+ // and register_cache_class() instances, and only the serializer that made
2627
+ // them knows how to carry them across. null means the data as a whole could
2628
+ // not be expressed; followers then load independently.
2629
+ entry.leader_data = Jqhtml_Local_Storage.serialize(final_data);
2630
+ entry.status = 'completed';
2631
+ if (window.jqhtml?.debug?.verbose) {
2632
+ console.log(`[Load Coordinator] Leader ${leader._cid} completed, data available for ${entry.waiting.length} followers`, { key, data: entry.leader_data });
2633
+ }
2634
+ // Resolve the promise - followers waiting on it will now wake up
2635
+ entry.resolve_promise();
2636
+ // Nobody is waiting, so nothing will ever come back to collect the data.
2637
+ // Without this the registry grows once per distinct name+args, forever.
2638
+ if (entry.waiting.length === 0) {
2639
+ this._registry.delete(key);
2493
2640
  }
2494
- this._set_item(key, serialized);
2495
2641
  }
2496
2642
  /**
2497
- * Get item from localStorage with class-aware deserialization.
2498
- *
2499
- * If deserialization fails, returns null (as if no cache exists).
2643
+ * Get leader's data for a follower component
2644
+ * Called by follower after coordination promise resolves
2645
+ * Returns a freshly parsed copy and cleans up if this is the last follower
2500
2646
  *
2501
- * @param {string} key - Storage key
2502
- * @returns {*|null} Deserialized value with class instances restored, or null if not found/failed
2647
+ * @param key - The dedup key captured by the follower before it began waiting
2503
2648
  */
2504
- static get(key) {
2505
- if (!this._is_ready()) {
2649
+ static get_leader_data(component, key) {
2650
+ const entry = this._registry.get(key);
2651
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
2506
2652
  return null;
2507
2653
  }
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;
2654
+ // Remove this follower from the waiting list
2655
+ const follower_index = entry.waiting.indexOf(component);
2656
+ if (follower_index !== -1) {
2657
+ entry.waiting.splice(follower_index, 1);
2525
2658
  }
2526
- catch (e) {
2527
- if (verbose) {
2528
- console.warn('[JQHTML Cache] Failed to get item:', e);
2529
- }
2530
- return null;
2659
+ // One deserialize per follower: each gets its OWN object graph, never a
2660
+ // shared reference, with the same instances the leader's this.data holds.
2661
+ const data = Jqhtml_Local_Storage.deserialize(entry.leader_data);
2662
+ if (window.jqhtml?.debug?.verbose) {
2663
+ console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
2531
2664
  }
2532
- }
2533
- /**
2534
- * Remove item from localStorage
2535
- * @param {string} key - Storage key
2536
- */
2537
- static remove(key) {
2538
- if (!this._is_ready()) {
2539
- return;
2665
+ // If this was the last follower, clean up the entry
2666
+ if (entry.waiting.length === 0) {
2667
+ this._registry.delete(key);
2668
+ if (window.jqhtml?.debug?.verbose) {
2669
+ console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
2670
+ }
2540
2671
  }
2541
- this._remove_item(key);
2672
+ return data;
2542
2673
  }
2543
2674
  /**
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.
2675
+ * Handle leader on_load() error
2676
+ * Rejects every waiting follower and clears the entry so the next component retries
2551
2677
  *
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.
2678
+ * @param key - The dedup key captured at registration
2556
2679
  */
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 {};
2680
+ static handle_leader_error(key, error) {
2681
+ const entry = this._registry.get(key);
2682
+ if (!entry) {
2683
+ return;
2567
2684
  }
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 {};
2685
+ entry.status = 'failed';
2686
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
2687
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
2688
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
2689
+ // without rejecting left them awaiting a promise nobody could ever settle.
2690
+ entry.reject_promise(error);
2691
+ // Clear coordination entry so future requests can retry
2692
+ this._registry.delete(key);
2693
+ if (window.jqhtml?.debug?.verbose) {
2694
+ console.log(`[Load Coordinator] Cleared failed coordination for key: ${key}`, { followers_count: entry.waiting.length });
2576
2695
  }
2577
- return deserialized;
2578
2696
  }
2579
2697
  /**
2580
- * Internal set implementation with scope validation and quota handling
2581
- * @param {string} key
2582
- * @param {string} serialized - Pre-serialized JSON string
2583
- * @private
2698
+ * Get current registry state (for debugging)
2584
2699
  */
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);
2591
- }
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
- }
2700
+ static get_registry_state() {
2701
+ const state = {};
2702
+ for (const [key, entry] of this._registry.entries()) {
2703
+ state[key] = {
2704
+ status: entry.status,
2705
+ leader_cid: entry.leader_component._cid,
2706
+ waiting_count: entry.waiting.length,
2707
+ waiting_cids: entry.waiting.map(c => c._cid)
2708
+ };
2612
2709
  }
2710
+ return state;
2613
2711
  }
2614
2712
  /**
2615
- * Internal remove implementation
2616
- * @param {string} key
2617
- * @private
2713
+ * Clear all coordination entries (for testing/debugging)
2618
2714
  */
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);
2626
- }
2715
+ static clear_all() {
2716
+ this._registry.clear();
2627
2717
  }
2628
2718
  }
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;
2719
+ Load_Coordinator._registry = new Map();
2635
2720
 
2636
2721
  /**
2637
2722
  * JQHTML Component Event System
@@ -3093,6 +3178,85 @@ async function execute_on_load_detached(component, dedup_key = null) {
3093
3178
  };
3094
3179
  }
3095
3180
 
3181
+ /**
3182
+ * JQHTML DOM Attachment Tracking
3183
+ *
3184
+ * Backs the component 'attach' / 'detach' lifecycle events.
3185
+ *
3186
+ * One document-level MutationObserver (childList + subtree) is installed the first
3187
+ * time any component expresses interest in attachment, and disconnected when the
3188
+ * last interested component is stopped or collected. The observer never inspects
3189
+ * mutation records: it is only a "the tree changed" tick. On each batch it walks
3190
+ * the tracked components and asks each to compare its root's isConnected against
3191
+ * its own recorded attachment state (Jqhtml_Component._attachment_check()). Cost is
3192
+ * O(tracked components) per mutation batch, independent of how much of the page
3193
+ * changed, and zero when nothing has opted in.
3194
+ *
3195
+ * Because MutationObserver delivers per microtask, a node moved synchronously
3196
+ * (remove + insert in one task, e.g. appendTo) never shows a transition. Only a
3197
+ * component whose root is actually off-document when the batch flushes is detached.
3198
+ *
3199
+ * Tracked components are held by WeakRef. A component that is removed from the
3200
+ * document natively (never stopped) and then dropped by everything else is therefore
3201
+ * collectable: the tracker pins nothing. Dead refs are pruned on the next batch.
3202
+ */
3203
+ const tracked = new Set();
3204
+ const ref_of = new WeakMap();
3205
+ let observer = null;
3206
+ function on_mutation() {
3207
+ for (const ref of tracked) {
3208
+ const component = ref.deref();
3209
+ if (!component || component._stopped) {
3210
+ tracked.delete(ref);
3211
+ continue;
3212
+ }
3213
+ component._attachment_check();
3214
+ }
3215
+ if (tracked.size === 0)
3216
+ disconnect();
3217
+ }
3218
+ function connect() {
3219
+ if (observer !== null)
3220
+ return;
3221
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined')
3222
+ return;
3223
+ observer = new MutationObserver(on_mutation);
3224
+ observer.observe(document.documentElement, { childList: true, subtree: true });
3225
+ }
3226
+ function disconnect() {
3227
+ if (observer === null)
3228
+ return;
3229
+ observer.disconnect();
3230
+ observer = null;
3231
+ }
3232
+ /** Start watching a component's root for attach/detach transitions. Idempotent. */
3233
+ function track_attachment(component) {
3234
+ if (ref_of.has(component))
3235
+ return;
3236
+ const ref = new WeakRef(component);
3237
+ ref_of.set(component, ref);
3238
+ tracked.add(ref);
3239
+ connect();
3240
+ }
3241
+ /** Stop watching a component (called from _stop()). Idempotent. */
3242
+ function untrack_attachment(component) {
3243
+ const ref = ref_of.get(component);
3244
+ if (!ref)
3245
+ return;
3246
+ ref_of.delete(component);
3247
+ tracked.delete(ref);
3248
+ if (tracked.size === 0)
3249
+ disconnect();
3250
+ }
3251
+ /** Number of components currently tracked (diagnostics / tests). */
3252
+ function tracked_attachment_count() {
3253
+ return tracked.size;
3254
+ }
3255
+ /** Whether the document observer is currently installed (diagnostics / tests). */
3256
+ function attachment_observer_active() {
3257
+ return observer !== null;
3258
+ }
3259
+
3096
3260
  /**
3097
3261
  * JQHTML Component Cache Integration
3098
3262
  *
@@ -4001,6 +4165,12 @@ class Jqhtml_Component {
4001
4165
  this.__load_gates = []; // Pending gates (cleared once the first load begins)
4002
4166
  this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
4003
4167
  this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
4168
+ // DOM attachment state machine - see _attachment_check() / dom-attachment.ts.
4169
+ // _attached is the component's OWN belief about whether it is in the document;
4170
+ // it flips at most once per direction, and 'attach' / 'detach' fire only on a flip.
4171
+ this._attached = false;
4172
+ this._attachment_checked = false; // First check has run (after the first 'ready' dispatch)
4173
+ this._attachment_tracking = false; // Something is interested: on_attach/on_detach overridden, or .on('attach'|'detach')
4004
4174
  // Detect custom on_load() override immediately
4005
4175
  // Prototype chain is set up before constructor runs, so this.on_load
4006
4176
  // correctly resolves to child class override if one exists
@@ -4074,6 +4244,12 @@ class Jqhtml_Component {
4074
4244
  // Initialize this.state as an empty object (convention for arbitrary component state)
4075
4245
  // No special meaning to framework - mutable anywhere, not cached, not frozen
4076
4246
  this.state = {};
4247
+ // Overriding on_attach()/on_detach() is interest in attachment tracking. Tracking
4248
+ // itself only starts after the first 'ready' dispatch (see _attachment_check()).
4249
+ if (this.on_attach !== Jqhtml_Component.prototype.on_attach ||
4250
+ this.on_detach !== Jqhtml_Component.prototype.on_detach) {
4251
+ this._attachment_tracking = true;
4252
+ }
4077
4253
  this._log_lifecycle('construct', 'complete');
4078
4254
  }
4079
4255
  /**
@@ -4087,7 +4263,9 @@ class Jqhtml_Component {
4087
4263
  on_render: 'Use render() to trigger a re-render.',
4088
4264
  on_load: 'Use reload() to refresh data.',
4089
4265
  on_ready: 'Called automatically when ready.',
4090
- on_stop: 'Use stop() to stop the component.'
4266
+ on_stop: 'Use stop() to stop the component.',
4267
+ on_attach: 'Called automatically when the component enters the document.',
4268
+ on_detach: 'Called automatically when the component leaves the document.'
4091
4269
  };
4092
4270
  const impls = {};
4093
4271
  const self = this;
@@ -5441,8 +5619,13 @@ class Jqhtml_Component {
5441
5619
  // stopped children into the parent's registry.
5442
5620
  this._log_lifecycle('destroy', 'start');
5443
5621
  this.$.addClass('_Component_Stopped');
5444
- // Unregister from lifecycle manager
5445
- this._lifecycle_manager.unregister_component(this);
5622
+ // A component that believes it is attached is detached first, so 'detach' always
5623
+ // precedes 'stop' and subscribers can rely on strict attach/detach alternation.
5624
+ // A component that never fired 'attach' (stopped before ready, or born and kept
5625
+ // off-document) fires no 'detach' either. Either way it leaves the tracker.
5626
+ if (this._attached)
5627
+ this._transition_attachment(false);
5628
+ untrack_attachment(this);
5446
5629
  // Call user's on_stop() hook with authorization (sync) - only when actually
5447
5630
  // overridden, since _call_lifecycle_sync() on the base no-op costs per child
5448
5631
  // on every re-render.
@@ -5487,6 +5670,89 @@ class Jqhtml_Component {
5487
5670
  on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
5488
5671
  async on_ready() { }
5489
5672
  on_stop() { }
5673
+ on_attach() { } // Sync. Root entered the document (first checked after 'ready')
5674
+ on_detach() { } // Sync. Root left the document, or the component is being stopped while attached
5675
+ // -------------------------------------------------------------------------
5676
+ // DOM attachment state machine
5677
+ // -------------------------------------------------------------------------
5678
+ /**
5679
+ * Whether this component currently considers itself attached to the document.
5680
+ *
5681
+ * This is the component's recorded state, not a live isConnected read: it is
5682
+ * false until the first check after 'ready', and from then on flips only when a
5683
+ * transition is observed (or on stop()). It is exactly the state 'attach' and
5684
+ * 'detach' have reported.
5685
+ */
5686
+ is_attached() {
5687
+ return this._attached;
5688
+ }
5689
+ /**
5690
+ * Flip the recorded state and fire the matching hook + event. The opposite
5691
+ * sticky event is invalidated first, so a late subscriber only ever sees the
5692
+ * current state. Hook errors are isolated; the event still fires.
5693
+ * @private
5694
+ */
5695
+ _transition_attachment(attached) {
5696
+ this._attached = attached;
5697
+ const event_name = attached ? 'attach' : 'detach';
5698
+ const hook_name = attached ? 'on_attach' : 'on_detach';
5699
+ this.invalidate(attached ? 'detach' : 'attach');
5700
+ if (this[hook_name] !== Jqhtml_Component.prototype[hook_name]) {
5701
+ try {
5702
+ const result = this._call_lifecycle_sync(hook_name);
5703
+ if (result && typeof result.then === 'function') {
5704
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from ${hook_name}(). ` +
5705
+ `${hook_name}() must be synchronous code. Remove 'async' from the function declaration.`);
5706
+ }
5707
+ }
5708
+ catch (error) {
5709
+ console.error(`[JQHTML] Error in ${hook_name}() for ${this.component_name()}:`, error);
5710
+ }
5711
+ }
5712
+ this.trigger(event_name);
5713
+ }
5714
+ /**
5715
+ * Compare the root's live connectivity with the recorded state and transition
5716
+ * on a difference. Idempotent. Called by the document observer on every
5717
+ * mutation batch for tracked components, and directly by the framework after
5718
+ * 'ready' and when interest is registered late.
5719
+ * @private
5720
+ */
5721
+ _attachment_check() {
5722
+ if (this._stopped)
5723
+ return;
5724
+ const connected = !!this.$[0].isConnected;
5725
+ if (connected !== this._attached)
5726
+ this._transition_attachment(connected);
5727
+ }
5728
+ /**
5729
+ * The first check, run once the 'ready' handlers have completed. Fires 'attach'
5730
+ * if the component was born attached, even with no listener, so a later
5731
+ * .on('attach') gets the sticky fire. Deferred interest starts tracking here.
5732
+ * @private
5733
+ */
5734
+ _attachment_first_check() {
5735
+ const first = !this._attachment_checked;
5736
+ this._attachment_checked = true;
5737
+ this._attachment_check();
5738
+ if (first && this._attachment_tracking)
5739
+ track_attachment(this);
5740
+ }
5741
+ /**
5742
+ * Register interest in attach/detach. Before the first ready check this only
5743
+ * records the interest; after it, tracking starts immediately and a check runs
5744
+ * at once, so a late subscriber on a natively-removed component sees 'detach'.
5745
+ * @private
5746
+ */
5747
+ _watch_attachment() {
5748
+ if (this._attachment_tracking)
5749
+ return;
5750
+ this._attachment_tracking = true;
5751
+ if (this._attachment_checked && !this._stopped) {
5752
+ track_attachment(this);
5753
+ this._attachment_check();
5754
+ }
5755
+ }
5490
5756
  /**
5491
5757
  * Called with the viewport width in CSS pixels:
5492
5758
  * - after every on_render()
@@ -5551,6 +5817,8 @@ class Jqhtml_Component {
5551
5817
  * @see component-events.ts for full documentation
5552
5818
  */
5553
5819
  on(event_name, callback) {
5820
+ if (event_name === 'attach' || event_name === 'detach')
5821
+ this._watch_attachment();
5554
5822
  return event_on(this, event_name, callback);
5555
5823
  }
5556
5824
  /**
@@ -5559,14 +5827,24 @@ class Jqhtml_Component {
5559
5827
  * @see component-events.ts for full documentation
5560
5828
  */
5561
5829
  once(event_name, callback) {
5830
+ if (event_name === 'attach' || event_name === 'detach')
5831
+ this._watch_attachment();
5562
5832
  return event_once(this, event_name, callback);
5563
5833
  }
5564
5834
  /**
5565
5835
  * Trigger a lifecycle event - delegates to component-events.ts
5566
5836
  * @see component-events.ts for full documentation
5837
+ *
5838
+ * 'ready' is special: once its handlers have run, the component runs its first
5839
+ * attachment check (and every later one, idempotently). Doing it here rather than
5840
+ * at each of the call sites that trigger 'ready' guarantees the order
5841
+ * on_ready() -> 'ready' handlers -> 'attach', so everything on_ready() set up is
5842
+ * complete before anyone learns the component is in the document.
5567
5843
  */
5568
5844
  trigger(event_name, data) {
5569
5845
  event_trigger(this, event_name, data);
5846
+ if (event_name === 'ready' && !this._stopped)
5847
+ this._attachment_first_check();
5570
5848
  }
5571
5849
  /**
5572
5850
  * Check if any callbacks are registered for a given event
@@ -7000,9 +7278,42 @@ function init_jquery_plugin(jQuery) {
7000
7278
  return jQuery(results);
7001
7279
  };
7002
7280
  // Store original jQuery methods
7003
- const originalEmpty = jQuery.fn.empty;
7004
7281
  const originalHtml = jQuery.fn.html;
7005
- const originalText = jQuery.fn.text;
7282
+ // ---------------------------------------------------------------------------
7283
+ // Stop components on every jQuery-driven removal by hooking jQuery.cleanData.
7284
+ //
7285
+ // jQuery routes .remove(), .empty(), .html(x), .text(x), .replaceWith() and
7286
+ // .replaceAll() through jQuery.cleanData(elems) to drop the data store and
7287
+ // handlers of every element about to leave the tree - and `elems` is already the
7288
+ // flattened subtree, in document order (parent before child). Stopping here is
7289
+ // therefore one classList check per removed element, no extra traversal, and
7290
+ // covers every jQuery removal path at once. It is the same hook jQuery UI's
7291
+ // widget factory uses for the same reason.
7292
+ //
7293
+ // It MUST be installed on the original jQuery object: jQuery's own code calls
7294
+ // cleanData through its closure-local reference, not through our wrapper. The
7295
+ // wrapper's copied static is updated below so both names agree.
7296
+ //
7297
+ // .detach() does NOT call cleanData, by jQuery's own design (it keeps data for
7298
+ // reinsertion), and native DOM removal (el.remove(), innerHTML = '') bypasses
7299
+ // jQuery entirely. Neither stops a component; the 'detach' lifecycle event is
7300
+ // the signal for those. Moving a component (.appendTo) is not a removal.
7301
+ // ---------------------------------------------------------------------------
7302
+ const originalCleanData = _jqhtml_original_jquery.cleanData;
7303
+ const cleanDataWithComponentStop = function (elems) {
7304
+ for (let i = 0; i < elems.length; i++) {
7305
+ const el = elems[i];
7306
+ if (el.nodeType !== 1 || !el.classList.contains('Component'))
7307
+ continue;
7308
+ const component = _jqhtml_original_jquery.data(el, '_component');
7309
+ if (component && !component._stopped) {
7310
+ component._stop(); // Not recursive: every descendant is in elems too
7311
+ }
7312
+ }
7313
+ return originalCleanData(elems);
7314
+ };
7315
+ _jqhtml_original_jquery.cleanData = cleanDataWithComponentStop;
7316
+ jQuery.cleanData = cleanDataWithComponentStop;
7006
7317
  // ---------------------------------------------------------------------------
7007
7318
  // Guard against hand-creating elements with data-sid via jQuery.
7008
7319
  //
@@ -7059,53 +7370,15 @@ function init_jquery_plugin(jQuery) {
7059
7370
  ` • <div class="MyComponent__${sid}"> → .find('.MyComponent__${sid}')`);
7060
7371
  };
7061
7372
  /**
7062
- * Override jQuery.fn.empty() to stop child components before clearing DOM
7063
- * This ensures component cleanup hooks fire when DOM is cleared
7064
- */
7065
- jQuery.fn.empty = function () {
7066
- return this.each(function () {
7067
- // Stop all child components before clearing DOM
7068
- jQuery(this).find('.Component').each(function () {
7069
- const component = jQuery(this).data('_component');
7070
- if (component && !component._stopped) {
7071
- component._stop(); // Stop just the component, DOM will be cleared anyway
7072
- }
7073
- });
7074
- // Call original empty
7075
- originalEmpty.call(jQuery(this));
7076
- });
7077
- };
7078
- /**
7079
- * Override jQuery.fn.html() to stop child components when setting new content
7080
- * When used as setter, calls .empty() first to properly cleanup components
7373
+ * Override jQuery.fn.html() setter to reject hand-written data-sid before the
7374
+ * DOM is mutated. Component stopping is handled by the cleanData hook above.
7081
7375
  */
7082
7376
  jQuery.fn.html = function (value) {
7083
- // Getter - just pass through
7084
7377
  if (arguments.length === 0) {
7085
7378
  return originalHtml.call(this);
7086
7379
  }
7087
- // Reject hand-written data-sid before mutating the DOM
7088
7380
  assert_no_manual_sid(value, '.html()');
7089
- // Setter - empty first (which stops components), then set content
7090
- return this.each(function () {
7091
- jQuery(this).empty();
7092
- originalHtml.call(jQuery(this), value);
7093
- });
7094
- };
7095
- /**
7096
- * Override jQuery.fn.text() to stop child components when setting new content
7097
- * When used as setter, calls .empty() first to properly cleanup components
7098
- */
7099
- jQuery.fn.text = function (value) {
7100
- // Getter - just pass through
7101
- if (arguments.length === 0) {
7102
- return originalText.call(this);
7103
- }
7104
- // Setter - empty first (which stops components), then set content
7105
- return this.each(function () {
7106
- jQuery(this).empty();
7107
- originalText.call(jQuery(this), value);
7108
- });
7381
+ return originalHtml.call(this, value);
7109
7382
  };
7110
7383
  // Store original jQuery.fn.on
7111
7384
  const originalOn = jQuery.fn.on;
@@ -7247,7 +7520,7 @@ function init(jQuery, config) {
7247
7520
  }
7248
7521
  }
7249
7522
  // Version - will be replaced during build with actual version from package.json
7250
- const version = '2.3.70';
7523
+ const version = '2.3.72';
7251
7524
  // Default export with all functionality
7252
7525
  const jqhtml = {
7253
7526
  // Core
@@ -7362,7 +7635,11 @@ const jqhtml = {
7362
7635
  get_captured_data,
7363
7636
  stop_data_capture,
7364
7637
  set_preload_data,
7365
- clear_preload_data
7638
+ clear_preload_data,
7639
+ // Attachment tracking diagnostics - how many components are watched for
7640
+ // attach/detach and whether the document observer is currently installed
7641
+ tracked_attachment_count,
7642
+ attachment_observer_active
7366
7643
  };
7367
7644
  // Auto-register on window for browser environments
7368
7645
  // This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -7401,6 +7678,7 @@ exports.LifecycleManager = LifecycleManager;
7401
7678
  exports.Load_Coordinator = Load_Coordinator;
7402
7679
  exports.add_object_printer = add_object_printer;
7403
7680
  exports.applyDebugDelay = applyDebugDelay;
7681
+ exports.attachment_observer_active = attachment_observer_active;
7404
7682
  exports.boot = boot;
7405
7683
  exports.clear_preload_data = clear_preload_data;
7406
7684
  exports.configure = configure;
@@ -7440,5 +7718,6 @@ exports.register_template = register_template;
7440
7718
  exports.set_preload_data = set_preload_data;
7441
7719
  exports.start_data_capture = start_data_capture;
7442
7720
  exports.stop_data_capture = stop_data_capture;
7721
+ exports.tracked_attachment_count = tracked_attachment_count;
7443
7722
  exports.version = version;
7444
7723
  //# sourceMappingURL=index.cjs.map