@jqhtml/core 2.3.50 → 2.3.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JQHTML Core v2.3.50
2
+ * JQHTML Core v2.3.54
3
3
  * (c) 2025 JQHTML Team
4
4
  * Released under the MIT License
5
5
  */
@@ -501,6 +501,70 @@ function register(source) {
501
501
  'class name mangling, define static component_name or use register_component().');
502
502
  }
503
503
 
504
+ /**
505
+ * JQHTML Runtime Configuration
506
+ *
507
+ * Integration-level settings supplied by the host application (RSpade, Laravel,
508
+ * a bundler entry point) when jqhtml is loaded. Distinct from `jqhtml.debug`,
509
+ * which is a developer's interactive tracing switch: config here describes the
510
+ * ENVIRONMENT the app is running in, and jqhtml derives behaviour from it.
511
+ *
512
+ * jqhtml.init($, { mode: 'production' });
513
+ * jqhtml.configure({ mode: 'development' });
514
+ *
515
+ * Default mode is 'development', so an integration that passes nothing keeps
516
+ * every diagnostic. Production is always opt-in.
517
+ */
518
+ /** Flags implied by each mode. Add future conventions here, not at call sites. */
519
+ const MODE_DEFAULTS = {
520
+ development: {
521
+ warn_uncacheable_args: true,
522
+ debug_attributes: true,
523
+ },
524
+ production: {
525
+ warn_uncacheable_args: false,
526
+ debug_attributes: false,
527
+ },
528
+ };
529
+ let _mode = 'development';
530
+ let _flags = { ...MODE_DEFAULTS.development };
531
+ /**
532
+ * Apply integration configuration.
533
+ *
534
+ * `mode` resets every flag to that mode's defaults; any explicit flag in the
535
+ * same call then overrides it. Calling with only flags leaves the mode alone.
536
+ * Repeated calls merge.
537
+ */
538
+ function configure(config) {
539
+ if (!config)
540
+ return;
541
+ if (config.mode) {
542
+ if (!MODE_DEFAULTS[config.mode]) {
543
+ throw new Error(`[JQHTML] Unknown mode '${config.mode}'. Expected 'development' or 'production'.`);
544
+ }
545
+ _mode = config.mode;
546
+ _flags = { ...MODE_DEFAULTS[config.mode] };
547
+ }
548
+ if (config.warn_uncacheable_args !== undefined) {
549
+ _flags.warn_uncacheable_args = !!config.warn_uncacheable_args;
550
+ }
551
+ if (config.debug_attributes !== undefined) {
552
+ _flags.debug_attributes = !!config.debug_attributes;
553
+ }
554
+ }
555
+ /** Current mode plus every resolved flag. */
556
+ function get_config() {
557
+ return { mode: _mode, ..._flags };
558
+ }
559
+ /** True when debug-only DOM attributes (data-sid) should be emitted. */
560
+ function debug_attributes_enabled() {
561
+ return _flags.debug_attributes;
562
+ }
563
+ /** True when uncacheable-args warnings should be printed. */
564
+ function warn_uncacheable_args_enabled() {
565
+ return _flags.warn_uncacheable_args;
566
+ }
567
+
504
568
  /**
505
569
  * JQHTML v2 Instruction Processor
506
570
  *
@@ -638,6 +702,9 @@ function process_tag_to_html(instruction, html, tagElements, components, context
638
702
  }
639
703
  // Add simple string/numeric attributes directly
640
704
  for (const [key, value] of Object.entries(attrs)) {
705
+ if (key === 'data-sid' && !debug_attributes_enabled()) {
706
+ continue; // debug-only attribute; the scoped id is what $sid() resolves
707
+ }
641
708
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
642
709
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
643
710
  (typeof value === 'string' || typeof value === 'number')) {
@@ -727,7 +794,10 @@ function process_component_to_html(instruction, html, components, context) {
727
794
  const baseId = props['data-sid'];
728
795
  // The compiled code ALWAYS sets props['id'] with the correct scoped value
729
796
  // Just use it directly - it already has the correct parent _cid baked in
730
- html.push(` id="${props['id']}" data-sid="${baseId}"`);
797
+ // data-sid is debug-only: $sid() resolves via the scoped id, never this.
798
+ html.push(debug_attributes_enabled()
799
+ ? ` id="${props['id']}" data-sid="${baseId}"`
800
+ : ` id="${props['id']}"`);
731
801
  }
732
802
  // Regular id passes through unchanged
733
803
  else if (props['id']) {
@@ -869,6 +939,10 @@ function apply_attributes(element, attrs, context) {
869
939
  }
870
940
  }
871
941
  else if (key.startsWith('data-')) {
942
+ // data-sid is debug-only - suppressed entirely in production
943
+ if (key === 'data-sid' && !debug_attributes_enabled()) {
944
+ continue;
945
+ }
872
946
  // Data attributes - go into DOM
873
947
  const attrValue = typeof value === "string" ? value.trim() : value;
874
948
  element.attr(key, attrValue);
@@ -1231,6 +1305,146 @@ function handleComponentError(component, phase, error) {
1231
1305
  // 9. Performance Budgets - Warn when components exceed size/time limits
1232
1306
  // 10. Accessibility Auditing - Check for missing ARIA attributes
1233
1307
 
1308
+ /**
1309
+ * JQHTML Cache Key Serializer
1310
+ *
1311
+ * Produces a deterministic string for a plain-data argument value, so that two
1312
+ * structurally equal objects built on separate renders yield the SAME cache key.
1313
+ * Templates rebuild `{contact_id: 12}` on every render, so identity-based keying
1314
+ * can never match and permanently opts the receiving component out of caching.
1315
+ *
1316
+ * ---------------------------------------------------------------------------
1317
+ * THIS IS NOT `process_for_serialization()` IN local-storage.ts. DO NOT MERGE THEM.
1318
+ *
1319
+ * That serializer stores cache VALUES and deliberately degrades: a function, a
1320
+ * symbol or a circular reference is dropped and serialization continues, because
1321
+ * losing a method while keeping the data is the right trade for a payload.
1322
+ *
1323
+ * For a cache KEY that policy manufactures false hits. Two argument objects that
1324
+ * differ ONLY by a callback would serialize identically, and a component would
1325
+ * render with another component's cached content - strictly worse than not
1326
+ * caching at all. So this serializer DECLINES on the first value it cannot
1327
+ * express exactly. It never drops anything.
1328
+ * ---------------------------------------------------------------------------
1329
+ *
1330
+ * Allow-list, and nothing else: null, undefined, boolean, number, string, Date,
1331
+ * arrays, and plain objects (prototype Object.prototype or null), in any nesting.
1332
+ * Everything else declines with a reason that reaches `data-nocache`.
1333
+ */
1334
+ /**
1335
+ * Values larger than this are declined rather than keyed. The supported case is a
1336
+ * handful of key/value pairs or a list of ids - not a payload. Declining loudly
1337
+ * beats spending the budget on a key nobody benefits from.
1338
+ */
1339
+ const MAX_KEY_BYTES = 500;
1340
+ class Decline extends Error {
1341
+ constructor(reason) {
1342
+ super(reason);
1343
+ this.reason = reason;
1344
+ }
1345
+ }
1346
+ function push(state, text) {
1347
+ state.size += text.length;
1348
+ if (state.size > MAX_KEY_BYTES) {
1349
+ throw new Decline('too-large');
1350
+ }
1351
+ return text;
1352
+ }
1353
+ /**
1354
+ * Length-prefix strings so that no combination of separators can make two
1355
+ * different structures produce the same text.
1356
+ */
1357
+ function encode_string(state, prefix, value) {
1358
+ return push(state, `${prefix}${value.length}:${value}`);
1359
+ }
1360
+ function walk(value, state) {
1361
+ // --- primitives, each tagged so shapes cannot collide ---
1362
+ if (value === null)
1363
+ return push(state, 'n');
1364
+ if (value === undefined)
1365
+ return push(state, 'u');
1366
+ const type = typeof value;
1367
+ if (type === 'boolean')
1368
+ return push(state, value ? 'b1' : 'b0');
1369
+ if (type === 'number')
1370
+ return push(state, `d${String(value)}`);
1371
+ if (type === 'string')
1372
+ return encode_string(state, 's', value);
1373
+ if (type === 'function')
1374
+ throw new Decline('function');
1375
+ if (type === 'symbol')
1376
+ throw new Decline('symbol');
1377
+ if (type === 'bigint')
1378
+ throw new Decline('bigint');
1379
+ if (type !== 'object')
1380
+ throw new Decline(type);
1381
+ // --- objects: reject the impostors before treating anything as data ---
1382
+ if (typeof value.nodeType === 'number')
1383
+ throw new Decline('dom-node');
1384
+ if (value.jquery)
1385
+ throw new Decline('jquery');
1386
+ if (state.seen.has(value))
1387
+ throw new Decline('circular');
1388
+ state.seen.add(value);
1389
+ try {
1390
+ if (value instanceof Date) {
1391
+ const time = value.getTime();
1392
+ if (Number.isNaN(time))
1393
+ throw new Decline('invalid-date');
1394
+ return push(state, `t${value.toISOString()}`);
1395
+ }
1396
+ if (Array.isArray(value)) {
1397
+ // Order is meaningful - never sorted.
1398
+ const parts = [];
1399
+ push(state, 'a[');
1400
+ for (let i = 0; i < value.length; i++) {
1401
+ parts.push(walk(value[i], state));
1402
+ }
1403
+ push(state, ']');
1404
+ return `a[${parts.join(',')}]`;
1405
+ }
1406
+ // Plain object ONLY. A class instance, Map, Set, RegExp, Error, etc. is
1407
+ // declined: two different classes with identical fields would otherwise
1408
+ // serialize the same and collide.
1409
+ const proto = Object.getPrototypeOf(value);
1410
+ if (proto !== Object.prototype && proto !== null) {
1411
+ throw new Decline('non-plain-object');
1412
+ }
1413
+ // Recursive key sort - the existing top-level sort is not enough for nesting.
1414
+ const keys = Object.keys(value).sort();
1415
+ const parts = [];
1416
+ push(state, 'o{');
1417
+ for (const key of keys) {
1418
+ const encoded_key = encode_string(state, 'k', key);
1419
+ parts.push(`${encoded_key}=${walk(value[key], state)}`);
1420
+ }
1421
+ push(state, '}');
1422
+ return `o{${parts.join(',')}}`;
1423
+ }
1424
+ finally {
1425
+ state.seen.delete(value);
1426
+ }
1427
+ }
1428
+ /**
1429
+ * Serialize a value for use in a cache key.
1430
+ *
1431
+ * @returns `{ok: true, text}` when the value is exactly expressible, or
1432
+ * `{ok: false, reason}` naming why it is not. Never throws.
1433
+ */
1434
+ function serialize_for_cache_key(value) {
1435
+ const state = { seen: new WeakSet(), size: 0 };
1436
+ try {
1437
+ return { ok: true, text: walk(value, state) };
1438
+ }
1439
+ catch (error) {
1440
+ if (error instanceof Decline) {
1441
+ return { ok: false, reason: error.reason };
1442
+ }
1443
+ // A throwing getter or exotic proxy trap - decline rather than guess.
1444
+ return { ok: false, reason: 'unserializable' };
1445
+ }
1446
+ }
1447
+
1234
1448
  /**
1235
1449
  * Load Coordinator - Request deduplication for component on_load() calls
1236
1450
  *
@@ -1267,8 +1481,9 @@ class Load_Coordinator {
1267
1481
  * - key: Cache key string, or null if uncacheable
1268
1482
  * - uncacheable_property: Name of first property that prevented caching (for debugging)
1269
1483
  */
