@jqhtml/core 2.3.35 → 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.35
2
+ * JQHTML Core v2.3.54
3
3
  * (c) 2025 JQHTML Team
4
4
  * Released under the MIT License
5
5
  */
@@ -39,11 +39,9 @@ class LifecycleManager {
39
39
  * Boot a component - run its full lifecycle
40
40
  * Called when component is created
41
41
  *
42
- * Supports lifecycle skip flags:
43
- * - skip_render_and_ready: Skip first render/on_render and skip on_ready entirely.
44
- * Component reports ready after on_load completes.
45
- * - skip_ready: Skip on_ready only.
46
- * Component reports ready after on_render completes (after potential re-render from on_load).
42
+ * Supports lifecycle truncation flags:
43
+ * - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
44
+ * - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
47
45
  */
48
46
  async boot_component(component) {
49
47
  this.active_components.add(component);
@@ -55,20 +53,25 @@ class LifecycleManager {
55
53
  return;
56
54
  // Trigger create event
57
55
  component.trigger('create');
58
- // Check for skip_render_and_ready flag
59
- const skip_render_and_ready = component._skip_render_and_ready;
60
- const skip_ready = component._skip_ready;
56
+ // Check for lifecycle truncation flags
57
+ const load_only = component._load_only;
58
+ const load_render_only = component._load_render_only;
59
+ const is_detached = component._is_detached;
61
60
  let render_id;
62
- if (skip_render_and_ready) {
63
- // Skip the first render/on_render before on_load
64
- // Still need to set render_id to 0 for tracking
61
+ if (load_only || is_detached) {
62
+ // _load_only or detached: skip initial render entirely
65
63
  render_id = 0;
66
64
  component._render_count = 0;
67
65
  }
66
+ else if (load_render_only) {
67
+ // _load_render_only: render to create children, but skip on_render()
68
+ render_id = component._render(null, { skip_on_render: true });
69
+ // Check if stopped during render
70
+ if (component._stopped)
71
+ return;
72
+ }
68
73
  else {
69
- // Render phase - SYNCHRONOUS, creates DOM and child components
70
- // Note: _render() now calls on_render() internally after DOM update
71
- // Capture render ID to detect if another render happens before ready
74
+ // Normal: render with on_render()
72
75
  render_id = component._render();
73
76
  // Check if stopped during render
74
77
  if (component._stopped)
@@ -77,30 +80,39 @@ class LifecycleManager {
77
80
  // Load phase - ASYNC, but only if on_load() is overridden
78
81
  // Skip entirely if component uses default stub to avoid unnecessary yield
79
82
  if (component._has_on_load()) {
83
+ // Load gates: await any promises registered via gate_load() during
84
+ // on_create() before the FIRST on_load(). Gates delay ONLY this load
85
+ // phase — create/render/on_render/cached-paint already happened above.
86
+ // Released early by reload()/refresh()/stop() while gated (see component).
87
+ // Returns null (no await) for the common no-gate case to keep timing
88
+ // identical for ungated components.
89
+ const gate_wait = component._await_load_gates();
90
+ if (gate_wait) {
91
+ await gate_wait;
92
+ // Check if stopped while awaiting gates
93
+ if (component._stopped)
94
+ return;
95
+ }
80
96
  await component._load();
81
97
  // Yield to microtask queue after on_load completes
82
98
  // This ensures async queue tasks always run before the on_load process completes,
83
99
  // even if on_load() itself completes instantly (deterministic behavior)
84
100
  await Promise.resolve();
85
101
  }
86
- // Trigger 'loaded' event - fires after on_load completes (or would have completed)
87
- // This happens before the render/on_render second step
88
- component.trigger('loaded');
89
102
  // Check if stopped during load
90
103
  if (component._stopped)
91
104
  return;
92
- // If skip_render_and_ready, mark component as ready now and return
93
- if (skip_render_and_ready) {
94
- // Set ready state and trigger event
105
+ // If _load_only, mark component as ready now and return
106
+ if (load_only) {
95
107
  component._ready_state = 4;
96
108
  component._update_debug_attrs();
97
- component._log_lifecycle('ready', 'complete (skip_render_and_ready)');
109
+ component._log_lifecycle('ready', 'complete (_load_only)');
98
110
  component.trigger('ready');
99
111
  return;
100
112
  }
101
- // If data changed during load, re-render
102
- // Note: _render() now calls on_render() internally after DOM update
103
- if (component._should_rerender()) {
113
+ // Detached elements always render here (initial render was skipped)
114
+ // Normal elements re-render only if data changed during on_load
115
+ if (is_detached || component._should_rerender()) {
104
116
  // Disable animations during re-render to prevent visual artifacts
105
117
  // from CSS transitions/animations firing on newly rendered elements
106
118
  const $el = component.$;
@@ -117,11 +129,25 @@ class LifecycleManager {
117
129
  }, 5);
118
130
  });
119
131
  }
120
- render_id = component._render();
132
+ // _load_render_only: re-render with skip_on_render to suppress DOM hooks
133
+ if (load_render_only) {
134
+ render_id = component._render(null, { skip_on_render: true });
135
+ }
136
+ else {
137
+ render_id = component._render();
138
+ }
121
139
  // Check if stopped during re-render
122
140
  if (component._stopped)
123
141
  return;
124
142
  }
143
+ // _load_render_only: skip waiting for children, on_ready — mark ready and return
144
+ if (load_render_only) {
145
+ component._ready_state = 4;
146
+ component._update_debug_attrs();
147
+ component._log_lifecycle('ready', 'complete (_load_render_only)');
148
+ component.trigger('ready');
149
+ return;
150
+ }
125
151
  // Fire 'rendered' event - component has completed its synchronous render chain
126
152
  // This happens once, after the final on_render (whether from first render or re-render after on_load)
127
153
  if (!component._has_rendered) {
@@ -145,15 +171,6 @@ class LifecycleManager {
145
171
  if (component._render_count !== render_id) {
146
172
  return; // Stale render, don't call ready
147
173
  }
148
- // If skip_ready, mark component as ready now without calling _ready()
149
- if (skip_ready) {
150
- // Set ready state and trigger event
151
- component._ready_state = 4;
152
- component._update_debug_attrs();
153
- component._log_lifecycle('ready', 'complete (skip_ready)');
154
- component.trigger('ready');
155
- return;
156
- }
157
174
  // Ready phase - waits for children, then calls on_ready()
158
175
  await component._ready();
159
176
  // Check if stopped during ready
@@ -484,6 +501,70 @@ function register(source) {
484
501
  'class name mangling, define static component_name or use register_component().');
485
502
  }
486
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
+
487
568
  /**
488
569
  * JQHTML v2 Instruction Processor
489
570
  *
@@ -621,6 +702,9 @@ function process_tag_to_html(instruction, html, tagElements, components, context
621
702
  }
622
703
  // Add simple string/numeric attributes directly
623
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
+ }
624
708
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
625
709
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
626
710
  (typeof value === 'string' || typeof value === 'number')) {
@@ -659,20 +743,26 @@ function process_component_to_html(instruction, html, components, context) {
659
743
  // pass this behavior to all child components rendered in its template
660
744
  let props = originalProps;
661
745
  const parentArgs = context.args;
662
- // Check if we need to propagate any flags
663
- const propagate_use_cached_data = parentArgs?.use_cached_data === true && props.use_cached_data === undefined;
664
- const propagate_skip_render_and_ready = parentArgs?.skip_render_and_ready === true && props.skip_render_and_ready === undefined;
665
- const propagate_skip_ready = parentArgs?.skip_ready === true && props.skip_ready === undefined;
666
- if (propagate_use_cached_data || propagate_skip_render_and_ready || propagate_skip_ready) {
746
+ // Check if we need to propagate any flags.
747
+ // Template-authored flags arrive with the '$' still on the key ($_load_only=false
748
+ // compiles to props['$_load_only']) - honor both spellings so a child template can
749
+ // opt out of a cascading parent flag with an explicit false.
750
+ const own_use_cached_data = props.use_cached_data !== undefined ? props.use_cached_data : props['$use_cached_data'];
751
+ const own_load_only = props._load_only !== undefined ? props._load_only : props['$_load_only'];
752
+ const own_load_render_only = props._load_render_only !== undefined ? props._load_render_only : props['$_load_render_only'];
753
+ const propagate_use_cached_data = parentArgs?.use_cached_data === true && own_use_cached_data === undefined;
754
+ const propagate_load_only = parentArgs?._load_only === true && own_load_only === undefined;
755
+ const propagate_load_render_only = parentArgs?._load_render_only === true && own_load_render_only === undefined;
756
+ if (propagate_use_cached_data || propagate_load_only || propagate_load_render_only) {
667
757
  props = { ...originalProps };
668
758
  if (propagate_use_cached_data) {
669
759
  props.use_cached_data = true;
670
760
  }
671
- if (propagate_skip_render_and_ready) {
672
- props.skip_render_and_ready = true;
761
+ if (propagate_load_only) {
762
+ props._load_only = true;
673
763
  }
674
- if (propagate_skip_ready) {
675
- props.skip_ready = true;
764
+ if (propagate_load_render_only) {
765
+ props._load_render_only = true;
676
766
  }
677
767
  }
678
768
  // Determine if third parameter is a function (default content) or object (named slots)
@@ -704,7 +794,10 @@ function process_component_to_html(instruction, html, components, context) {
704
794
  const baseId = props['data-sid'];
705
795
  // The compiled code ALWAYS sets props['id'] with the correct scoped value
706
796
  // Just use it directly - it already has the correct parent _cid baked in
707
- 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']}"`);
708
801
  }
709
802
  // Regular id passes through unchanged
710
803
  else if (props['id']) {
@@ -846,6 +939,10 @@ function apply_attributes(element, attrs, context) {
846
939
  }
847
940
  }
848
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
+ }
849
946
  // Data attributes - go into DOM
850
947
  const attrValue = typeof value === "string" ? value.trim() : value;
851
948
  element.attr(key, attrValue);
@@ -988,6 +1085,14 @@ async function initialize_component(element, compData) {
988
1085
  if (ComponentClass.name !== name) {
989
1086
  options._component_name = name;
990
1087
  }
1088
+ // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1089
+ // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1090
+ if (props._load_only === true || props['$_load_only'] === true) {
1091
+ options._load_only = true;
1092
+ }
1093
+ if (props._load_render_only === true || props['$_load_render_only'] === true) {
1094
+ options._load_render_only = true;
1095
+ }
991
1096
  // Create component instance - element first, then options
992
1097
  const instance = new ComponentClass(element, options);
993
1098
  // Set the instantiator (the component that rendered this one in their template)
@@ -1200,6 +1305,146 @@ function handleComponentError(component, phase, error) {
1200
1305
  // 9. Performance Budgets - Warn when components exceed size/time limits
1201
1306
  // 10. Accessibility Auditing - Check for missing ARIA attributes
1202
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
+
1203
1448
  /**
1204
1449
  * Load Coordinator - Request deduplication for component on_load() calls
1205
1450
  *
@@ -1236,8 +1481,9 @@ class Load_Coordinator {
1236
1481
  * - key: Cache key string, or null if uncacheable
1237
1482
  * - uncacheable_property: Name of first property that prevented caching (for debugging)
1238
1483
  */
1239
- static generate_invocation_key(component_name, args) {
1484
+ static generate_invocation_key(component_name, args, options = {}) {
1240
1485
  let uncacheable_property;
1486
+ let uncacheable_reason;
1241
1487
  // Filter out internal properties (starting with _) and serialize args
1242
1488
  const serializable_args = {};
1243
1489
  for (const key of Object.keys(args).sort()) {
@@ -1245,7 +1491,8 @@ class Load_Coordinator {
1245
1491
  continue; // Skip internal properties
1246
1492
  }
1247
1493
  // Skip framework properties that shouldn't affect cache identity
1248
- if (key === 'use_cached_data' || key === 'skip_render_and_ready' || key === 'skip_ready') {
1494
+ // Note: _load_only and _load_render_only are already filtered by the _ prefix check above
1495
+ if (key === 'use_cached_data') {
1249
1496
  continue;
1250
1497
  }
1251
1498
  const value = args[key];
@@ -1275,21 +1522,37 @@ class Load_Coordinator {
1275
1522
  // Method threw error - treat as uncacheable
1276
1523
  if (!uncacheable_property) {
1277
1524
  uncacheable_property = key;
1525
+ uncacheable_reason = 'cache-id-threw';
1278
1526
  }
1279
- 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;
1280
1537
  }
1538
+ if (!uncacheable_property) {
1539
+ uncacheable_property = key;
1540
+ uncacheable_reason = serialized.reason;
1541
+ }
1542
+ return { key: null, uncacheable_property, uncacheable_reason };
1281
1543
  }
1282
- // No cache ID available - this property is uncacheable
1283
1544
  if (!uncacheable_property) {
1284
1545
  uncacheable_property = key;
1546
+ uncacheable_reason = value_type === 'function' ? 'function' : 'object';
1285
1547
  }
1286
- return { key: null, uncacheable_property };
1548
+ return { key: null, uncacheable_property, uncacheable_reason };
1287
1549
  }
1288
1550
  // Unknown type (symbol, bigint, etc.) - uncacheable
1289
1551
  if (!uncacheable_property) {
1290
1552
  uncacheable_property = key;
1553
+ uncacheable_reason = value_type;
1291
1554
  }
1292
- return { key: null, uncacheable_property };
1555
+ return { key: null, uncacheable_property, uncacheable_reason };
1293
1556
  }
1294
1557
  // Try to serialize - if it fails (shouldn't happen now, but safety net), return null
1295
1558
  try {
@@ -1307,6 +1570,12 @@ class Load_Coordinator {
1307
1570
  */
1308
1571
  static should_execute_on_load(component) {
1309
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
+ }
1310
1579
  const entry = this._registry.get(key);
1311
1580
  if (!entry) {
1312
1581
  // No existing request - this component becomes the leader
@@ -1329,6 +1598,10 @@ class Load_Coordinator {
1329
1598
  */
1330
1599
  static register_leader(component, on_load_promise) {
1331
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
+ }
1332
1605
  // Create a promise that we control - it resolves when complete_coordination is called
1333
1606
  let resolve_promise;
1334
1607
  const coordination_promise = new Promise((resolve) => {
@@ -1550,6 +1823,8 @@ Load_Coordinator._registry = new Map();
1550
1823
  // Registry mapping class names to constructors
1551
1824
  const class_registry = Object.create(null);
1552
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';
1553
1828
  const CLASS_MARKER = '__jqhtml_class__';
1554
1829
  const PROPS_MARKER = '__jqhtml_props__';
1555
1830
  /**
@@ -1623,7 +1898,13 @@ function process_for_serialization(value, verbose, seen) {
1623
1898
  // Return undefined to omit this value (not fail entire serialization)
1624
1899
  return undefined;
1625
1900
  }
1626
- // Handle circular references
1901
+ // Handle circular references.
1902
+ // `seen` tracks only the current ancestor path (the DFS stack): an object is
1903
+ // added on the way down and removed on the way back up (see finally below).
1904
+ // This flags a genuine cycle (a back-edge to an ancestor) while allowing a
1905
+ // shared reference in a DAG to be serialized independently on each visit.
1906
+ // JSON round-trip semantics do not preserve identity anyway, so emitting a
1907
+ // copy per reference is correct; silently omitting the property is not.
1627
1908
  if (seen.has(value)) {
1628
1909
  if (verbose) {
1629
1910
  console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
@@ -1631,69 +1912,89 @@ function process_for_serialization(value, verbose, seen) {
1631
1912
  return undefined; // Fail entire serialization
1632
1913
  }
1633
1914
  seen.add(value);
1634
- // Handle arrays
1635
- if (Array.isArray(value)) {
1636
- const result = [];
1637
- for (let i = 0; i < value.length; i++) {
1638
- const item = value[i];
1639
- const processed = process_for_serialization(item, verbose, seen);
1640
- // For arrays, we keep undefined as null to preserve indices
1641
- result.push(processed === undefined ? null : processed);
1915
+ try {
1916
+ // Handle arrays
1917
+ if (Array.isArray(value)) {
1918
+ const result = [];
1919
+ for (let i = 0; i < value.length; i++) {
1920
+ const item = value[i];
1921
+ const processed = process_for_serialization(item, verbose, seen);
1922
+ // For arrays, we keep undefined as null to preserve indices
1923
+ result.push(processed === undefined ? null : processed);
1924
+ }
1925
+ return result;
1642
1926
  }
1643
- return result;
1644
- }
1645
- // Handle Date objects specially
1646
- if (value instanceof Date) {
1647
- return {
1648
- [CLASS_MARKER]: 'Date',
1649
- [PROPS_MARKER]: value.toISOString()
1650
- };
1651
- }
1652
- // Handle Map objects
1653
- if (value instanceof Map) {
1654
- const entries = [];
1655
- for (const [k, v] of value) {
1656
- const processedKey = process_for_serialization(k, verbose, seen);
1657
- const processedValue = process_for_serialization(v, verbose, seen);
1658
- entries.push([processedKey, processedValue]);
1659
- }
1660
- return {
1661
- [CLASS_MARKER]: 'Map',
1662
- [PROPS_MARKER]: entries
1663
- };
1664
- }
1665
- // Handle Set objects
1666
- if (value instanceof Set) {
1667
- const items = [];
1668
- for (const item of value) {
1669
- items.push(process_for_serialization(item, verbose, seen));
1927
+ // Handle Date objects specially
1928
+ if (value instanceof Date) {
1929
+ return {
1930
+ [CLASS_MARKER]: 'Date',
1931
+ [PROPS_MARKER]: value.toISOString()
1932
+ };
1670
1933
  }
1671
- return {
1672
- [CLASS_MARKER]: 'Set',
1673
- [PROPS_MARKER]: items
1674
- };
1675
- }
1676
- // Get the constructor
1677
- const ctor = value.constructor;
1678
- // Handle registered class instances
1679
- if (ctor && ctor.name && class_registry[ctor.name]) {
1680
- const props = {};
1681
- // Extract own enumerable properties (bypass any toJSON method)
1682
- for (const key of Object.keys(value)) {
1683
- const propValue = value[key];
1684
- const processed = process_for_serialization(propValue, verbose, seen);
1685
- // Only include if not undefined (failed serialization)
1686
- if (processed !== undefined || propValue === undefined) {
1687
- props[key] = processed;
1934
+ // Handle Map objects
1935
+ if (value instanceof Map) {
1936
+ const entries = [];
1937
+ for (const [k, v] of value) {
1938
+ const processedKey = process_for_serialization(k, verbose, seen);
1939
+ const processedValue = process_for_serialization(v, verbose, seen);
1940
+ entries.push([processedKey, processedValue]);
1941
+ }
1942
+ return {
1943
+ [CLASS_MARKER]: 'Map',
1944
+ [PROPS_MARKER]: entries
1945
+ };
1946
+ }
1947
+ // Handle Set objects
1948
+ if (value instanceof Set) {
1949
+ const items = [];
1950
+ for (const item of value) {
1951
+ items.push(process_for_serialization(item, verbose, seen));
1688
1952
  }
1953
+ return {
1954
+ [CLASS_MARKER]: 'Set',
1955
+ [PROPS_MARKER]: items
1956
+ };
1689
1957
  }
1690
- return {
1691
- [CLASS_MARKER]: ctor.name,
1692
- [PROPS_MARKER]: props
1693
- };
1694
- }
1695
- // Handle plain objects (constructor is Object or no constructor)
1696
- if (ctor === Object || ctor === undefined || ctor === null) {
1958
+ // Get the constructor
1959
+ const ctor = value.constructor;
1960
+ // Handle registered class instances
1961
+ if (ctor && ctor.name && class_registry[ctor.name]) {
1962
+ const props = {};
1963
+ // Extract own enumerable properties (bypass any toJSON method)
1964
+ for (const key of Object.keys(value)) {
1965
+ const propValue = value[key];
1966
+ const processed = process_for_serialization(propValue, verbose, seen);
1967
+ // Only include if not undefined (failed serialization)
1968
+ if (processed !== undefined || propValue === undefined) {
1969
+ props[key] = processed;
1970
+ }
1971
+ }
1972
+ return {
1973
+ [CLASS_MARKER]: ctor.name,
1974
+ [PROPS_MARKER]: props
1975
+ };
1976
+ }
1977
+ // Handle plain objects (constructor is Object or no constructor)
1978
+ if (ctor === Object || ctor === undefined || ctor === null) {
1979
+ const result = {};
1980
+ for (const key of Object.keys(value)) {
1981
+ const propValue = value[key];
1982
+ const processed = process_for_serialization(propValue, verbose, seen);
1983
+ // Only include if not undefined (unless original was undefined)
1984
+ if (processed !== undefined || propValue === undefined) {
1985
+ result[key] = processed;
1986
+ }
1987
+ }
1988
+ return result;
1989
+ }
1990
+ // Unregistered class instance - convert to plain object for hot/cold parity
1991
+ // This ensures developers catch missing class registrations during development
1992
+ // (methods will be lost, only properties preserved)
1993
+ if (verbose) {
1994
+ console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
1995
+ `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
1996
+ }
1997
+ // Convert to plain object - properties are preserved, prototype methods are lost
1697
1998
  const result = {};
1698
1999
  for (const key of Object.keys(value)) {
1699
2000
  const propValue = value[key];
@@ -1705,24 +2006,10 @@ function process_for_serialization(value, verbose, seen) {
1705
2006
  }
1706
2007
  return result;
1707
2008
  }
1708
- // Unregistered class instance - convert to plain object for hot/cold parity
1709
- // This ensures developers catch missing class registrations during development
1710
- // (methods will be lost, only properties preserved)
1711
- if (verbose) {
1712
- console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
1713
- `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
1714
- }
1715
- // Convert to plain object - properties are preserved, prototype methods are lost
1716
- const result = {};
1717
- for (const key of Object.keys(value)) {
1718
- const propValue = value[key];
1719
- const processed = process_for_serialization(propValue, verbose, seen);
1720
- // Only include if not undefined (unless original was undefined)
1721
- if (processed !== undefined || propValue === undefined) {
1722
- result[key] = processed;
1723
- }
2009
+ finally {
2010
+ // Pop this object off the ancestor path now that its subtree is done.
2011
+ seen.delete(value);
1724
2012
  }
1725
- return result;
1726
2013
  }
1727
2014
  // ============================================================================
1728
2015
  // Deserialization (String → Object)
@@ -1827,6 +2114,12 @@ class Jqhtml_Local_Storage {
1827
2114
  */
1828
2115
  static set_cache_key(cache_key, cache_mode = 'data') {
1829
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}`;
1830
2123
  this._cache_mode = cache_mode;
1831
2124
  this._init();
1832
2125
  }
@@ -1896,21 +2189,21 @@ class Jqhtml_Local_Storage {
1896
2189
  }
1897
2190
  try {
1898
2191
  const stored_key = localStorage.getItem('_jqhtml_cache_key');
1899
- // If cache key exists and has changed, clear only JQHTML keys
1900
- if (stored_key !== null && stored_key !== this._cache_key) {
1901
- 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:', {
1902
2195
  old_key: stored_key,
1903
- new_key: this._cache_key,
2196
+ new_key: this._scope_marker,
1904
2197
  });
1905
2198
  this._clear_jqhtml_keys();
1906
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2199
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1907
2200
  }
1908
2201
  else if (stored_key === null) {
1909
2202
  // First time JQHTML is using this storage - just set the key, don't clear
1910
- console.log('[JQHTML Local Storage] Initializing cache key (first use):', {
1911
- new_key: this._cache_key,
2203
+ console.log('[JQHTML Local Storage] Initializing cache scope (first use):', {
2204
+ new_key: this._scope_marker,
1912
2205
  });
1913
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2206
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
1914
2207
  }
1915
2208
  }
1916
2209
  catch (e) {
@@ -2142,10 +2435,964 @@ class Jqhtml_Local_Storage {
2142
2435
  }
2143
2436
  }
2144
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;
2145
2440
  Jqhtml_Local_Storage._cache_mode = 'data';
2146
2441
  Jqhtml_Local_Storage._storage_available = null;
2147
2442
  Jqhtml_Local_Storage._initialized = false;
2148
2443
 
2444
+ /**
2445
+ * JQHTML Component Event System
2446
+ *
2447
+ * Extracted from component.ts - handles lifecycle event registration,
2448
+ * triggering, and state tracking.
2449
+ *
2450
+ * Events have "sticky" semantics for lifecycle events: if an event has already
2451
+ * occurred, new .on() handlers fire immediately with the stored data.
2452
+ */
2453
+ /**
2454
+ * Register a callback for an event.
2455
+ *
2456
+ * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2457
+ * If a lifecycle event has already occurred, the callback fires immediately
2458
+ * AND registers for future occurrences.
2459
+ *
2460
+ * Custom events only fire when explicitly triggered via trigger().
2461
+ *
2462
+ * @param component - The component instance
2463
+ * @param event_name - Name of the event
2464
+ * @param callback - Callback: (component, data?) => void
2465
+ */
2466
+ function event_on(component, event_name, callback) {
2467
+ // Initialize callback array for this event if needed
2468
+ if (!component._lifecycle_callbacks.has(event_name)) {
2469
+ component._lifecycle_callbacks.set(event_name, []);
2470
+ }
2471
+ // Add callback to queue
2472
+ component._lifecycle_callbacks.get(event_name).push(callback);
2473
+ // If this lifecycle event has already occurred, fire the callback immediately
2474
+ // with the stored data from when trigger() was called
2475
+ if (component._lifecycle_states.has(event_name)) {
2476
+ try {
2477
+ const stored_data = component._lifecycle_states.get(event_name);
2478
+ callback(component, stored_data);
2479
+ }
2480
+ catch (error) {
2481
+ console.error(`[JQHTML] Error in ${event_name} callback:`, error);
2482
+ }
2483
+ }
2484
+ return component;
2485
+ }
2486
+ /**
2487
+ * Register a callback that fires exactly once.
2488
+ *
2489
+ * - If the event already occurred (sticky), fires immediately and does NOT register.
2490
+ * - If the event has not occurred, registers and auto-deregisters after first fire.
2491
+ *
2492
+ * @param component - The component instance
2493
+ * @param event_name - Name of the event
2494
+ * @param callback - Callback: (component, data?) => void
2495
+ */
2496
+ function event_once(component, event_name, callback) {
2497
+ // If event already occurred, fire immediately and we're done
2498
+ if (component._lifecycle_states.has(event_name)) {
2499
+ try {
2500
+ const stored_data = component._lifecycle_states.get(event_name);
2501
+ callback(component, stored_data);
2502
+ }
2503
+ catch (error) {
2504
+ console.error(`[JQHTML] Error in ${event_name} once callback:`, error);
2505
+ }
2506
+ return component;
2507
+ }
2508
+ // Wrap callback to auto-deregister after first fire
2509
+ const wrapper = (comp, data) => {
2510
+ // Remove ourselves from the callback list
2511
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2512
+ if (callbacks) {
2513
+ const idx = callbacks.indexOf(wrapper);
2514
+ if (idx !== -1)
2515
+ callbacks.splice(idx, 1);
2516
+ }
2517
+ callback(comp, data);
2518
+ };
2519
+ // Initialize callback array for this event if needed
2520
+ if (!component._lifecycle_callbacks.has(event_name)) {
2521
+ component._lifecycle_callbacks.set(event_name, []);
2522
+ }
2523
+ component._lifecycle_callbacks.get(event_name).push(wrapper);
2524
+ return component;
2525
+ }
2526
+ /**
2527
+ * Trigger an event - fires all registered callbacks.
2528
+ * Marks event as occurred so future .on() calls fire immediately.
2529
+ *
2530
+ * @param component - The component instance
2531
+ * @param event_name - Name of the event to trigger
2532
+ * @param data - Optional data to pass to callbacks as second parameter
2533
+ */
2534
+ function event_trigger(component, event_name, data) {
2535
+ // Mark this event as occurred and store the data for late subscribers
2536
+ component._lifecycle_states.set(event_name, data);
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).
2543
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2544
+ if (callbacks) {
2545
+ for (const callback of [...callbacks]) {
2546
+ try {
2547
+ callback.bind(component)(component, data);
2548
+ }
2549
+ catch (error) {
2550
+ console.error(`[JQHTML] Error in ${event_name} callback:`, error);
2551
+ }
2552
+ }
2553
+ }
2554
+ }
2555
+ /**
2556
+ * Check if any callbacks are registered for a given event.
2557
+ *
2558
+ * @param component - The component instance
2559
+ * @param event_name - Name of the event to check
2560
+ */
2561
+ function event_on_registered(component, event_name) {
2562
+ const callbacks = component._lifecycle_callbacks.get(event_name);
2563
+ return !!(callbacks && callbacks.length > 0);
2564
+ }
2565
+ /**
2566
+ * Invalidate a lifecycle event - removes the "already occurred" marker.
2567
+ *
2568
+ * After invalidate() is called:
2569
+ * - New .on() handlers will NOT fire immediately
2570
+ * - The ready() promise will NOT resolve immediately
2571
+ * - Handlers wait for the next trigger() call
2572
+ *
2573
+ * Existing registered callbacks are NOT removed - they'll fire on next trigger().
2574
+ *
2575
+ * Use case: Call invalidate('ready') at the start of reload() or render()
2576
+ * so that any new .on('ready') handlers wait for the reload/render to complete.
2577
+ *
2578
+ * @param component - The component instance
2579
+ * @param event_name - Name of the event to invalidate
2580
+ */
2581
+ function event_invalidate(component, event_name) {
2582
+ component._lifecycle_states.delete(event_name);
2583
+ }
2584
+
2585
+ /**
2586
+ * JQHTML Data Proxy System
2587
+ *
2588
+ * Extracted from component.ts - handles:
2589
+ * - Data freeze/unfreeze enforcement via Proxy
2590
+ * - Detached on_load() execution with restricted access
2591
+ */
2592
+ /**
2593
+ * Set up the `this.data` property on a component using Object.defineProperty
2594
+ * with a Proxy that enforces freeze/unfreeze semantics.
2595
+ *
2596
+ * After setup:
2597
+ * - `this.data` reads/writes go through a Proxy
2598
+ * - When `component.__data_frozen === true`, writes throw errors
2599
+ * - When frozen is false, writes pass through normally
2600
+ *
2601
+ * @param component - The component instance to set up data on
2602
+ */
2603
+ function setup_data_property(component) {
2604
+ let _data = {};
2605
+ // Helper to create frozen proxy for data object
2606
+ const create_proxy = (obj) => {
2607
+ return new Proxy(obj, {
2608
+ set: (target, prop, value) => {
2609
+ if (component.__data_frozen) {
2610
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2611
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2612
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2613
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2614
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2615
+ `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2616
+ ` ❌ In on_ready(): this.data.${String(prop)} = ${JSON.stringify(value)};\n` +
2617
+ ` ✅ In on_create(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Set default\n` +
2618
+ ` ✅ In on_load(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Fetch from API\n` +
2619
+ ` ✅ For component state: this.args.${String(prop)} = ${JSON.stringify(value)}; (accessible in on_load)`);
2620
+ throw new Error(`[JQHTML] Cannot modify this.data.${String(prop)} outside of on_create() or on_load(). ` +
2621
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2622
+ }
2623
+ target[prop] = value;
2624
+ return true;
2625
+ },
2626
+ deleteProperty: (target, prop) => {
2627
+ if (component.__data_frozen) {
2628
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to delete this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2629
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2630
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2631
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2632
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.`);
2633
+ throw new Error(`[JQHTML] Cannot delete this.data.${String(prop)} outside of on_create() or on_load(). ` +
2634
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2635
+ }
2636
+ delete target[prop];
2637
+ return true;
2638
+ }
2639
+ });
2640
+ };
2641
+ // Create initial proxied data object
2642
+ _data = create_proxy({});
2643
+ Object.defineProperty(component, 'data', {
2644
+ get: () => _data,
2645
+ set: (value) => {
2646
+ if (component.__data_frozen) {
2647
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to reassign this.data outside of on_create() or on_load().\n\n` +
2648
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2649
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2650
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2651
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2652
+ `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2653
+ ` ❌ In on_ready(): this.data = {...};\n` +
2654
+ ` ✅ In on_create(): this.data.count = 0; // Set default\n` +
2655
+ ` ✅ In on_load(): this.data = await fetch(...); // Fetch from API\n` +
2656
+ ` ✅ For component state: this.args.count = 5; (accessible in on_load)`);
2657
+ throw new Error(`[JQHTML] Cannot reassign this.data outside of on_create() or on_load(). ` +
2658
+ `this.data is frozen after on_create() and unfrozen only during on_load().`);
2659
+ }
2660
+ // When setting, wrap the new value in a proxy too
2661
+ _data = create_proxy(value);
2662
+ },
2663
+ enumerable: true,
2664
+ configurable: false
2665
+ });
2666
+ }
2667
+ /**
2668
+ * Execute on_load() in a detached context with restricted access.
2669
+ *
2670
+ * Creates a sandboxed environment where on_load() can only access:
2671
+ * - this.args (read-only)
2672
+ * - this.data (read/write, cloned from __initial_data_snapshot)
2673
+ *
2674
+ * All other property access (this.$, this.$sid, etc.) throws errors.
2675
+ *
2676
+ * @param component - The component instance
2677
+ * @param use_load_coordinator - Whether to use Load_Coordinator for deduplication
2678
+ * @returns The resulting data and optional coordination completion function
2679
+ */
2680
+ async function execute_on_load_detached(component, use_load_coordinator = false) {
2681
+ // Clone this.data from the snapshot captured after on_create()
2682
+ const data_clone = component.__initial_data_snapshot
2683
+ ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
2684
+ : {};
2685
+ // Create a read-only proxy for args that blocks all modifications
2686
+ // Can't use JSON clone because args may contain functions (_slots, callbacks)
2687
+ const component_name = component.component_name();
2688
+ const create_readonly_proxy = (obj, path = 'this.args') => {
2689
+ if (obj === null || typeof obj !== 'object')
2690
+ return obj;
2691
+ return new Proxy(obj, {
2692
+ get(target, prop) {
2693
+ const value = target[prop];
2694
+ // Recursively wrap nested objects (but not functions)
2695
+ if (value !== null && typeof value === 'object' && typeof value !== 'function') {
2696
+ return create_readonly_proxy(value, `${path}.${String(prop)}`);
2697
+ }
2698
+ return value;
2699
+ },
2700
+ set(_target, prop, value) {
2701
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify ${path}.${String(prop)} during on_load().\n\n` +
2702
+ `RESTRICTION: this.args is READ-ONLY during on_load().\n\n` +
2703
+ `WHY: this.args configures what data to fetch. Modifying it during on_load() creates circular dependencies.\n\n` +
2704
+ `FIX: Modify this.args BEFORE on_load() runs (in on_create() or before calling reload()):\n` +
2705
+ ` ❌ Inside on_load(): ${path}.${String(prop)} = ${JSON.stringify(value)};\n` +
2706
+ ` ✅ In on_create(): this.args.${String(prop)} = defaultValue;`);
2707
+ throw new Error(`[JQHTML] Cannot modify ${path}.${String(prop)} during on_load(). ` +
2708
+ `this.args is read-only in on_load().`);
2709
+ },
2710
+ deleteProperty(_target, prop) {
2711
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to delete ${path}.${String(prop)} during on_load().\n\n` +
2712
+ `RESTRICTION: this.args is READ-ONLY during on_load().`);
2713
+ throw new Error(`[JQHTML] Cannot delete ${path}.${String(prop)} during on_load(). ` +
2714
+ `this.args is read-only in on_load().`);
2715
+ }
2716
+ });
2717
+ };
2718
+ // Create a detached context object that on_load will operate on
2719
+ const detached_context = {
2720
+ args: create_readonly_proxy(component.args), // Read-only proxy wrapping real args
2721
+ data: data_clone // Cloned data - modifications stay isolated
2722
+ };
2723
+ // Create restricted proxy that operates on the detached context
2724
+ const restricted_this = new Proxy(detached_context, {
2725
+ get(target, prop) {
2726
+ // Only allow access to args and data
2727
+ if (prop === 'args') {
2728
+ return target.args;
2729
+ }
2730
+ if (prop === 'data') {
2731
+ return target.data;
2732
+ }
2733
+ // Block everything else
2734
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to access this.${String(prop)} during on_load().\n\n` +
2735
+ `RESTRICTION: on_load() may ONLY access:\n` +
2736
+ ` - this.args (read-only)\n` +
2737
+ ` - this.data (read/write)\n\n` +
2738
+ `WHY: on_load() is for data fetching only. All other component functionality should happen in other lifecycle methods.\n\n` +
2739
+ `FIX:\n` +
2740
+ ` - DOM manipulation → use on_render() or on_ready()\n` +
2741
+ ` - Component methods → call them before/after on_load(), not inside it\n` +
2742
+ ` - Other properties → restructure code to only use this.args and this.data in on_load()`);
2743
+ throw new Error(`[JQHTML] Cannot access this.${String(prop)} during on_load(). ` +
2744
+ `on_load() may only access this.args and this.data.`);
2745
+ },
2746
+ set(target, prop, value) {
2747
+ // Only allow setting data
2748
+ if (prop === 'data') {
2749
+ target.data = value;
2750
+ return true;
2751
+ }
2752
+ // Block setting args
2753
+ if (prop === 'args') {
2754
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.args during on_load().\n\n` +
2755
+ `RESTRICTION: on_load() may ONLY modify:\n` +
2756
+ ` - this.data (read/write)\n\n` +
2757
+ `WHY: this.args is component state that on_load() depends on. Modifying it inside on_load() creates circular dependencies.\n\n` +
2758
+ `FIX: Modify this.args BEFORE calling on_load() (in on_create() or other lifecycle methods), not inside on_load():\n` +
2759
+ ` ❌ Inside on_load(): this.args.filter = 'new_value';\n` +
2760
+ ` ✅ In on_create(): this.args.filter = this.args.initial_filter || 'default';`);
2761
+ throw new Error(`[JQHTML] Cannot modify this.args during on_load(). ` +
2762
+ `Modify this.args in other lifecycle methods, not inside on_load().`);
2763
+ }
2764
+ // Block setting any other properties
2765
+ console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.${String(prop)} during on_load().\n\n` +
2766
+ `RESTRICTION: on_load() may ONLY modify:\n` +
2767
+ ` - this.data (read/write)\n\n` +
2768
+ `WHY: on_load() is for data fetching only. Setting properties on the component instance should happen in other lifecycle methods.\n\n` +
2769
+ `FIX: Store your data in this.data instead:\n` +
2770
+ ` ❌ this.${String(prop)} = value;\n` +
2771
+ ` ✅ this.data.${String(prop)} = value;`);
2772
+ throw new Error(`[JQHTML] Cannot modify this.${String(prop)} during on_load(). ` +
2773
+ `Only this.data can be modified in on_load().`);
2774
+ }
2775
+ });
2776
+ // Create promise for this on_load() call
2777
+ const on_load_promise = (async () => {
2778
+ try {
2779
+ await component._call_lifecycle('on_load', restricted_this);
2780
+ }
2781
+ catch (error) {
2782
+ if (use_load_coordinator) {
2783
+ // Handle error and notify coordinator
2784
+ Load_Coordinator.handle_leader_error(component, error);
2785
+ }
2786
+ throw error;
2787
+ }
2788
+ })();
2789
+ // If using Load_Coordinator, register as leader
2790
+ // Note: We store the completion function but DON'T call it here
2791
+ // It will be called by _load() after _apply_load_result() updates this.data
2792
+ let complete_coordination = null;
2793
+ if (use_load_coordinator) {
2794
+ complete_coordination = Load_Coordinator.register_leader(component, on_load_promise);
2795
+ }
2796
+ await on_load_promise;
2797
+ // Note: We don't validate args changes here because external code (like parent
2798
+ // components calling reload()) is allowed to modify component.args during on_load().
2799
+ // The read-only proxy only prevents code INSIDE on_load() from modifying args.
2800
+ // Return the data from the detached context AND the completion function
2801
+ return {
2802
+ data: detached_context.data,
2803
+ complete_coordination
2804
+ };
2805
+ }
2806
+
2807
+ /**
2808
+ * JQHTML Component Cache Integration
2809
+ *
2810
+ * Extracted from component.ts - handles cache key generation,
2811
+ * cache reads during create(), cache checks during reload(),
2812
+ * and HTML cache snapshots during ready().
2813
+ *
2814
+ * Uses Jqhtml_Local_Storage as the underlying storage layer.
2815
+ */
2816
+ /**
2817
+ * Generate a cache key for a component using its name + args.
2818
+ * Supports custom cache_id() override.
2819
+ *
2820
+ * @param component - The component instance
2821
+ * @returns Cache key string or null if caching is disabled
2822
+ */
2823
+ function generate_cache_key(component) {
2824
+ if (typeof component.cache_id === 'function') {
2825
+ try {
2826
+ const custom_cache_id = component.cache_id();
2827
+ return { cache_key: `${component.component_name()}::${String(custom_cache_id)}` };
2828
+ }
2829
+ catch (error) {
2830
+ return { cache_key: null, uncacheable_property: 'cache_id()', uncacheable_reason: 'cache-id-threw' };
2831
+ }
2832
+ }
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
+ });
2897
+ }
2898
+ /**
2899
+ * Read cache during create() phase.
2900
+ * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
2901
+ *
2902
+ * @param component - The component instance
2903
+ */
2904
+ function read_cache_in_create(component) {
2905
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(component);
2906
+ // If cache_key is null, caching disabled
2907
+ if (cache_key === null) {
2908
+ // Set data-nocache attribute for debugging
2909
+ if (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);
2912
+ }
2913
+ warn_uncacheable_component(component, uncacheable_property, uncacheable_reason);
2914
+ if (window.jqhtml?.debug?.verbose) {
2915
+ console.log(`[Cache] Component ${component._cid} (${component.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2916
+ }
2917
+ return;
2918
+ }
2919
+ // Store cache key for later use
2920
+ component._cache_key = cache_key;
2921
+ // Get cache mode
2922
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2923
+ if (window.jqhtml?.debug?.verbose) {
2924
+ console.log(`[Cache ${cache_mode}] Component ${component._cid} (${component.component_name()}) checking cache in create()`, { cache_key, cache_mode, has_cache_key_set: Jqhtml_Local_Storage.has_cache_key() });
2925
+ }
2926
+ if (cache_mode === 'html') {
2927
+ // HTML cache mode - check for cached HTML to inject on first render
2928
+ const html_cache_key = `${cache_key}::html`;
2929
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2930
+ if (cached_html !== null && typeof cached_html === 'string') {
2931
+ component._cached_html = cached_html;
2932
+ if (window.jqhtml?.debug?.verbose) {
2933
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
2934
+ }
2935
+ }
2936
+ else {
2937
+ if (window.jqhtml?.debug?.verbose) {
2938
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) cache miss`, { cache_key: html_cache_key });
2939
+ }
2940
+ }
2941
+ // Warn if use_cached_data is set in html cache mode
2942
+ if (component.args.use_cached_data === true) {
2943
+ console.warn(`[JQHTML] Component "${component.component_name()}" has use_cached_data=true but cache mode is 'html'.\n` +
2944
+ `use_cached_data only applies to 'data' cache mode. In 'html' mode, the entire rendered HTML is cached.\n` +
2945
+ `The use_cached_data flag will be ignored.`);
2946
+ }
2947
+ }
2948
+ else {
2949
+ // Data cache mode (default) - check for cached data to hydrate this.data
2950
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
2951
+ if (cached_data !== null && typeof cached_data === 'object') {
2952
+ // Hydrate this.data with cached data
2953
+ component.data = cached_data;
2954
+ if (component.args.use_cached_data === true) {
2955
+ component._use_cached_data_hit = true;
2956
+ if (window.jqhtml?.debug?.verbose) {
2957
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) using cached data (use_cached_data=true, skipping on_load)`, { cache_key, data: cached_data });
2958
+ }
2959
+ }
2960
+ else {
2961
+ if (window.jqhtml?.debug?.verbose) {
2962
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) hydrated from cache`, { cache_key, data: cached_data });
2963
+ }
2964
+ }
2965
+ }
2966
+ else {
2967
+ if (window.jqhtml?.debug?.verbose) {
2968
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cache miss`, { cache_key });
2969
+ }
2970
+ }
2971
+ }
2972
+ }
2973
+ /**
2974
+ * Check cache during reload() when args have changed.
2975
+ * If cache hit, hydrates data/html and triggers an immediate render.
2976
+ *
2977
+ * @param component - The component instance
2978
+ * @returns true if rendered from cache, false otherwise
2979
+ */
2980
+ function check_cache_on_reload(component) {
2981
+ const { cache_key } = generate_cache_key(component);
2982
+ if (cache_key === null) {
2983
+ return false;
2984
+ }
2985
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2986
+ component._cache_key = cache_key;
2987
+ if (cache_mode === 'html') {
2988
+ // HTML cache mode - check for cached HTML
2989
+ const html_cache_key = `${cache_key}::html`;
2990
+ const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2991
+ if (cached_html !== null && typeof cached_html === 'string') {
2992
+ if (window.jqhtml?.debug?.verbose) {
2993
+ console.log(`[Cache html] reload() - Component ${component._cid} (${component.component_name()}) found cached HTML (args changed)`, { cache_key: html_cache_key, html_length: cached_html.length });
2994
+ }
2995
+ component._cached_html = cached_html;
2996
+ // Call _render() directly (not render()) since _reload() manages the full lifecycle
2997
+ // Using render() would trigger a separate queue entry and interfere with _reload()'s flow
2998
+ component._render();
2999
+ return true;
3000
+ }
3001
+ }
3002
+ else {
3003
+ // Data cache mode (default) - check for cached data
3004
+ const cached_data = Jqhtml_Local_Storage.get(cache_key);
3005
+ if (cached_data !== null && typeof cached_data === 'object' && JSON.stringify(cached_data) !== '{}') {
3006
+ if (window.jqhtml?.debug?.verbose) {
3007
+ console.log(`[Cache data] reload() - Component ${component._cid} (${component.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
3008
+ }
3009
+ // Hydrate this.data with cached data
3010
+ component.__data_frozen = false;
3011
+ component.data = cached_data;
3012
+ component.__data_frozen = true;
3013
+ // Call _render() directly (not render()) since _reload() manages the full lifecycle
3014
+ component._render();
3015
+ return true;
3016
+ }
3017
+ }
3018
+ return false;
3019
+ }
3020
+ /**
3021
+ * Write HTML cache snapshot during ready() phase.
3022
+ * Only caches if the component is dynamic (data changed during on_load).
3023
+ *
3024
+ * @param component - The component instance
3025
+ */
3026
+ function write_html_cache_snapshot(component) {
3027
+ if (!component._should_cache_html_after_ready || !component._cache_key) {
3028
+ return;
3029
+ }
3030
+ if (component._is_dynamic) {
3031
+ component._should_cache_html_after_ready = false;
3032
+ const html = component.$.html();
3033
+ const html_cache_key = `${component._cache_key}::html`;
3034
+ Jqhtml_Local_Storage.set(html_cache_key, html);
3035
+ if (window.jqhtml?.debug?.verbose) {
3036
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) cached HTML after children on_render complete`, { cache_key: html_cache_key, html_length: html.length });
3037
+ }
3038
+ }
3039
+ else {
3040
+ component._should_cache_html_after_ready = false;
3041
+ if (window.jqhtml?.debug?.verbose) {
3042
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) is static (no data change) - skipping HTML cache`);
3043
+ }
3044
+ }
3045
+ }
3046
+ /**
3047
+ * Write data/HTML to cache after on_load() completes.
3048
+ * Called from _apply_load_result().
3049
+ *
3050
+ * @param component - The component instance
3051
+ * @param data_changed - Whether this.data changed during on_load
3052
+ */
3053
+ function write_cache_on_loaded(component, data_changed) {
3054
+ if (!data_changed || !component._cache_key) {
3055
+ return;
3056
+ }
3057
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3058
+ if (cache_mode === 'html') {
3059
+ // HTML cache mode - flag to cache HTML after children ready in _ready()
3060
+ component._should_cache_html_after_ready = true;
3061
+ if (window.jqhtml?.debug?.verbose) {
3062
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) will cache HTML after ready`, { cache_key: component._cache_key });
3063
+ }
3064
+ }
3065
+ else {
3066
+ // Data cache mode (default) - write this.data to cache
3067
+ Jqhtml_Local_Storage.set(component._cache_key, component.data);
3068
+ if (window.jqhtml?.debug?.verbose) {
3069
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cached data after on_load()`, { cache_key: component._cache_key, data: component.data });
3070
+ }
3071
+ }
3072
+ }
3073
+
3074
+ /**
3075
+ * JQHTML Component Lifecycle Queue
3076
+ *
3077
+ * Serializes lifecycle operations (render, reload, refresh) per component.
3078
+ * Replaces _create_debounced_function with a proper queue that:
3079
+ *
3080
+ * - At most one operation runs at a time per component
3081
+ * - At most one operation is pending (waiting for the current to finish)
3082
+ * - Same-type pending operations collapse (fan-in: all callers share one execution)
3083
+ * - Different-type pending operations: new replaces old (old callers get the new result)
3084
+ *
3085
+ * Boot bypasses this queue entirely - it runs the lifecycle directly.
3086
+ */
3087
+ class Component_Queue {
3088
+ constructor() {
3089
+ /** Currently executing operation */
3090
+ this._current = null;
3091
+ /** Next operation waiting to execute after current completes */
3092
+ this._pending = null;
3093
+ }
3094
+ /**
3095
+ * Enqueue a lifecycle operation.
3096
+ *
3097
+ * Behavior:
3098
+ * - Nothing running → execute immediately
3099
+ * - Something running, no pending → create pending entry
3100
+ * - Something running, same type pending → collapse (share pending promise)
3101
+ * - Something running, different type pending → replace pending (all callers get new result)
3102
+ *
3103
+ * @param type - Operation type ('render', 'reload', 'refresh', 'load')
3104
+ * @param executor - The async function to execute
3105
+ * @returns Promise that resolves when the operation completes
3106
+ */
3107
+ enqueue(type, executor) {
3108
+ // If nothing running, execute immediately
3109
+ if (!this._current) {
3110
+ return this._execute(type, executor);
3111
+ }
3112
+ // Something is running — add to pending
3113
+ if (this._pending) {
3114
+ if (this._pending.type === type) {
3115
+ // Same type pending → collapse (fan-in)
3116
+ // All callers share the same future execution
3117
+ return new Promise((resolve, reject) => {
3118
+ this._pending.resolvers.push(resolve);
3119
+ this._pending.rejecters.push(reject);
3120
+ });
3121
+ }
3122
+ else {
3123
+ // Different type pending → replace
3124
+ // Old pending callers join the new operation
3125
+ // (their operation was superseded by a different type)
3126
+ const old_resolvers = this._pending.resolvers;
3127
+ const old_rejecters = this._pending.rejecters;
3128
+ return new Promise((resolve, reject) => {
3129
+ this._pending = {
3130
+ type,
3131
+ executor,
3132
+ resolvers: [...old_resolvers, resolve],
3133
+ rejecters: [...old_rejecters, reject]
3134
+ };
3135
+ });
3136
+ }
3137
+ }
3138
+ // No pending entry yet — create one
3139
+ return new Promise((resolve, reject) => {
3140
+ this._pending = {
3141
+ type,
3142
+ executor,
3143
+ resolvers: [resolve],
3144
+ rejecters: [reject]
3145
+ };
3146
+ });
3147
+ }
3148
+ /**
3149
+ * Execute an operation and handle pending queue after completion.
3150
+ * @private
3151
+ */
3152
+ _execute(type, executor) {
3153
+ const promise = (async () => {
3154
+ try {
3155
+ await executor();
3156
+ }
3157
+ finally {
3158
+ this._current = null;
3159
+ // Run pending if exists
3160
+ if (this._pending) {
3161
+ const pending = this._pending;
3162
+ this._pending = null;
3163
+ try {
3164
+ await this._execute(pending.type, pending.executor);
3165
+ for (const resolve of pending.resolvers)
3166
+ resolve();
3167
+ }
3168
+ catch (err) {
3169
+ for (const reject of pending.rejecters)
3170
+ reject(err);
3171
+ }
3172
+ }
3173
+ }
3174
+ })();
3175
+ this._current = { type, promise };
3176
+ return promise;
3177
+ }
3178
+ /**
3179
+ * Check if any operation is currently running.
3180
+ */
3181
+ get is_busy() {
3182
+ return this._current !== null;
3183
+ }
3184
+ /**
3185
+ * Check if there's a pending operation waiting.
3186
+ */
3187
+ get has_pending() {
3188
+ return this._pending !== null;
3189
+ }
3190
+ }
3191
+
3192
+ /**
3193
+ * JQHTML SSR Preload Data System
3194
+ *
3195
+ * Captures component data during SSR rendering and replays it on the client
3196
+ * to skip redundant on_load() calls during hydration.
3197
+ *
3198
+ * Server-side: start_data_capture() → render → get_captured_data()
3199
+ * Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
3200
+ */
3201
+ // --- Capture (Server Side) ---
3202
+ let _capture_enabled = false;
3203
+ let _capture_buffer = [];
3204
+ // Track captured keys to avoid duplicates from Load Coordinator followers
3205
+ let _captured_keys = new Set();
3206
+ /**
3207
+ * Enable data capture mode. After this call, every component that completes
3208
+ * on_load() has its final this.data recorded.
3209
+ *
3210
+ * Idempotent — calling twice is fine.
3211
+ */
3212
+ function start_data_capture() {
3213
+ _capture_enabled = true;
3214
+ _capture_buffer = [];
3215
+ _captured_keys.clear();
3216
+ }
3217
+ /**
3218
+ * Record a component's data after on_load() completes.
3219
+ * Called internally from _apply_load_result().
3220
+ *
3221
+ * @param component_name - Component name
3222
+ * @param args - Component args at time of on_load()
3223
+ * @param data - this.data after on_load() completed
3224
+ * @param cache_key - The cache key for deduplication (null = always capture)
3225
+ */
3226
+ function capture_component_data(component_name, args, data, cache_key) {
3227
+ if (!_capture_enabled)
3228
+ return;
3229
+ // Deduplicate: if Load Coordinator already captured this key, skip
3230
+ if (cache_key !== null) {
3231
+ if (_captured_keys.has(cache_key))
3232
+ return;
3233
+ _captured_keys.add(cache_key);
3234
+ }
3235
+ // Deep clone args and data to plain objects (no proxies, no class instances)
3236
+ try {
3237
+ const cloned_args = JSON.parse(JSON.stringify(args));
3238
+ const cloned_data = JSON.parse(JSON.stringify(data));
3239
+ _capture_buffer.push({
3240
+ component: component_name,
3241
+ args: cloned_args,
3242
+ data: cloned_data,
3243
+ });
3244
+ }
3245
+ catch (error) {
3246
+ // Non-serializable data — skip capture for this component
3247
+ if (typeof console !== 'undefined') {
3248
+ console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
3249
+ }
3250
+ }
3251
+ }
3252
+ /**
3253
+ * Returns all captured component data and resets the capture buffer.
3254
+ * One-shot: calling clears the buffer.
3255
+ */
3256
+ function get_captured_data() {
3257
+ const result = _capture_buffer;
3258
+ _capture_buffer = [];
3259
+ _captured_keys.clear();
3260
+ return result;
3261
+ }
3262
+ /**
3263
+ * Check if data capture is currently enabled.
3264
+ */
3265
+ function is_capture_enabled() {
3266
+ return _capture_enabled;
3267
+ }
3268
+ /**
3269
+ * Stop data capture and clear all state.
3270
+ */
3271
+ function stop_data_capture() {
3272
+ _capture_enabled = false;
3273
+ _capture_buffer = [];
3274
+ _captured_keys.clear();
3275
+ }
3276
+ // --- Preload (Client Side) ---
3277
+ let _preload_cache = new Map();
3278
+ /**
3279
+ * Seed the preload cache with SSR-captured data.
3280
+ * Must be called before any component _load() runs.
3281
+ *
3282
+ * @param entries - Array of { component, args, data } entries from get_captured_data()
3283
+ */
3284
+ function set_preload_data(entries) {
3285
+ if (!entries || entries.length === 0)
3286
+ return;
3287
+ _preload_cache.clear();
3288
+ for (const entry of entries) {
3289
+ // Generate key using the same algorithm as Load Coordinator
3290
+ const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
3291
+ if (result.key !== null) {
3292
+ _preload_cache.set(result.key, entry.data);
3293
+ }
3294
+ }
3295
+ }
3296
+ /**
3297
+ * Check if preloaded data exists for a given cache key.
3298
+ * If found, returns the data and removes the entry (one-shot).
3299
+ *
3300
+ * @param cache_key - The cache key (same format as Load Coordinator)
3301
+ * @returns The preloaded data, or null if no match
3302
+ */
3303
+ function consume_preload_data(cache_key) {
3304
+ if (_preload_cache.size === 0)
3305
+ return null;
3306
+ const data = _preload_cache.get(cache_key);
3307
+ if (data !== undefined) {
3308
+ _preload_cache.delete(cache_key);
3309
+ return data;
3310
+ }
3311
+ return null;
3312
+ }
3313
+ /**
3314
+ * Clear any remaining unconsumed preload entries.
3315
+ * Call after initial page hydration is complete.
3316
+ */
3317
+ function clear_preload_data() {
3318
+ _preload_cache.clear();
3319
+ }
3320
+ /**
3321
+ * Check if any preload data is available (for fast early exit in _load).
3322
+ */
3323
+ function has_preload_data() {
3324
+ return _preload_cache.size > 0;
3325
+ }
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
+
2149
3396
  /**
2150
3397
  * JQHTML v2 Component Base Class
2151
3398
  *
@@ -2176,6 +3423,7 @@ class Jqhtml_Component {
2176
3423
  this._data_on_last_render = null; // Data snapshot from last render (for refresh() comparison)
2177
3424
  this.__initial_data_snapshot = null; // Snapshot of this.data after on_create() for restoration before on_load()
2178
3425
  this.__data_frozen = false; // Track if this.data is currently frozen
3426
+ this._queue = new Component_Queue(); // Lifecycle operation queue (render/reload/refresh)
2179
3427
  this.next_reload_force_refresh = null; // State machine for reload()/refresh() debounce precedence
2180
3428
  this.__lifecycle_authorized = false; // Flag for lifecycle method protection
2181
3429
  // Cache mode properties
@@ -2189,12 +3437,17 @@ class Jqhtml_Component {
2189
3437
  this._on_render_complete = false; // True after on_render() has been called post-on_load
2190
3438
  // use_cached_data feature - skip on_load() when cache hit occurs
2191
3439
  this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
2192
- // skip_render_and_ready feature - skip first render/on_render and on_ready entirely
2193
- // Component reports ready after on_load completes
2194
- this._skip_render_and_ready = false;
2195
- // skip_ready feature - skip on_ready only
2196
- // Component reports ready after on_render completes
2197
- this._skip_ready = false;
3440
+ // _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
3441
+ this._load_only = false;
3442
+ // _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
3443
+ this._load_render_only = false;
3444
+ // Detached optimization: true when element is not in the DOM at boot time.
3445
+ // Skips initial render and cache read — just on_load then render.
3446
+ // Override with _force_initial_render to keep normal double-render behavior.
3447
+ this._is_detached = false;
3448
+ // _force_initial_render: override detached optimization — render even when not in DOM.
3449
+ // Use when you need the loading spinner visible immediately after appending.
3450
+ this._force_initial_render = false;
2198
3451
  // rendered event - fires once after the synchronous render chain completes
2199
3452
  // (after on_load's re-render if applicable, or after first render if no on_load)
2200
3453
  this._has_rendered = false;
@@ -2205,6 +3458,11 @@ class Jqhtml_Component {
2205
3458
  // Set in constructor (prototype chain is established before constructor runs)
2206
3459
  // Used to skip expensive cache/load operations for static components
2207
3460
  this.__has_custom_on_load = false;
3461
+ // gate_load() — "load gates" registered during on_create() that must settle
3462
+ // before the component's FIRST on_load() runs. See gate_load() / _await_load_gates().
3463
+ this.__load_gates = []; // Pending gates (cleared once the first load begins)
3464
+ this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
3465
+ this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
2208
3466
  // Detect custom on_load() override immediately
2209
3467
  // Prototype chain is set up before constructor runs, so this.on_load
2210
3468
  // correctly resolves to child class override if one exists
@@ -2246,84 +3504,27 @@ class Jqhtml_Component {
2246
3504
  // Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
2247
3505
  const defineArgs = template_for_args?.defineArgs || {};
2248
3506
  this.args = { ...defineArgs, ...dataAttrs, ...args };
2249
- // Set lifecycle skip flags from args
2250
- if (this.args.skip_render_and_ready === true) {
2251
- this._skip_render_and_ready = true;
3507
+ // Set lifecycle truncation flags from args
3508
+ if (this.args._load_only === true) {
3509
+ this._load_only = true;
2252
3510
  }
2253
- if (this.args.skip_ready === true) {
2254
- this._skip_ready = true;
2255
- }
2256
- // Attach component to element
2257
- this.$.data('_component', this);
2258
- // Apply CSS classes and attributes
2259
- this._apply_css_classes();
2260
- this._apply_default_attributes(); // Apply defaultAttributes from template
2261
- this._set_attributes();
2262
- // Find DOM parent component (for lifecycle coordination)
2263
- this._find_dom_parent();
2264
- // Setup data property with freeze enforcement using Proxy
2265
- let _data = {};
2266
- // Helper to create frozen proxy for data object
2267
- const createDataProxy = (obj) => {
2268
- return new Proxy(obj, {
2269
- set: (target, prop, value) => {
2270
- if (this.__data_frozen) {
2271
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2272
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2273
- ` - on_create() (set initial defaults, synchronous only)\n` +
2274
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2275
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2276
- `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2277
- ` ❌ In on_ready(): this.data.${String(prop)} = ${JSON.stringify(value)};\n` +
2278
- ` ✅ In on_create(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Set default\n` +
2279
- ` ✅ In on_load(): this.data.${String(prop)} = ${JSON.stringify(value)}; // Fetch from API\n` +
2280
- ` ✅ For component state: this.args.${String(prop)} = ${JSON.stringify(value)}; (accessible in on_load)`);
2281
- throw new Error(`[JQHTML] Cannot modify this.data.${String(prop)} outside of on_create() or on_load(). ` +
2282
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2283
- }
2284
- target[prop] = value;
2285
- return true;
2286
- },
2287
- deleteProperty: (target, prop) => {
2288
- if (this.__data_frozen) {
2289
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to delete this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
2290
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2291
- ` - on_create() (set initial defaults, synchronous only)\n` +
2292
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2293
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.`);
2294
- throw new Error(`[JQHTML] Cannot delete this.data.${String(prop)} outside of on_create() or on_load(). ` +
2295
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2296
- }
2297
- delete target[prop];
2298
- return true;
2299
- }
2300
- });
2301
- };
2302
- // Create initial proxied data object
2303
- _data = createDataProxy({});
2304
- Object.defineProperty(this, 'data', {
2305
- get: () => _data,
2306
- set: (value) => {
2307
- if (this.__data_frozen) {
2308
- console.error(`[JQHTML] ERROR: Component "${this.component_name()}" attempted to reassign this.data outside of on_create() or on_load().\n\n` +
2309
- `RESTRICTION: this.data can ONLY be modified in:\n` +
2310
- ` - on_create() (set initial defaults, synchronous only)\n` +
2311
- ` - on_load() (fetch data from APIs, can be async)\n\n` +
2312
- `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2313
- `FIX: Modify this.data in on_create() (for defaults) or on_load() (for fetched data):\n` +
2314
- ` ❌ In on_ready(): this.data = {...};\n` +
2315
- ` ✅ In on_create(): this.data.count = 0; // Set default\n` +
2316
- ` ✅ In on_load(): this.data = await fetch(...); // Fetch from API\n` +
2317
- ` ✅ For component state: this.args.count = 5; (accessible in on_load)`);
2318
- throw new Error(`[JQHTML] Cannot reassign this.data outside of on_create() or on_load(). ` +
2319
- `this.data is frozen after on_create() and unfrozen only during on_load().`);
2320
- }
2321
- // When setting, wrap the new value in a proxy too
2322
- _data = createDataProxy(value);
2323
- },
2324
- enumerable: true,
2325
- configurable: false
2326
- });
3511
+ if (this.args._load_render_only === true) {
3512
+ this._load_render_only = true;
3513
+ }
3514
+ if (this.args._force_initial_render === true) {
3515
+ this._force_initial_render = true;
3516
+ }
3517
+ // Attach component to element
3518
+ this.$.data('_component', this);
3519
+ // Apply CSS classes and attributes
3520
+ this._apply_css_classes();
3521
+ this._apply_default_attributes(); // Apply defaultAttributes from template
3522
+ this._set_attributes();
3523
+ // Find DOM parent component (for lifecycle coordination)
3524
+ this._find_dom_parent();
3525
+ // Setup data property with freeze enforcement using Proxy
3526
+ // @see data-proxy.ts for full implementation
3527
+ setup_data_property(this);
2327
3528
  // Initialize this.state as an empty object (convention for arbitrary component state)
