@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.
package/dist/index.js CHANGED
@@ -1,3 +1,194 @@
1
+ /**
2
+ * JQHTML Debug Module
3
+ *
4
+ * Provides comprehensive debugging capabilities for JQHTML components
5
+ */
6
+ // Global debug state
7
+ let performanceMetrics = new Map();
8
+ /**
9
+ * Development warning helper
10
+ * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
11
+ */
12
+ function devWarn(message) {
13
+ // Check if warnings are suppressed
14
+ if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
15
+ return;
16
+ }
17
+ // Check if in production mode
18
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
19
+ return;
20
+ }
21
+ console.warn(`[JQHTML Dev Warning] ${message}`);
22
+ }
23
+ // Get global jqhtml object
24
+ function getJqhtml() {
25
+ if (typeof window !== 'undefined' && window.jqhtml) {
26
+ return window.jqhtml;
27
+ }
28
+ // Fallback: try to get from global if available
29
+ if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
30
+ return globalThis.jqhtml;
31
+ }
32
+ throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
33
+ 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
34
+ }
35
+ // Visual flash effect
36
+ function flashComponent(component, eventType) {
37
+ const jqhtml = getJqhtml();
38
+ if (!jqhtml?.debug?.flashComponents)
39
+ return;
40
+ const duration = jqhtml.debug.flashDuration || 500;
41
+ const colors = jqhtml.debug.flashColors || {};
42
+ const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
43
+ eventType === 'render' ? '#27ae60' :
44
+ '#9b59b6');
45
+ // Store original border
46
+ const originalBorder = component.$.css('border');
47
+ // Apply flash border
48
+ component.$.css({
49
+ 'border': `2px solid ${color}`,
50
+ 'transition': `border ${duration}ms ease-out`
51
+ });
52
+ // Remove after duration
53
+ setTimeout(() => {
54
+ component.$.css('border', originalBorder || '');
55
+ }, duration);
56
+ }
57
+ // Log lifecycle event
58
+ function logLifecycle(component, phase, status) {
59
+ const jqhtml = getJqhtml();
60
+ if (!jqhtml?.debug)
61
+ return;
62
+ const shouldLog = jqhtml.debug.logFullLifecycle ||
63
+ (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
64
+ if (!shouldLog)
65
+ return;
66
+ const componentName = component.constructor.name;
67
+ const timestamp = new Date().toISOString();
68
+ const prefix = `[JQHTML ${timestamp}]`;
69
+ if (status === 'start') {
70
+ console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
71
+ // Start performance tracking
72
+ if (jqhtml.debug.profilePerformance) {
73
+ performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
74
+ }
75
+ }
76
+ else {
77
+ let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
78
+ // Add performance data
79
+ if (jqhtml.debug.profilePerformance) {
80
+ const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
81
+ if (startTime) {
82
+ const duration = Date.now() - startTime;
83
+ message += ` (${duration}ms)`;
84
+ // Highlight slow renders
85
+ if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
86
+ duration > jqhtml.debug.highlightSlowRenders) {
87
+ console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
88
+ component.$.css('outline', '2px dashed red');
89
+ }
90
+ }
91
+ }
92
+ console.log(message);
93
+ // Visual feedback
94
+ if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
95
+ flashComponent(component, phase);
96
+ }
97
+ }
98
+ // Update component tree if enabled
99
+ if (jqhtml.debug.showComponentTree) {
100
+ updateComponentTree();
101
+ }
102
+ }
103
+ // Apply delays based on lifecycle phase
104
+ function applyDebugDelay(phase) {
105
+ const jqhtml = getJqhtml();
106
+ if (!jqhtml?.debug)
107
+ return;
108
+ let delayMs = 0;
109
+ switch (phase) {
110
+ case 'component':
111
+ delayMs = jqhtml.debug.delayAfterComponent || 0;
112
+ break;
113
+ case 'render':
114
+ delayMs = jqhtml.debug.delayAfterRender || 0;
115
+ break;
116
+ case 'rerender':
117
+ delayMs = jqhtml.debug.delayAfterRerender || 0;
118
+ break;
119
+ }
120
+ if (delayMs > 0) {
121
+ console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
122
+ }
123
+ }
124
+ // Log instruction processing
125
+ function logInstruction(type, data) {
126
+ const jqhtml = getJqhtml();
127
+ if (!jqhtml?.debug?.logInstructionProcessing)
128
+ return;
129
+ console.log(`[JQHTML Instruction] ${type}:`, data);
130
+ }
131
+ // Log data changes
132
+ function logDataChange(component, property, oldValue, newValue) {
133
+ const jqhtml = getJqhtml();
134
+ if (!jqhtml?.debug?.traceDataFlow)
135
+ return;
136
+ console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
137
+ }
138
+ // Update component tree visualization
139
+ function updateComponentTree() {
140
+ // This would update a debug panel if implemented
141
+ // For now, just log the tree structure periodically
142
+ console.log('[JQHTML Tree] Component hierarchy updated');
143
+ }
144
+ // Router dispatch logging
145
+ function logDispatch(url, route, params, verbose = false) {
146
+ const jqhtml = getJqhtml();
147
+ if (!jqhtml?.debug)
148
+ return;
149
+ const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
150
+ if (!shouldLog)
151
+ return;
152
+ const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
153
+ if (isVerbose) {
154
+ console.group(`[JQHTML Router] Dispatching: ${url}`);
155
+ console.log('Matched route:', route);
156
+ console.log('Extracted params:', params);
157
+ console.log('Route component:', route.component);
158
+ console.log('Route layout:', route.layout);
159
+ console.log('Route meta:', route.meta);
160
+ console.groupEnd();
161
+ }
162
+ else {
163
+ console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
164
+ }
165
+ }
166
+ // Check if sequential processing is enabled
167
+ function isSequentialProcessing() {
168
+ const jqhtml = getJqhtml();
169
+ return jqhtml?.debug?.sequentialProcessing || false;
170
+ }
171
+ // Error handling with break on error
172
+ function handleComponentError(component, phase, error) {
173
+ const jqhtml = getJqhtml();
174
+ console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
175
+ if (jqhtml?.debug?.breakOnError) {
176
+ debugger; // This will pause execution in dev tools
177
+ }
178
+ }
179
+ // Additional debug suggestions that could be implemented:
180
+ //
181
+ // 1. Component Inspector - Click on any component to see its data/args/state
182
+ // 2. Time Travel Debugging - Record state changes and replay them
183
+ // 3. Network Request Tracking - Log all AJAX calls made during load()
184
+ // 4. Memory Leak Detection - Track component creation/destruction
185
+ // 5. Template Compilation Debugging - Show compiled template functions
186
+ // 6. Event Flow Visualization - Show event bubbling through components
187
+ // 7. Dependency Graph - Show which components depend on which data
188
+ // 8. Hot Reload Support - Reload components without losing state
189
+ // 9. Performance Budgets - Warn when components exceed size/time limits
190
+ // 10. Accessibility Auditing - Check for missing ARIA attributes
191
+
1
192
  /**
2
193
  * JQHTML v2 Lifecycle Manager
3
194
  *
@@ -46,8 +237,9 @@ class LifecycleManager {
46
237
  // Check if stopped during create
47
238
  if (component._stopped)
48
239
  return;
49
- // Trigger create event
50
- component.trigger('create');
240
+ // NOTE: no trigger('create') here. component.create() already fires it exactly
241
+ // once - its `_ready_state >= 1` guard makes a second create() a no-op, so this
242
+ // duplicate trigger only ever double-fired the event for subscribers.
51
243
  // Check for lifecycle truncation flags
52
244
  const load_only = component._load_only;
53
245
  const load_render_only = component._load_render_only;
@@ -173,8 +365,30 @@ class LifecycleManager {
173
365
  return;
174
366
  }
175
367
  catch (error) {
176
- console.error(`Error booting component ${component.component_name()}:`, error);
177
- throw error;
368
+ // A boot that throws must leave a STOPPED component behind, not a half-booted
369
+ // one. The component never reaches 'ready', so an ancestor parked in
370
+ // _wait_for_children_ready() can only be released by the child's 'stop' event;
371
+ // nothing else stops it, so the whole subtree above it would hang forever.
372
+ //
373
+ // And the throw must stop here: _boot() is called unawaited by
374
+ // instruction-processor.initialize_component() and by the jQuery plugin, so a
375
+ // rethrow surfaces as an unhandled promise rejection instead of an error report.
376
+ // The error object stays a console.error argument so callers (and tests) that
377
+ // grep the console for the thrown message still see it.
378
+ try {
379
+ handleComponentError(component, 'boot', error);
380
+ }
381
+ catch {
382
+ // handleComponentError needs the global jqhtml object and throws without it.
383
+ // Never lose the original error to a failure in the reporting path.
384
+ console.error(`Error booting component ${component.component_name()}:`, error);
385
+ }
386
+ try {
387
+ component._stop();
388
+ }
389
+ catch (stop_error) {
390
+ console.error(`[JQHTML] on_stop() threw while unwinding a failed boot:`, stop_error);
391
+ }
178
392
  }
179
393
  }
180
394
  /**
@@ -183,20 +397,6 @@ class LifecycleManager {
183
397
  unregister_component(component) {
184
398
  this.active_components.delete(component);
185
399
  }
186
- /**
187
- * Wait for all active components to reach ready state
188
- */
189
- async wait_for_ready() {
190
- const ready_promises = [];
191
- for (const component of this.active_components) {
192
- if (component._ready_state < 4) {
193
- ready_promises.push(new Promise((resolve) => {
194
- component.on('ready', () => resolve());
195
- }));
196
- }
197
- }
198
- await Promise.all(ready_promises);
199
- }
200
400
  }
201
401
 
