@jqhtml/core 2.3.67 → 2.3.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,199 @@
1
1
  /**
2
- * JQHTML Core v2.3.67
2
+ * JQHTML Core v2.3.70
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
  /**
@@ -664,30 +864,32 @@ function process_instructions(instructions, target, context, slots) {
664
864
  // Set innerHTML once - this is the performance win
665
865
  // Use native innerHTML for better performance
666
866
  target[0].innerHTML = html.join('');
667
- // Second pass: initialize special attributes and events
668
- for (const [tid, tagData] of Object.entries(tagElements)) {
669
- // Use native querySelector for better performance
670
- const el = target[0].querySelector(`[data-tid="${tid}"]`);
671
- if (el) {
672
- const element = $(el);
673
- 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) {
674
878
  // Bind to the component whose template wrote this element, which for
675
879
  // spliced content is not the component being rendered.
676
- apply_attributes(element, tagData.attrs, tagData.context);
880
+ apply_attributes($(el), tagData.attrs, tagData.context);
677
881
  }
678
882
  }
679
883
  // Third pass: initialize and boot components in parallel
680
884
  // Like v1, all sibling components at this level boot simultaneously
681
885
  // DO NOT await - let children boot in background while parent continues
682
- for (const [cid, compData] of Object.entries(components)) {
683
- // Use native querySelector for better performance
684
- const el = target[0].querySelector(`[data-cid="${cid}"]`);
685
- if (el) {
686
- const element = $(el);
687
- 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) {
688
890
  // Boot this component (which will render and boot its children recursively)
689
891
  // Fire and forget - don't wait for boot to complete
690
- initialize_component(element, compData);
892
+ initialize_component($(el), compData);
691
893
  }
692
894
  }
693
895
  }
@@ -752,21 +954,13 @@ function process_tag_to_html(instruction, html, tagElements, components, context
752
954
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
753
955
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
754
956
  (typeof value === 'string' || typeof value === 'number')) {
755
- if (key === 'id' && tid) {
756
- // Special handling for id attribute - scope to parent component's _cid
757
- // This is for regular id="foo" attributes that need scoping (rare case)
758
- // Most scoping happens via $sid attribute which becomes data-sid
759
- // Don't double-scope if already scoped (contains :)
760
- if (typeof value === 'string' && value.includes(':')) {
761
- html.push(` id="${value}"`);
762
- }
763
- else {
764
- html.push(` id="${value}:${context._cid}"`);
765
- }
766
- }
767
- else {
768
- html.push(` ${key}="${value}"`);
769
- }
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)}"`);
770
964
  }
771
965
  }
772
966
  // Close opening tag
@@ -777,6 +971,16 @@ function process_tag_to_html(instruction, html, tagElements, components, context
777
971
  html.push('>');
778
972
  }
779
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
+ }
780
984
  /**
781
985
  * Process a component instruction to HTML
782
986
  */
@@ -1129,233 +1333,42 @@ async function initialize_component(element, compData) {
1129
1333
  if (contentFn) {
1130
1334
  options._innerhtml_function = contentFn;
1131
1335
  }
1132
- // Pass named slots if provided
1133
- if (slots) {
1134
- options._slots = slots;
1135
- }
1136
- // Always pass the invocation name: it is what component_name() reports, it
1137
- // selects the template when the class is inherited via an extends chain or
1138
- // is the bare Jqhtml_Component, and it is the class the element carries.
1139
- options._component_name = name;
1140
- // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1141
- // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1142
- if (props._load_only === true || props['$_load_only'] === true) {
1143
- options._load_only = true;
1144
- }
1145
- if (props._load_render_only === true || props['$_load_render_only'] === true) {
1146
- options._load_render_only = true;
1147
- }
1148
- // Create component instance - element first, then options
1149
- const instance = new ComponentClass(element, options);
1150
- // Set the instantiator (the component that rendered this one in their template)
1151
- instance._instantiator = context;
1152
- // Boot the component immediately - this runs its full lifecycle
1153
- await instance._boot();
1154
- }
1155
- /**
1156
- * Utility to extract slots from instructions
1157
- */
1158
- function extract_slots(instructions) {
1159
- const slots = {};
1160
- for (const instruction of instructions) {
1161
- if (typeof instruction === 'object' && 'slot' in instruction) {
1162
- const [name] = instruction.slot;
1163
- slots[name] = instruction;
1164
- }
1165
- }
1166
- return slots;
1167
- }
1168
-
1169
- /**
1170
- * JQHTML Debug Module
1171
- *
1172
- * Provides comprehensive debugging capabilities for JQHTML components
1173
- */
1174
- // Global debug state
1175
- let performanceMetrics = new Map();
1176
- /**
1177
- * Development warning helper
1178
- * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
1179
- */
1180
- function devWarn(message) {
1181
- // Check if warnings are suppressed
1182
- if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
1183
- return;
1184
- }
1185
- // Check if in production mode
1186
- if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
1187
- return;
1188
- }
1189
- console.warn(`[JQHTML Dev Warning] ${message}`);
1190
- }
1191
- // Get global jqhtml object
1192
- function getJqhtml() {
1193
- if (typeof window !== 'undefined' && window.jqhtml) {
1194
- return window.jqhtml;
1195
- }
1196
- // Fallback: try to get from global if available
1197
- if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
1198
- return globalThis.jqhtml;
1199
- }
1200
- throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
1201
- 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
1202
- }
1203
- // Visual flash effect
1204
- function flashComponent(component, eventType) {
1205
- const jqhtml = getJqhtml();
1206
- if (!jqhtml?.debug?.flashComponents)
1207
- return;
1208
- const duration = jqhtml.debug.flashDuration || 500;
1209
- const colors = jqhtml.debug.flashColors || {};
1210
- const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
1211
- eventType === 'render' ? '#27ae60' :
1212
- '#9b59b6');
1213
- // Store original border
1214
- const originalBorder = component.$.css('border');
1215
- // Apply flash border
1216
- component.$.css({
1217
- 'border': `2px solid ${color}`,
1218
- 'transition': `border ${duration}ms ease-out`
1219
- });
1220
- // Remove after duration
1221
- setTimeout(() => {
1222
- component.$.css('border', originalBorder || '');
1223
- }, duration);
1224
- }
1225
- // Log lifecycle event
1226
- function logLifecycle(component, phase, status) {
1227
- const jqhtml = getJqhtml();
1228
- if (!jqhtml?.debug)
1229
- return;
1230
- const shouldLog = jqhtml.debug.logFullLifecycle ||
1231
- (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
1232
- if (!shouldLog)
1233
- return;
1234
- const componentName = component.constructor.name;
1235
- const timestamp = new Date().toISOString();
1236
- const prefix = `[JQHTML ${timestamp}]`;
1237
- if (status === 'start') {
1238
- console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
1239
- // Start performance tracking
1240
- if (jqhtml.debug.profilePerformance) {
1241
- performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
1242
- }
1243
- }
1244
- else {
1245
- let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
1246
- // Add performance data
1247
- if (jqhtml.debug.profilePerformance) {
1248
- const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
1249
- if (startTime) {
1250
- const duration = Date.now() - startTime;
1251
- message += ` (${duration}ms)`;
1252
- // Highlight slow renders
1253
- if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
1254
- duration > jqhtml.debug.highlightSlowRenders) {
1255
- console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
1256
- component.$.css('outline', '2px dashed red');
1257
- }
1258
- }
1259
- }
1260
- console.log(message);
1261
- // Visual feedback
1262
- if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
1263
- flashComponent(component, phase);
1264
- }
1265
- }
1266
- // Update component tree if enabled
1267
- if (jqhtml.debug.showComponentTree) {
1268
- updateComponentTree();
1269
- }
1270
- }
1271
- // Apply delays based on lifecycle phase
1272
- function applyDebugDelay(phase) {
1273
- const jqhtml = getJqhtml();
1274
- if (!jqhtml?.debug)
1275
- return;
1276
- let delayMs = 0;
1277
- switch (phase) {
1278
- case 'component':
1279
- delayMs = jqhtml.debug.delayAfterComponent || 0;
1280
- break;
1281
- case 'render':
1282
- delayMs = jqhtml.debug.delayAfterRender || 0;
1283
- break;
1284
- case 'rerender':
1285
- delayMs = jqhtml.debug.delayAfterRerender || 0;
1286
- break;
1287
- }
1288
- if (delayMs > 0) {
1289
- console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
1290
- }
1291
- }
1292
- // Log instruction processing
1293
- function logInstruction(type, data) {
1294
- const jqhtml = getJqhtml();
1295
- if (!jqhtml?.debug?.logInstructionProcessing)
1296
- return;
1297
- console.log(`[JQHTML Instruction] ${type}:`, data);
1298
- }
1299
- // Log data changes
1300
- function logDataChange(component, property, oldValue, newValue) {
1301
- const jqhtml = getJqhtml();
1302
- if (!jqhtml?.debug?.traceDataFlow)
1303
- return;
1304
- console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
1305
- }
1306
- // Update component tree visualization
1307
- function updateComponentTree() {
1308
- // This would update a debug panel if implemented
1309
- // For now, just log the tree structure periodically
1310
- console.log('[JQHTML Tree] Component hierarchy updated');
1311
- }
1312
- // Router dispatch logging
1313
- function logDispatch(url, route, params, verbose = false) {
1314
- const jqhtml = getJqhtml();
1315
- if (!jqhtml?.debug)
1316
- return;
1317
- const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
1318
- if (!shouldLog)
1319
- return;
1320
- const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
1321
- if (isVerbose) {
1322
- console.group(`[JQHTML Router] Dispatching: ${url}`);
1323
- console.log('Matched route:', route);
1324
- console.log('Extracted params:', params);
1325
- console.log('Route component:', route.component);
1326
- console.log('Route layout:', route.layout);
1327
- console.log('Route meta:', route.meta);
1328
- console.groupEnd();
1329
- }
1330
- else {
1331
- console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
1336
+ // Pass named slots if provided
1337
+ if (slots) {
1338
+ options._slots = slots;
1332
1339
  }
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;
1344
+ // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1345
+ // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1346
+ if (props._load_only === true || props['$_load_only'] === true) {
1347
+ options._load_only = true;
1348
+ }
1349
+ if (props._load_render_only === true || props['$_load_render_only'] === true) {
1350
+ options._load_render_only = true;
1351
+ }
1352
+ // Create component instance - element first, then options
1353
+ const instance = new ComponentClass(element, options);
1354
+ // Set the instantiator (the component that rendered this one in their template)
1355
+ instance._instantiator = context;
1356
+ // Boot the component immediately - this runs its full lifecycle
1357
+ await instance._boot();
1333
1358
  }
1334
- // Check if sequential processing is enabled
1335
- function isSequentialProcessing() {
1336
- const jqhtml = getJqhtml();
1337
- return jqhtml?.debug?.sequentialProcessing || false;
1338
- }
1339
- // Error handling with break on error
1340
- function handleComponentError(component, phase, error) {
1341
- const jqhtml = getJqhtml();
1342
- console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
1343
- if (jqhtml?.debug?.breakOnError) {
1344
- 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
+ }
1345
1369
  }
1370
+ return slots;
1346
1371
  }
1347
- // Additional debug suggestions that could be implemented:
1348
- //
1349
- // 1. Component Inspector - Click on any component to see its data/args/state
1350
- // 2. Time Travel Debugging - Record state changes and replay them
1351
- // 3. Network Request Tracking - Log all AJAX calls made during load()
1352
- // 4. Memory Leak Detection - Track component creation/destruction
1353
- // 5. Template Compilation Debugging - Show compiled template functions
1354
- // 6. Event Flow Visualization - Show event bubbling through components
1355
- // 7. Dependency Graph - Show which components depend on which data
1356
- // 8. Hot Reload Support - Reload components without losing state
1357
- // 9. Performance Budgets - Warn when components exceed size/time limits
1358
- // 10. Accessibility Auditing - Check for missing ARIA attributes
1359
1372
 
1360
1373
  /**
1361
1374
  * JQHTML Cache Key Serializer
@@ -1624,15 +1637,12 @@ class Load_Coordinator {
1624
1637
  /**
1625
1638
  * Check if a component should execute on_load() or wait for existing request
1626
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.
1627
1644
  */