2328
3529
  // No special meaning to framework - mutable anywhere, not cached, not frozen
2329
3530
  this.state = {};
@@ -2386,6 +3587,21 @@ class Jqhtml_Component {
2386
3587
  // Call original directly - no flag needed since we bypass the wrapper
2387
3588
  return impl.call(this);
2388
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
+ }
2389
3605
  /**
2390
3606
  * Check if on_load() is overridden in a subclass
2391
3607
  * Used to skip the load phase entirely for components that don't fetch data
@@ -2425,7 +3641,7 @@ class Jqhtml_Component {
2425
3641
  * @returns The current _render_count after incrementing (used to detect stale renders)
2426
3642
  * @private
2427
3643
  */
2428
- _render(id = null) {
3644
+ _render(id = null, options = {}) {
2429
3645
  // Increment render count to track this specific render
2430
3646
  this._render_count++;
2431
3647
  const current_render_id = this._render_count;
@@ -2446,7 +3662,7 @@ class Jqhtml_Component {
2446
3662
  `Element with $sid="${id}" exists but is not initialized as a component.\n` +
2447
3663
  `Add $redrawable attribute or make it a proper component.`);
2448
3664
  }
2449
- return child._render();
3665
+ return child._render(null, options);
2450
3666
  }
2451
3667
  this._log_lifecycle('render', 'start');