1270
- static generate_invocation_key(component_name, args) {
1484
+ static generate_invocation_key(component_name, args, options = {}) {
1271
1485
  let uncacheable_property;
1486
+ let uncacheable_reason;
1272
1487
  // Filter out internal properties (starting with _) and serialize args
1273
1488
  const serializable_args = {};
1274
1489
  for (const key of Object.keys(args).sort()) {
@@ -1307,21 +1522,37 @@ class Load_Coordinator {
1307
1522
  // Method threw error - treat as uncacheable
1308
1523
  if (!uncacheable_property) {
1309
1524
  uncacheable_property = key;
1525
+ uncacheable_reason = 'cache-id-threw';
1310
1526
  }
1311
- return { key: null, uncacheable_property };
1527
+ return { key: null, uncacheable_property, uncacheable_reason };
1528
+ }
1529
+ }
1530
+ // No author-supplied id. The cache may fall back to keying by
1531
+ // deterministic content; deduplication deliberately may not.
1532
+ if (options.allow_content_serialization) {
1533
+ const serialized = serialize_for_cache_key(value);
1534
+ if (serialized.ok) {
1535
+ serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
1536
+ continue;
1312
1537
  }
1538
+ if (!uncacheable_property) {
1539
+ uncacheable_property = key;
1540
+ uncacheable_reason = serialized.reason;
1541
+ }
1542
+ return { key: null, uncacheable_property, uncacheable_reason };
1313
1543
  }
1314
- // No cache ID available - this property is uncacheable
1315
1544
  if (!uncacheable_property) {
1316
1545
  uncacheable_property = key;
1546
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
1317
1547
  }
1318
- return { key: null, uncacheable_property };
1548
+ return { key: null, uncacheable_property, uncacheable_reason };
1319
1549
  }
1320
1550
  // Unknown type (symbol, bigint, etc.) - uncacheable
1321
1551
  if (!uncacheable_property) {
1322
1552
  uncacheable_property = key;
1553
+ uncacheable_reason = value_type;
1323
1554
  }
1324
- return { key: null, uncacheable_property };
1555
+ return { key: null, uncacheable_property, uncacheable_reason };
1325
1556
  }
