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