2452
3668
  // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
@@ -2463,13 +3679,14 @@ class Jqhtml_Component {
2463
3679
  // Mark first render complete
2464
3680
  this._did_first_render = true;
2465
3681
  this._log_lifecycle('render', 'complete (cached HTML)');
2466
- // NEW ARCHITECTURE: Call on_render() even after cache inject
2467
- // This ensures consistent behavior - on_render() always runs after DOM update
2468
- // Note: this.data has defaults from on_create(), not fresh data yet
2469
- const cacheRenderResult = this._call_lifecycle_sync('on_render');
2470
- if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
2471
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
2472
- `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3682
+ // Call on_render() after cache inject (unless suppressed by _load_render_only)
3683
+ if (!options.skip_on_render) {
3684
+ const cacheRenderResult = this._call_lifecycle_sync('on_render');
3685
+ if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
3686
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3687
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3688
+ }
3689
+ this._fire_viewport_resize();
2473
3690
  }
2474
3691
  // Emit lifecycle event
2475
3692
  this.trigger('render');
@@ -2654,18 +3871,14 @@ class Jqhtml_Component {
2654
3871
  // Don't update ready state here - let phases complete in order
2655
3872
  this._update_debug_attrs();
2656
3873
  this._log_lifecycle('render', 'complete');
2657
- // Call on_render() with authorization (sync) immediately after render completes
2658
- // This ensures event handlers are always re-attached after DOM updates
2659
- //
2660
- // NEW ARCHITECTURE: on_render() can now access this.data normally
2661
- // The HTML cache mode now properly synchronizes - on_render() runs after both:
2662
- // 1. Cache inject (with on_create() defaults)
2663
- // 2. Second render (with fresh data from on_load())
2664
- // Since on_render() always runs with appropriate data, no proxy restriction needed
2665
- const renderResult = this._call_lifecycle_sync('on_render');
2666
- if (renderResult && typeof renderResult.then === 'function') {
2667
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
2668
- `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3874
+ // Call on_render() after render completes (unless suppressed by _load_render_only)
3875
+ if (!options.skip_on_render) {
3876
+ const renderResult = this._call_lifecycle_sync('on_render');
3877
+ if (renderResult && typeof renderResult.then === 'function') {
3878
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3879
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3880
+ }
3881
+ this._fire_viewport_resize();
2669
3882
  }
2670
3883
  // Emit lifecycle event
2671
3884
  this.trigger('render');
@@ -2695,18 +3908,18 @@ class Jqhtml_Component {
2695
3908
  * Public render method - re-renders component and completes lifecycle
2696
3909
  * This is what users should call when they want to update a component.
2697
3910
  *
2698
- * Lifecycle sequence:
3911
+ * Lifecycle sequence (serialized via component queue):
2699
3912
  * 1. _render() - Updates DOM synchronously, calls on_render(), fires 'render' event
2700
- * 2. Async continuation (fire and forget):
2701
- * - _wait_for_children_ready() - Waits for all children to reach ready state
2702
- * - on_ready() - Calls user's ready hook
2703
- * - trigger('ready') - Fires ready event
3913
+ * 2. _wait_for_children_ready() - Waits for all children to reach ready state
3914
+ * 3. on_ready() - Calls user's ready hook
3915
+ * 4. trigger('ready') - Fires ready event
2704
3916
  *
2705
- * Returns immediately after _render() completes - does NOT wait for children
3917
+ * Goes through the component queue to prevent concurrent lifecycle operations.
3918
+ * Returns a Promise that resolves when the full render lifecycle completes.
2706
3919
  */
2707
3920
  render(id = null) {
2708
3921
  if (this._stopped)
2709
- return;
3922
+ return Promise.resolve();
2710
3923
  // Invalidate ready event so new handlers wait for this render cycle to complete
2711
3924
  // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
2712
3925
  this.invalidate('ready');
@@ -2725,10 +3938,12 @@ class Jqhtml_Component {
2725
3938
  }
2726
3939
  return child.render();
2727
3940
  }
2728
- // Execute render phase synchronously and capture render ID
2729
- const render_id = this._render();
2730
- // Fire off async lifecycle completion in background (don't await)
2731
- (async () => {
3941
+ // Enqueue the full render lifecycle through the component queue
3942
+ // Return the queue promise so `await this.render()` resolves when the
3943
+ // full render lifecycle (DOM update, children ready, on_ready) completes
3944
+ return this._queue.enqueue('render', async () => {
3945
+ // Execute render phase synchronously and capture render ID
3946
+ const render_id = this._render();
2732
3947
  // Wait for all child components to be ready
2733
3948
  await this._wait_for_children_ready();
2734
3949
  // Check if this render is still current before calling on_ready
@@ -2738,9 +3953,10 @@ class Jqhtml_Component {
2738
3953
  }
2739
3954
  // Call on_ready hook with authorization
2740
3955
  await this._call_lifecycle('on_ready');
3956
+ this._fire_viewport_resize();
2741
3957
  // Trigger ready event
2742
- await this.trigger('ready');
2743
- })();
3958
+ this.trigger('ready');
3959
+ });
2744
3960
  }
2745
3961
  /**
2746
3962
  * Alias for render() - re-renders component with current data
@@ -2749,6 +3965,70 @@ class Jqhtml_Component {
2749
3965
  redraw(id = null) {
2750
3966
  return this.render(id);
2751
3967
  }
3968
+ /**
3969
+ * Re-fetch data by executing on_load() without triggering re-render or lifecycle.
3970
+ *
3971
+ * Runs on_load() on a detached proxy (same restrictions as during boot):
3972
+ * - this.data is restored to on_create() snapshot, then unfrozen for writing
3973
+ * - this.args is read-only
3974
+ * - All other properties (this.$, this.$sid, etc.) are blocked
3975
+ *
3976
+ * After on_load() completes, this.data is atomically updated and re-frozen.
3977
+ * The developer decides what to do next (e.g., call this.render()).
3978
+ *
3979
+ * Pattern: load() invokes on_load(), just as render() invokes on_render().
3980
+ *
3981
+ * @returns true if this.data changed, false if unchanged
3982
+ */
3983
+ async load() {
3984
+ if (this._stopped)
3985
+ return false;
3986
+ // Capture result through queue closure
3987
+ let data_changed = false;
3988
+ await this._queue.enqueue('load', async () => {
3989
+ // Snapshot current data for change detection
3990
+ const data_before = JSON.stringify(this.data);
3991
+ // Execute on_load() on detached proxy (restores data to on_create snapshot)
3992
+ const { data: result_data } = await this._execute_on_load_detached(false);
3993
+ // Atomically update this.data: unfreeze → assign → normalize → refreeze
3994
+ this.__data_frozen = false;
3995
+ this.data = result_data;
3996
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3997
+ if (cache_mode === 'data') {
3998
+ const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
3999
+ this.data = normalized;
4000
+ }
4001
+ this.__data_frozen = true;
4002
+ // Detect if data changed
4003
+ const data_after = JSON.stringify(this.data);
4004
+ data_changed = data_before !== data_after;
4005
+ // Update cache with fresh data if changed
4006
+ if (data_changed) {
4007
+ // Regenerate cache key (args may have changed since boot)
4008
+ let cache_key = null;
4009
+ if (typeof this.cache_id === 'function') {
4010
+ try {
4011
+ cache_key = `${this.component_name()}::${String(this.cache_id())}`;
4012
+ }
4013
+ catch { /* cache_id() threw - skip caching */ }
4014
+ }
4015
+ else {
4016
+ const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4017
+ cache_key = result.key;
4018
+ }
4019
+ if (cache_key && cache_mode !== 'html') {
4020
+ Jqhtml_Local_Storage.set(cache_key, this.data);
4021
+ }
4022
+ }
4023
+ // Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
4024
+ // Suppressed by _load_only and _load_render_only flags (preloading mode)
4025
+ if (!this._load_only && !this._load_render_only) {
4026
+ await this._call_lifecycle('on_loaded');
4027
+ this.trigger('loaded');
4028
+ }
4029
+ });
4030
+ return data_changed;
4031
+ }
2752
4032
  /**
2753
4033
  * Create phase - Quick setup, prepare UI
2754
4034
  * SYNCHRONOUS - no awaits, no yields
@@ -2764,97 +4044,17 @@ class Jqhtml_Component {
2764
4044
  `on_create() must be synchronous code. Remove 'async' from the function declaration.`);
2765
4045
  // Don't await - on_create MUST be sync. The warning is enough.
2766
4046
  }
4047
+ // Detect detached elements — skip cache and initial render for elements not in DOM
4048
+ // _force_initial_render overrides this optimization
4049
+ this._is_detached = !this.$[0].isConnected && !this._force_initial_render;
2767
4050
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
2768
4051
  // Components without on_load() don't fetch data, so nothing to cache or restore
2769
4052
  if (this.__has_custom_on_load) {
2770
4053
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
2771
- // This happens after on_create() but before render, allowing instant first render
2772
- // Check if component implements cache_id() for custom cache key
2773
- let cache_key = null;
2774
- let uncacheable_property;
2775
- if (typeof this.cache_id === 'function') {
2776
- try {
2777
- const custom_cache_id = this.cache_id();
2778
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
2779
- }
2780
- catch (error) {
2781
- // cache_id() threw error - disable caching
2782
- uncacheable_property = 'cache_id()';
2783
- }
2784
- }
2785
- else {
2786
- // Use standard args-based cache key generation
2787
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
2788
- cache_key = result.key;
2789
- uncacheable_property = result.uncacheable_property;
2790
- }
2791
- // If cache_key is null, caching disabled
2792
- if (cache_key === null) {
2793
- // Set data-nocache attribute for debugging (shows which property prevented caching)
2794
- if (uncacheable_property) {
2795
- this.$.attr('data-nocache', uncacheable_property);
2796
- }
2797
- if (window.jqhtml?.debug?.verbose) {
2798
- console.log(`[Cache] Component ${this._cid} (${this.component_name()}) has non-serializable args - caching disabled`, { uncacheable_property });
2799
- }
2800
- // Don't return - continue to snapshot this.data for on_load restoration
2801
- }
2802
- else {
2803
- // Store cache key for later use
2804
- this._cache_key = cache_key;
2805
- // Get cache mode
2806
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
2807
- if (window.jqhtml?.debug?.verbose) {
2808
- console.log(`[Cache ${cache_mode}] Component ${this._cid} (${this.component_name()}) checking cache in create()`, { cache_key, cache_mode, has_cache_key_set: Jqhtml_Local_Storage.has_cache_key() });
2809
- }
2810
- if (cache_mode === 'html') {
2811
- // HTML cache mode - check for cached HTML to inject on first render
2812
- const html_cache_key = `${cache_key}::html`;
2813
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2814
- if (cached_html !== null && typeof cached_html === 'string') {
2815
- // Store cached HTML for injection in _render()
2816
- this._cached_html = cached_html;
2817
- if (window.jqhtml?.debug?.verbose) {
2818
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
2819
- }
2820
- }
2821
- else {
2822
- if (window.jqhtml?.debug?.verbose) {
2823
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key: html_cache_key });
2824
- }
2825
- }
2826
- // Warn if use_cached_data is set in html cache mode - it has no effect
2827
- if (this.args.use_cached_data === true) {
2828
- console.warn(`[JQHTML] Component "${this.component_name()}" has use_cached_data=true but cache mode is 'html'.\n` +
2829
- `use_cached_data only applies to 'data' cache mode. In 'html' mode, the entire rendered HTML is cached.\n` +
2830
- `The use_cached_data flag will be ignored.`);
2831
- }
2832
- }
2833
- else {
2834
- // Data cache mode (default) - check for cached data to hydrate this.data
2835
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
2836
- if (cached_data !== null && typeof cached_data === 'object') {
2837
- // Hydrate this.data with cached data
2838
- this.data = cached_data;
2839
- // If use_cached_data=true, skip on_load() entirely - use cached data as final data
2840
- if (this.args.use_cached_data === true) {
2841
- this._use_cached_data_hit = true;
2842
- if (window.jqhtml?.debug?.verbose) {
2843
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) using cached data (use_cached_data=true, skipping on_load)`, { cache_key, data: cached_data });
2844
- }
2845
- }
2846
- else {
2847
- if (window.jqhtml?.debug?.verbose) {
2848
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) hydrated from cache`, { cache_key, data: cached_data });
2849
- }
2850
- }
2851
- }
2852
- else {
2853
- if (window.jqhtml?.debug?.verbose) {
2854
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cache miss`, { cache_key });
2855
- }
2856
- }
2857
- }
4054
+ // Skip cache for detached elements — no point hydrating from cache when not in DOM
4055
+ // @see component-cache.ts for full implementation
4056
+ if (!this._is_detached) {
4057
+ read_cache_in_create(this);
2858
4058
  }
2859
4059
  // Snapshot this.data after on_create() completes
2860
4060
  // This will be restored before each on_load() execution to reset state
@@ -2868,6 +4068,90 @@ class Jqhtml_Component {
2868
4068
  // Emit lifecycle event
2869
4069
  this.trigger('create');
2870
4070
  }
4071
+ /**
4072
+ * Register a "load gate" — a promise that must settle before this component's
4073
+ * FIRST on_load() runs.
4074
+ *
4075
+ * Register gates during on_create(). Multiple calls accumulate; all gates are
4076
+ * awaited together (via Promise.allSettled) at the seam between create and the
4077
+ * first load. A REJECTED gate does NOT block or abort the load — it is an
4078
+ * ordering hint, not a data dependency, so loading proceeds after settlement
4079
+ * (rejections are logged through the debug channel).
4080
+ *
4081
+ * Gates are ONE-SHOT: they delay the first on_load() only. Once that load
4082
+ * begins the gate list is cleared, and reload()/refresh() never re-await gates.
4083
+ * Calling reload()/refresh() while a gate is still holding the first load
4084
+ * releases the wait immediately and resumes the lifecycle (a later gate
4085
+ * settlement then does nothing).
4086
+ *
4087
+ * Gates delay ONLY the on_load phase — never create(), the initial render,
4088
+ * on_render(), or the cached-content first paint (stale-while-revalidate is
4089
+ * preserved). Components with no custom on_load() ignore gates entirely.
4090
+ *
4091
+ * No-op during SSR (the server cannot await arbitrary client promises).
4092
+ *
4093
+ * jqhtml has no knowledge of WHAT is being awaited — the caller supplies the
4094
+ * promise, and policy such as timeouts is the caller's responsibility (bake it
4095
+ * into the promise you pass).
4096
+ *
4097
+ * @param promise The promise to await before the first on_load().
4098
+ * @throws If called after the component's first load has already started.
4099
+ */
4100
+ gate_load(promise) {
4101
+ if (this.__first_load_started) {
4102
+ throw new Error(`[JQHTML] gate_load() may only be called before the component's first load - ` +
4103
+ `register gates in on_create(). ` +
4104
+ `Component: ${this.component_name()} (_cid: ${this._cid})`);
4105
+ }
4106
+ this.__load_gates.push(promise);
4107
+ }
4108
+ /**
4109
+ * Await all registered load gates before the first on_load().
4110
+ *
4111
+ * Called exactly once, at the boot seam immediately before the load phase
4112
+ * (only for components with a custom on_load()). Latches "first load started"
4113
+ * so any later gate_load() throws and reload()/refresh() never re-await.
4114
+ *
4115
+ * Returns a promise to await when there are gates to wait on, or `null` when
4116
+ * there is nothing to wait for (no gates, or SSR). Returning `null` keeps the
4117
+ * common no-gate path free of an extra microtask hop, so it does not shift the
4118
+ * timing of ungated components.
4119
+ *
4120
+ * The returned promise resolves as soon as any of the following happens
4121
+ * (whichever is first — the rest become no-ops):
4122
+ * - all gates settle (Promise.allSettled),
4123
+ * - reload()/refresh() is called while gated (they resolve __gate_resume),
4124
+ * - the component is stopped (stop() resolves __gate_resume).
4125
+ *
4126
+ * @private
4127
+ */
4128
+ _await_load_gates() {
4129
+ // Latch (synchronous): from here on, gate_load() throws and this runs at
4130
+ // most once — set even when there are no gates so point 2 always holds.
4131
+ this.__first_load_started = true;
4132
+ const gates = this.__load_gates;
4133
+ this.__load_gates = [];
4134
+ // Nothing to gate, or SSR (cannot await arbitrary client promises) — proceed now.
4135
+ if (gates.length === 0 || is_capture_enabled())
4136
+ return null;
4137
+ // Promise.allSettled never rejects; rejected gates are logged (debug channel)
4138
+ // whenever they settle, independent of whether the wait was resumed early.
4139
+ const settled = Promise.allSettled(gates);
4140
+ settled.then((results) => {
4141
+ for (const result of results) {
4142
+ if (result.status === 'rejected' && window.jqhtml?.debug?.verbose) {
4143
+ console.log(`[JQHTML gate_load] Component ${this._cid} (${this.component_name()}) gate rejected (ignored, load proceeds)`, result.reason);
4144
+ }
4145
+ }
4146
+ });
4147
+ // Race gate settlement against an early-resume signal (reload/refresh/stop while gated).
4148
+ return new Promise((resolve) => {
4149
+ this.__gate_resume = resolve;
4150
+ settled.then(() => resolve());
4151
+ }).then(() => {
4152
+ this.__gate_resume = null;
4153
+ });
4154
+ }
2871
4155
  /**
2872
4156
  * Load phase - Fetch asynchronous data
2873
4157
  * Called bottom-up, fully parallel
@@ -2890,11 +4174,24 @@ class Jqhtml_Component {
2890
4174
  this._update_debug_attrs();
2891
4175
  this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
2892
4176
  this.trigger('load');
4177
+ if (!this._load_only && !this._load_render_only) {
4178
+ await this._call_lifecycle('on_loaded');
4179
+ this.trigger('loaded');
4180
+ }
2893
4181
  return;
2894
4182
  }
2895
- // Check if component implements cache_id() for custom cache key
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.
2896
4192
  let cache_key = null;
2897
4193
  let uncacheable_property;
4194
+ let uncacheable_reason;
2898
4195
  if (typeof this.cache_id === 'function') {
2899
4196
  try {
2900
4197
  const custom_cache_id = this.cache_id();
@@ -2903,33 +4200,49 @@ class Jqhtml_Component {
2903
4200
  catch (error) {
2904
4201
  // cache_id() threw error - disable caching
2905
4202
  uncacheable_property = 'cache_id()';
4203
+ uncacheable_reason = 'cache-id-threw';
2906
4204
  }
2907
4205
  }
2908
4206
  else {
2909
- // Use standard args-based cache key generation
2910
- 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 });
2911
4208
  cache_key = result.key;
2912
4209
  uncacheable_property = result.uncacheable_property;
4210
+ uncacheable_reason = result.uncacheable_reason;
4211
+ }
4212
+ const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
4213
+ // SSR preload check: if preloaded data exists for this component+args, use it
4214
+ if (cache_key !== null && has_preload_data()) {
4215
+ const preloaded_data = consume_preload_data(cache_key);
4216
+ if (preloaded_data !== null) {
4217
+ this._cache_key = cache_key;
4218
+ if (window.jqhtml?.debug?.verbose) {
4219
+ console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
4220
+ }
4221
+ const data_before_load = JSON.stringify(this.data);
4222
+ await this._apply_load_result(preloaded_data, data_before_load);
4223
+ return;
4224
+ }
2913
4225
  }
2914
4226
  // Store cache key for later use
2915
4227
  this._cache_key = cache_key;
2916
- // If cache_key is null, args are not serializable - skip load deduplication and caching
2917
- if (cache_key === null) {
2918
- // Set data-nocache attribute for debugging (shows which property prevented caching)
2919
- if (uncacheable_property) {
2920
- 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 });
2921
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) {
2922
4239
  if (window.jqhtml?.debug?.verbose) {
2923
- 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`);
2924
4241
  }
2925
- // Execute on_load on detached proxy without deduplication
2926
4242
  const { data: result_data } = await this._execute_on_load_detached();
2927
- // Apply result via sequential queue
2928
- await this._apply_load_result(result_data, null);
4243
+ await this._apply_load_result(result_data, data_before_load);
2929
4244
  return;
2930
4245
  }
2931
- // Store "before" snapshot for comparison after on_load()
2932
- const data_before_load = JSON.stringify(this.data);
2933
4246
  // Check if this component should execute on_load() or wait for existing request
2934
4247
  const should_execute = Load_Coordinator.should_execute_on_load(this);
2935
4248
  if (!should_execute) {
@@ -2966,6 +4279,10 @@ class Jqhtml_Component {
2966
4279
  this._update_debug_attrs();
2967
4280
  this._log_lifecycle('load', 'complete (follower)');
2968
4281
  this.trigger('load');
4282
+ if (!this._load_only && !this._load_render_only) {
4283
+ await this._call_lifecycle('on_loaded');
4284
+ this.trigger('loaded');
4285
+ }
2969
4286
  return;
2970
4287
  }
2971
4288
  // This component is a leader - execute on_load() on detached proxy
@@ -2984,143 +4301,11 @@ class Jqhtml_Component {
2984
4301
  }
2985
4302
  /**
2986
4303
  * Execute on_load() on a fully detached proxy.
2987
- *
2988
- * The proxy has:
2989
- * - A CLONE of this.data (from __initial_data_snapshot)
2990
- * - Read-only access to this.args
2991
- * - No access to anything else (this.$, this.sid, etc.)
2992
- *
2993
- * This ensures on_load runs completely isolated from the component instance.
2994
- *
2995
- * @param use_load_coordinator - If true, register with Load_Coordinator for deduplication
2996
- * @returns The resulting data from the proxy after on_load completes
4304
+ * @see data-proxy.ts for full implementation
2997
4305
  * @private
2998
4306
  */
2999
4307
  async _execute_on_load_detached(use_load_coordinator = false) {
3000
- // Clone this.data from the snapshot captured after on_create()
3001
- const data_clone = this.__initial_data_snapshot
3002
- ? JSON.parse(JSON.stringify(this.__initial_data_snapshot))
3003
- : {};
3004
- // Create a read-only proxy for args that blocks all modifications
3005
- // Can't use JSON clone because args may contain functions (_slots, callbacks)
3006
- const create_readonly_proxy = (obj, path = 'this.args') => {
3007
- if (obj === null || typeof obj !== 'object')
3008
- return obj;
3009
- return new Proxy(obj, {
3010
- get(target, prop) {
3011
- const value = target[prop];
3012
- // Recursively wrap nested objects (but not functions)
3013
- if (value !== null && typeof value === 'object' && typeof value !== 'function') {
3014
- return create_readonly_proxy(value, `${path}.${String(prop)}`);
3015
- }
3016
- return value;
3017
- },
3018
- set(target, prop, value) {
3019
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify ${path}.${String(prop)} during on_load().\n\n` +
3020
- `RESTRICTION: this.args is READ-ONLY during on_load().\n\n` +
3021
- `WHY: this.args configures what data to fetch. Modifying it during on_load() creates circular dependencies.\n\n` +
3022
- `FIX: Modify this.args BEFORE on_load() runs (in on_create() or before calling reload()):\n` +
3023
- ` ❌ Inside on_load(): ${path}.${String(prop)} = ${JSON.stringify(value)};\n` +
3024
- ` ✅ In on_create(): this.args.${String(prop)} = defaultValue;`);
3025
- throw new Error(`[JQHTML] Cannot modify ${path}.${String(prop)} during on_load(). ` +
3026
- `this.args is read-only in on_load().`);
3027
- },
3028
- deleteProperty(target, prop) {
3029
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to delete ${path}.${String(prop)} during on_load().\n\n` +
3030
- `RESTRICTION: this.args is READ-ONLY during on_load().`);
3031
- throw new Error(`[JQHTML] Cannot delete ${path}.${String(prop)} during on_load(). ` +
3032
- `this.args is read-only in on_load().`);
3033
- }
3034
- });
3035
- };
3036
- // Create a detached context object that on_load will operate on
3037
- const detached_context = {
3038
- args: create_readonly_proxy(this.args), // Read-only proxy wrapping real args
3039
- data: data_clone // Cloned data - modifications stay isolated
3040
- };
3041
- // Create restricted proxy that operates on the detached context
3042
- const component_name = this.component_name();
3043
- const restricted_this = new Proxy(detached_context, {
3044
- get(target, prop) {
3045
- // Only allow access to args and data
3046
- if (prop === 'args') {
3047
- return target.args;
3048
- }
3049
- if (prop === 'data') {
3050
- return target.data;
3051
- }
3052
- // Block everything else
3053
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to access this.${String(prop)} during on_load().\n\n` +
3054
- `RESTRICTION: on_load() may ONLY access:\n` +
3055
- ` - this.args (read-only)\n` +
3056
- ` - this.data (read/write)\n\n` +
3057
- `WHY: on_load() is for data fetching only. All other component functionality should happen in other lifecycle methods.\n\n` +
3058
- `FIX:\n` +
3059
- ` - DOM manipulation → use on_render() or on_ready()\n` +
3060
- ` - Component methods → call them before/after on_load(), not inside it\n` +
3061
- ` - Other properties → restructure code to only use this.args and this.data in on_load()`);
3062
- throw new Error(`[JQHTML] Cannot access this.${String(prop)} during on_load(). ` +
3063
- `on_load() may only access this.args and this.data.`);
3064
- },
3065
- set(target, prop, value) {
3066
- // Only allow setting data
3067
- if (prop === 'data') {
3068
- target.data = value;
3069
- return true;
3070
- }
3071
- // Block setting args
3072
- if (prop === 'args') {
3073
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.args during on_load().\n\n` +
3074
- `RESTRICTION: on_load() may ONLY modify:\n` +
3075
- ` - this.data (read/write)\n\n` +
3076
- `WHY: this.args is component state that on_load() depends on. Modifying it inside on_load() creates circular dependencies.\n\n` +
3077
- `FIX: Modify this.args BEFORE calling on_load() (in on_create() or other lifecycle methods), not inside on_load():\n` +
3078
- ` ❌ Inside on_load(): this.args.filter = 'new_value';\n` +
3079
- ` ✅ In on_create(): this.args.filter = this.args.initial_filter || 'default';`);
3080
- throw new Error(`[JQHTML] Cannot modify this.args during on_load(). ` +
3081
- `Modify this.args in other lifecycle methods, not inside on_load().`);
3082
- }
3083
- // Block setting any other properties
3084
- console.error(`[JQHTML] ERROR: Component "${component_name}" attempted to modify this.${String(prop)} during on_load().\n\n` +
3085
- `RESTRICTION: on_load() may ONLY modify:\n` +
3086
- ` - this.data (read/write)\n\n` +
3087
- `WHY: on_load() is for data fetching only. Setting properties on the component instance should happen in other lifecycle methods.\n\n` +
3088
- `FIX: Store your data in this.data instead:\n` +
3089
- ` ❌ this.${String(prop)} = value;\n` +
3090
- ` ✅ this.data.${String(prop)} = value;`);
3091
- throw new Error(`[JQHTML] Cannot modify this.${String(prop)} during on_load(). ` +
3092
- `Only this.data can be modified in on_load().`);
3093
- }
3094
- });
3095
- // Create promise for this on_load() call
3096
- const on_load_promise = (async () => {
3097
- try {
3098
- await this._call_lifecycle('on_load', restricted_this);
3099
- }
3100
- catch (error) {
3101
- if (use_load_coordinator) {
3102
- // Handle error and notify coordinator
3103
- Load_Coordinator.handle_leader_error(this, error);
3104
- }
3105
- throw error;
3106
- }
3107
- })();
3108
- // If using Load_Coordinator, register as leader
3109
- // Note: We store the completion function but DON'T call it here
3110
- // It will be called by _load() after _apply_load_result() updates this.data
3111
- let complete_coordination = null;
3112
- if (use_load_coordinator) {
3113
- complete_coordination = Load_Coordinator.register_leader(this, on_load_promise);
3114
- }
3115
- await on_load_promise;
3116
- // Note: We don't validate args changes here because external code (like parent
3117
- // components calling reload()) is allowed to modify component.args during on_load().
3118
- // The read-only proxy only prevents code INSIDE on_load() from modifying args.
3119
- // Return the data from the detached context AND the completion function
3120
- return {
3121
- data: detached_context.data,
3122
- complete_coordination
3123
- };
4308
+ return execute_on_load_detached(this, use_load_coordinator);
3124
4309
  }
