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