@jqhtml/core 2.3.50 → 2.3.59

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.
Files changed (88) hide show
  1. package/README.md +3 -1
  2. package/dist/boot.d.ts +0 -0
  3. package/dist/boot.d.ts.map +0 -0
  4. package/dist/boot.js +130 -0
  5. package/dist/boot.js.map +1 -0
  6. package/dist/cache-key-serializer.d.ts +47 -0
  7. package/dist/cache-key-serializer.d.ts.map +1 -0
  8. package/dist/cache-key-serializer.js +140 -0
  9. package/dist/cache-key-serializer.js.map +1 -0
  10. package/dist/component-cache.d.ts +1 -0
  11. package/dist/component-cache.d.ts.map +1 -1
  12. package/dist/component-cache.js +270 -0
  13. package/dist/component-cache.js.map +1 -0
  14. package/dist/component-events.d.ts +0 -0
  15. package/dist/component-events.d.ts.map +1 -1
  16. package/dist/component-events.js +141 -0
  17. package/dist/component-events.js.map +1 -0
  18. package/dist/component-queue.d.ts +0 -0
  19. package/dist/component-queue.d.ts.map +0 -0
  20. package/dist/component-queue.js +118 -0
  21. package/dist/component-queue.js.map +1 -0
  22. package/dist/component-registry.d.ts +0 -0
  23. package/dist/component-registry.d.ts.map +0 -0
  24. package/dist/component-registry.js +298 -0
  25. package/dist/component-registry.js.map +1 -0
  26. package/dist/component.d.ts +26 -0
  27. package/dist/component.d.ts.map +1 -1
  28. package/dist/component.js +1892 -0
  29. package/dist/component.js.map +1 -0
  30. package/dist/config.d.ts +57 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +64 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/data-proxy.d.ts +0 -0
  35. package/dist/data-proxy.d.ts.map +0 -0
  36. package/dist/data-proxy.js +223 -0
  37. package/dist/data-proxy.js.map +1 -0
  38. package/dist/debug-entry.d.ts +0 -0
  39. package/dist/debug-entry.d.ts.map +0 -0
  40. package/dist/debug-entry.js +8 -0
  41. package/dist/debug-entry.js.map +1 -0
  42. package/dist/debug.d.ts +0 -0
  43. package/dist/debug.d.ts.map +0 -0
  44. package/dist/debug.js +203 -0
  45. package/dist/debug.js.map +1 -0
  46. package/dist/index.cjs +520 -46
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.ts +7 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +519 -47
  51. package/dist/index.js.map +1 -1
  52. package/dist/instruction-processor.d.ts +0 -0
  53. package/dist/instruction-processor.d.ts.map +1 -1
  54. package/dist/instruction-processor.js +563 -0
  55. package/dist/instruction-processor.js.map +1 -0
  56. package/dist/jqhtml-core.esm.js +520 -48
  57. package/dist/jqhtml-core.esm.js.map +1 -1
  58. package/dist/jqhtml-debug.esm.js +0 -0
  59. package/dist/jqhtml-debug.esm.js.map +0 -0
  60. package/dist/jquery-plugin.d.ts +0 -0
  61. package/dist/jquery-plugin.d.ts.map +1 -1
  62. package/dist/jquery-plugin.js +472 -0
  63. package/dist/jquery-plugin.js.map +1 -0
  64. package/dist/lifecycle-manager.d.ts +0 -0
  65. package/dist/lifecycle-manager.d.ts.map +0 -0
  66. package/dist/lifecycle-manager.js +201 -0
  67. package/dist/lifecycle-manager.js.map +1 -0
  68. package/dist/load-coordinator.d.ts +18 -1
  69. package/dist/load-coordinator.d.ts.map +1 -1
  70. package/dist/load-coordinator.js +309 -0
  71. package/dist/load-coordinator.js.map +1 -0
  72. package/dist/local-storage.d.ts +2 -0
  73. package/dist/local-storage.d.ts.map +1 -1
  74. package/dist/local-storage.js +699 -0
  75. package/dist/local-storage.js.map +1 -0
  76. package/dist/preload-data.d.ts +0 -0
  77. package/dist/preload-data.d.ts.map +0 -0
  78. package/dist/preload-data.js +136 -0
  79. package/dist/preload-data.js.map +1 -0
  80. package/dist/template-renderer.d.ts +0 -0
  81. package/dist/template-renderer.d.ts.map +0 -0
  82. package/dist/template-renderer.js +278 -0
  83. package/dist/template-renderer.js.map +1 -0
  84. package/dist/viewport.d.ts +36 -0
  85. package/dist/viewport.d.ts.map +1 -0
  86. package/dist/viewport.js +69 -0
  87. package/dist/viewport.js.map +1 -0
  88. package/package.json +7 -2
package/dist/index.cjs CHANGED
@@ -500,6 +500,70 @@ function register(source) {
500
500
  'class name mangling, define static component_name or use register_component().');
501
501
  }
502
502
 