3125
4310
  /**
3126
4311
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -3158,34 +4343,30 @@ class Jqhtml_Component {
3158
4343
  }
3159
4344
  // Freeze this.data
3160
4345
  this.__data_frozen = true;
4346
+ // SSR data capture: record this component's data for preloading
4347
+ if (is_capture_enabled() && this._has_on_load()) {
4348
+ capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
4349
+ }
3161
4350
  // Calculate if data changed
3162
4351
  const data_after_load = JSON.stringify(this.data);
3163
4352
  const data_changed = data_before_load !== null && data_after_load !== data_before_load;
3164
4353
  // Track if component is "dynamic" (this.data changed during on_load)
3165
4354
  // Used by HTML cache mode for synchronization - static parents don't block children
3166
4355
  this._is_dynamic = data_changed && data_after_load !== '{}';
3167
- // CACHE WRITE - If data changed during on_load(), write to cache based on mode
3168
- if (this._is_dynamic && this._cache_key) {
3169
- if (cache_mode === 'html') {
3170
- // HTML cache mode - flag to cache HTML after children ready in _ready()
3171
- this._should_cache_html_after_ready = true;
3172
- if (window.jqhtml?.debug?.verbose) {
3173
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) will cache HTML after ready`, { cache_key: this._cache_key });
3174
- }
3175
- }
3176
- else {
3177
- // Data cache mode (default) - write this.data to cache
3178
- Jqhtml_Local_Storage.set(this._cache_key, this.data);
3179
- if (window.jqhtml?.debug?.verbose) {
3180
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) cached data after on_load()`, { cache_key: this._cache_key, data: this.data });
3181
- }
3182
- }
3183
- }
4356
+ // CACHE WRITE - @see component-cache.ts
4357
+ write_cache_on_loaded(this, this._is_dynamic);
3184
4358
  this._ready_state = 2;
3185
4359
  this._update_debug_attrs();
3186
4360
  this._log_lifecycle('load', 'complete');
3187
4361
  // Emit lifecycle event
3188
4362
  this.trigger('load');
4363
+ // Call on_loaded() - runs on the REAL component (not detached proxy)
4364
+ // this.data is frozen (read-only), but this.$, this.state, this.args are accessible
4365
+ // Suppressed by _load_only and _load_render_only flags (preloading mode)
4366
+ if (!this._load_only && !this._load_render_only) {
4367
+ await this._call_lifecycle('on_loaded');
4368
+ this.trigger('loaded');
4369
+ }
3189
4370
  }
3190
4371
  finally {
3191
4372
  // Signal next in queue
@@ -3201,36 +4382,16 @@ class Jqhtml_Component {
3201
4382
  if (this._stopped || this._ready_state >= 4)
3202
4383
  return;
3203
4384
  this._log_lifecycle('ready', 'start');
3204
- // HTML CACHE MODE - New synchronization architecture:
3205
- // 1. Wait for all children to complete on_render (post-on_load)
3206
- // 2. Take HTML snapshot BEFORE waiting for children ready
3207
- // 3. This ensures we capture the DOM after on_render but before on_ready manipulations
4385
+ // HTML CACHE MODE - Wait for children on_render, then snapshot
4386
+ // @see component-cache.ts for write_html_cache_snapshot implementation
3208
4387
  if (this._should_cache_html_after_ready && this._cache_key) {
3209
- // Wait for all children to complete their on_render
3210
4388
  await this._wait_for_children_on_render();
3211
- // Only cache if this component is dynamic (data changed during on_load)
3212
- // Static parents don't cache - they just let children proceed
3213
- if (this._is_dynamic) {
3214
- this._should_cache_html_after_ready = false;
3215
- // Cache the rendered HTML
3216
- const html = this.$.html();
3217
- const html_cache_key = `${this._cache_key}::html`;
3218
- Jqhtml_Local_Storage.set(html_cache_key, html);
3219
- if (window.jqhtml?.debug?.verbose) {
3220
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) cached HTML after children on_render complete`, { cache_key: html_cache_key, html_length: html.length });
3221
- }
3222
- }
3223
- else {
3224
- // Static component - just clear the flag, don't cache
3225
- this._should_cache_html_after_ready = false;
3226
- if (window.jqhtml?.debug?.verbose) {
3227
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) is static (no data change) - skipping HTML cache`);
3228
- }
3229
- }
4389
+ write_html_cache_snapshot(this);
3230
4390
  }
3231
4391
  // Wait for all children to reach ready state (bottom-up execution)
3232
4392
  await this._wait_for_children_ready();
3233
4393
  await this._call_lifecycle('on_ready');
4394
+ this._fire_viewport_resize();
3234
4395
  this._ready_state = 4;
3235
4396
  this._update_debug_attrs();
3236
4397
  this._log_lifecycle('ready', 'complete');
@@ -3387,6 +4548,17 @@ class Jqhtml_Component {
3387
4548
  * If false, only re-render if data actually changed.
3388
4549
  */
