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