@jqhtml/core 2.3.64 → 2.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/component-cache.d.ts +18 -0
  3. package/dist/component-cache.d.ts.map +1 -1
  4. package/dist/component-events.d.ts +11 -6
  5. package/dist/component-events.d.ts.map +1 -1
  6. package/dist/component-name.d.ts +7 -0
  7. package/dist/component-name.d.ts.map +1 -1
  8. package/dist/component-queue.d.ts +23 -12
  9. package/dist/component-queue.d.ts.map +1 -1
  10. package/dist/component.d.ts +48 -0
  11. package/dist/component.d.ts.map +1 -1
  12. package/dist/data-proxy.d.ts +5 -2
  13. package/dist/data-proxy.d.ts.map +1 -1
  14. package/dist/debug-overlay/index.d.ts +35 -0
  15. package/dist/debug-overlay/index.d.ts.map +1 -0
  16. package/dist/escape.d.ts +13 -0
  17. package/dist/escape.d.ts.map +1 -0
  18. package/dist/index.cjs +1853 -1022
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +18 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1849 -1022
  23. package/dist/index.js.map +1 -1
  24. package/dist/instruction-processor.d.ts.map +1 -1
  25. package/dist/jqhtml-core.esm.js +1850 -1023
  26. package/dist/jqhtml-core.esm.js.map +1 -1
  27. package/dist/jquery-plugin.d.ts.map +1 -1
  28. package/dist/lifecycle-manager.d.ts +0 -4
  29. package/dist/lifecycle-manager.d.ts.map +1 -1
  30. package/dist/load-coordinator.d.ts +18 -9
  31. package/dist/load-coordinator.d.ts.map +1 -1
  32. package/dist/local-storage.d.ts +41 -8
  33. package/dist/local-storage.d.ts.map +1 -1
  34. package/dist/preload-data.d.ts +8 -0
  35. package/dist/preload-data.d.ts.map +1 -1
  36. package/dist/value-printers.d.ts +47 -0
  37. package/dist/value-printers.d.ts.map +1 -0
  38. package/package.json +2 -1
  39. package/dist/template-renderer.d.ts +0 -22
  40. package/dist/template-renderer.d.ts.map +0 -1
@@ -1,8 +1,199 @@
1
1
  /**
2
- * JQHTML Core v2.3.64
2
+ * JQHTML Core v2.3.68
3
3
  * (c) 2025 JQHTML Team
4
4
  * Released under the MIT License
5
5
  */
6
+ /**
7
+ * JQHTML Debug Module
8
+ *
9
+ * Provides comprehensive debugging capabilities for JQHTML components
10
+ */
11
+ // Global debug state
12
+ let performanceMetrics = new Map();
13
+ /**
14
+ * Development warning helper
15
+ * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
16
+ */
17
+ function devWarn(message) {
18
+ // Check if warnings are suppressed
19
+ if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
20
+ return;
21
+ }
22
+ // Check if in production mode
23
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
24
+ return;
25
+ }
26
+ console.warn(`[JQHTML Dev Warning] ${message}`);
27
+ }
28
+ // Get global jqhtml object
29
+ function getJqhtml() {
30
+ if (typeof window !== 'undefined' && window.jqhtml) {
31
+ return window.jqhtml;
32
+ }
33
+ // Fallback: try to get from global if available
34
+ if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
35
+ return globalThis.jqhtml;
36
+ }
37
+ throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
38
+ 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
39
+ }
40
+ // Visual flash effect
41
+ function flashComponent(component, eventType) {
42
+ const jqhtml = getJqhtml();
43
+ if (!jqhtml?.debug?.flashComponents)
44
+ return;
45
+ const duration = jqhtml.debug.flashDuration || 500;
46
+ const colors = jqhtml.debug.flashColors || {};
47
+ const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
48
+ eventType === 'render' ? '#27ae60' :
49
+ '#9b59b6');
50
+ // Store original border
51
+ const originalBorder = component.$.css('border');
52
+ // Apply flash border
53
+ component.$.css({
54
+ 'border': `2px solid ${color}`,
55
+ 'transition': `border ${duration}ms ease-out`
56
+ });
57
+ // Remove after duration
58
+ setTimeout(() => {
59
+ component.$.css('border', originalBorder || '');
60
+ }, duration);
61
+ }
62
+ // Log lifecycle event
63
+ function logLifecycle(component, phase, status) {
64
+ const jqhtml = getJqhtml();
65
+ if (!jqhtml?.debug)
66
+ return;
67
+ const shouldLog = jqhtml.debug.logFullLifecycle ||
68
+ (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
69
+ if (!shouldLog)
70
+ return;
71
+ const componentName = component.constructor.name;
72
+ const timestamp = new Date().toISOString();
73
+ const prefix = `[JQHTML ${timestamp}]`;
74
+ if (status === 'start') {
75
+ console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
76
+ // Start performance tracking
77
+ if (jqhtml.debug.profilePerformance) {
78
+ performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
79
+ }
80
+ }
81
+ else {
82
+ let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
83
+ // Add performance data
84
+ if (jqhtml.debug.profilePerformance) {
85
+ const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
86
+ if (startTime) {
87
+ const duration = Date.now() - startTime;
88
+ message += ` (${duration}ms)`;
89
+ // Highlight slow renders
90
+ if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
91
+ duration > jqhtml.debug.highlightSlowRenders) {
92
+ console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
93
+ component.$.css('outline', '2px dashed red');
94
+ }
95
+ }
96
+ }
97
+ console.log(message);
98
+ // Visual feedback
99
+ if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
100
+ flashComponent(component, phase);
101
+ }
102
+ }
103
+ // Update component tree if enabled
104
+ if (jqhtml.debug.showComponentTree) {
105
+ updateComponentTree();
106
+ }
107
+ }
108
+ // Apply delays based on lifecycle phase
109
+ function applyDebugDelay(phase) {
110
+ const jqhtml = getJqhtml();
111
+ if (!jqhtml?.debug)
112
+ return;
113
+ let delayMs = 0;
114
+ switch (phase) {
115
+ case 'component':
116
+ delayMs = jqhtml.debug.delayAfterComponent || 0;
117
+ break;
118
+ case 'render':
119
+ delayMs = jqhtml.debug.delayAfterRender || 0;
120
+ break;
121
+ case 'rerender':
122
+ delayMs = jqhtml.debug.delayAfterRerender || 0;
123
+ break;
124
+ }
125
+ if (delayMs > 0) {
126
+ console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
127
+ }
128
+ }
129
+ // Log instruction processing
130
+ function logInstruction(type, data) {
131
+ const jqhtml = getJqhtml();
132
+ if (!jqhtml?.debug?.logInstructionProcessing)
133
+ return;
134
+ console.log(`[JQHTML Instruction] ${type}:`, data);
135
+ }
136
+ // Log data changes
137
+ function logDataChange(component, property, oldValue, newValue) {
138
+ const jqhtml = getJqhtml();
139
+ if (!jqhtml?.debug?.traceDataFlow)
140
+ return;
141
+ console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
142
+ }
143
+ // Update component tree visualization
144
+ function updateComponentTree() {
145
+ // This would update a debug panel if implemented
146
+ // For now, just log the tree structure periodically
147
+ console.log('[JQHTML Tree] Component hierarchy updated');
148
+ }
149
+ // Router dispatch logging
150
+ function logDispatch(url, route, params, verbose = false) {
151
+ const jqhtml = getJqhtml();
152
+ if (!jqhtml?.debug)
153
+ return;
154
+ const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
155
+ if (!shouldLog)
156
+ return;
157
+ const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
158
+ if (isVerbose) {
159
+ console.group(`[JQHTML Router] Dispatching: ${url}`);
160
+ console.log('Matched route:', route);
161
+ console.log('Extracted params:', params);
162
+ console.log('Route component:', route.component);
163
+ console.log('Route layout:', route.layout);
164
+ console.log('Route meta:', route.meta);
165
+ console.groupEnd();
166
+ }
167
+ else {
168
+ console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
169
+ }
170
+ }
171
+ // Check if sequential processing is enabled
172
+ function isSequentialProcessing() {
173
+ const jqhtml = getJqhtml();
174
+ return jqhtml?.debug?.sequentialProcessing || false;
175
+ }
176
+ // Error handling with break on error
177
+ function handleComponentError(component, phase, error) {
178
+ const jqhtml = getJqhtml();
179
+ console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
180
+ if (jqhtml?.debug?.breakOnError) {
181
+ debugger; // This will pause execution in dev tools
182
+ }
183
+ }
184
+ // Additional debug suggestions that could be implemented:
185
+ //
186
+ // 1. Component Inspector - Click on any component to see its data/args/state
187
+ // 2. Time Travel Debugging - Record state changes and replay them
188
+ // 3. Network Request Tracking - Log all AJAX calls made during load()
189
+ // 4. Memory Leak Detection - Track component creation/destruction
190
+ // 5. Template Compilation Debugging - Show compiled template functions
191
+ // 6. Event Flow Visualization - Show event bubbling through components
192
+ // 7. Dependency Graph - Show which components depend on which data
193
+ // 8. Hot Reload Support - Reload components without losing state
194
+ // 9. Performance Budgets - Warn when components exceed size/time limits
195
+ // 10. Accessibility Auditing - Check for missing ARIA attributes
196
+
6
197
  /**
7
198
  * JQHTML v2 Lifecycle Manager
8
199
  *
@@ -51,8 +242,9 @@ class LifecycleManager {
51
242
  // Check if stopped during create
52
243
  if (component._stopped)
53
244
  return;
54
- // Trigger create event
55
- component.trigger('create');
245
+ // NOTE: no trigger('create') here. component.create() already fires it exactly
246
+ // once - its `_ready_state >= 1` guard makes a second create() a no-op, so this
247
+ // duplicate trigger only ever double-fired the event for subscribers.
56
248
  // Check for lifecycle truncation flags
57
249
  const load_only = component._load_only;
58
250
  const load_render_only = component._load_render_only;
@@ -178,8 +370,30 @@ class LifecycleManager {
178
370
  return;
179
371
  }
180
372
  catch (error) {
181
- console.error(`Error booting component ${component.component_name()}:`, error);
182
- throw error;
373
+ // A boot that throws must leave a STOPPED component behind, not a half-booted
374
+ // one. The component never reaches 'ready', so an ancestor parked in
375
+ // _wait_for_children_ready() can only be released by the child's 'stop' event;
376
+ // nothing else stops it, so the whole subtree above it would hang forever.
377
+ //
378
+ // And the throw must stop here: _boot() is called unawaited by
379
+ // instruction-processor.initialize_component() and by the jQuery plugin, so a
380
+ // rethrow surfaces as an unhandled promise rejection instead of an error report.
381
+ // The error object stays a console.error argument so callers (and tests) that
382
+ // grep the console for the thrown message still see it.
383
+ try {
384
+ handleComponentError(component, 'boot', error);
385
+ }
386
+ catch {
387
+ // handleComponentError needs the global jqhtml object and throws without it.
388
+ // Never lose the original error to a failure in the reporting path.
389
+ console.error(`Error booting component ${component.component_name()}:`, error);
390
+ }
391
+ try {
392
+ component._stop();
393
+ }
394
+ catch (stop_error) {
395
+ console.error(`[JQHTML] on_stop() threw while unwinding a failed boot:`, stop_error);
396
+ }
183
397
  }
184
398
  }
185
399
  /**
@@ -188,20 +402,6 @@ class LifecycleManager {
188
402
  unregister_component(component) {
189
403
  this.active_components.delete(component);
190
404
  }
191
- /**
192
- * Wait for all active components to reach ready state
193
- */
194
- async wait_for_ready() {
195
- const ready_promises = [];
196
- for (const component of this.active_components) {
197
- if (component._ready_state < 4) {
198
- ready_promises.push(new Promise((resolve) => {
199
- component.on('ready', () => resolve());
200
- }));
201
- }
202
- }
203
- await Promise.all(ready_promises);
204
- }
205
405
  }
206
406
 