3389
4550
  async reload(always_render) {
4551
+ // Load-gate short-circuit: if a gate is currently holding the first on_load,
4552
+ // reload()/refresh() resumes the paused boot lifecycle instead of running its
4553
+ // normal debounced re-fetch. The boot lifecycle then proceeds to run on_load
4554
+ // (the fresh fetch) exactly as reload() would want. Any later gate settlement
4555
+ // becomes a no-op. Return a promise that resolves when the component is ready.
4556
+ if (this.__gate_resume) {
4557
+ const resume = this.__gate_resume;
4558
+ this.__gate_resume = null;
4559
+ resume();
4560
+ return this.ready();
4561
+ }
3390
4562
  // Default to true if undefined
3391
4563
  const force_refresh = always_render !== undefined ? always_render : true;
3392
4564
  // State machine: reload(true) takes precedence over reload(false)
@@ -3399,11 +4571,9 @@ class Jqhtml_Component {
3399
4571
  this.next_reload_force_refresh = false;
3400
4572
  }
3401
4573
  }
3402
- // Lazy-create debounced _reload function on first use
3403
- if (!this._reload_debounced) {
3404
- this._reload_debounced = this._create_debounced_function(this._reload.bind(this), 0);
3405
- }
3406
- return this._reload_debounced();
4574
+ // Enqueue through component queue (replaces _create_debounced_function)
4575
+ // Same-type pending operations collapse (multiple reload() calls share one execution)
4576
+ return this._queue.enqueue('reload', () => this._reload());
3407
4577
  }