1628
- static should_execute_on_load(component) {
1629
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1630
- // No identity means no coordination. Without this, every un-keyable component
1631
- // would share the single `null` registry entry and adopt an unrelated
1632
- // component's data - the worst failure this system can produce.
1633
- if (key === null) {
1634
- return true;
1635
- }
1645
+ static should_execute_on_load(component, key) {
1636
1646
  const entry = this._registry.get(key);
1637
1647
  if (!entry) {
1638
1648
  // No existing request - this component becomes the leader
@@ -1651,26 +1661,27 @@ class Load_Coordinator {
1651
1661
  * Register a leader component that will execute on_load()
1652
1662
  * Creates coordination entry and returns a function to call when data is ready
1653
1663
  *
1664
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
1654
1665
  * @returns A function that accepts the final data and completes coordination
1655
1666
  */
1656
- static register_leader(component, on_load_promise) {
1657
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1658
- // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1659
- if (key === null) {
1660
- return () => { };
1661
- }
1662
- // 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
1663
1669
  let resolve_promise;
1664
- const coordination_promise = new Promise((resolve) => {
1670
+ let reject_promise;
1671
+ const coordination_promise = new Promise((resolve, reject) => {
1665
1672
  resolve_promise = resolve;
1673
+ reject_promise = reject;
1666
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(() => { });
1667
1678
  const entry = {
1668
1679
  status: 'loading',
1669
- promise: coordination_promise, // Followers await THIS promise, not on_load_promise
1680
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
1670
1681
  resolve_promise,
1682
+ reject_promise,
1671
1683
  leader_component: component,
1672
1684
  leader_data: null,
1673
- leader_error: null,
1674
1685
  waiting: []
1675
1686
  };
1676
1687
  this._registry.set(key, entry);
@@ -1679,10 +1690,9 @@ class Load_Coordinator {
1679
1690
  }
1680
1691
  /**
1681
1692
  * Get the coordination promise for a follower component
1682
- * Returns a promise that resolves when the leader completes
1693
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
1683
1694
  */
1684
- static get_coordination_promise(component) {
1685
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1695
+ static get_coordination_promise(key) {
1686
1696
  const entry = this._registry.get(key);
1687
1697
  if (!entry || entry.status !== 'loading') {
1688
1698
  return null;
@@ -1694,7 +1704,7 @@ class Load_Coordinator {
1694
1704
  * Stores leader's data, marks entry as completed, and resolves the promise.
1695
1705
  * Followers retrieve the data themselves via get_leader_data().
1696
1706
  *
1697
- * @param key - The coordination key
1707
+ * @param key - The coordination key captured at registration
1698
1708
  * @param leader - The leader component
1699
1709
  * @param final_data - The final data after on_load and normalization
1700
1710
  * @private
@@ -1704,14 +1714,14 @@ class Load_Coordinator {
1704
1714
  if (!entry) {
1705
1715
  return;
1706
1716
  }
1707
- // Store the final data (already normalized by _apply_load_result)
1708
- // 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.
1709
1719
  try {
1710
- entry.leader_data = JSON.parse(JSON.stringify(final_data));
1720
+ entry.leader_data = JSON.stringify(final_data);
1711
1721
  }
1712
1722
  catch (error) {
1713
- // If data can't be serialized, use direct reference (may have issues)
1714
- entry.leader_data = final_data;
1723
+ // Unserializable data cannot be shared; followers load independently.
1724
+ entry.leader_data = null;
1715
1725
  }
1716
1726
  entry.status = 'completed';
1717
1727
  if (window.jqhtml?.debug?.verbose) {
@@ -1719,22 +1729,22 @@ class Load_Coordinator {
1719
1729
  }
1720
1730
  // Resolve the promise - followers waiting on it will now wake up
1721
1731
  entry.resolve_promise();
1722
- // Note: We do NOT delete the entry here anymore.
1723
- // Followers need to retrieve the data after the promise resolves.
1724
- // 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
+ }
1725
1737
  }
1726
1738
  /**
1727
1739
  * Get leader's data for a follower component
1728
1740
  * Called by follower after coordination promise resolves
1729
- * 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
1730
1744
  */
1731
- static get_leader_data(component) {
1732
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1733
- if (!key) {
1734
- return null;
1735
- }
1745
+ static get_leader_data(component, key) {
1736
1746
  const entry = this._registry.get(key);
1737
- if (!entry || entry.status !== 'completed') {
1747
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1738
1748
  return null;
1739
1749
  }
1740
1750
  // Remove this follower from the waiting list
@@ -1742,6 +1752,8 @@ class Load_Coordinator {
1742
1752
  if (follower_index !== -1) {
1743
1753
  entry.waiting.splice(follower_index, 1);
1744
1754
  }
1755
+ // One parse per follower: each gets its OWN object, never a shared reference.
1756
+ const data = JSON.parse(entry.leader_data);
1745
1757
  if (window.jqhtml?.debug?.verbose) {
1746
1758
  console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1747
1759
  }
@@ -1752,29 +1764,25 @@ class Load_Coordinator {
1752
1764
  console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1753
1765
  }
1754
1766
  }
1755
- return entry.leader_data;
1767
+ return data;
1756
1768
  }
1757
1769
  /**
1758
1770
  * Handle leader on_load() error
1759
- * 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
1760
1774
  */
1761
- static handle_leader_error(component, error) {
1762
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1775
+ static handle_leader_error(key, error) {
1763
1776
  const entry = this._registry.get(key);
1764
1777
  if (!entry) {
1765
1778
  return;
1766
1779
  }
1767
- entry.leader_error = error;
1768
1780
  entry.status = 'failed';
1769
- console.error(`[Load Coordinator] Leader ${component._cid} on_load() failed for key: ${key}`, error);
1770
- // Propagate error to all followers
1771
- // Note: Followers will handle errors the same way as if their own on_load() failed
1772
- // This is transparent to the developer
1773
- for (const follower of entry.waiting) {
1774
- console.error(`[Load Coordinator] Follower ${follower._cid} failed due to leader error`, error);
1775
- // The follower's lifecycle will handle the error naturally
1776
- // as the promise rejection will propagate
1777
- }
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);
1778
1786
  // Clear coordination entry so future requests can retry
1779
1787
  this._registry.delete(key);
1780
1788
  if (window.jqhtml?.debug?.verbose) {
@@ -1873,6 +1881,31 @@ Load_Coordinator._registry = new Map();
1873
1881
  * - Transient UI state (convenience, not required)
1874
1882
  *
1875
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
+ * ---------------------------------------------------------------------------
1876
1909
  */
1877
1910
  // ============================================================================
1878
1911
  // Class Registry for Serialization
@@ -1881,7 +1914,7 @@ Load_Coordinator._registry = new Map();
1881
1914
  const class_registry = Object.create(null);
1882
1915
  // Markers for serialized class instances (unique to avoid collisions with user data)
1883
1916
  // Replaced at build time by rollup (see rollup.config.js).
1884
- const CORE_VERSION = '2.3.67';
1917
+ const CORE_VERSION = '2.3.70';
1885
1918
  const CLASS_MARKER = '__jqhtml_class__';
1886
1919
  const PROPS_MARKER = '__jqhtml_props__';
1887
1920
  /**
@@ -1897,21 +1930,20 @@ function register_cache_class(klass) {
1897
1930
  }
1898
1931
  class_registry[klass.name] = klass;
1899
1932
  }
1900
- // ============================================================================
1901
- // Serialization (Object → String)
1902
- // ============================================================================
1903
1933
  /**
1904
1934
  * Serialize a value to JSON string, handling ES6 class instances recursively.
1905
1935
  * Returns null if serialization fails for any reason.
1906
1936
  *
1907
1937
  * @param value - The value to serialize
1908
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
1909
1941
  * @returns Serialized JSON string, or null if serialization failed
1910
1942
  */
1911
- function serialize_value(value, verbose) {
1943
+ function serialize_value(value, verbose, path = '', collector) {
1912
1944
  try {
1913
1945
  const seen = new WeakSet();
1914
- const processed = process_for_serialization(value, verbose, seen);
1946
+ const processed = process_for_serialization(value, verbose, seen, path, collector);
1915
1947
  if (processed === undefined) {
1916
1948
  // Serialization failed - undefined signals failure
1917
1949
  return null;
@@ -1927,14 +1959,23 @@ function serialize_value(value, verbose) {
1927
1959
  }
1928
1960
  /**
1929
1961
  * Recursively process a value for serialization.
1930
- * 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.
1931
1970
  *
1932
1971
  * @param value - The value to process
1933
1972
  * @param verbose - Whether to log warnings
1934
1973
  * @param seen - WeakSet to detect circular references
1935
- * @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
1936
1977
  */
1937
- function process_for_serialization(value, verbose, seen) {
1978
+ function process_for_serialization(value, verbose, seen, path = '', collector) {
1938
1979
  // Handle null and undefined
1939
1980
  if (value === null) {
1940
1981
  return null;
@@ -1949,12 +1990,44 @@ function process_for_serialization(value, verbose, seen) {
1949
1990
  return value;
1950
1991
  }
1951
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
+ }
1952
2002
  if (verbose) {
1953
2003
  console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
1954
2004
  }
1955
2005
  // Return undefined to omit this value (not fail entire serialization)
1956
2006
  return undefined;
1957
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
+ }
1958
2031
  // Handle circular references.
1959
2032
  // `seen` tracks only the current ancestor path (the DFS stack): an object is
1960
2033
  // added on the way down and removed on the way back up (see finally below).
@@ -1963,10 +2036,11 @@ function process_for_serialization(value, verbose, seen) {
1963
2036
  // JSON round-trip semantics do not preserve identity anyway, so emitting a
1964
2037
  // copy per reference is correct; silently omitting the property is not.
1965
2038
  if (seen.has(value)) {
2039
+ collector?.(path, 'circular');
1966
2040
  if (verbose) {
1967
- console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
2041
+ console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
1968
2042
  }
1969
- return undefined; // Fail entire serialization
2043
+ return undefined; // Cut the cycle here; the rest of the tree still serializes
1970
2044
  }
1971
2045
  seen.add(value);
1972
2046
  try {
@@ -1975,7 +2049,7 @@ function process_for_serialization(value, verbose, seen) {
1975
2049
  const result = [];
1976
2050
  for (let i = 0; i < value.length; i++) {
1977
2051
  const item = value[i];
1978
- const processed = process_for_serialization(item, verbose, seen);
2052
+ const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
1979
2053
  // For arrays, we keep undefined as null to preserve indices
1980
2054
  result.push(processed === undefined ? null : processed);
1981
2055
  }
@@ -1992,8 +2066,9 @@ function process_for_serialization(value, verbose, seen) {
1992
2066
  if (value instanceof Map) {
1993
2067
  const entries = [];
1994
2068
  for (const [k, v] of value) {
1995
- const processedKey = process_for_serialization(k, verbose, seen);
1996
- 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);
1997
2072
  entries.push([processedKey, processedValue]);
1998
2073
  }
1999
2074
  return {
@@ -2004,8 +2079,9 @@ function process_for_serialization(value, verbose, seen) {
2004
2079
  // Handle Set objects
2005
2080
  if (value instanceof Set) {
2006
2081
  const items = [];
2082
+ let index = 0;
2007
2083
  for (const item of value) {
2008
- items.push(process_for_serialization(item, verbose, seen));
2084
+ items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
2009
2085
  }
2010
2086
  return {
2011
2087
  [CLASS_MARKER]: 'Set',
@@ -2016,58 +2092,115 @@ function process_for_serialization(value, verbose, seen) {
2016
2092
  const ctor = value.constructor;
2017
2093
  // Handle registered class instances
2018
2094
  if (ctor && ctor.name && class_registry[ctor.name]) {
2019
- const props = {};
2020
- // Extract own enumerable properties (bypass any toJSON method)
2021
- for (const key of Object.keys(value)) {
2022
- const propValue = value[key];
2023
- const processed = process_for_serialization(propValue, verbose, seen);
2024
- // Only include if not undefined (failed serialization)
2025
- if (processed !== undefined || propValue === undefined) {
2026
- props[key] = processed;
2027
- }
2028
- }
2029
2095
  return {
2030
2096
  [CLASS_MARKER]: ctor.name,
2031
- [PROPS_MARKER]: props
2097
+ [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2032
2098
  };
2033
2099
  }
2034
2100
  // Handle plain objects (constructor is Object or no constructor)
2035
2101
  if (ctor === Object || ctor === undefined || ctor === null) {
2036
- const result = {};
2037
- for (const key of Object.keys(value)) {
2038
- const propValue = value[key];
2039
- const processed = process_for_serialization(propValue, verbose, seen);
2040
- // Only include if not undefined (unless original was undefined)
2041
- if (processed !== undefined || propValue === undefined) {
2042
- result[key] = processed;
2043
- }
2044
- }
2045
- return result;
2102
+ return process_own_properties(value, verbose, seen, path, collector);
2046
2103
  }
2047
- // Unregistered class instance - convert to plain object for hot/cold parity
2048
- // This ensures developers catch missing class registrations during development
2049
- // (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);
2050
2109
  if (verbose) {
2051
2110
  console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2052
2111
  `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2053
2112
  }
2054
- // Convert to plain object - properties are preserved, prototype methods are lost
2055
- const result = {};
2056
- for (const key of Object.keys(value)) {
2057
- const propValue = value[key];
2058
- const processed = process_for_serialization(propValue, verbose, seen);
2059
- // Only include if not undefined (unless original was undefined)
2060
- if (processed !== undefined || propValue === undefined) {
2061
- result[key] = processed;
2062
- }
2063
- }
2064
- return result;
2113
+ return process_own_properties(value, verbose, seen, path, collector);
2065
2114
  }
2066
2115
  finally {
2067
2116
  // Pop this object off the ancestor path now that its subtree is done.
2068
2117
  seen.delete(value);
2069
2118
  }
2070
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
+ }
2071
2204
  // ============================================================================
2072
2205
  // Deserialization (String → Object)
2073
2206
  // ============================================================================
@@ -2411,36 +2544,36 @@ class Jqhtml_Local_Storage {
2411
2544
  /**
2412
2545
  * Perform a serialize/deserialize round-trip on a value.
2413
2546
  *
2414
- * This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
2415
- * (restored from cache). Unregistered class instances will be converted to plain
2416
- * objects, exactly as they would be if restored from cache.
2417
- *
2418
- * Use this to normalize data after on_load() so developers catch missing class
2419
- * 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.
2420
2552
  *
2421
2553
  * @param {any} value - The value to normalize
2422
- * @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.
2423
2557
  */
2424
- static normalize_for_cache(value) {
2558
+ static normalize_for_cache(value, component_name) {
2425
2559
  const verbose = this._is_verbose();
2560
+ const collector = make_strip_collector(component_name);
2426
2561
  // Serialize to JSON string
2427
- const serialized = serialize_value(value, verbose);
2562
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2428
2563
  if (serialized === null) {
2429
- // Serialization failed completely - return original
2430
- // (This happens with circular references or other fatal issues)
2431
- if (verbose) {
2432
- console.warn('[JQHTML Cache] normalize_for_cache: Serialization failed, returning original value');
2433
- }
2434
- 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 {};
2435
2568
  }
2436
2569
  // Deserialize back to object
2437
2570
  const deserialized = deserialize_value(serialized, verbose);
2438
- if (deserialized === null) {
2439
- // Deserialization failed - return original
2440
- if (verbose) {
2441
- console.warn('[JQHTML Cache] normalize_for_cache: Deserialization failed, returning original value');
2442
- }
2443
- 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 {};
2444
2577
  }
2445
2578
  return deserialized;
2446
2579
  }
@@ -2463,7 +2596,10 @@ class Jqhtml_Local_Storage {
2463
2596
  console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2464
2597
  // Clear only JQHTML keys and retry once
2465
2598
  this._clear_jqhtml_keys();
2466
- 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);
2467
2603
  try {
2468
2604
  localStorage.setItem(scoped_key, serialized);
2469
2605
  }
@@ -2504,17 +2640,22 @@ Jqhtml_Local_Storage._initialized = false;
2504
2640
  * Extracted from component.ts - handles lifecycle event registration,
2505
2641
  * triggering, and state tracking.
2506
2642
  *
2507
- * Events have "sticky" semantics for lifecycle events: if an event has already
2508
- * 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.
2509
2648
  */
2510
2649
  /**
2511
2650
  * Register a callback for an event.
2512
2651
  *
2513
- * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2514
- * If a lifecycle event has already occurred, the callback fires immediately
2515
- * 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.
2516
2655
  *
2517
- * 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').
2518
2659
  *
2519
2660
  * @param component - The component instance
2520
2661
  * @param event_name - Name of the event
@@ -2646,6 +2787,20 @@ function event_invalidate(component, event_name) {
2646
2787
  * - Data freeze/unfreeze enforcement via Proxy
2647
2788
  * - Detached on_load() execution with restricted access
2648
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
+ }
2649
2804
  /**
2650
2805
  * Set up the `this.data` property on a component using Object.defineProperty
2651
2806
  * with a Proxy that enforces freeze/unfreeze semantics.
@@ -2659,9 +2814,78 @@ function event_invalidate(component, event_name) {
2659
2814
  */
2660
2815
  function setup_data_property(component) {
2661
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
+ };
2662
2875
  // Helper to create frozen proxy for data object
2663
2876
  const create_proxy = (obj) => {
2664
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
+ },
2665
2889
  set: (target, prop, value) => {
2666
2890
  if (component.__data_frozen) {
2667
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` +
@@ -2731,10 +2955,13 @@ function setup_data_property(component) {
2731
2955
  * All other property access (this.$, this.$sid, etc.) throws errors.
2732
2956
  *
2733
2957
  * @param component - The component instance
2734
- * @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.
2735
2962
  * @returns The resulting data and optional coordination completion function
2736
2963
  */
2737
- async function execute_on_load_detached(component, use_load_coordinator = false) {
2964
+ async function execute_on_load_detached(component, dedup_key = null) {
2738
2965
  // Clone this.data from the snapshot captured after on_create()
2739
2966
  const data_clone = component.__initial_data_snapshot
2740
2967
  ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
@@ -2742,14 +2969,23 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2742
2969
  // Create a read-only proxy for args that blocks all modifications
2743
2970
  // Can't use JSON clone because args may contain functions (_slots, callbacks)
2744
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();
2745
2977
  const create_readonly_proxy = (obj, path = 'this.args') => {
2746
2978
  if (obj === null || typeof obj !== 'object')
2747
2979
  return obj;
2748
- return new Proxy(obj, {
2980
+ const existing = readonly_wrappers.get(obj);
2981
+ if (existing)
2982
+ return existing;
2983
+ const proxy = new Proxy(obj, {
2749
2984
  get(target, prop) {
2750
2985
  const value = target[prop];
2751
- // Recursively wrap nested objects (but not functions)
2752
- 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') {
2753
2989
  return create_readonly_proxy(value, `${path}.${String(prop)}`);
2754
2990
  }
2755
2991
  return value;
@@ -2771,6 +3007,8 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2771
3007
  `this.args is read-only in on_load().`);
2772
3008
  }
2773
3009
  });
3010
+ readonly_wrappers.set(obj, proxy);
3011
+ return proxy;
2774
3012
  };
2775
3013
  // Create a detached context object that on_load will operate on
2776
3014
  const detached_context = {
@@ -2830,27 +3068,22 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2830
3068
  `Only this.data can be modified in on_load().`);
2831
3069
  }
2832
3070
  });
2833
- // Create promise for this on_load() call
2834
- const on_load_promise = (async () => {
2835
- try {
2836
- await component._call_lifecycle('on_load', restricted_this);
2837
- }
2838
- catch (error) {
2839
- if (use_load_coordinator) {
2840
- // Handle error and notify coordinator
2841
- Load_Coordinator.handle_leader_error(component, error);
2842
- }
2843
- throw error;
2844
- }
2845
- })();
2846
- // If using Load_Coordinator, register as leader
2847
- // Note: We store the completion function but DON'T call it here
2848
- // 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.
2849
3074
  let complete_coordination = null;
2850
- if (use_load_coordinator) {
2851
- 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;
2852
3086
  }
2853
- await on_load_promise;
2854
3087
  // Note: We don't validate args changes here because external code (like parent
2855
3088
  // components calling reload()) is allowed to modify component.args during on_load().
2856
3089
  // The read-only proxy only prevents code INSIDE on_load() from modifying args.
@@ -2952,6 +3185,21 @@ function warn_uncacheable_component(component, uncacheable_property, uncacheable
2952
3185
  all_args: component.args ? Object.keys(component.args) : [],
2953
3186
  });
2954
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
+ }
2955
3203
  /**
2956
3204
  * Read cache during create() phase.
2957
3205
  * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
@@ -2983,11 +3231,11 @@ function read_cache_in_create(component) {
2983
3231
  if (cache_mode === 'html') {
2984
3232
  // HTML cache mode - check for cached HTML to inject on first render
2985
3233
  const html_cache_key = `${cache_key}::html`;
2986
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2987
- if (cached_html !== null && typeof cached_html === 'string') {
3234
+ const cached_html = read_html_snapshot(html_cache_key);
3235
+ if (cached_html !== null) {
2988
3236
  component._cached_html = cached_html;
2989
3237
  if (window.jqhtml?.debug?.verbose) {
2990
- 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 });
2991
3239
  }
2992
3240
  }
2993
3241
  else {
@@ -3044,10 +3292,10 @@ function check_cache_on_reload(component) {
3044
3292
  if (cache_mode === 'html') {
3045
3293
  // HTML cache mode - check for cached HTML
3046
3294
  const html_cache_key = `${cache_key}::html`;
3047
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3048
- if (cached_html !== null && typeof cached_html === 'string') {
3295
+ const cached_html = read_html_snapshot(html_cache_key);
3296
+ if (cached_html !== null) {
3049
3297
  if (window.jqhtml?.debug?.verbose) {
3050
- 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 });
3051
3299
  }
3052
3300
  component._cached_html = cached_html;
3053
3301
  // Call _render() directly (not render()) since _reload() manages the full lifecycle
@@ -3088,7 +3336,11 @@ function write_html_cache_snapshot(component) {
3088
3336
  component._should_cache_html_after_ready = false;
3089
3337
  const html = component.$.html();
3090
3338
  const html_cache_key = `${component._cache_key}::html`;
3091
- 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 });
3092
3344
  if (window.jqhtml?.debug?.verbose) {
3093
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 });
3094
3346
  }
@@ -3129,277 +3381,27 @@ function write_cache_on_loaded(component, data_changed) {
3129
3381
  }
3130
3382
 
3131
3383
  /**
3132
- * JQHTML v2 Template Renderer
3133
- *
3134
- * Connects compiled templates to components
3135
- * Processes instruction arrays and handles bindings
3136
- */
3137
- /**
3138
- * Process slot-only template inheritance
3139
- *
3140
- * When a component's template returns {_slots: {...}}, walk the prototype
3141
- * chain to find a parent template and invoke it with the child's slots as content.
3384
+ * HTML escaping for interpolated text.
3142
3385
  *
3143
- * @param component The component instance
3144
- * @param childSlots The slots object from the child template
3145
- * @returns [instructions, context] tuple or null if no parent found
3146
- */
3147
- async function process_slot_inheritance(component, childSlots) {
3148
- // Walk the prototype chain to find parent class with template
3149
- let currentClass = Object.getPrototypeOf(component.constructor);
3150
- console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
3151
- while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
3152
- const className = currentClass.name;
3153
- console.log(`[JQHTML] Checking parent class: ${className}`);
3154
- // Skip internal framework classes
3155
- if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
3156
- currentClass = Object.getPrototypeOf(currentClass);
3157
- continue;
3158
- }
3159
- // Try to find template for this parent class
3160
- try {
3161
- const parentTemplate = get_template(className);
3162
- console.log(`[JQHTML] Template found for ${className}:`, parentTemplate ? parentTemplate.name : 'null');
3163
- // Check if we got the default template (means no real template found)
3164
- if (parentTemplate && parentTemplate.name !== 'Jqhtml_Component') {
3165
- console.log(`[JQHTML] Invoking parent template ${className}`);
3166
- // Found a real parent template - invoke it with child's slots as content
3167
- const [parentInstructions, parentContext] = parentTemplate.render.call(component, component.data, component.args, childSlots // Pass child slots as content parameter
3168
- );
3169
- // Check if parent also returns slots (recursive inheritance)
3170
- if (parentInstructions && typeof parentInstructions === 'object' && parentInstructions._slots) {
3171
- // Parent also has slot-only template - recurse
3172
- console.log(`[JQHTML] Parent also slot-only, recursing`);
3173
- return await process_slot_inheritance(component, parentInstructions._slots);
3174
- }
3175
- // Parent returned HTML - we're done
3176
- console.log(`[JQHTML] Parent returned instructions, inheritance complete`);
3177
- return [parentInstructions, parentContext];
3178
- }
3179
- }
3180
- catch (error) {
3181
- console.warn(`[JQHTML] Error looking up parent template for ${className}:`, error);
3182
- }
3183
- // Move to next parent
3184
- currentClass = Object.getPrototypeOf(currentClass);
3185
- }
3186
- // No parent template found
3187
- console.warn(`[JQHTML] No parent template found after walking chain`);
3188
- return null;
3189
- }
3190
- /**
3191
- * Render a template for a component
3192
- * Templates are functions that return [instructions, context] tuples
3193
- */
3194
- async function render_template(component, template_fn) {
3195
- // Get template from registry if not provided
3196
- let render_fn = template_fn;
3197
- if (!render_fn) {
3198
- const template_def = get_template_by_class(component.constructor);
3199
- render_fn = template_def.render;
3200
- }
3201
- if (!render_fn) {
3202
- // No template, component should handle its own rendering
3203
- return;
3204
- }
3205
- // Clear existing content
3206
- component.$.empty();
3207
- // Execute template function
3208
- // Templates receive: (data, args, content)
3209
- // Provide a default empty content function if none provided
3210
- const defaultContent = () => '';
3211
- let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
3212
- );
3213
- // Check for slot-only template inheritance
3214
- // If instructions is {_slots: {...}}, this is a slot-only template
3215
- // We need to find the parent template and invoke it with these slots
3216
- if (instructions && typeof instructions === 'object' && instructions._slots) {
3217
- console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
3218
- const result = await process_slot_inheritance(component, instructions._slots);
3219
- if (result) {
3220
- console.log(`[JQHTML] Parent template found, using parent instructions`);
3221
- instructions = result[0];
3222
- context = result[1];
3223
- }
3224
- else {
3225
- console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
3226
- // No parent template found - return empty
3227
- instructions = [];
3228
- }
3229
- }
3230
- // Process instructions to generate DOM
3231
- await process_instructions(instructions, component.$, component);
3232
- // Process bindings after DOM is created
3233
- await process_bindings(component);
3234
- // Attach event handlers
3235
- await attach_event_handlers(component);
3236
- }
3237
- /**
3238
- * Process data bindings (attributes created by :prop syntax)
3239
- */
3240
- async function process_bindings(component) {
3241
- // Find all elements with data-bind-* attributes
3242
- component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
3243
- const el = $(element);
3244
- const attrs = element.attributes;
3245
- for (let i = 0; i < attrs.length; i++) {
3246
- const attr = attrs[i];
3247
- if (attr.name.startsWith('data-bind-')) {
3248
- const binding_type = attr.name.substring(10); // Remove 'data-bind-'
3249
- const expression = attr.value;
3250
- try {
3251
- // Evaluate expression in component context
3252
- const value = evaluate_expression(expression, component);
3253
- // Apply binding based on type
3254
- switch (binding_type) {
3255
- case 'prop':
3256
- // Generic property binding
3257
- const prop_name = el.attr('data-bind-prop-name') || 'value';
3258
- el.prop(prop_name, value);
3259
- break;
3260
- case 'value':
3261
- el.val(value);
3262
- break;
3263
- case 'text':
3264
- el.text(value);
3265
- break;
3266
- case 'html':
3267
- el.html(value);
3268
- break;
3269
- case 'class':
3270
- if (typeof value === 'object') {
3271
- // Object syntax: {active: true, disabled: false}
3272
- Object.entries(value).forEach(([className, enabled]) => {
3273
- el.toggleClass(className, !!enabled);
3274
- });
3275
- }
3276
- else {
3277
- // String syntax
3278
- el.addClass(String(value));
3279
- }
3280
- break;
3281
- case 'style':
3282
- if (typeof value === 'object') {
3283
- el.css(value);
3284
- }
3285
- else {
3286
- el.attr('style', String(value));
3287
- }
3288
- break;
3289
- default:
3290
- // Custom binding - set as attribute
3291
- el.attr(binding_type, value);
3292
- }
3293
- }
3294
- catch (error) {
3295
- console.error(`Error evaluating binding "${expression}":`, error);
3296
- }
3297
- }
3298
- }
3299
- });
3300
- }
3301
- /**
3302
- * Attach event handlers (attributes created by @event syntax)
3303
- */
3304
- async function attach_event_handlers(component) {
3305
- // Find all elements with data-on-* attributes
3306
- 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) => {
3307
- const el = $(element);
3308
- const attrs = element.attributes;
3309
- for (let i = 0; i < attrs.length; i++) {
3310
- const attr = attrs[i];
3311
- if (attr.name.startsWith('data-on-')) {
3312
- const event_name = attr.name.substring(8); // Remove 'data-on-'
3313
- const handler_expr = attr.value;
3314
- // Remove the attribute to avoid re-processing
3315
- el.removeAttr(attr.name);
3316
- // Attach event handler
3317
- el.on(event_name, function (event) {
3318
- try {
3319
- // Create handler function in component context
3320
- const handler = evaluate_handler(handler_expr, component);
3321
- if (typeof handler === 'function') {
3322
- // Call with component as 'this' and pass event
3323
- handler.call(component, event);
3324
- }
3325
- else {
3326
- // Expression to execute
3327
- evaluate_expression(handler_expr, component, { $event: event });
3328
- }
3329
- }
3330
- catch (error) {
3331
- console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
3332
- }
3333
- });
3334
- }
3335
- }
3336
- });
3337
- }
3338
- /**
3339
- * Evaluate an expression in component context
3340
- */
3341
- function evaluate_expression(expression, component, locals = {}) {
3342
- // Create evaluation context
3343
- const context = {
3344
- // Component properties
3345
- data: component.data,
3346
- args: component.args,
3347
- $: component.$,
3348
- // Component methods
3349
- $sid: component.$sid.bind(component),
3350
- // Locals (like $event)
3351
- ...locals
3352
- };
3353
- // Build function that evaluates in context
3354
- const keys = Object.keys(context);
3355
- const values = Object.values(context);
3356
- try {
3357
- // Use Function constructor for better performance than eval
3358
- const fn = new Function(...keys, `return (${expression})`);
3359
- return fn(...values);
3360
- }
3361
- catch (error) {
3362
- console.error(`Invalid expression: ${expression}`, error);
3363
- return undefined;
3364
- }
3365
- }
3366
- /**
3367
- * Evaluate a handler expression (might be method name or inline code)
3368
- */
3369
- function evaluate_handler(expression, component) {
3370
- // Check if it's a method name on the component
3371
- if (expression in component && typeof component[expression] === 'function') {
3372
- return component[expression];
3373
- }
3374
- // Otherwise treat as inline code
3375
- try {
3376
- return new Function('$event', `
3377
- const { data, args, $, emit, $sid } = this;
3378
- ${expression}
3379
- `).bind(component);
3380
- }
3381
- catch (error) {
3382
- console.error(`Invalid handler: ${expression}`, error);
3383
- return null;
3384
- }
3385
- }
3386
- /**
3387
- * Helper to escape HTML for safe output
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.
3388
3391
  */
3389
- function escape_html(str) {
3390
- const div = document.createElement('div');
3391
- div.textContent = str;
3392
- return div.innerHTML;
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;
3393
3401
  }
3394
- /**
3395
- * Helper to escape HTML and convert newlines to <br /> tags
3396
- * Used by <%br= %> syntax for displaying user-entered text with line breaks
3397
- */
3398
- function escape_html_nl2br(str) {
3399
- const div = document.createElement('div');
3400
- div.textContent = str;
3401
- // Escape HTML first, then replace newlines with <br />
3402
- return div.innerHTML.replace(/\n/g, '<br />');
3402
+ /** Escaped, then newlines become <br /> - the <%br= %> construct. */
3403
+ function escape_html_nl2br(value) {
3404
+ return escape_html(value).replace(/\n/g, '<br />');
3403
3405
  }
3404
3406
 
3405
3407
  /**
@@ -3522,13 +3524,14 @@ function describe(value) {
3522
3524
  /**
3523
3525
  * JQHTML Component Lifecycle Queue
3524
3526
  *
3525
- * Serializes lifecycle operations (render, reload, refresh) per component.
3527
+ * Serializes lifecycle operations (render, reload, refresh, load) per component.
3526
3528
  * Replaces _create_debounced_function with a proper queue that:
3527
3529
  *
3528
3530
  * - At most one operation runs at a time per component
3529
- * - At most one operation is pending (waiting for the current to finish)
3530
- * - Same-type pending operations collapse (fan-in: all callers share one execution)
3531
- * - 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
3532
3535
  *
3533
3536
  * Boot bypasses this queue entirely - it runs the lifecycle directly.
3534
3537
  */
@@ -3536,92 +3539,106 @@ class Component_Queue {
3536
3539
  constructor() {
3537
3540
  /** Currently executing operation */
3538
3541
  this._current = null;
3539
- /** Next operation waiting to execute after current completes */
3540
- 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 = [];
3541
3551
  }
3542
3552
  /**
3543
3553
  * Enqueue a lifecycle operation.
3544
3554
  *
3545
3555
  * Behavior:
3546
3556
  * - Nothing running → execute immediately
3547
- * - Something running, no pending → create pending entry
3548
- * - Something running, same type pending → collapse (share pending promise)
3549
- * - 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.
3550
3563
  *
3551
3564
  * @param type - Operation type ('render', 'reload', 'refresh', 'load')
3552
3565
  * @param executor - The async function to execute
3553
- * @returns Promise that resolves when the operation completes
3566
+ * @returns Promise that settles with the result of the execution that ran
3554
3567
  */
3555
3568
  enqueue(type, executor) {
3556
- // 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.
3557
3573
  if (!this._current) {
3558
- return this._execute(type, executor);
3559
- }
3560
- // Something is running — add to pending
3561
- if (this._pending) {
3562
- if (this._pending.type === type) {
3563
- // Same type pending → collapse (fan-in)
3564
- // All callers share the same future execution
3565
- return new Promise((resolve, reject) => {
3566
- this._pending.resolvers.push(resolve);
3567
- this._pending.rejecters.push(reject);
3568
- });
3569
- }
3570
- else {
3571
- // Different type pending → replace
3572
- // Old pending callers join the new operation
3573
- // (their operation was superseded by a different type)
3574
- const old_resolvers = this._pending.resolvers;
3575
- const old_rejecters = this._pending.rejecters;
3576
- return new Promise((resolve, reject) => {
3577
- this._pending = {
3578
- type,
3579
- executor,
3580
- resolvers: [...old_resolvers, resolve],
3581
- rejecters: [...old_rejecters, reject]
3582
- };
3583
- });
3584
- }
3574
+ return new Promise((resolve, reject) => {
3575
+ this._run({ type, executor, resolvers: [resolve], rejecters: [reject] });
3576
+ });
3585
3577
  }
3586
- // 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.
3587
3592
  return new Promise((resolve, reject) => {
3588
- this._pending = {
3589
- type,
3590
- executor,
3591
- resolvers: [resolve],
3592
- rejecters: [reject]
3593
- };
3593
+ this._pending.push({ type, executor, resolvers: [resolve], rejecters: [reject] });
3594
3594
  });
3595
3595
  }
3596
3596
  /**
3597
- * Execute an operation and handle pending queue after completion.
3597
+ * Run one entry, settle its callers, then start the next pending entry.
3598
3598
  * @private
3599
3599
  */
3600
- _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 };
3601
3605
  const promise = (async () => {
3602
3606
  try {
3603
- 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);
3611
+ }
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);
3604
3616
  }
3605
- finally {
3617
+ })();
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.
3606
3625
  this._current = null;
3607
- // Run pending if exists
3608
- if (this._pending) {
3609
- const pending = this._pending;
3610
- this._pending = null;
3611
- try {
3612
- await this._execute(pending.type, pending.executor);
3613
- for (const resolve of pending.resolvers)
3614
- resolve();
3615
- }
3616
- catch (err) {
3617
- for (const reject of pending.rejecters)
3618
- reject(err);
3619
- }
3620
- }
3626
+ return;
3621
3627
  }
3622
- })();
3623
- this._current = { type, promise };
3624
- return promise;
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
+ });
3625
3642
  }
3626
3643
  /**
3627
3644
  * Check if any operation is currently running.
@@ -3633,7 +3650,7 @@ class Component_Queue {
3633
3650
  * Check if there's a pending operation waiting.
3634
3651
  */
3635
3652
  get has_pending() {
3636
- return this._pending !== null;
3653
+ return this._pending.length > 0;
3637
3654
  }
3638
3655
  }
3639
3656
 
@@ -3684,11 +3701,16 @@ function capture_component_data(component_name, args, data, cache_key) {
3684
3701
  try {
3685
3702
  const cloned_args = JSON.parse(JSON.stringify(args));
3686
3703
  const cloned_data = JSON.parse(JSON.stringify(data));
3687
- _capture_buffer.push({
3704
+ const entry = {
3688
3705
  component: component_name,
3689
3706
  args: cloned_args,
3690
3707
  data: cloned_data,
3691
- });
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);
3692
3714
  }
3693
3715
  catch (error) {
3694
3716
  // Non-serializable data — skip capture for this component
@@ -3734,10 +3756,15 @@ function set_preload_data(entries) {
3734
3756
  return;
3735
3757
  _preload_cache.clear();
3736
3758
  for (const entry of entries) {
3737
- // Generate key using the same algorithm as Load Coordinator
3738
- const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
3739
- if (result.key !== null) {
3740
- _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);
3741
3768
  }
3742
3769
  }
3743
3770
  }
@@ -3850,12 +3877,73 @@ function init_viewport_resize(jQuery) {
3850
3877
  * - Scoped IDs using _cid pattern
3851
3878
  * - Event emission and CSS class hierarchy
3852
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
+ };
3853
3891
  // WeakMap storage for protected lifecycle method implementations (Option 2)
3854
3892
  // See docs/internal/lifecycle-method-protection.md for design details
3855
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
+ }
3856
3943
  class Jqhtml_Component {
3857
3944
  constructor(element, args = {}) {
3858
3945
  this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
3946
+ this._debug_no_data = false; // Set by _reload_without_data(); cleared by any real load
3859
3947
  this._instantiator = null; // Component that instantiated this one
3860
3948
  this._dom_parent = null; // Closest component in DOM tree (for lifecycle)
3861
3949
  this._dom_children = new Set(); // Components in DOM subtree (for lifecycle)
@@ -3877,7 +3965,10 @@ class Jqhtml_Component {
3877
3965
  // Cache mode properties
3878
3966
  this._cache_key = null; // Cache key for caching
3879
3967
  // 'html' mode caching
3880
- this._cached_html = null; // Cached HTML to inject on first render
3968
+ // The snapshot carries the cid it was taken under, not just markup: every scoped id
3969
+ // inside it reads "<name>:<cid>", so replaying it into a different instance requires
3970
+ // knowing which cid was the snapshotting component's own. See _rescope_cached_html().
3971
+ this._cached_html = null; // Cached HTML snapshot to inject on first render
3881
3972
  this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
3882
3973
  this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
3883
3974
  this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
@@ -4121,19 +4212,58 @@ class Jqhtml_Component {
4121
4212
  return child._render(null, options);
4122
4213
  }
4123
4214
  this._log_lifecycle('render', 'start');
4215
+ // --- Shared re-render prologue -------------------------------------------------
4216
+ // Everything below runs for BOTH the cached-HTML path and the template path. The
4217
+ // cached path used to return before it, so a cache-mode reload injected over a live
4218
+ // subtree without ever stopping the old children: their on_stop() never ran (timers
4219
+ // and listeners leaked) and they stayed in _dom_children as dead entries that every
4220
+ // later _wait_for_children_ready() walked.
4221
+ // Determine child-finding strategy: If component is off-DOM, children can't register
4222
+ // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4223
+ // If attached to DOM, children register normally and we can use the fast _dom_children path.
4224
+ // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4225
+ if (!$.contains(document.documentElement, this.$[0])) {
4226
+ this._use_dom_fallback = true;
4227
+ }
4228
+ else {
4229
+ this._use_dom_fallback = false;
4230
+ }
4231
+ // If this is not the first render, stop child components and clear DOM
4232
+ if (this._did_first_render) {
4233
+ // Stop all child components before clearing DOM
4234
+ this.$.find('.Component').each(function () {
4235
+ const child = $(this).data('_component');
4236
+ if (child && !child._stopped) {
4237
+ child._stop(); // Stop just the component, DOM will be cleared next
4238
+ }
4239
+ });
4240
+ // Clear the DOM
4241
+ this.$[0].innerHTML = '';
4242
+ }
4243
+ else {
4244
+ this._did_first_render = true;
4245
+ }
4246
+ // (No removeClass('_Component_Stopped') here: _render() returns above when
4247
+ // _stopped, and nothing ever clears _stopped, so a stopped component can never
4248
+ // reach this line. Clearing the class would only ever hide a real stopped state.)
4249
+ // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4250
+ this._dom_children.clear();
4251
+ // --- End shared re-render prologue ---------------------------------------------
4124
4252
  // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
4125
4253
  if (this._cached_html !== null) {
4126
4254
  if (window.jqhtml?.debug?.verbose) {
4127
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
4128
- }
4129
- // Inject cached HTML directly
4130
- this.$[0].innerHTML = this._cached_html;
4255
+ 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 });
4256
+ }
4257
+ // Inject cached HTML directly, with every scoped id re-scoped to THIS instance.
4258
+ // The snapshot was taken from another component instance (or an earlier life of
4259
+ // this one), so its ids carry that instance's cids verbatim. Injecting them as-is
4260
+ // put duplicate ids in the document, and $sid() - which is getElementById first -
4261
+ // could hand this component the other instance's element.
4262
+ this.$[0].innerHTML = this._rescope_cached_html(this._cached_html);
4131
4263
  // Mark that we used cached HTML (forces re-render after on_load)
4132
4264
  this._used_cached_html = true;
4133
4265
  // Clear cached HTML so next render uses template
4134
4266
  this._cached_html = null;
4135
- // Mark first render complete
4136
- this._did_first_render = true;
4137
4267
  this._log_lifecycle('render', 'complete (cached HTML)');
4138
4268
  // Call on_render() after cache inject (unless suppressed by _load_render_only)
4139
4269
  if (!options.skip_on_render) {
@@ -4156,39 +4286,10 @@ class Jqhtml_Component {
4156
4286
  this._data_on_last_render = JSON.stringify(this.data);
4157
4287
  return current_render_id;
4158
4288
  }
4159
- // Determine child-finding strategy: If component is off-DOM, children can't register
4160
- // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4161
- // If attached to DOM, children register normally and we can use the fast _dom_children path.
4162
- // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4163
- if (!$.contains(document.documentElement, this.$[0])) {
4164
- this._use_dom_fallback = true;
4165
- }
4166
- else {
4167
- this._use_dom_fallback = false;
4168
- }
4169
- // If this is not the first render, stop child components and clear DOM
4170
- if (this._did_first_render) {
4171
- // Stop all child components before clearing DOM
4172
- this.$.find('.Component').each(function () {
4173
- const child = $(this).data('_component');
4174
- if (child && !child._stopped) {
4175
- child._stop(); // Stop just the component, DOM will be cleared next
4176
- }
4177
- });
4178
- // Clear the DOM
4179
- this.$[0].innerHTML = '';
4180
- }
4181
- else {
4182
- this._did_first_render = true;
4183
- }
4184
- // Remove _Component_Stopped class if present (allows re-render after stop)
4185
- this.$.removeClass('_Component_Stopped');
4186
4289
  // Capture data state before first render for comparison later
4187
4290
  if (this._data_before_render === null) {
4188
4291
  this._data_before_render = JSON.stringify(this.data);
4189
4292
  }
4190
- // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4191
- this._dom_children.clear();
4192
4293
  // Get template and render it
4193
4294
  let template_def;
4194
4295
  // If we have a component name, try looking up by name first (for unregistered components)
@@ -4200,24 +4301,8 @@ class Jqhtml_Component {
4200
4301
  template_def = get_template_by_class(this.constructor);
4201
4302
  }
4202
4303
  if (template_def && template_def.render) {
4203
- // Create jqhtml utilities object
4204
- const jqhtml = {
4205
- escape_html: (str) => {
4206
- const div = document.createElement('div');
4207
- div.textContent = String(str);
4208
- return div.innerHTML;
4209
- },
4210
- escape_html_nl2br: (str) => {
4211
- const div = document.createElement('div');
4212
- div.textContent = String(str);
4213
- // Escape HTML first, then replace newlines with <br />
4214
- return div.innerHTML.replace(/\n/g, '<br />');
4215
- },
4216
- // Objects reaching <%= %> / <%!= %> / <%br= %> - see value-printers.ts
4217
- print_object,
4218
- // <{expression}> name validation - see component-name.ts
4219
- dynamic_component_name
4220
- };
4304
+ // Utilities the compiled render function calls (see render_utilities)
4305
+ const jqhtml = render_utilities;
4221
4306
  // Execute template function
4222
4307
  // Commented out debug logging
4223
4308
  // console.log('[Component] About to call render, this:', this);
@@ -4256,69 +4341,48 @@ class Jqhtml_Component {
4256
4341
  // Check for template inheritance (slot-only OR explicit extends)
4257
4342
  // If instructions is {_slots: {...}}, find parent template and invoke it
4258
4343
  if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
4259
- const componentName = template_def.name || this.args._component_name || this.constructor.name;
4260
- console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
4261
- let parentTemplate = null;
4262
- let parentTemplateName = null;
4263
- // First check for explicit extends attribute in template metadata
4264
- if (template_def.extends) {
4265
- console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
4266
- parentTemplate = get_template(template_def.extends);
4267
- parentTemplateName = template_def.extends;
4268
- }
4269
- // If no explicit extends, walk the prototype chain to find parent class with template
4270
- if (!parentTemplate) {
4271
- let currentClass = Object.getPrototypeOf(this.constructor);
4272
- while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
4273
- const className = currentClass.name;
4274
- console.log(`[JQHTML] Checking parent: ${className}`);
4275
- try {
4276
- const classTemplate = get_template(className);
4277
- if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
4278
- console.log(`[JQHTML] Found parent template: ${className}`);
4279
- parentTemplate = classTemplate;
4280
- parentTemplateName = className;
4281
- break;
4282
- }
4283
- }
4284
- catch (error) {
4285
- console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
4286
- }
4287
- currentClass = Object.getPrototypeOf(currentClass);
4288
- }
4344
+ const component_name = template_def.name || this.args._component_name || this.constructor.name;
4345
+ const parent = resolve_parent_template(template_def, this.constructor);
4346
+ // A slot-only template is nothing but an override of a parent. With no parent
4347
+ // there is nothing to render, and rendering empty hides the mistake (usually a
4348
+ // missing register_template) behind a blank element. Fail loudly instead.
4349
+ if (!parent) {
4350
+ throw new Error(`[JQHTML] ${component_name}: slot-only template has no parent template. ` +
4351
+ `Add extends="Parent_Name" to <Define:${component_name}>, extend a class whose ` +
4352
+ `template is registered, or give the template markup of its own.`);
4289
4353
  }
4290
- // If we found a parent template, invoke it with child's slots
4291
- if (parentTemplate) {
4292
- try {
4293
- // Create a content function that invokes child slots
4294
- // When parent calls content('slotName'), it invokes the child's slot function
4295
- const childSlots = instructions._slots;
4296
- const contentFunction = (slotName, ...slotArgs) => {
4297
- if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4298
- // Invoke the slot function with everything content() was given
4299
- const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4300
- // Return in render function format: [instructions, context]
4301
- // The template expression handler expects this format
4302
- return [slotInstructions, slotContext];
4303
- }
4304
- // Slot not found, return empty
4305
- return '';
4306
- };
4307
- // Invoke parent template with child's slots as content function
4308
- const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4309
- jqhtml);
4310
- console.log(`[JQHTML] Parent template invoked successfully`);
4311
- instructions = parentInstructions;
4312
- context = parentContext;
4313
- }
4314
- catch (error) {
4315
- console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
4316
- instructions = [];
4354
+ // Create a content function that invokes child slots
4355
+ // When parent calls content('slotName'), it invokes the child's slot function
4356
+ const childSlots = instructions._slots;
4357
+ const contentFunction = (slotName, ...slotArgs) => {
4358
+ if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4359
+ // Invoke the slot function with everything content() was given
4360
+ const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4361
+ // Return in render function format: [instructions, context]
4362
+ // The template expression handler expects this format
4363
+ return [slotInstructions, slotContext];
4317
4364
  }
4365
+ // Slot not found, return empty
4366
+ return '';
4367
+ };
4368
+ // Invoke parent template with child's slots as content function.
4369
+ // A parent template that throws is a real defect in the parent's markup. It used
4370
+ // to be swallowed into an empty render plus a warning nobody reads; it is now
4371
+ // re-thrown, which routes it through the boot error path: logged, this component
4372
+ // stopped, its waiting ancestors released.
4373
+ try {
4374
+ const [parentInstructions, parentContext] = parent.template.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4375
+ jqhtml);
4376
+ instructions = parentInstructions;
4377
+ context = parentContext;
4318
4378
  }
4319
- else {
4320
- console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
4321
- instructions = [];
4379
+ catch (error) {
4380
+ // Rethrown, not swallowed - only re-labelled so the report names both ends
4381
+ // of the inheritance, which the raw template error does not.
4382
+ const wrapped = new Error(`[JQHTML] ${component_name}: parent template ${parent.name} threw while ` +
4383
+ `rendering slot-only child: ${error?.message ?? error}`);
4384
+ wrapped.cause = error; // ES2020 target: Error(message, {cause}) is not typed
4385
+ throw wrapped;
4322
4386
  }
4323
4387
  }
4324
4388
  // Process instructions to generate DOM. ['_content', ...] markers from
@@ -4339,10 +4403,33 @@ class Jqhtml_Component {
4339
4403
  }
4340
4404
  this._fire_viewport_resize();
4341
4405
  }
4406
+ // "Already rendered once" - read before the state below can move to 3.
4407
+ const isRerender = this._ready_state >= 3;
4408
+ // HTML CACHE MODE: Mark on_render complete after the post-on_load render.
4409
+ // This signals to parent components that this component's DOM is fully rendered
4410
+ // with fresh data and ready for HTML snapshot. It is set BEFORE trigger('render')
4411
+ // because _wait_for_children_on_render() waits on that very event and reads the
4412
+ // flag from its handler.
4413
+ if (this._ready_state >= 2) {
4414
+ this._on_render_complete = true;
4415
+ // State 3 = "rendered with loaded data, ready phase not finished yet".
4416
+ // Only meaningful once the load phase has happened (>= 2); before that the
4417
+ // component is still walking 0 -> 1 -> 2 and must not be pushed forward.
4418
+ // The ready phase (_ready(), render()'s executor, _reload()) restores 4.
4419
+ this._ready_state = 3;
4420
+ this._update_debug_attrs();
4421
+ }
4422
+ else if (!this.__has_custom_on_load) {
4423
+ // A component with no custom on_load() skips the load phase entirely, so it never
4424
+ // reaches _ready_state 2 and would never set the flag above - and its data cannot
4425
+ // change, so this first render IS its final, fresh render. Without this, an
4426
+ // html-cache parent waited forever in _wait_for_children_on_render() for a
4427
+ // template-only child and never snapshotted, rendered or fired 'ready'.
4428
+ this._on_render_complete = true;
4429
+ }
4342
4430
  // Emit lifecycle event
4343
4431
  this.trigger('render');
4344
4432
  // Apply debug delay after render
4345
- const isRerender = this._ready_state >= 3; // Already rendered once
4346
4433
  applyDebugDelay(isRerender ? 'rerender' : 'render');
4347
4434
  // Store args snapshot for reload() comparison (skip if non-serializable)
4348
4435
  try {
@@ -4354,12 +4441,6 @@ class Jqhtml_Component {
4354
4441
  }
4355
4442
  // Store data snapshot for refresh() comparison
4356
4443
  this._data_on_last_render = JSON.stringify(this.data);
4357
- // HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
4358
- // This signals to parent components that this component's DOM is fully rendered
4359
- // with fresh data and ready for HTML snapshot
4360
- if (this._ready_state >= 2) {
4361
- this._on_render_complete = true;
4362
- }
4363
4444
  // Return the render ID so callers can check if this render is still current
4364
4445
  return current_render_id;
4365
4446
  }
@@ -4379,10 +4460,12 @@ class Jqhtml_Component {
4379
4460
  render(id = null) {
4380
4461
  if (this._stopped)
4381
4462
  return Promise.resolve();
4382
- // Invalidate ready event so new handlers wait for this render cycle to complete
4383
- // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4384
- this.invalidate('ready');
4385
- // If id provided, delegate to child component
4463
+ // If id provided, delegate to child component.
4464
+ // NOTE: this runs BEFORE invalidate('ready'). render(sid) does not start a
4465
+ // lifecycle on THIS component - it delegates to the redrawable child, which
4466
+ // invalidates and re-triggers its own 'ready'. Invalidating here would delete
4467
+ // this component's sticky ready marker with nothing left to re-trigger it, so
4468
+ // every later `await comp.ready()` would hang.
4386
4469
  if (id) {
4387
4470
  const $element = this.$sid(id);
4388
4471
  if ($element.length === 0) {
@@ -4397,14 +4480,23 @@ class Jqhtml_Component {
4397
4480
  }
4398
4481
  return child.render();
4399
4482
  }
4483
+ // Invalidate ready event so new handlers wait for this render cycle to complete
4484
+ // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4485
+ this.invalidate('ready');
4400
4486
  // Enqueue the full render lifecycle through the component queue
4401
4487
  // Return the queue promise so `await this.render()` resolves when the
4402
4488
  // full render lifecycle (DOM update, children ready, on_ready) completes
4403
4489
  return this._queue.enqueue('render', async () => {
4490
+ // The queued executor can start long after render() was called - re-check.
4491
+ if (this._stopped)
4492
+ return;
4404
4493
  // Execute render phase synchronously and capture render ID
4405
4494
  const render_id = this._render();
4406
4495
  // Wait for all child components to be ready
4407
4496
  await this._wait_for_children_ready();
4497
+ // Stopped while waiting for children: no hook, no event on a dead component.
4498
+ if (this._stopped)
4499
+ return;
4408
4500
  // Check if this render is still current before calling on_ready
4409
4501
  // If _render_count changed, another render happened and we should skip on_ready
4410
4502
  if (this._render_count !== render_id) {
@@ -4412,7 +4504,15 @@ class Jqhtml_Component {
4412
4504
  }
4413
4505
  // Call on_ready hook with authorization
4414
4506
  await this._call_lifecycle('on_ready');
4507
+ // on_ready() may itself have awaited long enough for a stop().
4508
+ if (this._stopped)
4509
+ return;
4415
4510
  this._fire_viewport_resize();
4511
+ // Back to the ready state: _render() dropped it to 3. Set BEFORE the trigger
4512
+ // so a handler that calls ready() during dispatch takes the already-ready
4513
+ // fast path instead of waiting for another trigger that will never come.
4514
+ this._ready_state = 4;
4515
+ this._update_debug_attrs();
4416
4516
  // Trigger ready event
4417
4517
  this.trigger('ready');
4418
4518
  });
@@ -4442,40 +4542,56 @@ class Jqhtml_Component {
4442
4542
  async load() {
4443
4543
  if (this._stopped)
4444
4544
  return false;
4445
- // Capture result through queue closure
4446
- let data_changed = false;
4447
- await this._queue.enqueue('load', async () => {
4545
+ // No custom on_load() means there is nothing to run and nothing can have changed.
4546
+ // Running the detached execution anyway seeded this.data with the empty detached
4547
+ // clone (create() takes no snapshot for these components), silently wiping whatever
4548
+ // on_create() had set.
4549
+ if (!this.__has_custom_on_load)
4550
+ return false;
4551
+ // The queue settles this caller with the value of the executor that actually
4552
+ // ran - including one that collapsed two rapid load() calls into one run - so
4553
+ // the result is returned from the executor, not read back out of a closure
4554
+ // variable that a discarded executor would have left at its initial value.
4555
+ return this._queue.enqueue('load', async () => {
4556
+ // The queued executor can start long after load() was called - re-check.
4557
+ if (this._stopped)
4558
+ return false;
4559
+ // Any real load returns the component to normal, whatever the debug tool did.
4560
+ this._debug_no_data = false;
4448
4561
  // Snapshot current data for change detection
4449
4562
  const data_before = JSON.stringify(this.data);
4450
4563
  // Execute on_load() on detached proxy (restores data to on_create snapshot)
4451
- const { data: result_data } = await this._execute_on_load_detached(false);
4452
- // Atomically update this.data: unfreeze → assign → normalize → refreeze
4564
+ const { data: result_data } = await this._execute_on_load_detached(null);
4565
+ // Stopped during on_load(): don't write data or cache for a dead component.
4566
+ if (this._stopped)
4567
+ return false;
4568
+ // Atomically update this.data: unfreeze → normalize → assign → refreeze.
4569
+ // The round trip runs in EVERY cache mode - see _apply_load_result().
4453
4570
  this.__data_frozen = false;
4454
- this.data = result_data;
4455
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4456
- if (cache_mode === 'data') {
4457
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4458
- this.data = normalized;
4459
- }
4571
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4460
4572
  this.__data_frozen = true;
4573
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4461
4574
  // Detect if data changed
4462
4575
  const data_after = JSON.stringify(this.data);
4463
- data_changed = data_before !== data_after;
4576
+ const data_changed = data_before !== data_after;
4464
4577
  // Update cache with fresh data if changed
4465
4578
  if (data_changed) {
4466
- // Regenerate cache key (args may have changed since boot)
4467
- let cache_key = null;
4468
- if (typeof this.cache_id === 'function') {
4469
- try {
4470
- cache_key = `${this.component_name()}::${String(this.cache_id())}`;
4579
+ // ONE derivation, shared with the cache read in create() and with _load().
4580
+ // The copy that used to live here omitted content serialization, so an
4581
+ // object-arg component computed a null key and silently never wrote the
4582
+ // cache that create() would later read with the content-serialized key.
4583
+ // Recomputed rather than reused: args may have changed since boot.
4584
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4585
+ if (cache_key === null) {
4586
+ // Leave the same record _load() leaves: the offending arg on the element,
4587
+ // and one dev warning. A silent catch here made a throwing cache_id()
4588
+ // undiagnosable - the component simply stopped being cached.
4589
+ if (uncacheable_property) {
4590
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4471
4591
  }
4472
- catch { /* cache_id() threw - skip caching */ }
4473
- }
4474
- else {
4475
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4476
- cache_key = result.key;
4592
+ warn_uncacheable_component(this, uncacheable_property, uncacheable_reason);
4477
4593
  }
4478
- if (cache_key && cache_mode !== 'html') {
4594
+ else if (cache_mode !== 'html') {
4479
4595
  Jqhtml_Local_Storage.set(cache_key, this.data);
4480
4596
  }
4481
4597
  }
@@ -4483,10 +4599,13 @@ class Jqhtml_Component {
4483
4599
  // Suppressed by _load_only and _load_render_only flags (preloading mode)
4484
4600
  if (!this._load_only && !this._load_render_only) {
4485
4601
  await this._call_lifecycle('on_loaded');
4602
+ // on_loaded() may have awaited across a stop(); don't fire 'loaded' after it.
4603
+ if (this._stopped)
4604
+ return data_changed;
4486
4605
  this.trigger('loaded');
4487
4606
  }
4607
+ return data_changed;
4488
4608
  });
4489
- return data_changed;
4490
4609
  }
4491
4610
  /**
4492
4611
  * Create phase - Quick setup, prepare UI
@@ -4509,15 +4628,18 @@ class Jqhtml_Component {
4509
4628
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
4510
4629
  // Components without on_load() don't fetch data, so nothing to cache or restore
4511
4630
  if (this.__has_custom_on_load) {
4631
+ // Snapshot this.data the moment on_create() returns, BEFORE any cache read.
4632
+ // on_load() restarts from this snapshot every time, and CLAUDE.md promises that
4633
+ // is the on_create() state. read_cache_in_create() REPLACES this.data on a warm
4634
+ // 'data' cache, so snapshotting after it made a warm boot restart on_load() from
4635
+ // cached data - `this.data.items.push(x)` doubled its rows on every reload.
4636
+ this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4512
4637
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
4513
4638
  // Skip cache for detached elements — no point hydrating from cache when not in DOM
4514
4639
  // @see component-cache.ts for full implementation
4515
4640
  if (!this._is_detached) {
4516
4641
  read_cache_in_create(this);
4517
4642
  }
4518
- // Snapshot this.data after on_create() completes
4519
- // This will be restored before each on_load() execution to reset state
4520
- this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4521
4643
  }
4522
4644
  // Freeze this.data after on_create() - only on_load() can modify it now
4523
4645
  this.__data_frozen = true;
@@ -4625,6 +4747,8 @@ class Jqhtml_Component {
4625
4747
  async _load() {
4626
4748
  if (this._stopped || this._ready_state >= 2)
4627
4749
  return;
4750
+ // Any real load returns the component to normal, whatever the debug tool did.
4751
+ this._debug_no_data = false;
4628
4752
  this._log_lifecycle('load', 'start');
4629
4753
  // use_cached_data feature: If cache hit occurred and use_cached_data=true, skip on_load() entirely
4630
4754
  // The cached data is already in this.data from create() phase
@@ -4648,26 +4772,7 @@ class Jqhtml_Component {
4648
4772
  // follower never runs on_load() and adopts the leader's data with NO
4649
4773
  // revalidation, so a wrong key here is permanently wrong data. Redundant
4650
4774
  // concurrent requests are the cheaper failure.
4651
- let cache_key = null;
4652
- let uncacheable_property;
4653
- let uncacheable_reason;
4654
- if (typeof this.cache_id === 'function') {
4655
- try {
4656
- const custom_cache_id = this.cache_id();
4657
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
4658
- }
4659
- catch (error) {
4660
- // cache_id() threw error - disable caching
4661
- uncacheable_property = 'cache_id()';
4662
- uncacheable_reason = 'cache-id-threw';
4663
- }
4664
- }
4665
- else {
4666
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
4667
- cache_key = result.key;
4668
- uncacheable_property = result.uncacheable_property;
4669
- uncacheable_reason = result.uncacheable_reason;
4670
- }
4775
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4671
4776
  const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
4672
4777
  // SSR preload check: if preloaded data exists for this component+args, use it
4673
4778
  if (cache_key !== null && has_preload_data()) {
@@ -4698,24 +4803,26 @@ class Jqhtml_Component {
4698
4803
  if (window.jqhtml?.debug?.verbose) {
4699
4804
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
4700
4805
  }
4701
- const { data: result_data } = await this._execute_on_load_detached();
4806
+ const { data: result_data } = await this._execute_on_load_detached(null);
4702
4807
  await this._apply_load_result(result_data, data_before_load);
4703
4808
  return;
4704
4809
  }
4705
4810
  // Check if this component should execute on_load() or wait for existing request
4706
- const should_execute = Load_Coordinator.should_execute_on_load(this);
4811
+ const should_execute = Load_Coordinator.should_execute_on_load(this, dedup_key);
4707
4812
  if (!should_execute) {
4708
4813
  // This component is a follower - wait for leader to complete
4709
4814
  if (window.jqhtml?.debug?.verbose) {
4710
4815
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
4711
4816
  }
4712
- const coordination_promise = Load_Coordinator.get_coordination_promise(this);
4817
+ // Every coordinator call uses the key captured ABOVE, before on_load() began.
4818
+ // Recomputing it here would miss the entry whenever args changed meanwhile.
4819
+ const coordination_promise = Load_Coordinator.get_coordination_promise(dedup_key);
4713
4820
  if (coordination_promise) {
4714
4821
  try {
4715
- // Wait for leader to complete
4822
+ // Wait for leader to complete (rejects if the leader's on_load() threw)
4716
4823
  await coordination_promise;
4717
4824
  // Retrieve leader's data from coordinator
4718
- const leader_data = Load_Coordinator.get_leader_data(this);
4825
+ const leader_data = Load_Coordinator.get_leader_data(this, dedup_key);
4719
4826
  if (leader_data !== null) {
4720
4827
  // Apply leader's data using the same method as leader
4721
4828
  // This properly handles freeze/unfreeze and normalization
@@ -4733,15 +4840,11 @@ class Jqhtml_Component {
4733
4840
  throw error;
4734
4841
  }
4735
4842
  }
4736
- // Fallback: if we couldn't get leader data, just mark as complete
4737
- this._ready_state = 2;
4738
- this._update_debug_attrs();
4739
- this._log_lifecycle('load', 'complete (follower)');
4740
- this.trigger('load');
4741
- if (!this._load_only && !this._load_render_only) {
4742
- await this._call_lifecycle('on_loaded');
4743
- this.trigger('loaded');
4744
- }
4843
+ // No leader data to adopt (the entry was already collected, or its data could
4844
+ // not be serialized). Load independently rather than firing load/loaded with the
4845
+ // untouched on_create() data.
4846
+ const { data: result_data } = await this._execute_on_load_detached(null);
4847
+ await this._apply_load_result(result_data, data_before_load);
4745
4848
  return;
4746
4849
  }
4747
4850
  // This component is a leader - execute on_load() on detached proxy
@@ -4749,7 +4852,7 @@ class Jqhtml_Component {
4749
4852
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
4750
4853
  }
4751
4854
  // Execute on_load on detached proxy with Load_Coordinator registration
4752
- const { data: result_data, complete_coordination } = await this._execute_on_load_detached(true);
4855
+ const { data: result_data, complete_coordination } = await this._execute_on_load_detached(dedup_key);
4753
4856
  // Apply result via sequential queue
4754
4857
  await this._apply_load_result(result_data, data_before_load);
4755
4858
  // Complete coordination AFTER this.data is updated
@@ -4763,8 +4866,8 @@ class Jqhtml_Component {
4763
4866
  * @see data-proxy.ts for full implementation
4764
4867
  * @private
4765
4868
  */
4766
- async _execute_on_load_detached(use_load_coordinator = false) {
4767
- return execute_on_load_detached(this, use_load_coordinator);
4869
+ async _execute_on_load_detached(dedup_key = null) {
4870
+ return execute_on_load_detached(this, dedup_key);
4768
4871
  }
4769
4872
  /**
4770
4873
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -4787,21 +4890,22 @@ class Jqhtml_Component {
4787
4890
  await my_turn;
4788
4891
  try {
4789
4892
  // Now it's our turn to set this.data
4790
- // Unfreeze, set data, freeze
4893
+ // Every on_load() result is round-tripped through the cache serializer before it
4894
+ // becomes this.data - in EVERY cache mode, not just 'data'. Two consequences the
4895
+ // rest of the framework depends on:
4896
+ // - this.data is jqhtml's own copy. The author's object graph is never aliased,
4897
+ // so mutating what on_load() returned cannot reach a frozen this.data.
4898
+ // - this.data is BY CONSTRUCTION what the cache stores and what a cache hit
4899
+ // returns. Hot and cold are the same object shape in every mode, so turning
4900
+ // caching on can never change what a component sees.
4901
+ // Dates, Maps, Sets and register_cache_class() classes are reconstructed; anything
4902
+ // the serializer cannot express is dropped and warned about once (local-storage.ts).
4791
4903
  this.__data_frozen = false;
4792
- this.data = result_data;
4793
- // DATA MODE: Normalize this.data through serialize/deserialize round-trip
4794
- // This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
4795
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4796
- if (cache_mode === 'data') {
4797
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4798
- this.data = normalized;
4799
- if (window.jqhtml?.debug?.verbose) {
4800
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4801
- }
4802
- }
4803
- // Freeze this.data
4904
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4804
4905
  this.__data_frozen = true;
4906
+ if (window.jqhtml?.debug?.verbose) {
4907
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4908
+ }
4805
4909
  // SSR data capture: record this component's data for preloading
4806
4910
  if (is_capture_enabled() && this._has_on_load()) {
4807
4911
  capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
@@ -4887,9 +4991,11 @@ class Jqhtml_Component {
4887
4991
  callback();
4888
4992
  return Promise.resolve();
4889
4993
  }
4890
- // Return promise that resolves when ready event fires
4994
+ // Return promise that resolves when ready event fires.
4995
+ // once(), not on(): a promise can only settle once, so a permanent listener
4996
+ // just accumulates on the component - one per ready() call, forever.
4891
4997
  return new Promise((resolve) => {
4892
- this.on('ready', () => {
4998
+ this.once('ready', () => {
4893
4999
  if (callback)
4894
5000
  callback();
4895
5001
  resolve();
@@ -4947,9 +5053,18 @@ class Jqhtml_Component {
4947
5053
  if (child._ready_state >= 4) {
4948
5054
  continue;
4949
5055
  }
4950
- // Create promise that resolves when child reaches ready
5056
+ // A stopped child will never fire 'ready' again - waiting on it would wedge
5057
+ // this parent (and everything awaiting its ready()) forever.
5058
+ if (child._stopped) {
5059
+ continue;
5060
+ }
5061
+ // Create promise that resolves when child reaches ready.
5062
+ // once(), not on(): a permanent listener would be appended to the child on
5063
+ // every render/reload cycle of a polling parent and never removed.
5064
+ // Raced against 'stop' so a child stopped mid-wait releases the parent.
4951
5065
  const ready_promise = new Promise((resolve) => {
4952
- child.on('ready', () => resolve());
5066
+ child.once('ready', () => resolve());
5067
+ child.once('stop', () => resolve());
4953
5068
  });
4954
5069
  ready_promises.push(ready_promise);
4955
5070
  }
@@ -4975,22 +5090,42 @@ class Jqhtml_Component {
4975
5090
  // Create promises for each child that hasn't completed on_render yet
4976
5091
  const render_promises = [];
4977
5092
  for (const child of children) {
4978
- // If child already completed on_render post-on_load, skip
4979
- if (child._on_render_complete) {
5093
+ // If child already completed on_render post-on_load (or is dead), skip
5094
+ if (child._on_render_complete || child._stopped) {
4980
5095
  continue;
4981
5096
  }
4982
- // Create promise that resolves when child completes on_render
5097
+ // Event-driven, not polled: the child sets _on_render_complete just before it
5098
+ // fires 'render', and 'stop' ends the wait for a child that dies first.
5099
+ //
5100
+ // A plain once('render') would be wrong: events are sticky, and this child has
5101
+ // already rendered at least once (the pre-on_load render), so once() would fire
5102
+ // immediately off that stale marker and let the parent snapshot half-rendered DOM.
5103
+ // The handler therefore re-checks the flag and stays registered until it is set.
4983
5104
  const render_promise = new Promise((resolve) => {
4984
- // Poll for completion (simple approach - could use events for more efficiency)
4985
- const check = () => {
4986
- if (child._on_render_complete || child._stopped) {
4987
- resolve();
4988
- }
4989
- else {
4990
- setTimeout(check, 10);
5105
+ let settled = false;
5106
+ const on_child_event = () => {
5107
+ if (settled)
5108
+ return;
5109
+ if (!child._on_render_complete && !child._stopped)
5110
+ return; // pre-load render
5111
+ settled = true;
5112
+ // There is no off() in the event API; splice this handler out of both queues
5113
+ // the way once() does, so a long-lived child does not accumulate handlers
5114
+ // across repeated waits.
5115
+ for (const event_name of ['render', 'stop']) {
5116
+ const callbacks = child._lifecycle_callbacks.get(event_name);
5117
+ if (callbacks) {
5118
+ const idx = callbacks.indexOf(on_child_event);
5119
+ if (idx !== -1)
5120
+ callbacks.splice(idx, 1);
5121
+ }
4991
5122
  }
5123
+ resolve();
4992
5124
  };
4993
- check();
5125
+ // 'stop' first: registering 'render' can fire the handler synchronously (sticky),
5126
+ // and the handler must be able to deregister both queues when it does.
5127
+ child.on('stop', on_child_event);
5128
+ child.on('render', on_child_event);
4994
5129
  });
4995
5130
  render_promises.push(render_promise);
4996
5131
  }
@@ -5046,6 +5181,51 @@ class Jqhtml_Component {
5046
5181
  async refresh() {
5047
5182
  return this.reload(false);
5048
5183
  }
5184
+ /**
5185
+ * Re-render this component from its on_create() state with its cache dropped and
5186
+ * WITHOUT running on_load()/on_loaded()/on_ready().
5187
+ *
5188
+ * @internal debug tool. Used by the debug overlay's lifecycle footer to see what a
5189
+ * component looks like with no loaded data at all. Not public API, not documented,
5190
+ * and no downstream code should call it: reload() or refresh() afterwards returns
5191
+ * the component to normal (the snapshot is intact and the cache is gone, so the
5192
+ * next load is a genuine fresh fetch).
5193
+ */
5194
+ _reload_without_data() {
5195
+ if (this._stopped)
5196
+ return Promise.resolve();
5197
+ // Same marker invalidation reload()/render() do: later ready() waiters must wait
5198
+ // for THIS cycle rather than resolve off the previous one.
5199
+ this.invalidate('ready');
5200
+ // Serialised with reload()/refresh() so it can never interleave with a real load.
5201
+ return this._queue.enqueue('reload', async () => {
5202
+ if (this._stopped)
5203
+ return;
5204
+ // Drop this component's own cache entries (data and html snapshot), so a later
5205
+ // reload() genuinely re-fetches instead of hydrating from what is on screen now.
5206
+ const { cache_key } = generate_cache_key(this);
5207
+ if (cache_key !== null) {
5208
+ Jqhtml_Local_Storage.remove(cache_key);
5209
+ Jqhtml_Local_Storage.remove(`${cache_key}::html`);
5210
+ }
5211
+ // Back to the on_create() state. Components with no custom on_load() take no
5212
+ // snapshot (nothing can have changed this.data), so they simply re-render.
5213
+ if (this.__initial_data_snapshot !== null) {
5214
+ this.__data_frozen = false;
5215
+ this.data = JSON.parse(JSON.stringify(this.__initial_data_snapshot));
5216
+ this.__data_frozen = true;
5217
+ }
5218
+ // Read by the debug overlay's lifecycle row; cleared by _reload()/load()/_load().
5219
+ this._debug_no_data = true;
5220
+ this._render();
5221
+ await this._wait_for_children_ready();
5222
+ if (this._stopped)
5223
+ return;
5224
+ this._ready_state = 4;
5225
+ this._update_debug_attrs();
5226
+ this.trigger('ready');
5227
+ });
5228
+ }
5049
5229
  /**
5050
5230
  * Internal reload implementation - re-fetch data and re-render
5051
5231
  *
@@ -5083,6 +5263,8 @@ class Jqhtml_Component {
5083
5263
  async _reload() {
5084
5264
  if (this._stopped)
5085
5265
  return;
5266
+ // Any real load returns the component to normal, whatever the debug tool did.
5267
+ this._debug_no_data = false;
5086
5268
  // Invalidate ready event so new handlers wait for this reload to complete
5087
5269
  // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
5088
5270
  this.invalidate('ready');
@@ -5094,8 +5276,17 @@ class Jqhtml_Component {
5094
5276
  this.next_reload_force_refresh = null;
5095
5277
  this._render();
5096
5278
  await this._wait_for_children_ready();
5279
+ // Stopped while waiting for children: no hook, no event on a dead component.
5280
+ if (this._stopped)
5281
+ return;
5097
5282
  await this._call_lifecycle('on_ready');
5283
+ // on_ready() may itself have awaited long enough for a stop().
5284
+ if (this._stopped)
5285
+ return;
5098
5286
  this._fire_viewport_resize();
5287
+ // Restore the ready state (_render() dropped it to 3) before the trigger
5288
+ this._ready_state = 4;
5289
+ this._update_debug_attrs();
5099
5290
  this.trigger('ready');
5100
5291
  this._log_lifecycle('reload', 'complete (no on_load)');
5101
5292
  return;
@@ -5123,10 +5314,16 @@ class Jqhtml_Component {
5123
5314
  // STEP 2: Call on_load() on detached proxy
5124
5315
  // This uses the same detached execution as _load() - on_load runs isolated
5125
5316
  // and result is applied via sequential queue
5126
- const { data: result_data } = await this._execute_on_load_detached(false); // false = don't use Load_Coordinator
5317
+ const { data: result_data } = await this._execute_on_load_detached(null); // null = no coordination
5318
+ // Stopped during on_load(): don't write data or re-render a dead component.
5319
+ if (this._stopped)
5320
+ return;
5127
5321
  // Apply result via sequential queue
5128
5322
  // This handles normalization, caching, and setting _is_dynamic
5129
5323
  await this._apply_load_result(result_data, data_before_load);
5324
+ // The apply queue can yield; a stop() there must abort the render/ready tail.
5325
+ if (this._stopped)
5326
+ return;
5130
5327
  // Re-read data state after apply (for render decision below)
5131
5328
  const data_after_load = JSON.stringify(this.data);
5132
5329
  const data_changed = data_after_load !== data_before_load;
@@ -5135,6 +5332,15 @@ class Jqhtml_Component {
5135
5332
  const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
5136
5333
  // Track if we need to render
5137
5334
  let should_render = false;
5335
+ // HTML CACHE MODE: a cache injection above replaced the subtree with inert markup -
5336
+ // a snapshot string, with no live child components in it. It must be re-rendered
5337
+ // from the template regardless of what the load decided, exactly as _should_rerender()
5338
+ // forces it at boot. Without this, a reload whose data came back unchanged left dead
5339
+ // HTML (stopped children, no instances) on screen.
5340
+ const used_cached_html = this._used_cached_html;
5341
+ if (used_cached_html) {
5342
+ this._used_cached_html = false;
5343
+ }
5138
5344
  if (force_refresh) {
5139
5345
  // reload(true) or reload() - always render if we haven't yet, OR data changed
5140
5346
  should_render = !rendered_from_cache || data_changed;
@@ -5155,6 +5361,11 @@ class Jqhtml_Component {
5155
5361
  }
5156
5362
  }
5157
5363
  // STEP 3: Perform second render if needed
5364
+ // The cache injection's inert markup is not optional to replace - this overrides both
5365
+ // the reload() and the refresh()/reload(false) decision above.
5366
+ if (used_cached_html) {
5367
+ should_render = true;
5368
+ }
5158
5369
  if (should_render) {
5159
5370
  this._render();
5160
5371
  }
@@ -5169,11 +5380,33 @@ class Jqhtml_Component {
5169
5380
  // STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
5170
5381
  if (rendered_from_cache || should_render) {
5171
5382
  await this._wait_for_children_ready();
5383
+ // Stopped while waiting for children: no hook, no event on a dead component.
5384
+ if (this._stopped)
5385
+ return;
5172
5386
  await this._call_lifecycle('on_ready');
5387
+ // on_ready() may itself have awaited long enough for a stop().
5388
+ if (this._stopped)
5389
+ return;
5173
5390
  this._fire_viewport_resize();
5391
+ // Restore the ready state (the load phase dropped it to 2, _render() to 3)
5392
+ // before the trigger, so handlers see a ready component.
5393
+ this._ready_state = 4;
5394
+ this._update_debug_attrs();
5174
5395
  // Trigger ready event so parent callbacks fire on subsequent reloads
5175
5396
  this.trigger('ready');
5176
5397
  }
5398
+ else {
5399
+ // refresh()/reload(false) found unchanged data: no re-render, and on_ready()
5400
+ // is deliberately NOT called (documented behaviour). But the component IS
5401
+ // ready - and this cycle deleted the sticky 'ready' marker at the top of
5402
+ // _reload() and dropped _ready_state to 2 in the load phase. Without
5403
+ // restoring both, ready() never resolves again and a parent re-entering
5404
+ // _wait_for_children_ready() hangs on this child forever (the documented
5405
+ // polling pattern breaks after its first quiet refresh).
5406
+ this._ready_state = 4;
5407
+ this._update_debug_attrs();
5408
+ this.trigger('ready');
5409
+ }
5177
5410
  this._log_lifecycle('reload', 'complete');
5178
5411
  }
5179
5412
  /**
@@ -5198,26 +5431,28 @@ class Jqhtml_Component {
5198
5431
  this.__gate_resume = null;
5199
5432
  resume();
5200
5433
  }
5201
- // Early bailout: skip expensive cleanup if no handlers registered
5202
- // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
5203
- const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
5204
- const has_stop_callbacks = this._on_registered('stop');
5205
- if (!has_custom_stop && !has_stop_callbacks) {
5206
- // Fast path: no cleanup logic defined, just mark as stopped
5207
- this._lifecycle_manager.unregister_component(this);
5208
- this._ready_state = 99;
5209
- return;
5210
- }
5211
- // Full cleanup path: component has custom stop logic
5434
+ // There is deliberately NO fast path for components without stop logic. The
5435
+ // marker class, the parent de-registration and the 'stop' event are not cleanup
5436
+ // the component asked for, they are how the rest of the framework learns this
5437
+ // component is dead: the class is the documented, observable stopped state; a
5438
+ // child left in _dom_parent._dom_children is a dead component every later
5439
+ // _wait_for_children_ready() and _get_dom_children() walks; and 'stop' is what
5440
+ // releases a parent (and boot's failure path) waiting on a child that will never
5441
+ // fire 'ready'. Skipping them for the common no-on_stop() child was what leaked
5442
+ // stopped children into the parent's registry.
5212
5443
  this._log_lifecycle('destroy', 'start');
5213
5444
  this.$.addClass('_Component_Stopped');
5214
5445
  // Unregister from lifecycle manager
5215
5446
  this._lifecycle_manager.unregister_component(this);
5216
- // Call user's on_stop() hook with authorization (sync)
5217
- const stopResult = this._call_lifecycle_sync('on_stop');
5218
- if (stopResult && typeof stopResult.then === 'function') {
5219
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5220
- `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5447
+ // Call user's on_stop() hook with authorization (sync) - only when actually
5448
+ // overridden, since _call_lifecycle_sync() on the base no-op costs per child
5449
+ // on every re-render.
5450
+ if (this.on_stop !== Jqhtml_Component.prototype.on_stop) {
5451
+ const stopResult = this._call_lifecycle_sync('on_stop');
5452
+ if (stopResult && typeof stopResult.then === 'function') {
5453
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5454
+ `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5455
+ }
5221
5456
  }
5222
5457
  // Fire registered stop callbacks
5223
5458
  this.trigger('stop');
@@ -5493,6 +5728,48 @@ class Jqhtml_Component {
5493
5728
  _generate_cid() {
5494
5729
  return uid();
5495
5730
  }
5731
+ /**
5732
+ * Re-scope every scoped id in an HTML-cache snapshot so it belongs to THIS instance.
5733
+ *
5734
+ * A snapshot is the inner HTML of some component instance, so it contains ids of the
5735
+ * form `<name>:<cid>`: the snapshotting component's own elements carry its cid, and
5736
+ * elements rendered by nested children carry each child's cid. Replayed verbatim into
5737
+ * a second instance those ids are duplicates of the originals, and $sid() resolves via
5738
+ * getElementById(), which returns whichever matching element is first in the document -
5739
+ * so one instance could read and write another instance's DOM.
5740
+ *
5741
+ * Mapping: the snapshot owner's cid becomes this._cid (so $sid() finds the elements
5742
+ * this component is about to treat as its own), and every other cid becomes a fresh
5743
+ * uid(), consistently within one injection. Two instances hydrated from one snapshot
5744
+ * therefore share no id at all.
5745
+ *
5746
+ * The ':' separator is written only by the compiler ($sid) and the instruction
5747
+ * processor (a scoped plain id), and cids are uid() output - [a-z][a-z0-9]* - so the
5748
+ * pattern is exact. A plain unscoped id has no ':' and is left alone.
5749
+ *
5750
+ * data-cid is the debug mirror of a component's own _cid (config.ts debug_attributes).
5751
+ * The snapshot's values are stale cids belonging to components that no longer exist,
5752
+ * so they go through the same mapping rather than being left to lie in DevTools.
5753
+ *
5754
+ * @private
5755
+ */
5756
+ _rescope_cached_html(snapshot) {
5757
+ const cid_map = new Map();
5758
+ cid_map.set(snapshot.cid, this._cid);
5759
+ const remap = (old_cid) => {
5760
+ let new_cid = cid_map.get(old_cid);
5761
+ if (new_cid === undefined) {
5762
+ new_cid = uid();
5763
+ cid_map.set(old_cid, new_cid);
5764
+ }
5765
+ return new_cid;
5766
+ };
5767
+ return snapshot.html
5768
+ // id="<name>:<cid>" - the name half may itself contain ':' (an author-written
5769
+ // id="a:b" is scoped as "a:b:cid"), so the cid is anchored at the end.
5770
+ .replace(/ id="([^"]*):([a-z][a-z0-9]*)"/g, (_match, name, old_cid) => ` id="${name}:${remap(old_cid)}"`)
5771
+ .replace(/ data-cid="([a-z][a-z0-9]*)"/g, (_match, old_cid) => ` data-cid="${remap(old_cid)}"`);
5772
+ }
5496
5773
  _apply_css_classes() {
5497
5774
  const hierarchy = this.constructor.get_class_hierarchy();
5498
5775
  // If component name differs from class name, add component name to hierarchy
@@ -5532,26 +5809,29 @@ class Jqhtml_Component {
5532
5809
  }
5533
5810
  if (!template)
5534
5811
  return;
5535
- // Walk the extends chain to collect all defaultAttributes from parent templates
5536
- // Apply from parent to child so child attributes take precedence
5812
+ // Walk the inheritance chain to collect defaultAttributes from parent templates,
5813
+ // applying parent first so the child's own attributes take precedence.
5814
+ //
5815
+ // This uses resolve_parent_template(), the same helper the slot-only render path
5816
+ // uses, so a <Define>'s class/attrs resolve through EXACTLY the chain its markup
5817
+ // does. It previously followed only `extends=`, so a slot-only JS subclass
5818
+ // (class Users_Grid extends Grid_Abstract) inherited the parent's template but
5819
+ // none of the parent <Define>'s class="" or attributes.
5537
5820
  const templateChain = [];
5538
5821
  let currentTemplate = template;
5539
- // Build chain from child to parent
5540
- while (currentTemplate) {
5822
+ let currentClass = this.constructor;
5823
+ const seen = new Set();
5824
+ while (currentTemplate && !seen.has(currentTemplate)) {
5825
+ seen.add(currentTemplate);
5541
5826
  templateChain.unshift(currentTemplate); // Add to front so parent comes first
5542
- // Check if this template extends another
5543
- if (currentTemplate.extends) {
5544
- try {
5545
- currentTemplate = get_template(currentTemplate.extends);
5546
- }
5547
- catch (error) {
5548
- // Parent template not found, stop chain
5549
- break;
5550
- }
5551
- }
5552
- else {
5827
+ const parent = resolve_parent_template(currentTemplate, currentClass);
5828
+ if (!parent)
5553
5829
  break;
5554
- }
5830
+ currentTemplate = parent.template;
5831
+ // Advance the prototype walk too: the next parent must be resolved relative to
5832
+ // the class we just landed on, not the original one. A template-only parent has
5833
+ // no class, which is correct - its own chain can only be an explicit extends=.
5834
+ currentClass = get_component_class(parent.name) || null;
5555
5835
  }
5556
5836
  // Apply defaultAttributes from each template in the chain (parent first, child last)
5557
5837
  for (const tmpl of templateChain) {
@@ -5628,8 +5908,10 @@ class Jqhtml_Component {
5628
5908
  }
5629
5909
  }
5630
5910
  else {
5631
- // Regular attributes - apply directly if not already set
5632
- if (!this.$.attr(key)) {
5911
+ // Regular attributes - apply directly if not already set.
5912
+ // === undefined, not falsy: tabindex="0" and an empty-string attribute
5913
+ // written at the invocation are SET, and a Define default must not win.
5914
+ if (this.$.attr(key) === undefined) {
5633
5915
  this.$.attr(key, value);
5634
5916
  }
5635
5917
  }
@@ -5721,9 +6003,9 @@ Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() det
5721
6003
  // A bare component name, as accepted by closest() in place of a class selector
5722
6004
  Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
5723
6005
 
5724
- 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}";
6006
+ var light_css = "html[data-jqhtml-debug] .jqhtml-debug-selected {\n outline: 2px solid #b45309;\n outline-offset: -2px;\n}\n\nhtml[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}";
5725
6007
 
5726
- 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}";
6008
+ 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: auto;\n cursor: pointer;\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 overflow: hidden;\n padding: 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-modal.jqhtml-debug-modal-left {\n left: 24px;\n right: auto;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-body {\n display: block;\n box-sizing: border-box;\n overflow-y: scroll;\n min-height: 300px;\n max-height: min(600px, 100vh - 200px);\n padding: 0 0 12px 0;\n}\n\n.jqhtml-debug-footer {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n background: #ffffff;\n border-top: 1px solid #d0d0d0;\n}\n\n.jqhtml-debug-footer-label {\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\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-button.jqhtml-debug-footer-button {\n color: #1c1c1c;\n border-color: #d0d0d0;\n}\n\n.jqhtml-debug-button.jqhtml-debug-footer-button:hover {\n background: #eef3ff;\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}";
5727
6009
 
5728
6010
  /**
5729
6011
  * Component debug overlay.
@@ -5735,6 +6017,12 @@ var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed
5735
6017
  * data, state, ancestry, instantiator) instead of firing the click. Alt+click
5736
6018
  * passes through. `disable()` puts everything back.
5737
6019
  *
6020
+ * The labels are part of the interaction, not just decoration: the pointer can
6021
+ * move from a component onto one of its tabs without the hover set collapsing,
6022
+ * and clicking a tab inspects that tab's component. The modal keeps an amber
6023
+ * outline on whatever it is showing, and its title bar carries Parent / Back so
6024
+ * the DOM chain can be walked without going back to the page.
6025
+ *
5738
6026
  * Non-invasiveness, the design goal (details in ./CLAUDE.md):
5739
6027
  * - The overlay's own UI lives in a shadow root on a host element appended to
5740
6028
  * <body>, styled by shadow.scss. Page CSS cannot select into it and :host
@@ -5753,8 +6041,11 @@ const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
5753
6041
  const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
5754
6042
  const HIT_CLASS = `${PREFIX}hit`;
5755
6043
  const DEPTH_CLASS = `${PREFIX}depth-`;
6044
+ const SELECTED_CLASS = `${PREFIX}selected`; // on the element the modal is showing
5756
6045
  const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
5757
6046
  const LABEL_HEIGHT = 18;
6047
+ const LABEL_MIN_WIDTH = 400; // a narrow component still gets a readable tab
6048
+ const VIEWPORT_MARGIN = 5; // gap kept between a label's right edge and the viewport
5758
6049
  const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
5759
6050
  const state = {
5760
6051
  enabled: false,
@@ -5764,7 +6055,10 @@ const state = {
5764
6055
  modal: null,
5765
6056
  chain: [],
5766
6057
  hit_elements: [],
6058
+ label_hovered: false,
5767
6059
  modal_component: null,
6060
+ nav_stack: [],
6061
+ selected_element: null,
5768
6062
  };
5769
6063
  // ---------------------------------------------------------------------------
5770
6064
  // Public API
@@ -5821,7 +6115,7 @@ const debug_overlay = {
5821
6115
  : chain_for(target && target.jquery ? target[0] : target)[0];
5822
6116
  if (!component)
5823
6117
  return false;
5824
- open_modal(component);
6118
+ select(component);
5825
6119
  return true;
5826
6120
  },
5827
6121
  };
@@ -5876,9 +6170,18 @@ function is_ours(event) {
5876
6170
  // ---------------------------------------------------------------------------
5877
6171
  // Hover
5878
6172
  // ---------------------------------------------------------------------------
6173
+ /**
6174
+ * A hover on the page replaces the hover set - except while the pointer is on
6175
+ * our own UI. An event from inside the shadow host (a label, the modal) never
6176
+ * clears or changes the hover, and neither does a page hover raised while the
6177
+ * pointer is parked on a label: that is what lets a label be reached and clicked
6178
+ * without the outlines it belongs to vanishing on the way.
6179
+ */
5879
6180
  function on_mouseover(event) {
5880
6181
  if (is_ours(event))
5881
6182
  return;
6183
+ if (state.label_hovered)
6184
+ return;
5882
6185
  const chain = chain_for(event.target);
5883
6186
  if (chain[0] === state.chain[0])
5884
6187
  return;
@@ -5888,6 +6191,32 @@ function on_mouseout(event) {
5888
6191
  if (!event.relatedTarget)
5889
6192
  clear_hover(); // pointer left the document
5890
6193
  }
6194
+ /**
6195
+ * The pointer left a label. If it landed back inside the component the label
6196
+ * belongs to (the usual case - the label sits on the component's own corner),
6197
+ * the hover set stays; anywhere else it goes. `elementFromPoint` reports our
6198
+ * shadow host for a point covered by another label, so the host is skipped and
6199
+ * the page element underneath is used.
6200
+ */
6201
+ function on_label_leave(event) {
6202
+ state.label_hovered = false;
6203
+ const innermost = state.chain[0];
6204
+ if (!innermost)
6205
+ return;
6206
+ const under = element_under(event.clientX, event.clientY);
6207
+ if (!under || !innermost.$[0].contains(under))
6208
+ clear_hover();
6209
+ }
6210
+ function element_under(x, y) {
6211
+ const all = document.elementsFromPoint;
6212
+ const candidates = all
6213
+ ? all.call(document, x, y)
6214
+ : [document.elementFromPoint(x, y)].filter(Boolean);
6215
+ for (const node of candidates)
6216
+ if (node !== state.host)
6217
+ return node;
6218
+ return null;
6219
+ }
5891
6220
  function on_reposition() {
5892
6221
  if (state.chain.length)
5893
6222
  render_labels();
@@ -5910,18 +6239,23 @@ function clear_hover() {
5910
6239
  }
5911
6240
  state.hit_elements = [];
5912
6241
  state.chain = [];
6242
+ state.label_hovered = false;
5913
6243
  if (state.layer)
5914
6244
  state.layer.textContent = '';
5915
6245
  }
5916
6246
  /**
5917
6247
  * One tab per component in the chain, pinned to the component's top-left
5918
- * corner and no wider than the component. Outermost first so the innermost
5919
- * paints on top; tabs that share a corner (a child filling its parent, the
5920
- * usual case) stack downward instead of hiding each other.
6248
+ * corner. Width: the component's, but a narrow component still gets up to
6249
+ * LABEL_MIN_WIDTH so its name and args stay legible; a label that would then
6250
+ * run off the right edge is slid back so its right edge sits at the margin.
6251
+ * Outermost first so the innermost paints on top; tabs that share a corner (a
6252
+ * child filling its parent, the usual case) stack downward instead of hiding
6253
+ * each other.
5921
6254
  */
5922
6255
  function render_labels() {
5923
6256
  const layer = state.layer;
5924
6257
  layer.textContent = '';
6258
+ state.label_hovered = false; // the element the pointer was on is gone
5925
6259
  const taken = new Map();
5926
6260
  for (let depth = state.chain.length - 1; depth >= 0; depth--) {
5927
6261
  const component = state.chain[depth];
@@ -5934,14 +6268,33 @@ function render_labels() {
5934
6268
  const stacked = taken.get(key) || 0;
5935
6269
  taken.set(key, stacked + 1);
5936
6270
  const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
5937
- label.style.left = `${left}px`;
6271
+ // Laid out at the left edge FIRST, then moved. An absolutely positioned box
6272
+ // shrink-to-fits into the room its own `left` leaves, so measuring it at its
6273
+ // final position would report the squeezed width, and moving it would let it
6274
+ // re-expand - the two chase each other. At left 0 it has the whole viewport,
6275
+ // so the measurement below is the width it will actually keep.
6276
+ label.style.left = '0px';
5938
6277
  label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
5939
- label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
6278
+ label.style.maxWidth = `${rect.width < LABEL_MIN_WIDTH ? LABEL_MIN_WIDTH : Math.round(rect.width)}px`;
5940
6279
  label.appendChild(el('span', 'label-name', component.component_name()));
5941
6280
  const args = simple_args(component);
5942
6281
  if (args)
5943
6282
  label.appendChild(el('span', 'label-args', args));
6283
+ // Each label knows its own component: it is a hover target that holds the set
6284
+ // in place, and a click target that inspects THAT component, not the innermost.
6285
+ label.addEventListener('mouseenter', () => { state.label_hovered = true; });
6286
+ label.addEventListener('mouseleave', on_label_leave);
6287
+ label.addEventListener('click', (event) => {
6288
+ event.stopPropagation(); // the document handler already skips is_ours events
6289
+ select(component);
6290
+ });
5944
6291
  layer.appendChild(label);
6292
+ // A label on a narrow component may be wider than the component, so a component
6293
+ // near the right edge can push its label off-screen. Slide it back until its
6294
+ // right edge sits on the margin, never past the left edge.
6295
+ const width = label.getBoundingClientRect().width || label.offsetWidth;
6296
+ const limit = window.innerWidth - VIEWPORT_MARGIN;
6297
+ label.style.left = `${left + width > limit ? Math.max(0, Math.round(limit - width)) : left}px`;
5945
6298
  }
5946
6299
  }
5947
6300
  /** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
@@ -5971,7 +6324,7 @@ function on_click(event) {
5971
6324
  close_modal();
5972
6325
  return;
5973
6326
  }
5974
- open_modal(chain[0]);
6327
+ select(chain[0]);
5975
6328
  }
5976
6329
  function on_keydown(event) {
5977
6330
  if (event.key === 'Escape' && state.modal_component) {
@@ -5985,17 +6338,82 @@ function close_modal() {
5985
6338
  state.modal.classList.remove(`${PREFIX}modal-open`);
5986
6339
  state.modal.textContent = '';
5987
6340
  state.modal_component = null;
6341
+ state.nav_stack = [];
6342
+ set_selected(null);
6343
+ }
6344
+ // ---------------------------------------------------------------------------
6345
+ // Selection and navigation
6346
+ //
6347
+ // Three ways in, and they differ only in what they do to the navigation stack:
6348
+ // select() - a FRESH selection (page click, label click, inspect()): the stack
6349
+ // is dropped, so Back disappears.
6350
+ // navigate() - a step along the chain (Parent, an Ancestry entry, the
6351
+ // Instantiator link): the component being left is pushed.
6352
+ // show() - render only, stack untouched; what Back and the lifecycle
6353
+ // buttons re-open with.
6354
+ // ---------------------------------------------------------------------------
6355
+ function select(component) {
6356
+ state.nav_stack = [];
6357
+ show(component);
6358
+ }
6359
+ function navigate(component) {
6360
+ if (state.modal_component && state.modal_component !== component) {
6361
+ state.nav_stack.push(state.modal_component);
6362
+ }
6363
+ show(component);
6364
+ }
6365
+ /** Pop to the last component still alive; a stopped one is skipped, not shown. */
6366
+ function go_back() {
6367
+ while (state.nav_stack.length) {
6368
+ const previous = state.nav_stack.pop();
6369
+ if (previous.$.hasClass('_Component_Stopped'))
6370
+ continue;
6371
+ show(previous);
6372
+ return;
6373
+ }
6374
+ if (state.modal_component)
6375
+ show(state.modal_component); // nothing left: drop Back
5988
6376
  }
5989
- function open_modal(component) {
6377
+ /** Move SELECTED_CLASS to `element`, or take it off the page entirely for null. */
6378
+ function set_selected(element) {
6379
+ if (state.selected_element && state.selected_element !== element) {
6380
+ state.selected_element.classList.remove(SELECTED_CLASS);
6381
+ }
6382
+ state.selected_element = element;
6383
+ if (element)
6384
+ element.classList.add(SELECTED_CLASS);
6385
+ }
6386
+ /**
6387
+ * The inspector. Three children: a fixed title bar, a scrolling body holding every
6388
+ * section, and a fixed footer of lifecycle buttons. The modal sits top-right, but
6389
+ * moves to the left when the inspected component is itself in the right half of the
6390
+ * viewport - otherwise the panel covers the thing being inspected.
6391
+ */
6392
+ function show(component) {
5990
6393
  const modal = state.modal;
5991
6394
  modal.textContent = '';
5992
6395
  state.modal_component = component;
5993
6396
  const chain = chain_for(component.$[0]);
5994
6397
  const ancestors = chain.slice(1);
5995
6398
  const element = component.$[0];
5996
- // Title bar
6399
+ set_selected(element);
6400
+ modal.classList.toggle(`${PREFIX}modal-left`, element.getBoundingClientRect().left > window.innerWidth / 2);
6401
+ // Title bar: the name, then [Back] [Parent] [Log to console] [Close]. Back is
6402
+ // there only while something was navigated away from, Parent only while the
6403
+ // component has a DOM parent component - the first Ancestry entry.
5997
6404
  const title = el('div', 'title');
5998
6405
  title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
6406
+ if (state.nav_stack.length) {
6407
+ const back = el('button', 'button', 'Back');
6408
+ back.addEventListener('click', go_back);
6409
+ title.appendChild(back);
6410
+ }
6411
+ const parent = ancestors[0];
6412
+ if (parent) {
6413
+ const up = el('button', 'button', 'Parent');
6414
+ up.addEventListener('click', () => navigate(parent));
6415
+ title.appendChild(up);
6416
+ }
5999
6417
  const log = el('button', 'button', 'Log to console');
6000
6418
  log.addEventListener('click', () => {
6001
6419
  console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
@@ -6005,6 +6423,9 @@ function open_modal(component) {
6005
6423
  close.addEventListener('click', close_modal);
6006
6424
  title.appendChild(close);
6007
6425
  modal.appendChild(title);
6426
+ // The only scrolling element: the title bar and the footer stay put.
6427
+ const body = el('div', 'body');
6428
+ modal.appendChild(body);
6008
6429
  // Identity
6009
6430
  const ctor = component.constructor;
6010
6431
  const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
@@ -6013,15 +6434,18 @@ function open_modal(component) {
6013
6434
  row(identity, 'class', '', hierarchy.join(' → '));
6014
6435
  row(identity, 'element', '', describe_element(element));
6015
6436
  row(identity, '_cid', '', String(component._cid));
6016
- row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
6437
+ const lifecycle = READY_STATES[component._ready_state] || String(component._ready_state);
6438
+ row(identity, 'lifecycle', '', component._debug_no_data
6439
+ ? `${lifecycle} (no data - reload w/o data)`
6440
+ : lifecycle);
6017
6441
  const nocache = element.getAttribute('data-nocache');
6018
6442
  if (nocache)
6019
6443
  row(identity, 'cache', '', `declined: ${nocache}`);
6020
- modal.appendChild(section('Identity', identity));
6444
+ body.appendChild(section('Identity', identity));
6021
6445
  // Args / data / state
6022
- modal.appendChild(section('Args', object_table(component.args)));
6023
- modal.appendChild(section('Data', object_table(component.data)));
6024
- modal.appendChild(section('State', object_table(component.state)));
6446
+ body.appendChild(section('Args', object_table(component.args)));
6447
+ body.appendChild(section('Data', object_table(component.data)));
6448
+ body.appendChild(section('State', object_table(component.state)));
6025
6449
  // Ancestry
6026
6450
  const list = el('div', 'list');
6027
6451
  if (!ancestors.length)
@@ -6030,10 +6454,10 @@ function open_modal(component) {
6030
6454
  const link = el('div', 'link');
6031
6455
  link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
6032
6456
  link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
6033
- link.addEventListener('click', () => open_modal(ancestor));
6457
+ link.addEventListener('click', () => navigate(ancestor));
6034
6458
  list.appendChild(link);
6035
6459
  }
6036
- modal.appendChild(section('Ancestry (DOM, nearest first)', list));
6460
+ body.appendChild(section('Ancestry (DOM, nearest first)', list));
6037
6461
  // Instantiator - the component whose template wrote this tag. Differs from
6038
6462
  // the DOM parent for markup written in a slot body or default content.
6039
6463
  const instantiator = component.instantiator ? component.instantiator() : null;
@@ -6042,7 +6466,7 @@ function open_modal(component) {
6042
6466
  const link = el('div', 'link');
6043
6467
  link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
6044
6468
  link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
6045
- link.addEventListener('click', () => open_modal(instantiator));
6469
+ link.addEventListener('click', () => navigate(instantiator));
6046
6470
  inst.appendChild(link);
6047
6471
  if (ancestors[0] && instantiator !== ancestors[0]) {
6048
6472
  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.'));
@@ -6051,9 +6475,49 @@ function open_modal(component) {
6051
6475
  else {
6052
6476
  inst.appendChild(el('div', 'empty', 'none - created directly'));
6053
6477
  }
6054
- modal.appendChild(section('Instantiator', inst));
6478
+ body.appendChild(section('Instantiator', inst));
6479
+ // Footer: lifecycle controls. Clicks land inside the shadow host, so the
6480
+ // overlay's own click hijack lets them through (is_ours).
6481
+ const footer = el('div', 'footer');
6482
+ footer.appendChild(el('span', 'footer-label', 'Lifecycle:'));
6483
+ footer.appendChild(lifecycle_button(component, 'Reload', (c) => c.reload()));
6484
+ footer.appendChild(lifecycle_button(component, 'Refresh', (c) => c.refresh()));
6485
+ footer.appendChild(lifecycle_button(component, 'Rerender', (c) => c.render()));
6486
+ footer.appendChild(lifecycle_button(component, 'Reload w/o data', (c) => c._reload_without_data()));
6487
+ modal.appendChild(footer);
6055
6488
  modal.classList.add(`${PREFIX}modal-open`);
6056
6489
  }
6490
+ /**
6491
+ * One footer button. Runs its lifecycle call on a live component, logs whatever it
6492
+ * throws or rejects with, and re-opens the modal once the call settles so the panel
6493
+ * (lifecycle row above all) describes the state the button just produced.
6494
+ */
6495
+ function lifecycle_button(component, label, run) {
6496
+ const button = el('button', 'button footer-button', label);
6497
+ button.addEventListener('click', () => {
6498
+ if (component.$.hasClass('_Component_Stopped'))
6499
+ return; // dead component: nothing to do
6500
+ let result;
6501
+ try {
6502
+ result = run(component);
6503
+ }
6504
+ catch (error) {
6505
+ console.error(`[JQHTML debug] ${label} threw on <${component.component_name()}>`, error);
6506
+ return;
6507
+ }
6508
+ Promise.resolve(result)
6509
+ .catch((error) => {
6510
+ console.error(`[JQHTML debug] ${label} rejected on <${component.component_name()}>`, error);
6511
+ })
6512
+ .then(() => {
6513
+ // `show`, not `select`: the lifecycle buttons re-render the panel in place
6514
+ // and must not throw away the navigation stack behind it.
6515
+ if (state.enabled && state.modal_component === component)
6516
+ show(component);
6517
+ });
6518
+ });
6519
+ return button;
6520
+ }
6057
6521
  // ---------------------------------------------------------------------------
6058
6522
  // DOM helpers (shadow side)
6059
6523
  // ---------------------------------------------------------------------------
@@ -6290,13 +6754,12 @@ function init_jquery_plugin(jQuery) {
6290
6754
  const _jqhtml_original_jquery = jQuery;
6291
6755
  // Override jQuery constructor to handle component instances
6292
6756
  const JQueryWithComponentSupport = function (selector, context) {
6293
- // Check if selector is a JQHTML component instance
6294
- if (selector &&
6295
- typeof selector === 'object' &&
6296
- selector.$ &&
6297
- typeof selector.$sid === 'function' &&
6298
- typeof selector.id === 'function') {
6299
- // Return the component's jQuery element
6757
+ // Unwrap a component instance to its root element, so $(component) behaves
6758
+ // exactly like component.$ (addClass, find, [0] and every other jQuery method).
6759
+ // The check is an instanceof on the class itself: the previous duck-type tested
6760
+ // for an id() method, which Jqhtml_Component has never had, so this branch was
6761
+ // dead and $(component) wrapped the plain object instead.
6762
+ if (selector instanceof Jqhtml_Component) {
6300
6763
  return selector.$;
6301
6764
  }
6302
6765
  // Otherwise, call original jQuery constructor
@@ -6372,13 +6835,12 @@ function init_jquery_plugin(jQuery) {
6372
6835
  // Check if component already exists on this element
6373
6836
  const existingComponent = element.data('_component');
6374
6837
  if (existingComponent) {
6375
- // Stop existing component (with error handling to continue on failure)
6376
- try {
6377
- existingComponent.stop();
6378
- }
6379
- catch (error) {
6380
- console.warn('[JQHTML] Error stopping existing component during replacement:', error);
6381
- }
6838
+ // Stop the existing component. A throwing stop() is NOT swallowed: the caller
6839
+ // asked to replace a live component, and overwriting one whose on_stop() failed
6840
+ // would leave its timers, intervals and listeners running against DOM that is
6841
+ // about to be destroyed. Let the throw reach the caller with the old component
6842
+ // still in place, so the failure is visible and recoverable.
6843
+ existingComponent.stop();
6382
6844
  // Remove component classes (any class starting with a capital letter, or a
6383
6845
  // component name with the reserved underscore prefix - except BEM classes)
6384
6846
  const classes = element.attr('class');
@@ -6394,7 +6856,15 @@ function init_jquery_plugin(jQuery) {
6394
6856
  const capital_first = cls[0] === cls[0].toUpperCase() && cls[0] !== cls[0].toLowerCase();
6395
6857
  return !capital_first && !is_component_name(cls);
6396
6858
  });
6397
- element.attr('class', nonComponentClasses.join(' '));
6859
+ // Drop the attribute entirely when nothing survived the strip: attr('class', '')
6860
+ // leaves a `class=""` in the markup, which is noise in the DOM and in snapshot
6861
+ // comparisons for an element that now has no classes at all.
6862
+ if (nonComponentClasses.length > 0) {
6863
+ element.attr('class', nonComponentClasses.join(' '));
6864
+ }
6865
+ else {
6866
+ element.removeAttr('class');
6867
+ }
6398
6868
  }
6399
6869
  // Remove component data
6400
6870
  element.removeData('_component');
@@ -6435,18 +6905,35 @@ function init_jquery_plugin(jQuery) {
6435
6905
  const expectedTag = args._tag || template.tag || 'div';
6436
6906
  const currentTag = element.prop('tagName').toLowerCase();
6437
6907
  if (currentTag !== expectedTag.toLowerCase()) {
6438
- // Tag mismatch
6439
- if (args._inner_html) {
6440
- // Replace element with correct tag (only when using server-rendered content)
6908
+ // Tag mismatch. Replace whenever we can actually do it: the element must be in
6909
+ // a document (replaceWith() on a parentless element is a silent no-op that would
6910
+ // strand the component on a detached node) and must not be <body>.
6911
+ //
6912
+ // This used to require a truthy args._inner_html, i.e. server-rendered content.
6913
+ // boot.ts always sets _inner_html - but it empties the placeholder first, so a
6914
+ // placeholder with no server content carries '' and fell through to a warning,
6915
+ // hydrating into the wrong tag. Hydration of an empty placeholder still works:
6916
+ // the copied innerHTML is simply ''.
6917
+ const oldEl = element[0];
6918
+ const can_replace = currentTag !== 'body' && !!(oldEl && oldEl.parentNode);
6919
+ if (can_replace) {
6441
6920
  const newElement = jQuery(`<${expectedTag}></${expectedTag}>`);
6442
6921
  // Copy all attributes from old element to new element
6443
- const oldEl = element[0];
6444
6922
  if (oldEl && oldEl.attributes) {
6445
6923
  for (let i = 0; i < oldEl.attributes.length; i++) {
6446
6924
  const attr = oldEl.attributes[i];
6447
6925
  newElement.attr(attr.name, attr.value);
6448
6926
  }
6449
6927
  }
6928
+ // Carry the jQuery data store across. $-args and anything the caller stashed
6929
+ // with .data() live there, and they are read back off the element during
6930
+ // component construction - dropping them silently changed the component's args.
6931
+ const old_data = element.data();
6932
+ if (old_data) {
6933
+ for (const key of Object.keys(old_data)) {
6934
+ newElement.data(key, old_data[key]);
6935
+ }
6936
+ }
6450
6937
  // Copy innerHTML
6451
6938
  newElement.html(element.html());
6452
6939
  // Replace in DOM
@@ -6761,12 +7248,14 @@ function init(jQuery, config) {
6761
7248
  }
6762
7249
  }
6763
7250
  // Version - will be replaced during build with actual version from package.json
6764
- const version = '2.3.67';
7251
+ const version = '2.3.70';
6765
7252
  // Default export with all functionality
6766
7253
  const jqhtml = {
6767
7254
  // Core
6768
7255
  Jqhtml_Component,
6769
7256
  LifecycleManager,
7257
+ // Internal - exposed for debugging and for tests that inspect coordination state
7258
+ Load_Coordinator,
6770
7259
  // Registry
6771
7260
  register,
6772
7261
  register_component,
@@ -6782,7 +7271,6 @@ const jqhtml = {
6782
7271
  // Template system
6783
7272
  process_instructions,
6784
7273
  extract_slots,
6785
- render_template,
6786
7274
  escape_html,
6787
7275
  escape_html_nl2br,
6788
7276
  // Version property - internal
@@ -6905,5 +7393,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
6905
7393
  }
6906
7394
  }
6907
7395
 
6908
- 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, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
7396
+ 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 };
6909
7397
  //# sourceMappingURL=jqhtml-core.esm.js.map