207
407
  /**
@@ -218,6 +418,23 @@ const COMPONENT_NAME_RULE = 'must start with a capital letter, optionally preced
218
418
  function is_component_name(name) {
219
419
  return COMPONENT_NAME_PATTERN.test(name);
220
420
  }
421
+ /**
422
+ * The render-time check behind <{expression}>: the expression must evaluate
423
+ * to a component name string that a literal tag would accept - no more, no
424
+ * less. A valid name that names nothing renders the ordinary placeholder
425
+ * component, exactly as an undefined literal tag does.
426
+ */
427
+ function dynamic_component_name(value) {
428
+ if (typeof value !== 'string' || value === '') {
429
+ const shown = value === '' ? 'an empty string' : typeof value === 'string' ? `'${value}'` : `${value === null ? 'null' : typeof value}`;
430
+ throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to ${shown}; expected a non-empty component name string`);
431
+ }
432
+ if (!is_component_name(value)) {
433
+ throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to '${value}', which is not a valid component name: ` +
434
+ `a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
435
+ }
436
+ return value;
437
+ }
221
438
 
222
439
  /**
223
440
  * JQHTML v2 Component Registry
@@ -647,30 +864,32 @@ function process_instructions(instructions, target, context, slots) {
647
864
  // Set innerHTML once - this is the performance win
648
865
  // Use native innerHTML for better performance
649
866
  target[0].innerHTML = html.join('');
650
- // Second pass: initialize special attributes and events
651
- for (const [tid, tagData] of Object.entries(tagElements)) {
652
- // Use native querySelector for better performance
653
- const el = target[0].querySelector(`[data-tid="${tid}"]`);
654
- if (el) {
655
- const element = $(el);
656
- el.removeAttribute('data-tid');
867
+ // Second pass: initialize special attributes and events.
868
+ // One querySelectorAll over the freshly injected subtree, in document
869
+ // order, rather than one querySelector per tracked element: a scan per
870
+ // element made this pass quadratic in the size of the template. Child
871
+ // components have not rendered yet (their elements are empty), so every
872
+ // data-tid here belongs to this level.
873
+ const root = target[0];
874
+ for (const el of Array.from(root.querySelectorAll('[data-tid]'))) {
875
+ const tagData = tagElements[el.getAttribute('data-tid')];
876
+ el.removeAttribute('data-tid');
877
+ if (tagData) {
657
878
  // Bind to the component whose template wrote this element, which for
658
879
  // spliced content is not the component being rendered.
659
- apply_attributes(element, tagData.attrs, tagData.context);
880
+ apply_attributes($(el), tagData.attrs, tagData.context);
660
881
  }
661
882
  }
662
883
  // Third pass: initialize and boot components in parallel
663
884
  // Like v1, all sibling components at this level boot simultaneously
664
885
  // DO NOT await - let children boot in background while parent continues
665
- for (const [cid, compData] of Object.entries(components)) {
666
- // Use native querySelector for better performance
667
- const el = target[0].querySelector(`[data-cid="${cid}"]`);
668
- if (el) {
669
- const element = $(el);
670
- el.removeAttribute('data-cid');
886
+ for (const el of Array.from(root.querySelectorAll('[data-cid]'))) {
887
+ const compData = components[el.getAttribute('data-cid')];
888
+ el.removeAttribute('data-cid');
889
+ if (compData) {
671
890
  // Boot this component (which will render and boot its children recursively)
672
891
  // Fire and forget - don't wait for boot to complete
673
- initialize_component(element, compData);
892
+ initialize_component($(el), compData);
674
893
  }
675
894
  }
676
895
  }
@@ -735,21 +954,13 @@ function process_tag_to_html(instruction, html, tagElements, components, context
735
954
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
736
955
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
737
956
  (typeof value === 'string' || typeof value === 'number')) {
738
- if (key === 'id' && tid) {
739
- // Special handling for id attribute - scope to parent component's _cid
740
- // This is for regular id="foo" attributes that need scoping (rare case)
741
- // Most scoping happens via $sid attribute which becomes data-sid
742
- // Don't double-scope if already scoped (contains :)
743
- if (typeof value === 'string' && value.includes(':')) {
744
- html.push(` id="${value}"`);
745
- }
746
- else {
747
- html.push(` id="${value}:${context._cid}"`);
748
- }
749
- }
750
- else {
751
- html.push(` ${key}="${value}"`);
752
- }
957
+ // A hand-written `id` is emitted exactly as written - including on a tracked
958
+ // element. It used to be rewritten to `<value>:<cid>` whenever the element
959
+ // happened to carry some OTHER tracked attribute, so `<label id="x" @click=…>`
960
+ // and `<label id="x">` disagreed. Per-instance ids are what $sid is for, and
961
+ // the compiler emits those already scoped (codegen.ts), so they arrive here as
962
+ // ordinary values and pass straight through.
963
+ html.push(` ${key}="${escape_attribute(value)}"`);
753
964
  }
754
965
  }
755
966
  // Close opening tag
@@ -760,6 +971,16 @@ function process_tag_to_html(instruction, html, tagElements, components, context
760
971
  html.push('>');
761
972
  }
762
973
  }
974
+ /**
975
+ * A double quote inside an attribute value would end the attribute in the
976
+ * HTML string this pass builds; an interpolated value can never legitimately
977
+ * do that, so it is encoded. Nothing else is touched - the value is otherwise
978
+ * emitted exactly as the template produced it.
979
+ */
980
+ function escape_attribute(value) {
981
+ const s = typeof value === 'string' ? value : String(value);
982
+ return s.indexOf('"') === -1 ? s : s.replace(/"/g, '&quot;');
983
+ }
763
984
  /**
764
985
  * Process a component instruction to HTML
765
986
  */
@@ -1116,13 +1337,10 @@ async function initialize_component(element, compData) {
1116
1337
  if (slots) {
1117
1338
  options._slots = slots;
1118
1339
  }
1119
- // Always pass _component_name when component name differs from class name
1120
- // This happens when:
1121
- // 1. No class registered for component (uses Jqhtml_Component)
1122
- // 2. Class inherited from parent via extends chain (uses parent class but child template)
1123
- if (ComponentClass.name !== name) {
1124
- options._component_name = name;
1125
- }
1340
+ // Always pass the invocation name: it is what component_name() reports, it
1341
+ // selects the template when the class is inherited via an extends chain or
1342
+ // is the bare Jqhtml_Component, and it is the class the element carries.
1343
+ options._component_name = name;
1126
1344
  // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1127
1345
  // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1128
1346
  if (props._load_only === true || props['$_load_only'] === true) {
@@ -1138,210 +1356,19 @@ async function initialize_component(element, compData) {
1138
1356
  // Boot the component immediately - this runs its full lifecycle
1139
1357
  await instance._boot();
1140
1358
  }
1141
- /**
1142
- * Utility to extract slots from instructions
1143
- */
1144
- function extract_slots(instructions) {
1145
- const slots = {};
1146
- for (const instruction of instructions) {
1147
- if (typeof instruction === 'object' && 'slot' in instruction) {
1148
- const [name] = instruction.slot;
1149
- slots[name] = instruction;
1150
- }
1151
- }
1152
- return slots;
1153
- }
1154
-
1155
- /**
1156
- * JQHTML Debug Module
1157
- *
1158
- * Provides comprehensive debugging capabilities for JQHTML components
1159
- */
1160
- // Global debug state
1161
- let performanceMetrics = new Map();
1162
- /**
1163
- * Development warning helper
1164
- * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
1165
- */
1166
- function devWarn(message) {
1167
- // Check if warnings are suppressed
1168
- if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
1169
- return;
1170
- }
1171
- // Check if in production mode
1172
- if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
1173
- return;
1174
- }
1175
- console.warn(`[JQHTML Dev Warning] ${message}`);
1176
- }
1177
- // Get global jqhtml object
1178
- function getJqhtml() {
1179
- if (typeof window !== 'undefined' && window.jqhtml) {
1180
- return window.jqhtml;
1181
- }
1182
- // Fallback: try to get from global if available
1183
- if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
1184
- return globalThis.jqhtml;
1185
- }
1186
- throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
1187
- 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
1188
- }
1189
- // Visual flash effect
1190
- function flashComponent(component, eventType) {
1191
- const jqhtml = getJqhtml();
1192
- if (!jqhtml?.debug?.flashComponents)
1193
- return;
1194
- const duration = jqhtml.debug.flashDuration || 500;
1195
- const colors = jqhtml.debug.flashColors || {};
1196
- const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
1197
- eventType === 'render' ? '#27ae60' :
1198
- '#9b59b6');
1199
- // Store original border
1200
- const originalBorder = component.$.css('border');
1201
- // Apply flash border
1202
- component.$.css({
1203
- 'border': `2px solid ${color}`,
1204
- 'transition': `border ${duration}ms ease-out`
1205
- });
1206
- // Remove after duration
1207
- setTimeout(() => {
1208
- component.$.css('border', originalBorder || '');
1209
- }, duration);
1210
- }
1211
- // Log lifecycle event
1212
- function logLifecycle(component, phase, status) {
1213
- const jqhtml = getJqhtml();
1214
- if (!jqhtml?.debug)
1215
- return;
1216
- const shouldLog = jqhtml.debug.logFullLifecycle ||
1217
- (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
1218
- if (!shouldLog)
1219
- return;
1220
- const componentName = component.constructor.name;
1221
- const timestamp = new Date().toISOString();
1222
- const prefix = `[JQHTML ${timestamp}]`;
1223
- if (status === 'start') {
1224
- console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
1225
- // Start performance tracking
1226
- if (jqhtml.debug.profilePerformance) {
1227
- performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
1228
- }
1229
- }
1230
- else {
1231
- let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
1232
- // Add performance data
1233
- if (jqhtml.debug.profilePerformance) {
1234
- const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
1235
- if (startTime) {
1236
- const duration = Date.now() - startTime;
1237
- message += ` (${duration}ms)`;
1238
- // Highlight slow renders
1239
- if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
1240
- duration > jqhtml.debug.highlightSlowRenders) {
1241
- console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
1242
- component.$.css('outline', '2px dashed red');
1243
- }
1244
- }
1245
- }
1246
- console.log(message);
1247
- // Visual feedback
1248
- if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
1249
- flashComponent(component, phase);
1250
- }
1251
- }
1252
- // Update component tree if enabled
1253
- if (jqhtml.debug.showComponentTree) {
1254
- updateComponentTree();
1255
- }
1256
- }
1257
- // Apply delays based on lifecycle phase
1258
- function applyDebugDelay(phase) {
1259
- const jqhtml = getJqhtml();
1260
- if (!jqhtml?.debug)
1261
- return;
1262
- let delayMs = 0;
1263
- switch (phase) {
1264
- case 'component':
1265
- delayMs = jqhtml.debug.delayAfterComponent || 0;
1266
- break;
1267
- case 'render':
1268
- delayMs = jqhtml.debug.delayAfterRender || 0;
1269
- break;
1270
- case 'rerender':
1271
- delayMs = jqhtml.debug.delayAfterRerender || 0;
1272
- break;
1273
- }
1274
- if (delayMs > 0) {
1275
- console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
1276
- }
1277
- }
1278
- // Log instruction processing
1279
- function logInstruction(type, data) {
1280
- const jqhtml = getJqhtml();
1281
- if (!jqhtml?.debug?.logInstructionProcessing)
1282
- return;
1283
- console.log(`[JQHTML Instruction] ${type}:`, data);
1284
- }
1285
- // Log data changes
1286
- function logDataChange(component, property, oldValue, newValue) {
1287
- const jqhtml = getJqhtml();
1288
- if (!jqhtml?.debug?.traceDataFlow)
1289
- return;
1290
- console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
1291
- }
1292
- // Update component tree visualization
1293
- function updateComponentTree() {
1294
- // This would update a debug panel if implemented
1295
- // For now, just log the tree structure periodically
1296
- console.log('[JQHTML Tree] Component hierarchy updated');
1297
- }
1298
- // Router dispatch logging
1299
- function logDispatch(url, route, params, verbose = false) {
1300
- const jqhtml = getJqhtml();
1301
- if (!jqhtml?.debug)
1302
- return;
1303
- const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
1304
- if (!shouldLog)
1305
- return;
1306
- const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
1307
- if (isVerbose) {
1308
- console.group(`[JQHTML Router] Dispatching: ${url}`);
1309
- console.log('Matched route:', route);
1310
- console.log('Extracted params:', params);
1311
- console.log('Route component:', route.component);
1312
- console.log('Route layout:', route.layout);
1313
- console.log('Route meta:', route.meta);
1314
- console.groupEnd();
1315
- }
1316
- else {
1317
- console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
1318
- }
1319
- }
1320
- // Check if sequential processing is enabled
1321
- function isSequentialProcessing() {
1322
- const jqhtml = getJqhtml();
1323
- return jqhtml?.debug?.sequentialProcessing || false;
1324
- }
1325
- // Error handling with break on error
1326
- function handleComponentError(component, phase, error) {
1327
- const jqhtml = getJqhtml();
1328
- console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
1329
- if (jqhtml?.debug?.breakOnError) {
1330
- debugger; // This will pause execution in dev tools
1359
+ /**
1360
+ * Utility to extract slots from instructions
1361
+ */
1362
+ function extract_slots(instructions) {
1363
+ const slots = {};
1364
+ for (const instruction of instructions) {
1365
+ if (typeof instruction === 'object' && 'slot' in instruction) {
1366
+ const [name] = instruction.slot;
1367
+ slots[name] = instruction;
1368
+ }
1331
1369
  }
1370
+ return slots;
1332
1371
  }
1333
- // Additional debug suggestions that could be implemented:
1334
- //
1335
- // 1. Component Inspector - Click on any component to see its data/args/state
1336
- // 2. Time Travel Debugging - Record state changes and replay them
1337
- // 3. Network Request Tracking - Log all AJAX calls made during load()
1338
- // 4. Memory Leak Detection - Track component creation/destruction
1339
- // 5. Template Compilation Debugging - Show compiled template functions
1340
- // 6. Event Flow Visualization - Show event bubbling through components
1341
- // 7. Dependency Graph - Show which components depend on which data
1342
- // 8. Hot Reload Support - Reload components without losing state
1343
- // 9. Performance Budgets - Warn when components exceed size/time limits
1344
- // 10. Accessibility Auditing - Check for missing ARIA attributes
1345
1372
 
1346
1373
  /**
1347
1374
  * JQHTML Cache Key Serializer
@@ -1610,15 +1637,12 @@ class Load_Coordinator {
1610
1637
  /**
1611
1638
  * Check if a component should execute on_load() or wait for existing request
1612
1639
  * Returns true if component should execute (is leader), false if should wait (is follower)
1640
+ *
1641
+ * @param key - The dedup key the caller captured for this load. Every coordinator
1642
+ * method takes it, because args may legally change during on_load()
1643
+ * and a recomputed key would address a different (or no) entry.
1613
1644
  */
1614
- static should_execute_on_load(component) {
1615
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1616
- // No identity means no coordination. Without this, every un-keyable component
1617
- // would share the single `null` registry entry and adopt an unrelated
1618
- // component's data - the worst failure this system can produce.
1619
- if (key === null) {
1620
- return true;
1621
- }
1645
+ static should_execute_on_load(component, key) {
1622
1646
  const entry = this._registry.get(key);
1623
1647
  if (!entry) {
1624
1648
  // No existing request - this component becomes the leader
@@ -1637,26 +1661,27 @@ class Load_Coordinator {
1637
1661
  * Register a leader component that will execute on_load()
1638
1662
  * Creates coordination entry and returns a function to call when data is ready
1639
1663
  *
1664
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
1640
1665
  * @returns A function that accepts the final data and completes coordination
1641
1666
  */
1642
- static register_leader(component, on_load_promise) {
1643
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1644
- // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1645
- if (key === null) {
1646
- return () => { };
1647
- }
1648
- // Create a promise that we control - it resolves when complete_coordination is called
1667
+ static register_leader(component, key) {
1668
+ // Create a promise that we control - it settles when coordination completes or fails
1649
1669
  let resolve_promise;
1650
- const coordination_promise = new Promise((resolve) => {
1670
+ let reject_promise;
1671
+ const coordination_promise = new Promise((resolve, reject) => {
1651
1672
  resolve_promise = resolve;
1673
+ reject_promise = reject;
1652
1674
  });
1675
+ // A leader with no followers still has to be able to reject this promise on
1676
+ // error; without a handler that rejection surfaces as an unhandled rejection.
1677
+ coordination_promise.catch(() => { });
1653
1678
  const entry = {
1654
1679
  status: 'loading',
1655
- promise: coordination_promise, // Followers await THIS promise, not on_load_promise
1680
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
1656
1681
  resolve_promise,
1682
+ reject_promise,
1657
1683
  leader_component: component,
1658
1684
  leader_data: null,
1659
- leader_error: null,
1660
1685
  waiting: []
1661
1686
  };
1662
1687
  this._registry.set(key, entry);
@@ -1665,10 +1690,9 @@ class Load_Coordinator {
1665
1690
  }
1666
1691
  /**
1667
1692
  * Get the coordination promise for a follower component
1668
- * Returns a promise that resolves when the leader completes
1693
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
1669
1694
  */
1670
- static get_coordination_promise(component) {
1671
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1695
+ static get_coordination_promise(key) {
1672
1696
  const entry = this._registry.get(key);
1673
1697
  if (!entry || entry.status !== 'loading') {
1674
1698
  return null;
@@ -1680,7 +1704,7 @@ class Load_Coordinator {
1680
1704
  * Stores leader's data, marks entry as completed, and resolves the promise.
1681
1705
  * Followers retrieve the data themselves via get_leader_data().
1682
1706
  *
1683
- * @param key - The coordination key
1707
+ * @param key - The coordination key captured at registration
1684
1708
  * @param leader - The leader component
1685
1709
  * @param final_data - The final data after on_load and normalization
1686
1710
  * @private
@@ -1690,14 +1714,14 @@ class Load_Coordinator {
1690
1714
  if (!entry) {
1691
1715
  return;
1692
1716
  }
1693
- // Store the final data (already normalized by _apply_load_result)
1694
- // Deep clone to avoid sharing proxy objects between components
1717
+ // Stored as TEXT so each follower parses its own object graph - sharing one
1718
+ // decoded object would let one component's mutation reach every other.
1695
1719
  try {
1696
- entry.leader_data = JSON.parse(JSON.stringify(final_data));
1720
+ entry.leader_data = JSON.stringify(final_data);
1697
1721
  }
1698
1722
  catch (error) {
1699
- // If data can't be serialized, use direct reference (may have issues)
1700
- entry.leader_data = final_data;
1723
+ // Unserializable data cannot be shared; followers load independently.
1724
+ entry.leader_data = null;
1701
1725
  }
1702
1726
  entry.status = 'completed';
1703
1727
  if (window.jqhtml?.debug?.verbose) {
@@ -1705,22 +1729,22 @@ class Load_Coordinator {
1705
1729
  }
1706
1730
  // Resolve the promise - followers waiting on it will now wake up
1707
1731
  entry.resolve_promise();
1708
- // Note: We do NOT delete the entry here anymore.
1709
- // Followers need to retrieve the data after the promise resolves.
1710
- // Entry will be cleaned up when last follower retrieves data.
1732
+ // Nobody is waiting, so nothing will ever come back to collect the data.
1733
+ // Without this the registry grows once per distinct name+args, forever.
1734
+ if (entry.waiting.length === 0) {
1735
+ this._registry.delete(key);
1736
+ }
1711
1737
  }
1712
1738
  /**
1713
1739
  * Get leader's data for a follower component
1714
1740
  * Called by follower after coordination promise resolves
1715
- * Returns the data and cleans up if this is the last follower
1741
+ * Returns a freshly parsed copy and cleans up if this is the last follower
1742
+ *
1743
+ * @param key - The dedup key captured by the follower before it began waiting
1716
1744
  */
1717
- static get_leader_data(component) {
1718
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1719
- if (!key) {
1720
- return null;
1721
- }
1745
+ static get_leader_data(component, key) {
1722
1746
  const entry = this._registry.get(key);
1723
- if (!entry || entry.status !== 'completed') {
1747
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1724
1748
  return null;
1725
1749
  }
1726
1750
  // Remove this follower from the waiting list
@@ -1728,6 +1752,8 @@ class Load_Coordinator {
1728
1752
  if (follower_index !== -1) {
1729
1753
  entry.waiting.splice(follower_index, 1);
1730
1754
  }
1755
+ // One parse per follower: each gets its OWN object, never a shared reference.
1756
+ const data = JSON.parse(entry.leader_data);
1731
1757
  if (window.jqhtml?.debug?.verbose) {
1732
1758
  console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1733
1759
  }
@@ -1738,29 +1764,25 @@ class Load_Coordinator {
1738
1764
  console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1739
1765
  }
1740
1766
  }
1741
- return entry.leader_data;
1767
+ return data;
1742
1768
  }
1743
1769
  /**
1744
1770
  * Handle leader on_load() error
1745
- * Propagates error to all followers and cleans up entry
1771
+ * Rejects every waiting follower and clears the entry so the next component retries
1772
+ *
1773
+ * @param key - The dedup key captured at registration
1746
1774
  */
1747
- static handle_leader_error(component, error) {
1748
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1775
+ static handle_leader_error(key, error) {
1749
1776
  const entry = this._registry.get(key);
1750
1777
  if (!entry) {
1751
1778
  return;
1752
1779
  }
1753
- entry.leader_error = error;
1754
1780
  entry.status = 'failed';
1755
- console.error(`[Load Coordinator] Leader ${component._cid} on_load() failed for key: ${key}`, error);
1756
- // Propagate error to all followers
1757
- // Note: Followers will handle errors the same way as if their own on_load() failed
1758
- // This is transparent to the developer
1759
- for (const follower of entry.waiting) {
1760
- console.error(`[Load Coordinator] Follower ${follower._cid} failed due to leader error`, error);
1761
- // The follower's lifecycle will handle the error naturally
1762
- // as the promise rejection will propagate
1763
- }
1781
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
1782
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
1783
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
1784
+ // without rejecting left them awaiting a promise nobody could ever settle.
1785
+ entry.reject_promise(error);
1764
1786
  // Clear coordination entry so future requests can retry
1765
1787
  this._registry.delete(key);
1766
1788
  if (window.jqhtml?.debug?.verbose) {
@@ -1859,6 +1881,31 @@ Load_Coordinator._registry = new Map();
1859
1881
  * - Transient UI state (convenience, not required)
1860
1882
  *
1861
1883
  * @internal This class is not exposed in the public API
1884
+ *
1885
+ * ---------------------------------------------------------------------------
1886
+ * REPORTING WHAT WAS DROPPED
1887
+ *
1888
+ * `this.data` is always the serialized copy this file produces - in EVERY cache
1889
+ * mode, including 'none' (see component.ts `_apply_load_result`). Whatever the
1890
+ * serializer cannot express is therefore not merely missing from the cache, it
1891
+ * is missing from `this.data`, and an author needs to be told once rather than
1892
+ * discover it on the next cache hit.
1893
+ *
1894
+ * Two small optional parameters carry that:
1895
+ *
1896
+ * process_for_serialization(value, verbose, seen, path, collector?)
1897
+ * `collector(path, kind)` is invoked for every value that is dropped or
1898
+ * degraded. `path` is a dotted/bracketed path from the root the caller
1899
+ * named; `kind` is one of Strip_Kind. Paths are only BUILT when a
1900
+ * collector is present, so the no-collector path costs nothing.
1901
+ *
1902
+ * Jqhtml_Local_Storage.normalize_for_cache(value, component_name?)
1903
+ * With a component name, and only in development mode, each dropped path
1904
+ * prints exactly ONE console.warn for the life of the page (keyed
1905
+ * `${component_name}:${path}`, the same once-only shape as
1906
+ * warn_uncacheable_args in component-cache.ts). Production converts
1907
+ * silently. Neither ever throws.
1908
+ * ---------------------------------------------------------------------------
1862
1909
  */
1863
1910
  // ============================================================================
1864
1911
  // Class Registry for Serialization
@@ -1867,7 +1914,7 @@ Load_Coordinator._registry = new Map();
1867
1914
  const class_registry = Object.create(null);
1868
1915
  // Markers for serialized class instances (unique to avoid collisions with user data)
1869
1916
  // Replaced at build time by rollup (see rollup.config.js).
1870
- const CORE_VERSION = '2.3.64';
1917
+ const CORE_VERSION = '2.3.68';
1871
1918
  const CLASS_MARKER = '__jqhtml_class__';
1872
1919
  const PROPS_MARKER = '__jqhtml_props__';
1873
1920
  /**
@@ -1883,21 +1930,20 @@ function register_cache_class(klass) {
1883
1930
  }
1884
1931
  class_registry[klass.name] = klass;
1885
1932
  }
1886
- // ============================================================================
1887
- // Serialization (Object → String)
1888
- // ============================================================================
1889
1933
  /**
1890
1934
  * Serialize a value to JSON string, handling ES6 class instances recursively.
1891
1935
  * Returns null if serialization fails for any reason.
1892
1936
  *
1893
1937
  * @param value - The value to serialize
1894
1938
  * @param verbose - Whether to log warnings
1939
+ * @param path - Root path used when reporting drops (only used with a collector)
1940
+ * @param collector - Optional reporter, see the file header
1895
1941
  * @returns Serialized JSON string, or null if serialization failed
1896
1942
  */
1897
- function serialize_value(value, verbose) {
1943
+ function serialize_value(value, verbose, path = '', collector) {
1898
1944
  try {
1899
1945
  const seen = new WeakSet();
1900
- const processed = process_for_serialization(value, verbose, seen);
1946
+ const processed = process_for_serialization(value, verbose, seen, path, collector);
1901
1947
  if (processed === undefined) {
1902
1948
  // Serialization failed - undefined signals failure
1903
1949
  return null;
@@ -1913,14 +1959,23 @@ function serialize_value(value, verbose) {
1913
1959
  }
1914
1960
  /**
1915
1961
  * Recursively process a value for serialization.
1916
- * Returns undefined if serialization should fail (unserializable value encountered).
1962
+ *
1963
+ * Values the serializer cannot express are DROPPED, never passed through: the
1964
+ * result of this function is what becomes `this.data`, so letting a function or
1965
+ * a DOM node survive here would make `this.data` differ from what the cache can
1966
+ * ever return. A dropped property is omitted from an object and becomes `null`
1967
+ * in an array - JSON's own semantics for the same situation.
1968
+ *
1969
+ * Returns undefined for a value that has to be dropped.
1917
1970
  *
1918
1971
  * @param value - The value to process
1919
1972
  * @param verbose - Whether to log warnings
1920
1973
  * @param seen - WeakSet to detect circular references
1921
- * @returns Processed value ready for JSON.stringify, or undefined if failed
1974
+ * @param path - Dotted path of this value (only built when a collector is present)
1975
+ * @param collector - Optional reporter for dropped/degraded values
1976
+ * @returns Processed value ready for JSON.stringify, or undefined if dropped
1922
1977
  */
1923
- function process_for_serialization(value, verbose, seen) {
1978
+ function process_for_serialization(value, verbose, seen, path = '', collector) {
1924
1979
  // Handle null and undefined
1925
1980
  if (value === null) {
1926
1981
  return null;
@@ -1935,12 +1990,44 @@ function process_for_serialization(value, verbose, seen) {
1935
1990
  return value;
1936
1991
  }
1937
1992
  // bigint, symbol, function cannot be serialized
1993
+ if (typeof value === 'function') {
1994
+ collector?.(path, 'function');
1995
+ }
1996
+ else if (typeof value === 'symbol') {
1997
+ collector?.(path, 'symbol');
1998
+ }
1999
+ else if (typeof value === 'bigint') {
2000
+ collector?.(path, 'bigint');
2001
+ }
1938
2002
  if (verbose) {
1939
2003
  console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
1940
2004
  }
1941
2005
  // Return undefined to omit this value (not fail entire serialization)
1942
2006
  return undefined;
1943
2007
  }
2008
+ // Live objects that only LOOK like data. Each is checked before anything
2009
+ // treats it as a bag of properties, because walking one produces a
2010
+ // meaningless husk (a jQuery object becomes {0: ..., length: 1}) instead of
2011
+ // the drop the author needs to hear about.
2012
+ if (typeof value.nodeType === 'number') {
2013
+ collector?.(path, 'dom-node');
2014
+ return undefined;
2015
+ }
2016
+ if (value.jquery) {
2017
+ collector?.(path, 'jquery');
2018
+ return undefined;
2019
+ }
2020
+ // Duck-typed on purpose: importing Jqhtml_Component here would be a cycle
2021
+ // (component.ts imports this file). _cid + component_name() is the pair no
2022
+ // plain data object carries.
2023
+ if (value._cid !== undefined && typeof value.component_name === 'function') {
2024
+ collector?.(path, 'component');
2025
+ return undefined;
2026
+ }
2027
+ if (typeof value.then === 'function') {
2028
+ collector?.(path, 'promise');
2029
+ return undefined;
2030
+ }
1944
2031
  // Handle circular references.
1945
2032
  // `seen` tracks only the current ancestor path (the DFS stack): an object is
1946
2033
  // added on the way down and removed on the way back up (see finally below).
@@ -1949,10 +2036,11 @@ function process_for_serialization(value, verbose, seen) {
1949
2036
  // JSON round-trip semantics do not preserve identity anyway, so emitting a
1950
2037
  // copy per reference is correct; silently omitting the property is not.
1951
2038
  if (seen.has(value)) {
2039
+ collector?.(path, 'circular');
1952
2040
  if (verbose) {
1953
- console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
2041
+ console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
1954
2042
  }
1955
- return undefined; // Fail entire serialization
2043
+ return undefined; // Cut the cycle here; the rest of the tree still serializes
1956
2044
  }
1957
2045
  seen.add(value);
1958
2046
  try {
@@ -1961,7 +2049,7 @@ function process_for_serialization(value, verbose, seen) {
1961
2049
  const result = [];
1962
2050
  for (let i = 0; i < value.length; i++) {
1963
2051
  const item = value[i];
1964
- const processed = process_for_serialization(item, verbose, seen);
2052
+ const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
1965
2053
  // For arrays, we keep undefined as null to preserve indices
1966
2054
  result.push(processed === undefined ? null : processed);
1967
2055
  }
@@ -1978,8 +2066,9 @@ function process_for_serialization(value, verbose, seen) {
1978
2066
  if (value instanceof Map) {
1979
2067
  const entries = [];
1980
2068
  for (const [k, v] of value) {
1981
- const processedKey = process_for_serialization(k, verbose, seen);
1982
- const processedValue = process_for_serialization(v, verbose, seen);
2069
+ const entry_path = collector ? `${path}.get(${String(k)})` : '';
2070
+ const processedKey = process_for_serialization(k, verbose, seen, entry_path, collector);
2071
+ const processedValue = process_for_serialization(v, verbose, seen, entry_path, collector);
1983
2072
  entries.push([processedKey, processedValue]);
1984
2073
  }
1985
2074
  return {
@@ -1990,8 +2079,9 @@ function process_for_serialization(value, verbose, seen) {
1990
2079
  // Handle Set objects
1991
2080
  if (value instanceof Set) {
1992
2081
  const items = [];
2082
+ let index = 0;
1993
2083
  for (const item of value) {
1994
- items.push(process_for_serialization(item, verbose, seen));
2084
+ items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
1995
2085
  }
1996
2086
  return {
1997
2087
  [CLASS_MARKER]: 'Set',
@@ -2002,58 +2092,115 @@ function process_for_serialization(value, verbose, seen) {
2002
2092
  const ctor = value.constructor;
2003
2093
  // Handle registered class instances
2004
2094
  if (ctor && ctor.name && class_registry[ctor.name]) {
2005
- const props = {};
2006
- // Extract own enumerable properties (bypass any toJSON method)
2007
- for (const key of Object.keys(value)) {
2008
- const propValue = value[key];
2009
- const processed = process_for_serialization(propValue, verbose, seen);
2010
- // Only include if not undefined (failed serialization)
2011
- if (processed !== undefined || propValue === undefined) {
2012
- props[key] = processed;
2013
- }
2014
- }
2015
2095
  return {
2016
2096
  [CLASS_MARKER]: ctor.name,
2017
- [PROPS_MARKER]: props
2097
+ [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2018
2098
  };
2019
2099
  }
2020
2100
  // Handle plain objects (constructor is Object or no constructor)
2021
2101
  if (ctor === Object || ctor === undefined || ctor === null) {
2022
- const result = {};
2023
- for (const key of Object.keys(value)) {
2024
- const propValue = value[key];
2025
- const processed = process_for_serialization(propValue, verbose, seen);
2026
- // Only include if not undefined (unless original was undefined)
2027
- if (processed !== undefined || propValue === undefined) {
2028
- result[key] = processed;
2029
- }
2030
- }
2031
- return result;
2102
+ return process_own_properties(value, verbose, seen, path, collector);
2032
2103
  }
2033
- // Unregistered class instance - convert to plain object for hot/cold parity
2034
- // This ensures developers catch missing class registrations during development
2035
- // (methods will be lost, only properties preserved)
2104
+ // Unregistered class instance - convert to plain object for hot/cold parity.
2105
+ // Properties are preserved, prototype methods are lost, exactly as a cache
2106
+ // hit would produce, so the loss shows up on the first load rather than on
2107
+ // the next page view.
2108
+ collector?.(path, 'unregistered-class', ctor.name);
2036
2109
  if (verbose) {
2037
2110
  console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2038
2111
  `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2039
2112
  }
2040
- // Convert to plain object - properties are preserved, prototype methods are lost
2041
- const result = {};
2042
- for (const key of Object.keys(value)) {
2043
- const propValue = value[key];
2044
- const processed = process_for_serialization(propValue, verbose, seen);
2045
- // Only include if not undefined (unless original was undefined)
2046
- if (processed !== undefined || propValue === undefined) {
2047
- result[key] = processed;
2048
- }
2049
- }
2050
- return result;
2113
+ return process_own_properties(value, verbose, seen, path, collector);
2051
2114
  }