3408
4578
  /**
3409
4579
  * Refresh component - re-fetch data and re-render only if data changed (debounced)
@@ -3466,6 +4636,7 @@ class Jqhtml_Component {
3466
4636
  this._render();
3467
4637
  await this._wait_for_children_ready();
3468
4638
  await this._call_lifecycle('on_ready');
4639
+ this._fire_viewport_resize();
3469
4640
  this.trigger('ready');
3470
4641
  this._log_lifecycle('reload', 'complete (no on_load)');
3471
4642
  return;
@@ -3485,57 +4656,8 @@ class Jqhtml_Component {
3485
4656
  }
3486
4657
  }
3487
4658
  if (args_changed) {
3488
- // Check if component implements cache_id() for custom cache key
3489
- let cache_key = null;
3490
- if (typeof this.cache_id === 'function') {
3491
- try {
3492
- const custom_cache_id = this.cache_id();
3493
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
3494
- }
3495
- catch (error) {
3496
- // cache_id() threw error - disable caching
3497
- cache_key = null;
3498
- }
3499
- }
3500
- else {
3501
- // Use standard args-based cache key generation
3502
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
3503
- cache_key = result.key;
3504
- }
3505
- // Check for cached data/html when args changed
3506
- if (cache_key !== null) {
3507
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3508
- this._cache_key = cache_key;
3509
- if (cache_mode === 'html') {
3510
- // HTML cache mode - check for cached HTML
3511
- const html_cache_key = `${cache_key}::html`;
3512
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3513
- if (cached_html !== null && typeof cached_html === 'string') {
3514
- if (window.jqhtml?.debug?.verbose) {
3515
- console.log(`[Cache html] reload() - Component ${this._cid} (${this.component_name()}) found cached HTML (args changed)`, { cache_key: html_cache_key, html_length: cached_html.length });
3516
- }
3517
- // Store cached HTML for injection in _render()
3518
- this._cached_html = cached_html;
3519
- this.render();
3520
- rendered_from_cache = true;
3521
- }
3522
- }
3523
- else {
3524
- // Data cache mode (default) - check for cached data
3525
- const cached_data = Jqhtml_Local_Storage.get(cache_key);
3526
- if (cached_data !== null && typeof cached_data === 'object' && JSON.stringify(cached_data) !== '{}') {
3527
- if (window.jqhtml?.debug?.verbose) {
3528
- console.log(`[Cache data] reload() - Component ${this._cid} (${this.component_name()}) hydrated from cache (args changed)`, { cache_key, data: cached_data });
3529
- }
3530
- // Hydrate this.data with cached data
3531
- this.__data_frozen = false;
3532
- this.data = cached_data;
3533
- this.__data_frozen = true;
3534
- this.render();
3535
- rendered_from_cache = true;
3536
- }
3537
- }
3538
- }
4659
+ // @see component-cache.ts for full implementation
4660
+ rendered_from_cache = check_cache_on_reload(this);
3539
4661
  }
3540
4662
  // Capture data state before on_load for comparison
3541
4663
  data_before_load = JSON.stringify(this.data);
@@ -3589,6 +4711,7 @@ class Jqhtml_Component {
3589
4711
  if (rendered_from_cache || should_render) {
3590
4712
  await this._wait_for_children_ready();
3591
4713
  await this._call_lifecycle('on_ready');
4714
+ this._fire_viewport_resize();
3592
4715
  // Trigger ready event so parent callbacks fire on subsequent reloads
3593
4716
  this.trigger('ready');
3594
4717
  }
@@ -3609,11 +4732,18 @@ class Jqhtml_Component {
3609
4732
  if (this._stopped)
3610
4733
  return;
3611
4734
  this._stopped = true;
4735
+ // If a load gate is holding the first on_load, release the wait so the boot
4736
+ // lifecycle unwinds cleanly (it will see _stopped and abandon the load).
4737
+ if (this.__gate_resume) {
4738
+ const resume = this.__gate_resume;
4739
+ this.__gate_resume = null;
4740
+ resume();
4741
+ }
3612
4742
  // Early bailout: skip expensive cleanup if no handlers registered
3613
4743
  // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
3614
4744
  const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
3615
- const has_destroy_callbacks = this._on_registered('destroy');
3616
- if (!has_custom_stop && !has_destroy_callbacks) {
4745
+ const has_stop_callbacks = this._on_registered('stop');
4746
+ if (!has_custom_stop && !has_stop_callbacks) {
3617
4747
  // Fast path: no cleanup logic defined, just mark as stopped
3618
4748
  this._lifecycle_manager.unregister_component(this);
3619
4749
  this._ready_state = 99;
@@ -3661,8 +4791,19 @@ class Jqhtml_Component {
3661
4791
  on_render() { }
3662
4792
  on_create() { }
3663
4793
  on_load() { } // Override to fetch data asynchronously
4794
+ on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
3664
4795
  async on_ready() { }
3665
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) { }
3666
4807
  /**
3667
4808
  * Should component re-render after load?
3668
4809
  * By default, only re-renders if data has changed
@@ -3706,85 +4847,39 @@ class Jqhtml_Component {
3706
4847
  return this.constructor.name;
3707
4848
  }
3708
4849
  /**
3709
- * Register event callback
3710
- * Supports lifecycle events ('render', 'create', 'load', 'ready', 'stop') and custom events
3711
- * Lifecycle event callbacks fire after the lifecycle method completes
3712
- * If a lifecycle event has already occurred, the callback fires immediately AND registers for future occurrences
3713
- * Custom events only fire when explicitly triggered via .trigger()
3714
- *
3715
- * Callback signature: (component, data?) => void
3716
- * - component: The component instance that triggered the event
3717
- * - data: Optional data passed as second parameter to trigger()
4850
+ * Register event callback - delegates to component-events.ts
4851
+ * @see component-events.ts for full documentation
3718
4852
  */