503
+ /**
504
+ * JQHTML Runtime Configuration
505
+ *
506
+ * Integration-level settings supplied by the host application (RSpade, Laravel,
507
+ * a bundler entry point) when jqhtml is loaded. Distinct from `jqhtml.debug`,
508
+ * which is a developer's interactive tracing switch: config here describes the
509
+ * ENVIRONMENT the app is running in, and jqhtml derives behaviour from it.
510
+ *
511
+ * jqhtml.init($, { mode: 'production' });
512
+ * jqhtml.configure({ mode: 'development' });
513
+ *
514
+ * Default mode is 'development', so an integration that passes nothing keeps
515
+ * every diagnostic. Production is always opt-in.
516
+ */
517
+ /** Flags implied by each mode. Add future conventions here, not at call sites. */
518
+ const MODE_DEFAULTS = {
519
+ development: {
520
+ warn_uncacheable_args: true,
521
+ debug_attributes: true,
522
+ },
523
+ production: {
524
+ warn_uncacheable_args: false,
525
+ debug_attributes: false,
526
+ },
527
+ };
528
+ let _mode = 'development';
529
+ let _flags = { ...MODE_DEFAULTS.development };
530
+ /**
531
+ * Apply integration configuration.
532
+ *
533
+ * `mode` resets every flag to that mode's defaults; any explicit flag in the
534
+ * same call then overrides it. Calling with only flags leaves the mode alone.
535
+ * Repeated calls merge.
536
+ */
537
+ function configure(config) {
538
+ if (!config)
539
+ return;
540
+ if (config.mode) {
541
+ if (!MODE_DEFAULTS[config.mode]) {
542
+ throw new Error(`[JQHTML] Unknown mode '${config.mode}'. Expected 'development' or 'production'.`);
543
+ }
544
+ _mode = config.mode;
545
+ _flags = { ...MODE_DEFAULTS[config.mode] };
546
+ }
547
+ if (config.warn_uncacheable_args !== undefined) {
548
+ _flags.warn_uncacheable_args = !!config.warn_uncacheable_args;
549
+ }
550
+ if (config.debug_attributes !== undefined) {
551
+ _flags.debug_attributes = !!config.debug_attributes;
552
+ }
553
+ }
554
+ /** Current mode plus every resolved flag. */
555
+ function get_config() {
556
+ return { mode: _mode, ..._flags };
557
+ }
558
+ /** True when debug-only DOM attributes (data-sid) should be emitted. */
559
+ function debug_attributes_enabled() {
560
+ return _flags.debug_attributes;
561
+ }
562
+ /** True when uncacheable-args warnings should be printed. */
563
+ function warn_uncacheable_args_enabled() {
564
+ return _flags.warn_uncacheable_args;
565
+ }
566
+
503
567
  /**
504
568
  * JQHTML v2 Instruction Processor
505
569
  *
@@ -637,6 +701,9 @@ function process_tag_to_html(instruction, html, tagElements, components, context
637
701
  }
638
702
  // Add simple string/numeric attributes directly
639
703
  for (const [key, value] of Object.entries(attrs)) {
704
+ if (key === 'data-sid' && !debug_attributes_enabled()) {
705
+ continue; // debug-only attribute; the scoped id is what $sid() resolves
706
+ }
640
707
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
641
708
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
642
709
  (typeof value === 'string' || typeof value === 'number')) {
@@ -726,7 +793,10 @@ function process_component_to_html(instruction, html, components, context) {
726
793
  const baseId = props['data-sid'];
727
794
  // The compiled code ALWAYS sets props['id'] with the correct scoped value
728
795
  // Just use it directly - it already has the correct parent _cid baked in
729
- html.push(` id="${props['id']}" data-sid="${baseId}"`);
796
+ // data-sid is debug-only: $sid() resolves via the scoped id, never this.
797
+ html.push(debug_attributes_enabled()
798
+ ? ` id="${props['id']}" data-sid="${baseId}"`
799
+ : ` id="${props['id']}"`);
730
800
  }
731
801
  // Regular id passes through unchanged
732
802
  else if (props['id']) {
@@ -790,11 +860,22 @@ function process_rawtag_to_html(instruction, html) {
790
860
  }
791
861
  }
792
862
  html.push('>');
793
- // Add raw content - escape HTML entities but preserve whitespace
794
- const escaped_content = rawContent
795
- .replace(/&/g, '&')
796
- .replace(/</g, '&lt;')
797
- .replace(/>/g, '&gt;');
863
+ // Add raw content, preserving whitespace.
864
+ //
865
+ // <textarea> holds text and nothing else, so everything is escaped.
866
+ //
867
+ // <pre> holds phrasing content - <pre><code> is the standard code-block idiom - so
868
+ // markup the author wrote is passed through. A '<' that does not begin a tag is
869
+ // still escaped, which is what the HTML tokenizer itself does with a stray '<' and
870
+ // keeps code samples such as "if (a < b)" rendering as written. Interpolated
871
+ // values are escaped by the compiler before they reach this point.
872
+ const is_raw_html_content = tagName.toLowerCase() === 'pre';
873
+ const escaped_content = is_raw_html_content
874
+ ? rawContent.replace(/<(?![a-zA-Z/!?])/g, '&lt;')
875
+ : rawContent
876
+ .replace(/&/g, '&amp;')
877
+ .replace(/</g, '&lt;')
878
+ .replace(/>/g, '&gt;');
798
879
  html.push(escaped_content);
799
880
  // Close tag
800
881
  html.push(`</${tagName}>`);
@@ -868,6 +949,10 @@ function apply_attributes(element, attrs, context) {
868
949
  }
869
950
  }
870
951
  else if (key.startsWith('data-')) {
952
+ // data-sid is debug-only - suppressed entirely in production
953
+ if (key === 'data-sid' && !debug_attributes_enabled()) {
954
+ continue;
955
+ }
871
956
  // Data attributes - go into DOM
872
957
  const attrValue = typeof value === "string" ? value.trim() : value;
873
958
  element.attr(key, attrValue);
@@ -1230,6 +1315,146 @@ function handleComponentError(component, phase, error) {
1230
1315
  // 9. Performance Budgets - Warn when components exceed size/time limits
1231
1316
  // 10. Accessibility Auditing - Check for missing ARIA attributes
1232
1317
 
1318
+ /**
1319
+ * JQHTML Cache Key Serializer
1320
+ *
1321
+ * Produces a deterministic string for a plain-data argument value, so that two
1322
+ * structurally equal objects built on separate renders yield the SAME cache key.
1323
+ * Templates rebuild `{contact_id: 12}` on every render, so identity-based keying
1324
+ * can never match and permanently opts the receiving component out of caching.
1325
+ *
1326
+ * ---------------------------------------------------------------------------
1327
+ * THIS IS NOT `process_for_serialization()` IN local-storage.ts. DO NOT MERGE THEM.
1328
+ *
1329
+ * That serializer stores cache VALUES and deliberately degrades: a function, a
1330
+ * symbol or a circular reference is dropped and serialization continues, because
1331
+ * losing a method while keeping the data is the right trade for a payload.
1332
+ *
1333
+ * For a cache KEY that policy manufactures false hits. Two argument objects that
1334
+ * differ ONLY by a callback would serialize identically, and a component would
1335
+ * render with another component's cached content - strictly worse than not
1336
+ * caching at all. So this serializer DECLINES on the first value it cannot
1337
+ * express exactly. It never drops anything.
1338
+ * ---------------------------------------------------------------------------
1339
+ *
1340
+ * Allow-list, and nothing else: null, undefined, boolean, number, string, Date,
1341
+ * arrays, and plain objects (prototype Object.prototype or null), in any nesting.
1342
+ * Everything else declines with a reason that reaches `data-nocache`.
1343
+ */
1344
+ /**
1345
+ * Values larger than this are declined rather than keyed. The supported case is a
1346
+ * handful of key/value pairs or a list of ids - not a payload. Declining loudly
1347
+ * beats spending the budget on a key nobody benefits from.
1348
+ */
1349
+ const MAX_KEY_BYTES = 500;
1350
+ class Decline extends Error {
1351
+ constructor(reason) {
1352
+ super(reason);
1353
+ this.reason = reason;
1354
+ }
1355
+ }
1356
+ function push(state, text) {
1357
+ state.size += text.length;
1358
+ if (state.size > MAX_KEY_BYTES) {
1359
+ throw new Decline('too-large');
1360
+ }
1361
+ return text;
1362
+ }
1363
+ /**
1364
+ * Length-prefix strings so that no combination of separators can make two
1365
+ * different structures produce the same text.
1366
+ */
1367
+ function encode_string(state, prefix, value) {
1368
+ return push(state, `${prefix}${value.length}:${value}`);
1369
+ }
1370
+ function walk(value, state) {
1371
+ // --- primitives, each tagged so shapes cannot collide ---
1372
+ if (value === null)
1373
+ return push(state, 'n');
1374
+ if (value === undefined)
1375
+ return push(state, 'u');
1376
+ const type = typeof value;
1377
+ if (type === 'boolean')
1378
+ return push(state, value ? 'b1' : 'b0');
1379
+ if (type === 'number')
1380
+ return push(state, `d${String(value)}`);
1381
+ if (type === 'string')
1382
+ return encode_string(state, 's', value);
1383
+ if (type === 'function')
1384
+ throw new Decline('function');
1385
+ if (type === 'symbol')
1386
+ throw new Decline('symbol');
1387
+ if (type === 'bigint')
1388
+ throw new Decline('bigint');
1389
+ if (type !== 'object')
1390
+ throw new Decline(type);
1391
+ // --- objects: reject the impostors before treating anything as data ---
1392
+ if (typeof value.nodeType === 'number')
1393
+ throw new Decline('dom-node');
1394
+ if (value.jquery)
1395
+ throw new Decline('jquery');
1396
+ if (state.seen.has(value))
1397
+ throw new Decline('circular');
1398
+ state.seen.add(value);
1399
+ try {
1400
+ if (value instanceof Date) {
1401
+ const time = value.getTime();
1402
+ if (Number.isNaN(time))
1403
+ throw new Decline('invalid-date');
1404
+ return push(state, `t${value.toISOString()}`);
1405
+ }
1406
+ if (Array.isArray(value)) {
1407
+ // Order is meaningful - never sorted.
1408
+ const parts = [];
1409
+ push(state, 'a[');
1410
+ for (let i = 0; i < value.length; i++) {
1411
+ parts.push(walk(value[i], state));
1412
+ }
1413
+ push(state, ']');
1414
+ return `a[${parts.join(',')}]`;
1415
+ }
1416
+ // Plain object ONLY. A class instance, Map, Set, RegExp, Error, etc. is
1417
+ // declined: two different classes with identical fields would otherwise
1418
+ // serialize the same and collide.
1419
+ const proto = Object.getPrototypeOf(value);
1420
+ if (proto !== Object.prototype && proto !== null) {
1421
+ throw new Decline('non-plain-object');
1422
+ }
1423
+ // Recursive key sort - the existing top-level sort is not enough for nesting.
1424
+ const keys = Object.keys(value).sort();
1425
+ const parts = [];
1426
+ push(state, 'o{');
1427
+ for (const key of keys) {
1428
+ const encoded_key = encode_string(state, 'k', key);
1429
+ parts.push(`${encoded_key}=${walk(value[key], state)}`);
1430
+ }
1431
+ push(state, '}');
1432
+ return `o{${parts.join(',')}}`;
1433
+ }
1434
+ finally {
1435
+ state.seen.delete(value);
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Serialize a value for use in a cache key.
1440
+ *
1441
+ * @returns `{ok: true, text}` when the value is exactly expressible, or
1442
+ * `{ok: false, reason}` naming why it is not. Never throws.
1443
+ */
1444
+ function serialize_for_cache_key(value) {
1445
+ const state = { seen: new WeakSet(), size: 0 };
1446
+ try {
1447
+ return { ok: true, text: walk(value, state) };
1448
+ }
1449
+ catch (error) {
1450
+ if (error instanceof Decline) {
1451
+ return { ok: false, reason: error.reason };
1452
+ }
1453
+ // A throwing getter or exotic proxy trap - decline rather than guess.
1454
+ return { ok: false, reason: 'unserializable' };
1455
+ }
1456
+ }
1457
+
1233
1458
  /**
1234
1459
  * Load Coordinator - Request deduplication for component on_load() calls
1235
1460
  *
@@ -1266,8 +1491,9 @@ class Load_Coordinator {
1266
1491
  * - key: Cache key string, or null if uncacheable
1267
1492
  * - uncacheable_property: Name of first property that prevented caching (for debugging)
1268
1493
  */
1269
- static generate_invocation_key(component_name, args) {
1494
+ static generate_invocation_key(component_name, args, options = {}) {
1270
1495
  let uncacheable_property;
1496
+ let uncacheable_reason;
1271
1497
  // Filter out internal properties (starting with _) and serialize args
1272
1498
  const serializable_args = {};
1273
1499
  for (const key of Object.keys(args).sort()) {
@@ -1306,21 +1532,42 @@ class Load_Coordinator {
1306
1532
  // Method threw error - treat as uncacheable
1307
1533
  if (!uncacheable_property) {
1308
1534
  uncacheable_property = key;
1535
+ uncacheable_reason = 'cache-id-threw';
1309
1536
  }
1310
- return { key: null, uncacheable_property };
1537
+ return { key: null, uncacheable_property, uncacheable_reason };
1311
1538
  }
1312
1539
  }
1313
- // No cache ID available - this property is uncacheable
1540
+ // No author-supplied id. The cache may fall back to keying by
1541
+ // deterministic content; deduplication deliberately may not.
1542
+ if (options.allow_content_serialization) {
1543
+ const serialized = serialize_for_cache_key(value);
1544
+ if (serialized.ok) {
1545
+ serializable_args[key] = `__JQHTML_CONTENT__:${serialized.text}`;
1546
+ continue;
1547
+ }
1548
+ if (!uncacheable_property) {
1549
+ uncacheable_property = key;
1550
+ // This package compiles with strictNullChecks off, and without it
1551
+ // TypeScript will not narrow a discriminated union by its boolean
1552
+ // discriminant - so `serialized` is still the full Serialize_Result
1553
+ // here even though `ok` has been ruled out above. The cast states
1554
+ // what the control flow already guarantees.
1555
+ uncacheable_reason = serialized.reason;
1556
+ }
1557
+ return { key: null, uncacheable_property, uncacheable_reason };
1558
+ }
1314
1559
  if (!uncacheable_property) {
1315
1560
  uncacheable_property = key;
1561
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
1316
1562
  }
1317
- return { key: null, uncacheable_property };
1563
+ return { key: null, uncacheable_property, uncacheable_reason };
1318
1564
  }
1319
1565
  // Unknown type (symbol, bigint, etc.) - uncacheable
1320
1566
  if (!uncacheable_property) {
1321
1567
  uncacheable_property = key;
1568
+ uncacheable_reason = value_type;
1322
1569
  }
1323
- return { key: null, uncacheable_property };
1570
+ return { key: null, uncacheable_property, uncacheable_reason };
1324
1571
  }
1325
1572
  // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
1326
1573
  try {
@@ -1338,6 +1585,12 @@ class Load_Coordinator {
1338
1585
  */
1339
1586
  static should_execute_on_load(component) {
1340
1587
  const { key } = this.generate_invocation_key(component.component_name(), component.args);
1588
+ // No identity means no coordination. Without this, every un-keyable component
1589
+ // would share the single `null` registry entry and adopt an unrelated
1590
+ // component's data - the worst failure this system can produce.
1591
+ if (key === null) {
1592
+ return true;
1593
+ }
1341
1594
  const entry = this._registry.get(key);
1342
1595
  if (!entry) {
1343
1596
  // No existing request - this component becomes the leader
@@ -1360,6 +1613,10 @@ class Load_Coordinator {
1360
1613
  */
1361
1614
  static register_leader(component, on_load_promise) {
1362
1615
  const { key } = this.generate_invocation_key(component.component_name(), component.args);
1616
+ // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1617
+ if (key === null) {
1618
+ return () => { };
1619
+ }
1363
1620
  // Create a promise that we control - it resolves when complete_coordination is called
1364
1621
  let resolve_promise;
1365
1622
  const coordination_promise = new Promise((resolve) => {
@@ -1581,6 +1838,8 @@ Load_Coordinator._registry = new Map();
1581
1838
  // Registry mapping class names to constructors
1582
1839
  const class_registry = Object.create(null);
1583
1840
  // Markers for serialized class instances (unique to avoid collisions with user data)
1841
+ // Replaced at build time by rollup (see rollup.config.js).
1842
+ const CORE_VERSION = '2.3.59';
1584
1843
  const CLASS_MARKER = '__jqhtml_class__';
1585
1844
  const PROPS_MARKER = '__jqhtml_props__';
1586
1845
  /**
@@ -1870,6 +2129,12 @@ class Jqhtml_Local_Storage {
1870
2129
  */
1871
2130
  static set_cache_key(cache_key, cache_mode = 'data') {
1872
2131
  this._cache_key = cache_key;
2132
+ // Key SHAPE is a function of the library (see cache-key-serializer.ts), so an
2133
+ // upgrade that changes it must invalidate everything persisted under the old shape.
2134
+ // Version the SCOPE MARKER rather than the key itself: _validate_scope() already
2135
+ // clears every jqhtml key when this value changes, and leaving the per-entry key
2136
+ // format alone keeps stored keys readable and avoids repeating the version in each.
2137
+ this._scope_marker = `${CORE_VERSION}::${cache_key}`;
1873
2138
  this._cache_mode = cache_mode;
1874
2139
  this._init();
1875
2140
  }
@@ -1939,21 +2204,21 @@ class Jqhtml_Local_Storage {
1939
2204
  }
1940
2205
  try {
1941
2206
  const stored_key = localStorage.getItem('_jqhtml_cache_key');
1942
- // If cache key exists and has changed, clear only JQHTML keys
1943
- if (stored_key !== null && stored_key !== this._cache_key) {
1944
- console.log('[JQHTML Local Storage] Cache key changed, clearing JQHTML keys:', {
2207
+ // If the scope marker (core version + developer key) changed, clear only JQHTML keys
2208
+ if (stored_key !== null && stored_key !== this._scope_marker) {
2209
+ console.log('[JQHTML Local Storage] Cache scope changed, clearing JQHTML keys:', {
1945
2210
  old_key: stored_key,
1946
- new_key: this._cache_key,
2211
+ new_key: this._scope_marker,
1947
2212
  });
1948
2213
  this._clear_jqhtml_keys();
1949
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2214
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1950
2215
  }
1951
2216
  else if (stored_key === null) {
1952
2217
  // First time JQHTML is using this storage - just set the key, don't clear
1953
- console.log('[JQHTML Local Storage] Initializing cache key (first use):', {
1954
- new_key: this._cache_key,
2218
+ console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2219
+ new_key: this._scope_marker,
1955
2220
  });
1956
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2221
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1957
2222
  }
1958
2223
  }
1959
2224
  catch (e) {
@@ -2185,6 +2450,8 @@ class Jqhtml_Local_Storage {
2185
2450
  }
2186
2451
  }
2187
2452
  Jqhtml_Local_Storage._cache_key = null;
2453
+ /** Versioned scope identity - compared in _validate_scope() to clear on a core upgrade. */
2454
+ Jqhtml_Local_Storage._scope_marker = null;
2188
2455
  Jqhtml_Local_Storage._cache_mode = 'data';
2189
2456
  Jqhtml_Local_Storage._storage_available = null;
2190
2457
  Jqhtml_Local_Storage._initialized = false;
@@ -2282,10 +2549,15 @@ function event_once(component, event_name, callback) {
2282
2549
  function event_trigger(component, event_name, data) {
2283
2550
  // Mark this event as occurred and store the data for late subscribers
2284
2551
  component._lifecycle_states.set(event_name, data);
2285
- // Fire all registered callbacks for this event
2552
+ // Fire all registered callbacks for this event.
2553
+ // Iterate a SNAPSHOT: handlers registered via once() splice themselves out of the
2554
+ // live array while it is being iterated, which would shift later handlers into
2555
+ // already-visited slots and skip them. The snapshot also means a handler
2556
+ // registered during dispatch does not fire in this dispatch (event_on's sticky
2557
+ // immediate-fire already delivered it).
2286
2558
  const callbacks = component._lifecycle_callbacks.get(event_name);
2287
2559
  if (callbacks) {
2288
- for (const callback of callbacks) {
2560
+ for (const callback of [...callbacks]) {
2289
2561
  try {
2290
2562
  callback.bind(component)(component, data);
2291
2563
  }
@@ -2570,12 +2842,73 @@ function generate_cache_key(component) {
2570
2842
  return { cache_key: `${component.component_name()}::${String(custom_cache_id)}` };
2571
2843
  }
2572
2844
  catch (error) {
2573
- return { cache_key: null, uncacheable_property: 'cache_id()' };
2845
+ return { cache_key: null, uncacheable_property: 'cache_id()', uncacheable_reason: 'cache-id-threw' };
2574
2846
  }
2575
2847
  }
2576
- // Use standard args-based cache key generation
2577
- const result = Load_Coordinator.generate_invocation_key(component.component_name(), component.args);
2578
- return { cache_key: result.key, uncacheable_property: result.uncacheable_property };
2848
+ // Standard args-based key. The CACHE opts into content serialization for plain-data
2849
+ // object args; deduplication does not - see InvocationKeyOptions for why.
2850
+ const result = Load_Coordinator.generate_invocation_key(component.component_name(), component.args, { allow_content_serialization: true });
2851
+ return {
2852
+ cache_key: result.key,
2853
+ uncacheable_property: result.uncacheable_property,
2854
+ uncacheable_reason: result.uncacheable_reason,
2855
+ };
2856
+ }
2857
+ // One warning per component+property. A list of 50 rows must not print 50 warnings.
2858
+ const _warned_uncacheable = new Set();
2859
+ /**
2860
+ * Development warning: this component fetches data but cannot be cached, because
2861
+ * its args do not reduce to a stable key and it defines no cache_id() to say what
2862
+ * its identity is. Nothing is broken - the component works - but it silently opts
2863
+ * out of cache reuse and of load deduplication.
2864
+ *
2865
+ * Controlled by the `warn_uncacheable_args` integration flag (see config.ts).
2866
+ */
2867
+ function warn_uncacheable_component(component, uncacheable_property, uncacheable_reason) {
2868
+ if (!warn_uncacheable_args_enabled())
2869
+ return;
2870
+ let name = 'unknown';
2871
+ try {
2872
+ name = component.component_name();
2873
+ }
2874
+ catch { /* name unavailable */ }
2875
+ const dedupe_key = `${name}::${uncacheable_property || '?'}::${uncacheable_reason || '?'}`;
2876
+ if (_warned_uncacheable.has(dedupe_key))
2877
+ return;
2878
+ _warned_uncacheable.add(dedupe_key);
2879
+ // cache_id() exists but threw - a different problem with a different fix
2880
+ if (uncacheable_property === 'cache_id()' || uncacheable_reason === 'cache-id-threw') {
2881
+ console.warn(`[JQHTML] <${name}> defines cache_id() but it threw, so this component is not cached ` +
2882
+ `and its loads are not deduplicated. Fix cache_id() so it returns a stable string.`, { component: name, cid: component._cid });
2883
+ return;
2884
+ }
2885
+ const REASONS = {
2886
+ 'function': 'it contains a function - a callback is real identity that content cannot express',
2887
+ 'symbol': 'it contains a symbol',
2888
+ 'bigint': 'it contains a bigint',
2889
+ 'dom-node': 'it contains a DOM node',
2890
+ 'jquery': 'it contains a jQuery object',
2891
+ 'circular': 'it contains a circular reference',
2892
+ 'non-plain-object': 'it contains a class instance, Map, Set or similar - only plain objects and arrays can be keyed by content',
2893
+ 'too-large': 'it serializes to more than 500 bytes - args are meant to identify data, not carry it',
2894
+ 'invalid-date': 'it contains an invalid Date',
2895
+ };
2896
+ const why = REASONS[uncacheable_reason || ''] || 'it cannot be serialized deterministically';
2897
+ console.warn(`[JQHTML] <${name}> cannot be cached: arg $${uncacheable_property} is not keyable because ` +
2898
+ `${why}.\n` +
2899
+ ` The component works, but it will not be restored from cache when reused.\n` +
2900
+ ` Fix: define cache_id() on the component to state its identity explicitly, e.g.\n` +
2901
+ ` cache_id() { return \`${name.toLowerCase()}_\${this.args.some_id}\`; }\n` +
2902
+ ` Or pass plain data (objects/arrays of primitives are keyed automatically) instead of ` +
2903
+ `$${uncacheable_property}.\n` +
2904
+ ` Find it in DevTools with the selector ` +
2905
+ `[data-nocache="${uncacheable_property}:${uncacheable_reason || ''}"].`, {
2906
+ component: name,
2907
+ cid: component._cid,
2908
+ uncacheable_arg: uncacheable_property,
2909
+ reason: uncacheable_reason,
2910
+ all_args: component.args ? Object.keys(component.args) : [],
2911
+ });
2579
2912
  }
2580
2913
  /**
2581
2914
  * Read cache during create() phase.
@@ -2584,13 +2917,15 @@ function generate_cache_key(component) {
2584
2917
  * @param component - The component instance
2585
2918
  */
2586
2919
  function read_cache_in_create(component) {
2587
- const { cache_key, uncacheable_property } = generate_cache_key(component);
2920
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(component);
2588
2921
  // If cache_key is null, caching disabled
2589
2922
  if (cache_key === null) {
2590
2923
  // Set data-nocache attribute for debugging
2591
2924
  if (uncacheable_property) {
2592
- component.$.attr('data-nocache', uncacheable_property);
2925
+ // "<arg>:<reason>" - a bare property name left authors reverse-engineering why
2926
+ component.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
2593
2927
  }
2928
+ warn_uncacheable_component(component, uncacheable_property, uncacheable_reason);
2594
2929
  if (window.jqhtml?.debug?.verbose) {
2595
2930
  console.log(`[Cache] Component ${component._cid} (${component.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2596
2931
  }
@@ -3004,6 +3339,75 @@ function has_preload_data() {
3004
3339
  return _preload_cache.size > 0;
3005
3340
  }
3006
3341
 
3342
+ /**
3343
+ * JQHTML Viewport Resize Dispatch
3344
+ *
3345
+ * One framework-owned, debounced window 'resize' listener fans out to every live
3346
+ * component in the document by calling on_viewport_resize(viewport_width).
3347
+ *
3348
+ * Components never bind their own window listener, so there is nothing to unbind
3349
+ * in on_stop() - a component that is stopped or removed from the document simply
3350
+ * stops being found by the dispatcher.
3351
+ */
3352
+ /** Trailing-edge debounce applied to the window resize listener. */
3353
+ const VIEWPORT_RESIZE_DEBOUNCE_MS = 30;
3354
+ let debounce_timer = null;
3355
+ let listener_installed = false;
3356
+ /**
3357
+ * Current viewport width in CSS pixels.
3358
+ *
3359
+ * window.innerWidth is used (not $(window).width()) because it matches the width
3360
+ * CSS media queries resolve against. It includes the scrollbar gutter.
3361
+ *
3362
+ * Returns 0 outside a browser (SSR).
3363
+ */
3364
+ function get_viewport_width() {
3365
+ return typeof window !== 'undefined' ? window.innerWidth : 0;
3366
+ }
3367
+ /**
3368
+ * Call on_viewport_resize(width) on every component currently in the document.
3369
+ *
3370
+ * Walks $('.Component') in document order (parents before children). Elements
3371
+ * with no component instance and stopped components are skipped. A component
3372
+ * that throws is logged and the walk continues - one bad handler must not stop
3373
+ * the rest of the page from resizing.
3374
+ */
3375
+ function dispatch_viewport_resize(jQuery) {
3376
+ const viewport_width = get_viewport_width();
3377
+ jQuery('.Component').each(function () {
3378
+ const component = jQuery(this).data('_component');
3379
+ if (!component || component._stopped)
3380
+ return;
3381
+ if (typeof component.on_viewport_resize !== 'function')
3382
+ return;
3383
+ try {
3384
+ component.on_viewport_resize(viewport_width);
3385
+ }
3386
+ catch (error) {
3387
+ console.error(`[JQHTML] Error in on_viewport_resize() for ${component.component_name?.() || 'component'}:`, error);
3388
+ }
3389
+ });
3390
+ }
3391
+ /**
3392
+ * Install the single window resize listener. Idempotent - repeated init() calls
3393
+ * and non-browser environments are no-ops.
3394
+ */
3395
+ function init_viewport_resize(jQuery) {
3396
+ if (typeof window === 'undefined')
3397
+ return;
3398
+ if (listener_installed)
3399
+ return;
3400
+ listener_installed = true;
3401
+ jQuery(window).on('resize.jqhtml_viewport', function () {
3402
+ if (debounce_timer !== null)
3403
+ clearTimeout(debounce_timer);
3404
+ debounce_timer = setTimeout(function () {
3405
+ debounce_timer = null;
3406
+ dispatch_viewport_resize(jQuery);
3407
+ }, VIEWPORT_RESIZE_DEBOUNCE_MS);
3408
+ });
3409
+ }
3410
+
3007
3411
  /**
3008
3412
  * JQHTML v2 Component Base Class
3009
3413
  *
@@ -3198,6 +3602,21 @@ class Jqhtml_Component {
3198
3602
  // Call original directly - no flag needed since we bypass the wrapper
3199
3603
  return impl.call(this);
3200
3604
  }
3605
+ /**
3606
+ * Fire on_viewport_resize() with the current viewport width.
3607
+ * Called after every on_render() and on_ready(); the debounced window
3608
+ * listener in viewport.ts drives it from then on. Errors are isolated so a
3609
+ * bad handler cannot break the lifecycle.
3610
+ * @private
3611
+ */
3612
+ _fire_viewport_resize() {
3613
+ try {
3614
+ this.on_viewport_resize(get_viewport_width());
3615
+ }
3616
+ catch (error) {
3617
+ console.error(`[JQHTML] Error in on_viewport_resize() for ${this.component_name()}:`, error);
3618
+ }
3619
+ }
3201
3620
  /**
3202
3621
  * Check if on_load() is overridden in a subclass
3203
3622
  * Used to skip the load phase entirely for components that don't fetch data
@@ -3282,6 +3701,7 @@ class Jqhtml_Component {
3282
3701
  console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3283
3702
  `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3284
3703
  }
3704
+ this._fire_viewport_resize();
3285
3705
  }
3286
3706
  // Emit lifecycle event
3287
3707
  this.trigger('render');
@@ -3473,6 +3893,7 @@ class Jqhtml_Component {
3473
3893
  console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3474
3894
  `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3475
3895
  }
3896
+ this._fire_viewport_resize();
3476
3897
  }
3477
3898
  // Emit lifecycle event
3478
3899
  this.trigger('render');
@@ -3547,6 +3968,7 @@ class Jqhtml_Component {
3547
3968
  }
3548
3969
  // Call on_ready hook with authorization
3549
3970
  await this._call_lifecycle('on_ready');
3971
+ this._fire_viewport_resize();
3550
3972
  // Trigger ready event
3551
3973
  this.trigger('ready');
3552
3974
  });
@@ -3773,9 +4195,18 @@ class Jqhtml_Component {
3773
4195
  }
3774
4196
  return;
3775
4197
  }
3776
- // Generate cache key (needed for both preload check and load deduplication)
4198
+ // TWO identities, deliberately computed separately.
4199
+ //
4200
+ // cache_key - may key a plain-data object arg by deterministic CONTENT, and may come
4201
+ // from the author's cache_id(). A wrong key here is corrected by
4202
+ // stale-while-revalidate.
4203
+ // dedup_key - args only, no content serialization, no cache_id(). A deduplicated
4204
+ // follower never runs on_load() and adopts the leader's data with NO
4205
+ // revalidation, so a wrong key here is permanently wrong data. Redundant
4206
+ // concurrent requests are the cheaper failure.
3777
4207
  let cache_key = null;
3778
4208
  let uncacheable_property;
4209
+ let uncacheable_reason;
3779
4210
  if (typeof this.cache_id === 'function') {
3780
4211
  try {
3781
4212
  const custom_cache_id = this.cache_id();
@@ -3784,14 +4215,16 @@ class Jqhtml_Component {
3784
4215
  catch (error) {
3785
4216
  // cache_id() threw error - disable caching
3786
4217
  uncacheable_property = 'cache_id()';
4218
+ uncacheable_reason = 'cache-id-threw';
3787
4219
  }
3788
4220
  }
3789
4221
  else {
3790
- // Use standard args-based cache key generation
3791
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4222
+ const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
3792
4223
  cache_key = result.key;
3793
4224
  uncacheable_property = result.uncacheable_property;
4225
+ uncacheable_reason = result.uncacheable_reason;
3794
4226
  }
4227
+ const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
3795
4228
  // SSR preload check: if preloaded data exists for this component+args, use it
3796
4229
  if (cache_key !== null && has_preload_data()) {
3797
4230
  const preloaded_data = consume_preload_data(cache_key);
@@ -3807,23 +4240,24 @@ class Jqhtml_Component {
3807
4240
  }
3808
4241
  // Store cache key for later use
3809
4242
  this._cache_key = cache_key;
3810
- // If cache_key is null, args are not serializable - skip load deduplication and caching
3811
- if (cache_key === null) {
3812
- // Set data-nocache attribute for debugging (shows which property prevented caching)
3813
- if (uncacheable_property) {
3814
- this.$.attr('data-nocache', uncacheable_property);
4243
+ // Caching disabled - mark the element with the offending arg AND why
4244
+ if (cache_key === null && uncacheable_property) {
4245
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4246
+ if (window.jqhtml?.debug?.verbose) {
4247
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-keyable args - caching disabled`, { uncacheable_property, uncacheable_reason });
3815
4248
  }
4249
+ }
4250
+ // Store "before" snapshot for comparison after on_load()
4251
+ const data_before_load = JSON.stringify(this.data);
4252
+ // No dedup identity - run on_load() alone rather than risk sharing a load
4253
+ if (dedup_key === null) {
3816
4254
  if (window.jqhtml?.debug?.verbose) {
3817
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-serializable args - load deduplication and caching disabled`, { uncacheable_property });
4255
+ console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
3818
4256
  }
3819
- // Execute on_load on detached proxy without deduplication
3820
4257
  const { data: result_data } = await this._execute_on_load_detached();
3821
- // Apply result via sequential queue
3822
- await this._apply_load_result(result_data, null);
4258
+ await this._apply_load_result(result_data, data_before_load);
3823
4259
  return;
3824
4260
  }
3825
- // Store "before" snapshot for comparison after on_load()
3826
- const data_before_load = JSON.stringify(this.data);
3827
4261
  // Check if this component should execute on_load() or wait for existing request
3828
4262
  const should_execute = Load_Coordinator.should_execute_on_load(this);
3829
4263
  if (!should_execute) {
@@ -3972,6 +4406,7 @@ class Jqhtml_Component {
3972
4406
  // Wait for all children to reach ready state (bottom-up execution)
3973
4407
  await this._wait_for_children_ready();
3974
4408
  await this._call_lifecycle('on_ready');
4409
+ this._fire_viewport_resize();
3975
4410
  this._ready_state = 4;
3976
4411
  this._update_debug_attrs();
3977
4412
  this._log_lifecycle('ready', 'complete');
@@ -4216,6 +4651,7 @@ class Jqhtml_Component {
4216
4651
  this._render();
4217
4652
  await this._wait_for_children_ready();
4218
4653
  await this._call_lifecycle('on_ready');
4654
+ this._fire_viewport_resize();
4219
4655
  this.trigger('ready');
4220
4656
  this._log_lifecycle('reload', 'complete (no on_load)');
4221
4657
  return;
@@ -4290,6 +4726,7 @@ class Jqhtml_Component {
4290
4726
  if (rendered_from_cache || should_render) {
4291
4727
  await this._wait_for_children_ready();
4292
4728
  await this._call_lifecycle('on_ready');
4729
+ this._fire_viewport_resize();
4293
4730
  // Trigger ready event so parent callbacks fire on subsequent reloads
4294
4731
  this.trigger('ready');
4295
4732
  }
@@ -4372,6 +4809,16 @@ class Jqhtml_Component {
4372
4809
  on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
4373
4810
  async on_ready() { }
4374
4811
  on_stop() { }
4812
+ /**
4813
+ * Called with the viewport width in CSS pixels:
4814
+ * - after every on_render()
4815
+ * - after every on_ready()
4816
+ * - on window resize, debounced 30ms, for every component in the document
4817
+ *
4818
+ * MUST be synchronous. Prefer CSS media/container queries for pure styling -
4819
+ * override this only when layout requires imperative measurement.
4820
+ */
4821
+ on_viewport_resize(viewport_width) { }
4375
4822
  /**
4376
4823
  * Should component re-render after load?
4377
4824
  * By default, only re-renders if data has changed
@@ -4526,11 +4973,21 @@ class Jqhtml_Component {
4526
4973
  }
4527
4974
  /**
4528
4975
  * Find closest ancestor component matching selector
4976
+ *
4977
+ * A bare component name - a plain identifier starting with a capital letter,
4978
+ * such as 'Parent_Dashboard' - is treated as the component-name class
4979
+ * selector '.Parent_Dashboard'. Components carry a CSS class for every name
4980
+ * in their prototype chain, so a base class name matches its subclasses too.
4981
+ * Every other selector ('.Foo', '#id', '[attr]', 'div > .Component') is
4982
+ * passed to jQuery unchanged.
4529
4983
  */
4530
4984
  closest(selector) {
4985
+ const search = Jqhtml_Component.COMPONENT_NAME_PATTERN.test(selector)
4986
+ ? `.${selector}`
4987
+ : selector;
4531
4988
  let current = this.$.parent();
4532
4989
  while (current.length > 0) {
4533
- if (current.is(selector)) {
4990
+ if (current.is(search)) {
4534
4991
  const comp = current.data('_component');
4535
4992
  if (comp instanceof Jqhtml_Component) {
4536
4993
  return comp;
@@ -4746,8 +5203,13 @@ class Jqhtml_Component {
4746
5203
  }
4747
5204
  }
4748
5205
  _set_attributes() {
4749
- // Always set data-cid
4750
- this.$.attr('data-cid', this._cid);
5206
+ // data-cid on the rendered element is debug-only: it mirrors this._cid so component
5207
+ // boundaries are legible in DevTools. The runtime never reads it - scoping uses the
5208
+ // _cid property, and the transient data-cid placeholder the instruction processor
5209
+ // matches on is written and removed before this ever runs.
5210
+ if (debug_attributes_enabled()) {
5211
+ this.$.attr('data-cid', this._cid);
5212
+ }
4751
5213
  // Only set lifecycle state attribute if verbose mode enabled
4752
5214
  if (window.jqhtml?.debug?.verbose) {
4753
5215
  this.$.attr('data-_lifecycle-state', this._ready_state.toString());
@@ -4822,6 +5284,8 @@ class Jqhtml_Component {
4822
5284
  }
4823
5285
  // Static properties
4824
5286
  Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() detection
5287
+ // A bare component name, as accepted by closest() in place of a class selector
5288
+ Jqhtml_Component.COMPONENT_NAME_PATTERN = /^[A-Z][A-Za-z0-9_]*$/;
4825
5289
 
4826
5290
  /**
4827
5291
  * JQHTML v2 Template Renderer
@@ -5686,6 +6150,9 @@ function init_jquery_plugin(jQuery) {
5686
6150
  PatchedInit.prototype = jQuery.fn;
5687
6151
  jQuery.fn.init = PatchedInit;
5688
6152
  }
6153
+ // Install the single debounced window resize listener that drives
6154
+ // on_viewport_resize() on every component. Idempotent.
6155
+ init_viewport_resize(jQuery);
5689
6156
  }
5690
6157
  // Try to auto-initialize if global jQuery exists
5691
6158
  if (typeof window !== 'undefined' && window.jQuery) {
@@ -5699,7 +6166,9 @@ if (typeof window !== 'undefined' && window.jQuery) {
5699
6166
  */
5700
6167
  // Core exports
5701
6168
  // Main initialization function
5702
- function init(jQuery) {
6169
+ function init(jQuery, config) {
6170
+ // Integration config first - it governs behaviour during the very first render
6171
+ configure(config);
5703
6172
  // If jQuery is provided, initialize the plugin
5704
6173
  if (jQuery) {
5705
6174
  init_jquery_plugin(jQuery);
@@ -5713,7 +6182,7 @@ function init(jQuery) {
5713
6182
  }
5714
6183
  }
5715
6184
  // Version - will be replaced during build with actual version from package.json
5716
- const version = '2.3.50';
6185
+ const version = '2.3.59';
5717
6186
  // Default export with all functionality
5718
6187
  const jqhtml = {
5719
6188
  // Core
@@ -5741,6 +6210,9 @@ const jqhtml = {
5741
6210
  __version: version,
5742
6211
  // state facts
5743
6212
  tombstone: 'pepperoni and cheese',
6213
+ // Integration configuration - see config.ts
6214
+ configure,
6215
+ get_config,
5744
6216
  // Debug settings
5745
6217
  debug: {
5746
6218
  enabled: false,
@@ -5854,6 +6326,7 @@ exports.Load_Coordinator = Load_Coordinator;
5854
6326
  exports.applyDebugDelay = applyDebugDelay;
5855
6327
  exports.boot = boot;
5856
6328
  exports.clear_preload_data = clear_preload_data;
6329
+ exports.configure = configure;
5857
6330
  exports.create_component = create_component;
5858
6331
  exports.default = jqhtml;
5859
6332
  exports.devWarn = devWarn;
@@ -5863,6 +6336,7 @@ exports.extract_slots = extract_slots;
5863
6336
  exports.get_captured_data = get_captured_data;
5864
6337
  exports.get_component_class = get_component_class;
5865
6338
  exports.get_component_names = get_component_names;
6339
+ exports.get_config = get_config;
5866
6340
  exports.get_registered_templates = get_registered_templates;
5867
6341
  exports.get_template = get_template;
5868
6342
  exports.get_template_by_class = get_template_by_class;