2052
2115
  finally {
2053
2116
  // Pop this object off the ancestor path now that its subtree is done.
2054
2117
  seen.delete(value);
2055
2118
  }
2056
2119
  }
2120
+ /**
2121
+ * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
2122
+ * object. Shared by plain objects, registered class instances and degraded
2123
+ * unregistered instances - all three keep exactly the same property semantics.
2124
+ */
2125
+ function process_own_properties(value, verbose, seen, path, collector) {
2126
+ const result = {};
2127
+ for (const key of Object.keys(value)) {
2128
+ const propValue = value[key];
2129
+ const processed = process_for_serialization(propValue, verbose, seen, collector ? `${path}.${key}` : '', collector);
2130
+ // Only include if not undefined (unless original was undefined)
2131
+ if (processed !== undefined || propValue === undefined) {
2132
+ result[key] = processed;
2133
+ }
2134
+ }
2135
+ return result;
2136
+ }
2137
+ // ============================================================================
2138
+ // Development warnings for dropped values
2139
+ // ============================================================================
2140
+ // One warning per component + path, for the life of the page. A list of 50 rows
2141
+ // each carrying an $on_select callback must print ONE warning, not 50, and must
2142
+ // not print it again on the next reload() or on the next instance of the same
2143
+ // component. Same shape as _warned_uncacheable in component-cache.ts.
2144
+ const _warned_stripped = new Set();
2145
+ /**
2146
+ * What was lost, and where the value actually belongs. Every message names a
2147
+ * destination, because "it was dropped" without "put it here instead" is not
2148
+ * actionable.
2149
+ */
2150
+ function strip_advice(kind, detail) {
2151
+ switch (kind) {
2152
+ case 'function':
2153
+ return 'a function cannot be serialized. Callbacks are configuration, not loaded data - ' +
2154
+ 'pass it in this.args (from the parent template) and keep only ids in this.data.';
2155
+ case 'promise':
2156
+ return 'a promise cannot be serialized. Await it inside on_load() and store the RESULT, ' +
2157
+ 'or hold the pending promise in this.state.';
2158
+ case 'dom-node':
2159
+ return 'a DOM node cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2160
+ case 'jquery':
2161
+ return 'a jQuery object cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2162
+ case 'component':
2163
+ return 'a Jqhtml_Component cannot be serialized. Keep component references in this.state ' +
2164
+ 'and address children with this.sid().';
2165
+ case 'symbol':
2166
+ return 'a symbol cannot be serialized. Use a string, or keep it in this.state.';
2167
+ case 'bigint':
2168
+ return 'a bigint cannot be serialized. Convert it to a string in on_load(), or keep it in this.state.';
2169
+ case 'circular':
2170
+ return 'it points back to one of its own ancestors, and the cycle was cut. Store the graph ' +
2171
+ 'flat (ids instead of back-references) in this.data, or keep the linked form in this.state.';
2172
+ case 'unregistered-class':
2173
+ return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2174
+ `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2175
+ `to get real instances back.`;
2176
+ }
2177
+ }
2178
+ /**
2179
+ * Build the collector normalize_for_cache() hands to the serializer, or
2180
+ * `undefined` when nobody is listening - production, or a caller that named no
2181
+ * component. Returning undefined (rather than a no-op function) is what keeps
2182
+ * the serializer from building path strings at all on the hot path.
2183
+ */
2184
+ function make_strip_collector(component_name) {
2185
+ if (!component_name)
2186
+ return undefined;
2187
+ if (get_config().mode !== 'development')
2188
+ return undefined;
2189
+ return (path, kind, detail) => {
2190
+ try {
2191
+ const dedupe_key = `${component_name}:${path}`;
2192
+ if (_warned_stripped.has(dedupe_key))
2193
+ return;
2194
+ _warned_stripped.add(dedupe_key);
2195
+ console.warn(`[JQHTML] <${component_name}> dropped ${path} from this.data: ${strip_advice(kind, detail)}\n` +
2196
+ ` this.data is always the serialized copy jqhtml stores in and restores from cache, ` +
2197
+ `so only serializable values survive on_load() - in every cache mode.`, { component: component_name, path, kind });
2198
+ }
2199
+ catch {
2200
+ // A warning must never be able to break a load.
2201
+ }
2202
+ };
2203
+ }
2057
2204
  // ============================================================================
2058
2205
  // Deserialization (String → Object)
2059
2206
  // ============================================================================
@@ -2397,36 +2544,36 @@ class Jqhtml_Local_Storage {
2397
2544
  /**
2398
2545
  * Perform a serialize/deserialize round-trip on a value.
2399
2546
  *
2400
- * This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
2401
- * (restored from cache). Unregistered class instances will be converted to plain
2402
- * objects, exactly as they would be if restored from cache.
2403
- *
2404
- * Use this to normalize data after on_load() so developers catch missing class
2405
- * registrations immediately rather than only after a page reload.
2547
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2548
+ * is aliased through, and a value the serializer cannot express is dropped
2549
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2550
+ * came from on_load() or from a cache hit - the old "on failure return the
2551
+ * original" branch broke exactly that promise, and only on the hard cases.
2406
2552
  *
2407
2553
  * @param {any} value - The value to normalize
2408
- * @returns {any} The value after serialize/deserialize round-trip, or original if serialization fails
2554
+ * @param {string} [component_name] - Names the component in development warnings.
2555
+ * Omit to convert silently (the unit tests and non-component callers).
2556
+ * @returns {any} The round-tripped value. Never the original object.
2409
2557
  */
2410
- static normalize_for_cache(value) {
2558
+ static normalize_for_cache(value, component_name) {
2411
2559
  const verbose = this._is_verbose();
2560
+ const collector = make_strip_collector(component_name);
2412
2561
  // Serialize to JSON string
2413
- const serialized = serialize_value(value, verbose);
2562
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2414
2563
  if (serialized === null) {
2415
- // Serialization failed completely - return original
2416
- // (This happens with circular references or other fatal issues)
2417
- if (verbose) {
2418
- console.warn('[JQHTML Cache] normalize_for_cache: Serialization failed, returning original value');
2419
- }
2420
- return value;
2564
+ // The value as a WHOLE could not be expressed (it was itself a function,
2565
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2566
+ // returning the original would put an uncacheable object into this.data.
2567
+ return {};
2421
2568
  }
2422
2569
  // Deserialize back to object
2423
2570
  const deserialized = deserialize_value(serialized, verbose);
2424
- if (deserialized === null) {
2425
- // Deserialization failed - return original
2426
- if (verbose) {
2427
- console.warn('[JQHTML Cache] normalize_for_cache: Deserialization failed, returning original value');
2428
- }
2429
- return value;
2571
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2572
+ // that legitimately serialized to the text `null` also comes back as null. The
2573
+ // serialized text disambiguates.
2574
+ if (deserialized === null && serialized !== 'null') {
2575
+ // Only reachable when a registered class constructor blew up on restore.
2576
+ return {};
2430
2577
  }
2431
2578
  return deserialized;
2432
2579
  }
@@ -2449,7 +2596,10 @@ class Jqhtml_Local_Storage {
2449
2596
  console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2450
2597
  // Clear only JQHTML keys and retry once
2451
2598
  this._clear_jqhtml_keys();
2452
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2599
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2600
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2601
+ // made the very next write look like a scope change and wipe the cache again.
2602
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2453
2603
  try {
2454
2604
  localStorage.setItem(scoped_key, serialized);
2455
2605
  }
@@ -2490,17 +2640,22 @@ Jqhtml_Local_Storage._initialized = false;
2490
2640
  * Extracted from component.ts - handles lifecycle event registration,
2491
2641
  * triggering, and state tracking.
2492
2642
  *
2493
- * Events have "sticky" semantics for lifecycle events: if an event has already
2494
- * occurred, new .on() handlers fire immediately with the stored data.
2643
+ * EVERY event is "sticky", not just the lifecycle ones: trigger() records the event
2644
+ * name and its payload, and a handler registered afterwards fires immediately with that
2645
+ * last payload. This is deliberate and applies to custom events too - a subscriber that
2646
+ * arrives after `trigger('row_selected', row)` still learns which row is selected,
2647
+ * which is what makes late-attaching UI work without a separate "current state" read.
2495
2648
  */
2496
2649
  /**
2497
2650
  * Register a callback for an event.
2498
2651
  *
2499
- * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2500
- * If a lifecycle event has already occurred, the callback fires immediately
2501
- * AND registers for future occurrences.
2652
+ * All events are "sticky", lifecycle (create, render, load, loaded, ready, stop) and
2653
+ * custom alike: if the event has already been triggered, the callback fires immediately
2654
+ * with the payload of that last trigger AND registers for future occurrences.
2502
2655
  *
2503
- * Custom events only fire when explicitly triggered via trigger().
2656
+ * invalidate(event_name) drops the stored marker when a component wants later
2657
+ * subscribers to wait for the next real occurrence instead (reload() does this for
2658
+ * 'ready').
2504
2659
  *
2505
2660
  * @param component - The component instance
2506
2661
  * @param event_name - Name of the event
@@ -2632,6 +2787,20 @@ function event_invalidate(component, event_name) {
2632
2787
  * - Data freeze/unfreeze enforcement via Proxy
2633
2788
  * - Detached on_load() execution with restricted access
2634
2789
  */
2790
+ /**
2791
+ * JSON for an error message. The value being rejected is arbitrary author data and may
2792
+ * be cyclic or otherwise unserializable - the diagnostic must never throw over the
2793
+ * error it is trying to report.
2794
+ */
2795
+ function safe_json(value) {
2796
+ try {
2797
+ const text = JSON.stringify(value);
2798
+ return text === undefined ? String(value) : text;
2799
+ }
2800
+ catch {
2801
+ return String(value);
2802
+ }
2803
+ }
2635
2804
  /**
2636
2805
  * Set up the `this.data` property on a component using Object.defineProperty
2637
2806
  * with a Proxy that enforces freeze/unfreeze semantics.
@@ -2645,9 +2814,78 @@ function event_invalidate(component, event_name) {
2645
2814
  */
2646
2815
  function setup_data_property(component) {
2647
2816
  let _data = {};
2817
+ // Wrapper memo for the deep-freeze proxies. Keyed by the RAW target object, so a
2818
+ // value read twice returns the same wrapper and `this.data.items === this.data.items`
2819
+ // holds - identity comparisons and `Set`/`Map` membership on nested data keep working.
2820
+ // WeakMap: a wrapper dies with the object it wraps.
2821
+ const frozen_wrappers = new WeakMap();
2822
+ // Only plain objects and arrays are wrapped. Anything with a real prototype
2823
+ // (Date, Map, Set, and classes restored by register_cache_class()) carries internal
2824
+ // slots that a Proxy receiver cannot satisfy: `this.data.created.getTime()` would
2825
+ // call the method with the proxy as `this` and throw. Those values pass through raw -
2826
+ // a shallow hole we accept, because breaking Date/Map/Set reads would be worse than
2827
+ // missing a rare mutation of one.
2828
+ const is_wrappable = (value) => {
2829
+ if (value === null || typeof value !== 'object')
2830
+ return false;
2831
+ if (Array.isArray(value))
2832
+ return true;
2833
+ const proto = Object.getPrototypeOf(value);
2834
+ return proto === Object.prototype || proto === null;
2835
+ };
2836
+ // Dotted path for error messages: array indices read as [0], keys as .name
2837
+ const join_path = (path, target, prop) => Array.isArray(target) ? `${path}[${String(prop)}]` : `${path}.${String(prop)}`;
2838
+ const frozen_violation = (path, verb, value) => {
2839
+ const value_text = verb === 'modify' ? ` = ${safe_json(value)}` : '';
2840
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to ${verb} ${path} outside of on_create() or on_load().\n\n` +
2841
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2842
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2843
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2844
+ `NESTED VALUES COUNT: the freeze is DEEP. Mutating an object or array inside\n` +
2845
+ `this.data (push, splice, assigning a property, delete) is modifying this.data.\n\n` +
2846
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2847
+ `FIX:\n` +
2848
+ ` ❌ In on_ready(): ${path}${value_text};\n` +
2849
+ ` ✅ In on_load(): fetch the value, so the framework can re-render\n` +
2850
+ ` ✅ For component-local bookkeeping: this.state (unrestricted, never cached)`);
2851
+ throw new Error(`[JQHTML] Cannot ${verb} ${path} outside of on_create() or on_load(). ` +
2852
+ `this.data is frozen after on_create() and unfrozen only during on_load(). ` +
2853
+ `The freeze is deep - nested objects and arrays are frozen too.`);
2854
+ };
2855
+ // Read-only view of a nested value. Handed out ONLY while __data_frozen is true
2856
+ // (the top-level get trap checks), so the traps here never need to re-check: a
2857
+ // wrapper the author kept a reference to stays read-only for its whole life.
2858
+ const create_frozen_wrapper = (obj, path) => {
2859
+ const existing = frozen_wrappers.get(obj);
2860
+ if (existing)
2861
+ return existing;
2862
+ const wrapper = new Proxy(obj, {
2863
+ get: (target, prop) => {
2864
+ const value = target[prop];
2865
+ // Functions are returned raw: array methods are invoked with the wrapper as
2866
+ // `this`, so push/splice still land in this trap's set/deleteProperty.
2867
+ return is_wrappable(value) ? create_frozen_wrapper(value, join_path(path, target, prop)) : value;
2868
+ },
2869
+ set: (target, prop, value) => frozen_violation(join_path(path, target, prop), 'modify', value),
2870
+ deleteProperty: (target, prop) => frozen_violation(join_path(path, target, prop), 'delete')
2871
+ });
2872
+ frozen_wrappers.set(obj, wrapper);
2873
+ return wrapper;
2874
+ };
2648
2875
  // Helper to create frozen proxy for data object