3719
4853
  on(event_name, callback) {
3720
- // Initialize callback array for this event if needed
3721
- if (!this._lifecycle_callbacks.has(event_name)) {
3722
- this._lifecycle_callbacks.set(event_name, []);
3723
- }
3724
- // Add callback to queue
3725
- this._lifecycle_callbacks.get(event_name).push(callback);
3726
- // If this lifecycle event has already occurred, fire the callback immediately
3727
- // with the stored data from when trigger() was called
3728
- if (this._lifecycle_states.has(event_name)) {
3729
- try {
3730
- const stored_data = this._lifecycle_states.get(event_name);
3731
- callback(this, stored_data);
3732
- }
3733
- catch (error) {
3734
- console.error(`[JQHTML] Error in ${event_name} callback:`, error);
3735
- }
3736
- }
3737
- return this;
4854
+ return event_on(this, event_name, callback);
3738
4855
  }
3739
4856
  /**
3740
- * Trigger a lifecycle event - fires all registered callbacks
3741
- * Marks event as occurred so future .on() calls fire immediately
3742
- *
3743
- * @param event_name - Name of the event to trigger
3744
- * @param data - Optional data to pass to callbacks as second parameter
4857
+ * Register a callback that fires exactly once - delegates to component-events.ts
4858
+ * If the event already occurred, fires immediately and does not register.
4859
+ * @see component-events.ts for full documentation
4860
+ */
4861
+ once(event_name, callback) {
4862
+ return event_once(this, event_name, callback);
4863
+ }
4864
+ /**
4865
+ * Trigger a lifecycle event - delegates to component-events.ts
4866
+ * @see component-events.ts for full documentation
3745
4867
  */
