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