2649
2876
  const create_proxy = (obj) => {
2650
2877
  return new Proxy(obj, {
2878
+ get: (target, prop) => {
2879
+ const value = target[prop];
2880
+ // Deep freeze: while frozen, every nested object/array is read through a
2881
+ // read-only wrapper, so this.data.items.push(x) throws instead of silently
2882
+ // mutating. Unfrozen (on_create, on_load's detached run, _apply_load_result)
2883
+ // reads return the raw value, because those phases may legally mutate it.
2884
+ if (component.__data_frozen && is_wrappable(value)) {
2885
+ return create_frozen_wrapper(value, `this.data.${String(prop)}`);
2886
+ }
2887
+ return value;
2888
+ },
2651
2889
  set: (target, prop, value) => {
2652
2890
  if (component.__data_frozen) {
2653
2891
  console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
@@ -2717,10 +2955,13 @@ function setup_data_property(component) {
2717
2955
  * All other property access (this.$, this.$sid, etc.) throws errors.
2718
2956
  *
2719
2957
  * @param component - The component instance
2720
- * @param use_load_coordinator - Whether to use Load_Coordinator for deduplication
2958
+ * @param dedup_key - The dedup key captured by the caller, or null to run uncoordinated.
2959
+ * The key is passed in rather than recomputed because args may legally
2960
+ * change while on_load() runs, and a recomputed key would address a
2961
+ * different entry than the one this leader registered.
2721
2962
  * @returns The resulting data and optional coordination completion function
2722
2963
  */
2723
- async function execute_on_load_detached(component, use_load_coordinator = false) {
2964
+ async function execute_on_load_detached(component, dedup_key = null) {
2724
2965
  // Clone this.data from the snapshot captured after on_create()
2725
2966
  const data_clone = component.__initial_data_snapshot
2726
2967
  ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
@@ -2728,14 +2969,23 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2728
2969
  // Create a read-only proxy for args that blocks all modifications
2729
2970
  // Can't use JSON clone because args may contain functions (_slots, callbacks)
2730
2971
  const component_name = component.component_name();
2972
+ // Wrapper memo for this one on_load() run. Without it every nested read built a
2973
+ // fresh Proxy, so `this.args.filter !== this.args.filter` for an object arg and
2974
+ // identity comparisons inside on_load() silently failed. Lives only for the duration
2975
+ // of this call - args may legally be replaced between loads.
2976
+ const readonly_wrappers = new WeakMap();
2731
2977
  const create_readonly_proxy = (obj, path = 'this.args') => {
2732
2978
  if (obj === null || typeof obj !== 'object')
2733
2979
  return obj;
2734
- return new Proxy(obj, {
2980
+ const existing = readonly_wrappers.get(obj);
2981
+ if (existing)
2982
+ return existing;
2983
+ const proxy = new Proxy(obj, {
2735
2984
  get(target, prop) {
2736
2985
  const value = target[prop];
2737
- // Recursively wrap nested objects (but not functions)
2738
- if (value !== null && typeof value === 'object' && typeof value !== 'function') {
2986
+ // Recursively wrap nested objects. Functions are returned raw - `typeof value`
2987
+ // is already 'object' here, so a function never reaches this branch.
2988
+ if (value !== null && typeof value === 'object') {
2739
2989
  return create_readonly_proxy(value, `${path}.${String(prop)}`);
2740
2990
  }
2741
2991
  return value;
@@ -2757,6 +3007,8 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2757
3007
  `this.args is read-only in on_load().`);
2758
3008
  }
2759
3009
  });
3010
+ readonly_wrappers.set(obj, proxy);
3011
+ return proxy;
2760
3012
  };
2761
3013
  // Create a detached context object that on_load will operate on
2762
3014
  const detached_context = {
@@ -2816,27 +3068,22 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2816
3068
  `Only this.data can be modified in on_load().`);
2817
3069
  }
2818
3070
  });
2819
- // Create promise for this on_load() call
2820
- const on_load_promise = (async () => {
2821
- try {
2822
- await component._call_lifecycle('on_load', restricted_this);
2823
- }
2824
- catch (error) {
2825
- if (use_load_coordinator) {
2826
- // Handle error and notify coordinator
2827
- Load_Coordinator.handle_leader_error(component, error);
2828
- }
2829
- throw error;
2830
- }
2831
- })();
2832
- // If using Load_Coordinator, register as leader
2833
- // Note: We store the completion function but DON'T call it here
2834
- // It will be called by _load() after _apply_load_result() updates this.data
3071
+ // Register as leader BEFORE on_load() runs, so the error path always finds the entry
3072
+ // it has to reject. The completion function is stored, not called here: _load() calls
3073
+ // it after _apply_load_result() has updated this.data.
2835
3074
  let complete_coordination = null;
2836
- if (use_load_coordinator) {
2837
- complete_coordination = Load_Coordinator.register_leader(component, on_load_promise);
3075
+ if (dedup_key !== null) {
3076
+ complete_coordination = Load_Coordinator.register_leader(component, dedup_key);
3077
+ }
3078
+ try {
3079
+ await component._call_lifecycle('on_load', restricted_this);
3080
+ }
3081
+ catch (error) {
3082
+ if (dedup_key !== null) {
3083
+ Load_Coordinator.handle_leader_error(dedup_key, error);
3084
+ }
3085
+ throw error;
2838
3086
  }
2839
- await on_load_promise;
2840
3087
  // Note: We don't validate args changes here because external code (like parent
2841
3088
  // components calling reload()) is allowed to modify component.args during on_load().
2842
3089
  // The read-only proxy only prevents code INSIDE on_load() from modifying args.
@@ -2938,6 +3185,21 @@ function warn_uncacheable_component(component, uncacheable_property, uncacheable
2938
3185
  all_args: component.args ? Object.keys(component.args) : [],
2939
3186
  });
2940
3187
  }
3188
+ /**
3189
+ * Read an HTML-cache entry, rejecting anything that is not a well-formed snapshot.
3190
+ * Entries written by an older core are bare strings; they are treated as a miss (and
3191
+ * are cleared wholesale by the CORE_VERSION scope marker on the next version bump).
3192
+ */
3193
+ function read_html_snapshot(html_cache_key) {
3194
+ const entry = Jqhtml_Local_Storage.get(html_cache_key);
3195
+ if (entry !== null &&
3196
+ typeof entry === 'object' &&
3197
+ typeof entry.cid === 'string' &&
3198
+ typeof entry.html === 'string') {
3199
+ return entry;
3200
+ }
3201
+ return null;
3202
+ }
2941
3203
  /**
2942
3204
  * Read cache during create() phase.
2943
3205
  * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
@@ -2969,11 +3231,11 @@ function read_cache_in_create(component) {
2969
3231
  if (cache_mode === 'html') {
2970
3232
  // HTML cache mode - check for cached HTML to inject on first render
2971
3233
  const html_cache_key = `${cache_key}::html`;
2972
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2973
- if (cached_html !== null && typeof cached_html === 'string') {
3234
+ const cached_html = read_html_snapshot(html_cache_key);
3235
+ if (cached_html !== null) {
2974
3236
  component._cached_html = cached_html;
2975
3237
  if (window.jqhtml?.debug?.verbose) {
2976
- console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
3238
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.html.length, snapshot_cid: cached_html.cid });
2977
3239
  }
2978
3240
  }
2979
3241
  else {
@@ -3030,10 +3292,10 @@ function check_cache_on_reload(component) {
3030
3292
  if (cache_mode === 'html') {
3031
3293
  // HTML cache mode - check for cached HTML
3032
3294
  const html_cache_key = `${cache_key}::html`;
3033
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3034
- if (cached_html !== null && typeof cached_html === 'string') {
3295
+ const cached_html = read_html_snapshot(html_cache_key);
3296
+ if (cached_html !== null) {
3035
3297
  if (window.jqhtml?.debug?.verbose) {
3036
- 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 });
3298
+ 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.html.length, snapshot_cid: cached_html.cid });
3037
3299
  }
3038
3300
  component._cached_html = cached_html;
3039
3301
  // Call _render() directly (not render()) since _reload() manages the full lifecycle
@@ -3074,7 +3336,11 @@ function write_html_cache_snapshot(component) {
3074
3336
  component._should_cache_html_after_ready = false;
3075
3337
  const html = component.$.html();
3076
3338
  const html_cache_key = `${component._cache_key}::html`;
3077
- Jqhtml_Local_Storage.set(html_cache_key, html);
3339
+ // Store the cid this markup was rendered under alongside it: every scoped id inside
3340
+ // reads "<name>:<cid>", and a reader cannot tell the snapshotting component's own
3341
+ // elements from its children's without knowing which cid was the owner's.
3342
+ // See Jqhtml_Component._rescope_cached_html().
3343
+ Jqhtml_Local_Storage.set(html_cache_key, { cid: component._cid, html });
3078
3344
  if (window.jqhtml?.debug?.verbose) {
3079
3345
  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 });
3080
3346
  }
@@ -3114,16 +3380,158 @@ function write_cache_on_loaded(component, data_changed) {
3114
3380
  }
3115
3381
  }
3116
3382
 
3383
+ /**
3384
+ * HTML escaping for interpolated text.
3385
+ *
3386
+ * Output matches what `div.textContent = s; div.innerHTML` produced before -
3387
+ * a text node serialises only &, < and > as entities, and null/undefined as
3388
+ * "" - but without touching the DOM. Interpolation runs for every value of
3389
+ * every render, and a createElement per value was the most expensive thing a
3390
+ * text-heavy template did.
3391
+ */
3392
+ const NEEDS_ESCAPE = /[&<>]/;
3393
+ const ESCAPE_ALL = /[&<>]/g;
3394
+ const ENTITY = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
3395
+ const to_entity = (c) => ENTITY[c];
3396
+ function escape_html(value) {
3397
+ if (value === null || value === undefined)
3398
+ return '';
3399
+ const s = typeof value === 'string' ? value : String(value);
3400
+ return NEEDS_ESCAPE.test(s) ? s.replace(ESCAPE_ALL, to_entity) : s;
3401
+ }
3402
+ /** Escaped, then newlines become <br /> - the <%br= %> construct. */
3403
+ function escape_html_nl2br(value) {
3404
+ return escape_html(value).replace(/\n/g, '<br />');
3405
+ }
3406
+
3407
+ /**
3408
+ * Value printers: how <%= %> renders an OBJECT.
3409
+ *
3410
+ * Primitives never come here - the generated template code routes only
3411
+ * non-null, non-array objects to print_object(), so a hundred-row list of
3412
+ * strings costs exactly what it did before. An object reaching an
3413
+ * interpolation is walked through the printer chain in registration order;
3414
+ * the first printer to return anything but undefined handles it. A printer
3415
+ * returns a string (escaped or not according to the construct, exactly as a
3416
+ * string literal at that site) or a component descriptor:
3417
+ *
3418
+ * { component: { name: 'Rich_Text_Display', args: { value }, attrs: { class: 'x' } } }
3419
+ *
3420
+ * which jqhtml mounts as if the template had written the tag - live in the
3421
+ * browser, server-rendered under SSR - with no environment awareness on the
3422
+ * printer's side. A descriptor carries no content.
3423
+ *
3424
+ * Attribute position (<div title="<%= v %>">) compiles through a different
3425
+ * path and never consults printers.
3426
+ */
3427
+ const printers = [];
3428
+ /**
3429
+ * Register a printer at the end of the chain. Printers run in registration
3430
+ * order; return undefined to decline, a string, or a component descriptor.
3431
+ */
3432
+ function add_object_printer(printer) {
3433
+ if (typeof printer !== 'function') {
3434
+ throw new Error('[JQHTML] add_object_printer() expects a function (value) => undefined | string | descriptor');
3435
+ }
3436
+ printers.push(printer);
3437
+ }
3438
+ /** The registered printers, in order. Read-only view for diagnostics. */
3439
+ function get_object_printers() {
3440
+ return printers;
3441
+ }
3442
+ /**
3443
+ * Render an object at an interpolation site. Called by compiled templates for
3444
+ * every non-null, non-array object reaching <%= %>, <%!= %> or <%br= %>.
3445
+ * Throws when nothing handles the value or a printer returns garbage.
3446
+ */
3447
+ function print_object(value, mode) {
3448
+ if (value === null || typeof value !== 'object') {
3449
+ throw new TypeError(`[JQHTML] print_object() is the object branch of interpolation; got ${typeof value}`);
3450
+ }
3451
+ for (let i = 0; i < printers.length; i++) {
3452
+ const result = printers[i](value);
3453
+ if (result === undefined)
3454
+ continue;
3455
+ if (typeof result === 'string')
3456
+ return print_string(result, mode);
3457
+ if (is_descriptor(result))
3458
+ return descriptor_to_instruction(result, i);
3459
+ throw new Error(`[JQHTML] Object printer #${i + 1} of ${printers.length} returned ${describe(result)}; ` +
3460
+ `a printer must return undefined (decline), a string, or a { component: { name, args, attrs } } descriptor`);
3461
+ }
3462
+ throw new Error(`[JQHTML] Cannot interpolate a ${constructor_name(value)} object: ` +
3463
+ (printers.length
3464
+ ? `none of the ${printers.length} registered object printer(s) handled it. `
3465
+ : `no object printers are registered. `) +
3466
+ `Register one with jqhtml.add_object_printer(fn), or interpolate a primitive.`);
3467
+ }
3468
+ function print_string(text, mode) {
3469
+ if (mode === 'escape')
3470
+ return escape_html(text);
3471
+ if (mode === 'nl2br')
3472
+ return escape_html_nl2br(text);
3473
+ return text;
3474
+ }
3475
+ function is_descriptor(value) {
3476
+ return !!value && typeof value === 'object' && !Array.isArray(value) &&
3477
+ !!value.component && typeof value.component === 'object' && !Array.isArray(value.component);
3478
+ }
3479
+ function descriptor_to_instruction(descriptor, index) {
3480
+ const { name, args, attrs } = descriptor.component;
3481
+ const where = `Object printer #${index + 1}`;
3482
+ if (typeof name !== 'string' || !is_component_name(name)) {
3483
+ throw new Error(`[JQHTML] ${where} returned a descriptor whose component name ${describe(name)} is invalid: ` +
3484
+ `a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
3485
+ }
3486
+ const props = {};
3487
+ if (attrs !== undefined) {
3488
+ if (!is_plain_object(attrs))
3489
+ throw new Error(`[JQHTML] ${where}: descriptor attrs must be a plain object`);
3490
+ for (const [key, value] of Object.entries(attrs)) {
3491
+ if (key.startsWith('$'))
3492
+ throw new Error(`[JQHTML] ${where}: descriptor attrs key '${key}' - component arguments go in args, without the $ sigil`);
3493
+ props[key] = value;
3494
+ }
3495
+ }
3496
+ if (args !== undefined) {
3497
+ if (!is_plain_object(args))
3498
+ throw new Error(`[JQHTML] ${where}: descriptor args must be a plain object`);
3499
+ for (const [key, value] of Object.entries(args)) {
3500
+ if (key.startsWith('$'))
3501
+ throw new Error(`[JQHTML] ${where}: descriptor args key '${key}' - the $ sigil is template syntax, not part of the argument name`);
3502
+ props[`$${key}`] = value;
3503
+ }
3504
+ }
3505
+ return { comp: [name, props] };
3506
+ }
3507
+ function is_plain_object(value) {
3508
+ return !!value && typeof value === 'object' && !Array.isArray(value);
3509
+ }
3510
+ function constructor_name(value) {
3511
+ const ctor = value.constructor;
3512
+ return ctor && ctor.name ? ctor.name : 'Object';
3513
+ }
3514
+ function describe(value) {
3515
+ if (value === null)
3516
+ return 'null';
3517
+ if (typeof value === 'string')
3518
+ return `'${value}'`;
3519
+ if (typeof value === 'object')
3520
+ return Array.isArray(value) ? 'an array' : `a ${constructor_name(value)} object without a valid component property`;
3521
+ return `${typeof value} ${String(value)}`;
3522
+ }
3523
+
3117
3524
  /**
3118
3525
  * JQHTML Component Lifecycle Queue
3119
3526
  *
3120
- * Serializes lifecycle operations (render, reload, refresh) per component.
3527
+ * Serializes lifecycle operations (render, reload, refresh, load) per component.
3121
3528
  * Replaces _create_debounced_function with a proper queue that:
3122
3529
  *
3123
3530
  * - At most one operation runs at a time per component
3124
- * - At most one operation is pending (waiting for the current to finish)
3125
- * - Same-type pending operations collapse (fan-in: all callers share one execution)
3126
- * - Different-type pending operations: new replaces old (old callers get the new result)
3531
+ * - At most one PENDING entry per type; different types queue behind each other FIFO
3532
+ * - Same-type pending operations collapse: the newest executor wins, and every
3533
+ * collapsed caller is settled with the value of the executor that actually ran
3534
+ * - A caller is settled BEFORE the next pending entry starts
3127
3535
  *
3128
3536
  * Boot bypasses this queue entirely - it runs the lifecycle directly.
3129
3537
  */
@@ -3131,92 +3539,106 @@ class Component_Queue {
3131
3539
  constructor() {
3132
3540
  /** Currently executing operation */
3133
3541
  this._current = null;
3134
- /** Next operation waiting to execute after current completes */
3135
- this._pending = null;
3542
+ /**
3543
+ * Operations waiting to run after the current one, in FIFO order.
3544
+ * At most one entry per type: a second reload() while a reload is pending
3545
+ * collapses into it, but a render() queued over a pending reload() is its
3546
+ * own entry and both run. Replacing the older entry (the previous behaviour)
3547
+ * dropped that operation entirely while still resolving its callers as if it
3548
+ * had run.
3549
+ */
3550
+ this._pending = [];
3136
3551
  }
3137
3552
  /**
3138
3553
  * Enqueue a lifecycle operation.
3139
3554
  *
3140
3555
  * Behavior:
3141
3556
  * - Nothing running → execute immediately
3142
- * - Something running, no pending → create pending entry
3143
- * - Something running, same type pending → collapse (share pending promise)
3144
- * - Something running, different type pending → replace pending (all callers get new result)
3557
+ * - Something running, no pending entry of this type → queue one (FIFO)
3558
+ * - Something running, same type pending → collapse: keep every caller, but
3559
+ * swap in the newest executor ("only the most recent call executes")
3560
+ *
3561
+ * Every caller collapsed onto one entry receives the value that entry's
3562
+ * executor returned - or its error, if it threw.
3145
3563
  *
3146
3564
  * @param type - Operation type ('render', 'reload', 'refresh', 'load')
3147
3565
  * @param executor - The async function to execute
3148
- * @returns Promise that resolves when the operation completes
3566
+ * @returns Promise that settles with the result of the execution that ran
3149
3567
  */
3150
3568
  enqueue(type, executor) {
3151
- // If nothing running, execute immediately
3569
+ // If nothing running, execute immediately.
3570
+ // The Promise executor runs synchronously, so the operation's synchronous
3571
+ // prologue (_render(), the ready invalidation) still happens in the calling
3572
+ // tick, as it did before the queue existed.
3152
3573
  if (!this._current) {
3153
- return this._execute(type, executor);
3154
- }
3155
- // Something is running — add to pending
3156
- if (this._pending) {
3157
- if (this._pending.type === type) {
3158
- // Same type pending → collapse (fan-in)
3159
- // All callers share the same future execution
3160
- return new Promise((resolve, reject) => {
3161
- this._pending.resolvers.push(resolve);
3162
- this._pending.rejecters.push(reject);
3163
- });
3164
- }
3165
- else {
3166
- // Different type pending → replace
3167
- // Old pending callers join the new operation
3168
- // (their operation was superseded by a different type)
3169
- const old_resolvers = this._pending.resolvers;
3170
- const old_rejecters = this._pending.rejecters;
3171
- return new Promise((resolve, reject) => {
3172
- this._pending = {
3173
- type,
3174
- executor,
3175
- resolvers: [...old_resolvers, resolve],
3176
- rejecters: [...old_rejecters, reject]
3177
- };
3178
- });
3179
- }
3574
+ return new Promise((resolve, reject) => {
3575
+ this._run({ type, executor, resolvers: [resolve], rejecters: [reject] });
3576
+ });
3180
3577
  }
3181
- // No pending entry yet — create one
3578
+ const existing = this._pending.find(entry => entry.type === type);
3579
+ if (existing) {
3580
+ // Same type already pending → collapse (fan-in).
3581
+ // The newest executor replaces the pending one: the documented rule is
3582
+ // "only the most recent call executes", and the older closure may carry
3583
+ // stale arguments. Keeping the OLD executor made two rapid `await load()`
3584
+ // calls resolve the second one `false` without ever running on_load().
3585
+ existing.executor = executor;
3586
+ return new Promise((resolve, reject) => {
3587
+ existing.resolvers.push(resolve);
3588
+ existing.rejecters.push(reject);
3589
+ });
3590
+ }
3591
+ // No pending entry of this type yet — queue one behind whatever is already waiting.
3182
3592
  return new Promise((resolve, reject) => {
3183
- this._pending = {
3184
- type,
3185
- executor,
3186
- resolvers: [resolve],
3187
- rejecters: [reject]
3188
- };
3593
+ this._pending.push({ type, executor, resolvers: [resolve], rejecters: [reject] });
3189
3594
  });
3190
3595
  }
3191
3596
  /**
3192
- * Execute an operation and handle pending queue after completion.
3597
+ * Run one entry, settle its callers, then start the next pending entry.
3193
3598
  * @private
3194
3599
  */
3195
- _execute(type, executor) {
3600
+ _run(entry) {
3601
+ // Mark busy BEFORE invoking the executor: the executor's synchronous prologue
3602
+ // can call back into enqueue(), and that call must queue rather than start a
3603
+ // second concurrent operation.
3604
+ this._current = { type: entry.type };
3196
3605
  const promise = (async () => {
3197
3606
  try {
3198
- await executor();
3607
+ const value = await entry.executor();
3608
+ // Settle this entry's callers FIRST, while the queue still reads as busy.
3609
+ for (const resolve of entry.resolvers)
3610
+ resolve(value);
3199
3611
  }
3200
- finally {
3201
- this._current = null;
3202
- // Run pending if exists
3203
- if (this._pending) {
3204
- const pending = this._pending;
3205
- this._pending = null;
3206
- try {
3207
- await this._execute(pending.type, pending.executor);
3208
- for (const resolve of pending.resolvers)
3209
- resolve();
3210
- }
3211
- catch (err) {
3212
- for (const reject of pending.rejecters)
3213
- reject(err);
3214
- }
3215
- }
3612
+ catch (error) {
3613
+ // A throwing executor rejects only its own callers; the queue continues.
3614
+ for (const reject of entry.rejecters)
3615
+ reject(error);
3216
3616
  }
3217
3617
  })();
3218
- this._current = { type, promise };
3219
- return promise;
3618
+ // The drain is deliberately OUTSIDE the awaited chain above: chaining it
3619
+ // inside made `await c.render()` also wait for a reload that was queued
3620
+ // after it.
3621
+ promise.then(() => {
3622
+ if (this._pending.length === 0) {
3623
+ // Nothing queued: release immediately, so the very common
3624
+ // `await c.reload(); c.render();` still starts the render in that tick.
3625
+ this._current = null;
3626
+ return;
3627
+ }
3628
+ // A queued entry must not start until every caller of the operation that
3629
+ // just finished has actually resumed. Those callers sit behind an unknown
3630
+ // number of microtask hops - an `async` public method adds two just by
3631
+ // returning the queue's promise - so draining on a microtask would start
3632
+ // the next on_load() before the previous caller's `await` came back. A
3633
+ // macrotask is the only boundary that is after all of them. _current stays
3634
+ // set until then, so nothing can start concurrently in the meantime.
3635
+ setTimeout(() => {
3636
+ this._current = null;
3637
+ const next = this._pending.shift();
3638
+ if (next)
3639
+ this._run(next);
3640
+ }, 0);
3641
+ });
3220
3642
  }
3221
3643
  /**
3222
3644
  * Check if any operation is currently running.
@@ -3228,7 +3650,7 @@ class Component_Queue {
3228
3650
  * Check if there's a pending operation waiting.
3229
3651
  */
3230
3652
  get has_pending() {
3231
- return this._pending !== null;
3653
+ return this._pending.length > 0;
3232
3654
  }
3233
3655
  }
3234
3656
 
@@ -3279,11 +3701,16 @@ function capture_component_data(component_name, args, data, cache_key) {
3279
3701
  try {
3280
3702
  const cloned_args = JSON.parse(JSON.stringify(args));
3281
3703
  const cloned_data = JSON.parse(JSON.stringify(data));
3282
- _capture_buffer.push({
3704
+ const entry = {
3283
3705
  component: component_name,
3284
3706
  args: cloned_args,
3285
3707
  data: cloned_data,
3286
- });
3708
+ };
3709
+ // Carry the key forward so the client matches on the SAME identity the server
3710
+ // used, rather than re-deriving one from args and hoping the shapes agree.
3711
+ if (cache_key !== null)
3712
+ entry.key = cache_key;
3713
+ _capture_buffer.push(entry);
3287
3714
  }
3288
3715
  catch (error) {
3289
3716
  // Non-serializable data — skip capture for this component
@@ -3329,10 +3756,15 @@ function set_preload_data(entries) {
3329
3756
  return;
3330
3757
  _preload_cache.clear();
3331
3758
  for (const entry of entries) {
3332
- // Generate key using the same algorithm as Load Coordinator
3333
- const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
3334
- if (result.key !== null) {
3335
- _preload_cache.set(result.key, entry.data);
3759
+ // The captured key wins. Falling back to args means matching what
3760
+ // generate_cache_key() computes for a component with no cache_id(), which
3761
+ // INCLUDES content serialization - without it an object arg keyed to null
3762
+ // here and the entry was dropped, so preload never applied to it.
3763
+ const key = entry.key !== undefined
3764
+ ? entry.key
3765
+ : Load_Coordinator.generate_invocation_key(entry.component, entry.args, { allow_content_serialization: true }).key;
3766
+ if (key !== null) {
3767
+ _preload_cache.set(key, entry.data);
3336
3768
  }
3337
3769
  }
3338
3770
  }
@@ -3445,9 +3877,69 @@ function init_viewport_resize(jQuery) {
3445
3877
  * - Scoped IDs using _cid pattern
3446
3878
  * - Event emission and CSS class hierarchy
3447
3879
  */
3880
+ /**
3881
+ * The `jqhtml` argument every compiled render function receives: the same
3882
+ * escaping the runtime exports, the object-printer entry point, and the
3883
+ * dynamic tag name check. One shared object, not one per render.
3884
+ */
3885
+ const render_utilities = {
3886
+ escape_html,
3887
+ escape_html_nl2br,
3888
+ print_object,
3889
+ dynamic_component_name,
3890
+ };
3448
3891
  // WeakMap storage for protected lifecycle method implementations (Option 2)
3449
3892
  // See docs/internal/lifecycle-method-protection.md for design details
3450
3893
  const lifecycle_impls = new WeakMap();
3894
+ /**
3895
+ * Resolve the template a template inherits FROM, in the one order CLAUDE.md
3896
+ * documents ("Template Inheritance Resolution"): the explicit `extends=""`
3897
+ * attribute first, then the JS class prototype chain.
3898
+ *
3899
+ * This is the single implementation. Both slot-only rendering (which invokes the
3900
+ * parent template with the child's slots) and default-attribute collection walk
3901
+ * it, so a `<Define>`'s class/attrs can never resolve through a different chain
3902
+ * than its markup does.
3903
+ *
3904
+ * @param template_def The template whose parent is wanted
3905
+ * @param ctor The component class to walk the prototype chain of (null skips it)
3906
+ * @returns The parent template and the name it was found under, or null
3907
+ */
3908
+ function resolve_parent_template(template_def, ctor) {
3909
+ const verbose = window.jqhtml?.debug?.verbose;
3910
+ // 1. Explicit extends="" wins - the author said exactly which template to use.
3911
+ if (template_def?.extends) {
3912
+ const parent = get_template(template_def.extends);
3913
+ // get_template() never returns null: a miss yields DEFAULT_TEMPLATE, whose
3914
+ // name is 'Jqhtml_Component'. That is "not found", not a parent.
3915
+ if (parent && parent.name !== 'Jqhtml_Component') {
3916
+ if (verbose)
3917
+ console.log(`[JQHTML] resolve_parent_template: explicit extends -> ${template_def.extends}`);
3918
+ return { template: parent, name: template_def.extends };
3919
+ }
3920
+ if (verbose)
3921
+ console.log(`[JQHTML] resolve_parent_template: extends="${template_def.extends}" has no template`);
3922
+ }
3923
+ // 2. Otherwise walk the JS prototype chain for the nearest ancestor with a template.
3924
+ // Stop at the framework base, at Object, and at Function.prototype (name === ''),
3925
+ // which is what getPrototypeOf() hands back once the class chain is exhausted.
3926
+ let current_class = ctor ? Object.getPrototypeOf(ctor) : null;
3927
+ while (typeof current_class === 'function' && current_class.name &&
3928
+ current_class.name !== 'Object' && current_class.name !== 'Jqhtml_Component') {
3929
+ const class_name = current_class.name;
3930
+ // Internal base classes are aliases of Jqhtml_Component, never a parent template.
3931
+ if (class_name !== '_Jqhtml_Component' && class_name !== '_Base_Jqhtml_Component') {
3932
+ const class_template = get_template(class_name);
3933
+ if (class_template && class_template.name !== 'Jqhtml_Component' && class_template !== template_def) {
3934
+ if (verbose)
3935
+ console.log(`[JQHTML] resolve_parent_template: prototype chain -> ${class_name}`);
3936
+ return { template: class_template, name: class_name };
3937
+ }
3938
+ }
3939
+ current_class = Object.getPrototypeOf(current_class);
3940
+ }
3941
+ return null;
3942
+ }
3451
3943
  class Jqhtml_Component {
3452
3944
  constructor(element, args = {}) {
3453
3945
  this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
@@ -3472,7 +3964,10 @@ class Jqhtml_Component {
3472
3964
  // Cache mode properties
3473
3965
  this._cache_key = null; // Cache key for caching
3474
3966
  // 'html' mode caching
3475
- this._cached_html = null; // Cached HTML to inject on first render
3967
+ // The snapshot carries the cid it was taken under, not just markup: every scoped id
3968
+ // inside it reads "<name>:<cid>", so replaying it into a different instance requires
3969
+ // knowing which cid was the snapshotting component's own. See _rescope_cached_html().
3970
+ this._cached_html = null; // Cached HTML snapshot to inject on first render
3476
3971
  this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
3477
3972
  this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
3478
3973
  this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
@@ -3547,6 +4042,14 @@ class Jqhtml_Component {
3547
4042
  // Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
3548
4043
  const defineArgs = template_for_args?.defineArgs || {};
3549
4044
  this.args = { ...defineArgs, ...dataAttrs, ...args };
4045
+ // The name this instance was invoked as. Every invocation path passes it
4046
+ // in args._component_name (template tag, $(el).component('Name'), boot
4047
+ // placeholder); a class handed directly to $(el).component(Class) has
4048
+ // only its own name to offer.
4049
+ const ctor = this.constructor;
4050
+ this._component_name = (typeof args._component_name === 'string' && args._component_name)
4051
+ || ctor.component_name
4052
+ || ctor.name;
3550
4053
  // Set lifecycle truncation flags from args
3551
4054
  if (this.args._load_only === true) {
3552
4055
  this._load_only = true;
@@ -3708,41 +4211,12 @@ class Jqhtml_Component {
3708
4211
  return child._render(null, options);
3709
4212
  }
3710
4213
  this._log_lifecycle('render', 'start');
3711
- // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
3712
- if (this._cached_html !== null) {
3713
- if (window.jqhtml?.debug?.verbose) {
3714
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
3715
- }
3716
- // Inject cached HTML directly
3717
- this.$[0].innerHTML = this._cached_html;
3718
- // Mark that we used cached HTML (forces re-render after on_load)
3719
- this._used_cached_html = true;
3720
- // Clear cached HTML so next render uses template
3721
- this._cached_html = null;
3722
- // Mark first render complete
3723
- this._did_first_render = true;
3724
- this._log_lifecycle('render', 'complete (cached HTML)');
3725
- // Call on_render() after cache inject (unless suppressed by _load_render_only)
3726
- if (!options.skip_on_render) {
3727
- const cacheRenderResult = this._call_lifecycle_sync('on_render');
3728
- if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
3729
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
3730
- `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
3731
- }
3732
- this._fire_viewport_resize();
3733
- }
3734
- // Emit lifecycle event
3735
- this.trigger('render');
3736
- // Store args/data snapshots for later comparison
3737
- try {
3738
- this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
3739
- }
3740
- catch (error) {
3741
- this._args_on_last_render = null;
3742
- }
3743
- this._data_on_last_render = JSON.stringify(this.data);
3744
- return current_render_id;
3745
- }
4214
+ // --- Shared re-render prologue -------------------------------------------------
4215
+ // Everything below runs for BOTH the cached-HTML path and the template path. The
4216
+ // cached path used to return before it, so a cache-mode reload injected over a live
4217
+ // subtree without ever stopping the old children: their on_stop() never ran (timers
4218
+ // and listeners leaked) and they stayed in _dom_children as dead entries that every
4219
+ // later _wait_for_children_ready() walked.
3746
4220
  // Determine child-finding strategy: If component is off-DOM, children can't register
3747
4221
  // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
3748
4222
  // If attached to DOM, children register normally and we can use the fast _dom_children path.
@@ -3761,21 +4235,60 @@ class Jqhtml_Component {
3761
4235
  if (child && !child._stopped) {
3762
4236
  child._stop(); // Stop just the component, DOM will be cleared next
3763
4237
  }
3764
- });
3765
- // Clear the DOM
3766
- this.$[0].innerHTML = '';
3767
- }
3768
- else {
3769
- this._did_first_render = true;
4238
+ });
4239
+ // Clear the DOM
4240
+ this.$[0].innerHTML = '';
4241
+ }
4242
+ else {
4243
+ this._did_first_render = true;
4244
+ }
4245
+ // (No removeClass('_Component_Stopped') here: _render() returns above when
4246
+ // _stopped, and nothing ever clears _stopped, so a stopped component can never
4247
+ // reach this line. Clearing the class would only ever hide a real stopped state.)
4248
+ // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4249
+ this._dom_children.clear();
4250
+ // --- End shared re-render prologue ---------------------------------------------
4251
+ // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
4252
+ if (this._cached_html !== null) {
4253
+ if (window.jqhtml?.debug?.verbose) {
4254
+ console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.html.length, snapshot_cid: this._cached_html.cid });
4255
+ }
4256
+ // Inject cached HTML directly, with every scoped id re-scoped to THIS instance.
4257
+ // The snapshot was taken from another component instance (or an earlier life of
4258
+ // this one), so its ids carry that instance's cids verbatim. Injecting them as-is
4259
+ // put duplicate ids in the document, and $sid() - which is getElementById first -
4260
+ // could hand this component the other instance's element.
4261
+ this.$[0].innerHTML = this._rescope_cached_html(this._cached_html);
4262
+ // Mark that we used cached HTML (forces re-render after on_load)
4263
+ this._used_cached_html = true;
4264
+ // Clear cached HTML so next render uses template
4265
+ this._cached_html = null;
4266
+ this._log_lifecycle('render', 'complete (cached HTML)');
4267
+ // Call on_render() after cache inject (unless suppressed by _load_render_only)
4268
+ if (!options.skip_on_render) {
4269
+ const cacheRenderResult = this._call_lifecycle_sync('on_render');
4270
+ if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
4271
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
4272
+ `on_render() must be synchronous code. Remove 'async' from the function declaration.`);
4273
+ }
4274
+ this._fire_viewport_resize();
4275
+ }
4276
+ // Emit lifecycle event
4277
+ this.trigger('render');
4278
+ // Store args/data snapshots for later comparison
4279
+ try {
4280
+ this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
4281
+ }
4282
+ catch (error) {
4283
+ this._args_on_last_render = null;
4284
+ }
4285
+ this._data_on_last_render = JSON.stringify(this.data);
4286
+ return current_render_id;
3770
4287
  }