3746
4868
  trigger(event_name, data) {
3747
- // Mark this event as occurred and store the data for late subscribers
3748
- this._lifecycle_states.set(event_name, data);
3749
- // Fire all registered callbacks for this event
3750
- const callbacks = this._lifecycle_callbacks.get(event_name);
3751
- if (callbacks) {
3752
- for (const callback of callbacks) {
3753
- try {
3754
- callback.bind(this)(this, data);
3755
- }
3756
- catch (error) {
3757
- console.error(`[JQHTML] Error in ${event_name} callback:`, error);
3758
- }
3759
- }
3760
- }
4869
+ event_trigger(this, event_name, data);
3761
4870
  }
3762
4871
  /**
3763
4872
  * Check if any callbacks are registered for a given event
3764
- * Used to determine if cleanup logic needs to run
3765
4873
  */
3766
4874
  _on_registered(event_name) {
3767
- const callbacks = this._lifecycle_callbacks.get(event_name);
3768
- return !!(callbacks && callbacks.length > 0);
4875
+ return event_on_registered(this, event_name);
3769
4876
  }
3770
4877
  /**
3771
- * Invalidate a lifecycle event - removes the "already occurred" marker
3772
- *
3773
- * This is the opposite of trigger(). After invalidate() is called:
3774
- * - New .on() handlers will NOT fire immediately
3775
- * - The ready() promise will NOT resolve immediately
3776
- * - Handlers wait for the next trigger() call
3777
- *
3778
- * Existing registered callbacks are NOT removed - they'll fire on next trigger().
3779
- *
3780
- * Use case: Call invalidate('ready') at the start of reload() or render()
3781
- * so that any new .on('ready') handlers wait for the reload/render to complete
3782
- * rather than firing immediately based on the previous lifecycle's state.
3783
- *
3784
- * @param event_name - Name of the event to invalidate
4878
+ * Invalidate a lifecycle event - delegates to component-events.ts
4879
+ * @see component-events.ts for full documentation
3785
4880
  */
3786
4881
  invalidate(event_name) {
3787
- this._lifecycle_states.delete(event_name);
4882
+ event_invalidate(this, event_name);
3788
4883
  }
3789
4884
  /**
3790
4885
  * Find element by scoped ID
@@ -4083,8 +5178,13 @@ class Jqhtml_Component {
4083
5178
  }
4084
5179
  }
4085
5180
  _set_attributes() {
4086
- // Always set data-cid
4087
- 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
+ }
4088
5188
  // Only set lifecycle state attribute if verbose mode enabled
4089
5189
  if (window.jqhtml?.debug?.verbose) {
4090
5190
  this.$.attr('data-_lifecycle-state', this._ready_state.toString());
@@ -4156,84 +5256,6 @@ class Jqhtml_Component {
4156
5256
  window.JQHTML_DEBUG.log(this.component_name(), 'debug', `${action}: ${args.map(a => JSON.stringify(a)).join(', ')}`);
4157
5257
  }
4158
5258
  }
4159
- /**
4160
- * Creates a debounced function with exclusivity and promise fan-in
4161
- *
4162
- * When invoked, immediately runs the callback exclusively.
4163
- * For subsequent invocations, applies a delay before running the callback exclusively again.
4164
- * The delay starts after the current asynchronous operation resolves.
4165
- *
4166
- * If delay is 0, the function only prevents enqueueing multiple executions,
4167
- * but will still run them immediately in an exclusive sequential manner.
4168
- *
4169
- * The most recent invocation's parameters are used when the function executes.
4170
- * Returns a promise that resolves when the next exclusive execution completes.
4171
- *
4172
- * @private
4173
- */
4174
- _create_debounced_function(callback, delay) {
4175
- let running = false;
4176
- let queued = false;
4177
- let last_end_time = 0; // timestamp of last completed run
4178
- let timer = null;
4179
- let next_args = [];
4180
- let resolve_queue = [];
4181
- let reject_queue = [];
4182
- const run_function = async () => {
4183
- const these_resolves = resolve_queue;
4184
- const these_rejects = reject_queue;
4185
- const args = next_args;
4186
- resolve_queue = [];
4187
- reject_queue = [];
4188
- next_args = [];
4189
- queued = false;
4190
- running = true;
4191
- try {
4192
- const result = await callback(...args);
4193
- for (const resolve of these_resolves)
4194
- resolve(result);
4195
- }
4196
- catch (err) {
4197
- for (const reject of these_rejects)
4198
- reject(err);
4199
- }
4200
- finally {
4201
- running = false;
4202
- last_end_time = Date.now();
4203
- if (queued) {
4204
- clearTimeout(timer);
4205
- timer = setTimeout(run_function, Math.max(delay, 0));
4206
- }
4207
- else {
4208
- timer = null;
4209
- }
4210
- }
4211
- };
4212
- return function (...args) {
4213
- next_args = args;
4214
- return new Promise((resolve, reject) => {
4215
- resolve_queue.push(resolve);
4216
- reject_queue.push(reject);
4217
- // Nothing running and nothing scheduled
4218
- if (!running && !timer) {
4219
- const first_call = last_end_time === 0;
4220
- const since = first_call ? Infinity : Date.now() - last_end_time;
4221
- if (since >= delay) {
4222
- run_function();
4223
- }
4224
- else {
4225
- const wait = Math.max(delay - since, 0);
4226
- clearTimeout(timer);
4227
- timer = setTimeout(run_function, wait);
4228
- }
4229
- return;
4230
- }
4231
- // If already running or timer exists, just mark queued
4232
- // The finally{} of run_function handles scheduling after full delay
4233
- queued = true;
4234
- });
4235
- };
4236
- }
4237
5259
  }
4238
5260
  // Static properties
4239
5261
  Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() detection
@@ -4889,6 +5911,61 @@ function init_jquery_plugin(jQuery) {
4889
5911
  const originalEmpty = jQuery.fn.empty;
4890
5912
  const originalHtml = jQuery.fn.html;
4891
5913
  const originalText = jQuery.fn.text;
5914
+ // ---------------------------------------------------------------------------
5915
+ // Guard against hand-creating elements with data-sid via jQuery.
5916
+ //
5917
+ // $sid / data-sid is provisioned ONLY by the jqhtml template renderer, which
5918
+ // ALWAYS emits data-sid paired with a scoped id ("<sid>:<component_cid>").
5919
+ // That scoped id is what binds the element to its component; data-sid on its
5920
+ // own does nothing. So an element whose data-sid is NOT paired with such an id
5921
+ // was hand-written (e.g. .html('<div data-sid="x">')) and the framework never
5922
+ // provisioned it - this.$sid('x') can never find it. We detect exactly that.
5923
+ //
5924
+ // The framework's own rendering injects data-sid via native innerHTML, never
5925
+ // through these jQuery methods, and the one internal jQuery .html() copy
5926
+ // (the .component() tag-mismatch path) carries the provisioned id - so neither
5927
+ // trips this guard.
5928
+ // ---------------------------------------------------------------------------
5929
+ // Return the offending data-sid value if `html` contains a hand-written
5930
+ // data-sid element (data-sid without the scoped "<sid>:<cid>" id), else null.
5931
+ const find_manual_sid = (html) => {
5932
+ if (typeof html !== 'string' || html.indexOf('data-sid') === -1)
5933
+ return null;
5934
+ if (typeof document === 'undefined')
5935
+ return null; // no DOM to parse (SSR)
5936
+ const tpl = document.createElement('template');
5937
+ tpl.innerHTML = html; // native parse - does not route back through jQuery
5938
+ const candidates = tpl.content.querySelectorAll('[data-sid]');
5939
+ for (let i = 0; i < candidates.length; i++) {
5940
+ const el = candidates[i];
5941
+ const sid = el.getAttribute('data-sid') || '';
5942
+ const id = el.getAttribute('id') || '';
5943
+ // Provisioned elements have id="<sid>:<cid>". Anything else is hand-written.
5944
+ if (id.indexOf(sid + ':') !== 0)
5945
+ return sid;
5946
+ }
5947
+ return null;
5948
+ };
5949
+ const assert_no_manual_sid = (value, where) => {
5950
+ const sid = find_manual_sid(value);
5951
+ if (sid === null)
5952
+ return;
5953
+ throw new Error(`[JQHTML] Manually creating an element with data-sid="${sid}" via jQuery (${where}) is not allowed.\n\n` +
5954
+ `data-sid is NOT a normal attribute you can hand-write. It is provisioned by the jqhtml template\n` +
5955
+ `renderer, which scopes the element to its component by pairing data-sid with a unique id\n` +
5956
+ `("${sid}:<component_cid>"). An element you create yourself has no such scoped id, so\n` +
5957
+ `this.$sid('${sid}') / this.sid('${sid}') can never find it - scoping to a component is the entire\n` +
5958
+ `point of data-sid.\n\n` +
5959
+ `CORRECT USAGE:\n` +
5960
+ ` • Put the element in your component template with $sid:\n` +
5961
+ ` <div $sid="${sid}"></div>\n` +
5962
+ ` then access it from the component: this.$sid('${sid}')\n` +
5963
+ ` • Need it created dynamically? Use a child component or a $redrawable element in the template\n` +
5964
+ ` and render() it, instead of injecting raw HTML.\n\n` +
5965
+ `If you only need to select or mark a plain element, use a normal attribute or class instead:\n` +
5966
+ ` • <div data-role="${sid}"> → .find('[data-role="${sid}"]')\n` +
5967
+ ` • <div class="MyComponent__${sid}"> → .find('.MyComponent__${sid}')`);
5968
+ };
4892
5969
  /**
4893
5970
  * Override jQuery.fn.empty() to stop child components before clearing DOM
4894
5971
  * This ensures component cleanup hooks fire when DOM is cleared
@@ -4915,6 +5992,8 @@ function init_jquery_plugin(jQuery) {
4915
5992
  if (arguments.length === 0) {
4916
5993
  return originalHtml.call(this);
4917
5994
  }
5995
+ // Reject hand-written data-sid before mutating the DOM
5996
+ assert_no_manual_sid(value, '.html()');
4918
5997
  // Setter - empty first (which stops components), then set content
4919
5998
  return this.each(function () {
4920
5999
  jQuery(this).empty();
@@ -5017,6 +6096,36 @@ function init_jquery_plugin(jQuery) {
5017
6096
  // Pass through to original .find()
5018
6097
  return originalFind.call(this, selector);
5019
6098
  };
6099
+ // Guard the content-insertion methods that build elements from HTML strings,
6100
+ // plus $() construction. Wrapped once so repeated init() calls don't re-wrap.
6101
+ if (!jQuery.fn.__jqhtml_sid_guarded) {
6102
+ jQuery.fn.__jqhtml_sid_guarded = true;
6103
+ // .append('<div data-sid>'), .prepend, .before, .after, .replaceWith
6104
+ for (const name of ['append', 'prepend', 'before', 'after', 'replaceWith']) {
6105
+ const original = jQuery.fn[name];
6106
+ if (typeof original !== 'function')
6107
+ continue;
6108
+ jQuery.fn[name] = function (...args) {
6109
+ for (const arg of args)
6110
+ assert_no_manual_sid(arg, '.' + name + '()');
6111
+ return original.apply(this, args);
6112
+ };
6113
+ }
6114
+ // $('<div data-sid="x">') - catch the element at construction time. A
6115
+ // selector like $('[data-sid="x"]') parses to no data-sid element, so it is
6116
+ // not flagged here (selector misuse is handled by .find()/.on()).
6117
+ const originalInit = jQuery.fn.init;
6118
+ const PatchedInit = function (selector, context, root) {
6119
+ if (typeof selector === 'string')
6120
+ assert_no_manual_sid(selector, '$()');
6121
+ return new originalInit(selector, context, root);
6122
+ };
6123
+ PatchedInit.prototype = jQuery.fn;
6124
+ jQuery.fn.init = PatchedInit;
6125
+ }
6126
+ // Install the single debounced window resize listener that drives
6127
+ // on_viewport_resize() on every component. Idempotent.
6128
+ init_viewport_resize(jQuery);
5020
6129
  }
5021
6130
  // Try to auto-initialize if global jQuery exists
5022
6131
  if (typeof window !== 'undefined' && window.jQuery) {
@@ -5030,7 +6139,9 @@ if (typeof window !== 'undefined' && window.jQuery) {
5030
6139
  */
5031
6140
  // Core exports
5032
6141
  // Main initialization function
5033
- function init(jQuery) {
6142
+ function init(jQuery, config) {
6143
+ // Integration config first - it governs behaviour during the very first render
6144
+ configure(config);
5034
6145
  // If jQuery is provided, initialize the plugin
5035
6146
  if (jQuery) {
5036
6147
  init_jquery_plugin(jQuery);
@@ -5044,7 +6155,7 @@ function init(jQuery) {
5044
6155
  }
5045
6156
  }
5046
6157
  // Version - will be replaced during build with actual version from package.json
5047
- const version = '2.3.35';
6158
+ const version = '2.3.54';
5048
6159
  // Default export with all functionality
5049
6160
  const jqhtml = {
5050
6161
  // Core
@@ -5072,6 +6183,9 @@ const jqhtml = {
5072
6183
  __version: version,
5073
6184
  // state facts
5074
6185
  tombstone: 'pepperoni and cheese',
6186
+ // Integration configuration - see config.ts
6187
+ configure,
6188
+ get_config,
5075
6189
  // Debug settings
5076
6190
  debug: {
5077
6191
  enabled: false,
@@ -5141,7 +6255,13 @@ const jqhtml = {
5141
6255
  // Required for ES6 class instances to be stored and restored from localStorage
5142
6256
  register_cache_class,
5143
6257
  // Boot - hydrate server-rendered component placeholders
5144
- boot
6258
+ boot,
6259
+ // SSR Preload - capture data during SSR, replay on client to skip on_load()
6260
+ start_data_capture,
6261
+ get_captured_data,
6262
+ stop_data_capture,
6263
+ set_preload_data,
6264
+ clear_preload_data
5145
6265
  };
5146
6266
  // Auto-register on window for browser environments
5147
6267
  // This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -5171,5 +6291,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
5171
6291
  }
5172
6292
  }
5173
6293
 
5174
- export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, 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, 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 };
5175
6295
  //# sourceMappingURL=jqhtml-core.esm.js.map