202
402
  /**
@@ -659,30 +859,32 @@ function process_instructions(instructions, target, context, slots) {
659
859
  // Set innerHTML once - this is the performance win
660
860
  // Use native innerHTML for better performance
661
861
  target[0].innerHTML = html.join('');
662
- // Second pass: initialize special attributes and events
663
- for (const [tid, tagData] of Object.entries(tagElements)) {
664
- // Use native querySelector for better performance
665
- const el = target[0].querySelector(`[data-tid="${tid}"]`);
666
- if (el) {
667
- const element = $(el);
668
- el.removeAttribute('data-tid');
862
+ // Second pass: initialize special attributes and events.
863
+ // One querySelectorAll over the freshly injected subtree, in document
864
+ // order, rather than one querySelector per tracked element: a scan per
865
+ // element made this pass quadratic in the size of the template. Child
866
+ // components have not rendered yet (their elements are empty), so every
867
+ // data-tid here belongs to this level.
868
+ const root = target[0];
869
+ for (const el of Array.from(root.querySelectorAll('[data-tid]'))) {
870
+ const tagData = tagElements[el.getAttribute('data-tid')];
871
+ el.removeAttribute('data-tid');
872
+ if (tagData) {
669
873
  // Bind to the component whose template wrote this element, which for
670
874
  // spliced content is not the component being rendered.
671
- apply_attributes(element, tagData.attrs, tagData.context);
875
+ apply_attributes($(el), tagData.attrs, tagData.context);
672
876
  }
673
877
  }
674
878
  // Third pass: initialize and boot components in parallel
675
879
  // Like v1, all sibling components at this level boot simultaneously
676
880
  // DO NOT await - let children boot in background while parent continues
677
- for (const [cid, compData] of Object.entries(components)) {
678
- // Use native querySelector for better performance
679
- const el = target[0].querySelector(`[data-cid="${cid}"]`);
680
- if (el) {
681
- const element = $(el);
682
- el.removeAttribute('data-cid');
881
+ for (const el of Array.from(root.querySelectorAll('[data-cid]'))) {
882
+ const compData = components[el.getAttribute('data-cid')];
883
+ el.removeAttribute('data-cid');
884
+ if (compData) {
683
885
  // Boot this component (which will render and boot its children recursively)
684
886
  // Fire and forget - don't wait for boot to complete
685
- initialize_component(element, compData);
887
+ initialize_component($(el), compData);
686
888
  }
687
889
  }
688
890
  }
@@ -747,21 +949,13 @@ function process_tag_to_html(instruction, html, tagElements, components, context
747
949
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
748
950
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
749
951
  (typeof value === 'string' || typeof value === 'number')) {
750
- if (key === 'id' && tid) {
751
- // Special handling for id attribute - scope to parent component's _cid
752
- // This is for regular id="foo" attributes that need scoping (rare case)
753
- // Most scoping happens via $sid attribute which becomes data-sid
754
- // Don't double-scope if already scoped (contains :)
755
- if (typeof value === 'string' && value.includes(':')) {
756
- html.push(` id="${value}"`);
757
- }
758
- else {
759
- html.push(` id="${value}:${context._cid}"`);
760
- }
761
- }
762
- else {
763
- html.push(` ${key}="${value}"`);
764
- }
952
+ // A hand-written `id` is emitted exactly as written - including on a tracked
953
+ // element. It used to be rewritten to `<value>:<cid>` whenever the element
954
+ // happened to carry some OTHER tracked attribute, so `<label id="x" @click=…>`
955
+ // and `<label id="x">` disagreed. Per-instance ids are what $sid is for, and
956
+ // the compiler emits those already scoped (codegen.ts), so they arrive here as
957
+ // ordinary values and pass straight through.
958
+ html.push(` ${key}="${escape_attribute(value)}"`);
765
959
  }
766
960
  }
767
961
  // Close opening tag
@@ -772,6 +966,16 @@ function process_tag_to_html(instruction, html, tagElements, components, context
772
966
  html.push('>');
773
967
  }
774
968
  }
969
+ /**
970
+ * A double quote inside an attribute value would end the attribute in the
971
+ * HTML string this pass builds; an interpolated value can never legitimately
972
+ * do that, so it is encoded. Nothing else is touched - the value is otherwise
973
+ * emitted exactly as the template produced it.
974
+ */
975
+ function escape_attribute(value) {
976
+ const s = typeof value === 'string' ? value : String(value);
977
+ return s.indexOf('"') === -1 ? s : s.replace(/"/g, '&quot;');
978
+ }
775
979
  /**
776
980
  * Process a component instruction to HTML
777
981
  */
@@ -1124,233 +1328,42 @@ async function initialize_component(element, compData) {
1124
1328
  if (contentFn) {
1125
1329
  options._innerhtml_function = contentFn;
1126
1330
  }
1127
- // Pass named slots if provided
1128
- if (slots) {
1129
- options._slots = slots;
1130
- }
1131
- // Always pass the invocation name: it is what component_name() reports, it
1132
- // selects the template when the class is inherited via an extends chain or
1133
- // is the bare Jqhtml_Component, and it is the class the element carries.
1134
- options._component_name = name;
1135
- // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1136
- // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1137
- if (props._load_only === true || props['$_load_only'] === true) {
1138
- options._load_only = true;
1139
- }
1140
- if (props._load_render_only === true || props['$_load_render_only'] === true) {
1141
- options._load_render_only = true;
1142
- }
1143
- // Create component instance - element first, then options
1144
- const instance = new ComponentClass(element, options);
1145
- // Set the instantiator (the component that rendered this one in their template)
1146
- instance._instantiator = context;
1147
- // Boot the component immediately - this runs its full lifecycle
1148
- await instance._boot();
1149
- }
1150
- /**
1151
- * Utility to extract slots from instructions
1152
- */
1153
- function extract_slots(instructions) {
1154
- const slots = {};
1155
- for (const instruction of instructions) {
1156
- if (typeof instruction === 'object' && 'slot' in instruction) {
1157
- const [name] = instruction.slot;
1158
- slots[name] = instruction;
1159
- }
1160
- }
1161
- return slots;
1162
- }
1163
-
1164
- /**
1165
- * JQHTML Debug Module
1166
- *
1167
- * Provides comprehensive debugging capabilities for JQHTML components
1168
- */
1169
- // Global debug state
1170
- let performanceMetrics = new Map();
1171
- /**
1172
- * Development warning helper
1173
- * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
1174
- */
1175
- function devWarn(message) {
1176
- // Check if warnings are suppressed
1177
- if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
1178
- return;
1179
- }
1180
- // Check if in production mode
1181
- if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
1182
- return;
1183
- }
1184
- console.warn(`[JQHTML Dev Warning] ${message}`);
1185
- }
1186
- // Get global jqhtml object
1187
- function getJqhtml() {
1188
- if (typeof window !== 'undefined' && window.jqhtml) {
1189
- return window.jqhtml;
1190
- }
1191
- // Fallback: try to get from global if available
1192
- if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
1193
- return globalThis.jqhtml;
1194
- }
1195
- throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
1196
- 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
1197
- }
1198
- // Visual flash effect
1199
- function flashComponent(component, eventType) {
1200
- const jqhtml = getJqhtml();
1201
- if (!jqhtml?.debug?.flashComponents)
1202
- return;
1203
- const duration = jqhtml.debug.flashDuration || 500;
1204
- const colors = jqhtml.debug.flashColors || {};
1205
- const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
1206
- eventType === 'render' ? '#27ae60' :
1207
- '#9b59b6');
1208
- // Store original border
1209
- const originalBorder = component.$.css('border');
1210
- // Apply flash border
1211
- component.$.css({
1212
- 'border': `2px solid ${color}`,
1213
- 'transition': `border ${duration}ms ease-out`
1214
- });
1215
- // Remove after duration
1216
- setTimeout(() => {
1217
- component.$.css('border', originalBorder || '');
1218
- }, duration);
1219
- }
1220
- // Log lifecycle event
1221
- function logLifecycle(component, phase, status) {
1222
- const jqhtml = getJqhtml();
1223
- if (!jqhtml?.debug)
1224
- return;
1225
- const shouldLog = jqhtml.debug.logFullLifecycle ||
1226
- (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
1227
- if (!shouldLog)
1228
- return;
1229
- const componentName = component.constructor.name;
1230
- const timestamp = new Date().toISOString();
1231
- const prefix = `[JQHTML ${timestamp}]`;
1232
- if (status === 'start') {
1233
- console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
1234
- // Start performance tracking
1235
- if (jqhtml.debug.profilePerformance) {
1236
- performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
1237
- }
1238
- }
1239
- else {
1240
- let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
1241
- // Add performance data
1242
- if (jqhtml.debug.profilePerformance) {
1243
- const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
1244
- if (startTime) {
1245
- const duration = Date.now() - startTime;
1246
- message += ` (${duration}ms)`;
1247
- // Highlight slow renders
1248
- if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
1249
- duration > jqhtml.debug.highlightSlowRenders) {
1250
- console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
1251
- component.$.css('outline', '2px dashed red');
1252
- }
1253
- }
1254
- }
1255
- console.log(message);
1256
- // Visual feedback
1257
- if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
1258
- flashComponent(component, phase);
1259
- }
1260
- }
1261
- // Update component tree if enabled
1262
- if (jqhtml.debug.showComponentTree) {
1263
- updateComponentTree();
1264
- }
1265
- }
1266
- // Apply delays based on lifecycle phase
1267
- function applyDebugDelay(phase) {
1268
- const jqhtml = getJqhtml();
1269
- if (!jqhtml?.debug)
1270
- return;
1271
- let delayMs = 0;
1272
- switch (phase) {
1273
- case 'component':
1274
- delayMs = jqhtml.debug.delayAfterComponent || 0;
1275
- break;
1276
- case 'render':
1277
- delayMs = jqhtml.debug.delayAfterRender || 0;
1278
- break;
1279
- case 'rerender':
1280
- delayMs = jqhtml.debug.delayAfterRerender || 0;
1281
- break;
1282
- }
1283
- if (delayMs > 0) {
1284
- console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
1285
- }
1286
- }
1287
- // Log instruction processing
1288
- function logInstruction(type, data) {
1289
- const jqhtml = getJqhtml();
1290
- if (!jqhtml?.debug?.logInstructionProcessing)
1291
- return;
1292
- console.log(`[JQHTML Instruction] ${type}:`, data);
1293
- }
1294
- // Log data changes
1295
- function logDataChange(component, property, oldValue, newValue) {
1296
- const jqhtml = getJqhtml();
1297
- if (!jqhtml?.debug?.traceDataFlow)
1298
- return;
1299
- console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
1300
- }
1301
- // Update component tree visualization
1302
- function updateComponentTree() {
1303
- // This would update a debug panel if implemented
1304
- // For now, just log the tree structure periodically
1305
- console.log('[JQHTML Tree] Component hierarchy updated');
1306
- }
1307
- // Router dispatch logging
1308
- function logDispatch(url, route, params, verbose = false) {
1309
- const jqhtml = getJqhtml();
1310
- if (!jqhtml?.debug)
1311
- return;
1312
- const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
1313
- if (!shouldLog)
1314
- return;
1315
- const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
1316
- if (isVerbose) {
1317
- console.group(`[JQHTML Router] Dispatching: ${url}`);
1318
- console.log('Matched route:', route);
1319
- console.log('Extracted params:', params);
1320
- console.log('Route component:', route.component);
1321
- console.log('Route layout:', route.layout);
1322
- console.log('Route meta:', route.meta);
1323
- console.groupEnd();
1324
- }
1325
- else {
1326
- console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
1331
+ // Pass named slots if provided
1332
+ if (slots) {
1333
+ options._slots = slots;
1327
1334
  }
1335
+ // Always pass the invocation name: it is what component_name() reports, it
1336
+ // selects the template when the class is inherited via an extends chain or
1337
+ // is the bare Jqhtml_Component, and it is the class the element carries.
1338
+ options._component_name = name;
1339
+ // Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
1340
+ // Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
1341
+ if (props._load_only === true || props['$_load_only'] === true) {
1342
+ options._load_only = true;
1343
+ }
1344
+ if (props._load_render_only === true || props['$_load_render_only'] === true) {
1345
+ options._load_render_only = true;
1346
+ }
1347
+ // Create component instance - element first, then options
1348
+ const instance = new ComponentClass(element, options);
1349
+ // Set the instantiator (the component that rendered this one in their template)
1350
+ instance._instantiator = context;
1351
+ // Boot the component immediately - this runs its full lifecycle
1352
+ await instance._boot();
1328
1353
  }
1329
- // Check if sequential processing is enabled
1330
- function isSequentialProcessing() {
1331
- const jqhtml = getJqhtml();
1332
- return jqhtml?.debug?.sequentialProcessing || false;
1333
- }
1334
- // Error handling with break on error
1335
- function handleComponentError(component, phase, error) {
1336
- const jqhtml = getJqhtml();
1337
- console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
1338
- if (jqhtml?.debug?.breakOnError) {
1339
- debugger; // This will pause execution in dev tools
1354
+ /**
1355
+ * Utility to extract slots from instructions
1356
+ */
1357
+ function extract_slots(instructions) {
1358
+ const slots = {};
1359
+ for (const instruction of instructions) {
1360
+ if (typeof instruction === 'object' && 'slot' in instruction) {
1361
+ const [name] = instruction.slot;
1362
+ slots[name] = instruction;
1363
+ }
1340
1364
  }
1365
+ return slots;
1341
1366
  }
1342
- // Additional debug suggestions that could be implemented:
1343
- //
1344
- // 1. Component Inspector - Click on any component to see its data/args/state
1345
- // 2. Time Travel Debugging - Record state changes and replay them
1346
- // 3. Network Request Tracking - Log all AJAX calls made during load()
1347
- // 4. Memory Leak Detection - Track component creation/destruction
1348
- // 5. Template Compilation Debugging - Show compiled template functions
1349
- // 6. Event Flow Visualization - Show event bubbling through components
1350
- // 7. Dependency Graph - Show which components depend on which data
1351
- // 8. Hot Reload Support - Reload components without losing state
1352
- // 9. Performance Budgets - Warn when components exceed size/time limits
1353
- // 10. Accessibility Auditing - Check for missing ARIA attributes
1354
1367
 
1355
1368
  /**
1356
1369
  * JQHTML Cache Key Serializer
@@ -1619,15 +1632,12 @@ class Load_Coordinator {
1619
1632
  /**
1620
1633
  * Check if a component should execute on_load() or wait for existing request
1621
1634
  * Returns true if component should execute (is leader), false if should wait (is follower)
1635
+ *
1636
+ * @param key - The dedup key the caller captured for this load. Every coordinator
1637
+ * method takes it, because args may legally change during on_load()
1638
+ * and a recomputed key would address a different (or no) entry.
1622
1639
  */
1623
- static should_execute_on_load(component) {
1624
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1625
- // No identity means no coordination. Without this, every un-keyable component
1626
- // would share the single `null` registry entry and adopt an unrelated
1627
- // component's data - the worst failure this system can produce.
1628
- if (key === null) {
1629
- return true;
1630
- }
1640
+ static should_execute_on_load(component, key) {
1631
1641
  const entry = this._registry.get(key);
1632
1642
  if (!entry) {
1633
1643
  // No existing request - this component becomes the leader
@@ -1646,26 +1656,27 @@ class Load_Coordinator {
1646
1656
  * Register a leader component that will execute on_load()
1647
1657
  * Creates coordination entry and returns a function to call when data is ready
1648
1658
  *
1659
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
1649
1660
  * @returns A function that accepts the final data and completes coordination
1650
1661
  */
1651
- static register_leader(component, on_load_promise) {
1652
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1653
- // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1654
- if (key === null) {
1655
- return () => { };
1656
- }
1657
- // Create a promise that we control - it resolves when complete_coordination is called
1662
+ static register_leader(component, key) {
1663
+ // Create a promise that we control - it settles when coordination completes or fails
1658
1664
  let resolve_promise;
1659
- const coordination_promise = new Promise((resolve) => {
1665
+ let reject_promise;
1666
+ const coordination_promise = new Promise((resolve, reject) => {
1660
1667
  resolve_promise = resolve;
1668
+ reject_promise = reject;
1661
1669
  });
1670
+ // A leader with no followers still has to be able to reject this promise on
1671
+ // error; without a handler that rejection surfaces as an unhandled rejection.
1672
+ coordination_promise.catch(() => { });
1662
1673
  const entry = {
1663
1674
  status: 'loading',
1664
- promise: coordination_promise, // Followers await THIS promise, not on_load_promise
1675
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
1665
1676
  resolve_promise,
1677
+ reject_promise,
1666
1678
  leader_component: component,
1667
1679
  leader_data: null,
1668
- leader_error: null,
1669
1680
  waiting: []
1670
1681
  };
1671
1682
  this._registry.set(key, entry);
@@ -1674,10 +1685,9 @@ class Load_Coordinator {
1674
1685
  }
1675
1686
  /**
1676
1687
  * Get the coordination promise for a follower component
1677
- * Returns a promise that resolves when the leader completes
1688
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
1678
1689
  */
1679
- static get_coordination_promise(component) {
1680
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1690
+ static get_coordination_promise(key) {
1681
1691
  const entry = this._registry.get(key);
1682
1692
  if (!entry || entry.status !== 'loading') {
1683
1693
  return null;
@@ -1689,7 +1699,7 @@ class Load_Coordinator {
1689
1699
  * Stores leader's data, marks entry as completed, and resolves the promise.
1690
1700
  * Followers retrieve the data themselves via get_leader_data().
1691
1701
  *
1692
- * @param key - The coordination key
1702
+ * @param key - The coordination key captured at registration
1693
1703
  * @param leader - The leader component
1694
1704
  * @param final_data - The final data after on_load and normalization
1695
1705
  * @private
@@ -1699,14 +1709,14 @@ class Load_Coordinator {
1699
1709
  if (!entry) {
1700
1710
  return;
1701
1711
  }
1702
- // Store the final data (already normalized by _apply_load_result)
1703
- // Deep clone to avoid sharing proxy objects between components
1712
+ // Stored as TEXT so each follower parses its own object graph - sharing one
1713
+ // decoded object would let one component's mutation reach every other.
1704
1714
  try {
1705
- entry.leader_data = JSON.parse(JSON.stringify(final_data));
1715
+ entry.leader_data = JSON.stringify(final_data);
1706
1716
  }
1707
1717
  catch (error) {
1708
- // If data can't be serialized, use direct reference (may have issues)
1709
- entry.leader_data = final_data;
1718
+ // Unserializable data cannot be shared; followers load independently.
1719
+ entry.leader_data = null;
1710
1720
  }
1711
1721
  entry.status = 'completed';
1712
1722
  if (window.jqhtml?.debug?.verbose) {
@@ -1714,22 +1724,22 @@ class Load_Coordinator {
1714
1724
  }
1715
1725
  // Resolve the promise - followers waiting on it will now wake up
1716
1726
  entry.resolve_promise();
1717
- // Note: We do NOT delete the entry here anymore.
1718
- // Followers need to retrieve the data after the promise resolves.
1719
- // Entry will be cleaned up when last follower retrieves data.
1727
+ // Nobody is waiting, so nothing will ever come back to collect the data.
1728
+ // Without this the registry grows once per distinct name+args, forever.
1729
+ if (entry.waiting.length === 0) {
1730
+ this._registry.delete(key);
1731
+ }
1720
1732
  }
1721
1733
  /**
1722
1734
  * Get leader's data for a follower component
1723
1735
  * Called by follower after coordination promise resolves
1724
- * Returns the data and cleans up if this is the last follower
1736
+ * Returns a freshly parsed copy and cleans up if this is the last follower
1737
+ *
1738
+ * @param key - The dedup key captured by the follower before it began waiting
1725
1739
  */
1726
- static get_leader_data(component) {
1727
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1728
- if (!key) {
1729
- return null;
1730
- }
1740
+ static get_leader_data(component, key) {
1731
1741
  const entry = this._registry.get(key);
1732
- if (!entry || entry.status !== 'completed') {
1742
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1733
1743
  return null;
1734
1744
  }
1735
1745
  // Remove this follower from the waiting list
@@ -1737,6 +1747,8 @@ class Load_Coordinator {
1737
1747
  if (follower_index !== -1) {
1738
1748
  entry.waiting.splice(follower_index, 1);
1739
1749
  }
1750
+ // One parse per follower: each gets its OWN object, never a shared reference.
1751
+ const data = JSON.parse(entry.leader_data);
1740
1752
  if (window.jqhtml?.debug?.verbose) {
1741
1753
  console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1742
1754
  }
@@ -1747,29 +1759,25 @@ class Load_Coordinator {
1747
1759
  console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1748
1760
  }
1749
1761
  }
1750
- return entry.leader_data;
1762
+ return data;
1751
1763
  }
1752
1764
  /**
1753
1765
  * Handle leader on_load() error
1754
- * Propagates error to all followers and cleans up entry
1766
+ * Rejects every waiting follower and clears the entry so the next component retries
1767
+ *
1768
+ * @param key - The dedup key captured at registration
1755
1769
  */
1756
- static handle_leader_error(component, error) {
1757
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1770
+ static handle_leader_error(key, error) {
1758
1771
  const entry = this._registry.get(key);
1759
1772
  if (!entry) {
1760
1773
  return;
1761
1774
  }
1762
- entry.leader_error = error;
1763
1775
  entry.status = 'failed';
1764
- console.error(`[Load Coordinator] Leader ${component._cid} on_load() failed for key: ${key}`, error);
1765
- // Propagate error to all followers
1766
- // Note: Followers will handle errors the same way as if their own on_load() failed
1767
- // This is transparent to the developer
1768
- for (const follower of entry.waiting) {
1769
- console.error(`[Load Coordinator] Follower ${follower._cid} failed due to leader error`, error);
1770
- // The follower's lifecycle will handle the error naturally
1771
- // as the promise rejection will propagate
1772
- }
1776
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
1777
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
1778
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
1779
+ // without rejecting left them awaiting a promise nobody could ever settle.
1780
+ entry.reject_promise(error);
1773
1781
  // Clear coordination entry so future requests can retry
1774
1782
  this._registry.delete(key);
1775
1783
  if (window.jqhtml?.debug?.verbose) {
@@ -1868,6 +1876,31 @@ Load_Coordinator._registry = new Map();
1868
1876
  * - Transient UI state (convenience, not required)
1869
1877
  *
1870
1878
  * @internal This class is not exposed in the public API
1879
+ *
1880
+ * ---------------------------------------------------------------------------
1881
+ * REPORTING WHAT WAS DROPPED
1882
+ *
1883
+ * `this.data` is always the serialized copy this file produces - in EVERY cache
1884
+ * mode, including 'none' (see component.ts `_apply_load_result`). Whatever the
1885
+ * serializer cannot express is therefore not merely missing from the cache, it
1886
+ * is missing from `this.data`, and an author needs to be told once rather than
1887
+ * discover it on the next cache hit.
1888
+ *
1889
+ * Two small optional parameters carry that:
1890
+ *
1891
+ * process_for_serialization(value, verbose, seen, path, collector?)
1892
+ * `collector(path, kind)` is invoked for every value that is dropped or
1893
+ * degraded. `path` is a dotted/bracketed path from the root the caller
1894
+ * named; `kind` is one of Strip_Kind. Paths are only BUILT when a
1895
+ * collector is present, so the no-collector path costs nothing.
1896
+ *
1897
+ * Jqhtml_Local_Storage.normalize_for_cache(value, component_name?)
1898
+ * With a component name, and only in development mode, each dropped path
1899
+ * prints exactly ONE console.warn for the life of the page (keyed
1900
+ * `${component_name}:${path}`, the same once-only shape as
1901
+ * warn_uncacheable_args in component-cache.ts). Production converts
1902
+ * silently. Neither ever throws.
1903
+ * ---------------------------------------------------------------------------
1871
1904
  */
1872
1905
  // ============================================================================
1873
1906
  // Class Registry for Serialization
@@ -1876,7 +1909,7 @@ Load_Coordinator._registry = new Map();
1876
1909
  const class_registry = Object.create(null);
1877
1910
  // Markers for serialized class instances (unique to avoid collisions with user data)
1878
1911
  // Replaced at build time by rollup (see rollup.config.js).
1879
- const CORE_VERSION = '2.3.67';
1912
+ const CORE_VERSION = '2.3.70';
1880
1913
  const CLASS_MARKER = '__jqhtml_class__';
1881
1914
  const PROPS_MARKER = '__jqhtml_props__';
1882
1915
  /**
@@ -1892,21 +1925,20 @@ function register_cache_class(klass) {
1892
1925
  }
1893
1926
  class_registry[klass.name] = klass;
1894
1927
  }
1895
- // ============================================================================
1896
- // Serialization (Object → String)
1897
- // ============================================================================
1898
1928
  /**
1899
1929
  * Serialize a value to JSON string, handling ES6 class instances recursively.
1900
1930
  * Returns null if serialization fails for any reason.
1901
1931
  *
1902
1932
  * @param value - The value to serialize
1903
1933
  * @param verbose - Whether to log warnings
1934
+ * @param path - Root path used when reporting drops (only used with a collector)
1935
+ * @param collector - Optional reporter, see the file header
1904
1936
  * @returns Serialized JSON string, or null if serialization failed
1905
1937
  */
1906
- function serialize_value(value, verbose) {
1938
+ function serialize_value(value, verbose, path = '', collector) {
1907
1939
  try {
1908
1940
  const seen = new WeakSet();
1909
- const processed = process_for_serialization(value, verbose, seen);
1941
+ const processed = process_for_serialization(value, verbose, seen, path, collector);
1910
1942
  if (processed === undefined) {
1911
1943
  // Serialization failed - undefined signals failure
1912
1944
  return null;
@@ -1922,14 +1954,23 @@ function serialize_value(value, verbose) {
1922
1954
  }
1923
1955
  /**
1924
1956
  * Recursively process a value for serialization.
1925
- * Returns undefined if serialization should fail (unserializable value encountered).
1957
+ *
1958
+ * Values the serializer cannot express are DROPPED, never passed through: the
1959
+ * result of this function is what becomes `this.data`, so letting a function or
1960
+ * a DOM node survive here would make `this.data` differ from what the cache can
1961
+ * ever return. A dropped property is omitted from an object and becomes `null`
1962
+ * in an array - JSON's own semantics for the same situation.
1963
+ *
1964
+ * Returns undefined for a value that has to be dropped.
1926
1965
  *
1927
1966
  * @param value - The value to process
1928
1967
  * @param verbose - Whether to log warnings
1929
1968
  * @param seen - WeakSet to detect circular references
1930
- * @returns Processed value ready for JSON.stringify, or undefined if failed
1969
+ * @param path - Dotted path of this value (only built when a collector is present)
1970
+ * @param collector - Optional reporter for dropped/degraded values
1971
+ * @returns Processed value ready for JSON.stringify, or undefined if dropped
1931
1972
  */
1932
- function process_for_serialization(value, verbose, seen) {
1973
+ function process_for_serialization(value, verbose, seen, path = '', collector) {
1933
1974
  // Handle null and undefined
1934
1975
  if (value === null) {
1935
1976
  return null;
@@ -1944,12 +1985,44 @@ function process_for_serialization(value, verbose, seen) {
1944
1985
  return value;
1945
1986
  }
1946
1987
  // bigint, symbol, function cannot be serialized
1988
+ if (typeof value === 'function') {
1989
+ collector?.(path, 'function');
1990
+ }
1991
+ else if (typeof value === 'symbol') {
1992
+ collector?.(path, 'symbol');
1993
+ }
1994
+ else if (typeof value === 'bigint') {
1995
+ collector?.(path, 'bigint');
1996
+ }
1947
1997
  if (verbose) {
1948
1998
  console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
1949
1999
  }
1950
2000
  // Return undefined to omit this value (not fail entire serialization)
1951
2001
  return undefined;
1952
2002
  }
2003
+ // Live objects that only LOOK like data. Each is checked before anything
2004
+ // treats it as a bag of properties, because walking one produces a
2005
+ // meaningless husk (a jQuery object becomes {0: ..., length: 1}) instead of
2006
+ // the drop the author needs to hear about.
2007
+ if (typeof value.nodeType === 'number') {
2008
+ collector?.(path, 'dom-node');
2009
+ return undefined;
2010
+ }
2011
+ if (value.jquery) {
2012
+ collector?.(path, 'jquery');
2013
+ return undefined;
2014
+ }
2015
+ // Duck-typed on purpose: importing Jqhtml_Component here would be a cycle
2016
+ // (component.ts imports this file). _cid + component_name() is the pair no
2017
+ // plain data object carries.
2018
+ if (value._cid !== undefined && typeof value.component_name === 'function') {
2019
+ collector?.(path, 'component');
2020
+ return undefined;
2021
+ }
2022
+ if (typeof value.then === 'function') {
2023
+ collector?.(path, 'promise');
2024
+ return undefined;
2025
+ }
1953
2026
  // Handle circular references.
1954
2027
  // `seen` tracks only the current ancestor path (the DFS stack): an object is
1955
2028
  // added on the way down and removed on the way back up (see finally below).
@@ -1958,10 +2031,11 @@ function process_for_serialization(value, verbose, seen) {
1958
2031
  // JSON round-trip semantics do not preserve identity anyway, so emitting a
1959
2032
  // copy per reference is correct; silently omitting the property is not.
1960
2033
  if (seen.has(value)) {
2034
+ collector?.(path, 'circular');
1961
2035
  if (verbose) {
1962
- console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
2036
+ console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
1963
2037
  }
1964
- return undefined; // Fail entire serialization
2038
+ return undefined; // Cut the cycle here; the rest of the tree still serializes
1965
2039
  }
1966
2040
  seen.add(value);
1967
2041
  try {
@@ -1970,7 +2044,7 @@ function process_for_serialization(value, verbose, seen) {
1970
2044
  const result = [];
1971
2045
  for (let i = 0; i < value.length; i++) {
1972
2046
  const item = value[i];
1973
- const processed = process_for_serialization(item, verbose, seen);
2047
+ const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
1974
2048
  // For arrays, we keep undefined as null to preserve indices
1975
2049
  result.push(processed === undefined ? null : processed);
1976
2050
  }
@@ -1987,8 +2061,9 @@ function process_for_serialization(value, verbose, seen) {
1987
2061
  if (value instanceof Map) {
1988
2062
  const entries = [];
1989
2063
  for (const [k, v] of value) {
1990
- const processedKey = process_for_serialization(k, verbose, seen);
1991
- const processedValue = process_for_serialization(v, verbose, seen);
2064
+ const entry_path = collector ? `${path}.get(${String(k)})` : '';
2065
+ const processedKey = process_for_serialization(k, verbose, seen, entry_path, collector);
2066
+ const processedValue = process_for_serialization(v, verbose, seen, entry_path, collector);
1992
2067
  entries.push([processedKey, processedValue]);
1993
2068
  }
1994
2069
  return {
@@ -1999,8 +2074,9 @@ function process_for_serialization(value, verbose, seen) {
1999
2074
  // Handle Set objects
2000
2075
  if (value instanceof Set) {
2001
2076
  const items = [];
2077
+ let index = 0;
2002
2078
  for (const item of value) {
2003
- items.push(process_for_serialization(item, verbose, seen));
2079
+ items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
2004
2080
  }
2005
2081
  return {
2006
2082
  [CLASS_MARKER]: 'Set',
@@ -2011,58 +2087,115 @@ function process_for_serialization(value, verbose, seen) {
2011
2087
  const ctor = value.constructor;
2012
2088
  // Handle registered class instances
2013
2089
  if (ctor && ctor.name && class_registry[ctor.name]) {
2014
- const props = {};
2015
- // Extract own enumerable properties (bypass any toJSON method)
2016
- for (const key of Object.keys(value)) {
2017
- const propValue = value[key];
2018
- const processed = process_for_serialization(propValue, verbose, seen);
2019
- // Only include if not undefined (failed serialization)
2020
- if (processed !== undefined || propValue === undefined) {
2021
- props[key] = processed;
2022
- }
2023
- }
2024
2090
  return {
2025
2091
  [CLASS_MARKER]: ctor.name,
2026
- [PROPS_MARKER]: props
2092
+ [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2027
2093
  };
2028
2094
  }
2029
2095
  // Handle plain objects (constructor is Object or no constructor)
2030
2096
  if (ctor === Object || ctor === undefined || ctor === null) {
2031
- const result = {};
2032
- for (const key of Object.keys(value)) {
2033
- const propValue = value[key];
2034
- const processed = process_for_serialization(propValue, verbose, seen);
2035
- // Only include if not undefined (unless original was undefined)
2036
- if (processed !== undefined || propValue === undefined) {
2037
- result[key] = processed;
2038
- }
2039
- }
2040
- return result;
2097
+ return process_own_properties(value, verbose, seen, path, collector);
2041
2098
  }
2042
- // Unregistered class instance - convert to plain object for hot/cold parity
2043
- // This ensures developers catch missing class registrations during development
2044
- // (methods will be lost, only properties preserved)
2099
+ // Unregistered class instance - convert to plain object for hot/cold parity.
2100
+ // Properties are preserved, prototype methods are lost, exactly as a cache
2101
+ // hit would produce, so the loss shows up on the first load rather than on
2102
+ // the next page view.
2103
+ collector?.(path, 'unregistered-class', ctor.name);
2045
2104
  if (verbose) {
2046
2105
  console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2047
2106
  `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2048
2107
  }
2049
- // Convert to plain object - properties are preserved, prototype methods are lost
2050
- const result = {};
2051
- for (const key of Object.keys(value)) {
2052
- const propValue = value[key];
2053
- const processed = process_for_serialization(propValue, verbose, seen);
2054
- // Only include if not undefined (unless original was undefined)
2055
- if (processed !== undefined || propValue === undefined) {
2056
- result[key] = processed;
2057
- }
2058
- }
2059
- return result;
2108
+ return process_own_properties(value, verbose, seen, path, collector);
2060
2109
  }
2061
2110
  finally {
2062
2111
  // Pop this object off the ancestor path now that its subtree is done.
2063
2112
  seen.delete(value);
2064
2113
  }
2065
2114
  }
2115
+ /**
2116
+ * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
2117
+ * object. Shared by plain objects, registered class instances and degraded
2118
+ * unregistered instances - all three keep exactly the same property semantics.
2119
+ */
2120
+ function process_own_properties(value, verbose, seen, path, collector) {
2121
+ const result = {};
2122
+ for (const key of Object.keys(value)) {
2123
+ const propValue = value[key];
2124
+ const processed = process_for_serialization(propValue, verbose, seen, collector ? `${path}.${key}` : '', collector);
2125
+ // Only include if not undefined (unless original was undefined)
2126
+ if (processed !== undefined || propValue === undefined) {
2127
+ result[key] = processed;
2128
+ }
2129
+ }
2130
+ return result;
2131
+ }
2132
+ // ============================================================================
2133
+ // Development warnings for dropped values
2134
+ // ============================================================================
2135
+ // One warning per component + path, for the life of the page. A list of 50 rows
2136
+ // each carrying an $on_select callback must print ONE warning, not 50, and must
2137
+ // not print it again on the next reload() or on the next instance of the same
2138
+ // component. Same shape as _warned_uncacheable in component-cache.ts.
2139
+ const _warned_stripped = new Set();
2140
+ /**
2141
+ * What was lost, and where the value actually belongs. Every message names a
2142
+ * destination, because "it was dropped" without "put it here instead" is not
2143
+ * actionable.
2144
+ */
2145
+ function strip_advice(kind, detail) {
2146
+ switch (kind) {
2147
+ case 'function':
2148
+ return 'a function cannot be serialized. Callbacks are configuration, not loaded data - ' +
2149
+ 'pass it in this.args (from the parent template) and keep only ids in this.data.';
2150
+ case 'promise':
2151
+ return 'a promise cannot be serialized. Await it inside on_load() and store the RESULT, ' +
2152
+ 'or hold the pending promise in this.state.';
2153
+ case 'dom-node':
2154
+ return 'a DOM node cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2155
+ case 'jquery':
2156
+ return 'a jQuery object cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2157
+ case 'component':
2158
+ return 'a Jqhtml_Component cannot be serialized. Keep component references in this.state ' +
2159
+ 'and address children with this.sid().';
2160
+ case 'symbol':
2161
+ return 'a symbol cannot be serialized. Use a string, or keep it in this.state.';
2162
+ case 'bigint':
2163
+ return 'a bigint cannot be serialized. Convert it to a string in on_load(), or keep it in this.state.';
2164
+ case 'circular':
2165
+ return 'it points back to one of its own ancestors, and the cycle was cut. Store the graph ' +
2166
+ 'flat (ids instead of back-references) in this.data, or keep the linked form in this.state.';
2167
+ case 'unregistered-class':
2168
+ return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2169
+ `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2170
+ `to get real instances back.`;
2171
+ }
2172
+ }
2173
+ /**
2174
+ * Build the collector normalize_for_cache() hands to the serializer, or
2175
+ * `undefined` when nobody is listening - production, or a caller that named no
2176
+ * component. Returning undefined (rather than a no-op function) is what keeps
2177
+ * the serializer from building path strings at all on the hot path.
2178
+ */
2179
+ function make_strip_collector(component_name) {
2180
+ if (!component_name)
2181
+ return undefined;
2182
+ if (get_config().mode !== 'development')
2183
+ return undefined;
2184
+ return (path, kind, detail) => {
2185
+ try {
2186
+ const dedupe_key = `${component_name}:${path}`;
2187
+ if (_warned_stripped.has(dedupe_key))
2188
+ return;
2189
+ _warned_stripped.add(dedupe_key);
2190
+ console.warn(`[JQHTML] <${component_name}> dropped ${path} from this.data: ${strip_advice(kind, detail)}\n` +
2191
+ ` this.data is always the serialized copy jqhtml stores in and restores from cache, ` +
2192
+ `so only serializable values survive on_load() - in every cache mode.`, { component: component_name, path, kind });
2193
+ }
2194
+ catch {
2195
+ // A warning must never be able to break a load.
2196
+ }
2197
+ };
2198
+ }
2066
2199
  // ============================================================================
2067
2200
  // Deserialization (String → Object)
2068
2201
  // ============================================================================
@@ -2406,36 +2539,36 @@ class Jqhtml_Local_Storage {
2406
2539
  /**
2407
2540
  * Perform a serialize/deserialize round-trip on a value.
2408
2541
  *
2409
- * This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
2410
- * (restored from cache). Unregistered class instances will be converted to plain
2411
- * objects, exactly as they would be if restored from cache.
2412
- *
2413
- * Use this to normalize data after on_load() so developers catch missing class
2414
- * registrations immediately rather than only after a page reload.
2542
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2543
+ * is aliased through, and a value the serializer cannot express is dropped
2544
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2545
+ * came from on_load() or from a cache hit - the old "on failure return the
2546
+ * original" branch broke exactly that promise, and only on the hard cases.
2415
2547
  *
2416
2548
  * @param {any} value - The value to normalize
2417
- * @returns {any} The value after serialize/deserialize round-trip, or original if serialization fails
2549
+ * @param {string} [component_name] - Names the component in development warnings.
2550
+ * Omit to convert silently (the unit tests and non-component callers).
2551
+ * @returns {any} The round-tripped value. Never the original object.
2418
2552
  */
2419
- static normalize_for_cache(value) {
2553
+ static normalize_for_cache(value, component_name) {
2420
2554
  const verbose = this._is_verbose();
2555
+ const collector = make_strip_collector(component_name);
2421
2556
  // Serialize to JSON string
2422
- const serialized = serialize_value(value, verbose);
2557
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2423
2558
  if (serialized === null) {
2424
- // Serialization failed completely - return original
2425
- // (This happens with circular references or other fatal issues)
2426
- if (verbose) {
2427
- console.warn('[JQHTML Cache] normalize_for_cache: Serialization failed, returning original value');
2428
- }
2429
- return value;
2559
+ // The value as a WHOLE could not be expressed (it was itself a function,
2560
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2561
+ // returning the original would put an uncacheable object into this.data.
2562
+ return {};
2430
2563
  }
2431
2564
  // Deserialize back to object
2432
2565
  const deserialized = deserialize_value(serialized, verbose);
2433
- if (deserialized === null) {
2434
- // Deserialization failed - return original
2435
- if (verbose) {
2436
- console.warn('[JQHTML Cache] normalize_for_cache: Deserialization failed, returning original value');
2437
- }
2438
- return value;
2566
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2567
+ // that legitimately serialized to the text `null` also comes back as null. The
2568
+ // serialized text disambiguates.
2569
+ if (deserialized === null && serialized !== 'null') {
2570
+ // Only reachable when a registered class constructor blew up on restore.
2571
+ return {};
2439
2572
  }
2440
2573
  return deserialized;
2441
2574
  }
@@ -2458,7 +2591,10 @@ class Jqhtml_Local_Storage {
2458
2591
  console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2459
2592
  // Clear only JQHTML keys and retry once
2460
2593
  this._clear_jqhtml_keys();
2461
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2594
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2595
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2596
+ // made the very next write look like a scope change and wipe the cache again.
2597
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2462
2598
  try {
2463
2599
  localStorage.setItem(scoped_key, serialized);
2464
2600
  }
@@ -2499,17 +2635,22 @@ Jqhtml_Local_Storage._initialized = false;
2499
2635
  * Extracted from component.ts - handles lifecycle event registration,
2500
2636
  * triggering, and state tracking.
2501
2637
  *
2502
- * Events have "sticky" semantics for lifecycle events: if an event has already
2503
- * occurred, new .on() handlers fire immediately with the stored data.
2638
+ * EVERY event is "sticky", not just the lifecycle ones: trigger() records the event
2639
+ * name and its payload, and a handler registered afterwards fires immediately with that
2640
+ * last payload. This is deliberate and applies to custom events too - a subscriber that
2641
+ * arrives after `trigger('row_selected', row)` still learns which row is selected,
2642
+ * which is what makes late-attaching UI work without a separate "current state" read.
2504
2643
  */
2505
2644
  /**
2506
2645
  * Register a callback for an event.
2507
2646
  *
2508
- * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2509
- * If a lifecycle event has already occurred, the callback fires immediately
2510
- * AND registers for future occurrences.
2647
+ * All events are "sticky", lifecycle (create, render, load, loaded, ready, stop) and
2648
+ * custom alike: if the event has already been triggered, the callback fires immediately
2649
+ * with the payload of that last trigger AND registers for future occurrences.
2511
2650
  *
2512
- * Custom events only fire when explicitly triggered via trigger().
2651
+ * invalidate(event_name) drops the stored marker when a component wants later
2652
+ * subscribers to wait for the next real occurrence instead (reload() does this for
2653
+ * 'ready').
2513
2654
  *
2514
2655
  * @param component - The component instance
2515
2656
  * @param event_name - Name of the event
@@ -2641,6 +2782,20 @@ function event_invalidate(component, event_name) {
2641
2782
  * - Data freeze/unfreeze enforcement via Proxy
2642
2783
  * - Detached on_load() execution with restricted access
2643
2784
  */
2785
+ /**
2786
+ * JSON for an error message. The value being rejected is arbitrary author data and may
2787
+ * be cyclic or otherwise unserializable - the diagnostic must never throw over the
2788
+ * error it is trying to report.
2789
+ */
2790
+ function safe_json(value) {
2791
+ try {
2792
+ const text = JSON.stringify(value);
2793
+ return text === undefined ? String(value) : text;
2794
+ }
2795
+ catch {
2796
+ return String(value);
2797
+ }
2798
+ }
2644
2799
  /**
2645
2800
  * Set up the `this.data` property on a component using Object.defineProperty
2646
2801
  * with a Proxy that enforces freeze/unfreeze semantics.
@@ -2654,9 +2809,78 @@ function event_invalidate(component, event_name) {
2654
2809
  */
2655
2810
  function setup_data_property(component) {
2656
2811
  let _data = {};
2812
+ // Wrapper memo for the deep-freeze proxies. Keyed by the RAW target object, so a
2813
+ // value read twice returns the same wrapper and `this.data.items === this.data.items`
2814
+ // holds - identity comparisons and `Set`/`Map` membership on nested data keep working.
2815
+ // WeakMap: a wrapper dies with the object it wraps.
2816
+ const frozen_wrappers = new WeakMap();
2817
+ // Only plain objects and arrays are wrapped. Anything with a real prototype
2818
+ // (Date, Map, Set, and classes restored by register_cache_class()) carries internal
2819
+ // slots that a Proxy receiver cannot satisfy: `this.data.created.getTime()` would
2820
+ // call the method with the proxy as `this` and throw. Those values pass through raw -
2821
+ // a shallow hole we accept, because breaking Date/Map/Set reads would be worse than
2822
+ // missing a rare mutation of one.
2823
+ const is_wrappable = (value) => {
2824
+ if (value === null || typeof value !== 'object')
2825
+ return false;
2826
+ if (Array.isArray(value))
2827
+ return true;
2828
+ const proto = Object.getPrototypeOf(value);
2829
+ return proto === Object.prototype || proto === null;
2830
+ };
2831
+ // Dotted path for error messages: array indices read as [0], keys as .name
2832
+ const join_path = (path, target, prop) => Array.isArray(target) ? `${path}[${String(prop)}]` : `${path}.${String(prop)}`;
2833
+ const frozen_violation = (path, verb, value) => {
2834
+ const value_text = verb === 'modify' ? ` = ${safe_json(value)}` : '';
2835
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to ${verb} ${path} outside of on_create() or on_load().\n\n` +
2836
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2837
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2838
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2839
+ `NESTED VALUES COUNT: the freeze is DEEP. Mutating an object or array inside\n` +
2840
+ `this.data (push, splice, assigning a property, delete) is modifying this.data.\n\n` +
2841
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2842
+ `FIX:\n` +
2843
+ ` ❌ In on_ready(): ${path}${value_text};\n` +
2844
+ ` ✅ In on_load(): fetch the value, so the framework can re-render\n` +
2845
+ ` ✅ For component-local bookkeeping: this.state (unrestricted, never cached)`);
2846
+ throw new Error(`[JQHTML] Cannot ${verb} ${path} outside of on_create() or on_load(). ` +
2847
+ `this.data is frozen after on_create() and unfrozen only during on_load(). ` +
2848
+ `The freeze is deep - nested objects and arrays are frozen too.`);
2849
+ };
2850
+ // Read-only view of a nested value. Handed out ONLY while __data_frozen is true
2851
+ // (the top-level get trap checks), so the traps here never need to re-check: a
2852
+ // wrapper the author kept a reference to stays read-only for its whole life.
2853
+ const create_frozen_wrapper = (obj, path) => {
2854
+ const existing = frozen_wrappers.get(obj);
2855
+ if (existing)
2856
+ return existing;
2857
+ const wrapper = new Proxy(obj, {
2858
+ get: (target, prop) => {
2859
+ const value = target[prop];
2860
+ // Functions are returned raw: array methods are invoked with the wrapper as
2861
+ // `this`, so push/splice still land in this trap's set/deleteProperty.
2862
+ return is_wrappable(value) ? create_frozen_wrapper(value, join_path(path, target, prop)) : value;
2863
+ },
2864
+ set: (target, prop, value) => frozen_violation(join_path(path, target, prop), 'modify', value),
2865
+ deleteProperty: (target, prop) => frozen_violation(join_path(path, target, prop), 'delete')
2866
+ });
2867
+ frozen_wrappers.set(obj, wrapper);
2868
+ return wrapper;
2869
+ };
2657
2870
  // Helper to create frozen proxy for data object
2658
2871
  const create_proxy = (obj) => {
2659
2872
  return new Proxy(obj, {
2873
+ get: (target, prop) => {
2874
+ const value = target[prop];
2875
+ // Deep freeze: while frozen, every nested object/array is read through a
2876
+ // read-only wrapper, so this.data.items.push(x) throws instead of silently
2877
+ // mutating. Unfrozen (on_create, on_load's detached run, _apply_load_result)
2878
+ // reads return the raw value, because those phases may legally mutate it.
2879
+ if (component.__data_frozen && is_wrappable(value)) {
2880
+ return create_frozen_wrapper(value, `this.data.${String(prop)}`);
2881
+ }
2882
+ return value;
2883
+ },
2660
2884
  set: (target, prop, value) => {
2661
2885
  if (component.__data_frozen) {
2662
2886
  console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
@@ -2726,10 +2950,13 @@ function setup_data_property(component) {
2726
2950
  * All other property access (this.$, this.$sid, etc.) throws errors.
2727
2951
  *
2728
2952
  * @param component - The component instance
2729
- * @param use_load_coordinator - Whether to use Load_Coordinator for deduplication
2953
+ * @param dedup_key - The dedup key captured by the caller, or null to run uncoordinated.
2954
+ * The key is passed in rather than recomputed because args may legally
2955
+ * change while on_load() runs, and a recomputed key would address a
2956
+ * different entry than the one this leader registered.
2730
2957
  * @returns The resulting data and optional coordination completion function
2731
2958
  */
2732
- async function execute_on_load_detached(component, use_load_coordinator = false) {
2959
+ async function execute_on_load_detached(component, dedup_key = null) {
2733
2960
  // Clone this.data from the snapshot captured after on_create()
2734
2961
  const data_clone = component.__initial_data_snapshot
2735
2962
  ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
@@ -2737,14 +2964,23 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2737
2964
  // Create a read-only proxy for args that blocks all modifications
2738
2965
  // Can't use JSON clone because args may contain functions (_slots, callbacks)
2739
2966
  const component_name = component.component_name();
2967
+ // Wrapper memo for this one on_load() run. Without it every nested read built a
2968
+ // fresh Proxy, so `this.args.filter !== this.args.filter` for an object arg and
2969
+ // identity comparisons inside on_load() silently failed. Lives only for the duration
2970
+ // of this call - args may legally be replaced between loads.
2971
+ const readonly_wrappers = new WeakMap();
2740
2972
  const create_readonly_proxy = (obj, path = 'this.args') => {
2741
2973
  if (obj === null || typeof obj !== 'object')
2742
2974
  return obj;
2743
- return new Proxy(obj, {
2975
+ const existing = readonly_wrappers.get(obj);
2976
+ if (existing)
2977
+ return existing;
2978
+ const proxy = new Proxy(obj, {
2744
2979
  get(target, prop) {
2745
2980
  const value = target[prop];
2746
- // Recursively wrap nested objects (but not functions)
2747
- if (value !== null && typeof value === 'object' && typeof value !== 'function') {
2981
+ // Recursively wrap nested objects. Functions are returned raw - `typeof value`
2982
+ // is already 'object' here, so a function never reaches this branch.
2983
+ if (value !== null && typeof value === 'object') {
2748
2984
  return create_readonly_proxy(value, `${path}.${String(prop)}`);
2749
2985
  }
2750
2986
  return value;
@@ -2766,6 +3002,8 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2766
3002
  `this.args is read-only in on_load().`);
2767
3003
  }
2768
3004
  });
3005
+ readonly_wrappers.set(obj, proxy);
3006
+ return proxy;
2769
3007
  };
2770
3008
  // Create a detached context object that on_load will operate on
2771
3009
  const detached_context = {
@@ -2825,27 +3063,22 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2825
3063
  `Only this.data can be modified in on_load().`);
2826
3064
  }
2827
3065
  });
2828
- // Create promise for this on_load() call
2829
- const on_load_promise = (async () => {
2830
- try {
2831
- await component._call_lifecycle('on_load', restricted_this);
2832
- }
2833
- catch (error) {
2834
- if (use_load_coordinator) {
2835
- // Handle error and notify coordinator
2836
- Load_Coordinator.handle_leader_error(component, error);
2837
- }
2838
- throw error;
2839
- }
2840
- })();
2841
- // If using Load_Coordinator, register as leader
2842
- // Note: We store the completion function but DON'T call it here
2843
- // It will be called by _load() after _apply_load_result() updates this.data
3066
+ // Register as leader BEFORE on_load() runs, so the error path always finds the entry
3067
+ // it has to reject. The completion function is stored, not called here: _load() calls
3068
+ // it after _apply_load_result() has updated this.data.
2844
3069
  let complete_coordination = null;
2845
- if (use_load_coordinator) {
2846
- complete_coordination = Load_Coordinator.register_leader(component, on_load_promise);
3070
+ if (dedup_key !== null) {
3071
+ complete_coordination = Load_Coordinator.register_leader(component, dedup_key);
3072
+ }
3073
+ try {
3074
+ await component._call_lifecycle('on_load', restricted_this);
3075
+ }
3076
+ catch (error) {
3077
+ if (dedup_key !== null) {
3078
+ Load_Coordinator.handle_leader_error(dedup_key, error);
3079
+ }
3080
+ throw error;
2847
3081
  }
2848
- await on_load_promise;
2849
3082
  // Note: We don't validate args changes here because external code (like parent
2850
3083
  // components calling reload()) is allowed to modify component.args during on_load().
2851
3084
  // The read-only proxy only prevents code INSIDE on_load() from modifying args.
@@ -2947,6 +3180,21 @@ function warn_uncacheable_component(component, uncacheable_property, uncacheable
2947
3180
  all_args: component.args ? Object.keys(component.args) : [],
2948
3181
  });
2949
3182
  }
3183
+ /**
3184
+ * Read an HTML-cache entry, rejecting anything that is not a well-formed snapshot.
3185
+ * Entries written by an older core are bare strings; they are treated as a miss (and
3186
+ * are cleared wholesale by the CORE_VERSION scope marker on the next version bump).
3187
+ */
3188
+ function read_html_snapshot(html_cache_key) {
3189
+ const entry = Jqhtml_Local_Storage.get(html_cache_key);
3190
+ if (entry !== null &&
3191
+ typeof entry === 'object' &&
3192
+ typeof entry.cid === 'string' &&
3193
+ typeof entry.html === 'string') {
3194
+ return entry;
3195
+ }
3196
+ return null;
3197
+ }
2950
3198
  /**
2951
3199
  * Read cache during create() phase.
2952
3200
  * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
@@ -2978,11 +3226,11 @@ function read_cache_in_create(component) {
2978
3226
  if (cache_mode === 'html') {
2979
3227
  // HTML cache mode - check for cached HTML to inject on first render
2980
3228
  const html_cache_key = `${cache_key}::html`;
2981
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2982
- if (cached_html !== null && typeof cached_html === 'string') {
3229
+ const cached_html = read_html_snapshot(html_cache_key);
3230
+ if (cached_html !== null) {
2983
3231
  component._cached_html = cached_html;
2984
3232
  if (window.jqhtml?.debug?.verbose) {
2985
- console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
3233
+ 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 });
2986
3234
  }
2987
3235
  }
2988
3236
  else {
@@ -3039,10 +3287,10 @@ function check_cache_on_reload(component) {
3039
3287
  if (cache_mode === 'html') {
3040
3288
  // HTML cache mode - check for cached HTML
3041
3289
  const html_cache_key = `${cache_key}::html`;
3042
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3043
- if (cached_html !== null && typeof cached_html === 'string') {
3290
+ const cached_html = read_html_snapshot(html_cache_key);
3291
+ if (cached_html !== null) {
3044
3292
  if (window.jqhtml?.debug?.verbose) {
3045
- 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 });
3293
+ 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 });
3046
3294
  }
3047
3295
  component._cached_html = cached_html;
3048
3296
  // Call _render() directly (not render()) since _reload() manages the full lifecycle
@@ -3083,7 +3331,11 @@ function write_html_cache_snapshot(component) {
3083
3331
  component._should_cache_html_after_ready = false;
3084
3332
  const html = component.$.html();
3085
3333
  const html_cache_key = `${component._cache_key}::html`;
3086
- Jqhtml_Local_Storage.set(html_cache_key, html);
3334
+ // Store the cid this markup was rendered under alongside it: every scoped id inside
3335
+ // reads "<name>:<cid>", and a reader cannot tell the snapshotting component's own
3336
+ // elements from its children's without knowing which cid was the owner's.
3337
+ // See Jqhtml_Component._rescope_cached_html().
3338
+ Jqhtml_Local_Storage.set(html_cache_key, { cid: component._cid, html });
3087
3339
  if (window.jqhtml?.debug?.verbose) {
3088
3340
  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 });
3089
3341
  }
@@ -3124,277 +3376,27 @@ function write_cache_on_loaded(component, data_changed) {
3124
3376
  }
3125
3377
 
3126
3378
  /**
3127
- * JQHTML v2 Template Renderer
3128
- *
3129
- * Connects compiled templates to components
3130
- * Processes instruction arrays and handles bindings
3131
- */
3132
- /**
3133
- * Process slot-only template inheritance
3134
- *
3135
- * When a component's template returns {_slots: {...}}, walk the prototype
3136
- * chain to find a parent template and invoke it with the child's slots as content.
3379
+ * HTML escaping for interpolated text.
3137
3380
  *
3138
- * @param component The component instance
3139
- * @param childSlots The slots object from the child template
3140
- * @returns [instructions, context] tuple or null if no parent found
3141
- */
3142
- async function process_slot_inheritance(component, childSlots) {
3143
- // Walk the prototype chain to find parent class with template
3144
- let currentClass = Object.getPrototypeOf(component.constructor);
3145
- console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
3146
- while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
3147
- const className = currentClass.name;
3148
- console.log(`[JQHTML] Checking parent class: ${className}`);
3149
- // Skip internal framework classes
3150
- if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
3151
- currentClass = Object.getPrototypeOf(currentClass);
3152
- continue;
3153
- }
3154
- // Try to find template for this parent class
3155
- try {
3156
- const parentTemplate = get_template(className);
3157
- console.log(`[JQHTML] Template found for ${className}:`, parentTemplate ? parentTemplate.name : 'null');
3158
- // Check if we got the default template (means no real template found)
3159
- if (parentTemplate && parentTemplate.name !== 'Jqhtml_Component') {
3160
- console.log(`[JQHTML] Invoking parent template ${className}`);
3161
- // Found a real parent template - invoke it with child's slots as content
3162
- const [parentInstructions, parentContext] = parentTemplate.render.call(component, component.data, component.args, childSlots // Pass child slots as content parameter
3163
- );
3164
- // Check if parent also returns slots (recursive inheritance)
3165
- if (parentInstructions && typeof parentInstructions === 'object' && parentInstructions._slots) {
3166
- // Parent also has slot-only template - recurse
3167
- console.log(`[JQHTML] Parent also slot-only, recursing`);
3168
- return await process_slot_inheritance(component, parentInstructions._slots);
3169
- }
3170
- // Parent returned HTML - we're done
3171
- console.log(`[JQHTML] Parent returned instructions, inheritance complete`);
3172
- return [parentInstructions, parentContext];
3173
- }
3174
- }
3175
- catch (error) {
3176
- console.warn(`[JQHTML] Error looking up parent template for ${className}:`, error);
3177
- }
3178
- // Move to next parent
3179
- currentClass = Object.getPrototypeOf(currentClass);
3180
- }
3181
- // No parent template found
3182
- console.warn(`[JQHTML] No parent template found after walking chain`);
3183
- return null;
3184
- }
3185
- /**
3186
- * Render a template for a component
3187
- * Templates are functions that return [instructions, context] tuples
3188
- */
3189
- async function render_template(component, template_fn) {
3190
- // Get template from registry if not provided
3191
- let render_fn = template_fn;
3192
- if (!render_fn) {
3193
- const template_def = get_template_by_class(component.constructor);
3194
- render_fn = template_def.render;
3195
- }
3196
- if (!render_fn) {
3197
- // No template, component should handle its own rendering
3198
- return;
3199
- }
3200
- // Clear existing content
3201
- component.$.empty();
3202
- // Execute template function
3203
- // Templates receive: (data, args, content)
3204
- // Provide a default empty content function if none provided
3205
- const defaultContent = () => '';
3206
- let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
3207
- );
3208
- // Check for slot-only template inheritance
3209
- // If instructions is {_slots: {...}}, this is a slot-only template
3210
- // We need to find the parent template and invoke it with these slots
3211
- if (instructions && typeof instructions === 'object' && instructions._slots) {
3212
- console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
3213
- const result = await process_slot_inheritance(component, instructions._slots);
3214
- if (result) {
3215
- console.log(`[JQHTML] Parent template found, using parent instructions`);
3216
- instructions = result[0];
3217
- context = result[1];
3218
- }
3219
- else {
3220
- console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
3221
- // No parent template found - return empty
3222
- instructions = [];
3223
- }
3224
- }
3225
- // Process instructions to generate DOM
3226
- await process_instructions(instructions, component.$, component);
3227
- // Process bindings after DOM is created
3228
- await process_bindings(component);
3229
- // Attach event handlers
3230
- await attach_event_handlers(component);
3231
- }
3232
- /**
3233
- * Process data bindings (attributes created by :prop syntax)
3234
- */
3235
- async function process_bindings(component) {
3236
- // Find all elements with data-bind-* attributes
3237
- component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
3238
- const el = $(element);
3239
- const attrs = element.attributes;
3240
- for (let i = 0; i < attrs.length; i++) {
3241
- const attr = attrs[i];
3242
- if (attr.name.startsWith('data-bind-')) {
3243
- const binding_type = attr.name.substring(10); // Remove 'data-bind-'
3244
- const expression = attr.value;
3245
- try {
3246
- // Evaluate expression in component context
3247
- const value = evaluate_expression(expression, component);
3248
- // Apply binding based on type
3249
- switch (binding_type) {
3250
- case 'prop':
3251
- // Generic property binding
3252
- const prop_name = el.attr('data-bind-prop-name') || 'value';
3253
- el.prop(prop_name, value);
3254
- break;
3255
- case 'value':
3256
- el.val(value);
3257
- break;
3258
- case 'text':
3259
- el.text(value);
3260
- break;
3261
- case 'html':
3262
- el.html(value);
3263
- break;
3264
- case 'class':
3265
- if (typeof value === 'object') {
3266
- // Object syntax: {active: true, disabled: false}
3267
- Object.entries(value).forEach(([className, enabled]) => {
3268
- el.toggleClass(className, !!enabled);
3269
- });
3270
- }
3271
- else {
3272
- // String syntax
3273
- el.addClass(String(value));
3274
- }
3275
- break;
3276
- case 'style':
3277
- if (typeof value === 'object') {
3278
- el.css(value);
3279
- }
3280
- else {
3281
- el.attr('style', String(value));
3282
- }
3283
- break;
3284
- default:
3285
- // Custom binding - set as attribute
3286
- el.attr(binding_type, value);
3287
- }
3288
- }
3289
- catch (error) {
3290
- console.error(`Error evaluating binding "${expression}":`, error);
3291
- }
3292
- }
3293
- }
3294
- });
3295
- }
3296
- /**
3297
- * Attach event handlers (attributes created by @event syntax)
3298
- */
3299
- async function attach_event_handlers(component) {
3300
- // Find all elements with data-on-* attributes
3301
- 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) => {
3302
- const el = $(element);
3303
- const attrs = element.attributes;
3304
- for (let i = 0; i < attrs.length; i++) {
3305
- const attr = attrs[i];
3306
- if (attr.name.startsWith('data-on-')) {
3307
- const event_name = attr.name.substring(8); // Remove 'data-on-'
3308
- const handler_expr = attr.value;
3309
- // Remove the attribute to avoid re-processing
3310
- el.removeAttr(attr.name);
3311
- // Attach event handler
3312
- el.on(event_name, function (event) {
3313
- try {
3314
- // Create handler function in component context
3315
- const handler = evaluate_handler(handler_expr, component);
3316
- if (typeof handler === 'function') {
3317
- // Call with component as 'this' and pass event
3318
- handler.call(component, event);
3319
- }
3320
- else {
3321
- // Expression to execute
3322
- evaluate_expression(handler_expr, component, { $event: event });
3323
- }
3324
- }
3325
- catch (error) {
3326
- console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
3327
- }
3328
- });
3329
- }
3330
- }
3331
- });
3332
- }
3333
- /**
3334
- * Evaluate an expression in component context
3335
- */
3336
- function evaluate_expression(expression, component, locals = {}) {
3337
- // Create evaluation context
3338
- const context = {
3339
- // Component properties
3340
- data: component.data,
3341
- args: component.args,
3342
- $: component.$,
3343
- // Component methods
3344
- $sid: component.$sid.bind(component),
3345
- // Locals (like $event)
3346
- ...locals
3347
- };
3348
- // Build function that evaluates in context
3349
- const keys = Object.keys(context);
3350
- const values = Object.values(context);
3351
- try {
3352
- // Use Function constructor for better performance than eval
3353
- const fn = new Function(...keys, `return (${expression})`);
3354
- return fn(...values);
3355
- }
3356
- catch (error) {
3357
- console.error(`Invalid expression: ${expression}`, error);
3358
- return undefined;
3359
- }
3360
- }
3361
- /**
3362
- * Evaluate a handler expression (might be method name or inline code)
3363
- */
3364
- function evaluate_handler(expression, component) {
3365
- // Check if it's a method name on the component
3366
- if (expression in component && typeof component[expression] === 'function') {
3367
- return component[expression];
3368
- }
3369
- // Otherwise treat as inline code
3370
- try {
3371
- return new Function('$event', `
3372
- const { data, args, $, emit, $sid } = this;
3373
- ${expression}
3374
- `).bind(component);
3375
- }
3376
- catch (error) {
3377
- console.error(`Invalid handler: ${expression}`, error);
3378
- return null;
3379
- }
3380
- }
3381
- /**
3382
- * Helper to escape HTML for safe output
3381
+ * Output matches what `div.textContent = s; div.innerHTML` produced before -
3382
+ * a text node serialises only &, < and > as entities, and null/undefined as
3383
+ * "" - but without touching the DOM. Interpolation runs for every value of
3384
+ * every render, and a createElement per value was the most expensive thing a
3385
+ * text-heavy template did.
3383
3386
  */
3384
- function escape_html(str) {
3385
- const div = document.createElement('div');
3386
- div.textContent = str;
3387
- return div.innerHTML;
3387
+ const NEEDS_ESCAPE = /[&<>]/;
3388
+ const ESCAPE_ALL = /[&<>]/g;
3389
+ const ENTITY = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
3390
+ const to_entity = (c) => ENTITY[c];
3391
+ function escape_html(value) {
3392
+ if (value === null || value === undefined)
3393
+ return '';
3394
+ const s = typeof value === 'string' ? value : String(value);
3395
+ return NEEDS_ESCAPE.test(s) ? s.replace(ESCAPE_ALL, to_entity) : s;
3388
3396
  }
3389
- /**
3390
- * Helper to escape HTML and convert newlines to <br /> tags
3391
- * Used by <%br= %> syntax for displaying user-entered text with line breaks
3392
- */
3393
- function escape_html_nl2br(str) {
3394
- const div = document.createElement('div');
3395
- div.textContent = str;
3396
- // Escape HTML first, then replace newlines with <br />
3397
- return div.innerHTML.replace(/\n/g, '<br />');
3397
+ /** Escaped, then newlines become <br /> - the <%br= %> construct. */
3398
+ function escape_html_nl2br(value) {
3399
+ return escape_html(value).replace(/\n/g, '<br />');
3398
3400
  }
3399
3401
 
3400
3402
  /**
@@ -3517,13 +3519,14 @@ function describe(value) {
3517
3519
  /**
3518
3520
  * JQHTML Component Lifecycle Queue
3519
3521
  *
3520
- * Serializes lifecycle operations (render, reload, refresh) per component.
3522
+ * Serializes lifecycle operations (render, reload, refresh, load) per component.
3521
3523
  * Replaces _create_debounced_function with a proper queue that:
3522
3524
  *
3523
3525
  * - At most one operation runs at a time per component
3524
- * - At most one operation is pending (waiting for the current to finish)
3525
- * - Same-type pending operations collapse (fan-in: all callers share one execution)
3526
- * - Different-type pending operations: new replaces old (old callers get the new result)
3526
+ * - At most one PENDING entry per type; different types queue behind each other FIFO
3527
+ * - Same-type pending operations collapse: the newest executor wins, and every
3528
+ * collapsed caller is settled with the value of the executor that actually ran
3529
+ * - A caller is settled BEFORE the next pending entry starts
3527
3530
  *
3528
3531
  * Boot bypasses this queue entirely - it runs the lifecycle directly.
3529
3532
  */
@@ -3531,92 +3534,106 @@ class Component_Queue {
3531
3534
  constructor() {
3532
3535
  /** Currently executing operation */
3533
3536
  this._current = null;
3534
- /** Next operation waiting to execute after current completes */
3535
- this._pending = null;
3537
+ /**
3538
+ * Operations waiting to run after the current one, in FIFO order.
3539
+ * At most one entry per type: a second reload() while a reload is pending
3540
+ * collapses into it, but a render() queued over a pending reload() is its
3541
+ * own entry and both run. Replacing the older entry (the previous behaviour)
3542
+ * dropped that operation entirely while still resolving its callers as if it
3543
+ * had run.
3544
+ */
3545
+ this._pending = [];
3536
3546
  }
3537
3547
  /**
3538
3548
  * Enqueue a lifecycle operation.
3539
3549
  *
3540
3550
  * Behavior:
3541
3551
  * - Nothing running → execute immediately
3542
- * - Something running, no pending → create pending entry
3543
- * - Something running, same type pending → collapse (share pending promise)
3544
- * - Something running, different type pending → replace pending (all callers get new result)
3552
+ * - Something running, no pending entry of this type → queue one (FIFO)
3553
+ * - Something running, same type pending → collapse: keep every caller, but
3554
+ * swap in the newest executor ("only the most recent call executes")
3555
+ *
3556
+ * Every caller collapsed onto one entry receives the value that entry's
3557
+ * executor returned - or its error, if it threw.
3545
3558
  *
3546
3559
  * @param type - Operation type ('render', 'reload', 'refresh', 'load')
3547
3560
  * @param executor - The async function to execute
3548
- * @returns Promise that resolves when the operation completes
3561
+ * @returns Promise that settles with the result of the execution that ran
3549
3562
  */
3550
3563
  enqueue(type, executor) {
3551
- // If nothing running, execute immediately
3564
+ // If nothing running, execute immediately.
3565
+ // The Promise executor runs synchronously, so the operation's synchronous
3566
+ // prologue (_render(), the ready invalidation) still happens in the calling
3567
+ // tick, as it did before the queue existed.
3552
3568
  if (!this._current) {
3553
- return this._execute(type, executor);
3554
- }
3555
- // Something is running — add to pending
3556
- if (this._pending) {
3557
- if (this._pending.type === type) {
3558
- // Same type pending → collapse (fan-in)
3559
- // All callers share the same future execution
3560
- return new Promise((resolve, reject) => {
3561
- this._pending.resolvers.push(resolve);
3562
- this._pending.rejecters.push(reject);
3563
- });
3564
- }
3565
- else {
3566
- // Different type pending → replace
3567
- // Old pending callers join the new operation
3568
- // (their operation was superseded by a different type)
3569
- const old_resolvers = this._pending.resolvers;
3570
- const old_rejecters = this._pending.rejecters;
3571
- return new Promise((resolve, reject) => {
3572
- this._pending = {
3573
- type,
3574
- executor,
3575
- resolvers: [...old_resolvers, resolve],
3576
- rejecters: [...old_rejecters, reject]
3577
- };
3578
- });
3579
- }
3569
+ return new Promise((resolve, reject) => {
3570
+ this._run({ type, executor, resolvers: [resolve], rejecters: [reject] });
3571
+ });
3580
3572
  }
3581
- // No pending entry yet — create one
3573
+ const existing = this._pending.find(entry => entry.type === type);
3574
+ if (existing) {
3575
+ // Same type already pending → collapse (fan-in).
3576
+ // The newest executor replaces the pending one: the documented rule is
3577
+ // "only the most recent call executes", and the older closure may carry
3578
+ // stale arguments. Keeping the OLD executor made two rapid `await load()`
3579
+ // calls resolve the second one `false` without ever running on_load().
3580
+ existing.executor = executor;
3581
+ return new Promise((resolve, reject) => {
3582
+ existing.resolvers.push(resolve);
3583
+ existing.rejecters.push(reject);
3584
+ });
3585
+ }
3586
+ // No pending entry of this type yet — queue one behind whatever is already waiting.
3582
3587
  return new Promise((resolve, reject) => {
3583
- this._pending = {
3584
- type,
3585
- executor,
3586
- resolvers: [resolve],
3587
- rejecters: [reject]
3588
- };
3588
+ this._pending.push({ type, executor, resolvers: [resolve], rejecters: [reject] });
3589
3589
  });
3590
3590
  }
3591
3591
  /**
3592
- * Execute an operation and handle pending queue after completion.
3592
+ * Run one entry, settle its callers, then start the next pending entry.
3593
3593
  * @private
3594
3594
  */
3595
- _execute(type, executor) {
3595
+ _run(entry) {
3596
+ // Mark busy BEFORE invoking the executor: the executor's synchronous prologue
3597
+ // can call back into enqueue(), and that call must queue rather than start a
3598
+ // second concurrent operation.
3599
+ this._current = { type: entry.type };
3596
3600
  const promise = (async () => {
3597
3601
  try {
3598
- await executor();
3602
+ const value = await entry.executor();
3603
+ // Settle this entry's callers FIRST, while the queue still reads as busy.
3604
+ for (const resolve of entry.resolvers)
3605
+ resolve(value);
3606
+ }
3607
+ catch (error) {
3608
+ // A throwing executor rejects only its own callers; the queue continues.
3609
+ for (const reject of entry.rejecters)
3610
+ reject(error);
3599
3611
  }
3600
- finally {
3612
+ })();
3613
+ // The drain is deliberately OUTSIDE the awaited chain above: chaining it
3614
+ // inside made `await c.render()` also wait for a reload that was queued
3615
+ // after it.
3616
+ promise.then(() => {
3617
+ if (this._pending.length === 0) {
3618
+ // Nothing queued: release immediately, so the very common
3619
+ // `await c.reload(); c.render();` still starts the render in that tick.
3601
3620
  this._current = null;
3602
- // Run pending if exists
3603
- if (this._pending) {
3604
- const pending = this._pending;
3605
- this._pending = null;
3606
- try {
3607
- await this._execute(pending.type, pending.executor);
3608
- for (const resolve of pending.resolvers)
3609
- resolve();
3610
- }
3611
- catch (err) {
3612
- for (const reject of pending.rejecters)
3613
- reject(err);
3614
- }
3615
- }
3621
+ return;
3616
3622
  }
3617
- })();
3618
- this._current = { type, promise };
3619
- return promise;
3623
+ // A queued entry must not start until every caller of the operation that
3624
+ // just finished has actually resumed. Those callers sit behind an unknown
3625
+ // number of microtask hops - an `async` public method adds two just by
3626
+ // returning the queue's promise - so draining on a microtask would start
3627
+ // the next on_load() before the previous caller's `await` came back. A
3628
+ // macrotask is the only boundary that is after all of them. _current stays
3629
+ // set until then, so nothing can start concurrently in the meantime.
3630
+ setTimeout(() => {
3631
+ this._current = null;
3632
+ const next = this._pending.shift();
3633
+ if (next)
3634
+ this._run(next);
3635
+ }, 0);
3636
+ });
3620
3637
  }
3621
3638
  /**
3622
3639
  * Check if any operation is currently running.
@@ -3628,7 +3645,7 @@ class Component_Queue {
3628
3645
  * Check if there's a pending operation waiting.
3629
3646
  */
3630
3647
  get has_pending() {
3631
- return this._pending !== null;
3648
+ return this._pending.length > 0;
3632
3649
  }
3633
3650
  }
3634
3651
 
@@ -3679,11 +3696,16 @@ function capture_component_data(component_name, args, data, cache_key) {
3679
3696
  try {
3680
3697
  const cloned_args = JSON.parse(JSON.stringify(args));
3681
3698
  const cloned_data = JSON.parse(JSON.stringify(data));
3682
- _capture_buffer.push({
3699
+ const entry = {
3683
3700
  component: component_name,
3684
3701
  args: cloned_args,
3685
3702
  data: cloned_data,
3686
- });
3703
+ };
3704
+ // Carry the key forward so the client matches on the SAME identity the server
3705
+ // used, rather than re-deriving one from args and hoping the shapes agree.
3706
+ if (cache_key !== null)
3707
+ entry.key = cache_key;
3708
+ _capture_buffer.push(entry);
3687
3709
  }
3688
3710
  catch (error) {
3689
3711
  // Non-serializable data — skip capture for this component
@@ -3729,10 +3751,15 @@ function set_preload_data(entries) {
3729
3751
  return;
3730
3752
  _preload_cache.clear();
3731
3753
  for (const entry of entries) {
3732
- // Generate key using the same algorithm as Load Coordinator
3733
- const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
3734
- if (result.key !== null) {
3735
- _preload_cache.set(result.key, entry.data);
3754
+ // The captured key wins. Falling back to args means matching what
3755
+ // generate_cache_key() computes for a component with no cache_id(), which
3756
+ // INCLUDES content serialization - without it an object arg keyed to null
3757
+ // here and the entry was dropped, so preload never applied to it.
3758
+ const key = entry.key !== undefined
3759
+ ? entry.key
3760
+ : Load_Coordinator.generate_invocation_key(entry.component, entry.args, { allow_content_serialization: true }).key;
3761
+ if (key !== null) {
3762
+ _preload_cache.set(key, entry.data);
3736
3763
  }
3737
3764
  }
3738
3765
  }
@@ -3845,12 +3872,73 @@ function init_viewport_resize(jQuery) {
3845
3872
  * - Scoped IDs using _cid pattern
3846
3873
  * - Event emission and CSS class hierarchy
3847
3874
  */
3875
+ /**
3876
+ * The `jqhtml` argument every compiled render function receives: the same
3877
+ * escaping the runtime exports, the object-printer entry point, and the
3878
+ * dynamic tag name check. One shared object, not one per render.
3879
+ */
3880
+ const render_utilities = {
3881
+ escape_html,
3882
+ escape_html_nl2br,
3883
+ print_object,
3884
+ dynamic_component_name,
3885
+ };
3848
3886
  // WeakMap storage for protected lifecycle method implementations (Option 2)
3849
3887
  // See docs/internal/lifecycle-method-protection.md for design details
3850
3888
  const lifecycle_impls = new WeakMap();
3889
+ /**
3890
+ * Resolve the template a template inherits FROM, in the one order CLAUDE.md
3891
+ * documents ("Template Inheritance Resolution"): the explicit `extends=""`
3892
+ * attribute first, then the JS class prototype chain.
3893
+ *
3894
+ * This is the single implementation. Both slot-only rendering (which invokes the
3895
+ * parent template with the child's slots) and default-attribute collection walk
3896
+ * it, so a `<Define>`'s class/attrs can never resolve through a different chain
3897
+ * than its markup does.
3898
+ *
3899
+ * @param template_def The template whose parent is wanted
3900
+ * @param ctor The component class to walk the prototype chain of (null skips it)
3901
+ * @returns The parent template and the name it was found under, or null
3902
+ */
3903
+ function resolve_parent_template(template_def, ctor) {
3904
+ const verbose = window.jqhtml?.debug?.verbose;
3905
+ // 1. Explicit extends="" wins - the author said exactly which template to use.
3906
+ if (template_def?.extends) {
3907
+ const parent = get_template(template_def.extends);
3908
+ // get_template() never returns null: a miss yields DEFAULT_TEMPLATE, whose
3909
+ // name is 'Jqhtml_Component'. That is "not found", not a parent.
3910
+ if (parent && parent.name !== 'Jqhtml_Component') {
3911
+ if (verbose)
3912
+ console.log(`[JQHTML] resolve_parent_template: explicit extends -> ${template_def.extends}`);
3913
+ return { template: parent, name: template_def.extends };
3914
+ }
3915
+ if (verbose)
3916
+ console.log(`[JQHTML] resolve_parent_template: extends="${template_def.extends}" has no template`);
3917
+ }
3918
+ // 2. Otherwise walk the JS prototype chain for the nearest ancestor with a template.
3919
+ // Stop at the framework base, at Object, and at Function.prototype (name === ''),
3920
+ // which is what getPrototypeOf() hands back once the class chain is exhausted.
3921
+ let current_class = ctor ? Object.getPrototypeOf(ctor) : null;
3922
+ while (typeof current_class === 'function' && current_class.name &&
3923
+ current_class.name !== 'Object' && current_class.name !== 'Jqhtml_Component') {
3924
+ const class_name = current_class.name;
3925
+ // Internal base classes are aliases of Jqhtml_Component, never a parent template.
3926
+ if (class_name !== '_Jqhtml_Component' && class_name !== '_Base_Jqhtml_Component') {
3927
+ const class_template = get_template(class_name);
3928
+ if (class_template && class_template.name !== 'Jqhtml_Component' && class_template !== template_def) {
3929
+ if (verbose)
3930
+ console.log(`[JQHTML] resolve_parent_template: prototype chain -> ${class_name}`);
3931
+ return { template: class_template, name: class_name };
3932
+ }
3933
+ }
3934
+ current_class = Object.getPrototypeOf(current_class);
3935
+ }
3936
+ return null;
3937
+ }
3851
3938
  class Jqhtml_Component {
3852
3939
  constructor(element, args = {}) {
3853
3940
  this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
3941
+ this._debug_no_data = false; // Set by _reload_without_data(); cleared by any real load
3854
3942
  this._instantiator = null; // Component that instantiated this one
3855
3943
  this._dom_parent = null; // Closest component in DOM tree (for lifecycle)
3856
3944
  this._dom_children = new Set(); // Components in DOM subtree (for lifecycle)
@@ -3872,7 +3960,10 @@ class Jqhtml_Component {
3872
3960
  // Cache mode properties
3873
3961
  this._cache_key = null; // Cache key for caching
3874
3962
  // 'html' mode caching
3875
- this._cached_html = null; // Cached HTML to inject on first render
3963
+ // The snapshot carries the cid it was taken under, not just markup: every scoped id
3964
+ // inside it reads "<name>:<cid>", so replaying it into a different instance requires
3965
+ // knowing which cid was the snapshotting component's own. See _rescope_cached_html().
3966
+ this._cached_html = null; // Cached HTML snapshot to inject on first render
3876
3967
  this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
3877
3968
  this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
3878
3969
  this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
@@ -4116,19 +4207,58 @@ class Jqhtml_Component {
4116
4207
  return child._render(null, options);
4117
4208
  }
4118
4209
  this._log_lifecycle('render', 'start');
4210
+ // --- Shared re-render prologue -------------------------------------------------
4211
+ // Everything below runs for BOTH the cached-HTML path and the template path. The
4212
+ // cached path used to return before it, so a cache-mode reload injected over a live
4213
+ // subtree without ever stopping the old children: their on_stop() never ran (timers
4214
+ // and listeners leaked) and they stayed in _dom_children as dead entries that every
4215
+ // later _wait_for_children_ready() walked.
4216
+ // Determine child-finding strategy: If component is off-DOM, children can't register
4217
+ // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4218
+ // If attached to DOM, children register normally and we can use the fast _dom_children path.
4219
+ // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4220
+ if (!$.contains(document.documentElement, this.$[0])) {
4221
+ this._use_dom_fallback = true;
4222
+ }
4223
+ else {
4224
+ this._use_dom_fallback = false;
4225
+ }
4226
+ // If this is not the first render, stop child components and clear DOM
4227
+ if (this._did_first_render) {
4228
+ // Stop all child components before clearing DOM
4229
+ this.$.find('.Component').each(function () {
4230
+ const child = $(this).data('_component');
4231
+ if (child && !child._stopped) {
4232
+ child._stop(); // Stop just the component, DOM will be cleared next
4233
+ }
4234
+ });
4235
+ // Clear the DOM
4236
+ this.$[0].innerHTML = '';
4237
+ }
4238
+ else {
4239
+ this._did_first_render = true;
4240
+ }
4241
+ // (No removeClass('_Component_Stopped') here: _render() returns above when
4242
+ // _stopped, and nothing ever clears _stopped, so a stopped component can never
4243
+ // reach this line. Clearing the class would only ever hide a real stopped state.)
4244
+ // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4245
+ this._dom_children.clear();
4246
+ // --- End shared re-render prologue ---------------------------------------------
4119
4247
  // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
4120
4248
  if (this._cached_html !== null) {
4121
4249
  if (window.jqhtml?.debug?.verbose) {
4122
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
4123
- }
4124
- // Inject cached HTML directly
4125
- this.$[0].innerHTML = this._cached_html;
4250
+ 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 });
4251
+ }
4252
+ // Inject cached HTML directly, with every scoped id re-scoped to THIS instance.
4253
+ // The snapshot was taken from another component instance (or an earlier life of
4254
+ // this one), so its ids carry that instance's cids verbatim. Injecting them as-is
4255
+ // put duplicate ids in the document, and $sid() - which is getElementById first -
4256
+ // could hand this component the other instance's element.
4257
+ this.$[0].innerHTML = this._rescope_cached_html(this._cached_html);
4126
4258
  // Mark that we used cached HTML (forces re-render after on_load)
4127
4259
  this._used_cached_html = true;
4128
4260
  // Clear cached HTML so next render uses template
4129
4261
  this._cached_html = null;
4130
- // Mark first render complete
4131
- this._did_first_render = true;
4132
4262
  this._log_lifecycle('render', 'complete (cached HTML)');
4133
4263
  // Call on_render() after cache inject (unless suppressed by _load_render_only)
4134
4264
  if (!options.skip_on_render) {
@@ -4151,39 +4281,10 @@ class Jqhtml_Component {
4151
4281
  this._data_on_last_render = JSON.stringify(this.data);
4152
4282
  return current_render_id;
4153
4283
  }
4154
- // Determine child-finding strategy: If component is off-DOM, children can't register
4155
- // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4156
- // If attached to DOM, children register normally and we can use the fast _dom_children path.
4157
- // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4158
- if (!$.contains(document.documentElement, this.$[0])) {
4159
- this._use_dom_fallback = true;
4160
- }
4161
- else {
4162
- this._use_dom_fallback = false;
4163
- }
4164
- // If this is not the first render, stop child components and clear DOM
4165
- if (this._did_first_render) {
4166
- // Stop all child components before clearing DOM
4167
- this.$.find('.Component').each(function () {
4168
- const child = $(this).data('_component');
4169
- if (child && !child._stopped) {
4170
- child._stop(); // Stop just the component, DOM will be cleared next
4171
- }
4172
- });
4173
- // Clear the DOM
4174
- this.$[0].innerHTML = '';
4175
- }
4176
- else {
4177
- this._did_first_render = true;
4178
- }
4179
- // Remove _Component_Stopped class if present (allows re-render after stop)
4180
- this.$.removeClass('_Component_Stopped');
4181
4284
  // Capture data state before first render for comparison later
4182
4285
  if (this._data_before_render === null) {
4183
4286
  this._data_before_render = JSON.stringify(this.data);
4184
4287
  }
4185
- // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4186
- this._dom_children.clear();
4187
4288
  // Get template and render it
4188
4289
  let template_def;
4189
4290
  // If we have a component name, try looking up by name first (for unregistered components)
@@ -4195,24 +4296,8 @@ class Jqhtml_Component {
4195
4296
  template_def = get_template_by_class(this.constructor);
4196
4297
  }
4197
4298
  if (template_def && template_def.render) {
4198
- // Create jqhtml utilities object
4199
- const jqhtml = {
4200
- escape_html: (str) => {
4201
- const div = document.createElement('div');
4202
- div.textContent = String(str);
4203
- return div.innerHTML;
4204
- },
4205
- escape_html_nl2br: (str) => {
4206
- const div = document.createElement('div');
4207
- div.textContent = String(str);
4208
- // Escape HTML first, then replace newlines with <br />
4209
- return div.innerHTML.replace(/\n/g, '<br />');
4210
- },
4211
- // Objects reaching <%= %> / <%!= %> / <%br= %> - see value-printers.ts
4212
- print_object,
4213
- // <{expression}> name validation - see component-name.ts
4214
- dynamic_component_name
4215
- };
4299
+ // Utilities the compiled render function calls (see render_utilities)
4300
+ const jqhtml = render_utilities;
4216
4301
  // Execute template function
4217
4302
  // Commented out debug logging
4218
4303
  // console.log('[Component] About to call render, this:', this);
@@ -4251,69 +4336,48 @@ class Jqhtml_Component {
4251
4336
  // Check for template inheritance (slot-only OR explicit extends)
4252
4337
  // If instructions is {_slots: {...}}, find parent template and invoke it
4253
4338
  if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
4254
- const componentName = template_def.name || this.args._component_name || this.constructor.name;
4255
- console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
4256
- let parentTemplate = null;
4257
- let parentTemplateName = null;
4258
- // First check for explicit extends attribute in template metadata
4259
- if (template_def.extends) {
4260
- console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
4261
- parentTemplate = get_template(template_def.extends);
4262
- parentTemplateName = template_def.extends;
4263
- }
4264
- // If no explicit extends, walk the prototype chain to find parent class with template
4265
- if (!parentTemplate) {
4266
- let currentClass = Object.getPrototypeOf(this.constructor);
4267
- while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
4268
- const className = currentClass.name;
4269
- console.log(`[JQHTML] Checking parent: ${className}`);
4270
- try {
4271
- const classTemplate = get_template(className);
4272
- if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
4273
- console.log(`[JQHTML] Found parent template: ${className}`);
4274
- parentTemplate = classTemplate;
4275
- parentTemplateName = className;
4276
- break;
4277
- }
4278
- }
4279
- catch (error) {
4280
- console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
4281
- }
4282
- currentClass = Object.getPrototypeOf(currentClass);
4283
- }
4339
+ const component_name = template_def.name || this.args._component_name || this.constructor.name;
4340
+ const parent = resolve_parent_template(template_def, this.constructor);
4341
+ // A slot-only template is nothing but an override of a parent. With no parent
4342
+ // there is nothing to render, and rendering empty hides the mistake (usually a
4343
+ // missing register_template) behind a blank element. Fail loudly instead.
4344
+ if (!parent) {
4345
+ throw new Error(`[JQHTML] ${component_name}: slot-only template has no parent template. ` +
4346
+ `Add extends="Parent_Name" to <Define:${component_name}>, extend a class whose ` +
4347
+ `template is registered, or give the template markup of its own.`);
4284
4348
  }
4285
- // If we found a parent template, invoke it with child's slots
4286
- if (parentTemplate) {
4287
- try {
4288
- // Create a content function that invokes child slots
4289
- // When parent calls content('slotName'), it invokes the child's slot function
4290
- const childSlots = instructions._slots;
4291
- const contentFunction = (slotName, ...slotArgs) => {
4292
- if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4293
- // Invoke the slot function with everything content() was given
4294
- const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4295
- // Return in render function format: [instructions, context]
4296
- // The template expression handler expects this format
4297
- return [slotInstructions, slotContext];
4298
- }
4299
- // Slot not found, return empty
4300
- return '';
4301
- };
4302
- // Invoke parent template with child's slots as content function
4303
- const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4304
- jqhtml);
4305
- console.log(`[JQHTML] Parent template invoked successfully`);
4306
- instructions = parentInstructions;
4307
- context = parentContext;
4308
- }
4309
- catch (error) {
4310
- console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
4311
- instructions = [];
4349
+ // Create a content function that invokes child slots
4350
+ // When parent calls content('slotName'), it invokes the child's slot function
4351
+ const childSlots = instructions._slots;
4352
+ const contentFunction = (slotName, ...slotArgs) => {
4353
+ if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4354
+ // Invoke the slot function with everything content() was given
4355
+ const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4356
+ // Return in render function format: [instructions, context]
4357
+ // The template expression handler expects this format
4358
+ return [slotInstructions, slotContext];
4312
4359
  }
4360
+ // Slot not found, return empty
4361
+ return '';
4362
+ };
4363
+ // Invoke parent template with child's slots as content function.
4364
+ // A parent template that throws is a real defect in the parent's markup. It used
4365
+ // to be swallowed into an empty render plus a warning nobody reads; it is now
4366
+ // re-thrown, which routes it through the boot error path: logged, this component
4367
+ // stopped, its waiting ancestors released.
4368
+ try {
4369
+ const [parentInstructions, parentContext] = parent.template.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4370
+ jqhtml);
4371
+ instructions = parentInstructions;
4372
+ context = parentContext;
4313
4373
  }
4314
- else {
4315
- console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
4316
- instructions = [];
4374
+ catch (error) {
4375
+ // Rethrown, not swallowed - only re-labelled so the report names both ends
4376
+ // of the inheritance, which the raw template error does not.
4377
+ const wrapped = new Error(`[JQHTML] ${component_name}: parent template ${parent.name} threw while ` +
4378
+ `rendering slot-only child: ${error?.message ?? error}`);
4379
+ wrapped.cause = error; // ES2020 target: Error(message, {cause}) is not typed
4380
+ throw wrapped;
4317
4381
  }
4318
4382
  }
4319
4383
  // Process instructions to generate DOM. ['_content', ...] markers from
@@ -4334,10 +4398,33 @@ class Jqhtml_Component {
4334
4398
  }
4335
4399
  this._fire_viewport_resize();
4336
4400
  }
4401
+ // "Already rendered once" - read before the state below can move to 3.
4402
+ const isRerender = this._ready_state >= 3;
4403
+ // HTML CACHE MODE: Mark on_render complete after the post-on_load render.
4404
+ // This signals to parent components that this component's DOM is fully rendered
4405
+ // with fresh data and ready for HTML snapshot. It is set BEFORE trigger('render')
4406
+ // because _wait_for_children_on_render() waits on that very event and reads the
4407
+ // flag from its handler.
4408
+ if (this._ready_state >= 2) {
4409
+ this._on_render_complete = true;
4410
+ // State 3 = "rendered with loaded data, ready phase not finished yet".
4411
+ // Only meaningful once the load phase has happened (>= 2); before that the
4412
+ // component is still walking 0 -> 1 -> 2 and must not be pushed forward.
4413
+ // The ready phase (_ready(), render()'s executor, _reload()) restores 4.
4414
+ this._ready_state = 3;
4415
+ this._update_debug_attrs();
4416
+ }
4417
+ else if (!this.__has_custom_on_load) {
4418
+ // A component with no custom on_load() skips the load phase entirely, so it never
4419
+ // reaches _ready_state 2 and would never set the flag above - and its data cannot
4420
+ // change, so this first render IS its final, fresh render. Without this, an
4421
+ // html-cache parent waited forever in _wait_for_children_on_render() for a
4422
+ // template-only child and never snapshotted, rendered or fired 'ready'.
4423
+ this._on_render_complete = true;
4424
+ }
4337
4425
  // Emit lifecycle event
4338
4426
  this.trigger('render');
4339
4427
  // Apply debug delay after render
4340
- const isRerender = this._ready_state >= 3; // Already rendered once
4341
4428
  applyDebugDelay(isRerender ? 'rerender' : 'render');
4342
4429
  // Store args snapshot for reload() comparison (skip if non-serializable)
4343
4430
  try {
@@ -4349,12 +4436,6 @@ class Jqhtml_Component {
4349
4436
  }
4350
4437
  // Store data snapshot for refresh() comparison
4351
4438
  this._data_on_last_render = JSON.stringify(this.data);
4352
- // HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
4353
- // This signals to parent components that this component's DOM is fully rendered
4354
- // with fresh data and ready for HTML snapshot
4355
- if (this._ready_state >= 2) {
4356
- this._on_render_complete = true;
4357
- }
4358
4439
  // Return the render ID so callers can check if this render is still current
4359
4440
  return current_render_id;
4360
4441
  }
@@ -4374,10 +4455,12 @@ class Jqhtml_Component {
4374
4455
  render(id = null) {
4375
4456
  if (this._stopped)
4376
4457
  return Promise.resolve();
4377
- // Invalidate ready event so new handlers wait for this render cycle to complete
4378
- // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4379
- this.invalidate('ready');
4380
- // If id provided, delegate to child component
4458
+ // If id provided, delegate to child component.
4459
+ // NOTE: this runs BEFORE invalidate('ready'). render(sid) does not start a
4460
+ // lifecycle on THIS component - it delegates to the redrawable child, which
4461
+ // invalidates and re-triggers its own 'ready'. Invalidating here would delete
4462
+ // this component's sticky ready marker with nothing left to re-trigger it, so
4463
+ // every later `await comp.ready()` would hang.
4381
4464
  if (id) {
4382
4465
  const $element = this.$sid(id);
4383
4466
  if ($element.length === 0) {
@@ -4392,14 +4475,23 @@ class Jqhtml_Component {
4392
4475
  }
4393
4476
  return child.render();
4394
4477
  }
4478
+ // Invalidate ready event so new handlers wait for this render cycle to complete
4479
+ // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4480
+ this.invalidate('ready');
4395
4481
  // Enqueue the full render lifecycle through the component queue
4396
4482
  // Return the queue promise so `await this.render()` resolves when the
4397
4483
  // full render lifecycle (DOM update, children ready, on_ready) completes
4398
4484
  return this._queue.enqueue('render', async () => {
4485
+ // The queued executor can start long after render() was called - re-check.
4486
+ if (this._stopped)
4487
+ return;
4399
4488
  // Execute render phase synchronously and capture render ID
4400
4489
  const render_id = this._render();
4401
4490
  // Wait for all child components to be ready
4402
4491
  await this._wait_for_children_ready();
4492
+ // Stopped while waiting for children: no hook, no event on a dead component.
4493
+ if (this._stopped)
4494
+ return;
4403
4495
  // Check if this render is still current before calling on_ready
4404
4496
  // If _render_count changed, another render happened and we should skip on_ready
4405
4497
  if (this._render_count !== render_id) {
@@ -4407,7 +4499,15 @@ class Jqhtml_Component {
4407
4499
  }
4408
4500
  // Call on_ready hook with authorization
4409
4501
  await this._call_lifecycle('on_ready');
4502
+ // on_ready() may itself have awaited long enough for a stop().
4503
+ if (this._stopped)
4504
+ return;
4410
4505
  this._fire_viewport_resize();
4506
+ // Back to the ready state: _render() dropped it to 3. Set BEFORE the trigger
4507
+ // so a handler that calls ready() during dispatch takes the already-ready
4508
+ // fast path instead of waiting for another trigger that will never come.
4509
+ this._ready_state = 4;
4510
+ this._update_debug_attrs();
4411
4511
  // Trigger ready event
4412
4512
  this.trigger('ready');
4413
4513
  });
@@ -4437,40 +4537,56 @@ class Jqhtml_Component {
4437
4537
  async load() {
4438
4538
  if (this._stopped)
4439
4539
  return false;
4440
- // Capture result through queue closure
4441
- let data_changed = false;
4442
- await this._queue.enqueue('load', async () => {
4540
+ // No custom on_load() means there is nothing to run and nothing can have changed.
4541
+ // Running the detached execution anyway seeded this.data with the empty detached
4542
+ // clone (create() takes no snapshot for these components), silently wiping whatever
4543
+ // on_create() had set.
4544
+ if (!this.__has_custom_on_load)
4545
+ return false;
4546
+ // The queue settles this caller with the value of the executor that actually
4547
+ // ran - including one that collapsed two rapid load() calls into one run - so
4548
+ // the result is returned from the executor, not read back out of a closure
4549
+ // variable that a discarded executor would have left at its initial value.
4550
+ return this._queue.enqueue('load', async () => {
4551
+ // The queued executor can start long after load() was called - re-check.
4552
+ if (this._stopped)
4553
+ return false;
4554
+ // Any real load returns the component to normal, whatever the debug tool did.
4555
+ this._debug_no_data = false;
4443
4556
  // Snapshot current data for change detection
4444
4557
  const data_before = JSON.stringify(this.data);
4445
4558
  // Execute on_load() on detached proxy (restores data to on_create snapshot)
4446
- const { data: result_data } = await this._execute_on_load_detached(false);
4447
- // Atomically update this.data: unfreeze → assign → normalize → refreeze
4559
+ const { data: result_data } = await this._execute_on_load_detached(null);
4560
+ // Stopped during on_load(): don't write data or cache for a dead component.
4561
+ if (this._stopped)
4562
+ return false;
4563
+ // Atomically update this.data: unfreeze → normalize → assign → refreeze.
4564
+ // The round trip runs in EVERY cache mode - see _apply_load_result().
4448
4565
  this.__data_frozen = false;
4449
- this.data = result_data;
4450
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4451
- if (cache_mode === 'data') {
4452
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4453
- this.data = normalized;
4454
- }
4566
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4455
4567
  this.__data_frozen = true;
4568
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4456
4569
  // Detect if data changed
4457
4570
  const data_after = JSON.stringify(this.data);
4458
- data_changed = data_before !== data_after;
4571
+ const data_changed = data_before !== data_after;
4459
4572
  // Update cache with fresh data if changed
4460
4573
  if (data_changed) {
4461
- // Regenerate cache key (args may have changed since boot)
4462
- let cache_key = null;
4463
- if (typeof this.cache_id === 'function') {
4464
- try {
4465
- cache_key = `${this.component_name()}::${String(this.cache_id())}`;
4574
+ // ONE derivation, shared with the cache read in create() and with _load().
4575
+ // The copy that used to live here omitted content serialization, so an
4576
+ // object-arg component computed a null key and silently never wrote the
4577
+ // cache that create() would later read with the content-serialized key.
4578
+ // Recomputed rather than reused: args may have changed since boot.
4579
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4580
+ if (cache_key === null) {
4581
+ // Leave the same record _load() leaves: the offending arg on the element,
4582
+ // and one dev warning. A silent catch here made a throwing cache_id()
4583
+ // undiagnosable - the component simply stopped being cached.
4584
+ if (uncacheable_property) {
4585
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4466
4586
  }
4467
- catch { /* cache_id() threw - skip caching */ }
4468
- }
4469
- else {
4470
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4471
- cache_key = result.key;
4587
+ warn_uncacheable_component(this, uncacheable_property, uncacheable_reason);
4472
4588
  }
4473
- if (cache_key && cache_mode !== 'html') {
4589
+ else if (cache_mode !== 'html') {
4474
4590
  Jqhtml_Local_Storage.set(cache_key, this.data);
4475
4591
  }
4476
4592
  }
@@ -4478,10 +4594,13 @@ class Jqhtml_Component {
4478
4594
  // Suppressed by _load_only and _load_render_only flags (preloading mode)
4479
4595
  if (!this._load_only && !this._load_render_only) {
4480
4596
  await this._call_lifecycle('on_loaded');
4597
+ // on_loaded() may have awaited across a stop(); don't fire 'loaded' after it.
4598
+ if (this._stopped)
4599
+ return data_changed;
4481
4600
  this.trigger('loaded');
4482
4601
  }
4602
+ return data_changed;
4483
4603
  });
4484
- return data_changed;
4485
4604
  }
4486
4605
  /**
4487
4606
  * Create phase - Quick setup, prepare UI
@@ -4504,15 +4623,18 @@ class Jqhtml_Component {
4504
4623
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
4505
4624
  // Components without on_load() don't fetch data, so nothing to cache or restore
4506
4625
  if (this.__has_custom_on_load) {
4626
+ // Snapshot this.data the moment on_create() returns, BEFORE any cache read.
4627
+ // on_load() restarts from this snapshot every time, and CLAUDE.md promises that
4628
+ // is the on_create() state. read_cache_in_create() REPLACES this.data on a warm
4629
+ // 'data' cache, so snapshotting after it made a warm boot restart on_load() from
4630
+ // cached data - `this.data.items.push(x)` doubled its rows on every reload.
4631
+ this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4507
4632
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
4508
4633
  // Skip cache for detached elements — no point hydrating from cache when not in DOM
4509
4634
  // @see component-cache.ts for full implementation
4510
4635
  if (!this._is_detached) {
4511
4636
  read_cache_in_create(this);
4512
4637
  }
4513
- // Snapshot this.data after on_create() completes
4514
- // This will be restored before each on_load() execution to reset state
4515
- this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4516
4638
  }
4517
4639
  // Freeze this.data after on_create() - only on_load() can modify it now
4518
4640
  this.__data_frozen = true;
@@ -4620,6 +4742,8 @@ class Jqhtml_Component {
4620
4742
  async _load() {
4621
4743
  if (this._stopped || this._ready_state >= 2)
4622
4744
  return;
4745
+ // Any real load returns the component to normal, whatever the debug tool did.
4746
+ this._debug_no_data = false;
4623
4747
  this._log_lifecycle('load', 'start');
4624
4748
  // use_cached_data feature: If cache hit occurred and use_cached_data=true, skip on_load() entirely
4625
4749
  // The cached data is already in this.data from create() phase
@@ -4643,26 +4767,7 @@ class Jqhtml_Component {
4643
4767
  // follower never runs on_load() and adopts the leader's data with NO
4644
4768
  // revalidation, so a wrong key here is permanently wrong data. Redundant
4645
4769
  // concurrent requests are the cheaper failure.
4646
- let cache_key = null;
4647
- let uncacheable_property;
4648
- let uncacheable_reason;
4649
- if (typeof this.cache_id === 'function') {
4650
- try {
4651
- const custom_cache_id = this.cache_id();
4652
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
4653
- }
4654
- catch (error) {
4655
- // cache_id() threw error - disable caching
4656
- uncacheable_property = 'cache_id()';
4657
- uncacheable_reason = 'cache-id-threw';
4658
- }
4659
- }
4660
- else {
4661
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
4662
- cache_key = result.key;
4663
- uncacheable_property = result.uncacheable_property;
4664
- uncacheable_reason = result.uncacheable_reason;
4665
- }
4770
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4666
4771
  const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
4667
4772
  // SSR preload check: if preloaded data exists for this component+args, use it
4668
4773
  if (cache_key !== null && has_preload_data()) {
@@ -4693,24 +4798,26 @@ class Jqhtml_Component {
4693
4798
  if (window.jqhtml?.debug?.verbose) {
4694
4799
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
4695
4800
  }
4696
- const { data: result_data } = await this._execute_on_load_detached();
4801
+ const { data: result_data } = await this._execute_on_load_detached(null);
4697
4802
  await this._apply_load_result(result_data, data_before_load);
4698
4803
  return;
4699
4804
  }
4700
4805
  // Check if this component should execute on_load() or wait for existing request
4701
- const should_execute = Load_Coordinator.should_execute_on_load(this);
4806
+ const should_execute = Load_Coordinator.should_execute_on_load(this, dedup_key);
4702
4807
  if (!should_execute) {
4703
4808
  // This component is a follower - wait for leader to complete
4704
4809
  if (window.jqhtml?.debug?.verbose) {
4705
4810
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
4706
4811
  }
4707
- const coordination_promise = Load_Coordinator.get_coordination_promise(this);
4812
+ // Every coordinator call uses the key captured ABOVE, before on_load() began.
4813
+ // Recomputing it here would miss the entry whenever args changed meanwhile.
4814
+ const coordination_promise = Load_Coordinator.get_coordination_promise(dedup_key);
4708
4815
  if (coordination_promise) {
4709
4816
  try {
4710
- // Wait for leader to complete
4817
+ // Wait for leader to complete (rejects if the leader's on_load() threw)
4711
4818
  await coordination_promise;
4712
4819
  // Retrieve leader's data from coordinator
4713
- const leader_data = Load_Coordinator.get_leader_data(this);
4820
+ const leader_data = Load_Coordinator.get_leader_data(this, dedup_key);
4714
4821
  if (leader_data !== null) {
4715
4822
  // Apply leader's data using the same method as leader
4716
4823
  // This properly handles freeze/unfreeze and normalization
@@ -4728,15 +4835,11 @@ class Jqhtml_Component {
4728
4835
  throw error;
4729
4836
  }
4730
4837
  }
4731
- // Fallback: if we couldn't get leader data, just mark as complete
4732
- this._ready_state = 2;
4733
- this._update_debug_attrs();
4734
- this._log_lifecycle('load', 'complete (follower)');
4735
- this.trigger('load');
4736
- if (!this._load_only && !this._load_render_only) {
4737
- await this._call_lifecycle('on_loaded');
4738
- this.trigger('loaded');
4739
- }
4838
+ // No leader data to adopt (the entry was already collected, or its data could
4839
+ // not be serialized). Load independently rather than firing load/loaded with the
4840
+ // untouched on_create() data.
4841
+ const { data: result_data } = await this._execute_on_load_detached(null);
4842
+ await this._apply_load_result(result_data, data_before_load);
4740
4843
  return;
4741
4844
  }
4742
4845
  // This component is a leader - execute on_load() on detached proxy
@@ -4744,7 +4847,7 @@ class Jqhtml_Component {
4744
4847
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
4745
4848
  }
4746
4849
  // Execute on_load on detached proxy with Load_Coordinator registration
4747
- const { data: result_data, complete_coordination } = await this._execute_on_load_detached(true);
4850
+ const { data: result_data, complete_coordination } = await this._execute_on_load_detached(dedup_key);
4748
4851
  // Apply result via sequential queue
4749
4852
  await this._apply_load_result(result_data, data_before_load);
4750
4853
  // Complete coordination AFTER this.data is updated
@@ -4758,8 +4861,8 @@ class Jqhtml_Component {
4758
4861
  * @see data-proxy.ts for full implementation
4759
4862
  * @private
4760
4863
  */
4761
- async _execute_on_load_detached(use_load_coordinator = false) {
4762
- return execute_on_load_detached(this, use_load_coordinator);
4864
+ async _execute_on_load_detached(dedup_key = null) {
4865
+ return execute_on_load_detached(this, dedup_key);
4763
4866
  }
4764
4867
  /**
4765
4868
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -4782,21 +4885,22 @@ class Jqhtml_Component {
4782
4885
  await my_turn;
4783
4886
  try {
4784
4887
  // Now it's our turn to set this.data
4785
- // Unfreeze, set data, freeze
4888
+ // Every on_load() result is round-tripped through the cache serializer before it
4889
+ // becomes this.data - in EVERY cache mode, not just 'data'. Two consequences the
4890
+ // rest of the framework depends on:
4891
+ // - this.data is jqhtml's own copy. The author's object graph is never aliased,
4892
+ // so mutating what on_load() returned cannot reach a frozen this.data.
4893
+ // - this.data is BY CONSTRUCTION what the cache stores and what a cache hit
4894
+ // returns. Hot and cold are the same object shape in every mode, so turning
4895
+ // caching on can never change what a component sees.
4896
+ // Dates, Maps, Sets and register_cache_class() classes are reconstructed; anything
4897
+ // the serializer cannot express is dropped and warned about once (local-storage.ts).
4786
4898
  this.__data_frozen = false;
4787
- this.data = result_data;
4788
- // DATA MODE: Normalize this.data through serialize/deserialize round-trip
4789
- // This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
4790
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4791
- if (cache_mode === 'data') {
4792
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4793
- this.data = normalized;
4794
- if (window.jqhtml?.debug?.verbose) {
4795
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4796
- }
4797
- }
4798
- // Freeze this.data
4899
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4799
4900
  this.__data_frozen = true;
4901
+ if (window.jqhtml?.debug?.verbose) {
4902
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4903
+ }
4800
4904
  // SSR data capture: record this component's data for preloading
4801
4905
  if (is_capture_enabled() && this._has_on_load()) {
4802
4906
  capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
@@ -4882,9 +4986,11 @@ class Jqhtml_Component {
4882
4986
  callback();
4883
4987
  return Promise.resolve();
4884
4988
  }
4885
- // Return promise that resolves when ready event fires
4989
+ // Return promise that resolves when ready event fires.
4990
+ // once(), not on(): a promise can only settle once, so a permanent listener
4991
+ // just accumulates on the component - one per ready() call, forever.
4886
4992
  return new Promise((resolve) => {
4887
- this.on('ready', () => {
4993
+ this.once('ready', () => {
4888
4994
  if (callback)
4889
4995
  callback();
4890
4996
  resolve();
@@ -4942,9 +5048,18 @@ class Jqhtml_Component {
4942
5048
  if (child._ready_state >= 4) {
4943
5049
  continue;
4944
5050
  }
4945
- // Create promise that resolves when child reaches ready
5051
+ // A stopped child will never fire 'ready' again - waiting on it would wedge
5052
+ // this parent (and everything awaiting its ready()) forever.
5053
+ if (child._stopped) {
5054
+ continue;
5055
+ }
5056
+ // Create promise that resolves when child reaches ready.
5057
+ // once(), not on(): a permanent listener would be appended to the child on
5058
+ // every render/reload cycle of a polling parent and never removed.
5059
+ // Raced against 'stop' so a child stopped mid-wait releases the parent.
4946
5060
  const ready_promise = new Promise((resolve) => {
4947
- child.on('ready', () => resolve());
5061
+ child.once('ready', () => resolve());
5062
+ child.once('stop', () => resolve());
4948
5063
  });
4949
5064
  ready_promises.push(ready_promise);
4950
5065
  }
@@ -4970,22 +5085,42 @@ class Jqhtml_Component {
4970
5085
  // Create promises for each child that hasn't completed on_render yet
4971
5086
  const render_promises = [];
4972
5087
  for (const child of children) {
4973
- // If child already completed on_render post-on_load, skip
4974
- if (child._on_render_complete) {
5088
+ // If child already completed on_render post-on_load (or is dead), skip
5089
+ if (child._on_render_complete || child._stopped) {
4975
5090
  continue;
4976
5091
  }
4977
- // Create promise that resolves when child completes on_render
5092
+ // Event-driven, not polled: the child sets _on_render_complete just before it
5093
+ // fires 'render', and 'stop' ends the wait for a child that dies first.
5094
+ //
5095
+ // A plain once('render') would be wrong: events are sticky, and this child has
5096
+ // already rendered at least once (the pre-on_load render), so once() would fire
5097
+ // immediately off that stale marker and let the parent snapshot half-rendered DOM.
5098
+ // The handler therefore re-checks the flag and stays registered until it is set.
4978
5099
  const render_promise = new Promise((resolve) => {
4979
- // Poll for completion (simple approach - could use events for more efficiency)
4980
- const check = () => {
4981
- if (child._on_render_complete || child._stopped) {
4982
- resolve();
4983
- }
4984
- else {
4985
- setTimeout(check, 10);
5100
+ let settled = false;
5101
+ const on_child_event = () => {
5102
+ if (settled)
5103
+ return;
5104
+ if (!child._on_render_complete && !child._stopped)
5105
+ return; // pre-load render
5106
+ settled = true;
5107
+ // There is no off() in the event API; splice this handler out of both queues
5108
+ // the way once() does, so a long-lived child does not accumulate handlers
5109
+ // across repeated waits.
5110
+ for (const event_name of ['render', 'stop']) {
5111
+ const callbacks = child._lifecycle_callbacks.get(event_name);
5112
+ if (callbacks) {
5113
+ const idx = callbacks.indexOf(on_child_event);
5114
+ if (idx !== -1)
5115
+ callbacks.splice(idx, 1);
5116
+ }
4986
5117
  }
5118
+ resolve();
4987
5119
  };
4988
- check();
5120
+ // 'stop' first: registering 'render' can fire the handler synchronously (sticky),
5121
+ // and the handler must be able to deregister both queues when it does.
5122
+ child.on('stop', on_child_event);
5123
+ child.on('render', on_child_event);
4989
5124
  });
4990
5125
  render_promises.push(render_promise);
4991
5126
  }
@@ -5041,6 +5176,51 @@ class Jqhtml_Component {
5041
5176
  async refresh() {
5042
5177
  return this.reload(false);
5043
5178
  }
5179
+ /**
5180
+ * Re-render this component from its on_create() state with its cache dropped and
5181
+ * WITHOUT running on_load()/on_loaded()/on_ready().
5182
+ *
5183
+ * @internal debug tool. Used by the debug overlay's lifecycle footer to see what a
5184
+ * component looks like with no loaded data at all. Not public API, not documented,
5185
+ * and no downstream code should call it: reload() or refresh() afterwards returns
5186
+ * the component to normal (the snapshot is intact and the cache is gone, so the
5187
+ * next load is a genuine fresh fetch).
5188
+ */
5189
+ _reload_without_data() {
5190
+ if (this._stopped)
5191
+ return Promise.resolve();
5192
+ // Same marker invalidation reload()/render() do: later ready() waiters must wait
5193
+ // for THIS cycle rather than resolve off the previous one.
5194
+ this.invalidate('ready');
5195
+ // Serialised with reload()/refresh() so it can never interleave with a real load.
5196
+ return this._queue.enqueue('reload', async () => {
5197
+ if (this._stopped)
5198
+ return;
5199
+ // Drop this component's own cache entries (data and html snapshot), so a later
5200
+ // reload() genuinely re-fetches instead of hydrating from what is on screen now.
5201
+ const { cache_key } = generate_cache_key(this);
5202
+ if (cache_key !== null) {
5203
+ Jqhtml_Local_Storage.remove(cache_key);
5204
+ Jqhtml_Local_Storage.remove(`${cache_key}::html`);
5205
+ }
5206
+ // Back to the on_create() state. Components with no custom on_load() take no
5207
+ // snapshot (nothing can have changed this.data), so they simply re-render.
5208
+ if (this.__initial_data_snapshot !== null) {
5209
+ this.__data_frozen = false;
5210
+ this.data = JSON.parse(JSON.stringify(this.__initial_data_snapshot));
5211
+ this.__data_frozen = true;
5212
+ }
5213
+ // Read by the debug overlay's lifecycle row; cleared by _reload()/load()/_load().
5214
+ this._debug_no_data = true;
5215
+ this._render();
5216
+ await this._wait_for_children_ready();
5217
+ if (this._stopped)
5218
+ return;
5219
+ this._ready_state = 4;
5220
+ this._update_debug_attrs();
5221
+ this.trigger('ready');
5222
+ });
5223
+ }
5044
5224
  /**
5045
5225
  * Internal reload implementation - re-fetch data and re-render
5046
5226
  *
@@ -5078,6 +5258,8 @@ class Jqhtml_Component {
5078
5258
  async _reload() {
5079
5259
  if (this._stopped)
5080
5260
  return;
5261
+ // Any real load returns the component to normal, whatever the debug tool did.
5262
+ this._debug_no_data = false;
5081
5263
  // Invalidate ready event so new handlers wait for this reload to complete
5082
5264
  // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
5083
5265
  this.invalidate('ready');
@@ -5089,8 +5271,17 @@ class Jqhtml_Component {
5089
5271
  this.next_reload_force_refresh = null;
5090
5272
  this._render();
5091
5273
  await this._wait_for_children_ready();
5274
+ // Stopped while waiting for children: no hook, no event on a dead component.
5275
+ if (this._stopped)
5276
+ return;
5092
5277
  await this._call_lifecycle('on_ready');
5278
+ // on_ready() may itself have awaited long enough for a stop().
5279
+ if (this._stopped)
5280
+ return;
5093
5281
  this._fire_viewport_resize();
5282
+ // Restore the ready state (_render() dropped it to 3) before the trigger
5283
+ this._ready_state = 4;
5284
+ this._update_debug_attrs();
5094
5285
  this.trigger('ready');
5095
5286
  this._log_lifecycle('reload', 'complete (no on_load)');
5096
5287
  return;
@@ -5118,10 +5309,16 @@ class Jqhtml_Component {
5118
5309
  // STEP 2: Call on_load() on detached proxy
5119
5310
  // This uses the same detached execution as _load() - on_load runs isolated
5120
5311
  // and result is applied via sequential queue
5121
- const { data: result_data } = await this._execute_on_load_detached(false); // false = don't use Load_Coordinator
5312
+ const { data: result_data } = await this._execute_on_load_detached(null); // null = no coordination
5313
+ // Stopped during on_load(): don't write data or re-render a dead component.
5314
+ if (this._stopped)
5315
+ return;
5122
5316
  // Apply result via sequential queue
5123
5317
  // This handles normalization, caching, and setting _is_dynamic
5124
5318
  await this._apply_load_result(result_data, data_before_load);
5319
+ // The apply queue can yield; a stop() there must abort the render/ready tail.
5320
+ if (this._stopped)
5321
+ return;
5125
5322
  // Re-read data state after apply (for render decision below)
5126
5323
  const data_after_load = JSON.stringify(this.data);
5127
5324
  const data_changed = data_after_load !== data_before_load;
@@ -5130,6 +5327,15 @@ class Jqhtml_Component {
5130
5327
  const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
5131
5328
  // Track if we need to render
5132
5329
  let should_render = false;
5330
+ // HTML CACHE MODE: a cache injection above replaced the subtree with inert markup -
5331
+ // a snapshot string, with no live child components in it. It must be re-rendered
5332
+ // from the template regardless of what the load decided, exactly as _should_rerender()
5333
+ // forces it at boot. Without this, a reload whose data came back unchanged left dead
5334
+ // HTML (stopped children, no instances) on screen.
5335
+ const used_cached_html = this._used_cached_html;
5336
+ if (used_cached_html) {
5337
+ this._used_cached_html = false;
5338
+ }
5133
5339
  if (force_refresh) {
5134
5340
  // reload(true) or reload() - always render if we haven't yet, OR data changed
5135
5341
  should_render = !rendered_from_cache || data_changed;
@@ -5150,6 +5356,11 @@ class Jqhtml_Component {
5150
5356
  }
5151
5357
  }
5152
5358
  // STEP 3: Perform second render if needed
5359
+ // The cache injection's inert markup is not optional to replace - this overrides both
5360
+ // the reload() and the refresh()/reload(false) decision above.
5361
+ if (used_cached_html) {
5362
+ should_render = true;
5363
+ }
5153
5364
  if (should_render) {
5154
5365
  this._render();
5155
5366
  }
@@ -5164,11 +5375,33 @@ class Jqhtml_Component {
5164
5375
  // STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
5165
5376
  if (rendered_from_cache || should_render) {
5166
5377
  await this._wait_for_children_ready();
5378
+ // Stopped while waiting for children: no hook, no event on a dead component.
5379
+ if (this._stopped)
5380
+ return;
5167
5381
  await this._call_lifecycle('on_ready');
5382
+ // on_ready() may itself have awaited long enough for a stop().
5383
+ if (this._stopped)
5384
+ return;
5168
5385
  this._fire_viewport_resize();
5386
+ // Restore the ready state (the load phase dropped it to 2, _render() to 3)
5387
+ // before the trigger, so handlers see a ready component.
5388
+ this._ready_state = 4;
5389
+ this._update_debug_attrs();
5169
5390
  // Trigger ready event so parent callbacks fire on subsequent reloads
5170
5391
  this.trigger('ready');
5171
5392
  }
5393
+ else {
5394
+ // refresh()/reload(false) found unchanged data: no re-render, and on_ready()
5395
+ // is deliberately NOT called (documented behaviour). But the component IS
5396
+ // ready - and this cycle deleted the sticky 'ready' marker at the top of
5397
+ // _reload() and dropped _ready_state to 2 in the load phase. Without
5398
+ // restoring both, ready() never resolves again and a parent re-entering
5399
+ // _wait_for_children_ready() hangs on this child forever (the documented
5400
+ // polling pattern breaks after its first quiet refresh).
5401
+ this._ready_state = 4;
5402
+ this._update_debug_attrs();
5403
+ this.trigger('ready');
5404
+ }
5172
5405
  this._log_lifecycle('reload', 'complete');
5173
5406
  }
5174
5407
  /**
@@ -5193,26 +5426,28 @@ class Jqhtml_Component {
5193
5426
  this.__gate_resume = null;
5194
5427
  resume();
5195
5428
  }
5196
- // Early bailout: skip expensive cleanup if no handlers registered
5197
- // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
5198
- const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
5199
- const has_stop_callbacks = this._on_registered('stop');
5200
- if (!has_custom_stop && !has_stop_callbacks) {
5201
- // Fast path: no cleanup logic defined, just mark as stopped
5202
- this._lifecycle_manager.unregister_component(this);
5203
- this._ready_state = 99;
5204
- return;
5205
- }
5206
- // Full cleanup path: component has custom stop logic
5429
+ // There is deliberately NO fast path for components without stop logic. The
5430
+ // marker class, the parent de-registration and the 'stop' event are not cleanup
5431
+ // the component asked for, they are how the rest of the framework learns this
5432
+ // component is dead: the class is the documented, observable stopped state; a
5433
+ // child left in _dom_parent._dom_children is a dead component every later
5434
+ // _wait_for_children_ready() and _get_dom_children() walks; and 'stop' is what
5435
+ // releases a parent (and boot's failure path) waiting on a child that will never
5436
+ // fire 'ready'. Skipping them for the common no-on_stop() child was what leaked
5437
+ // stopped children into the parent's registry.
5207
5438
  this._log_lifecycle('destroy', 'start');
5208
5439
  this.$.addClass('_Component_Stopped');
5209
5440
  // Unregister from lifecycle manager
5210
5441
  this._lifecycle_manager.unregister_component(this);
5211
- // Call user's on_stop() hook with authorization (sync)
5212
- const stopResult = this._call_lifecycle_sync('on_stop');
5213
- if (stopResult && typeof stopResult.then === 'function') {
5214
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5215
- `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5442
+ // Call user's on_stop() hook with authorization (sync) - only when actually
5443
+ // overridden, since _call_lifecycle_sync() on the base no-op costs per child
5444
+ // on every re-render.
5445
+ if (this.on_stop !== Jqhtml_Component.prototype.on_stop) {
5446
+ const stopResult = this._call_lifecycle_sync('on_stop');
5447
+ if (stopResult && typeof stopResult.then === 'function') {
5448
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5449
+ `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5450
+ }
5216
5451
  }
5217
5452
  // Fire registered stop callbacks
5218
5453
  this.trigger('stop');
@@ -5488,6 +5723,48 @@ class Jqhtml_Component {
5488
5723
  _generate_cid() {
5489
5724
  return uid();
5490
5725
  }
5726
+ /**
5727
+ * Re-scope every scoped id in an HTML-cache snapshot so it belongs to THIS instance.
5728
+ *
5729
+ * A snapshot is the inner HTML of some component instance, so it contains ids of the
5730
+ * form `<name>:<cid>`: the snapshotting component's own elements carry its cid, and
5731
+ * elements rendered by nested children carry each child's cid. Replayed verbatim into
5732
+ * a second instance those ids are duplicates of the originals, and $sid() resolves via
5733
+ * getElementById(), which returns whichever matching element is first in the document -
5734
+ * so one instance could read and write another instance's DOM.
5735
+ *
5736
+ * Mapping: the snapshot owner's cid becomes this._cid (so $sid() finds the elements
5737
+ * this component is about to treat as its own), and every other cid becomes a fresh
5738
+ * uid(), consistently within one injection. Two instances hydrated from one snapshot
5739
+ * therefore share no id at all.
5740
+ *
5741
+ * The ':' separator is written only by the compiler ($sid) and the instruction
5742
+ * processor (a scoped plain id), and cids are uid() output - [a-z][a-z0-9]* - so the
5743
+ * pattern is exact. A plain unscoped id has no ':' and is left alone.
5744
+ *
5745
+ * data-cid is the debug mirror of a component's own _cid (config.ts debug_attributes).
5746
+ * The snapshot's values are stale cids belonging to components that no longer exist,
5747
+ * so they go through the same mapping rather than being left to lie in DevTools.
5748
+ *
5749
+ * @private
5750
+ */
5751
+ _rescope_cached_html(snapshot) {
5752
+ const cid_map = new Map();
5753
+ cid_map.set(snapshot.cid, this._cid);
5754
+ const remap = (old_cid) => {
5755
+ let new_cid = cid_map.get(old_cid);
5756
+ if (new_cid === undefined) {
5757
+ new_cid = uid();
5758
+ cid_map.set(old_cid, new_cid);
5759
+ }
5760
+ return new_cid;
5761
+ };
5762
+ return snapshot.html
5763
+ // id="<name>:<cid>" - the name half may itself contain ':' (an author-written
5764
+ // id="a:b" is scoped as "a:b:cid"), so the cid is anchored at the end.
5765
+ .replace(/ id="([^"]*):([a-z][a-z0-9]*)"/g, (_match, name, old_cid) => ` id="${name}:${remap(old_cid)}"`)
5766
+ .replace(/ data-cid="([a-z][a-z0-9]*)"/g, (_match, old_cid) => ` data-cid="${remap(old_cid)}"`);
5767
+ }
5491
5768
  _apply_css_classes() {
5492
5769
  const hierarchy = this.constructor.get_class_hierarchy();
5493
5770
  // If component name differs from class name, add component name to hierarchy
@@ -5527,26 +5804,29 @@ class Jqhtml_Component {
5527
5804
  }
5528
5805
  if (!template)
5529
5806
  return;
5530
- // Walk the extends chain to collect all defaultAttributes from parent templates
5531
- // Apply from parent to child so child attributes take precedence
5807
+ // Walk the inheritance chain to collect defaultAttributes from parent templates,
5808
+ // applying parent first so the child's own attributes take precedence.
5809
+ //
5810
+ // This uses resolve_parent_template(), the same helper the slot-only render path
5811
+ // uses, so a <Define>'s class/attrs resolve through EXACTLY the chain its markup
5812
+ // does. It previously followed only `extends=`, so a slot-only JS subclass
5813
+ // (class Users_Grid extends Grid_Abstract) inherited the parent's template but
5814
+ // none of the parent <Define>'s class="" or attributes.
5532
5815
  const templateChain = [];
5533
5816
  let currentTemplate = template;
5534
- // Build chain from child to parent
5535
- while (currentTemplate) {
5817
+ let currentClass = this.constructor;
5818
+ const seen = new Set();
5819
+ while (currentTemplate && !seen.has(currentTemplate)) {
5820
+ seen.add(currentTemplate);
5536
5821
  templateChain.unshift(currentTemplate); // Add to front so parent comes first
5537
- // Check if this template extends another
5538
- if (currentTemplate.extends) {
5539
- try {
5540
- currentTemplate = get_template(currentTemplate.extends);
5541
- }
5542
- catch (error) {
5543
- // Parent template not found, stop chain
5544
- break;
5545
- }
5546
- }
5547
- else {
5822
+ const parent = resolve_parent_template(currentTemplate, currentClass);
5823
+ if (!parent)
5548
5824
  break;
5549
- }
5825
+ currentTemplate = parent.template;
5826
+ // Advance the prototype walk too: the next parent must be resolved relative to
5827
+ // the class we just landed on, not the original one. A template-only parent has
5828
+ // no class, which is correct - its own chain can only be an explicit extends=.
5829
+ currentClass = get_component_class(parent.name) || null;
5550
5830
  }
5551
5831
  // Apply defaultAttributes from each template in the chain (parent first, child last)
5552
5832
  for (const tmpl of templateChain) {
@@ -5623,8 +5903,10 @@ class Jqhtml_Component {
5623
5903
  }
5624
5904
  }
5625
5905
  else {
5626
- // Regular attributes - apply directly if not already set
5627
- if (!this.$.attr(key)) {
5906
+ // Regular attributes - apply directly if not already set.
5907
+ // === undefined, not falsy: tabindex="0" and an empty-string attribute
5908
+ // written at the invocation are SET, and a Define default must not win.
5909
+ if (this.$.attr(key) === undefined) {
5628
5910
  this.$.attr(key, value);
5629
5911
  }
5630
5912
  }
@@ -5716,9 +5998,9 @@ Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() det
5716
5998
  // A bare component name, as accepted by closest() in place of a class selector
5717
5999
  Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
5718
6000
 
5719
- 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}";
6001
+ 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}";
5720
6002
 
5721
- 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}";
6003
+ 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}";
5722
6004
 
5723
6005
  /**
5724
6006
  * Component debug overlay.
@@ -5730,6 +6012,12 @@ var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed
5730
6012
  * data, state, ancestry, instantiator) instead of firing the click. Alt+click
5731
6013
  * passes through. `disable()` puts everything back.
5732
6014
  *
6015
+ * The labels are part of the interaction, not just decoration: the pointer can
6016
+ * move from a component onto one of its tabs without the hover set collapsing,
6017
+ * and clicking a tab inspects that tab's component. The modal keeps an amber
6018
+ * outline on whatever it is showing, and its title bar carries Parent / Back so
6019
+ * the DOM chain can be walked without going back to the page.
6020
+ *
5733
6021
  * Non-invasiveness, the design goal (details in ./CLAUDE.md):
5734
6022
  * - The overlay's own UI lives in a shadow root on a host element appended to
5735
6023
  * <body>, styled by shadow.scss. Page CSS cannot select into it and :host
@@ -5748,8 +6036,11 @@ const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
5748
6036
  const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
5749
6037
  const HIT_CLASS = `${PREFIX}hit`;
5750
6038
  const DEPTH_CLASS = `${PREFIX}depth-`;
6039
+ const SELECTED_CLASS = `${PREFIX}selected`; // on the element the modal is showing
5751
6040
  const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
5752
6041
  const LABEL_HEIGHT = 18;
6042
+ const LABEL_MIN_WIDTH = 400; // a narrow component still gets a readable tab
6043
+ const VIEWPORT_MARGIN = 5; // gap kept between a label's right edge and the viewport
5753
6044
  const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
5754
6045
  const state = {
5755
6046
  enabled: false,
@@ -5759,7 +6050,10 @@ const state = {
5759
6050
  modal: null,
5760
6051
  chain: [],
5761
6052
  hit_elements: [],
6053
+ label_hovered: false,
5762
6054
  modal_component: null,
6055
+ nav_stack: [],
6056
+ selected_element: null,
5763
6057
  };
5764
6058
  // ---------------------------------------------------------------------------
5765
6059
  // Public API
@@ -5816,7 +6110,7 @@ const debug_overlay = {
5816
6110
  : chain_for(target && target.jquery ? target[0] : target)[0];
5817
6111
  if (!component)
5818
6112
  return false;
5819
- open_modal(component);
6113
+ select(component);
5820
6114
  return true;
5821
6115
  },
5822
6116
  };
@@ -5871,9 +6165,18 @@ function is_ours(event) {
5871
6165
  // ---------------------------------------------------------------------------
5872
6166
  // Hover
5873
6167
  // ---------------------------------------------------------------------------
6168
+ /**
6169
+ * A hover on the page replaces the hover set - except while the pointer is on
6170
+ * our own UI. An event from inside the shadow host (a label, the modal) never
6171
+ * clears or changes the hover, and neither does a page hover raised while the
6172
+ * pointer is parked on a label: that is what lets a label be reached and clicked
6173
+ * without the outlines it belongs to vanishing on the way.
6174
+ */
5874
6175
  function on_mouseover(event) {
5875
6176
  if (is_ours(event))
5876
6177
  return;
6178
+ if (state.label_hovered)
6179
+ return;
5877
6180
  const chain = chain_for(event.target);
5878
6181
  if (chain[0] === state.chain[0])
5879
6182
  return;
@@ -5883,6 +6186,32 @@ function on_mouseout(event) {
5883
6186
  if (!event.relatedTarget)
5884
6187
  clear_hover(); // pointer left the document
5885
6188
  }
6189
+ /**
6190
+ * The pointer left a label. If it landed back inside the component the label
6191
+ * belongs to (the usual case - the label sits on the component's own corner),
6192
+ * the hover set stays; anywhere else it goes. `elementFromPoint` reports our
6193
+ * shadow host for a point covered by another label, so the host is skipped and
6194
+ * the page element underneath is used.
6195
+ */
6196
+ function on_label_leave(event) {
6197
+ state.label_hovered = false;
6198
+ const innermost = state.chain[0];
6199
+ if (!innermost)
6200
+ return;
6201
+ const under = element_under(event.clientX, event.clientY);
6202
+ if (!under || !innermost.$[0].contains(under))
6203
+ clear_hover();
6204
+ }
6205
+ function element_under(x, y) {
6206
+ const all = document.elementsFromPoint;
6207
+ const candidates = all
6208
+ ? all.call(document, x, y)
6209
+ : [document.elementFromPoint(x, y)].filter(Boolean);
6210
+ for (const node of candidates)
6211
+ if (node !== state.host)
6212
+ return node;
6213
+ return null;
6214
+ }
5886
6215
  function on_reposition() {
5887
6216
  if (state.chain.length)
5888
6217
  render_labels();
@@ -5905,18 +6234,23 @@ function clear_hover() {
5905
6234
  }
5906
6235
  state.hit_elements = [];
5907
6236
  state.chain = [];
6237
+ state.label_hovered = false;
5908
6238
  if (state.layer)
5909
6239
  state.layer.textContent = '';
5910
6240
  }
5911
6241
  /**
5912
6242
  * One tab per component in the chain, pinned to the component's top-left
5913
- * corner and no wider than the component. Outermost first so the innermost
5914
- * paints on top; tabs that share a corner (a child filling its parent, the
5915
- * usual case) stack downward instead of hiding each other.
6243
+ * corner. Width: the component's, but a narrow component still gets up to
6244
+ * LABEL_MIN_WIDTH so its name and args stay legible; a label that would then
6245
+ * run off the right edge is slid back so its right edge sits at the margin.
6246
+ * Outermost first so the innermost paints on top; tabs that share a corner (a
6247
+ * child filling its parent, the usual case) stack downward instead of hiding
6248
+ * each other.
5916
6249
  */
5917
6250
  function render_labels() {
5918
6251
  const layer = state.layer;
5919
6252
  layer.textContent = '';
6253
+ state.label_hovered = false; // the element the pointer was on is gone
5920
6254
  const taken = new Map();
5921
6255
  for (let depth = state.chain.length - 1; depth >= 0; depth--) {
5922
6256
  const component = state.chain[depth];
@@ -5929,14 +6263,33 @@ function render_labels() {
5929
6263
  const stacked = taken.get(key) || 0;
5930
6264
  taken.set(key, stacked + 1);
5931
6265
  const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
5932
- label.style.left = `${left}px`;
6266
+ // Laid out at the left edge FIRST, then moved. An absolutely positioned box
6267
+ // shrink-to-fits into the room its own `left` leaves, so measuring it at its
6268
+ // final position would report the squeezed width, and moving it would let it
6269
+ // re-expand - the two chase each other. At left 0 it has the whole viewport,
6270
+ // so the measurement below is the width it will actually keep.
6271
+ label.style.left = '0px';
5933
6272
  label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
5934
- label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
6273
+ label.style.maxWidth = `${rect.width < LABEL_MIN_WIDTH ? LABEL_MIN_WIDTH : Math.round(rect.width)}px`;
5935
6274
  label.appendChild(el('span', 'label-name', component.component_name()));
5936
6275
  const args = simple_args(component);
5937
6276
  if (args)
5938
6277
  label.appendChild(el('span', 'label-args', args));
6278
+ // Each label knows its own component: it is a hover target that holds the set
6279
+ // in place, and a click target that inspects THAT component, not the innermost.
6280
+ label.addEventListener('mouseenter', () => { state.label_hovered = true; });
6281
+ label.addEventListener('mouseleave', on_label_leave);
6282
+ label.addEventListener('click', (event) => {
6283
+ event.stopPropagation(); // the document handler already skips is_ours events
6284
+ select(component);
6285
+ });
5939
6286
  layer.appendChild(label);
6287
+ // A label on a narrow component may be wider than the component, so a component
6288
+ // near the right edge can push its label off-screen. Slide it back until its
6289
+ // right edge sits on the margin, never past the left edge.
6290
+ const width = label.getBoundingClientRect().width || label.offsetWidth;
6291
+ const limit = window.innerWidth - VIEWPORT_MARGIN;
6292
+ label.style.left = `${left + width > limit ? Math.max(0, Math.round(limit - width)) : left}px`;
5940
6293
  }
5941
6294
  }
5942
6295
  /** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
@@ -5966,7 +6319,7 @@ function on_click(event) {
5966
6319
  close_modal();
5967
6320
  return;
5968
6321
  }
5969
- open_modal(chain[0]);
6322
+ select(chain[0]);
5970
6323
  }
5971
6324
  function on_keydown(event) {
5972
6325
  if (event.key === 'Escape' && state.modal_component) {
@@ -5980,17 +6333,82 @@ function close_modal() {
5980
6333
  state.modal.classList.remove(`${PREFIX}modal-open`);
5981
6334
  state.modal.textContent = '';
5982
6335
  state.modal_component = null;
6336
+ state.nav_stack = [];
6337
+ set_selected(null);
6338
+ }
6339
+ // ---------------------------------------------------------------------------
6340
+ // Selection and navigation
6341
+ //
6342
+ // Three ways in, and they differ only in what they do to the navigation stack:
6343
+ // select() - a FRESH selection (page click, label click, inspect()): the stack
6344
+ // is dropped, so Back disappears.
6345
+ // navigate() - a step along the chain (Parent, an Ancestry entry, the
6346
+ // Instantiator link): the component being left is pushed.
6347
+ // show() - render only, stack untouched; what Back and the lifecycle
6348
+ // buttons re-open with.
6349
+ // ---------------------------------------------------------------------------
6350
+ function select(component) {
6351
+ state.nav_stack = [];
6352
+ show(component);
6353
+ }
6354
+ function navigate(component) {
6355
+ if (state.modal_component && state.modal_component !== component) {
6356
+ state.nav_stack.push(state.modal_component);
6357
+ }
6358
+ show(component);
6359
+ }
6360
+ /** Pop to the last component still alive; a stopped one is skipped, not shown. */
6361
+ function go_back() {
6362
+ while (state.nav_stack.length) {
6363
+ const previous = state.nav_stack.pop();
6364
+ if (previous.$.hasClass('_Component_Stopped'))
6365
+ continue;
6366
+ show(previous);
6367
+ return;
6368
+ }
6369
+ if (state.modal_component)
6370
+ show(state.modal_component); // nothing left: drop Back
5983
6371
  }
5984
- function open_modal(component) {
6372
+ /** Move SELECTED_CLASS to `element`, or take it off the page entirely for null. */
6373
+ function set_selected(element) {
6374
+ if (state.selected_element && state.selected_element !== element) {
6375
+ state.selected_element.classList.remove(SELECTED_CLASS);
6376
+ }
6377
+ state.selected_element = element;
6378
+ if (element)
6379
+ element.classList.add(SELECTED_CLASS);
6380
+ }
6381
+ /**
6382
+ * The inspector. Three children: a fixed title bar, a scrolling body holding every
6383
+ * section, and a fixed footer of lifecycle buttons. The modal sits top-right, but
6384
+ * moves to the left when the inspected component is itself in the right half of the
6385
+ * viewport - otherwise the panel covers the thing being inspected.
6386
+ */
6387
+ function show(component) {
5985
6388
  const modal = state.modal;
5986
6389
  modal.textContent = '';
5987
6390
  state.modal_component = component;
5988
6391
  const chain = chain_for(component.$[0]);
5989
6392
  const ancestors = chain.slice(1);
5990
6393
  const element = component.$[0];
5991
- // Title bar
6394
+ set_selected(element);
6395
+ modal.classList.toggle(`${PREFIX}modal-left`, element.getBoundingClientRect().left > window.innerWidth / 2);
6396
+ // Title bar: the name, then [Back] [Parent] [Log to console] [Close]. Back is
6397
+ // there only while something was navigated away from, Parent only while the
6398
+ // component has a DOM parent component - the first Ancestry entry.
5992
6399
  const title = el('div', 'title');
5993
6400
  title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
6401
+ if (state.nav_stack.length) {
6402
+ const back = el('button', 'button', 'Back');
6403
+ back.addEventListener('click', go_back);
6404
+ title.appendChild(back);
6405
+ }
6406
+ const parent = ancestors[0];
6407
+ if (parent) {
6408
+ const up = el('button', 'button', 'Parent');
6409
+ up.addEventListener('click', () => navigate(parent));
6410
+ title.appendChild(up);
6411
+ }
5994
6412
  const log = el('button', 'button', 'Log to console');
5995
6413
  log.addEventListener('click', () => {
5996
6414
  console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
@@ -6000,6 +6418,9 @@ function open_modal(component) {
6000
6418
  close.addEventListener('click', close_modal);
6001
6419
  title.appendChild(close);
6002
6420
  modal.appendChild(title);
6421
+ // The only scrolling element: the title bar and the footer stay put.
6422
+ const body = el('div', 'body');
6423
+ modal.appendChild(body);
6003
6424
  // Identity
6004
6425
  const ctor = component.constructor;
6005
6426
  const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
@@ -6008,15 +6429,18 @@ function open_modal(component) {
6008
6429
  row(identity, 'class', '', hierarchy.join(' → '));
6009
6430
  row(identity, 'element', '', describe_element(element));
6010
6431
  row(identity, '_cid', '', String(component._cid));
6011
- row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
6432
+ const lifecycle = READY_STATES[component._ready_state] || String(component._ready_state);
6433
+ row(identity, 'lifecycle', '', component._debug_no_data
6434
+ ? `${lifecycle} (no data - reload w/o data)`
6435
+ : lifecycle);
6012
6436
  const nocache = element.getAttribute('data-nocache');
6013
6437
  if (nocache)
6014
6438
  row(identity, 'cache', '', `declined: ${nocache}`);
6015
- modal.appendChild(section('Identity', identity));
6439
+ body.appendChild(section('Identity', identity));
6016
6440
  // Args / data / state
6017
- modal.appendChild(section('Args', object_table(component.args)));
6018
- modal.appendChild(section('Data', object_table(component.data)));
6019
- modal.appendChild(section('State', object_table(component.state)));
6441
+ body.appendChild(section('Args', object_table(component.args)));
6442
+ body.appendChild(section('Data', object_table(component.data)));
6443
+ body.appendChild(section('State', object_table(component.state)));
6020
6444
  // Ancestry
6021
6445
  const list = el('div', 'list');
6022
6446
  if (!ancestors.length)
@@ -6025,10 +6449,10 @@ function open_modal(component) {
6025
6449
  const link = el('div', 'link');
6026
6450
  link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
6027
6451
  link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
6028
- link.addEventListener('click', () => open_modal(ancestor));
6452
+ link.addEventListener('click', () => navigate(ancestor));
6029
6453
  list.appendChild(link);
6030
6454
  }
6031
- modal.appendChild(section('Ancestry (DOM, nearest first)', list));
6455
+ body.appendChild(section('Ancestry (DOM, nearest first)', list));
6032
6456
  // Instantiator - the component whose template wrote this tag. Differs from
6033
6457
  // the DOM parent for markup written in a slot body or default content.
6034
6458
  const instantiator = component.instantiator ? component.instantiator() : null;
@@ -6037,7 +6461,7 @@ function open_modal(component) {
6037
6461
  const link = el('div', 'link');
6038
6462
  link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
6039
6463
  link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
6040
- link.addEventListener('click', () => open_modal(instantiator));
6464
+ link.addEventListener('click', () => navigate(instantiator));
6041
6465
  inst.appendChild(link);
6042
6466
  if (ancestors[0] && instantiator !== ancestors[0]) {
6043
6467
  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.'));
@@ -6046,9 +6470,49 @@ function open_modal(component) {
6046
6470
  else {
6047
6471
  inst.appendChild(el('div', 'empty', 'none - created directly'));
6048
6472
  }
6049
- modal.appendChild(section('Instantiator', inst));
6473
+ body.appendChild(section('Instantiator', inst));
6474
+ // Footer: lifecycle controls. Clicks land inside the shadow host, so the
6475
+ // overlay's own click hijack lets them through (is_ours).
6476
+ const footer = el('div', 'footer');
6477
+ footer.appendChild(el('span', 'footer-label', 'Lifecycle:'));
6478
+ footer.appendChild(lifecycle_button(component, 'Reload', (c) => c.reload()));
6479
+ footer.appendChild(lifecycle_button(component, 'Refresh', (c) => c.refresh()));
6480
+ footer.appendChild(lifecycle_button(component, 'Rerender', (c) => c.render()));
6481
+ footer.appendChild(lifecycle_button(component, 'Reload w/o data', (c) => c._reload_without_data()));
6482
+ modal.appendChild(footer);
6050
6483
  modal.classList.add(`${PREFIX}modal-open`);
6051
6484
  }
6485
+ /**
6486
+ * One footer button. Runs its lifecycle call on a live component, logs whatever it
6487
+ * throws or rejects with, and re-opens the modal once the call settles so the panel
6488
+ * (lifecycle row above all) describes the state the button just produced.
6489
+ */
6490
+ function lifecycle_button(component, label, run) {
6491
+ const button = el('button', 'button footer-button', label);
6492
+ button.addEventListener('click', () => {
6493
+ if (component.$.hasClass('_Component_Stopped'))
6494
+ return; // dead component: nothing to do
6495
+ let result;
6496
+ try {
6497
+ result = run(component);
6498
+ }
6499
+ catch (error) {
6500
+ console.error(`[JQHTML debug] ${label} threw on <${component.component_name()}>`, error);
6501
+ return;
6502
+ }
6503
+ Promise.resolve(result)
6504
+ .catch((error) => {
6505
+ console.error(`[JQHTML debug] ${label} rejected on <${component.component_name()}>`, error);
6506
+ })
6507
+ .then(() => {
6508
+ // `show`, not `select`: the lifecycle buttons re-render the panel in place
6509
+ // and must not throw away the navigation stack behind it.
6510
+ if (state.enabled && state.modal_component === component)
6511
+ show(component);
6512
+ });
6513
+ });
6514
+ return button;
6515
+ }
6052
6516
  // ---------------------------------------------------------------------------
6053
6517
  // DOM helpers (shadow side)
6054
6518
  // ---------------------------------------------------------------------------
@@ -6285,13 +6749,12 @@ function init_jquery_plugin(jQuery) {
6285
6749
  const _jqhtml_original_jquery = jQuery;
6286
6750
  // Override jQuery constructor to handle component instances
6287
6751
  const JQueryWithComponentSupport = function (selector, context) {
6288
- // Check if selector is a JQHTML component instance
6289
- if (selector &&
6290
- typeof selector === 'object' &&
6291
- selector.$ &&
6292
- typeof selector.$sid === 'function' &&
6293
- typeof selector.id === 'function') {
6294
- // Return the component's jQuery element
6752
+ // Unwrap a component instance to its root element, so $(component) behaves
6753
+ // exactly like component.$ (addClass, find, [0] and every other jQuery method).
6754
+ // The check is an instanceof on the class itself: the previous duck-type tested
6755
+ // for an id() method, which Jqhtml_Component has never had, so this branch was
6756
+ // dead and $(component) wrapped the plain object instead.
6757
+ if (selector instanceof Jqhtml_Component) {
6295
6758
  return selector.$;
6296
6759
  }
6297
6760
  // Otherwise, call original jQuery constructor
@@ -6367,13 +6830,12 @@ function init_jquery_plugin(jQuery) {
6367
6830
  // Check if component already exists on this element
6368
6831
  const existingComponent = element.data('_component');
6369
6832
  if (existingComponent) {
6370
- // Stop existing component (with error handling to continue on failure)
6371
- try {
6372
- existingComponent.stop();
6373
- }
6374
- catch (error) {
6375
- console.warn('[JQHTML] Error stopping existing component during replacement:', error);
6376
- }
6833
+ // Stop the existing component. A throwing stop() is NOT swallowed: the caller
6834
+ // asked to replace a live component, and overwriting one whose on_stop() failed
6835
+ // would leave its timers, intervals and listeners running against DOM that is
6836
+ // about to be destroyed. Let the throw reach the caller with the old component
6837
+ // still in place, so the failure is visible and recoverable.
6838
+ existingComponent.stop();
6377
6839
  // Remove component classes (any class starting with a capital letter, or a
6378
6840
  // component name with the reserved underscore prefix - except BEM classes)
6379
6841
  const classes = element.attr('class');
@@ -6389,7 +6851,15 @@ function init_jquery_plugin(jQuery) {
6389
6851
  const capital_first = cls[0] === cls[0].toUpperCase() && cls[0] !== cls[0].toLowerCase();
6390
6852
  return !capital_first && !is_component_name(cls);
6391
6853
  });
6392
- element.attr('class', nonComponentClasses.join(' '));
6854
+ // Drop the attribute entirely when nothing survived the strip: attr('class', '')
6855
+ // leaves a `class=""` in the markup, which is noise in the DOM and in snapshot
6856
+ // comparisons for an element that now has no classes at all.
6857
+ if (nonComponentClasses.length > 0) {
6858
+ element.attr('class', nonComponentClasses.join(' '));
6859
+ }
6860
+ else {
6861
+ element.removeAttr('class');
6862
+ }
6393
6863
  }
6394
6864
  // Remove component data
6395
6865
  element.removeData('_component');
@@ -6430,18 +6900,35 @@ function init_jquery_plugin(jQuery) {
6430
6900
  const expectedTag = args._tag || template.tag || 'div';
6431
6901
  const currentTag = element.prop('tagName').toLowerCase();
6432
6902
  if (currentTag !== expectedTag.toLowerCase()) {
6433
- // Tag mismatch
6434
- if (args._inner_html) {
6435
- // Replace element with correct tag (only when using server-rendered content)
6903
+ // Tag mismatch. Replace whenever we can actually do it: the element must be in
6904
+ // a document (replaceWith() on a parentless element is a silent no-op that would
6905
+ // strand the component on a detached node) and must not be <body>.
6906
+ //
6907
+ // This used to require a truthy args._inner_html, i.e. server-rendered content.
6908
+ // boot.ts always sets _inner_html - but it empties the placeholder first, so a
6909
+ // placeholder with no server content carries '' and fell through to a warning,
6910
+ // hydrating into the wrong tag. Hydration of an empty placeholder still works:
6911
+ // the copied innerHTML is simply ''.
6912
+ const oldEl = element[0];
6913
+ const can_replace = currentTag !== 'body' && !!(oldEl && oldEl.parentNode);
6914
+ if (can_replace) {
6436
6915
  const newElement = jQuery(`<${expectedTag}></${expectedTag}>`);
6437
6916
  // Copy all attributes from old element to new element
6438
- const oldEl = element[0];
6439
6917
  if (oldEl && oldEl.attributes) {
6440
6918
  for (let i = 0; i < oldEl.attributes.length; i++) {
6441
6919
  const attr = oldEl.attributes[i];
6442
6920
  newElement.attr(attr.name, attr.value);
6443
6921
  }
6444
6922
  }
6923
+ // Carry the jQuery data store across. $-args and anything the caller stashed
6924
+ // with .data() live there, and they are read back off the element during
6925
+ // component construction - dropping them silently changed the component's args.
6926
+ const old_data = element.data();
6927
+ if (old_data) {
6928
+ for (const key of Object.keys(old_data)) {
6929
+ newElement.data(key, old_data[key]);
6930
+ }
6931
+ }
6445
6932
  // Copy innerHTML
6446
6933
  newElement.html(element.html());
6447
6934
  // Replace in DOM
@@ -6756,12 +7243,14 @@ function init(jQuery, config) {
6756
7243
  }
6757
7244
  }
6758
7245
  // Version - will be replaced during build with actual version from package.json
6759
- const version = '2.3.67';
7246
+ const version = '2.3.70';
6760
7247
  // Default export with all functionality
6761
7248
  const jqhtml = {
6762
7249
  // Core
6763
7250
  Jqhtml_Component,
6764
7251
  LifecycleManager,
7252
+ // Internal - exposed for debugging and for tests that inspect coordination state
7253
+ Load_Coordinator,
6765
7254
  // Registry
6766
7255
  register,
6767
7256
  register_component,
@@ -6777,7 +7266,6 @@ const jqhtml = {
6777
7266
  // Template system
6778
7267
  process_instructions,
6779
7268
  extract_slots,
6780
- render_template,
6781
7269
  escape_html,
6782
7270
  escape_html_nl2br,
6783
7271
  // Version property - internal
@@ -6900,5 +7388,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
6900
7388
  }
6901
7389
  }
6902
7390
 
6903
- 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 };
7391
+ 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 };
6904
7392
  //# sourceMappingURL=index.js.map