3771
- // Remove _Component_Stopped class if present (allows re-render after stop)
3772
- this.$.removeClass('_Component_Stopped');
3773
4288
  // Capture data state before first render for comparison later
3774
4289
  if (this._data_before_render === null) {
3775
4290
  this._data_before_render = JSON.stringify(this.data);
3776
4291
  }
3777
- // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
3778
- this._dom_children.clear();
3779
4292
  // Get template and render it
3780
4293
  let template_def;
3781
4294
  // If we have a component name, try looking up by name first (for unregistered components)
@@ -3787,20 +4300,8 @@ class Jqhtml_Component {
3787
4300
  template_def = get_template_by_class(this.constructor);
3788
4301
  }
3789
4302
  if (template_def && template_def.render) {
3790
- // Create jqhtml utilities object
3791
- const jqhtml = {
3792
- escape_html: (str) => {
3793
- const div = document.createElement('div');
3794
- div.textContent = String(str);
3795
- return div.innerHTML;
3796
- },
3797
- escape_html_nl2br: (str) => {
3798
- const div = document.createElement('div');
3799
- div.textContent = String(str);
3800
- // Escape HTML first, then replace newlines with <br />
3801
- return div.innerHTML.replace(/\n/g, '<br />');
3802
- }
3803
- };
4303
+ // Utilities the compiled render function calls (see render_utilities)
4304
+ const jqhtml = render_utilities;
3804
4305
  // Execute template function
3805
4306
  // Commented out debug logging
3806
4307
  // console.log('[Component] About to call render, this:', this);
@@ -3839,69 +4340,48 @@ class Jqhtml_Component {
3839
4340
  // Check for template inheritance (slot-only OR explicit extends)
3840
4341
  // If instructions is {_slots: {...}}, find parent template and invoke it
3841
4342
  if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
3842
- const componentName = template_def.name || this.args._component_name || this.constructor.name;
3843
- console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
3844
- let parentTemplate = null;
3845
- let parentTemplateName = null;
3846
- // First check for explicit extends attribute in template metadata
3847
- if (template_def.extends) {
3848
- console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
3849
- parentTemplate = get_template(template_def.extends);
3850
- parentTemplateName = template_def.extends;
3851
- }
3852
- // If no explicit extends, walk the prototype chain to find parent class with template
3853
- if (!parentTemplate) {
3854
- let currentClass = Object.getPrototypeOf(this.constructor);
3855
- while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
3856
- const className = currentClass.name;
3857
- console.log(`[JQHTML] Checking parent: ${className}`);
3858
- try {
3859
- const classTemplate = get_template(className);
3860
- if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
3861
- console.log(`[JQHTML] Found parent template: ${className}`);
3862
- parentTemplate = classTemplate;
3863
- parentTemplateName = className;
3864
- break;
3865
- }
3866
- }
3867
- catch (error) {
3868
- console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
3869
- }
3870
- currentClass = Object.getPrototypeOf(currentClass);
3871
- }
4343
+ const component_name = template_def.name || this.args._component_name || this.constructor.name;
4344
+ const parent = resolve_parent_template(template_def, this.constructor);
4345
+ // A slot-only template is nothing but an override of a parent. With no parent
4346
+ // there is nothing to render, and rendering empty hides the mistake (usually a
4347
+ // missing register_template) behind a blank element. Fail loudly instead.
4348
+ if (!parent) {
4349
+ throw new Error(`[JQHTML] ${component_name}: slot-only template has no parent template. ` +
4350
+ `Add extends="Parent_Name" to <Define:${component_name}>, extend a class whose ` +
4351
+ `template is registered, or give the template markup of its own.`);
3872
4352
  }
3873
- // If we found a parent template, invoke it with child's slots
3874
- if (parentTemplate) {
3875
- try {
3876
- // Create a content function that invokes child slots
3877
- // When parent calls content('slotName'), it invokes the child's slot function
3878
- const childSlots = instructions._slots;
3879
- const contentFunction = (slotName, ...slotArgs) => {
3880
- if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
3881
- // Invoke the slot function with everything content() was given
3882
- const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
3883
- // Return in render function format: [instructions, context]
3884
- // The template expression handler expects this format
3885
- return [slotInstructions, slotContext];
3886
- }
3887
- // Slot not found, return empty
3888
- return '';
3889
- };
3890
- // Invoke parent template with child's slots as content function
3891
- const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
3892
- jqhtml);
3893
- console.log(`[JQHTML] Parent template invoked successfully`);
3894
- instructions = parentInstructions;
3895
- context = parentContext;
3896
- }
3897
- catch (error) {
3898
- console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
3899
- instructions = [];
4353
+ // Create a content function that invokes child slots
4354
+ // When parent calls content('slotName'), it invokes the child's slot function
4355
+ const childSlots = instructions._slots;
4356
+ const contentFunction = (slotName, ...slotArgs) => {
4357
+ if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4358
+ // Invoke the slot function with everything content() was given
4359
+ const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4360
+ // Return in render function format: [instructions, context]
4361
+ // The template expression handler expects this format
4362
+ return [slotInstructions, slotContext];
3900
4363
  }
4364
+ // Slot not found, return empty
4365
+ return '';
4366
+ };
4367
+ // Invoke parent template with child's slots as content function.
4368
+ // A parent template that throws is a real defect in the parent's markup. It used
4369
+ // to be swallowed into an empty render plus a warning nobody reads; it is now
4370
+ // re-thrown, which routes it through the boot error path: logged, this component
4371
+ // stopped, its waiting ancestors released.
4372
+ try {
4373
+ const [parentInstructions, parentContext] = parent.template.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4374
+ jqhtml);
4375
+ instructions = parentInstructions;
4376
+ context = parentContext;
3901
4377
  }
3902
- else {
3903
- console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
3904
- instructions = [];
4378
+ catch (error) {
4379
+ // Rethrown, not swallowed - only re-labelled so the report names both ends
4380
+ // of the inheritance, which the raw template error does not.
4381
+ const wrapped = new Error(`[JQHTML] ${component_name}: parent template ${parent.name} threw while ` +
4382
+ `rendering slot-only child: ${error?.message ?? error}`);
4383
+ wrapped.cause = error; // ES2020 target: Error(message, {cause}) is not typed
4384
+ throw wrapped;
3905
4385
  }
3906
4386
  }
3907
4387
  // Process instructions to generate DOM. ['_content', ...] markers from
@@ -3922,10 +4402,33 @@ class Jqhtml_Component {
3922
4402
  }
3923
4403
  this._fire_viewport_resize();
3924
4404
  }
4405
+ // "Already rendered once" - read before the state below can move to 3.
4406
+ const isRerender = this._ready_state >= 3;
4407
+ // HTML CACHE MODE: Mark on_render complete after the post-on_load render.
4408
+ // This signals to parent components that this component's DOM is fully rendered
4409
+ // with fresh data and ready for HTML snapshot. It is set BEFORE trigger('render')
4410
+ // because _wait_for_children_on_render() waits on that very event and reads the
4411
+ // flag from its handler.
4412
+ if (this._ready_state >= 2) {
4413
+ this._on_render_complete = true;
4414
+ // State 3 = "rendered with loaded data, ready phase not finished yet".
4415
+ // Only meaningful once the load phase has happened (>= 2); before that the
4416
+ // component is still walking 0 -> 1 -> 2 and must not be pushed forward.
4417
+ // The ready phase (_ready(), render()'s executor, _reload()) restores 4.
4418
+ this._ready_state = 3;
4419
+ this._update_debug_attrs();
4420
+ }
4421
+ else if (!this.__has_custom_on_load) {
4422
+ // A component with no custom on_load() skips the load phase entirely, so it never
4423
+ // reaches _ready_state 2 and would never set the flag above - and its data cannot
4424
+ // change, so this first render IS its final, fresh render. Without this, an
4425
+ // html-cache parent waited forever in _wait_for_children_on_render() for a
4426
+ // template-only child and never snapshotted, rendered or fired 'ready'.
4427
+ this._on_render_complete = true;
4428
+ }
3925
4429
  // Emit lifecycle event
3926
4430
  this.trigger('render');
3927
4431
  // Apply debug delay after render
3928
- const isRerender = this._ready_state >= 3; // Already rendered once
3929
4432
  applyDebugDelay(isRerender ? 'rerender' : 'render');
3930
4433
  // Store args snapshot for reload() comparison (skip if non-serializable)
3931
4434
  try {
@@ -3937,12 +4440,6 @@ class Jqhtml_Component {
3937
4440
  }
3938
4441
  // Store data snapshot for refresh() comparison
3939
4442
  this._data_on_last_render = JSON.stringify(this.data);
3940
- // HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
3941
- // This signals to parent components that this component's DOM is fully rendered
3942
- // with fresh data and ready for HTML snapshot
3943
- if (this._ready_state >= 2) {
3944
- this._on_render_complete = true;
3945
- }
3946
4443
  // Return the render ID so callers can check if this render is still current
3947
4444
  return current_render_id;
3948
4445
  }