1326
1557
  // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
1327
1558
  try {
@@ -1339,6 +1570,12 @@ class Load_Coordinator {
1339
1570
  */
1340
1571
  static should_execute_on_load(component) {
1341
1572
  const { key } = this.generate_invocation_key(component.component_name(), component.args);
1573
+ // No identity means no coordination. Without this, every un-keyable component
1574
+ // would share the single `null` registry entry and adopt an unrelated
1575
+ // component's data - the worst failure this system can produce.
1576
+ if (key === null) {
1577
+ return true;
1578
+ }
1342
1579
  const entry = this._registry.get(key);
1343
1580
  if (!entry) {
1344
1581
  // No existing request - this component becomes the leader
@@ -1361,6 +1598,10 @@ class Load_Coordinator {
1361
1598
  */
1362
1599
  static register_leader(component, on_load_promise) {
1363
1600
  const { key } = this.generate_invocation_key(component.component_name(), component.args);
1601
+ // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1602
+ if (key === null) {
1603
+ return () => { };
1604
+ }
1364
1605
  // Create a promise that we control - it resolves when complete_coordination is called
1365
1606
  let resolve_promise;
1366
1607
  const coordination_promise = new Promise((resolve) => {
@@ -1582,6 +1823,8 @@ Load_Coordinator._registry = new Map();
1582
1823
  // Registry mapping class names to constructors
1583
1824
  const class_registry = Object.create(null);
1584
1825
  // Markers for serialized class instances (unique to avoid collisions with user data)
1826
+ // Replaced at build time by rollup (see rollup.config.js).
1827
+ const CORE_VERSION = '2.3.54';
1585
1828
  const CLASS_MARKER = '__jqhtml_class__';
1586
1829
  const PROPS_MARKER = '__jqhtml_props__';
1587
1830
  /**
@@ -1871,6 +2114,12 @@ class Jqhtml_Local_Storage {
1871
2114
  */
1872
2115
  static set_cache_key(cache_key, cache_mode = 'data') {
1873
2116
  this._cache_key = cache_key;
2117
+ // Key SHAPE is a function of the library (see cache-key-serializer.ts), so an
2118
+ // upgrade that changes it must invalidate everything persisted under the old shape.
2119
+ // Version the SCOPE MARKER rather than the key itself: _validate_scope() already
2120
+ // clears every jqhtml key when this value changes, and leaving the per-entry key
2121
+ // format alone keeps stored keys readable and avoids repeating the version in each.
2122
+ this._scope_marker = `${CORE_VERSION}::${cache_key}`;
1874
2123
  this._cache_mode = cache_mode;
1875
2124
  this._init();
1876
2125
  }
@@ -1940,21 +2189,21 @@ class Jqhtml_Local_Storage {
1940
2189
  }
1941
2190
  try {
1942
2191
  const stored_key = localStorage.getItem('_jqhtml_cache_key');
1943
- // If cache key exists and has changed, clear only JQHTML keys
1944
- if (stored_key !== null && stored_key !== this._cache_key) {
1945
- console.log('[JQHTML Local Storage] Cache key changed, clearing JQHTML keys:', {
2192
+ // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2193
+ if (stored_key !== null && stored_key !== this._scope_marker) {
2194
+ console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
1946
2195
  old_key: stored_key,
1947
- new_key: this._cache_key,
2196
+ new_key: this._scope_marker,
1948
2197
  });
1949
2198
  this._clear_jqhtml_keys();
1950
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2199
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1951
2200
  }
1952
2201
  else if (stored_key === null) {
1953
2202
  // First time JQHTML is using this storage - just set the key, don't clear
1954
- console.log('[JQHTML Local Storage] Initializing cache key (first use):', {
1955
- new_key: this._cache_key,
2203
+ console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2204
+ new_key: this._scope_marker,
1956
2205
  });
1957
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2206
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1958
2207
  }
1959
2208
  }
1960
2209
  catch (e) {
@@ -2186,6 +2435,8 @@ class Jqhtml_Local_Storage {
2186
2435
  }
2187
2436
  }
2188
2437
  Jqhtml_Local_Storage._cache_key = null;
2438
+ /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2439
+ Jqhtml_Local_Storage._scope_marker = null;
2189
2440
  Jqhtml_Local_Storage._cache_mode = 'data';
2190
2441
  Jqhtml_Local_Storage._storage_available = null;
2191
2442
  Jqhtml_Local_Storage._initialized = false;
@@ -2283,10 +2534,15 @@ function event_once(component, event_name, callback) {
2283
2534
  function event_trigger(component, event_name, data) {
2284
2535
  // Mark this event as occurred and store the data for late subscribers
2285
2536
  component._lifecycle_states.set(event_name, data);
2286
- // Fire all registered callbacks for this event
2537
+ // Fire all registered callbacks for this event.
2538
+ // Iterate a SNAPSHOT: handlers registered via once() splice themselves out of the
2539
+ // live array while it is being iterated, which would shift later handlers into
2540
+ // already-visited slots and skip them. The snapshot also means a handler
2541
+ // registered during dispatch does not fire in this dispatch (event_on's sticky
2542
+ // immediate-fire already delivered it).
2287
2543
  const callbacks = component._lifecycle_callbacks.get(event_name);
2288
2544
  if (callbacks) {
2289
- for (const callback of callbacks) {
2545
+ for (const callback of [...callbacks]) {
2290
2546
  try {
2291
2547
  callback.bind(component)(component, data);
2292
2548
  }
@@ -2571,12 +2827,73 @@ function generate_cache_key(component) {
2571
2827
  return { cache_key: `${component.component_name()}::${String(custom_cache_id)}` };
2572
2828
  }
2573
2829
  catch (error) {
2574
- return { cache_key: null, uncacheable_property: 'cache_id()' };
2830
+ return { cache_key: null, uncacheable_property: 'cache_id()', uncacheable_reason: 'cache-id-threw' };
2575
2831
  }
2576
2832
  }
2577
- // Use standard args-based cache key generation
2578
- const result = Load_Coordinator.generate_invocation_key(component.component_name(), component.args);
2579
- return { cache_key: result.key, uncacheable_property: result.uncacheable_property };
2833
+ // Standard args-based key. The CACHE opts into content serialization for plain-data
2834
+ // object args; deduplication does not - see InvocationKeyOptions for why.
2835
+ const result = Load_Coordinator.generate_invocation_key(component.component_name(), component.args, { allow_content_serialization: true });
2836
+ return {
2837
+ cache_key: result.key,
2838
+ uncacheable_property: result.uncacheable_property,
2839
+ uncacheable_reason: result.uncacheable_reason,
2840
+ };
2841
+ }
2842
+ // One warning per component+property. A list of 50 rows must not print 50 warnings.
2843
+ const _warned_uncacheable = new Set();
2844
+ /**
2845
+ * Development warning: this component fetches data but cannot be cached, because
2846
+ * its args do not reduce to a stable key and it defines no cache_id() to say what
2847
+ * its identity is. Nothing is broken - the component works - but it silently opts
2848
+ * out of cache reuse and of load deduplication.
2849
+ *
2850
+ * Controlled by the `warn_uncacheable_args` integration flag (see config.ts).
2851
+ */
2852
+ function warn_uncacheable_component(component, uncacheable_property, uncacheable_reason) {
2853
+ if (!warn_uncacheable_args_enabled())
2854
+ return;
2855
+ let name = 'unknown';
2856
+ try {
2857
+ name = component.component_name();
2858
+ }
2859
+ catch { /* name unavailable */ }
2860
+ const dedupe_key = `${name}::${uncacheable_property || '?'}::${uncacheable_reason || '?'}`;
2861
+ if (_warned_uncacheable.has(dedupe_key))
2862
+ return;
2863
+ _warned_uncacheable.add(dedupe_key);
2864
+ // cache_id() exists but threw - a different problem with a different fix
2865
+ if (uncacheable_property === 'cache_id()' || uncacheable_reason === 'cache-id-threw') {
2866
+ console.warn(`[JQHTML] <${name}> defines cache_id() but it threw, so this component is not cached ` +
2867
+ `and its loads are not deduplicated. Fix cache_id() so it returns a stable string.`, { component: name, cid: component._cid });
2868
+ return;
2869
+ }
2870
+ const REASONS = {
2871
+ 'function': 'it contains a function - a callback is real identity that content cannot express',
2872
+ 'symbol': 'it contains a symbol',
2873
+ 'bigint': 'it contains a bigint',
2874
+ 'dom-node': 'it contains a DOM node',
2875
+ 'jquery': 'it contains a jQuery object',
2876
+ 'circular': 'it contains a circular reference',
2877
+ 'non-plain-object': 'it contains a class instance, Map, Set or similar - only plain objects and arrays can be keyed by content',
2878
+ 'too-large': 'it serializes to more than 500 bytes - args are meant to identify data, not carry it',
2879
+ 'invalid-date': 'it contains an invalid Date',
2880
+ };
2881
+ const why = REASONS[uncacheable_reason || ''] || 'it cannot be serialized deterministically';
2882
+ console.warn(`[JQHTML] <${name}> cannot be cached: arg $${uncacheable_property} is not keyable because ` +
2883
+ `${why}.\n` +
2884
+ ` The component works, but it will not be restored from cache when reused.\n` +
2885
+ ` Fix: define cache_id() on the component to state its identity explicitly, e.g.\n` +
2886
+ ` cache_id() { return \`${name.toLowerCase()}_\${this.args.some_id}\`; }\n` +
2887
+ ` Or pass plain data (objects/arrays of primitives are keyed automatically) instead of ` +
2888
+ `$${uncacheable_property}.\n` +
2889
+ ` Find it in DevTools with the selector ` +
2890
+ `[data-nocache="${uncacheable_property}:${uncacheable_reason || ''}"].`, {
2891
+ component: name,
2892
+ cid: component._cid,
2893
+ uncacheable_arg: uncacheable_property,
2894
+ reason: uncacheable_reason,
2895
+ all_args: component.args ? Object.keys(component.args) : [],
2896
+ });
2580
2897
  }
2581
2898
  /**
2582
2899
  * Read cache during create() phase.
@@ -2585,13 +2902,15 @@ function generate_cache_key(component) {
2585
2902
  * @param component - The component instance
2586
2903
  */
2587
2904
  function read_cache_in_create(component) {
2588
- const { cache_key, uncacheable_property } = generate_cache_key(component);
2905
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(component);
2589
2906
  // If cache_key is null, caching disabled
2590
2907
  if (cache_key === null) {
2591
2908
  // Set data-nocache attribute for debugging
2592
2909
  if (uncacheable_property) {
2593
- component.$.attr('data-nocache', uncacheable_property);
2910
+ // "<arg>:<reason>" - a bare property name left authors reverse-engineering why
2911
+ component.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
2594
2912
  }
2913
+ warn_uncacheable_component(component, uncacheable_property, uncacheable_reason);
2595
2914
  if (window.jqhtml?.debug?.verbose) {
2596
2915
  console.log(`[Cache] Component ${component._cid} (${component.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2597
2916
  }
@@ -3005,6 +3324,75 @@ function has_preload_data() {
3005
3324
  return _preload_cache.size > 0;
3006
3325
  }
3007
3326
 
3327
+ /**
3328
+ * JQHTML Viewport Resize Dispatch
3329
+ *
3330
+ * One framework-owned, debounced window 'resize' listener fans out to every live
3331
+ * component in the document by calling on_viewport_resize(viewport_width).
3332
+ *
3333
+ * Components never bind their own window listener, so there is nothing to unbind
3334
+ * in on_stop() - a component that is stopped or removed from the document simply
3335
+ * stops being found by the dispatcher.
3336
+ */
3337
+ /** Trailing-edge debounce applied to the window resize listener. */
3338
+ const VIEWPORT_RESIZE_DEBOUNCE_MS = 30;
3339
+ let debounce_timer = null;
3340
+ let listener_installed = false;
3341
+ /**
3342
+ * Current viewport width in CSS pixels.
3343
+ *
3344
+ * window.innerWidth is used (not $(window).width()) because it matches the width
3345
+ * CSS media queries resolve against. It includes the scrollbar gutter.
3346
+ *
3347
+ * Returns 0 outside a browser (SSR).
3348
+ */
3349
+ function get_viewport_width() {
3350
+ return typeof window !== 'undefined' ? window.innerWidth : 0;
3351
+ }
3352
+ /**
3353
+ * Call on_viewport_resize(width) on every component currently in the document.
3354
+ *
3355
+ * Walks $('.Component') in document order (parents before children). Elements
3356
+ * with no component instance and stopped components are skipped. A component
3357
+ * that throws is logged and the walk continues - one bad handler must not stop
3358
+ * the rest of the page from resizing.
3359
+ */
3360
+ function dispatch_viewport_resize(jQuery) {
3361
+ const viewport_width = get_viewport_width();
3362
+ jQuery('.Component').each(function () {
3363
+ const component = jQuery(this).data('_component');
3364
+ if (!component || component._stopped)
3365
+ return;
3366
+ if (typeof component.on_viewport_resize !== 'function')
3367
+ return;
3368
+ try {
3369
+ component.on_viewport_resize(viewport_width);
3370
+ }
3371
+ catch (error) {
3372
+ console.error(`[JQHTML] Error in on_viewport_resize() for ${component.component_name?.() || 'component'}:`, error);
3373
+ }
3374
+ });
3375
+ }
3376
+ /**
3377
+ * Install the single window resize listener. Idempotent - repeated init() calls
3378
+ * and non-browser environments are no-ops.
3379
+ */
3380
+ function init_viewport_resize(jQuery) {
3381
+ if (typeof window === 'undefined')
3382
+ return;
3383
+ if (listener_installed)
3384
+ return;
3385
+ listener_installed = true;
3386
+ jQuery(window).on('resize.jqhtml_viewport', function () {
3387
+ if (debounce_timer !== null)
3388
+ clearTimeout(debounce_timer);
3389
+ debounce_timer = setTimeout(function () {
3390
+ debounce_timer = null;
3391
+ dispatch_viewport_resize(jQuery);
3392
+ }, VIEWPORT_RESIZE_DEBOUNCE_MS);
3393
+ });
3394
+ }
3395
+
3008
3396
  /**
3009
3397
  * JQHTML v2 Component Base Class
3010
3398
  *
@@ -3199,6 +3587,21 @@ class Jqhtml_Component {
3199
3587
  // Call original directly - no flag needed since we bypass the wrapper
3200
3588
  return impl.call(this);
3201
3589
  }
3590
+ /**
3591
+ * Fire on_viewport_resize() with the current viewport width.
3592
+ * Called after every on_render() and on_ready(); the debounced window
3593
+ * listener in viewport.ts drives it from then on. Errors are isolated so a
3594
+ * bad handler cannot break the lifecycle.
3595
+ * @private
3596
+ */
3597
+ _fire_viewport_resize() {
3598
+ try {
3599
+ this.on_viewport_resize(get_viewport_width());
3600
+ }
3601
+ catch (error) {
3602
+ console.error(`[JQHTML] Error in on_viewport_resize() for ${this.component_name()}:`, error);
3603
+ }
3604
+ }
3202
3605
  /**
3203
3606
  * Check if on_load() is overridden in a subclass
3204
3607
  * Used to skip the load phase entirely for components that don't fetch data
@@ -3283,6 +3686,7 @@ class Jqhtml_Component {
3283
3686
  console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3284
3687
  `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3285
3688
  }
3689
+ this._fire_viewport_resize();
3286
3690
  }
3287
3691
  // Emit lifecycle event
3288
3692
  this.trigger('render');
@@ -3474,6 +3878,7 @@ class Jqhtml_Component {
3474
3878
  console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3475
3879
  `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3476
3880
  }
3881
+ this._fire_viewport_resize();
3477
3882
  }
3478
3883
  // Emit lifecycle event
3479
3884
  this.trigger('render');
@@ -3548,6 +3953,7 @@ class Jqhtml_Component {
3548
3953
  }
3549
3954
  // Call on_ready hook with authorization
3550
3955
  await this._call_lifecycle('on_ready');
3956
+ this._fire_viewport_resize();
3551
3957
  // Trigger ready event
3552
3958
  this.trigger('ready');
3553
3959
  });
@@ -3774,9 +4180,18 @@ class Jqhtml_Component {
3774
4180
  }
3775
4181
  return;
3776
4182
  }
3777
- // Generate cache key (needed for both preload check and load deduplication)
4183
+ // TWO identities, deliberately computed separately.
4184
+ //
4185
+ // cache_key - may key a plain-data object arg by deterministic CONTENT, and may come
4186
+ // from the author's cache_id(). A wrong key here is corrected by
4187
+ // stale-while-revalidate.
4188
+ // dedup_key - args only, no content serialization, no cache_id(). A deduplicated
4189
+ // follower never runs on_load() and adopts the leader's data with NO
4190
+ // revalidation, so a wrong key here is permanently wrong data. Redundant
4191
+ // concurrent requests are the cheaper failure.
3778
4192
  let cache_key = null;
3779
4193
  let uncacheable_property;
4194
+ let uncacheable_reason;
3780
4195
  if (typeof this.cache_id === 'function') {
3781
4196
  try {
3782
4197
  const custom_cache_id = this.cache_id();
@@ -3785,14 +4200,16 @@ class Jqhtml_Component {
3785
4200
  catch (error) {
3786
4201
  // cache_id() threw error - disable caching
3787
4202
  uncacheable_property = 'cache_id()';
4203
+ uncacheable_reason = 'cache-id-threw';
3788
4204
  }
3789
4205
  }
3790
4206
  else {
3791
- // Use standard args-based cache key generation
3792
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4207
+ const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
3793
4208
  cache_key = result.key;
3794
4209
  uncacheable_property = result.uncacheable_property;
4210
+ uncacheable_reason = result.uncacheable_reason;
3795
4211
  }
4212
+ const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
3796
4213
  // SSR preload check: if preloaded data exists for this component+args, use it
3797
4214
  if (cache_key !== null && has_preload_data()) {
3798
4215
  const preloaded_data = consume_preload_data(cache_key);
@@ -3808,23 +4225,24 @@ class Jqhtml_Component {
3808
4225
  }
3809
4226
  // Store cache key for later use
3810
4227
  this._cache_key = cache_key;
3811
- // If cache_key is null, args are not serializable - skip load deduplication and caching
3812
- if (cache_key === null) {
3813
- // Set data-nocache attribute for debugging (shows which property prevented caching)
3814
- if (uncacheable_property) {
3815
- this.$.attr('data-nocache', uncacheable_property);
4228
+ // Caching disabled - mark the element with the offending arg AND why
4229
+ if (cache_key === null && uncacheable_property) {
4230
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4231
+ if (window.jqhtml?.debug?.verbose) {
4232
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-keyable args - caching disabled`, { uncacheable_property, uncacheable_reason });
3816
4233
  }
4234
+ }
4235
+ // Store "before" snapshot for comparison after on_load()
4236
+ const data_before_load = JSON.stringify(this.data);
4237
+ // No dedup identity - run on_load() alone rather than risk sharing a load
4238
+ if (dedup_key === null) {
3817
4239
  if (window.jqhtml?.debug?.verbose) {
3818
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-serializable args - load deduplication and caching disabled`, { uncacheable_property });
4240
+ console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
3819
4241
  }
3820
- // Execute on_load on detached proxy without deduplication
3821
4242
  const { data: result_data } = await this._execute_on_load_detached();
3822
- // Apply result via sequential queue
3823
- await this._apply_load_result(result_data, null);
4243
+ await this._apply_load_result(result_data, data_before_load);
3824
4244
  return;
3825
4245
  }
3826
- // Store "before" snapshot for comparison after on_load()
3827
- const data_before_load = JSON.stringify(this.data);
3828
4246
  // Check if this component should execute on_load() or wait for existing request
3829
4247
  const should_execute = Load_Coordinator.should_execute_on_load(this);
3830
4248
  if (!should_execute) {
@@ -3973,6 +4391,7 @@ class Jqhtml_Component {
3973
4391
  // Wait for all children to reach ready state (bottom-up execution)
3974
4392
  await this._wait_for_children_ready();
3975
4393
  await this._call_lifecycle('on_ready');
4394
+ this._fire_viewport_resize();
3976
4395
  this._ready_state = 4;
3977
4396
  this._update_debug_attrs();
3978
4397
  this._log_lifecycle('ready', 'complete');
@@ -4217,6 +4636,7 @@ class Jqhtml_Component {
4217
4636
  this._render();
4218
4637
  await this._wait_for_children_ready();
4219
4638
  await this._call_lifecycle('on_ready');
4639
+ this._fire_viewport_resize();
4220
4640
  this.trigger('ready');
4221
4641
  this._log_lifecycle('reload', 'complete (no on_load)');
4222
4642
  return;
@@ -4291,6 +4711,7 @@ class Jqhtml_Component {
4291
4711
  if (rendered_from_cache || should_render) {
4292
4712
  await this._wait_for_children_ready();
4293
4713
  await this._call_lifecycle('on_ready');
4714
+ this._fire_viewport_resize();
4294
4715
  // Trigger ready event so parent callbacks fire on subsequent reloads
4295
4716
  this.trigger('ready');
4296
4717
  }
@@ -4373,6 +4794,16 @@ class Jqhtml_Component {
4373
4794
  on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
4374
4795
  async on_ready() { }
4375
4796
  on_stop() { }
4797
+ /**
4798
+ * Called with the viewport width in CSS pixels:
4799
+ * - after every on_render()
4800
+ * - after every on_ready()
4801
+ * - on window resize, debounced 30ms, for every component in the document
4802
+ *
4803
+ * MUST be synchronous. Prefer CSS media/container queries for pure styling -
4804
+ * override this only when layout requires imperative measurement.
4805
+ */
4806
+ on_viewport_resize(viewport_width) { }
4376
4807
  /**
4377
4808
  * Should component re-render after load?
4378
4809
  * By default, only re-renders if data has changed
@@ -4747,8 +5178,13 @@ class Jqhtml_Component {
4747
5178
  }
4748
5179
  }
4749
5180
  _set_attributes() {
4750
- // Always set data-cid
4751
- this.$.attr('data-cid', this._cid);
5181
+ // data-cid on the rendered element is debug-only: it mirrors this._cid so component
5182
+ // boundaries are legible in DevTools. The runtime never reads it - scoping uses the
5183
+ // _cid property, and the transient data-cid placeholder the instruction processor
5184
+ // matches on is written and removed before this ever runs.
5185
+ if (debug_attributes_enabled()) {
5186
+ this.$.attr('data-cid', this._cid);
5187
+ }
4752
5188
  // Only set lifecycle state attribute if verbose mode enabled
4753
5189
  if (window.jqhtml?.debug?.verbose) {
4754
5190
  this.$.attr('data-_lifecycle-state', this._ready_state.toString());
@@ -5687,6 +6123,9 @@ function init_jquery_plugin(jQuery) {
5687
6123
  PatchedInit.prototype = jQuery.fn;
5688
6124
  jQuery.fn.init = PatchedInit;
5689
6125
  }
6126
+ // Install the single debounced window resize listener that drives
6127
+ // on_viewport_resize() on every component. Idempotent.
6128
+ init_viewport_resize(jQuery);
5690
6129
  }
5691
6130
  // Try to auto-initialize if global jQuery exists
5692
6131
  if (typeof window !== 'undefined' && window.jQuery) {
@@ -5700,7 +6139,9 @@ if (typeof window !== 'undefined' && window.jQuery) {
5700
6139
  */
5701
6140
  // Core exports
5702
6141
  // Main initialization function
5703
- function init(jQuery) {
6142
+ function init(jQuery, config) {
6143
+ // Integration config first - it governs behaviour during the very first render
6144
+ configure(config);
5704
6145
  // If jQuery is provided, initialize the plugin
5705
6146
  if (jQuery) {
5706
6147
  init_jquery_plugin(jQuery);
@@ -5714,7 +6155,7 @@ function init(jQuery) {
5714
6155
  }
5715
6156
  }
5716
6157
  // Version - will be replaced during build with actual version from package.json
5717
- const version = '2.3.50';
6158
+ const version = '2.3.54';
5718
6159
  // Default export with all functionality
5719
6160
  const jqhtml = {
5720
6161
  // Core
@@ -5742,6 +6183,9 @@ const jqhtml = {
5742
6183
  __version: version,
5743
6184
  // state facts
5744
6185
  tombstone: 'pepperoni and cheese',
6186
+ // Integration configuration - see config.ts
6187
+ configure,
6188
+ get_config,
5745
6189
  // Debug settings
5746
6190
  debug: {
5747
6191
  enabled: false,
@@ -5847,5 +6291,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
5847
6291
  }
5848
6292
  }
5849
6293
 
5850
- export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, clear_preload_data, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
6294
+ export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, clear_preload_data, configure, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_config, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
5851
6295
  //# sourceMappingURL=jqhtml-core.esm.js.map