@@ -3962,10 +4459,12 @@ class Jqhtml_Component {
3962
4459
  render(id = null) {
3963
4460
  if (this._stopped)
3964
4461
  return Promise.resolve();
3965
- // Invalidate ready event so new handlers wait for this render cycle to complete
3966
- // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
3967
- this.invalidate('ready');
3968
- // If id provided, delegate to child component
4462
+ // If id provided, delegate to child component.
4463
+ // NOTE: this runs BEFORE invalidate('ready'). render(sid) does not start a
4464
+ // lifecycle on THIS component - it delegates to the redrawable child, which
4465
+ // invalidates and re-triggers its own 'ready'. Invalidating here would delete
4466
+ // this component's sticky ready marker with nothing left to re-trigger it, so
4467
+ // every later `await comp.ready()` would hang.
3969
4468
  if (id) {
3970
4469
  const $element = this.$sid(id);
3971
4470
  if ($element.length === 0) {
@@ -3980,14 +4479,23 @@ class Jqhtml_Component {
3980
4479
  }
3981
4480
  return child.render();
3982
4481
  }
4482
+ // Invalidate ready event so new handlers wait for this render cycle to complete
4483
+ // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4484
+ this.invalidate('ready');
3983
4485
  // Enqueue the full render lifecycle through the component queue
3984
4486
  // Return the queue promise so `await this.render()` resolves when the
3985
4487
  // full render lifecycle (DOM update, children ready, on_ready) completes
3986
4488
  return this._queue.enqueue('render', async () => {
4489
+ // The queued executor can start long after render() was called - re-check.
4490
+ if (this._stopped)
4491
+ return;
3987
4492
  // Execute render phase synchronously and capture render ID
3988
4493
  const render_id = this._render();
3989
4494
  // Wait for all child components to be ready
3990
4495
  await this._wait_for_children_ready();
4496
+ // Stopped while waiting for children: no hook, no event on a dead component.
4497
+ if (this._stopped)
4498
+ return;
3991
4499
  // Check if this render is still current before calling on_ready
3992
4500
  // If _render_count changed, another render happened and we should skip on_ready
3993
4501
  if (this._render_count !== render_id) {
@@ -3995,7 +4503,15 @@ class Jqhtml_Component {
3995
4503
  }
3996
4504
  // Call on_ready hook with authorization
3997
4505
  await this._call_lifecycle('on_ready');
4506
+ // on_ready() may itself have awaited long enough for a stop().
4507
+ if (this._stopped)
4508
+ return;
3998
4509
  this._fire_viewport_resize();
4510
+ // Back to the ready state: _render() dropped it to 3. Set BEFORE the trigger
4511
+ // so a handler that calls ready() during dispatch takes the already-ready
4512
+ // fast path instead of waiting for another trigger that will never come.
4513
+ this._ready_state = 4;
4514
+ this._update_debug_attrs();
3999
4515
  // Trigger ready event
4000
4516
  this.trigger('ready');
4001
4517
  });
@@ -4025,40 +4541,54 @@ class Jqhtml_Component {
4025
4541
  async load() {
4026
4542
  if (this._stopped)
4027
4543
  return false;
4028
- // Capture result through queue closure
4029
- let data_changed = false;
4030
- await this._queue.enqueue('load', async () => {
4544
+ // No custom on_load() means there is nothing to run and nothing can have changed.
4545
+ // Running the detached execution anyway seeded this.data with the empty detached
4546
+ // clone (create() takes no snapshot for these components), silently wiping whatever
4547
+ // on_create() had set.
4548
+ if (!this.__has_custom_on_load)
4549
+ return false;
4550
+ // The queue settles this caller with the value of the executor that actually
4551
+ // ran - including one that collapsed two rapid load() calls into one run - so
4552
+ // the result is returned from the executor, not read back out of a closure
4553
+ // variable that a discarded executor would have left at its initial value.
4554
+ return this._queue.enqueue('load', async () => {
4555
+ // The queued executor can start long after load() was called - re-check.
4556
+ if (this._stopped)
4557
+ return false;
4031
4558
  // Snapshot current data for change detection
4032
4559
  const data_before = JSON.stringify(this.data);
4033
4560
  // Execute on_load() on detached proxy (restores data to on_create snapshot)
4034
- const { data: result_data } = await this._execute_on_load_detached(false);
4035
- // Atomically update this.data: unfreeze → assign → normalize → refreeze
4561
+ const { data: result_data } = await this._execute_on_load_detached(null);
4562
+ // Stopped during on_load(): don't write data or cache for a dead component.
4563
+ if (this._stopped)
4564
+ return false;
4565
+ // Atomically update this.data: unfreeze → normalize → assign → refreeze.
4566
+ // The round trip runs in EVERY cache mode - see _apply_load_result().
4036
4567
  this.__data_frozen = false;
4037
- this.data = result_data;
4038
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4039
- if (cache_mode === 'data') {
4040
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4041
- this.data = normalized;
4042
- }
4568
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4043
4569
  this.__data_frozen = true;
4570
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4044
4571
  // Detect if data changed
4045
4572
  const data_after = JSON.stringify(this.data);
4046
- data_changed = data_before !== data_after;
4573
+ const data_changed = data_before !== data_after;
4047
4574
  // Update cache with fresh data if changed
4048
4575
  if (data_changed) {
4049
- // Regenerate cache key (args may have changed since boot)
4050
- let cache_key = null;
4051
- if (typeof this.cache_id === 'function') {
4052
- try {
4053
- cache_key = `${this.component_name()}::${String(this.cache_id())}`;
4576
+ // ONE derivation, shared with the cache read in create() and with _load().
4577
+ // The copy that used to live here omitted content serialization, so an
4578
+ // object-arg component computed a null key and silently never wrote the
4579
+ // cache that create() would later read with the content-serialized key.
4580
+ // Recomputed rather than reused: args may have changed since boot.
4581
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4582
+ if (cache_key === null) {
4583
+ // Leave the same record _load() leaves: the offending arg on the element,
4584
+ // and one dev warning. A silent catch here made a throwing cache_id()
4585
+ // undiagnosable - the component simply stopped being cached.
4586
+ if (uncacheable_property) {
4587
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4054
4588
  }
4055
- catch { /* cache_id() threw - skip caching */ }
4056
- }
4057
- else {
4058
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4059
- cache_key = result.key;
4589
+ warn_uncacheable_component(this, uncacheable_property, uncacheable_reason);
4060
4590
  }
4061
- if (cache_key && cache_mode !== 'html') {
4591
+ else if (cache_mode !== 'html') {
4062
4592
  Jqhtml_Local_Storage.set(cache_key, this.data);
4063
4593
  }
4064
4594
  }
@@ -4066,10 +4596,13 @@ class Jqhtml_Component {
4066
4596
  // Suppressed by _load_only and _load_render_only flags (preloading mode)
4067
4597
  if (!this._load_only && !this._load_render_only) {
4068
4598
  await this._call_lifecycle('on_loaded');
4599
+ // on_loaded() may have awaited across a stop(); don't fire 'loaded' after it.
4600
+ if (this._stopped)
4601
+ return data_changed;
4069
4602
  this.trigger('loaded');
4070
4603
  }
4604
+ return data_changed;
4071
4605
  });
4072
- return data_changed;
4073
4606
  }
4074
4607
  /**
4075
4608
  * Create phase - Quick setup, prepare UI
@@ -4092,15 +4625,18 @@ class Jqhtml_Component {
4092
4625
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
4093
4626
  // Components without on_load() don't fetch data, so nothing to cache or restore
4094
4627
  if (this.__has_custom_on_load) {
4628
+ // Snapshot this.data the moment on_create() returns, BEFORE any cache read.
4629
+ // on_load() restarts from this snapshot every time, and CLAUDE.md promises that
4630
+ // is the on_create() state. read_cache_in_create() REPLACES this.data on a warm
4631
+ // 'data' cache, so snapshotting after it made a warm boot restart on_load() from
4632
+ // cached data - `this.data.items.push(x)` doubled its rows on every reload.
4633
+ this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4095
4634
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
4096
4635
  // Skip cache for detached elements — no point hydrating from cache when not in DOM
4097
4636
  // @see component-cache.ts for full implementation
4098
4637
  if (!this._is_detached) {
4099
4638
  read_cache_in_create(this);
4100
4639
  }
4101
- // Snapshot this.data after on_create() completes
4102
- // This will be restored before each on_load() execution to reset state
4103
- this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4104
4640
  }
4105
4641
  // Freeze this.data after on_create() - only on_load() can modify it now
4106
4642
  this.__data_frozen = true;
@@ -4231,26 +4767,7 @@ class Jqhtml_Component {
4231
4767
  // follower never runs on_load() and adopts the leader's data with NO
4232
4768
  // revalidation, so a wrong key here is permanently wrong data. Redundant
4233
4769
  // concurrent requests are the cheaper failure.
4234
- let cache_key = null;
4235
- let uncacheable_property;
4236
- let uncacheable_reason;
4237
- if (typeof this.cache_id === 'function') {
4238
- try {
4239
- const custom_cache_id = this.cache_id();
4240
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
4241
- }
4242
- catch (error) {
4243
- // cache_id() threw error - disable caching
4244
- uncacheable_property = 'cache_id()';
4245
- uncacheable_reason = 'cache-id-threw';
4246
- }
4247
- }
4248
- else {
4249
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
4250
- cache_key = result.key;
4251
- uncacheable_property = result.uncacheable_property;
4252
- uncacheable_reason = result.uncacheable_reason;
4253
- }
4770
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4254
4771
  const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
4255
4772
  // SSR preload check: if preloaded data exists for this component+args, use it
4256
4773
  if (cache_key !== null && has_preload_data()) {
@@ -4281,24 +4798,26 @@ class Jqhtml_Component {
4281
4798
  if (window.jqhtml?.debug?.verbose) {
4282
4799
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
4283
4800
  }
4284
- const { data: result_data } = await this._execute_on_load_detached();
4801
+ const { data: result_data } = await this._execute_on_load_detached(null);
4285
4802
  await this._apply_load_result(result_data, data_before_load);
4286
4803
  return;
4287
4804
  }
4288
4805
  // Check if this component should execute on_load() or wait for existing request
4289
- const should_execute = Load_Coordinator.should_execute_on_load(this);
4806
+ const should_execute = Load_Coordinator.should_execute_on_load(this, dedup_key);
4290
4807
  if (!should_execute) {
4291
4808
  // This component is a follower - wait for leader to complete
4292
4809
  if (window.jqhtml?.debug?.verbose) {
4293
4810
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
4294
4811
  }
4295
- const coordination_promise = Load_Coordinator.get_coordination_promise(this);
4812
+ // Every coordinator call uses the key captured ABOVE, before on_load() began.
4813
+ // Recomputing it here would miss the entry whenever args changed meanwhile.
4814
+ const coordination_promise = Load_Coordinator.get_coordination_promise(dedup_key);
4296
4815
  if (coordination_promise) {
4297
4816
  try {
4298
- // Wait for leader to complete
4817
+ // Wait for leader to complete (rejects if the leader's on_load() threw)
4299
4818
  await coordination_promise;
4300
4819
  // Retrieve leader's data from coordinator
4301
- const leader_data = Load_Coordinator.get_leader_data(this);
4820
+ const leader_data = Load_Coordinator.get_leader_data(this, dedup_key);
4302
4821
  if (leader_data !== null) {
4303
4822
  // Apply leader's data using the same method as leader
4304
4823
  // This properly handles freeze/unfreeze and normalization
@@ -4316,15 +4835,11 @@ class Jqhtml_Component {
4316
4835
  throw error;
4317
4836
  }
4318
4837
  }
4319
- // Fallback: if we couldn't get leader data, just mark as complete
4320
- this._ready_state = 2;
4321
- this._update_debug_attrs();
4322
- this._log_lifecycle('load', 'complete (follower)');
4323
- this.trigger('load');
4324
- if (!this._load_only && !this._load_render_only) {
4325
- await this._call_lifecycle('on_loaded');
4326
- this.trigger('loaded');
4327
- }
4838
+ // No leader data to adopt (the entry was already collected, or its data could
4839
+ // not be serialized). Load independently rather than firing load/loaded with the
4840
+ // untouched on_create() data.
4841
+ const { data: result_data } = await this._execute_on_load_detached(null);
4842
+ await this._apply_load_result(result_data, data_before_load);
4328
4843
  return;
4329
4844
  }
4330
4845
  // This component is a leader - execute on_load() on detached proxy
@@ -4332,7 +4847,7 @@ class Jqhtml_Component {
4332
4847
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
4333
4848
  }
4334
4849
  // Execute on_load on detached proxy with Load_Coordinator registration
4335
- const { data: result_data, complete_coordination } = await this._execute_on_load_detached(true);
4850
+ const { data: result_data, complete_coordination } = await this._execute_on_load_detached(dedup_key);
4336
4851
  // Apply result via sequential queue
4337
4852
  await this._apply_load_result(result_data, data_before_load);
4338
4853
  // Complete coordination AFTER this.data is updated
@@ -4346,8 +4861,8 @@ class Jqhtml_Component {
4346
4861
  * @see data-proxy.ts for full implementation
4347
4862
  * @private
4348
4863
  */
4349
- async _execute_on_load_detached(use_load_coordinator = false) {
4350
- return execute_on_load_detached(this, use_load_coordinator);
4864
+ async _execute_on_load_detached(dedup_key = null) {
4865
+ return execute_on_load_detached(this, dedup_key);
4351
4866
  }
4352
4867
  /**
4353
4868
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -4370,21 +4885,22 @@ class Jqhtml_Component {
4370
4885
  await my_turn;
4371
4886
  try {
4372
4887
  // Now it's our turn to set this.data
4373
- // Unfreeze, set data, freeze
4888
+ // Every on_load() result is round-tripped through the cache serializer before it
4889
+ // becomes this.data - in EVERY cache mode, not just 'data'. Two consequences the
4890
+ // rest of the framework depends on:
4891
+ // - this.data is jqhtml's own copy. The author's object graph is never aliased,
4892
+ // so mutating what on_load() returned cannot reach a frozen this.data.
4893
+ // - this.data is BY CONSTRUCTION what the cache stores and what a cache hit
4894
+ // returns. Hot and cold are the same object shape in every mode, so turning
4895
+ // caching on can never change what a component sees.
4896
+ // Dates, Maps, Sets and register_cache_class() classes are reconstructed; anything
4897
+ // the serializer cannot express is dropped and warned about once (local-storage.ts).
4374
4898
  this.__data_frozen = false;
4375
- this.data = result_data;
4376
- // DATA MODE: Normalize this.data through serialize/deserialize round-trip
4377
- // This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
4378
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4379
- if (cache_mode === 'data') {
4380
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4381
- this.data = normalized;
4382
- if (window.jqhtml?.debug?.verbose) {
4383
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4384
- }
4385
- }
4386
- // Freeze this.data
4899
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4387
4900
  this.__data_frozen = true;
4901
+ if (window.jqhtml?.debug?.verbose) {
4902
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4903
+ }
4388
4904
  // SSR data capture: record this component's data for preloading
4389
4905
  if (is_capture_enabled() && this._has_on_load()) {
4390
4906
  capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
@@ -4470,9 +4986,11 @@ class Jqhtml_Component {
4470
4986
  callback();
4471
4987
  return Promise.resolve();
4472
4988
  }
4473
- // Return promise that resolves when ready event fires
4989
+ // Return promise that resolves when ready event fires.
4990
+ // once(), not on(): a promise can only settle once, so a permanent listener
4991
+ // just accumulates on the component - one per ready() call, forever.
4474
4992
  return new Promise((resolve) => {
4475
- this.on('ready', () => {
4993
+ this.once('ready', () => {
4476
4994
  if (callback)
4477
4995
  callback();
4478
4996
  resolve();
@@ -4530,9 +5048,18 @@ class Jqhtml_Component {
4530
5048
  if (child._ready_state >= 4) {
4531
5049
  continue;
4532
5050
  }
4533
- // Create promise that resolves when child reaches ready
5051
+ // A stopped child will never fire 'ready' again - waiting on it would wedge
5052
+ // this parent (and everything awaiting its ready()) forever.
5053
+ if (child._stopped) {
5054
+ continue;
5055
+ }
5056
+ // Create promise that resolves when child reaches ready.
5057
+ // once(), not on(): a permanent listener would be appended to the child on
5058
+ // every render/reload cycle of a polling parent and never removed.
5059
+ // Raced against 'stop' so a child stopped mid-wait releases the parent.
4534
5060
  const ready_promise = new Promise((resolve) => {
4535
- child.on('ready', () => resolve());
5061
+ child.once('ready', () => resolve());
5062
+ child.once('stop', () => resolve());
4536
5063
  });
4537
5064
  ready_promises.push(ready_promise);
4538
5065
  }
@@ -4558,22 +5085,42 @@ class Jqhtml_Component {
4558
5085
  // Create promises for each child that hasn't completed on_render yet
4559
5086
  const render_promises = [];
4560
5087
  for (const child of children) {
4561
- // If child already completed on_render post-on_load, skip
4562
- if (child._on_render_complete) {
5088
+ // If child already completed on_render post-on_load (or is dead), skip
5089
+ if (child._on_render_complete || child._stopped) {
4563
5090
  continue;
4564
5091
  }
4565
- // Create promise that resolves when child completes on_render
5092
+ // Event-driven, not polled: the child sets _on_render_complete just before it
5093
+ // fires 'render', and 'stop' ends the wait for a child that dies first.
5094
+ //
5095
+ // A plain once('render') would be wrong: events are sticky, and this child has
5096
+ // already rendered at least once (the pre-on_load render), so once() would fire
5097
+ // immediately off that stale marker and let the parent snapshot half-rendered DOM.
5098
+ // The handler therefore re-checks the flag and stays registered until it is set.
4566
5099
  const render_promise = new Promise((resolve) => {
4567
- // Poll for completion (simple approach - could use events for more efficiency)
4568
- const check = () => {
4569
- if (child._on_render_complete || child._stopped) {
4570
- resolve();
4571
- }
4572
- else {
4573
- setTimeout(check, 10);
5100
+ let settled = false;
5101
+ const on_child_event = () => {
5102
+ if (settled)
5103
+ return;
5104
+ if (!child._on_render_complete && !child._stopped)
5105
+ return; // pre-load render
5106
+ settled = true;
5107
+ // There is no off() in the event API; splice this handler out of both queues
5108
+ // the way once() does, so a long-lived child does not accumulate handlers
5109
+ // across repeated waits.
5110
+ for (const event_name of ['render', 'stop']) {
5111
+ const callbacks = child._lifecycle_callbacks.get(event_name);
5112
+ if (callbacks) {
5113
+ const idx = callbacks.indexOf(on_child_event);
5114
+ if (idx !== -1)
5115
+ callbacks.splice(idx, 1);
5116
+ }
4574
5117
  }
5118
+ resolve();
4575
5119
  };
4576
- check();
5120
+ // 'stop' first: registering 'render' can fire the handler synchronously (sticky),
5121
+ // and the handler must be able to deregister both queues when it does.
5122
+ child.on('stop', on_child_event);
5123
+ child.on('render', on_child_event);
4577
5124
  });
4578
5125
  render_promises.push(render_promise);
4579
5126
  }
@@ -4677,8 +5224,17 @@ class Jqhtml_Component {
4677
5224
  this.next_reload_force_refresh = null;
4678
5225
  this._render();
4679
5226
  await this._wait_for_children_ready();
5227
+ // Stopped while waiting for children: no hook, no event on a dead component.
5228
+ if (this._stopped)
5229
+ return;
4680
5230
  await this._call_lifecycle('on_ready');
5231
+ // on_ready() may itself have awaited long enough for a stop().
5232
+ if (this._stopped)
5233
+ return;
4681
5234
  this._fire_viewport_resize();
5235
+ // Restore the ready state (_render() dropped it to 3) before the trigger
5236
+ this._ready_state = 4;
5237
+ this._update_debug_attrs();
4682
5238
  this.trigger('ready');
4683
5239
  this._log_lifecycle('reload', 'complete (no on_load)');
4684
5240
  return;
@@ -4706,10 +5262,16 @@ class Jqhtml_Component {
4706
5262
  // STEP 2: Call on_load() on detached proxy
4707
5263
  // This uses the same detached execution as _load() - on_load runs isolated
4708
5264
  // and result is applied via sequential queue
4709
- const { data: result_data } = await this._execute_on_load_detached(false); // false = don't use Load_Coordinator
5265
+ const { data: result_data } = await this._execute_on_load_detached(null); // null = no coordination
5266
+ // Stopped during on_load(): don't write data or re-render a dead component.
5267
+ if (this._stopped)
5268
+ return;
4710
5269
  // Apply result via sequential queue
4711
5270
  // This handles normalization, caching, and setting _is_dynamic
4712
5271
  await this._apply_load_result(result_data, data_before_load);
5272
+ // The apply queue can yield; a stop() there must abort the render/ready tail.
5273
+ if (this._stopped)
5274
+ return;
4713
5275
  // Re-read data state after apply (for render decision below)
4714
5276
  const data_after_load = JSON.stringify(this.data);
4715
5277
  const data_changed = data_after_load !== data_before_load;
@@ -4718,6 +5280,15 @@ class Jqhtml_Component {
4718
5280
  const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
4719
5281
  // Track if we need to render
4720
5282
  let should_render = false;
5283
+ // HTML CACHE MODE: a cache injection above replaced the subtree with inert markup -
5284
+ // a snapshot string, with no live child components in it. It must be re-rendered
5285
+ // from the template regardless of what the load decided, exactly as _should_rerender()
5286
+ // forces it at boot. Without this, a reload whose data came back unchanged left dead
5287
+ // HTML (stopped children, no instances) on screen.
5288
+ const used_cached_html = this._used_cached_html;
5289
+ if (used_cached_html) {
5290
+ this._used_cached_html = false;
5291
+ }
4721
5292
  if (force_refresh) {
4722
5293
  // reload(true) or reload() - always render if we haven't yet, OR data changed
4723
5294
  should_render = !rendered_from_cache || data_changed;
@@ -4738,6 +5309,11 @@ class Jqhtml_Component {
4738
5309
  }
4739
5310
  }
4740
5311
  // STEP 3: Perform second render if needed
5312
+ // The cache injection's inert markup is not optional to replace - this overrides both
5313
+ // the reload() and the refresh()/reload(false) decision above.
5314
+ if (used_cached_html) {
5315
+ should_render = true;
5316
+ }
4741
5317
  if (should_render) {
4742
5318
  this._render();
4743
5319
  }
@@ -4752,11 +5328,33 @@ class Jqhtml_Component {
4752
5328
  // STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
4753
5329
  if (rendered_from_cache || should_render) {
4754
5330
  await this._wait_for_children_ready();
5331
+ // Stopped while waiting for children: no hook, no event on a dead component.
5332
+ if (this._stopped)
5333
+ return;
4755
5334
  await this._call_lifecycle('on_ready');
5335
+ // on_ready() may itself have awaited long enough for a stop().
5336
+ if (this._stopped)
5337
+ return;
4756
5338
  this._fire_viewport_resize();
5339
+ // Restore the ready state (the load phase dropped it to 2, _render() to 3)
5340
+ // before the trigger, so handlers see a ready component.
5341
+ this._ready_state = 4;
5342
+ this._update_debug_attrs();
4757
5343
  // Trigger ready event so parent callbacks fire on subsequent reloads
4758
5344
  this.trigger('ready');
4759
5345
  }
5346
+ else {
5347
+ // refresh()/reload(false) found unchanged data: no re-render, and on_ready()
5348
+ // is deliberately NOT called (documented behaviour). But the component IS
5349
+ // ready - and this cycle deleted the sticky 'ready' marker at the top of
5350
+ // _reload() and dropped _ready_state to 2 in the load phase. Without
5351
+ // restoring both, ready() never resolves again and a parent re-entering
5352
+ // _wait_for_children_ready() hangs on this child forever (the documented
5353
+ // polling pattern breaks after its first quiet refresh).
5354
+ this._ready_state = 4;
5355
+ this._update_debug_attrs();
5356
+ this.trigger('ready');
5357
+ }
4760
5358
  this._log_lifecycle('reload', 'complete');
4761
5359
  }
4762
5360
  /**
@@ -4781,26 +5379,28 @@ class Jqhtml_Component {
4781
5379
  this.__gate_resume = null;
4782
5380
  resume();
4783
5381
  }
4784
- // Early bailout: skip expensive cleanup if no handlers registered
4785
- // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
4786
- const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
4787
- const has_stop_callbacks = this._on_registered('stop');
4788
- if (!has_custom_stop && !has_stop_callbacks) {
4789
- // Fast path: no cleanup logic defined, just mark as stopped
4790
- this._lifecycle_manager.unregister_component(this);
4791
- this._ready_state = 99;
4792
- return;
4793
- }
4794
- // Full cleanup path: component has custom stop logic
5382
+ // There is deliberately NO fast path for components without stop logic. The
5383
+ // marker class, the parent de-registration and the 'stop' event are not cleanup
5384
+ // the component asked for, they are how the rest of the framework learns this
5385
+ // component is dead: the class is the documented, observable stopped state; a
5386
+ // child left in _dom_parent._dom_children is a dead component every later
5387
+ // _wait_for_children_ready() and _get_dom_children() walks; and 'stop' is what
5388
+ // releases a parent (and boot's failure path) waiting on a child that will never
5389
+ // fire 'ready'. Skipping them for the common no-on_stop() child was what leaked
5390
+ // stopped children into the parent's registry.
4795
5391
  this._log_lifecycle('destroy', 'start');
4796
5392
  this.$.addClass('_Component_Stopped');
4797
5393
  // Unregister from lifecycle manager
4798
5394
  this._lifecycle_manager.unregister_component(this);
4799
- // Call user's on_stop() hook with authorization (sync)
4800
- const stopResult = this._call_lifecycle_sync('on_stop');
4801
- if (stopResult && typeof stopResult.then === 'function') {
4802
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
4803
- `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5395
+ // Call user's on_stop() hook with authorization (sync) - only when actually
5396
+ // overridden, since _call_lifecycle_sync() on the base no-op costs per child
5397
+ // on every re-render.
5398
+ if (this.on_stop !== Jqhtml_Component.prototype.on_stop) {
5399
+ const stopResult = this._call_lifecycle_sync('on_stop');
5400
+ if (stopResult && typeof stopResult.then === 'function') {
5401
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5402
+ `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5403
+ }
4804
5404
  }
4805
5405
  // Fire registered stop callbacks
4806
5406
  this.trigger('stop');
@@ -4885,8 +5485,15 @@ class Jqhtml_Component {
4885
5485
  /**
4886
5486
  * Get component name for debugging
4887
5487
  */
5488
+ /**
5489
+ * The name this component was invoked as - the tag written in the template
5490
+ * (<User_Card> -> 'User_Card'), the name given to $(el).component('User_Card'),
5491
+ * or the boot placeholder's data-component-init-name. For a template-only
5492
+ * component this is the template name, not 'Jqhtml_Component'. Falls back to
5493
+ * the class's static component_name, then its JS class name.
5494
+ */
4888
5495
  component_name() {
4889
- return this.constructor.name;
5496
+ return this._component_name;
4890
5497
  }
4891
5498
  /**
4892
5499
  * Register event callback - delegates to component-events.ts
@@ -5069,6 +5676,48 @@ class Jqhtml_Component {
5069
5676
  _generate_cid() {
5070
5677
  return uid();
5071
5678
  }
5679
+ /**
5680
+ * Re-scope every scoped id in an HTML-cache snapshot so it belongs to THIS instance.
5681
+ *
5682
+ * A snapshot is the inner HTML of some component instance, so it contains ids of the
5683
+ * form `<name>:<cid>`: the snapshotting component's own elements carry its cid, and
5684
+ * elements rendered by nested children carry each child's cid. Replayed verbatim into
5685
+ * a second instance those ids are duplicates of the originals, and $sid() resolves via
5686
+ * getElementById(), which returns whichever matching element is first in the document -
5687
+ * so one instance could read and write another instance's DOM.
5688
+ *
5689
+ * Mapping: the snapshot owner's cid becomes this._cid (so $sid() finds the elements
5690
+ * this component is about to treat as its own), and every other cid becomes a fresh
5691
+ * uid(), consistently within one injection. Two instances hydrated from one snapshot
5692
+ * therefore share no id at all.
5693
+ *
5694
+ * The ':' separator is written only by the compiler ($sid) and the instruction
5695
+ * processor (a scoped plain id), and cids are uid() output - [a-z][a-z0-9]* - so the
5696
+ * pattern is exact. A plain unscoped id has no ':' and is left alone.
5697
+ *
5698
+ * data-cid is the debug mirror of a component's own _cid (config.ts debug_attributes).
5699
+ * The snapshot's values are stale cids belonging to components that no longer exist,
5700
+ * so they go through the same mapping rather than being left to lie in DevTools.
5701
+ *
5702
+ * @private
5703
+ */
5704
+ _rescope_cached_html(snapshot) {
5705
+ const cid_map = new Map();
5706
+ cid_map.set(snapshot.cid, this._cid);
5707
+ const remap = (old_cid) => {
5708
+ let new_cid = cid_map.get(old_cid);
5709
+ if (new_cid === undefined) {
5710
+ new_cid = uid();
5711
+ cid_map.set(old_cid, new_cid);
5712
+ }
5713
+ return new_cid;
5714
+ };
5715
+ return snapshot.html
5716
+ // id="<name>:<cid>" - the name half may itself contain ':' (an author-written
5717
+ // id="a:b" is scoped as "a:b:cid"), so the cid is anchored at the end.
5718
+ .replace(/ id="([^"]*):([a-z][a-z0-9]*)"/g, (_match, name, old_cid) => ` id="${name}:${remap(old_cid)}"`)
5719
+ .replace(/ data-cid="([a-z][a-z0-9]*)"/g, (_match, old_cid) => ` data-cid="${remap(old_cid)}"`);
5720
+ }
5072
5721
  _apply_css_classes() {
5073
5722
  const hierarchy = this.constructor.get_class_hierarchy();
5074
5723
  // If component name differs from class name, add component name to hierarchy
@@ -5108,26 +5757,29 @@ class Jqhtml_Component {
5108
5757
  }
5109
5758
  if (!template)
5110
5759
  return;
5111
- // Walk the extends chain to collect all defaultAttributes from parent templates
5112
- // Apply from parent to child so child attributes take precedence
5760
+ // Walk the inheritance chain to collect defaultAttributes from parent templates,
5761
+ // applying parent first so the child's own attributes take precedence.
5762
+ //
5763
+ // This uses resolve_parent_template(), the same helper the slot-only render path
5764
+ // uses, so a <Define>'s class/attrs resolve through EXACTLY the chain its markup
5765
+ // does. It previously followed only `extends=`, so a slot-only JS subclass
5766
+ // (class Users_Grid extends Grid_Abstract) inherited the parent's template but
5767
+ // none of the parent <Define>'s class="" or attributes.
5113
5768
  const templateChain = [];
5114
5769
  let currentTemplate = template;
5115
- // Build chain from child to parent
5116
- while (currentTemplate) {
5770
+ let currentClass = this.constructor;
5771
+ const seen = new Set();
5772
+ while (currentTemplate && !seen.has(currentTemplate)) {
5773
+ seen.add(currentTemplate);
5117
5774
  templateChain.unshift(currentTemplate); // Add to front so parent comes first
5118
- // Check if this template extends another
5119
- if (currentTemplate.extends) {
5120
- try {
5121
- currentTemplate = get_template(currentTemplate.extends);
5122
- }
5123
- catch (error) {
5124
- // Parent template not found, stop chain
5125
- break;
5126
- }
5127
- }
5128
- else {
5775
+ const parent = resolve_parent_template(currentTemplate, currentClass);
5776
+ if (!parent)
5129
5777
  break;
5130
- }
5778
+ currentTemplate = parent.template;
5779
+ // Advance the prototype walk too: the next parent must be resolved relative to
5780
+ // the class we just landed on, not the original one. A template-only parent has
5781
+ // no class, which is correct - its own chain can only be an explicit extends=.
5782
+ currentClass = get_component_class(parent.name) || null;
5131
5783
  }
5132
5784
  // Apply defaultAttributes from each template in the chain (parent first, child last)
5133
5785
  for (const tmpl of templateChain) {
@@ -5204,8 +5856,10 @@ class Jqhtml_Component {
5204
5856
  }
5205
5857
  }
5206
5858
  else {
5207
- // Regular attributes - apply directly if not already set
5208
- if (!this.$.attr(key)) {
5859
+ // Regular attributes - apply directly if not already set.
5860
+ // === undefined, not falsy: tabindex="0" and an empty-string attribute
5861
+ // written at the invocation are SET, and a Define default must not win.
5862
+ if (this.$.attr(key) === undefined) {
5209
5863
  this.$.attr(key, value);
5210
5864
  }
5211
5865
  }
@@ -5297,278 +5951,416 @@ Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() det
5297
5951
  // A bare component name, as accepted by closest() in place of a class selector
5298
5952
  Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
5299
5953
 
5954
+ var light_css = "html[data-jqhtml-debug] .jqhtml-debug-hit {\n outline: 2px solid #e5322d;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-0 {\n outline-color: #e5322d;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-1 {\n outline-color: #2f6fe4;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-2 {\n outline-color: #1f9d55;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-3 {\n outline-color: #d97706;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-4 {\n outline-color: #7c3aed;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-5 {\n outline-color: #0e7490;\n}";
5955
+
5956
+ var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed;\n box-sizing: border-box;\n top: 0;\n left: 0;\n width: 100vw;\n height: 100vh;\n z-index: 2147483647;\n pointer-events: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.4;\n color: #1c1c1c;\n text-align: left;\n text-transform: none;\n text-indent: 0;\n text-shadow: none;\n letter-spacing: normal;\n word-spacing: normal;\n white-space: normal;\n word-break: normal;\n overflow-wrap: anywhere;\n hyphens: none;\n tab-size: 4;\n direction: ltr;\n visibility: visible;\n cursor: default;\n list-style: none;\n quotes: none;\n}\n\n.jqhtml-debug-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label {\n position: absolute;\n box-sizing: border-box;\n display: block;\n padding: 1px 5px;\n min-height: 18px;\n color: #ffffff;\n background: #e5322d;\n border-radius: 0 0 3px 0;\n font-size: 11px;\n line-height: 16px;\n white-space: normal;\n overflow-wrap: anywhere;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-0 {\n background: #e5322d;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-1 {\n background: #2f6fe4;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-2 {\n background: #1f9d55;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-3 {\n background: #d97706;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-4 {\n background: #7c3aed;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-5 {\n background: #0e7490;\n}\n\n.jqhtml-debug-label-name {\n font-weight: bold;\n}\n\n.jqhtml-debug-label-args {\n opacity: 0.9;\n margin-left: 6px;\n}\n\n.jqhtml-debug-modal {\n display: none;\n position: absolute;\n box-sizing: border-box;\n top: 24px;\n right: 24px;\n width: 520px;\n max-width: calc(100% - 48px);\n max-height: calc(100% - 48px);\n overflow: auto;\n padding: 0 0 12px 0;\n background: #ffffff;\n color: #1c1c1c;\n border: 1px solid #d0d0d0;\n border-radius: 6px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n pointer-events: auto;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-open {\n display: block;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n position: sticky;\n top: 0;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-title-name {\n flex: 1 1 auto;\n font-size: 13px;\n}\n\n.jqhtml-debug-button {\n all: unset;\n display: inline-block;\n box-sizing: border-box;\n padding: 2px 8px;\n border: 1px solid rgba(255, 255, 255, 0.6);\n border-radius: 3px;\n color: #ffffff;\n font-size: 11px;\n line-height: 16px;\n cursor: pointer;\n user-select: none;\n}\n\n.jqhtml-debug-button:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.jqhtml-debug-section {\n padding: 8px 12px 0 12px;\n}\n\n.jqhtml-debug-h {\n margin: 4px 0;\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-table {\n display: grid;\n grid-template-columns: max-content max-content 1fr;\n column-gap: 12px;\n row-gap: 2px;\n align-items: baseline;\n}\n\n.jqhtml-debug-key {\n color: #005cc5;\n white-space: nowrap;\n}\n\n.jqhtml-debug-type {\n color: #666666;\n font-size: 11px;\n white-space: nowrap;\n}\n\n.jqhtml-debug-value {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.jqhtml-debug-empty {\n color: #666666;\n font-style: italic;\n}\n\n.jqhtml-debug-list {\n display: block;\n margin: 0;\n padding: 0;\n}\n\n.jqhtml-debug-link {\n display: block;\n padding: 2px 4px;\n margin: 0 -4px;\n border-radius: 3px;\n cursor: pointer;\n}\n\n.jqhtml-debug-link:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-link-cid {\n color: #666666;\n margin-left: 6px;\n}\n\n.jqhtml-debug-note {\n margin-top: 4px;\n color: #92400e;\n font-size: 11px;\n}";
5957
+
5300
5958
  /**
5301
- * JQHTML v2 Template Renderer
5302
- *
5303
- * Connects compiled templates to components
5304
- * Processes instruction arrays and handles bindings
5305
- */
5306
- /**
5307
- * Process slot-only template inheritance
5959
+ * Component debug overlay.
5308
5960
  *
5309
- * When a component's template returns {_slots: {...}}, walk the prototype
5310
- * chain to find a parent template and invoke it with the child's slots as content.
5961
+ * `jqhtml.debug_overlay.enable()` turns the page into an inspector: hovering
5962
+ * any element outlines the component under the pointer and every component
5963
+ * above it, each with a small tab naming the component and its simple args;
5964
+ * clicking opens a modal describing the innermost component (identity, args,
5965
+ * data, state, ancestry, instantiator) instead of firing the click. Alt+click
5966
+ * passes through. `disable()` puts everything back.
5311
5967
  *
5312
- * @param component The component instance
5313
- * @param childSlots The slots object from the child template
5314
- * @returns [instructions, context] tuple or null if no parent found
5968
+ * Non-invasiveness, the design goal (details in ./CLAUDE.md):
5969
+ * - The overlay's own UI lives in a shadow root on a host element appended to
5970
+ * <body>, styled by shadow.scss. Page CSS cannot select into it and :host
5971
+ * cuts inheritance, so its look is fixed whatever the page does.
5972
+ * - The only thing added to application elements is a pair of classes,
5973
+ * .jqhtml-debug-hit / .jqhtml-debug-depth-N, which light.scss paints as an
5974
+ * inset outline (no layout effect) and only while <html data-jqhtml-debug>.
5975
+ * - Listeners are attached on enable and removed on disable. The stylesheets
5976
+ * and the (detached) host stay once installed: inert, and cheap to re-enable.
5977
+ * - Nothing is registered per component, so components created after enable
5978
+ * are covered automatically.
5315
5979
  */
5316
- async function process_slot_inheritance(component, childSlots) {
5317
- // Walk the prototype chain to find parent class with template
5318
- let currentClass = Object.getPrototypeOf(component.constructor);
5319
- console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
5320
- while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
5321
- const className = currentClass.name;
5322
- console.log(`[JQHTML] Checking parent class: ${className}`);
5323
- // Skip internal framework classes
5324
- if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
5325
- currentClass = Object.getPrototypeOf(currentClass);
5326
- continue;
5327
- }
5328
- // Try to find template for this parent class
5329
- try {
5330
- const parentTemplate = get_template(className);
5331
- console.log(`[JQHTML] Template found for ${className}:`, parentTemplate ? parentTemplate.name : 'null');
5332
- // Check if we got the default template (means no real template found)
5333
- if (parentTemplate && parentTemplate.name !== 'Jqhtml_Component') {
5334
- console.log(`[JQHTML] Invoking parent template ${className}`);
5335
- // Found a real parent template - invoke it with child's slots as content
5336
- const [parentInstructions, parentContext] = parentTemplate.render.call(component, component.data, component.args, childSlots // Pass child slots as content parameter
5337
- );
5338
- // Check if parent also returns slots (recursive inheritance)
5339
- if (parentInstructions && typeof parentInstructions === 'object' && parentInstructions._slots) {
5340
- // Parent also has slot-only template - recurse
5341
- console.log(`[JQHTML] Parent also slot-only, recursing`);
5342
- return await process_slot_inheritance(component, parentInstructions._slots);
5343
- }
5344
- // Parent returned HTML - we're done
5345
- console.log(`[JQHTML] Parent returned instructions, inheritance complete`);
5346
- return [parentInstructions, parentContext];
5347
- }
5348
- }
5349
- catch (error) {
5350
- console.warn(`[JQHTML] Error looking up parent template for ${className}:`, error);
5351
- }
5352
- // Move to next parent
5353
- currentClass = Object.getPrototypeOf(currentClass);
5980
+ const PREFIX = 'jqhtml-debug-';
5981
+ const ROOT_ATTR = 'data-jqhtml-debug'; // on <html> while enabled - gates light.scss
5982
+ const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
5983
+ const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
5984
+ const HIT_CLASS = `${PREFIX}hit`;
5985
+ const DEPTH_CLASS = `${PREFIX}depth-`;
5986
+ const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
5987
+ const LABEL_HEIGHT = 18;
5988
+ const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
5989
+ const state = {
5990
+ enabled: false,
5991
+ host: null,
5992
+ shadow: null,
5993
+ layer: null,
5994
+ modal: null,
5995
+ chain: [],
5996
+ hit_elements: [],
5997
+ modal_component: null,
5998
+ };
5999
+ // ---------------------------------------------------------------------------
6000
+ // Public API
6001
+ // ---------------------------------------------------------------------------
6002
+ const debug_overlay = {
6003
+ /** Turn the overlay on. Returns false where there is no DOM (SSR). */
6004
+ enable() {
6005
+ if (typeof document === 'undefined' || !document.documentElement)
6006
+ return false;
6007
+ if (state.enabled)
6008
+ return true;
6009
+ install();
6010
+ if (!state.host.isConnected)
6011
+ (document.body || document.documentElement).appendChild(state.host);
6012
+ document.documentElement.setAttribute(ROOT_ATTR, '');
6013
+ document.addEventListener('mouseover', on_mouseover, true);
6014
+ document.addEventListener('mouseout', on_mouseout, true);
6015
+ document.addEventListener('click', on_click, true);
6016
+ document.addEventListener('keydown', on_keydown, true);
6017
+ document.addEventListener('scroll', on_reposition, true);
6018
+ window.addEventListener('resize', on_reposition);
6019
+ state.enabled = true;
6020
+ return true;
6021
+ },
6022
+ /** Turn the overlay off. Styles stay installed but match nothing. */
6023
+ disable() {
6024
+ if (!state.enabled)
6025
+ return;
6026
+ document.removeEventListener('mouseover', on_mouseover, true);
6027
+ document.removeEventListener('mouseout', on_mouseout, true);
6028
+ document.removeEventListener('click', on_click, true);
6029
+ document.removeEventListener('keydown', on_keydown, true);
6030
+ document.removeEventListener('scroll', on_reposition, true);
6031
+ window.removeEventListener('resize', on_reposition);
6032
+ clear_hover();
6033
+ close_modal();
6034
+ document.documentElement.removeAttribute(ROOT_ATTR);
6035
+ if (state.host && state.host.isConnected)
6036
+ state.host.remove();
6037
+ state.enabled = false;
6038
+ },
6039
+ is_enabled() {
6040
+ return state.enabled;
6041
+ },
6042
+ /**
6043
+ * Open the modal for a component, an element inside one, or a jQuery
6044
+ * object, without needing a click. Handy from the console and from tests.
6045
+ */
6046
+ inspect(target) {
6047
+ if (!state.enabled)
6048
+ return false;
6049
+ const component = target instanceof Jqhtml_Component
6050
+ ? target
6051
+ : chain_for(target && target.jquery ? target[0] : target)[0];
6052
+ if (!component)
6053
+ return false;
6054
+ open_modal(component);
6055
+ return true;
6056
+ },
6057
+ };
6058
+ // ---------------------------------------------------------------------------
6059
+ // Installation (once)
6060
+ // ---------------------------------------------------------------------------
6061
+ function install() {
6062
+ if (state.host)
6063
+ return;
6064
+ if (!document.getElementById(LIGHT_STYLE_ID)) {
6065
+ const style = document.createElement('style');
6066
+ style.id = LIGHT_STYLE_ID;
6067
+ style.textContent = light_css;
6068
+ document.head.appendChild(style);
6069
+ }
6070
+ const host = document.createElement('div');
6071
+ host.setAttribute(HOST_ATTR, '');
6072
+ const shadow = host.attachShadow({ mode: 'open' });
6073
+ const style = document.createElement('style');
6074
+ style.textContent = shadow_css;
6075
+ shadow.appendChild(style);
6076
+ const layer = el('div', 'layer');
6077
+ shadow.appendChild(layer);
6078
+ const modal = el('div', 'modal');
6079
+ shadow.appendChild(modal);
6080
+ state.host = host;
6081
+ state.shadow = shadow;
6082
+ state.layer = layer;
6083
+ state.modal = modal;
6084
+ }
6085
+ // ---------------------------------------------------------------------------
6086
+ // Component chain
6087
+ // ---------------------------------------------------------------------------
6088
+ /** Components from the one containing `node` outward to the root. */
6089
+ function chain_for(node) {
6090
+ const chain = [];
6091
+ let element = node instanceof Element ? node : (node && node.parentElement) || null;
6092
+ while (element) {
6093
+ const owner = element.closest('.Component');
6094
+ if (!owner)
6095
+ break;
6096
+ const component = $(owner).data('_component');
6097
+ if (component instanceof Jqhtml_Component && !component.$.hasClass('_Component_Stopped'))
6098
+ chain.push(component);
6099
+ element = owner.parentElement;
5354
6100
  }
5355
- // No parent template found
5356
- console.warn(`[JQHTML] No parent template found after walking chain`);
5357
- return null;
6101
+ return chain;
5358
6102
  }
5359
- /**
5360
- * Render a template for a component
5361
- * Templates are functions that return [instructions, context] tuples
5362
- */
5363
- async function render_template(component, template_fn) {
5364
- // Get template from registry if not provided
5365
- let render_fn = template_fn;
5366
- if (!render_fn) {
5367
- const template_def = get_template_by_class(component.constructor);
5368
- render_fn = template_def.render;
5369
- }
5370
- if (!render_fn) {
5371
- // No template, component should handle its own rendering
6103
+ function is_ours(event) {
6104
+ return !!state.host && event.composedPath().includes(state.host);
6105
+ }
6106
+ // ---------------------------------------------------------------------------
6107
+ // Hover
6108
+ // ---------------------------------------------------------------------------
6109
+ function on_mouseover(event) {
6110
+ if (is_ours(event))
5372
6111
  return;
5373
- }
5374
- // Clear existing content
5375
- component.$.empty();
5376
- // Execute template function
5377
- // Templates receive: (data, args, content)
5378
- // Provide a default empty content function if none provided
5379
- const defaultContent = () => '';
5380
- let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
5381
- );
5382
- // Check for slot-only template inheritance
5383
- // If instructions is {_slots: {...}}, this is a slot-only template
5384
- // We need to find the parent template and invoke it with these slots
5385
- if (instructions && typeof instructions === 'object' && instructions._slots) {
5386
- console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
5387
- const result = await process_slot_inheritance(component, instructions._slots);
5388
- if (result) {
5389
- console.log(`[JQHTML] Parent template found, using parent instructions`);
5390
- instructions = result[0];
5391
- context = result[1];
5392
- }
5393
- else {
5394
- console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
5395
- // No parent template found - return empty
5396
- instructions = [];
5397
- }
5398
- }
5399
- // Process instructions to generate DOM
5400
- await process_instructions(instructions, component.$, component);
5401
- // Process bindings after DOM is created
5402
- await process_bindings(component);
5403
- // Attach event handlers
5404
- await attach_event_handlers(component);
6112
+ const chain = chain_for(event.target);
6113
+ if (chain[0] === state.chain[0])
6114
+ return;
6115
+ set_hover(chain);
5405
6116
  }
5406
- /**
5407
- * Process data bindings (attributes created by :prop syntax)
5408
- */
5409
- async function process_bindings(component) {
5410
- // Find all elements with data-bind-* attributes
5411
- component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
5412
- const el = $(element);
5413
- const attrs = element.attributes;
5414
- for (let i = 0; i < attrs.length; i++) {
5415
- const attr = attrs[i];
5416
- if (attr.name.startsWith('data-bind-')) {
5417
- const binding_type = attr.name.substring(10); // Remove 'data-bind-'
5418
- const expression = attr.value;
5419
- try {
5420
- // Evaluate expression in component context
5421
- const value = evaluate_expression(expression, component);
5422
- // Apply binding based on type
5423
- switch (binding_type) {
5424
- case 'prop':
5425
- // Generic property binding
5426
- const prop_name = el.attr('data-bind-prop-name') || 'value';
5427
- el.prop(prop_name, value);
5428
- break;
5429
- case 'value':
5430
- el.val(value);
5431
- break;
5432
- case 'text':
5433
- el.text(value);
5434
- break;
5435
- case 'html':
5436
- el.html(value);
5437
- break;
5438
- case 'class':
5439
- if (typeof value === 'object') {
5440
- // Object syntax: {active: true, disabled: false}
5441
- Object.entries(value).forEach(([className, enabled]) => {
5442
- el.toggleClass(className, !!enabled);
5443
- });
5444
- }
5445
- else {
5446
- // String syntax
5447
- el.addClass(String(value));
5448
- }
5449
- break;
5450
- case 'style':
5451
- if (typeof value === 'object') {
5452
- el.css(value);
5453
- }
5454
- else {
5455
- el.attr('style', String(value));
5456
- }
5457
- break;
5458
- default:
5459
- // Custom binding - set as attribute
5460
- el.attr(binding_type, value);
5461
- }
5462
- }
5463
- catch (error) {
5464
- console.error(`Error evaluating binding "${expression}":`, error);
5465
- }
5466
- }
5467
- }
5468
- });
6117
+ function on_mouseout(event) {
6118
+ if (!event.relatedTarget)
6119
+ clear_hover(); // pointer left the document
5469
6120
  }
5470
- /**
5471
- * Attach event handlers (attributes created by @event syntax)
5472
- */
5473
- async function attach_event_handlers(component) {
5474
- // Find all elements with data-on-* attributes
5475
- component.$.find('[data-on-click], [data-on-change], [data-on-submit], [data-on-keyup], [data-on-keydown], [data-on-focus], [data-on-blur]').each((_, element) => {
5476
- const el = $(element);
5477
- const attrs = element.attributes;
5478
- for (let i = 0; i < attrs.length; i++) {
5479
- const attr = attrs[i];
5480
- if (attr.name.startsWith('data-on-')) {
5481
- const event_name = attr.name.substring(8); // Remove 'data-on-'
5482
- const handler_expr = attr.value;
5483
- // Remove the attribute to avoid re-processing
5484
- el.removeAttr(attr.name);
5485
- // Attach event handler
5486
- el.on(event_name, function (event) {
5487
- try {
5488
- // Create handler function in component context
5489
- const handler = evaluate_handler(handler_expr, component);
5490
- if (typeof handler === 'function') {
5491
- // Call with component as 'this' and pass event
5492
- handler.call(component, event);
5493
- }
5494
- else {
5495
- // Expression to execute
5496
- evaluate_expression(handler_expr, component, { $event: event });
5497
- }
5498
- }
5499
- catch (error) {
5500
- console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
5501
- }
5502
- });
5503
- }
5504
- }
6121
+ function on_reposition() {
6122
+ if (state.chain.length)
6123
+ render_labels();
6124
+ }
6125
+ function set_hover(chain) {
6126
+ clear_hover();
6127
+ state.chain = chain;
6128
+ chain.forEach((component, depth) => {
6129
+ const element = component.$[0];
6130
+ element.classList.add(HIT_CLASS, DEPTH_CLASS + (depth % DEPTH_COLORS));
6131
+ state.hit_elements.push(element);
5505
6132
  });
6133
+ render_labels();
6134
+ }
6135
+ function clear_hover() {
6136
+ for (const element of state.hit_elements) {
6137
+ element.classList.remove(HIT_CLASS);
6138
+ for (let i = 0; i < DEPTH_COLORS; i++)
6139
+ element.classList.remove(DEPTH_CLASS + i);
6140
+ }
6141
+ state.hit_elements = [];
6142
+ state.chain = [];
6143
+ if (state.layer)
6144
+ state.layer.textContent = '';
5506
6145
  }
5507
6146
  /**
5508
- * Evaluate an expression in component context
6147
+ * One tab per component in the chain, pinned to the component's top-left
6148
+ * corner and no wider than the component. Outermost first so the innermost
6149
+ * paints on top; tabs that share a corner (a child filling its parent, the
6150
+ * usual case) stack downward instead of hiding each other.
5509
6151
  */
5510
- function evaluate_expression(expression, component, locals = {}) {
5511
- // Create evaluation context
5512
- const context = {
5513
- // Component properties
5514
- data: component.data,
5515
- args: component.args,
5516
- $: component.$,
5517
- // Component methods
5518
- $sid: component.$sid.bind(component),
5519
- // Locals (like $event)
5520
- ...locals
5521
- };
5522
- // Build function that evaluates in context
5523
- const keys = Object.keys(context);
5524
- const values = Object.values(context);
5525
- try {
5526
- // Use Function constructor for better performance than eval
5527
- const fn = new Function(...keys, `return (${expression})`);
5528
- return fn(...values);
6152
+ function render_labels() {
6153
+ const layer = state.layer;
6154
+ layer.textContent = '';
6155
+ const taken = new Map();
6156
+ for (let depth = state.chain.length - 1; depth >= 0; depth--) {
6157
+ const component = state.chain[depth];
6158
+ const rect = component.$[0].getBoundingClientRect();
6159
+ if (rect.width <= 0 || rect.height <= 0)
6160
+ continue;
6161
+ const left = Math.max(0, Math.round(rect.left));
6162
+ const top = Math.max(0, Math.round(rect.top));
6163
+ const key = `${left},${top}`;
6164
+ const stacked = taken.get(key) || 0;
6165
+ taken.set(key, stacked + 1);
6166
+ const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
6167
+ label.style.left = `${left}px`;
6168
+ label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
6169
+ label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
6170
+ label.appendChild(el('span', 'label-name', component.component_name()));
6171
+ const args = simple_args(component);
6172
+ if (args)
6173
+ label.appendChild(el('span', 'label-args', args));
6174
+ layer.appendChild(label);
5529
6175
  }
5530
- catch (error) {
5531
- console.error(`Invalid expression: ${expression}`, error);
5532
- return undefined;
6176
+ }
6177
+ /** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
6178
+ function simple_args(component) {
6179
+ const parts = [];
6180
+ for (const [key, value] of Object.entries(component.args || {})) {
6181
+ if (key.startsWith('_'))
6182
+ continue;
6183
+ if (typeof value === 'string')
6184
+ parts.push(`$${key}=${JSON.stringify(truncate(value, 24))}`);
6185
+ else if (typeof value === 'number' || typeof value === 'boolean')
6186
+ parts.push(`$${key}=${value}`);
5533
6187
  }
6188
+ return parts.join(' ');
5534
6189
  }
5535
- /**
5536
- * Evaluate a handler expression (might be method name or inline code)
5537
- */
5538
- function evaluate_handler(expression, component) {
5539
- // Check if it's a method name on the component
5540
- if (expression in component && typeof component[expression] === 'function') {
5541
- return component[expression];
6190
+ // ---------------------------------------------------------------------------
6191
+ // Click -> modal
6192
+ // ---------------------------------------------------------------------------
6193
+ function on_click(event) {
6194
+ if (is_ours(event) || event.altKey)
6195
+ return;
6196
+ event.preventDefault();
6197
+ event.stopPropagation();
6198
+ event.stopImmediatePropagation();
6199
+ const chain = chain_for(event.target);
6200
+ if (!chain.length) {
6201
+ close_modal();
6202
+ return;
6203
+ }
6204
+ open_modal(chain[0]);
6205
+ }
6206
+ function on_keydown(event) {
6207
+ if (event.key === 'Escape' && state.modal_component) {
6208
+ close_modal();
6209
+ event.stopPropagation();
6210
+ }
6211
+ }
6212
+ function close_modal() {
6213
+ if (!state.modal)
6214
+ return;
6215
+ state.modal.classList.remove(`${PREFIX}modal-open`);
6216
+ state.modal.textContent = '';
6217
+ state.modal_component = null;
6218
+ }
6219
+ function open_modal(component) {
6220
+ const modal = state.modal;
6221
+ modal.textContent = '';
6222
+ state.modal_component = component;
6223
+ const chain = chain_for(component.$[0]);
6224
+ const ancestors = chain.slice(1);
6225
+ const element = component.$[0];
6226
+ // Title bar
6227
+ const title = el('div', 'title');
6228
+ title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
6229
+ const log = el('button', 'button', 'Log to console');
6230
+ log.addEventListener('click', () => {
6231
+ console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
6232
+ });
6233
+ title.appendChild(log);
6234
+ const close = el('button', 'button', 'Close');
6235
+ close.addEventListener('click', close_modal);
6236
+ title.appendChild(close);
6237
+ modal.appendChild(title);
6238
+ // Identity
6239
+ const ctor = component.constructor;
6240
+ const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
6241
+ const identity = table();
6242
+ row(identity, 'name', '', component.component_name());
6243
+ row(identity, 'class', '', hierarchy.join(' → '));
6244
+ row(identity, 'element', '', describe_element(element));
6245
+ row(identity, '_cid', '', String(component._cid));
6246
+ row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
6247
+ const nocache = element.getAttribute('data-nocache');
6248
+ if (nocache)
6249
+ row(identity, 'cache', '', `declined: ${nocache}`);
6250
+ modal.appendChild(section('Identity', identity));
6251
+ // Args / data / state
6252
+ modal.appendChild(section('Args', object_table(component.args)));
6253
+ modal.appendChild(section('Data', object_table(component.data)));
6254
+ modal.appendChild(section('State', object_table(component.state)));
6255
+ // Ancestry
6256
+ const list = el('div', 'list');
6257
+ if (!ancestors.length)
6258
+ list.appendChild(el('div', 'empty', 'none - this is a root component'));
6259
+ for (const ancestor of ancestors) {
6260
+ const link = el('div', 'link');
6261
+ link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
6262
+ link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
6263
+ link.addEventListener('click', () => open_modal(ancestor));
6264
+ list.appendChild(link);
6265
+ }
6266
+ modal.appendChild(section('Ancestry (DOM, nearest first)', list));
6267
+ // Instantiator - the component whose template wrote this tag. Differs from
6268
+ // the DOM parent for markup written in a slot body or default content.
6269
+ const instantiator = component.instantiator ? component.instantiator() : null;
6270
+ const inst = el('div', 'list');
6271
+ if (instantiator) {
6272
+ const link = el('div', 'link');
6273
+ link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
6274
+ link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
6275
+ link.addEventListener('click', () => open_modal(instantiator));
6276
+ inst.appendChild(link);
6277
+ if (ancestors[0] && instantiator !== ancestors[0]) {
6278
+ inst.appendChild(el('div', 'note', 'Written in this component\'s template but rendered inside a different parent (slot or default content): handlers and $sid bind here.'));
6279
+ }
5542
6280
  }
5543
- // Otherwise treat as inline code
6281
+ else {
6282
+ inst.appendChild(el('div', 'empty', 'none - created directly'));
6283
+ }
6284
+ modal.appendChild(section('Instantiator', inst));
6285
+ modal.classList.add(`${PREFIX}modal-open`);
6286
+ }
6287
+ // ---------------------------------------------------------------------------
6288
+ // DOM helpers (shadow side)
6289
+ // ---------------------------------------------------------------------------
6290
+ function el(tag, classes, text) {
6291
+ const node = document.createElement(tag);
6292
+ node.className = classes.split(' ').map((c) => (c.startsWith(PREFIX) ? c : PREFIX + c)).join(' ');
6293
+ if (text !== undefined)
6294
+ node.textContent = text;
6295
+ return node;
6296
+ }
6297
+ function section(heading, body) {
6298
+ const wrap = el('div', 'section');
6299
+ wrap.appendChild(el('div', 'h', heading));
6300
+ wrap.appendChild(body);
6301
+ return wrap;
6302
+ }
6303
+ function table() {
6304
+ return el('div', 'table');
6305
+ }
6306
+ function row(t, key, type, value) {
6307
+ t.appendChild(el('span', 'key', key));
6308
+ t.appendChild(el('span', 'type', type));
6309
+ t.appendChild(el('span', 'value', value));
6310
+ }
6311
+ function object_table(source) {
6312
+ const entries = source && typeof source === 'object' ? Object.entries(source) : [];
6313
+ if (!entries.length)
6314
+ return el('div', 'empty', 'empty');
6315
+ const t = table();
6316
+ for (const [key, value] of entries)
6317
+ row(t, key, type_of(value), preview(value));
6318
+ return t;
6319
+ }
6320
+ function type_of(value) {
6321
+ if (value === null)
6322
+ return 'null';
6323
+ if (Array.isArray(value))
6324
+ return `array(${value.length})`;
6325
+ if (value instanceof Jqhtml_Component)
6326
+ return 'component';
6327
+ if (value && value.jquery)
6328
+ return `jQuery(${value.length})`;
6329
+ if (typeof value === 'object' && value instanceof Element)
6330
+ return 'element';
6331
+ return typeof value;
6332
+ }
6333
+ function preview(value) {
6334
+ if (value === undefined)
6335
+ return 'undefined';
6336
+ if (value === null)
6337
+ return 'null';
6338
+ if (typeof value === 'string')
6339
+ return JSON.stringify(truncate(value, 200));
6340
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint')
6341
+ return String(value);
6342
+ if (typeof value === 'function')
6343
+ return `ƒ ${value.name || 'anonymous'}()`;
6344
+ if (value instanceof Jqhtml_Component)
6345
+ return `<${value.component_name()}> ${value._cid}`;
6346
+ if (value.jquery)
6347
+ return value.length ? describe_element(value[0]) : 'empty';
6348
+ if (value instanceof Element)
6349
+ return describe_element(value);
5544
6350
  try {
5545
- return new Function('$event', `
5546
- const { data, args, $, emit, $sid } = this;
5547
- ${expression}
5548
- `).bind(component);
6351
+ return truncate(JSON.stringify(value), 200);
5549
6352
  }
5550
- catch (error) {
5551
- console.error(`Invalid handler: ${expression}`, error);
5552
- return null;
6353
+ catch {
6354
+ return `{${Object.keys(value).join(', ')}}`;
5553
6355
  }
5554
6356
  }
5555
- /**
5556
- * Helper to escape HTML for safe output
5557
- */
5558
- function escape_html(str) {
5559
- const div = document.createElement('div');
5560
- div.textContent = str;
5561
- return div.innerHTML;
6357
+ function describe_element(element) {
6358
+ const id = element.id ? `#${element.id}` : '';
6359
+ const classes = element.classList.length ? `.${Array.from(element.classList).join('.')}` : '';
6360
+ return `${element.tagName.toLowerCase()}${id}${classes}`;
5562
6361
  }
5563
- /**
5564
- * Helper to escape HTML and convert newlines to <br /> tags
5565
- * Used by <%br= %> syntax for displaying user-entered text with line breaks
5566
- */
5567
- function escape_html_nl2br(str) {
5568
- const div = document.createElement('div');
5569
- div.textContent = str;
5570
- // Escape HTML first, then replace newlines with <br />
5571
- return div.innerHTML.replace(/\n/g, '<br />');
6362
+ function truncate(text, max) {
6363
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
5572
6364
  }
5573
6365
 
5574
6366
  /**
@@ -5728,13 +6520,12 @@ function init_jquery_plugin(jQuery) {
5728
6520
  const _jqhtml_original_jquery = jQuery;
5729
6521
  // Override jQuery constructor to handle component instances
5730
6522
  const JQueryWithComponentSupport = function (selector, context) {
5731
- // Check if selector is a JQHTML component instance
5732
- if (selector &&
5733
- typeof selector === 'object' &&
5734
- selector.$ &&
5735
- typeof selector.$sid === 'function' &&
5736
- typeof selector.id === 'function') {
5737
- // Return the component's jQuery element
6523
+ // Unwrap a component instance to its root element, so $(component) behaves
6524
+ // exactly like component.$ (addClass, find, [0] and every other jQuery method).
6525
+ // The check is an instanceof on the class itself: the previous duck-type tested
6526
+ // for an id() method, which Jqhtml_Component has never had, so this branch was
6527
+ // dead and $(component) wrapped the plain object instead.
6528
+ if (selector instanceof Jqhtml_Component) {
5738
6529
  return selector.$;
5739
6530
  }
5740
6531
  // Otherwise, call original jQuery constructor
@@ -5810,13 +6601,12 @@ function init_jquery_plugin(jQuery) {
5810
6601
  // Check if component already exists on this element
5811
6602
  const existingComponent = element.data('_component');
5812
6603
  if (existingComponent) {
5813
- // Stop existing component (with error handling to continue on failure)
5814
- try {
5815
- existingComponent.stop();
5816
- }
5817
- catch (error) {
5818
- console.warn('[JQHTML] Error stopping existing component during replacement:', error);
5819
- }
6604
+ // Stop the existing component. A throwing stop() is NOT swallowed: the caller
6605
+ // asked to replace a live component, and overwriting one whose on_stop() failed
6606
+ // would leave its timers, intervals and listeners running against DOM that is
6607
+ // about to be destroyed. Let the throw reach the caller with the old component
6608
+ // still in place, so the failure is visible and recoverable.
6609
+ existingComponent.stop();
5820
6610
  // Remove component classes (any class starting with a capital letter, or a
5821
6611
  // component name with the reserved underscore prefix - except BEM classes)
5822
6612
  const classes = element.attr('class');
@@ -5832,7 +6622,15 @@ function init_jquery_plugin(jQuery) {
5832
6622
  const capital_first = cls[0] === cls[0].toUpperCase() && cls[0] !== cls[0].toLowerCase();
5833
6623
  return !capital_first && !is_component_name(cls);
5834
6624
  });
5835
- element.attr('class', nonComponentClasses.join(' '));
6625
+ // Drop the attribute entirely when nothing survived the strip: attr('class', '')
6626
+ // leaves a `class=""` in the markup, which is noise in the DOM and in snapshot
6627
+ // comparisons for an element that now has no classes at all.
6628
+ if (nonComponentClasses.length > 0) {
6629
+ element.attr('class', nonComponentClasses.join(' '));
6630
+ }
6631
+ else {
6632
+ element.removeAttr('class');
6633
+ }
5836
6634
  }
5837
6635
  // Remove component data
5838
6636
  element.removeData('_component');
@@ -5859,8 +6657,11 @@ function init_jquery_plugin(jQuery) {
5859
6657
  }
5860
6658
  }
5861
6659
  else {
5862
- // Direct class reference
6660
+ // Direct class reference. The invocation name is the class's own; it is
6661
+ // recorded for component_name() but does not drive the template tag
6662
+ // check below, which is for named (template-backed) invocations.
5863
6663
  ComponentClass = componentOrName;
6664
+ args = { ...args, _component_name: componentOrName.component_name || componentOrName.name };
5864
6665
  }
5865
6666
  // Check if element tag matches expected tag from template
5866
6667
  let targetElement = element;
@@ -5870,18 +6671,35 @@ function init_jquery_plugin(jQuery) {
5870
6671
  const expectedTag = args._tag || template.tag || 'div';
5871
6672
  const currentTag = element.prop('tagName').toLowerCase();
5872
6673
  if (currentTag !== expectedTag.toLowerCase()) {
5873
- // Tag mismatch
5874
- if (args._inner_html) {
5875
- // Replace element with correct tag (only when using server-rendered content)
6674
+ // Tag mismatch. Replace whenever we can actually do it: the element must be in
6675
+ // a document (replaceWith() on a parentless element is a silent no-op that would
6676
+ // strand the component on a detached node) and must not be <body>.
6677
+ //
6678
+ // This used to require a truthy args._inner_html, i.e. server-rendered content.
6679
+ // boot.ts always sets _inner_html - but it empties the placeholder first, so a
6680
+ // placeholder with no server content carries '' and fell through to a warning,
6681
+ // hydrating into the wrong tag. Hydration of an empty placeholder still works:
6682
+ // the copied innerHTML is simply ''.
6683
+ const oldEl = element[0];
6684
+ const can_replace = currentTag !== 'body' && !!(oldEl && oldEl.parentNode);
6685
+ if (can_replace) {
5876
6686
  const newElement = jQuery(`<${expectedTag}></${expectedTag}>`);
5877
6687
  // Copy all attributes from old element to new element
5878
- const oldEl = element[0];
5879
6688
  if (oldEl && oldEl.attributes) {
5880
6689
  for (let i = 0; i < oldEl.attributes.length; i++) {
5881
6690
  const attr = oldEl.attributes[i];
5882
6691
  newElement.attr(attr.name, attr.value);
5883
6692
  }
5884
6693
  }
6694
+ // Carry the jQuery data store across. $-args and anything the caller stashed
6695
+ // with .data() live there, and they are read back off the element during
6696
+ // component construction - dropping them silently changed the component's args.
6697
+ const old_data = element.data();
6698
+ if (old_data) {
6699
+ for (const key of Object.keys(old_data)) {
6700
+ newElement.data(key, old_data[key]);
6701
+ }
6702
+ }
5885
6703
  // Copy innerHTML
5886
6704
  newElement.html(element.html());
5887
6705
  // Replace in DOM
@@ -6196,12 +7014,14 @@ function init(jQuery, config) {
6196
7014
  }
6197
7015
  }
6198
7016
  // Version - will be replaced during build with actual version from package.json
6199
- const version = '2.3.64';
7017
+ const version = '2.3.68';
6200
7018
  // Default export with all functionality
6201
7019
  const jqhtml = {
6202
7020
  // Core
6203
7021
  Jqhtml_Component,
6204
7022
  LifecycleManager,
7023
+ // Internal - exposed for debugging and for tests that inspect coordination state
7024
+ Load_Coordinator,
6205
7025
  // Registry
6206
7026
  register,
6207
7027
  register_component,
@@ -6217,7 +7037,6 @@ const jqhtml = {
6217
7037
  // Template system
6218
7038
  process_instructions,
6219
7039
  extract_slots,
6220
- render_template,
6221
7040
  escape_html,
6222
7041
  escape_html_nl2br,
6223
7042
  // Version property - internal
@@ -6227,6 +7046,14 @@ const jqhtml = {
6227
7046
  // Integration configuration - see config.ts
6228
7047
  configure,
6229
7048
  get_config,
7049
+ // Value printers: how <%= %> renders an object (see value-printers.ts).
7050
+ // print_object and dynamic_component_name are called by compiled templates.
7051
+ add_object_printer,
7052
+ print_object,
7053
+ dynamic_component_name,
7054
+ // Component debug overlay: hover outlines + click-to-inspect modal.
7055
+ // jqhtml.debug_overlay.enable() / .disable() / .is_enabled() / .inspect(x)
7056
+ debug_overlay,
6230
7057
  // Debug settings
6231
7058
  debug: {
6232
7059
  enabled: false,
@@ -6332,5 +7159,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
6332
7159
  }
6333
7160
  }
6334
7161
 
6335
- export { COMPONENT_NAME_PATTERN, COMPONENT_NAME_RULE, 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, is_component_name, 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 };
7162
+ export { COMPONENT_NAME_PATTERN, COMPONENT_NAME_RULE, Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, add_object_printer, applyDebugDelay, boot, clear_preload_data, configure, create_component, debug_overlay, jqhtml as default, devWarn, dynamic_component_name, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_config, get_object_printers, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, is_component_name, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, print_object, process_instructions, register, register_cache_class, register_component, register_template, set_preload_data, start_data_capture, stop_data_capture, version };
6336
7163
  //# sourceMappingURL=jqhtml-core.esm.js.map