@jqhtml/core 2.3.67 → 2.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -2,6 +2,197 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ /**
6
+ * JQHTML Debug Module
7
+ *
8
+ * Provides comprehensive debugging capabilities for JQHTML components
9
+ */
10
+ // Global debug state
11
+ let performanceMetrics = new Map();
12
+ /**
13
+ * Development warning helper
14
+ * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
15
+ */
16
+ function devWarn(message) {
17
+ // Check if warnings are suppressed
18
+ if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
19
+ return;
20
+ }
21
+ // Check if in production mode
22
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
23
+ return;
24
+ }
25
+ console.warn(`[JQHTML Dev Warning] ${message}`);
26
+ }
27
+ // Get global jqhtml object
28
+ function getJqhtml() {
29
+ if (typeof window !== 'undefined' && window.jqhtml) {
30
+ return window.jqhtml;
31
+ }
32
+ // Fallback: try to get from global if available
33
+ if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
34
+ return globalThis.jqhtml;
35
+ }
36
+ throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
37
+ 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
38
+ }
39
+ // Visual flash effect
40
+ function flashComponent(component, eventType) {
41
+ const jqhtml = getJqhtml();
42
+ if (!jqhtml?.debug?.flashComponents)
43
+ return;
44
+ const duration = jqhtml.debug.flashDuration || 500;
45
+ const colors = jqhtml.debug.flashColors || {};
46
+ const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
47
+ eventType === 'render' ? '#27ae60' :
48
+ '#9b59b6');
49
+ // Store original border
50
+ const originalBorder = component.$.css('border');
51
+ // Apply flash border
52
+ component.$.css({
53
+ 'border': `2px solid ${color}`,
54
+ 'transition': `border ${duration}ms ease-out`
55
+ });
56
+ // Remove after duration
57
+ setTimeout(() => {
58
+ component.$.css('border', originalBorder || '');
59
+ }, duration);
60
+ }
61
+ // Log lifecycle event
62
+ function logLifecycle(component, phase, status) {
63
+ const jqhtml = getJqhtml();
64
+ if (!jqhtml?.debug)
65
+ return;
66
+ const shouldLog = jqhtml.debug.logFullLifecycle ||
67
+ (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
68
+ if (!shouldLog)
69
+ return;
70
+ const componentName = component.constructor.name;
71
+ const timestamp = new Date().toISOString();
72
+ const prefix = `[JQHTML ${timestamp}]`;
73
+ if (status === 'start') {
74
+ console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
75
+ // Start performance tracking
76
+ if (jqhtml.debug.profilePerformance) {
77
+ performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
78
+ }
79
+ }
80
+ else {
81
+ let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
82
+ // Add performance data
83
+ if (jqhtml.debug.profilePerformance) {
84
+ const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
85
+ if (startTime) {
86
+ const duration = Date.now() - startTime;
87
+ message += ` (${duration}ms)`;
88
+ // Highlight slow renders
89
+ if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
90
+ duration > jqhtml.debug.highlightSlowRenders) {
91
+ console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
92
+ component.$.css('outline', '2px dashed red');
93
+ }
94
+ }
95
+ }
96
+ console.log(message);
97
+ // Visual feedback
98
+ if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
99
+ flashComponent(component, phase);
100
+ }
101
+ }
102
+ // Update component tree if enabled
103
+ if (jqhtml.debug.showComponentTree) {
104
+ updateComponentTree();
105
+ }
106
+ }
107
+ // Apply delays based on lifecycle phase
108
+ function applyDebugDelay(phase) {
109
+ const jqhtml = getJqhtml();
110
+ if (!jqhtml?.debug)
111
+ return;
112
+ let delayMs = 0;
113
+ switch (phase) {
114
+ case 'component':
115
+ delayMs = jqhtml.debug.delayAfterComponent || 0;
116
+ break;
117
+ case 'render':
118
+ delayMs = jqhtml.debug.delayAfterRender || 0;
119
+ break;
120
+ case 'rerender':
121
+ delayMs = jqhtml.debug.delayAfterRerender || 0;
122
+ break;
123
+ }
124
+ if (delayMs > 0) {
125
+ console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
126
+ }
127
+ }
128
+ // Log instruction processing
129
+ function logInstruction(type, data) {
130
+ const jqhtml = getJqhtml();
131
+ if (!jqhtml?.debug?.logInstructionProcessing)
132
+ return;
133
+ console.log(`[JQHTML Instruction] ${type}:`, data);
134
+ }
135
+ // Log data changes
136
+ function logDataChange(component, property, oldValue, newValue) {
137
+ const jqhtml = getJqhtml();
138
+ if (!jqhtml?.debug?.traceDataFlow)
139
+ return;
140
+ console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
141
+ }
142
+ // Update component tree visualization
143
+ function updateComponentTree() {
144
+ // This would update a debug panel if implemented
145
+ // For now, just log the tree structure periodically
146
+ console.log('[JQHTML Tree] Component hierarchy updated');
147
+ }
148
+ // Router dispatch logging
149
+ function logDispatch(url, route, params, verbose = false) {
150
+ const jqhtml = getJqhtml();
151
+ if (!jqhtml?.debug)
152
+ return;
153
+ const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
154
+ if (!shouldLog)
155
+ return;
156
+ const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
157
+ if (isVerbose) {
158
+ console.group(`[JQHTML Router] Dispatching: ${url}`);
159
+ console.log('Matched route:', route);
160
+ console.log('Extracted params:', params);
161
+ console.log('Route component:', route.component);
162
+ console.log('Route layout:', route.layout);
163
+ console.log('Route meta:', route.meta);
164
+ console.groupEnd();
165
+ }
166
+ else {
167
+ console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
168
+ }
169
+ }
170
+ // Check if sequential processing is enabled
171
+ function isSequentialProcessing() {
172
+ const jqhtml = getJqhtml();
173
+ return jqhtml?.debug?.sequentialProcessing || false;
174
+ }
175
+ // Error handling with break on error
176
+ function handleComponentError(component, phase, error) {
177
+ const jqhtml = getJqhtml();
178
+ console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
179
+ if (jqhtml?.debug?.breakOnError) {
180
+ debugger; // This will pause execution in dev tools
181
+ }
182
+ }
183
+ // Additional debug suggestions that could be implemented:
184
+ //
185
+ // 1. Component Inspector - Click on any component to see its data/args/state
186
+ // 2. Time Travel Debugging - Record state changes and replay them
187
+ // 3. Network Request Tracking - Log all AJAX calls made during load()
188
+ // 4. Memory Leak Detection - Track component creation/destruction
189
+ // 5. Template Compilation Debugging - Show compiled template functions
190
+ // 6. Event Flow Visualization - Show event bubbling through components
191
+ // 7. Dependency Graph - Show which components depend on which data
192
+ // 8. Hot Reload Support - Reload components without losing state
193
+ // 9. Performance Budgets - Warn when components exceed size/time limits
194
+ // 10. Accessibility Auditing - Check for missing ARIA attributes
195
+
5
196
  /**
6
197
  * JQHTML v2 Lifecycle Manager
7
198
  *
@@ -50,8 +241,9 @@ class LifecycleManager {
50
241
  // Check if stopped during create
51
242
  if (component._stopped)
52
243
  return;
53
- // Trigger create event
54
- component.trigger('create');
244
+ // NOTE: no trigger('create') here. component.create() already fires it exactly
245
+ // once - its `_ready_state >= 1` guard makes a second create() a no-op, so this
246
+ // duplicate trigger only ever double-fired the event for subscribers.
55
247
  // Check for lifecycle truncation flags
56
248
  const load_only = component._load_only;
57
249
  const load_render_only = component._load_render_only;
@@ -177,8 +369,30 @@ class LifecycleManager {
177
369
  return;
178
370
  }
179
371
  catch (error) {
180
- console.error(`Error booting component ${component.component_name()}:`, error);
181
- throw error;
372
+ // A boot that throws must leave a STOPPED component behind, not a half-booted
373
+ // one. The component never reaches 'ready', so an ancestor parked in
374
+ // _wait_for_children_ready() can only be released by the child's 'stop' event;
375
+ // nothing else stops it, so the whole subtree above it would hang forever.
376
+ //
377
+ // And the throw must stop here: _boot() is called unawaited by
378
+ // instruction-processor.initialize_component() and by the jQuery plugin, so a
379
+ // rethrow surfaces as an unhandled promise rejection instead of an error report.
380
+ // The error object stays a console.error argument so callers (and tests) that
381
+ // grep the console for the thrown message still see it.
382
+ try {
383
+ handleComponentError(component, 'boot', error);
384
+ }
385
+ catch {
386
+ // handleComponentError needs the global jqhtml object and throws without it.
387
+ // Never lose the original error to a failure in the reporting path.
388
+ console.error(`Error booting component ${component.component_name()}:`, error);
389
+ }
390
+ try {
391
+ component._stop();
392
+ }
393
+ catch (stop_error) {
394
+ console.error(`[JQHTML] on_stop() threw while unwinding a failed boot:`, stop_error);
395
+ }
182
396
  }
183
397
  }
184
398
  /**
@@ -187,20 +401,6 @@ class LifecycleManager {
187
401
  unregister_component(component) {
188
402
  this.active_components.delete(component);
189
403
  }
190
- /**
191
- * Wait for all active components to reach ready state
192
- */
193
- async wait_for_ready() {
194
- const ready_promises = [];
195
- for (const component of this.active_components) {
196
- if (component._ready_state < 4) {
197
- ready_promises.push(new Promise((resolve) => {
198
- component.on('ready', () => resolve());
199
- }));
200
- }
201
- }
202
- await Promise.all(ready_promises);
203
- }
204
404
  }
205
405
 
206
406
  /**
@@ -663,30 +863,32 @@ function process_instructions(instructions, target, context, slots) {
663
863
  // Set innerHTML once - this is the performance win
664
864
  // Use native innerHTML for better performance
665
865
  target[0].innerHTML = html.join('');
666
- // Second pass: initialize special attributes and events
667
- for (const [tid, tagData] of Object.entries(tagElements)) {
668
- // Use native querySelector for better performance
669
- const el = target[0].querySelector(`[data-tid="${tid}"]`);
670
- if (el) {
671
- const element = $(el);
672
- el.removeAttribute('data-tid');
866
+ // Second pass: initialize special attributes and events.
867
+ // One querySelectorAll over the freshly injected subtree, in document
868
+ // order, rather than one querySelector per tracked element: a scan per
869
+ // element made this pass quadratic in the size of the template. Child
870
+ // components have not rendered yet (their elements are empty), so every
871
+ // data-tid here belongs to this level.
872
+ const root = target[0];
873
+ for (const el of Array.from(root.querySelectorAll('[data-tid]'))) {
874
+ const tagData = tagElements[el.getAttribute('data-tid')];
875
+ el.removeAttribute('data-tid');
876
+ if (tagData) {
673
877
  // Bind to the component whose template wrote this element, which for
674
878
  // spliced content is not the component being rendered.
675
- apply_attributes(element, tagData.attrs, tagData.context);
879
+ apply_attributes($(el), tagData.attrs, tagData.context);
676
880
  }
677
881
  }
678
882
  // Third pass: initialize and boot components in parallel
679
883
  // Like v1, all sibling components at this level boot simultaneously
680
884
  // DO NOT await - let children boot in background while parent continues
681
- for (const [cid, compData] of Object.entries(components)) {
682
- // Use native querySelector for better performance
683
- const el = target[0].querySelector(`[data-cid="${cid}"]`);
684
- if (el) {
685
- const element = $(el);
686
- el.removeAttribute('data-cid');
885
+ for (const el of Array.from(root.querySelectorAll('[data-cid]'))) {
886
+ const compData = components[el.getAttribute('data-cid')];
887
+ el.removeAttribute('data-cid');
888
+ if (compData) {
687
889
  // Boot this component (which will render and boot its children recursively)
688
890
  // Fire and forget - don't wait for boot to complete
689
- initialize_component(element, compData);
891
+ initialize_component($(el), compData);
690
892
  }
691
893
  }
692
894
  }
@@ -751,21 +953,13 @@ function process_tag_to_html(instruction, html, tagElements, components, context
751
953
  if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
752
954
  !key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
753
955
  (typeof value === 'string' || typeof value === 'number')) {
754
- if (key === 'id' && tid) {
755
- // Special handling for id attribute - scope to parent component's _cid
756
- // This is for regular id="foo" attributes that need scoping (rare case)
757
- // Most scoping happens via $sid attribute which becomes data-sid
758
- // Don't double-scope if already scoped (contains :)
759
- if (typeof value === 'string' && value.includes(':')) {
760
- html.push(` id="${value}"`);
761
- }
762
- else {
763
- html.push(` id="${value}:${context._cid}"`);
764
- }
765
- }
766
- else {
767
- html.push(` ${key}="${value}"`);
768
- }
956
+ // A hand-written `id` is emitted exactly as written - including on a tracked
957
+ // element. It used to be rewritten to `<value>:<cid>` whenever the element
958
+ // happened to carry some OTHER tracked attribute, so `<label id="x" @click=…>`
959
+ // and `<label id="x">` disagreed. Per-instance ids are what $sid is for, and
960
+ // the compiler emits those already scoped (codegen.ts), so they arrive here as
961
+ // ordinary values and pass straight through.
962
+ html.push(` ${key}="${escape_attribute(value)}"`);
769
963
  }
770
964
  }
771
965
  // Close opening tag
@@ -776,6 +970,16 @@ function process_tag_to_html(instruction, html, tagElements, components, context
776
970
  html.push('>');
777
971
  }
778
972
  }
973
+ /**
974
+ * A double quote inside an attribute value would end the attribute in the
975
+ * HTML string this pass builds; an interpolated value can never legitimately
976
+ * do that, so it is encoded. Nothing else is touched - the value is otherwise
977
+ * emitted exactly as the template produced it.
978
+ */
979
+ function escape_attribute(value) {
980
+ const s = typeof value === 'string' ? value : String(value);
981
+ return s.indexOf('"') === -1 ? s : s.replace(/"/g, '&quot;');
982
+ }
779
983
  /**
780
984
  * Process a component instruction to HTML
781
985
  */
@@ -1141,220 +1345,29 @@ async function initialize_component(element, compData) {
1141
1345
  if (props._load_only === true || props['$_load_only'] === true) {
1142
1346
  options._load_only = true;
1143
1347
  }
1144
- if (props._load_render_only === true || props['$_load_render_only'] === true) {
1145
- options._load_render_only = true;
1146
- }
1147
- // Create component instance - element first, then options
1148
- const instance = new ComponentClass(element, options);
1149
- // Set the instantiator (the component that rendered this one in their template)
1150
- instance._instantiator = context;
1151
- // Boot the component immediately - this runs its full lifecycle
1152
- await instance._boot();
1153
- }
1154
- /**
1155
- * Utility to extract slots from instructions
1156
- */
1157
- function extract_slots(instructions) {
1158
- const slots = {};
1159
- for (const instruction of instructions) {
1160
- if (typeof instruction === 'object' && 'slot' in instruction) {
1161
- const [name] = instruction.slot;
1162
- slots[name] = instruction;
1163
- }
1164
- }
1165
- return slots;
1166
- }
1167
-
1168
- /**
1169
- * JQHTML Debug Module
1170
- *
1171
- * Provides comprehensive debugging capabilities for JQHTML components
1172
- */
1173
- // Global debug state
1174
- let performanceMetrics = new Map();
1175
- /**
1176
- * Development warning helper
1177
- * Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
1178
- */
1179
- function devWarn(message) {
1180
- // Check if warnings are suppressed
1181
- if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
1182
- return;
1183
- }
1184
- // Check if in production mode
1185
- if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
1186
- return;
1187
- }
1188
- console.warn(`[JQHTML Dev Warning] ${message}`);
1189
- }
1190
- // Get global jqhtml object
1191
- function getJqhtml() {
1192
- if (typeof window !== 'undefined' && window.jqhtml) {
1193
- return window.jqhtml;
1194
- }
1195
- // Fallback: try to get from global if available
1196
- if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
1197
- return globalThis.jqhtml;
1198
- }
1199
- throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
1200
- 'Import and initialize @jqhtml/core before attempting to use debug functionality.');
1201
- }
1202
- // Visual flash effect
1203
- function flashComponent(component, eventType) {
1204
- const jqhtml = getJqhtml();
1205
- if (!jqhtml?.debug?.flashComponents)
1206
- return;
1207
- const duration = jqhtml.debug.flashDuration || 500;
1208
- const colors = jqhtml.debug.flashColors || {};
1209
- const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
1210
- eventType === 'render' ? '#27ae60' :
1211
- '#9b59b6');
1212
- // Store original border
1213
- const originalBorder = component.$.css('border');
1214
- // Apply flash border
1215
- component.$.css({
1216
- 'border': `2px solid ${color}`,
1217
- 'transition': `border ${duration}ms ease-out`
1218
- });
1219
- // Remove after duration
1220
- setTimeout(() => {
1221
- component.$.css('border', originalBorder || '');
1222
- }, duration);
1223
- }
1224
- // Log lifecycle event
1225
- function logLifecycle(component, phase, status) {
1226
- const jqhtml = getJqhtml();
1227
- if (!jqhtml?.debug)
1228
- return;
1229
- const shouldLog = jqhtml.debug.logFullLifecycle ||
1230
- (jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
1231
- if (!shouldLog)
1232
- return;
1233
- const componentName = component.constructor.name;
1234
- const timestamp = new Date().toISOString();
1235
- const prefix = `[JQHTML ${timestamp}]`;
1236
- if (status === 'start') {
1237
- console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
1238
- // Start performance tracking
1239
- if (jqhtml.debug.profilePerformance) {
1240
- performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
1241
- }
1242
- }
1243
- else {
1244
- let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
1245
- // Add performance data
1246
- if (jqhtml.debug.profilePerformance) {
1247
- const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
1248
- if (startTime) {
1249
- const duration = Date.now() - startTime;
1250
- message += ` (${duration}ms)`;
1251
- // Highlight slow renders
1252
- if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
1253
- duration > jqhtml.debug.highlightSlowRenders) {
1254
- console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
1255
- component.$.css('outline', '2px dashed red');
1256
- }
1257
- }
1258
- }
1259
- console.log(message);
1260
- // Visual feedback
1261
- if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
1262
- flashComponent(component, phase);
1263
- }
1264
- }
1265
- // Update component tree if enabled
1266
- if (jqhtml.debug.showComponentTree) {
1267
- updateComponentTree();
1268
- }
1269
- }
1270
- // Apply delays based on lifecycle phase
1271
- function applyDebugDelay(phase) {
1272
- const jqhtml = getJqhtml();
1273
- if (!jqhtml?.debug)
1274
- return;
1275
- let delayMs = 0;
1276
- switch (phase) {
1277
- case 'component':
1278
- delayMs = jqhtml.debug.delayAfterComponent || 0;
1279
- break;
1280
- case 'render':
1281
- delayMs = jqhtml.debug.delayAfterRender || 0;
1282
- break;
1283
- case 'rerender':
1284
- delayMs = jqhtml.debug.delayAfterRerender || 0;
1285
- break;
1286
- }
1287
- if (delayMs > 0) {
1288
- console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
1289
- }
1290
- }
1291
- // Log instruction processing
1292
- function logInstruction(type, data) {
1293
- const jqhtml = getJqhtml();
1294
- if (!jqhtml?.debug?.logInstructionProcessing)
1295
- return;
1296
- console.log(`[JQHTML Instruction] ${type}:`, data);
1297
- }
1298
- // Log data changes
1299
- function logDataChange(component, property, oldValue, newValue) {
1300
- const jqhtml = getJqhtml();
1301
- if (!jqhtml?.debug?.traceDataFlow)
1302
- return;
1303
- console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
1304
- }
1305
- // Update component tree visualization
1306
- function updateComponentTree() {
1307
- // This would update a debug panel if implemented
1308
- // For now, just log the tree structure periodically
1309
- console.log('[JQHTML Tree] Component hierarchy updated');
1310
- }
1311
- // Router dispatch logging
1312
- function logDispatch(url, route, params, verbose = false) {
1313
- const jqhtml = getJqhtml();
1314
- if (!jqhtml?.debug)
1315
- return;
1316
- const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
1317
- if (!shouldLog)
1318
- return;
1319
- const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
1320
- if (isVerbose) {
1321
- console.group(`[JQHTML Router] Dispatching: ${url}`);
1322
- console.log('Matched route:', route);
1323
- console.log('Extracted params:', params);
1324
- console.log('Route component:', route.component);
1325
- console.log('Route layout:', route.layout);
1326
- console.log('Route meta:', route.meta);
1327
- console.groupEnd();
1328
- }
1329
- else {
1330
- console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
1331
- }
1332
- }
1333
- // Check if sequential processing is enabled
1334
- function isSequentialProcessing() {
1335
- const jqhtml = getJqhtml();
1336
- return jqhtml?.debug?.sequentialProcessing || false;
1348
+ if (props._load_render_only === true || props['$_load_render_only'] === true) {
1349
+ options._load_render_only = true;
1350
+ }
1351
+ // Create component instance - element first, then options
1352
+ const instance = new ComponentClass(element, options);
1353
+ // Set the instantiator (the component that rendered this one in their template)
1354
+ instance._instantiator = context;
1355
+ // Boot the component immediately - this runs its full lifecycle
1356
+ await instance._boot();
1337
1357
  }
1338
- // Error handling with break on error
1339
- function handleComponentError(component, phase, error) {
1340
- const jqhtml = getJqhtml();
1341
- console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
1342
- if (jqhtml?.debug?.breakOnError) {
1343
- debugger; // This will pause execution in dev tools
1358
+ /**
1359
+ * Utility to extract slots from instructions
1360
+ */
1361
+ function extract_slots(instructions) {
1362
+ const slots = {};
1363
+ for (const instruction of instructions) {
1364
+ if (typeof instruction === 'object' && 'slot' in instruction) {
1365
+ const [name] = instruction.slot;
1366
+ slots[name] = instruction;
1367
+ }
1344
1368
  }
1369
+ return slots;
1345
1370
  }
1346
- // Additional debug suggestions that could be implemented:
1347
- //
1348
- // 1. Component Inspector - Click on any component to see its data/args/state
1349
- // 2. Time Travel Debugging - Record state changes and replay them
1350
- // 3. Network Request Tracking - Log all AJAX calls made during load()
1351
- // 4. Memory Leak Detection - Track component creation/destruction
1352
- // 5. Template Compilation Debugging - Show compiled template functions
1353
- // 6. Event Flow Visualization - Show event bubbling through components
1354
- // 7. Dependency Graph - Show which components depend on which data
1355
- // 8. Hot Reload Support - Reload components without losing state
1356
- // 9. Performance Budgets - Warn when components exceed size/time limits
1357
- // 10. Accessibility Auditing - Check for missing ARIA attributes
1358
1371
 
1359
1372
  /**
1360
1373
  * JQHTML Cache Key Serializer
@@ -1623,15 +1636,12 @@ class Load_Coordinator {
1623
1636
  /**
1624
1637
  * Check if a component should execute on_load() or wait for existing request
1625
1638
  * Returns true if component should execute (is leader), false if should wait (is follower)
1639
+ *
1640
+ * @param key - The dedup key the caller captured for this load. Every coordinator
1641
+ * method takes it, because args may legally change during on_load()
1642
+ * and a recomputed key would address a different (or no) entry.
1626
1643
  */
1627
- static should_execute_on_load(component) {
1628
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1629
- // No identity means no coordination. Without this, every un-keyable component
1630
- // would share the single `null` registry entry and adopt an unrelated
1631
- // component's data - the worst failure this system can produce.
1632
- if (key === null) {
1633
- return true;
1634
- }
1644
+ static should_execute_on_load(component, key) {
1635
1645
  const entry = this._registry.get(key);
1636
1646
  if (!entry) {
1637
1647
  // No existing request - this component becomes the leader
@@ -1650,26 +1660,27 @@ class Load_Coordinator {
1650
1660
  * Register a leader component that will execute on_load()
1651
1661
  * Creates coordination entry and returns a function to call when data is ready
1652
1662
  *
1663
+ * @param key - The dedup key captured by the caller (see should_execute_on_load)
1653
1664
  * @returns A function that accepts the final data and completes coordination
1654
1665
  */
1655
- static register_leader(component, on_load_promise) {
1656
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1657
- // Un-keyable component: run alone, register nothing. See should_execute_on_load().
1658
- if (key === null) {
1659
- return () => { };
1660
- }
1661
- // Create a promise that we control - it resolves when complete_coordination is called
1666
+ static register_leader(component, key) {
1667
+ // Create a promise that we control - it settles when coordination completes or fails
1662
1668
  let resolve_promise;
1663
- const coordination_promise = new Promise((resolve) => {
1669
+ let reject_promise;
1670
+ const coordination_promise = new Promise((resolve, reject) => {
1664
1671
  resolve_promise = resolve;
1672
+ reject_promise = reject;
1665
1673
  });
1674
+ // A leader with no followers still has to be able to reject this promise on
1675
+ // error; without a handler that rejection surfaces as an unhandled rejection.
1676
+ coordination_promise.catch(() => { });
1666
1677
  const entry = {
1667
1678
  status: 'loading',
1668
- promise: coordination_promise, // Followers await THIS promise, not on_load_promise
1679
+ promise: coordination_promise, // Followers await THIS promise, not on_load's
1669
1680
  resolve_promise,
1681
+ reject_promise,
1670
1682
  leader_component: component,
1671
1683
  leader_data: null,
1672
- leader_error: null,
1673
1684
  waiting: []
1674
1685
  };
1675
1686
  this._registry.set(key, entry);
@@ -1678,10 +1689,9 @@ class Load_Coordinator {
1678
1689
  }
1679
1690
  /**
1680
1691
  * Get the coordination promise for a follower component
1681
- * Returns a promise that resolves when the leader completes
1692
+ * Returns a promise that resolves when the leader completes, or rejects if it failed
1682
1693
  */
1683
- static get_coordination_promise(component) {
1684
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1694
+ static get_coordination_promise(key) {
1685
1695
  const entry = this._registry.get(key);
1686
1696
  if (!entry || entry.status !== 'loading') {
1687
1697
  return null;
@@ -1693,7 +1703,7 @@ class Load_Coordinator {
1693
1703
  * Stores leader's data, marks entry as completed, and resolves the promise.
1694
1704
  * Followers retrieve the data themselves via get_leader_data().
1695
1705
  *
1696
- * @param key - The coordination key
1706
+ * @param key - The coordination key captured at registration
1697
1707
  * @param leader - The leader component
1698
1708
  * @param final_data - The final data after on_load and normalization
1699
1709
  * @private
@@ -1703,14 +1713,14 @@ class Load_Coordinator {
1703
1713
  if (!entry) {
1704
1714
  return;
1705
1715
  }
1706
- // Store the final data (already normalized by _apply_load_result)
1707
- // Deep clone to avoid sharing proxy objects between components
1716
+ // Stored as TEXT so each follower parses its own object graph - sharing one
1717
+ // decoded object would let one component's mutation reach every other.
1708
1718
  try {
1709
- entry.leader_data = JSON.parse(JSON.stringify(final_data));
1719
+ entry.leader_data = JSON.stringify(final_data);
1710
1720
  }
1711
1721
  catch (error) {
1712
- // If data can't be serialized, use direct reference (may have issues)
1713
- entry.leader_data = final_data;
1722
+ // Unserializable data cannot be shared; followers load independently.
1723
+ entry.leader_data = null;
1714
1724
  }
1715
1725
  entry.status = 'completed';
1716
1726
  if (window.jqhtml?.debug?.verbose) {
@@ -1718,22 +1728,22 @@ class Load_Coordinator {
1718
1728
  }
1719
1729
  // Resolve the promise - followers waiting on it will now wake up
1720
1730
  entry.resolve_promise();
1721
- // Note: We do NOT delete the entry here anymore.
1722
- // Followers need to retrieve the data after the promise resolves.
1723
- // Entry will be cleaned up when last follower retrieves data.
1731
+ // Nobody is waiting, so nothing will ever come back to collect the data.
1732
+ // Without this the registry grows once per distinct name+args, forever.
1733
+ if (entry.waiting.length === 0) {
1734
+ this._registry.delete(key);
1735
+ }
1724
1736
  }
1725
1737
  /**
1726
1738
  * Get leader's data for a follower component
1727
1739
  * Called by follower after coordination promise resolves
1728
- * Returns the data and cleans up if this is the last follower
1740
+ * Returns a freshly parsed copy and cleans up if this is the last follower
1741
+ *
1742
+ * @param key - The dedup key captured by the follower before it began waiting
1729
1743
  */
1730
- static get_leader_data(component) {
1731
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1732
- if (!key) {
1733
- return null;
1734
- }
1744
+ static get_leader_data(component, key) {
1735
1745
  const entry = this._registry.get(key);
1736
- if (!entry || entry.status !== 'completed') {
1746
+ if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
1737
1747
  return null;
1738
1748
  }
1739
1749
  // Remove this follower from the waiting list
@@ -1741,6 +1751,8 @@ class Load_Coordinator {
1741
1751
  if (follower_index !== -1) {
1742
1752
  entry.waiting.splice(follower_index, 1);
1743
1753
  }
1754
+ // One parse per follower: each gets its OWN object, never a shared reference.
1755
+ const data = JSON.parse(entry.leader_data);
1744
1756
  if (window.jqhtml?.debug?.verbose) {
1745
1757
  console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
1746
1758
  }
@@ -1751,29 +1763,25 @@ class Load_Coordinator {
1751
1763
  console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
1752
1764
  }
1753
1765
  }
1754
- return entry.leader_data;
1766
+ return data;
1755
1767
  }
1756
1768
  /**
1757
1769
  * Handle leader on_load() error
1758
- * Propagates error to all followers and cleans up entry
1770
+ * Rejects every waiting follower and clears the entry so the next component retries
1771
+ *
1772
+ * @param key - The dedup key captured at registration
1759
1773
  */
1760
- static handle_leader_error(component, error) {
1761
- const { key } = this.generate_invocation_key(component.component_name(), component.args);
1774
+ static handle_leader_error(key, error) {
1762
1775
  const entry = this._registry.get(key);
1763
1776
  if (!entry) {
1764
1777
  return;
1765
1778
  }
1766
- entry.leader_error = error;
1767
1779
  entry.status = 'failed';
1768
- console.error(`[Load Coordinator] Leader ${component._cid} on_load() failed for key: ${key}`, error);
1769
- // Propagate error to all followers
1770
- // Note: Followers will handle errors the same way as if their own on_load() failed
1771
- // This is transparent to the developer
1772
- for (const follower of entry.waiting) {
1773
- console.error(`[Load Coordinator] Follower ${follower._cid} failed due to leader error`, error);
1774
- // The follower's lifecycle will handle the error naturally
1775
- // as the promise rejection will propagate
1776
- }
1780
+ console.error(`[Load Coordinator] Leader ${entry.leader_component._cid} on_load() failed for key: ${key}`, error);
1781
+ // Reject FIRST: followers are parked on this promise and their catch in _load()
1782
+ // propagates the failure exactly as if their own on_load() had thrown. Deleting
1783
+ // without rejecting left them awaiting a promise nobody could ever settle.
1784
+ entry.reject_promise(error);
1777
1785
  // Clear coordination entry so future requests can retry
1778
1786
  this._registry.delete(key);
1779
1787
  if (window.jqhtml?.debug?.verbose) {
@@ -1872,6 +1880,31 @@ Load_Coordinator._registry = new Map();
1872
1880
  * - Transient UI state (convenience, not required)
1873
1881
  *
1874
1882
  * @internal This class is not exposed in the public API
1883
+ *
1884
+ * ---------------------------------------------------------------------------
1885
+ * REPORTING WHAT WAS DROPPED
1886
+ *
1887
+ * `this.data` is always the serialized copy this file produces - in EVERY cache
1888
+ * mode, including 'none' (see component.ts `_apply_load_result`). Whatever the
1889
+ * serializer cannot express is therefore not merely missing from the cache, it
1890
+ * is missing from `this.data`, and an author needs to be told once rather than
1891
+ * discover it on the next cache hit.
1892
+ *
1893
+ * Two small optional parameters carry that:
1894
+ *
1895
+ * process_for_serialization(value, verbose, seen, path, collector?)
1896
+ * `collector(path, kind)` is invoked for every value that is dropped or
1897
+ * degraded. `path` is a dotted/bracketed path from the root the caller
1898
+ * named; `kind` is one of Strip_Kind. Paths are only BUILT when a
1899
+ * collector is present, so the no-collector path costs nothing.
1900
+ *
1901
+ * Jqhtml_Local_Storage.normalize_for_cache(value, component_name?)
1902
+ * With a component name, and only in development mode, each dropped path
1903
+ * prints exactly ONE console.warn for the life of the page (keyed
1904
+ * `${component_name}:${path}`, the same once-only shape as
1905
+ * warn_uncacheable_args in component-cache.ts). Production converts
1906
+ * silently. Neither ever throws.
1907
+ * ---------------------------------------------------------------------------
1875
1908
  */
1876
1909
  // ============================================================================
1877
1910
  // Class Registry for Serialization
@@ -1880,7 +1913,7 @@ Load_Coordinator._registry = new Map();
1880
1913
  const class_registry = Object.create(null);
1881
1914
  // Markers for serialized class instances (unique to avoid collisions with user data)
1882
1915
  // Replaced at build time by rollup (see rollup.config.js).
1883
- const CORE_VERSION = '2.3.67';
1916
+ const CORE_VERSION = '2.3.68';
1884
1917
  const CLASS_MARKER = '__jqhtml_class__';
1885
1918
  const PROPS_MARKER = '__jqhtml_props__';
1886
1919
  /**
@@ -1896,21 +1929,20 @@ function register_cache_class(klass) {
1896
1929
  }
1897
1930
  class_registry[klass.name] = klass;
1898
1931
  }
1899
- // ============================================================================
1900
- // Serialization (Object → String)
1901
- // ============================================================================
1902
1932
  /**
1903
1933
  * Serialize a value to JSON string, handling ES6 class instances recursively.
1904
1934
  * Returns null if serialization fails for any reason.
1905
1935
  *
1906
1936
  * @param value - The value to serialize
1907
1937
  * @param verbose - Whether to log warnings
1938
+ * @param path - Root path used when reporting drops (only used with a collector)
1939
+ * @param collector - Optional reporter, see the file header
1908
1940
  * @returns Serialized JSON string, or null if serialization failed
1909
1941
  */
1910
- function serialize_value(value, verbose) {
1942
+ function serialize_value(value, verbose, path = '', collector) {
1911
1943
  try {
1912
1944
  const seen = new WeakSet();
1913
- const processed = process_for_serialization(value, verbose, seen);
1945
+ const processed = process_for_serialization(value, verbose, seen, path, collector);
1914
1946
  if (processed === undefined) {
1915
1947
  // Serialization failed - undefined signals failure
1916
1948
  return null;
@@ -1926,14 +1958,23 @@ function serialize_value(value, verbose) {
1926
1958
  }
1927
1959
  /**
1928
1960
  * Recursively process a value for serialization.
1929
- * Returns undefined if serialization should fail (unserializable value encountered).
1961
+ *
1962
+ * Values the serializer cannot express are DROPPED, never passed through: the
1963
+ * result of this function is what becomes `this.data`, so letting a function or
1964
+ * a DOM node survive here would make `this.data` differ from what the cache can
1965
+ * ever return. A dropped property is omitted from an object and becomes `null`
1966
+ * in an array - JSON's own semantics for the same situation.
1967
+ *
1968
+ * Returns undefined for a value that has to be dropped.
1930
1969
  *
1931
1970
  * @param value - The value to process
1932
1971
  * @param verbose - Whether to log warnings
1933
1972
  * @param seen - WeakSet to detect circular references
1934
- * @returns Processed value ready for JSON.stringify, or undefined if failed
1973
+ * @param path - Dotted path of this value (only built when a collector is present)
1974
+ * @param collector - Optional reporter for dropped/degraded values
1975
+ * @returns Processed value ready for JSON.stringify, or undefined if dropped
1935
1976
  */
1936
- function process_for_serialization(value, verbose, seen) {
1977
+ function process_for_serialization(value, verbose, seen, path = '', collector) {
1937
1978
  // Handle null and undefined
1938
1979
  if (value === null) {
1939
1980
  return null;
@@ -1948,12 +1989,44 @@ function process_for_serialization(value, verbose, seen) {
1948
1989
  return value;
1949
1990
  }
1950
1991
  // bigint, symbol, function cannot be serialized
1992
+ if (typeof value === 'function') {
1993
+ collector?.(path, 'function');
1994
+ }
1995
+ else if (typeof value === 'symbol') {
1996
+ collector?.(path, 'symbol');
1997
+ }
1998
+ else if (typeof value === 'bigint') {
1999
+ collector?.(path, 'bigint');
2000
+ }
1951
2001
  if (verbose) {
1952
2002
  console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
1953
2003
  }
1954
2004
  // Return undefined to omit this value (not fail entire serialization)
1955
2005
  return undefined;
1956
2006
  }
2007
+ // Live objects that only LOOK like data. Each is checked before anything
2008
+ // treats it as a bag of properties, because walking one produces a
2009
+ // meaningless husk (a jQuery object becomes {0: ..., length: 1}) instead of
2010
+ // the drop the author needs to hear about.
2011
+ if (typeof value.nodeType === 'number') {
2012
+ collector?.(path, 'dom-node');
2013
+ return undefined;
2014
+ }
2015
+ if (value.jquery) {
2016
+ collector?.(path, 'jquery');
2017
+ return undefined;
2018
+ }
2019
+ // Duck-typed on purpose: importing Jqhtml_Component here would be a cycle
2020
+ // (component.ts imports this file). _cid + component_name() is the pair no
2021
+ // plain data object carries.
2022
+ if (value._cid !== undefined && typeof value.component_name === 'function') {
2023
+ collector?.(path, 'component');
2024
+ return undefined;
2025
+ }
2026
+ if (typeof value.then === 'function') {
2027
+ collector?.(path, 'promise');
2028
+ return undefined;
2029
+ }
1957
2030
  // Handle circular references.
1958
2031
  // `seen` tracks only the current ancestor path (the DFS stack): an object is
1959
2032
  // added on the way down and removed on the way back up (see finally below).
@@ -1962,10 +2035,11 @@ function process_for_serialization(value, verbose, seen) {
1962
2035
  // JSON round-trip semantics do not preserve identity anyway, so emitting a
1963
2036
  // copy per reference is correct; silently omitting the property is not.
1964
2037
  if (seen.has(value)) {
2038
+ collector?.(path, 'circular');
1965
2039
  if (verbose) {
1966
- console.warn('[JQHTML Cache] Circular reference detected - cannot serialize');
2040
+ console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
1967
2041
  }
1968
- return undefined; // Fail entire serialization
2042
+ return undefined; // Cut the cycle here; the rest of the tree still serializes
1969
2043
  }
1970
2044
  seen.add(value);
1971
2045
  try {
@@ -1974,7 +2048,7 @@ function process_for_serialization(value, verbose, seen) {
1974
2048
  const result = [];
1975
2049
  for (let i = 0; i < value.length; i++) {
1976
2050
  const item = value[i];
1977
- const processed = process_for_serialization(item, verbose, seen);
2051
+ const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
1978
2052
  // For arrays, we keep undefined as null to preserve indices
1979
2053
  result.push(processed === undefined ? null : processed);
1980
2054
  }
@@ -1991,8 +2065,9 @@ function process_for_serialization(value, verbose, seen) {
1991
2065
  if (value instanceof Map) {
1992
2066
  const entries = [];
1993
2067
  for (const [k, v] of value) {
1994
- const processedKey = process_for_serialization(k, verbose, seen);
1995
- const processedValue = process_for_serialization(v, verbose, seen);
2068
+ const entry_path = collector ? `${path}.get(${String(k)})` : '';
2069
+ const processedKey = process_for_serialization(k, verbose, seen, entry_path, collector);
2070
+ const processedValue = process_for_serialization(v, verbose, seen, entry_path, collector);
1996
2071
  entries.push([processedKey, processedValue]);
1997
2072
  }
1998
2073
  return {
@@ -2003,8 +2078,9 @@ function process_for_serialization(value, verbose, seen) {
2003
2078
  // Handle Set objects
2004
2079
  if (value instanceof Set) {
2005
2080
  const items = [];
2081
+ let index = 0;
2006
2082
  for (const item of value) {
2007
- items.push(process_for_serialization(item, verbose, seen));
2083
+ items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
2008
2084
  }
2009
2085
  return {
2010
2086
  [CLASS_MARKER]: 'Set',
@@ -2015,58 +2091,115 @@ function process_for_serialization(value, verbose, seen) {
2015
2091
  const ctor = value.constructor;
2016
2092
  // Handle registered class instances
2017
2093
  if (ctor && ctor.name && class_registry[ctor.name]) {
2018
- const props = {};
2019
- // Extract own enumerable properties (bypass any toJSON method)
2020
- for (const key of Object.keys(value)) {
2021
- const propValue = value[key];
2022
- const processed = process_for_serialization(propValue, verbose, seen);
2023
- // Only include if not undefined (failed serialization)
2024
- if (processed !== undefined || propValue === undefined) {
2025
- props[key] = processed;
2026
- }
2027
- }
2028
2094
  return {
2029
2095
  [CLASS_MARKER]: ctor.name,
2030
- [PROPS_MARKER]: props
2096
+ [PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
2031
2097
  };
2032
2098
  }
2033
2099
  // Handle plain objects (constructor is Object or no constructor)
2034
2100
  if (ctor === Object || ctor === undefined || ctor === null) {
2035
- const result = {};
2036
- for (const key of Object.keys(value)) {
2037
- const propValue = value[key];
2038
- const processed = process_for_serialization(propValue, verbose, seen);
2039
- // Only include if not undefined (unless original was undefined)
2040
- if (processed !== undefined || propValue === undefined) {
2041
- result[key] = processed;
2042
- }
2043
- }
2044
- return result;
2101
+ return process_own_properties(value, verbose, seen, path, collector);
2045
2102
  }
2046
- // Unregistered class instance - convert to plain object for hot/cold parity
2047
- // This ensures developers catch missing class registrations during development
2048
- // (methods will be lost, only properties preserved)
2103
+ // Unregistered class instance - convert to plain object for hot/cold parity.
2104
+ // Properties are preserved, prototype methods are lost, exactly as a cache
2105
+ // hit would produce, so the loss shows up on the first load rather than on
2106
+ // the next page view.
2107
+ collector?.(path, 'unregistered-class', ctor.name);
2049
2108
  if (verbose) {
2050
2109
  console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
2051
2110
  `Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
2052
2111
  }
2053
- // Convert to plain object - properties are preserved, prototype methods are lost
2054
- const result = {};
2055
- for (const key of Object.keys(value)) {
2056
- const propValue = value[key];
2057
- const processed = process_for_serialization(propValue, verbose, seen);
2058
- // Only include if not undefined (unless original was undefined)
2059
- if (processed !== undefined || propValue === undefined) {
2060
- result[key] = processed;
2061
- }
2062
- }
2063
- return result;
2112
+ return process_own_properties(value, verbose, seen, path, collector);
2064
2113
  }
2065
2114
  finally {
2066
2115
  // Pop this object off the ancestor path now that its subtree is done.
2067
2116
  seen.delete(value);
2068
2117
  }
2069
2118
  }
2119
+ /**
2120
+ * Walk an object's own enumerable properties (bypassing any toJSON) into a plain
2121
+ * object. Shared by plain objects, registered class instances and degraded
2122
+ * unregistered instances - all three keep exactly the same property semantics.
2123
+ */
2124
+ function process_own_properties(value, verbose, seen, path, collector) {
2125
+ const result = {};
2126
+ for (const key of Object.keys(value)) {
2127
+ const propValue = value[key];
2128
+ const processed = process_for_serialization(propValue, verbose, seen, collector ? `${path}.${key}` : '', collector);
2129
+ // Only include if not undefined (unless original was undefined)
2130
+ if (processed !== undefined || propValue === undefined) {
2131
+ result[key] = processed;
2132
+ }
2133
+ }
2134
+ return result;
2135
+ }
2136
+ // ============================================================================
2137
+ // Development warnings for dropped values
2138
+ // ============================================================================
2139
+ // One warning per component + path, for the life of the page. A list of 50 rows
2140
+ // each carrying an $on_select callback must print ONE warning, not 50, and must
2141
+ // not print it again on the next reload() or on the next instance of the same
2142
+ // component. Same shape as _warned_uncacheable in component-cache.ts.
2143
+ const _warned_stripped = new Set();
2144
+ /**
2145
+ * What was lost, and where the value actually belongs. Every message names a
2146
+ * destination, because "it was dropped" without "put it here instead" is not
2147
+ * actionable.
2148
+ */
2149
+ function strip_advice(kind, detail) {
2150
+ switch (kind) {
2151
+ case 'function':
2152
+ return 'a function cannot be serialized. Callbacks are configuration, not loaded data - ' +
2153
+ 'pass it in this.args (from the parent template) and keep only ids in this.data.';
2154
+ case 'promise':
2155
+ return 'a promise cannot be serialized. Await it inside on_load() and store the RESULT, ' +
2156
+ 'or hold the pending promise in this.state.';
2157
+ case 'dom-node':
2158
+ return 'a DOM node cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2159
+ case 'jquery':
2160
+ return 'a jQuery object cannot be serialized. Elements, timers, files and sockets belong in this.state.';
2161
+ case 'component':
2162
+ return 'a Jqhtml_Component cannot be serialized. Keep component references in this.state ' +
2163
+ 'and address children with this.sid().';
2164
+ case 'symbol':
2165
+ return 'a symbol cannot be serialized. Use a string, or keep it in this.state.';
2166
+ case 'bigint':
2167
+ return 'a bigint cannot be serialized. Convert it to a string in on_load(), or keep it in this.state.';
2168
+ case 'circular':
2169
+ return 'it points back to one of its own ancestors, and the cycle was cut. Store the graph ' +
2170
+ 'flat (ids instead of back-references) in this.data, or keep the linked form in this.state.';
2171
+ case 'unregistered-class':
2172
+ return `it is an instance of the unregistered class "${detail}", so it was degraded to a plain ` +
2173
+ `object and its methods are gone. Call jqhtml.register_cache_class(${detail}) at startup ` +
2174
+ `to get real instances back.`;
2175
+ }
2176
+ }
2177
+ /**
2178
+ * Build the collector normalize_for_cache() hands to the serializer, or
2179
+ * `undefined` when nobody is listening - production, or a caller that named no
2180
+ * component. Returning undefined (rather than a no-op function) is what keeps
2181
+ * the serializer from building path strings at all on the hot path.
2182
+ */
2183
+ function make_strip_collector(component_name) {
2184
+ if (!component_name)
2185
+ return undefined;
2186
+ if (get_config().mode !== 'development')
2187
+ return undefined;
2188
+ return (path, kind, detail) => {
2189
+ try {
2190
+ const dedupe_key = `${component_name}:${path}`;
2191
+ if (_warned_stripped.has(dedupe_key))
2192
+ return;
2193
+ _warned_stripped.add(dedupe_key);
2194
+ console.warn(`[JQHTML] <${component_name}> dropped ${path} from this.data: ${strip_advice(kind, detail)}\n` +
2195
+ ` this.data is always the serialized copy jqhtml stores in and restores from cache, ` +
2196
+ `so only serializable values survive on_load() - in every cache mode.`, { component: component_name, path, kind });
2197
+ }
2198
+ catch {
2199
+ // A warning must never be able to break a load.
2200
+ }
2201
+ };
2202
+ }
2070
2203
  // ============================================================================
2071
2204
  // Deserialization (String → Object)
2072
2205
  // ============================================================================
@@ -2410,36 +2543,36 @@ class Jqhtml_Local_Storage {
2410
2543
  /**
2411
2544
  * Perform a serialize/deserialize round-trip on a value.
2412
2545
  *
2413
- * This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
2414
- * (restored from cache). Unregistered class instances will be converted to plain
2415
- * objects, exactly as they would be if restored from cache.
2416
- *
2417
- * Use this to normalize data after on_load() so developers catch missing class
2418
- * registrations immediately rather than only after a page reload.
2546
+ * The result is ALWAYS jqhtml's own copy: nothing of the caller's object graph
2547
+ * is aliased through, and a value the serializer cannot express is dropped
2548
+ * rather than smuggled out. That is what makes `this.data` identical whether it
2549
+ * came from on_load() or from a cache hit - the old "on failure return the
2550
+ * original" branch broke exactly that promise, and only on the hard cases.
2419
2551
  *
2420
2552
  * @param {any} value - The value to normalize
2421
- * @returns {any} The value after serialize/deserialize round-trip, or original if serialization fails
2553
+ * @param {string} [component_name] - Names the component in development warnings.
2554
+ * Omit to convert silently (the unit tests and non-component callers).
2555
+ * @returns {any} The round-tripped value. Never the original object.
2422
2556
  */
2423
- static normalize_for_cache(value) {
2557
+ static normalize_for_cache(value, component_name) {
2424
2558
  const verbose = this._is_verbose();
2559
+ const collector = make_strip_collector(component_name);
2425
2560
  // Serialize to JSON string
2426
- const serialized = serialize_value(value, verbose);
2561
+ const serialized = serialize_value(value, verbose, 'this.data', collector);
2427
2562
  if (serialized === null) {
2428
- // Serialization failed completely - return original
2429
- // (This happens with circular references or other fatal issues)
2430
- if (verbose) {
2431
- console.warn('[JQHTML Cache] normalize_for_cache: Serialization failed, returning original value');
2432
- }
2433
- return value;
2563
+ // The value as a WHOLE could not be expressed (it was itself a function,
2564
+ // a promise, a DOM node...). The round trip of such a value is nothing;
2565
+ // returning the original would put an uncacheable object into this.data.
2566
+ return {};
2434
2567
  }
2435
2568
  // Deserialize back to object
2436
2569
  const deserialized = deserialize_value(serialized, verbose);
2437
- if (deserialized === null) {
2438
- // Deserialization failed - return original
2439
- if (verbose) {
2440
- console.warn('[JQHTML Cache] normalize_for_cache: Deserialization failed, returning original value');
2441
- }
2442
- return value;
2570
+ // `null` from deserialize_value() is ambiguous: it means "failed", but a value
2571
+ // that legitimately serialized to the text `null` also comes back as null. The
2572
+ // serialized text disambiguates.
2573
+ if (deserialized === null && serialized !== 'null') {
2574
+ // Only reachable when a registered class constructor blew up on restore.
2575
+ return {};
2443
2576
  }
2444
2577
  return deserialized;
2445
2578
  }
@@ -2462,7 +2595,10 @@ class Jqhtml_Local_Storage {
2462
2595
  console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
2463
2596
  // Clear only JQHTML keys and retry once
2464
2597
  this._clear_jqhtml_keys();
2465
- localStorage.setItem('_jqhtml_cache_key', this._cache_key);
2598
+ // Re-stamp the SCOPE MARKER, not the raw developer key: _validate_scope()
2599
+ // compares against `${CORE_VERSION}::${key}`, so writing the bare key here
2600
+ // made the very next write look like a scope change and wipe the cache again.
2601
+ localStorage.setItem('_jqhtml_cache_key', this._scope_marker);
2466
2602
  try {
2467
2603
  localStorage.setItem(scoped_key, serialized);
2468
2604
  }
@@ -2503,17 +2639,22 @@ Jqhtml_Local_Storage._initialized = false;
2503
2639
  * Extracted from component.ts - handles lifecycle event registration,
2504
2640
  * triggering, and state tracking.
2505
2641
  *
2506
- * Events have "sticky" semantics for lifecycle events: if an event has already
2507
- * occurred, new .on() handlers fire immediately with the stored data.
2642
+ * EVERY event is "sticky", not just the lifecycle ones: trigger() records the event
2643
+ * name and its payload, and a handler registered afterwards fires immediately with that
2644
+ * last payload. This is deliberate and applies to custom events too - a subscriber that
2645
+ * arrives after `trigger('row_selected', row)` still learns which row is selected,
2646
+ * which is what makes late-attaching UI work without a separate "current state" read.
2508
2647
  */
2509
2648
  /**
2510
2649
  * Register a callback for an event.
2511
2650
  *
2512
- * Lifecycle events (create, render, load, loaded, ready) are "sticky":
2513
- * If a lifecycle event has already occurred, the callback fires immediately
2514
- * AND registers for future occurrences.
2651
+ * All events are "sticky", lifecycle (create, render, load, loaded, ready, stop) and
2652
+ * custom alike: if the event has already been triggered, the callback fires immediately
2653
+ * with the payload of that last trigger AND registers for future occurrences.
2515
2654
  *
2516
- * Custom events only fire when explicitly triggered via trigger().
2655
+ * invalidate(event_name) drops the stored marker when a component wants later
2656
+ * subscribers to wait for the next real occurrence instead (reload() does this for
2657
+ * 'ready').
2517
2658
  *
2518
2659
  * @param component - The component instance
2519
2660
  * @param event_name - Name of the event
@@ -2645,6 +2786,20 @@ function event_invalidate(component, event_name) {
2645
2786
  * - Data freeze/unfreeze enforcement via Proxy
2646
2787
  * - Detached on_load() execution with restricted access
2647
2788
  */
2789
+ /**
2790
+ * JSON for an error message. The value being rejected is arbitrary author data and may
2791
+ * be cyclic or otherwise unserializable - the diagnostic must never throw over the
2792
+ * error it is trying to report.
2793
+ */
2794
+ function safe_json(value) {
2795
+ try {
2796
+ const text = JSON.stringify(value);
2797
+ return text === undefined ? String(value) : text;
2798
+ }
2799
+ catch {
2800
+ return String(value);
2801
+ }
2802
+ }
2648
2803
  /**
2649
2804
  * Set up the `this.data` property on a component using Object.defineProperty
2650
2805
  * with a Proxy that enforces freeze/unfreeze semantics.
@@ -2658,9 +2813,78 @@ function event_invalidate(component, event_name) {
2658
2813
  */
2659
2814
  function setup_data_property(component) {
2660
2815
  let _data = {};
2816
+ // Wrapper memo for the deep-freeze proxies. Keyed by the RAW target object, so a
2817
+ // value read twice returns the same wrapper and `this.data.items === this.data.items`
2818
+ // holds - identity comparisons and `Set`/`Map` membership on nested data keep working.
2819
+ // WeakMap: a wrapper dies with the object it wraps.
2820
+ const frozen_wrappers = new WeakMap();
2821
+ // Only plain objects and arrays are wrapped. Anything with a real prototype
2822
+ // (Date, Map, Set, and classes restored by register_cache_class()) carries internal
2823
+ // slots that a Proxy receiver cannot satisfy: `this.data.created.getTime()` would
2824
+ // call the method with the proxy as `this` and throw. Those values pass through raw -
2825
+ // a shallow hole we accept, because breaking Date/Map/Set reads would be worse than
2826
+ // missing a rare mutation of one.
2827
+ const is_wrappable = (value) => {
2828
+ if (value === null || typeof value !== 'object')
2829
+ return false;
2830
+ if (Array.isArray(value))
2831
+ return true;
2832
+ const proto = Object.getPrototypeOf(value);
2833
+ return proto === Object.prototype || proto === null;
2834
+ };
2835
+ // Dotted path for error messages: array indices read as [0], keys as .name
2836
+ const join_path = (path, target, prop) => Array.isArray(target) ? `${path}[${String(prop)}]` : `${path}.${String(prop)}`;
2837
+ const frozen_violation = (path, verb, value) => {
2838
+ const value_text = verb === 'modify' ? ` = ${safe_json(value)}` : '';
2839
+ console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to ${verb} ${path} outside of on_create() or on_load().\n\n` +
2840
+ `RESTRICTION: this.data can ONLY be modified in:\n` +
2841
+ ` - on_create() (set initial defaults, synchronous only)\n` +
2842
+ ` - on_load() (fetch data from APIs, can be async)\n\n` +
2843
+ `NESTED VALUES COUNT: the freeze is DEEP. Mutating an object or array inside\n` +
2844
+ `this.data (push, splice, assigning a property, delete) is modifying this.data.\n\n` +
2845
+ `WHY: this.data represents loaded state. Modifying it outside these methods bypasses the framework's render cycle.\n\n` +
2846
+ `FIX:\n` +
2847
+ ` ❌ In on_ready(): ${path}${value_text};\n` +
2848
+ ` ✅ In on_load(): fetch the value, so the framework can re-render\n` +
2849
+ ` ✅ For component-local bookkeeping: this.state (unrestricted, never cached)`);
2850
+ throw new Error(`[JQHTML] Cannot ${verb} ${path} outside of on_create() or on_load(). ` +
2851
+ `this.data is frozen after on_create() and unfrozen only during on_load(). ` +
2852
+ `The freeze is deep - nested objects and arrays are frozen too.`);
2853
+ };
2854
+ // Read-only view of a nested value. Handed out ONLY while __data_frozen is true
2855
+ // (the top-level get trap checks), so the traps here never need to re-check: a
2856
+ // wrapper the author kept a reference to stays read-only for its whole life.
2857
+ const create_frozen_wrapper = (obj, path) => {
2858
+ const existing = frozen_wrappers.get(obj);
2859
+ if (existing)
2860
+ return existing;
2861
+ const wrapper = new Proxy(obj, {
2862
+ get: (target, prop) => {
2863
+ const value = target[prop];
2864
+ // Functions are returned raw: array methods are invoked with the wrapper as
2865
+ // `this`, so push/splice still land in this trap's set/deleteProperty.
2866
+ return is_wrappable(value) ? create_frozen_wrapper(value, join_path(path, target, prop)) : value;
2867
+ },
2868
+ set: (target, prop, value) => frozen_violation(join_path(path, target, prop), 'modify', value),
2869
+ deleteProperty: (target, prop) => frozen_violation(join_path(path, target, prop), 'delete')
2870
+ });
2871
+ frozen_wrappers.set(obj, wrapper);
2872
+ return wrapper;
2873
+ };
2661
2874
  // Helper to create frozen proxy for data object
2662
2875
  const create_proxy = (obj) => {
2663
2876
  return new Proxy(obj, {
2877
+ get: (target, prop) => {
2878
+ const value = target[prop];
2879
+ // Deep freeze: while frozen, every nested object/array is read through a
2880
+ // read-only wrapper, so this.data.items.push(x) throws instead of silently
2881
+ // mutating. Unfrozen (on_create, on_load's detached run, _apply_load_result)
2882
+ // reads return the raw value, because those phases may legally mutate it.
2883
+ if (component.__data_frozen && is_wrappable(value)) {
2884
+ return create_frozen_wrapper(value, `this.data.${String(prop)}`);
2885
+ }
2886
+ return value;
2887
+ },
2664
2888
  set: (target, prop, value) => {
2665
2889
  if (component.__data_frozen) {
2666
2890
  console.error(`[JQHTML] ERROR: Component "${component.component_name()}" attempted to modify this.data.${String(prop)} outside of on_create() or on_load().\n\n` +
@@ -2730,10 +2954,13 @@ function setup_data_property(component) {
2730
2954
  * All other property access (this.$, this.$sid, etc.) throws errors.
2731
2955
  *
2732
2956
  * @param component - The component instance
2733
- * @param use_load_coordinator - Whether to use Load_Coordinator for deduplication
2957
+ * @param dedup_key - The dedup key captured by the caller, or null to run uncoordinated.
2958
+ * The key is passed in rather than recomputed because args may legally
2959
+ * change while on_load() runs, and a recomputed key would address a
2960
+ * different entry than the one this leader registered.
2734
2961
  * @returns The resulting data and optional coordination completion function
2735
2962
  */
2736
- async function execute_on_load_detached(component, use_load_coordinator = false) {
2963
+ async function execute_on_load_detached(component, dedup_key = null) {
2737
2964
  // Clone this.data from the snapshot captured after on_create()
2738
2965
  const data_clone = component.__initial_data_snapshot
2739
2966
  ? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
@@ -2741,14 +2968,23 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2741
2968
  // Create a read-only proxy for args that blocks all modifications
2742
2969
  // Can't use JSON clone because args may contain functions (_slots, callbacks)
2743
2970
  const component_name = component.component_name();
2971
+ // Wrapper memo for this one on_load() run. Without it every nested read built a
2972
+ // fresh Proxy, so `this.args.filter !== this.args.filter` for an object arg and
2973
+ // identity comparisons inside on_load() silently failed. Lives only for the duration
2974
+ // of this call - args may legally be replaced between loads.
2975
+ const readonly_wrappers = new WeakMap();
2744
2976
  const create_readonly_proxy = (obj, path = 'this.args') => {
2745
2977
  if (obj === null || typeof obj !== 'object')
2746
2978
  return obj;
2747
- return new Proxy(obj, {
2979
+ const existing = readonly_wrappers.get(obj);
2980
+ if (existing)
2981
+ return existing;
2982
+ const proxy = new Proxy(obj, {
2748
2983
  get(target, prop) {
2749
2984
  const value = target[prop];
2750
- // Recursively wrap nested objects (but not functions)
2751
- if (value !== null && typeof value === 'object' && typeof value !== 'function') {
2985
+ // Recursively wrap nested objects. Functions are returned raw - `typeof value`
2986
+ // is already 'object' here, so a function never reaches this branch.
2987
+ if (value !== null && typeof value === 'object') {
2752
2988
  return create_readonly_proxy(value, `${path}.${String(prop)}`);
2753
2989
  }
2754
2990
  return value;
@@ -2770,6 +3006,8 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2770
3006
  `this.args is read-only in on_load().`);
2771
3007
  }
2772
3008
  });
3009
+ readonly_wrappers.set(obj, proxy);
3010
+ return proxy;
2773
3011
  };
2774
3012
  // Create a detached context object that on_load will operate on
2775
3013
  const detached_context = {
@@ -2829,27 +3067,22 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
2829
3067
  `Only this.data can be modified in on_load().`);
2830
3068
  }
2831
3069
  });
2832
- // Create promise for this on_load() call
2833
- const on_load_promise = (async () => {
2834
- try {
2835
- await component._call_lifecycle('on_load', restricted_this);
2836
- }
2837
- catch (error) {
2838
- if (use_load_coordinator) {
2839
- // Handle error and notify coordinator
2840
- Load_Coordinator.handle_leader_error(component, error);
2841
- }
2842
- throw error;
2843
- }
2844
- })();
2845
- // If using Load_Coordinator, register as leader
2846
- // Note: We store the completion function but DON'T call it here
2847
- // It will be called by _load() after _apply_load_result() updates this.data
3070
+ // Register as leader BEFORE on_load() runs, so the error path always finds the entry
3071
+ // it has to reject. The completion function is stored, not called here: _load() calls
3072
+ // it after _apply_load_result() has updated this.data.
2848
3073
  let complete_coordination = null;
2849
- if (use_load_coordinator) {
2850
- complete_coordination = Load_Coordinator.register_leader(component, on_load_promise);
3074
+ if (dedup_key !== null) {
3075
+ complete_coordination = Load_Coordinator.register_leader(component, dedup_key);
3076
+ }
3077
+ try {
3078
+ await component._call_lifecycle('on_load', restricted_this);
3079
+ }
3080
+ catch (error) {
3081
+ if (dedup_key !== null) {
3082
+ Load_Coordinator.handle_leader_error(dedup_key, error);
3083
+ }
3084
+ throw error;
2851
3085
  }
2852
- await on_load_promise;
2853
3086
  // Note: We don't validate args changes here because external code (like parent
2854
3087
  // components calling reload()) is allowed to modify component.args during on_load().
2855
3088
  // The read-only proxy only prevents code INSIDE on_load() from modifying args.
@@ -2951,6 +3184,21 @@ function warn_uncacheable_component(component, uncacheable_property, uncacheable
2951
3184
  all_args: component.args ? Object.keys(component.args) : [],
2952
3185
  });
2953
3186
  }
3187
+ /**
3188
+ * Read an HTML-cache entry, rejecting anything that is not a well-formed snapshot.
3189
+ * Entries written by an older core are bare strings; they are treated as a miss (and
3190
+ * are cleared wholesale by the CORE_VERSION scope marker on the next version bump).
3191
+ */
3192
+ function read_html_snapshot(html_cache_key) {
3193
+ const entry = Jqhtml_Local_Storage.get(html_cache_key);
3194
+ if (entry !== null &&
3195
+ typeof entry === 'object' &&
3196
+ typeof entry.cid === 'string' &&
3197
+ typeof entry.html === 'string') {
3198
+ return entry;
3199
+ }
3200
+ return null;
3201
+ }
2954
3202
  /**
2955
3203
  * Read cache during create() phase.
2956
3204
  * Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
@@ -2982,11 +3230,11 @@ function read_cache_in_create(component) {
2982
3230
  if (cache_mode === 'html') {
2983
3231
  // HTML cache mode - check for cached HTML to inject on first render
2984
3232
  const html_cache_key = `${cache_key}::html`;
2985
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
2986
- if (cached_html !== null && typeof cached_html === 'string') {
3233
+ const cached_html = read_html_snapshot(html_cache_key);
3234
+ if (cached_html !== null) {
2987
3235
  component._cached_html = cached_html;
2988
3236
  if (window.jqhtml?.debug?.verbose) {
2989
- console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) found cached HTML`, { cache_key: html_cache_key, html_length: cached_html.length });
3237
+ 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 });
2990
3238
  }
2991
3239
  }
2992
3240
  else {
@@ -3043,10 +3291,10 @@ function check_cache_on_reload(component) {
3043
3291
  if (cache_mode === 'html') {
3044
3292
  // HTML cache mode - check for cached HTML
3045
3293
  const html_cache_key = `${cache_key}::html`;
3046
- const cached_html = Jqhtml_Local_Storage.get(html_cache_key);
3047
- if (cached_html !== null && typeof cached_html === 'string') {
3294
+ const cached_html = read_html_snapshot(html_cache_key);
3295
+ if (cached_html !== null) {
3048
3296
  if (window.jqhtml?.debug?.verbose) {
3049
- 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 });
3297
+ 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 });
3050
3298
  }
3051
3299
  component._cached_html = cached_html;
3052
3300
  // Call _render() directly (not render()) since _reload() manages the full lifecycle
@@ -3086,319 +3334,73 @@ function write_html_cache_snapshot(component) {
3086
3334
  if (component._is_dynamic) {
3087
3335
  component._should_cache_html_after_ready = false;
3088
3336
  const html = component.$.html();
3089
- const html_cache_key = `${component._cache_key}::html`;
3090
- Jqhtml_Local_Storage.set(html_cache_key, html);
3091
- if (window.jqhtml?.debug?.verbose) {
3092
- 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 });
3093
- }
3094
- }
3095
- else {
3096
- component._should_cache_html_after_ready = false;
3097
- if (window.jqhtml?.debug?.verbose) {
3098
- console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) is static (no data change) - skipping HTML cache`);
3099
- }
3100
- }
3101
- }
3102
- /**
3103
- * Write data/HTML to cache after on_load() completes.
3104
- * Called from _apply_load_result().
3105
- *
3106
- * @param component - The component instance
3107
- * @param data_changed - Whether this.data changed during on_load
3108
- */
3109
- function write_cache_on_loaded(component, data_changed) {
3110
- if (!data_changed || !component._cache_key) {
3111
- return;
3112
- }
3113
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3114
- if (cache_mode === 'html') {
3115
- // HTML cache mode - flag to cache HTML after children ready in _ready()
3116
- component._should_cache_html_after_ready = true;
3117
- if (window.jqhtml?.debug?.verbose) {
3118
- console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) will cache HTML after ready`, { cache_key: component._cache_key });
3119
- }
3120
- }
3121
- else {
3122
- // Data cache mode (default) - write this.data to cache
3123
- Jqhtml_Local_Storage.set(component._cache_key, component.data);
3124
- if (window.jqhtml?.debug?.verbose) {
3125
- console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cached data after on_load()`, { cache_key: component._cache_key, data: component.data });
3126
- }
3127
- }
3128
- }
3129
-
3130
- /**
3131
- * JQHTML v2 Template Renderer
3132
- *
3133
- * Connects compiled templates to components
3134
- * Processes instruction arrays and handles bindings
3135
- */
3136
- /**
3137
- * Process slot-only template inheritance
3138
- *
3139
- * When a component's template returns {_slots: {...}}, walk the prototype
3140
- * chain to find a parent template and invoke it with the child's slots as content.
3141
- *
3142
- * @param component The component instance
3143
- * @param childSlots The slots object from the child template
3144
- * @returns [instructions, context] tuple or null if no parent found
3145
- */
3146
- async function process_slot_inheritance(component, childSlots) {
3147
- // Walk the prototype chain to find parent class with template
3148
- let currentClass = Object.getPrototypeOf(component.constructor);
3149
- console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
3150
- while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
3151
- const className = currentClass.name;
3152
- console.log(`[JQHTML] Checking parent class: ${className}`);
3153
- // Skip internal framework classes
3154
- if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
3155
- currentClass = Object.getPrototypeOf(currentClass);
3156
- continue;
3157
- }
3158
- // Try to find template for this parent class
3159
- try {
3160
- const parentTemplate = get_template(className);
3161
- console.log(`[JQHTML] Template found for ${className}:`, parentTemplate ? parentTemplate.name : 'null');
3162
- // Check if we got the default template (means no real template found)
3163
- if (parentTemplate && parentTemplate.name !== 'Jqhtml_Component') {
3164
- console.log(`[JQHTML] Invoking parent template ${className}`);
3165
- // Found a real parent template - invoke it with child's slots as content
3166
- const [parentInstructions, parentContext] = parentTemplate.render.call(component, component.data, component.args, childSlots // Pass child slots as content parameter
3167
- );
3168
- // Check if parent also returns slots (recursive inheritance)
3169
- if (parentInstructions && typeof parentInstructions === 'object' && parentInstructions._slots) {
3170
- // Parent also has slot-only template - recurse
3171
- console.log(`[JQHTML] Parent also slot-only, recursing`);
3172
- return await process_slot_inheritance(component, parentInstructions._slots);
3173
- }
3174
- // Parent returned HTML - we're done
3175
- console.log(`[JQHTML] Parent returned instructions, inheritance complete`);
3176
- return [parentInstructions, parentContext];
3177
- }
3178
- }
3179
- catch (error) {
3180
- console.warn(`[JQHTML] Error looking up parent template for ${className}:`, error);
3181
- }
3182
- // Move to next parent
3183
- currentClass = Object.getPrototypeOf(currentClass);
3184
- }
3185
- // No parent template found
3186
- console.warn(`[JQHTML] No parent template found after walking chain`);
3187
- return null;
3188
- }
3189
- /**
3190
- * Render a template for a component
3191
- * Templates are functions that return [instructions, context] tuples
3192
- */
3193
- async function render_template(component, template_fn) {
3194
- // Get template from registry if not provided
3195
- let render_fn = template_fn;
3196
- if (!render_fn) {
3197
- const template_def = get_template_by_class(component.constructor);
3198
- render_fn = template_def.render;
3199
- }
3200
- if (!render_fn) {
3201
- // No template, component should handle its own rendering
3202
- return;
3203
- }
3204
- // Clear existing content
3205
- component.$.empty();
3206
- // Execute template function
3207
- // Templates receive: (data, args, content)
3208
- // Provide a default empty content function if none provided
3209
- const defaultContent = () => '';
3210
- let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
3211
- );
3212
- // Check for slot-only template inheritance
3213
- // If instructions is {_slots: {...}}, this is a slot-only template
3214
- // We need to find the parent template and invoke it with these slots
3215
- if (instructions && typeof instructions === 'object' && instructions._slots) {
3216
- console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
3217
- const result = await process_slot_inheritance(component, instructions._slots);
3218
- if (result) {
3219
- console.log(`[JQHTML] Parent template found, using parent instructions`);
3220
- instructions = result[0];
3221
- context = result[1];
3222
- }
3223
- else {
3224
- console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
3225
- // No parent template found - return empty
3226
- instructions = [];
3227
- }
3228
- }
3229
- // Process instructions to generate DOM
3230
- await process_instructions(instructions, component.$, component);
3231
- // Process bindings after DOM is created
3232
- await process_bindings(component);
3233
- // Attach event handlers
3234
- await attach_event_handlers(component);
3235
- }
3236
- /**
3237
- * Process data bindings (attributes created by :prop syntax)
3238
- */
3239
- async function process_bindings(component) {
3240
- // Find all elements with data-bind-* attributes
3241
- component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
3242
- const el = $(element);
3243
- const attrs = element.attributes;
3244
- for (let i = 0; i < attrs.length; i++) {
3245
- const attr = attrs[i];
3246
- if (attr.name.startsWith('data-bind-')) {
3247
- const binding_type = attr.name.substring(10); // Remove 'data-bind-'
3248
- const expression = attr.value;
3249
- try {
3250
- // Evaluate expression in component context
3251
- const value = evaluate_expression(expression, component);
3252
- // Apply binding based on type
3253
- switch (binding_type) {
3254
- case 'prop':
3255
- // Generic property binding
3256
- const prop_name = el.attr('data-bind-prop-name') || 'value';
3257
- el.prop(prop_name, value);
3258
- break;
3259
- case 'value':
3260
- el.val(value);
3261
- break;
3262
- case 'text':
3263
- el.text(value);
3264
- break;
3265
- case 'html':
3266
- el.html(value);
3267
- break;
3268
- case 'class':
3269
- if (typeof value === 'object') {
3270
- // Object syntax: {active: true, disabled: false}
3271
- Object.entries(value).forEach(([className, enabled]) => {
3272
- el.toggleClass(className, !!enabled);
3273
- });
3274
- }
3275
- else {
3276
- // String syntax
3277
- el.addClass(String(value));
3278
- }
3279
- break;
3280
- case 'style':
3281
- if (typeof value === 'object') {
3282
- el.css(value);
3283
- }
3284
- else {
3285
- el.attr('style', String(value));
3286
- }
3287
- break;
3288
- default:
3289
- // Custom binding - set as attribute
3290
- el.attr(binding_type, value);
3291
- }
3292
- }
3293
- catch (error) {
3294
- console.error(`Error evaluating binding "${expression}":`, error);
3295
- }
3296
- }
3297
- }
3298
- });
3299
- }
3300
- /**
3301
- * Attach event handlers (attributes created by @event syntax)
3302
- */
3303
- async function attach_event_handlers(component) {
3304
- // Find all elements with data-on-* attributes
3305
- 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) => {
3306
- const el = $(element);
3307
- const attrs = element.attributes;
3308
- for (let i = 0; i < attrs.length; i++) {
3309
- const attr = attrs[i];
3310
- if (attr.name.startsWith('data-on-')) {
3311
- const event_name = attr.name.substring(8); // Remove 'data-on-'
3312
- const handler_expr = attr.value;
3313
- // Remove the attribute to avoid re-processing
3314
- el.removeAttr(attr.name);
3315
- // Attach event handler
3316
- el.on(event_name, function (event) {
3317
- try {
3318
- // Create handler function in component context
3319
- const handler = evaluate_handler(handler_expr, component);
3320
- if (typeof handler === 'function') {
3321
- // Call with component as 'this' and pass event
3322
- handler.call(component, event);
3323
- }
3324
- else {
3325
- // Expression to execute
3326
- evaluate_expression(handler_expr, component, { $event: event });
3327
- }
3328
- }
3329
- catch (error) {
3330
- console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
3331
- }
3332
- });
3333
- }
3334
- }
3335
- });
3336
- }
3337
- /**
3338
- * Evaluate an expression in component context
3339
- */
3340
- function evaluate_expression(expression, component, locals = {}) {
3341
- // Create evaluation context
3342
- const context = {
3343
- // Component properties
3344
- data: component.data,
3345
- args: component.args,
3346
- $: component.$,
3347
- // Component methods
3348
- $sid: component.$sid.bind(component),
3349
- // Locals (like $event)
3350
- ...locals
3351
- };
3352
- // Build function that evaluates in context
3353
- const keys = Object.keys(context);
3354
- const values = Object.values(context);
3355
- try {
3356
- // Use Function constructor for better performance than eval
3357
- const fn = new Function(...keys, `return (${expression})`);
3358
- return fn(...values);
3337
+ const html_cache_key = `${component._cache_key}::html`;
3338
+ // Store the cid this markup was rendered under alongside it: every scoped id inside
3339
+ // reads "<name>:<cid>", and a reader cannot tell the snapshotting component's own
3340
+ // elements from its children's without knowing which cid was the owner's.
3341
+ // See Jqhtml_Component._rescope_cached_html().
3342
+ Jqhtml_Local_Storage.set(html_cache_key, { cid: component._cid, html });
3343
+ if (window.jqhtml?.debug?.verbose) {
3344
+ 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 });
3345
+ }
3359
3346
  }
3360
- catch (error) {
3361
- console.error(`Invalid expression: ${expression}`, error);
3362
- return undefined;
3347
+ else {
3348
+ component._should_cache_html_after_ready = false;
3349
+ if (window.jqhtml?.debug?.verbose) {
3350
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) is static (no data change) - skipping HTML cache`);
3351
+ }
3363
3352
  }
3364
3353
  }
3365
3354
  /**
3366
- * Evaluate a handler expression (might be method name or inline code)
3355
+ * Write data/HTML to cache after on_load() completes.
3356
+ * Called from _apply_load_result().
3357
+ *
3358
+ * @param component - The component instance
3359
+ * @param data_changed - Whether this.data changed during on_load
3367
3360
  */
3368
- function evaluate_handler(expression, component) {
3369
- // Check if it's a method name on the component
3370
- if (expression in component && typeof component[expression] === 'function') {
3371
- return component[expression];
3361
+ function write_cache_on_loaded(component, data_changed) {
3362
+ if (!data_changed || !component._cache_key) {
3363
+ return;
3372
3364
  }
3373
- // Otherwise treat as inline code
3374
- try {
3375
- return new Function('$event', `
3376
- const { data, args, $, emit, $sid } = this;
3377
- ${expression}
3378
- `).bind(component);
3365
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
3366
+ if (cache_mode === 'html') {
3367
+ // HTML cache mode - flag to cache HTML after children ready in _ready()
3368
+ component._should_cache_html_after_ready = true;
3369
+ if (window.jqhtml?.debug?.verbose) {
3370
+ console.log(`[Cache html] Component ${component._cid} (${component.component_name()}) will cache HTML after ready`, { cache_key: component._cache_key });
3371
+ }
3379
3372
  }
3380
- catch (error) {
3381
- console.error(`Invalid handler: ${expression}`, error);
3382
- return null;
3373
+ else {
3374
+ // Data cache mode (default) - write this.data to cache
3375
+ Jqhtml_Local_Storage.set(component._cache_key, component.data);
3376
+ if (window.jqhtml?.debug?.verbose) {
3377
+ console.log(`[Cache data] Component ${component._cid} (${component.component_name()}) cached data after on_load()`, { cache_key: component._cache_key, data: component.data });
3378
+ }
3383
3379
  }
3384
3380
  }
3381
+
3385
3382
  /**
3386
- * Helper to escape HTML for safe output
3383
+ * HTML escaping for interpolated text.
3384
+ *
3385
+ * Output matches what `div.textContent = s; div.innerHTML` produced before -
3386
+ * a text node serialises only &, < and > as entities, and null/undefined as
3387
+ * "" - but without touching the DOM. Interpolation runs for every value of
3388
+ * every render, and a createElement per value was the most expensive thing a
3389
+ * text-heavy template did.
3387
3390
  */
3388
- function escape_html(str) {
3389
- const div = document.createElement('div');
3390
- div.textContent = str;
3391
- return div.innerHTML;
3391
+ const NEEDS_ESCAPE = /[&<>]/;
3392
+ const ESCAPE_ALL = /[&<>]/g;
3393
+ const ENTITY = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
3394
+ const to_entity = (c) => ENTITY[c];
3395
+ function escape_html(value) {
3396
+ if (value === null || value === undefined)
3397
+ return '';
3398
+ const s = typeof value === 'string' ? value : String(value);
3399
+ return NEEDS_ESCAPE.test(s) ? s.replace(ESCAPE_ALL, to_entity) : s;
3392
3400
  }
3393
- /**
3394
- * Helper to escape HTML and convert newlines to <br /> tags
3395
- * Used by <%br= %> syntax for displaying user-entered text with line breaks
3396
- */
3397
- function escape_html_nl2br(str) {
3398
- const div = document.createElement('div');
3399
- div.textContent = str;
3400
- // Escape HTML first, then replace newlines with <br />
3401
- return div.innerHTML.replace(/\n/g, '<br />');
3401
+ /** Escaped, then newlines become <br /> - the <%br= %> construct. */
3402
+ function escape_html_nl2br(value) {
3403
+ return escape_html(value).replace(/\n/g, '<br />');
3402
3404
  }
3403
3405
 
3404
3406
  /**
@@ -3521,13 +3523,14 @@ function describe(value) {
3521
3523
  /**
3522
3524
  * JQHTML Component Lifecycle Queue
3523
3525
  *
3524
- * Serializes lifecycle operations (render, reload, refresh) per component.
3526
+ * Serializes lifecycle operations (render, reload, refresh, load) per component.
3525
3527
  * Replaces _create_debounced_function with a proper queue that:
3526
3528
  *
3527
3529
  * - At most one operation runs at a time per component
3528
- * - At most one operation is pending (waiting for the current to finish)
3529
- * - Same-type pending operations collapse (fan-in: all callers share one execution)
3530
- * - Different-type pending operations: new replaces old (old callers get the new result)
3530
+ * - At most one PENDING entry per type; different types queue behind each other FIFO
3531
+ * - Same-type pending operations collapse: the newest executor wins, and every
3532
+ * collapsed caller is settled with the value of the executor that actually ran
3533
+ * - A caller is settled BEFORE the next pending entry starts
3531
3534
  *
3532
3535
  * Boot bypasses this queue entirely - it runs the lifecycle directly.
3533
3536
  */
@@ -3535,92 +3538,106 @@ class Component_Queue {
3535
3538
  constructor() {
3536
3539
  /** Currently executing operation */
3537
3540
  this._current = null;
3538
- /** Next operation waiting to execute after current completes */
3539
- this._pending = null;
3541
+ /**
3542
+ * Operations waiting to run after the current one, in FIFO order.
3543
+ * At most one entry per type: a second reload() while a reload is pending
3544
+ * collapses into it, but a render() queued over a pending reload() is its
3545
+ * own entry and both run. Replacing the older entry (the previous behaviour)
3546
+ * dropped that operation entirely while still resolving its callers as if it
3547
+ * had run.
3548
+ */
3549
+ this._pending = [];
3540
3550
  }
3541
3551
  /**
3542
3552
  * Enqueue a lifecycle operation.
3543
3553
  *
3544
3554
  * Behavior:
3545
3555
  * - Nothing running → execute immediately
3546
- * - Something running, no pending → create pending entry
3547
- * - Something running, same type pending → collapse (share pending promise)
3548
- * - Something running, different type pending → replace pending (all callers get new result)
3556
+ * - Something running, no pending entry of this type → queue one (FIFO)
3557
+ * - Something running, same type pending → collapse: keep every caller, but
3558
+ * swap in the newest executor ("only the most recent call executes")
3559
+ *
3560
+ * Every caller collapsed onto one entry receives the value that entry's
3561
+ * executor returned - or its error, if it threw.
3549
3562
  *
3550
3563
  * @param type - Operation type ('render', 'reload', 'refresh', 'load')
3551
3564
  * @param executor - The async function to execute
3552
- * @returns Promise that resolves when the operation completes
3565
+ * @returns Promise that settles with the result of the execution that ran
3553
3566
  */
3554
3567
  enqueue(type, executor) {
3555
- // If nothing running, execute immediately
3568
+ // If nothing running, execute immediately.
3569
+ // The Promise executor runs synchronously, so the operation's synchronous
3570
+ // prologue (_render(), the ready invalidation) still happens in the calling
3571
+ // tick, as it did before the queue existed.
3556
3572
  if (!this._current) {
3557
- return this._execute(type, executor);
3558
- }
3559
- // Something is running — add to pending
3560
- if (this._pending) {
3561
- if (this._pending.type === type) {
3562
- // Same type pending → collapse (fan-in)
3563
- // All callers share the same future execution
3564
- return new Promise((resolve, reject) => {
3565
- this._pending.resolvers.push(resolve);
3566
- this._pending.rejecters.push(reject);
3567
- });
3568
- }
3569
- else {
3570
- // Different type pending → replace
3571
- // Old pending callers join the new operation
3572
- // (their operation was superseded by a different type)
3573
- const old_resolvers = this._pending.resolvers;
3574
- const old_rejecters = this._pending.rejecters;
3575
- return new Promise((resolve, reject) => {
3576
- this._pending = {
3577
- type,
3578
- executor,
3579
- resolvers: [...old_resolvers, resolve],
3580
- rejecters: [...old_rejecters, reject]
3581
- };
3582
- });
3583
- }
3573
+ return new Promise((resolve, reject) => {
3574
+ this._run({ type, executor, resolvers: [resolve], rejecters: [reject] });
3575
+ });
3576
+ }
3577
+ const existing = this._pending.find(entry => entry.type === type);
3578
+ if (existing) {
3579
+ // Same type already pending → collapse (fan-in).
3580
+ // The newest executor replaces the pending one: the documented rule is
3581
+ // "only the most recent call executes", and the older closure may carry
3582
+ // stale arguments. Keeping the OLD executor made two rapid `await load()`
3583
+ // calls resolve the second one `false` without ever running on_load().
3584
+ existing.executor = executor;
3585
+ return new Promise((resolve, reject) => {
3586
+ existing.resolvers.push(resolve);
3587
+ existing.rejecters.push(reject);
3588
+ });
3584
3589
  }
3585
- // No pending entry yet — create one
3590
+ // No pending entry of this type yet — queue one behind whatever is already waiting.
3586
3591
  return new Promise((resolve, reject) => {
3587
- this._pending = {
3588
- type,
3589
- executor,
3590
- resolvers: [resolve],
3591
- rejecters: [reject]
3592
- };
3592
+ this._pending.push({ type, executor, resolvers: [resolve], rejecters: [reject] });
3593
3593
  });
3594
3594
  }
3595
3595
  /**
3596
- * Execute an operation and handle pending queue after completion.
3596
+ * Run one entry, settle its callers, then start the next pending entry.
3597
3597
  * @private
3598
3598
  */
3599
- _execute(type, executor) {
3599
+ _run(entry) {
3600
+ // Mark busy BEFORE invoking the executor: the executor's synchronous prologue
3601
+ // can call back into enqueue(), and that call must queue rather than start a
3602
+ // second concurrent operation.
3603
+ this._current = { type: entry.type };
3600
3604
  const promise = (async () => {
3601
3605
  try {
3602
- await executor();
3606
+ const value = await entry.executor();
3607
+ // Settle this entry's callers FIRST, while the queue still reads as busy.
3608
+ for (const resolve of entry.resolvers)
3609
+ resolve(value);
3603
3610
  }
3604
- finally {
3605
- this._current = null;
3606
- // Run pending if exists
3607
- if (this._pending) {
3608
- const pending = this._pending;
3609
- this._pending = null;
3610
- try {
3611
- await this._execute(pending.type, pending.executor);
3612
- for (const resolve of pending.resolvers)
3613
- resolve();
3614
- }
3615
- catch (err) {
3616
- for (const reject of pending.rejecters)
3617
- reject(err);
3618
- }
3619
- }
3611
+ catch (error) {
3612
+ // A throwing executor rejects only its own callers; the queue continues.
3613
+ for (const reject of entry.rejecters)
3614
+ reject(error);
3620
3615
  }
3621
3616
  })();
3622
- this._current = { type, promise };
3623
- return promise;
3617
+ // The drain is deliberately OUTSIDE the awaited chain above: chaining it
3618
+ // inside made `await c.render()` also wait for a reload that was queued
3619
+ // after it.
3620
+ promise.then(() => {
3621
+ if (this._pending.length === 0) {
3622
+ // Nothing queued: release immediately, so the very common
3623
+ // `await c.reload(); c.render();` still starts the render in that tick.
3624
+ this._current = null;
3625
+ return;
3626
+ }
3627
+ // A queued entry must not start until every caller of the operation that
3628
+ // just finished has actually resumed. Those callers sit behind an unknown
3629
+ // number of microtask hops - an `async` public method adds two just by
3630
+ // returning the queue's promise - so draining on a microtask would start
3631
+ // the next on_load() before the previous caller's `await` came back. A
3632
+ // macrotask is the only boundary that is after all of them. _current stays
3633
+ // set until then, so nothing can start concurrently in the meantime.
3634
+ setTimeout(() => {
3635
+ this._current = null;
3636
+ const next = this._pending.shift();
3637
+ if (next)
3638
+ this._run(next);
3639
+ }, 0);
3640
+ });
3624
3641
  }
3625
3642
  /**
3626
3643
  * Check if any operation is currently running.
@@ -3632,7 +3649,7 @@ class Component_Queue {
3632
3649
  * Check if there's a pending operation waiting.
3633
3650
  */
3634
3651
  get has_pending() {
3635
- return this._pending !== null;
3652
+ return this._pending.length > 0;
3636
3653
  }
3637
3654
  }
3638
3655
 
@@ -3683,11 +3700,16 @@ function capture_component_data(component_name, args, data, cache_key) {
3683
3700
  try {
3684
3701
  const cloned_args = JSON.parse(JSON.stringify(args));
3685
3702
  const cloned_data = JSON.parse(JSON.stringify(data));
3686
- _capture_buffer.push({
3703
+ const entry = {
3687
3704
  component: component_name,
3688
3705
  args: cloned_args,
3689
3706
  data: cloned_data,
3690
- });
3707
+ };
3708
+ // Carry the key forward so the client matches on the SAME identity the server
3709
+ // used, rather than re-deriving one from args and hoping the shapes agree.
3710
+ if (cache_key !== null)
3711
+ entry.key = cache_key;
3712
+ _capture_buffer.push(entry);
3691
3713
  }
3692
3714
  catch (error) {
3693
3715
  // Non-serializable data — skip capture for this component
@@ -3733,10 +3755,15 @@ function set_preload_data(entries) {
3733
3755
  return;
3734
3756
  _preload_cache.clear();
3735
3757
  for (const entry of entries) {
3736
- // Generate key using the same algorithm as Load Coordinator
3737
- const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
3738
- if (result.key !== null) {
3739
- _preload_cache.set(result.key, entry.data);
3758
+ // The captured key wins. Falling back to args means matching what
3759
+ // generate_cache_key() computes for a component with no cache_id(), which
3760
+ // INCLUDES content serialization - without it an object arg keyed to null
3761
+ // here and the entry was dropped, so preload never applied to it.
3762
+ const key = entry.key !== undefined
3763
+ ? entry.key
3764
+ : Load_Coordinator.generate_invocation_key(entry.component, entry.args, { allow_content_serialization: true }).key;
3765
+ if (key !== null) {
3766
+ _preload_cache.set(key, entry.data);
3740
3767
  }
3741
3768
  }
3742
3769
  }
@@ -3849,9 +3876,69 @@ function init_viewport_resize(jQuery) {
3849
3876
  * - Scoped IDs using _cid pattern
3850
3877
  * - Event emission and CSS class hierarchy
3851
3878
  */
3879
+ /**
3880
+ * The `jqhtml` argument every compiled render function receives: the same
3881
+ * escaping the runtime exports, the object-printer entry point, and the
3882
+ * dynamic tag name check. One shared object, not one per render.
3883
+ */
3884
+ const render_utilities = {
3885
+ escape_html,
3886
+ escape_html_nl2br,
3887
+ print_object,
3888
+ dynamic_component_name,
3889
+ };
3852
3890
  // WeakMap storage for protected lifecycle method implementations (Option 2)
3853
3891
  // See docs/internal/lifecycle-method-protection.md for design details
3854
3892
  const lifecycle_impls = new WeakMap();
3893
+ /**
3894
+ * Resolve the template a template inherits FROM, in the one order CLAUDE.md
3895
+ * documents ("Template Inheritance Resolution"): the explicit `extends=""`
3896
+ * attribute first, then the JS class prototype chain.
3897
+ *
3898
+ * This is the single implementation. Both slot-only rendering (which invokes the
3899
+ * parent template with the child's slots) and default-attribute collection walk
3900
+ * it, so a `<Define>`'s class/attrs can never resolve through a different chain
3901
+ * than its markup does.
3902
+ *
3903
+ * @param template_def The template whose parent is wanted
3904
+ * @param ctor The component class to walk the prototype chain of (null skips it)
3905
+ * @returns The parent template and the name it was found under, or null
3906
+ */
3907
+ function resolve_parent_template(template_def, ctor) {
3908
+ const verbose = window.jqhtml?.debug?.verbose;
3909
+ // 1. Explicit extends="" wins - the author said exactly which template to use.
3910
+ if (template_def?.extends) {
3911
+ const parent = get_template(template_def.extends);
3912
+ // get_template() never returns null: a miss yields DEFAULT_TEMPLATE, whose
3913
+ // name is 'Jqhtml_Component'. That is "not found", not a parent.
3914
+ if (parent && parent.name !== 'Jqhtml_Component') {
3915
+ if (verbose)
3916
+ console.log(`[JQHTML] resolve_parent_template: explicit extends -> ${template_def.extends}`);
3917
+ return { template: parent, name: template_def.extends };
3918
+ }
3919
+ if (verbose)
3920
+ console.log(`[JQHTML] resolve_parent_template: extends="${template_def.extends}" has no template`);
3921
+ }
3922
+ // 2. Otherwise walk the JS prototype chain for the nearest ancestor with a template.
3923
+ // Stop at the framework base, at Object, and at Function.prototype (name === ''),
3924
+ // which is what getPrototypeOf() hands back once the class chain is exhausted.
3925
+ let current_class = ctor ? Object.getPrototypeOf(ctor) : null;
3926
+ while (typeof current_class === 'function' && current_class.name &&
3927
+ current_class.name !== 'Object' && current_class.name !== 'Jqhtml_Component') {
3928
+ const class_name = current_class.name;
3929
+ // Internal base classes are aliases of Jqhtml_Component, never a parent template.
3930
+ if (class_name !== '_Jqhtml_Component' && class_name !== '_Base_Jqhtml_Component') {
3931
+ const class_template = get_template(class_name);
3932
+ if (class_template && class_template.name !== 'Jqhtml_Component' && class_template !== template_def) {
3933
+ if (verbose)
3934
+ console.log(`[JQHTML] resolve_parent_template: prototype chain -> ${class_name}`);
3935
+ return { template: class_template, name: class_name };
3936
+ }
3937
+ }
3938
+ current_class = Object.getPrototypeOf(current_class);
3939
+ }
3940
+ return null;
3941
+ }
3855
3942
  class Jqhtml_Component {
3856
3943
  constructor(element, args = {}) {
3857
3944
  this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
@@ -3876,7 +3963,10 @@ class Jqhtml_Component {
3876
3963
  // Cache mode properties
3877
3964
  this._cache_key = null; // Cache key for caching
3878
3965
  // 'html' mode caching
3879
- this._cached_html = null; // Cached HTML to inject on first render
3966
+ // The snapshot carries the cid it was taken under, not just markup: every scoped id
3967
+ // inside it reads "<name>:<cid>", so replaying it into a different instance requires
3968
+ // knowing which cid was the snapshotting component's own. See _rescope_cached_html().
3969
+ this._cached_html = null; // Cached HTML snapshot to inject on first render
3880
3970
  this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
3881
3971
  this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
3882
3972
  this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
@@ -4120,19 +4210,58 @@ class Jqhtml_Component {
4120
4210
  return child._render(null, options);
4121
4211
  }
4122
4212
  this._log_lifecycle('render', 'start');
4213
+ // --- Shared re-render prologue -------------------------------------------------
4214
+ // Everything below runs for BOTH the cached-HTML path and the template path. The
4215
+ // cached path used to return before it, so a cache-mode reload injected over a live
4216
+ // subtree without ever stopping the old children: their on_stop() never ran (timers
4217
+ // and listeners leaked) and they stayed in _dom_children as dead entries that every
4218
+ // later _wait_for_children_ready() walked.
4219
+ // Determine child-finding strategy: If component is off-DOM, children can't register
4220
+ // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4221
+ // If attached to DOM, children register normally and we can use the fast _dom_children path.
4222
+ // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4223
+ if (!$.contains(document.documentElement, this.$[0])) {
4224
+ this._use_dom_fallback = true;
4225
+ }
4226
+ else {
4227
+ this._use_dom_fallback = false;
4228
+ }
4229
+ // If this is not the first render, stop child components and clear DOM
4230
+ if (this._did_first_render) {
4231
+ // Stop all child components before clearing DOM
4232
+ this.$.find('.Component').each(function () {
4233
+ const child = $(this).data('_component');
4234
+ if (child && !child._stopped) {
4235
+ child._stop(); // Stop just the component, DOM will be cleared next
4236
+ }
4237
+ });
4238
+ // Clear the DOM
4239
+ this.$[0].innerHTML = '';
4240
+ }
4241
+ else {
4242
+ this._did_first_render = true;
4243
+ }
4244
+ // (No removeClass('_Component_Stopped') here: _render() returns above when
4245
+ // _stopped, and nothing ever clears _stopped, so a stopped component can never
4246
+ // reach this line. Clearing the class would only ever hide a real stopped state.)
4247
+ // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4248
+ this._dom_children.clear();
4249
+ // --- End shared re-render prologue ---------------------------------------------
4123
4250
  // HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
4124
4251
  if (this._cached_html !== null) {
4125
4252
  if (window.jqhtml?.debug?.verbose) {
4126
- console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
4127
- }
4128
- // Inject cached HTML directly
4129
- this.$[0].innerHTML = this._cached_html;
4253
+ 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 });
4254
+ }
4255
+ // Inject cached HTML directly, with every scoped id re-scoped to THIS instance.
4256
+ // The snapshot was taken from another component instance (or an earlier life of
4257
+ // this one), so its ids carry that instance's cids verbatim. Injecting them as-is
4258
+ // put duplicate ids in the document, and $sid() - which is getElementById first -
4259
+ // could hand this component the other instance's element.
4260
+ this.$[0].innerHTML = this._rescope_cached_html(this._cached_html);
4130
4261
  // Mark that we used cached HTML (forces re-render after on_load)
4131
4262
  this._used_cached_html = true;
4132
4263
  // Clear cached HTML so next render uses template
4133
4264
  this._cached_html = null;
4134
- // Mark first render complete
4135
- this._did_first_render = true;
4136
4265
  this._log_lifecycle('render', 'complete (cached HTML)');
4137
4266
  // Call on_render() after cache inject (unless suppressed by _load_render_only)
4138
4267
  if (!options.skip_on_render) {
@@ -4155,39 +4284,10 @@ class Jqhtml_Component {
4155
4284
  this._data_on_last_render = JSON.stringify(this.data);
4156
4285
  return current_render_id;
4157
4286
  }
4158
- // Determine child-finding strategy: If component is off-DOM, children can't register
4159
- // via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
4160
- // If attached to DOM, children register normally and we can use the fast _dom_children path.
4161
- // Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
4162
- if (!$.contains(document.documentElement, this.$[0])) {
4163
- this._use_dom_fallback = true;
4164
- }
4165
- else {
4166
- this._use_dom_fallback = false;
4167
- }
4168
- // If this is not the first render, stop child components and clear DOM
4169
- if (this._did_first_render) {
4170
- // Stop all child components before clearing DOM
4171
- this.$.find('.Component').each(function () {
4172
- const child = $(this).data('_component');
4173
- if (child && !child._stopped) {
4174
- child._stop(); // Stop just the component, DOM will be cleared next
4175
- }
4176
- });
4177
- // Clear the DOM
4178
- this.$[0].innerHTML = '';
4179
- }
4180
- else {
4181
- this._did_first_render = true;
4182
- }
4183
- // Remove _Component_Stopped class if present (allows re-render after stop)
4184
- this.$.removeClass('_Component_Stopped');
4185
4287
  // Capture data state before first render for comparison later
4186
4288
  if (this._data_before_render === null) {
4187
4289
  this._data_before_render = JSON.stringify(this.data);
4188
4290
  }
4189
- // Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
4190
- this._dom_children.clear();
4191
4291
  // Get template and render it
4192
4292
  let template_def;
4193
4293
  // If we have a component name, try looking up by name first (for unregistered components)
@@ -4199,24 +4299,8 @@ class Jqhtml_Component {
4199
4299
  template_def = get_template_by_class(this.constructor);
4200
4300
  }
4201
4301
  if (template_def && template_def.render) {
4202
- // Create jqhtml utilities object
4203
- const jqhtml = {
4204
- escape_html: (str) => {
4205
- const div = document.createElement('div');
4206
- div.textContent = String(str);
4207
- return div.innerHTML;
4208
- },
4209
- escape_html_nl2br: (str) => {
4210
- const div = document.createElement('div');
4211
- div.textContent = String(str);
4212
- // Escape HTML first, then replace newlines with <br />
4213
- return div.innerHTML.replace(/\n/g, '<br />');
4214
- },
4215
- // Objects reaching <%= %> / <%!= %> / <%br= %> - see value-printers.ts
4216
- print_object,
4217
- // <{expression}> name validation - see component-name.ts
4218
- dynamic_component_name
4219
- };
4302
+ // Utilities the compiled render function calls (see render_utilities)
4303
+ const jqhtml = render_utilities;
4220
4304
  // Execute template function
4221
4305
  // Commented out debug logging
4222
4306
  // console.log('[Component] About to call render, this:', this);
@@ -4255,69 +4339,48 @@ class Jqhtml_Component {
4255
4339
  // Check for template inheritance (slot-only OR explicit extends)
4256
4340
  // If instructions is {_slots: {...}}, find parent template and invoke it
4257
4341
  if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
4258
- const componentName = template_def.name || this.args._component_name || this.constructor.name;
4259
- console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
4260
- let parentTemplate = null;
4261
- let parentTemplateName = null;
4262
- // First check for explicit extends attribute in template metadata
4263
- if (template_def.extends) {
4264
- console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
4265
- parentTemplate = get_template(template_def.extends);
4266
- parentTemplateName = template_def.extends;
4267
- }
4268
- // If no explicit extends, walk the prototype chain to find parent class with template
4269
- if (!parentTemplate) {
4270
- let currentClass = Object.getPrototypeOf(this.constructor);
4271
- while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
4272
- const className = currentClass.name;
4273
- console.log(`[JQHTML] Checking parent: ${className}`);
4274
- try {
4275
- const classTemplate = get_template(className);
4276
- if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
4277
- console.log(`[JQHTML] Found parent template: ${className}`);
4278
- parentTemplate = classTemplate;
4279
- parentTemplateName = className;
4280
- break;
4281
- }
4282
- }
4283
- catch (error) {
4284
- console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
4285
- }
4286
- currentClass = Object.getPrototypeOf(currentClass);
4287
- }
4342
+ const component_name = template_def.name || this.args._component_name || this.constructor.name;
4343
+ const parent = resolve_parent_template(template_def, this.constructor);
4344
+ // A slot-only template is nothing but an override of a parent. With no parent
4345
+ // there is nothing to render, and rendering empty hides the mistake (usually a
4346
+ // missing register_template) behind a blank element. Fail loudly instead.
4347
+ if (!parent) {
4348
+ throw new Error(`[JQHTML] ${component_name}: slot-only template has no parent template. ` +
4349
+ `Add extends="Parent_Name" to <Define:${component_name}>, extend a class whose ` +
4350
+ `template is registered, or give the template markup of its own.`);
4288
4351
  }
4289
- // If we found a parent template, invoke it with child's slots
4290
- if (parentTemplate) {
4291
- try {
4292
- // Create a content function that invokes child slots
4293
- // When parent calls content('slotName'), it invokes the child's slot function
4294
- const childSlots = instructions._slots;
4295
- const contentFunction = (slotName, ...slotArgs) => {
4296
- if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4297
- // Invoke the slot function with everything content() was given
4298
- const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4299
- // Return in render function format: [instructions, context]
4300
- // The template expression handler expects this format
4301
- return [slotInstructions, slotContext];
4302
- }
4303
- // Slot not found, return empty
4304
- return '';
4305
- };
4306
- // Invoke parent template with child's slots as content function
4307
- const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4308
- jqhtml);
4309
- console.log(`[JQHTML] Parent template invoked successfully`);
4310
- instructions = parentInstructions;
4311
- context = parentContext;
4312
- }
4313
- catch (error) {
4314
- console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
4315
- instructions = [];
4352
+ // Create a content function that invokes child slots
4353
+ // When parent calls content('slotName'), it invokes the child's slot function
4354
+ const childSlots = instructions._slots;
4355
+ const contentFunction = (slotName, ...slotArgs) => {
4356
+ if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
4357
+ // Invoke the slot function with everything content() was given
4358
+ const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
4359
+ // Return in render function format: [instructions, context]
4360
+ // The template expression handler expects this format
4361
+ return [slotInstructions, slotContext];
4316
4362
  }
4363
+ // Slot not found, return empty
4364
+ return '';
4365
+ };
4366
+ // Invoke parent template with child's slots as content function.
4367
+ // A parent template that throws is a real defect in the parent's markup. It used
4368
+ // to be swallowed into an empty render plus a warning nobody reads; it is now
4369
+ // re-thrown, which routes it through the boot error path: logged, this component
4370
+ // stopped, its waiting ancestors released.
4371
+ try {
4372
+ const [parentInstructions, parentContext] = parent.template.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
4373
+ jqhtml);
4374
+ instructions = parentInstructions;
4375
+ context = parentContext;
4317
4376
  }
4318
- else {
4319
- console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
4320
- instructions = [];
4377
+ catch (error) {
4378
+ // Rethrown, not swallowed - only re-labelled so the report names both ends
4379
+ // of the inheritance, which the raw template error does not.
4380
+ const wrapped = new Error(`[JQHTML] ${component_name}: parent template ${parent.name} threw while ` +
4381
+ `rendering slot-only child: ${error?.message ?? error}`);
4382
+ wrapped.cause = error; // ES2020 target: Error(message, {cause}) is not typed
4383
+ throw wrapped;
4321
4384
  }
4322
4385
  }
4323
4386
  // Process instructions to generate DOM. ['_content', ...] markers from
@@ -4338,10 +4401,33 @@ class Jqhtml_Component {
4338
4401
  }
4339
4402
  this._fire_viewport_resize();
4340
4403
  }
4404
+ // "Already rendered once" - read before the state below can move to 3.
4405
+ const isRerender = this._ready_state >= 3;
4406
+ // HTML CACHE MODE: Mark on_render complete after the post-on_load render.
4407
+ // This signals to parent components that this component's DOM is fully rendered
4408
+ // with fresh data and ready for HTML snapshot. It is set BEFORE trigger('render')
4409
+ // because _wait_for_children_on_render() waits on that very event and reads the
4410
+ // flag from its handler.
4411
+ if (this._ready_state >= 2) {
4412
+ this._on_render_complete = true;
4413
+ // State 3 = "rendered with loaded data, ready phase not finished yet".
4414
+ // Only meaningful once the load phase has happened (>= 2); before that the
4415
+ // component is still walking 0 -> 1 -> 2 and must not be pushed forward.
4416
+ // The ready phase (_ready(), render()'s executor, _reload()) restores 4.
4417
+ this._ready_state = 3;
4418
+ this._update_debug_attrs();
4419
+ }
4420
+ else if (!this.__has_custom_on_load) {
4421
+ // A component with no custom on_load() skips the load phase entirely, so it never
4422
+ // reaches _ready_state 2 and would never set the flag above - and its data cannot
4423
+ // change, so this first render IS its final, fresh render. Without this, an
4424
+ // html-cache parent waited forever in _wait_for_children_on_render() for a
4425
+ // template-only child and never snapshotted, rendered or fired 'ready'.
4426
+ this._on_render_complete = true;
4427
+ }
4341
4428
  // Emit lifecycle event
4342
4429
  this.trigger('render');
4343
4430
  // Apply debug delay after render
4344
- const isRerender = this._ready_state >= 3; // Already rendered once
4345
4431
  applyDebugDelay(isRerender ? 'rerender' : 'render');
4346
4432
  // Store args snapshot for reload() comparison (skip if non-serializable)
4347
4433
  try {
@@ -4353,12 +4439,6 @@ class Jqhtml_Component {
4353
4439
  }
4354
4440
  // Store data snapshot for refresh() comparison
4355
4441
  this._data_on_last_render = JSON.stringify(this.data);
4356
- // HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
4357
- // This signals to parent components that this component's DOM is fully rendered
4358
- // with fresh data and ready for HTML snapshot
4359
- if (this._ready_state >= 2) {
4360
- this._on_render_complete = true;
4361
- }
4362
4442
  // Return the render ID so callers can check if this render is still current
4363
4443
  return current_render_id;
4364
4444
  }
@@ -4378,10 +4458,12 @@ class Jqhtml_Component {
4378
4458
  render(id = null) {
4379
4459
  if (this._stopped)
4380
4460
  return Promise.resolve();
4381
- // Invalidate ready event so new handlers wait for this render cycle to complete
4382
- // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4383
- this.invalidate('ready');
4384
- // If id provided, delegate to child component
4461
+ // If id provided, delegate to child component.
4462
+ // NOTE: this runs BEFORE invalidate('ready'). render(sid) does not start a
4463
+ // lifecycle on THIS component - it delegates to the redrawable child, which
4464
+ // invalidates and re-triggers its own 'ready'. Invalidating here would delete
4465
+ // this component's sticky ready marker with nothing left to re-trigger it, so
4466
+ // every later `await comp.ready()` would hang.
4385
4467
  if (id) {
4386
4468
  const $element = this.$sid(id);
4387
4469
  if ($element.length === 0) {
@@ -4396,14 +4478,23 @@ class Jqhtml_Component {
4396
4478
  }
4397
4479
  return child.render();
4398
4480
  }
4481
+ // Invalidate ready event so new handlers wait for this render cycle to complete
4482
+ // This prevents .on('ready') handlers from firing immediately based on previous lifecycle
4483
+ this.invalidate('ready');
4399
4484
  // Enqueue the full render lifecycle through the component queue
4400
4485
  // Return the queue promise so `await this.render()` resolves when the
4401
4486
  // full render lifecycle (DOM update, children ready, on_ready) completes
4402
4487
  return this._queue.enqueue('render', async () => {
4488
+ // The queued executor can start long after render() was called - re-check.
4489
+ if (this._stopped)
4490
+ return;
4403
4491
  // Execute render phase synchronously and capture render ID
4404
4492
  const render_id = this._render();
4405
4493
  // Wait for all child components to be ready
4406
4494
  await this._wait_for_children_ready();
4495
+ // Stopped while waiting for children: no hook, no event on a dead component.
4496
+ if (this._stopped)
4497
+ return;
4407
4498
  // Check if this render is still current before calling on_ready
4408
4499
  // If _render_count changed, another render happened and we should skip on_ready
4409
4500
  if (this._render_count !== render_id) {
@@ -4411,7 +4502,15 @@ class Jqhtml_Component {
4411
4502
  }
4412
4503
  // Call on_ready hook with authorization
4413
4504
  await this._call_lifecycle('on_ready');
4505
+ // on_ready() may itself have awaited long enough for a stop().
4506
+ if (this._stopped)
4507
+ return;
4414
4508
  this._fire_viewport_resize();
4509
+ // Back to the ready state: _render() dropped it to 3. Set BEFORE the trigger
4510
+ // so a handler that calls ready() during dispatch takes the already-ready
4511
+ // fast path instead of waiting for another trigger that will never come.
4512
+ this._ready_state = 4;
4513
+ this._update_debug_attrs();
4415
4514
  // Trigger ready event
4416
4515
  this.trigger('ready');
4417
4516
  });
@@ -4441,40 +4540,54 @@ class Jqhtml_Component {
4441
4540
  async load() {
4442
4541
  if (this._stopped)
4443
4542
  return false;
4444
- // Capture result through queue closure
4445
- let data_changed = false;
4446
- await this._queue.enqueue('load', async () => {
4543
+ // No custom on_load() means there is nothing to run and nothing can have changed.
4544
+ // Running the detached execution anyway seeded this.data with the empty detached
4545
+ // clone (create() takes no snapshot for these components), silently wiping whatever
4546
+ // on_create() had set.
4547
+ if (!this.__has_custom_on_load)
4548
+ return false;
4549
+ // The queue settles this caller with the value of the executor that actually
4550
+ // ran - including one that collapsed two rapid load() calls into one run - so
4551
+ // the result is returned from the executor, not read back out of a closure
4552
+ // variable that a discarded executor would have left at its initial value.
4553
+ return this._queue.enqueue('load', async () => {
4554
+ // The queued executor can start long after load() was called - re-check.
4555
+ if (this._stopped)
4556
+ return false;
4447
4557
  // Snapshot current data for change detection
4448
4558
  const data_before = JSON.stringify(this.data);
4449
4559
  // Execute on_load() on detached proxy (restores data to on_create snapshot)
4450
- const { data: result_data } = await this._execute_on_load_detached(false);
4451
- // Atomically update this.data: unfreeze → assign → normalize → refreeze
4560
+ const { data: result_data } = await this._execute_on_load_detached(null);
4561
+ // Stopped during on_load(): don't write data or cache for a dead component.
4562
+ if (this._stopped)
4563
+ return false;
4564
+ // Atomically update this.data: unfreeze → normalize → assign → refreeze.
4565
+ // The round trip runs in EVERY cache mode - see _apply_load_result().
4452
4566
  this.__data_frozen = false;
4453
- this.data = result_data;
4454
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4455
- if (cache_mode === 'data') {
4456
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4457
- this.data = normalized;
4458
- }
4567
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4459
4568
  this.__data_frozen = true;
4569
+ const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4460
4570
  // Detect if data changed
4461
4571
  const data_after = JSON.stringify(this.data);
4462
- data_changed = data_before !== data_after;
4572
+ const data_changed = data_before !== data_after;
4463
4573
  // Update cache with fresh data if changed
4464
4574
  if (data_changed) {
4465
- // Regenerate cache key (args may have changed since boot)
4466
- let cache_key = null;
4467
- if (typeof this.cache_id === 'function') {
4468
- try {
4469
- cache_key = `${this.component_name()}::${String(this.cache_id())}`;
4575
+ // ONE derivation, shared with the cache read in create() and with _load().
4576
+ // The copy that used to live here omitted content serialization, so an
4577
+ // object-arg component computed a null key and silently never wrote the
4578
+ // cache that create() would later read with the content-serialized key.
4579
+ // Recomputed rather than reused: args may have changed since boot.
4580
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4581
+ if (cache_key === null) {
4582
+ // Leave the same record _load() leaves: the offending arg on the element,
4583
+ // and one dev warning. A silent catch here made a throwing cache_id()
4584
+ // undiagnosable - the component simply stopped being cached.
4585
+ if (uncacheable_property) {
4586
+ this.$.attr('data-nocache', uncacheable_reason ? `${uncacheable_property}:${uncacheable_reason}` : uncacheable_property);
4470
4587
  }
4471
- catch { /* cache_id() threw - skip caching */ }
4588
+ warn_uncacheable_component(this, uncacheable_property, uncacheable_reason);
4472
4589
  }
4473
- else {
4474
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
4475
- cache_key = result.key;
4476
- }
4477
- if (cache_key && cache_mode !== 'html') {
4590
+ else if (cache_mode !== 'html') {
4478
4591
  Jqhtml_Local_Storage.set(cache_key, this.data);
4479
4592
  }
4480
4593
  }
@@ -4482,10 +4595,13 @@ class Jqhtml_Component {
4482
4595
  // Suppressed by _load_only and _load_render_only flags (preloading mode)
4483
4596
  if (!this._load_only && !this._load_render_only) {
4484
4597
  await this._call_lifecycle('on_loaded');
4598
+ // on_loaded() may have awaited across a stop(); don't fire 'loaded' after it.
4599
+ if (this._stopped)
4600
+ return data_changed;
4485
4601
  this.trigger('loaded');
4486
4602
  }
4603
+ return data_changed;
4487
4604
  });
4488
- return data_changed;
4489
4605
  }
4490
4606
  /**
4491
4607
  * Create phase - Quick setup, prepare UI
@@ -4508,15 +4624,18 @@ class Jqhtml_Component {
4508
4624
  // OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
4509
4625
  // Components without on_load() don't fetch data, so nothing to cache or restore
4510
4626
  if (this.__has_custom_on_load) {
4627
+ // Snapshot this.data the moment on_create() returns, BEFORE any cache read.
4628
+ // on_load() restarts from this snapshot every time, and CLAUDE.md promises that
4629
+ // is the on_create() state. read_cache_in_create() REPLACES this.data on a warm
4630
+ // 'data' cache, so snapshotting after it made a warm boot restart on_load() from
4631
+ // cached data - `this.data.items.push(x)` doubled its rows on every reload.
4632
+ this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4511
4633
  // CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
4512
4634
  // Skip cache for detached elements — no point hydrating from cache when not in DOM
4513
4635
  // @see component-cache.ts for full implementation
4514
4636
  if (!this._is_detached) {
4515
4637
  read_cache_in_create(this);
4516
4638
  }
4517
- // Snapshot this.data after on_create() completes
4518
- // This will be restored before each on_load() execution to reset state
4519
- this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
4520
4639
  }
4521
4640
  // Freeze this.data after on_create() - only on_load() can modify it now
4522
4641
  this.__data_frozen = true;
@@ -4647,26 +4766,7 @@ class Jqhtml_Component {
4647
4766
  // follower never runs on_load() and adopts the leader's data with NO
4648
4767
  // revalidation, so a wrong key here is permanently wrong data. Redundant
4649
4768
  // concurrent requests are the cheaper failure.
4650
- let cache_key = null;
4651
- let uncacheable_property;
4652
- let uncacheable_reason;
4653
- if (typeof this.cache_id === 'function') {
4654
- try {
4655
- const custom_cache_id = this.cache_id();
4656
- cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
4657
- }
4658
- catch (error) {
4659
- // cache_id() threw error - disable caching
4660
- uncacheable_property = 'cache_id()';
4661
- uncacheable_reason = 'cache-id-threw';
4662
- }
4663
- }
4664
- else {
4665
- const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
4666
- cache_key = result.key;
4667
- uncacheable_property = result.uncacheable_property;
4668
- uncacheable_reason = result.uncacheable_reason;
4669
- }
4769
+ const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
4670
4770
  const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
4671
4771
  // SSR preload check: if preloaded data exists for this component+args, use it
4672
4772
  if (cache_key !== null && has_preload_data()) {
@@ -4697,24 +4797,26 @@ class Jqhtml_Component {
4697
4797
  if (window.jqhtml?.debug?.verbose) {
4698
4798
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
4699
4799
  }
4700
- const { data: result_data } = await this._execute_on_load_detached();
4800
+ const { data: result_data } = await this._execute_on_load_detached(null);
4701
4801
  await this._apply_load_result(result_data, data_before_load);
4702
4802
  return;
4703
4803
  }
4704
4804
  // Check if this component should execute on_load() or wait for existing request
4705
- const should_execute = Load_Coordinator.should_execute_on_load(this);
4805
+ const should_execute = Load_Coordinator.should_execute_on_load(this, dedup_key);
4706
4806
  if (!should_execute) {
4707
4807
  // This component is a follower - wait for leader to complete
4708
4808
  if (window.jqhtml?.debug?.verbose) {
4709
4809
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
4710
4810
  }
4711
- const coordination_promise = Load_Coordinator.get_coordination_promise(this);
4811
+ // Every coordinator call uses the key captured ABOVE, before on_load() began.
4812
+ // Recomputing it here would miss the entry whenever args changed meanwhile.
4813
+ const coordination_promise = Load_Coordinator.get_coordination_promise(dedup_key);
4712
4814
  if (coordination_promise) {
4713
4815
  try {
4714
- // Wait for leader to complete
4816
+ // Wait for leader to complete (rejects if the leader's on_load() threw)
4715
4817
  await coordination_promise;
4716
4818
  // Retrieve leader's data from coordinator
4717
- const leader_data = Load_Coordinator.get_leader_data(this);
4819
+ const leader_data = Load_Coordinator.get_leader_data(this, dedup_key);
4718
4820
  if (leader_data !== null) {
4719
4821
  // Apply leader's data using the same method as leader
4720
4822
  // This properly handles freeze/unfreeze and normalization
@@ -4732,15 +4834,11 @@ class Jqhtml_Component {
4732
4834
  throw error;
4733
4835
  }
4734
4836
  }
4735
- // Fallback: if we couldn't get leader data, just mark as complete
4736
- this._ready_state = 2;
4737
- this._update_debug_attrs();
4738
- this._log_lifecycle('load', 'complete (follower)');
4739
- this.trigger('load');
4740
- if (!this._load_only && !this._load_render_only) {
4741
- await this._call_lifecycle('on_loaded');
4742
- this.trigger('loaded');
4743
- }
4837
+ // No leader data to adopt (the entry was already collected, or its data could
4838
+ // not be serialized). Load independently rather than firing load/loaded with the
4839
+ // untouched on_create() data.
4840
+ const { data: result_data } = await this._execute_on_load_detached(null);
4841
+ await this._apply_load_result(result_data, data_before_load);
4744
4842
  return;
4745
4843
  }
4746
4844
  // This component is a leader - execute on_load() on detached proxy
@@ -4748,7 +4846,7 @@ class Jqhtml_Component {
4748
4846
  console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
4749
4847
  }
4750
4848
  // Execute on_load on detached proxy with Load_Coordinator registration
4751
- const { data: result_data, complete_coordination } = await this._execute_on_load_detached(true);
4849
+ const { data: result_data, complete_coordination } = await this._execute_on_load_detached(dedup_key);
4752
4850
  // Apply result via sequential queue
4753
4851
  await this._apply_load_result(result_data, data_before_load);
4754
4852
  // Complete coordination AFTER this.data is updated
@@ -4762,8 +4860,8 @@ class Jqhtml_Component {
4762
4860
  * @see data-proxy.ts for full implementation
4763
4861
  * @private
4764
4862
  */
4765
- async _execute_on_load_detached(use_load_coordinator = false) {
4766
- return execute_on_load_detached(this, use_load_coordinator);
4863
+ async _execute_on_load_detached(dedup_key = null) {
4864
+ return execute_on_load_detached(this, dedup_key);
4767
4865
  }
4768
4866
  /**
4769
4867
  * Apply the result of on_load() to this.data via the sequential queue.
@@ -4786,21 +4884,22 @@ class Jqhtml_Component {
4786
4884
  await my_turn;
4787
4885
  try {
4788
4886
  // Now it's our turn to set this.data
4789
- // Unfreeze, set data, freeze
4887
+ // Every on_load() result is round-tripped through the cache serializer before it
4888
+ // becomes this.data - in EVERY cache mode, not just 'data'. Two consequences the
4889
+ // rest of the framework depends on:
4890
+ // - this.data is jqhtml's own copy. The author's object graph is never aliased,
4891
+ // so mutating what on_load() returned cannot reach a frozen this.data.
4892
+ // - this.data is BY CONSTRUCTION what the cache stores and what a cache hit
4893
+ // returns. Hot and cold are the same object shape in every mode, so turning
4894
+ // caching on can never change what a component sees.
4895
+ // Dates, Maps, Sets and register_cache_class() classes are reconstructed; anything
4896
+ // the serializer cannot express is dropped and warned about once (local-storage.ts).
4790
4897
  this.__data_frozen = false;
4791
- this.data = result_data;
4792
- // DATA MODE: Normalize this.data through serialize/deserialize round-trip
4793
- // This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
4794
- const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
4795
- if (cache_mode === 'data') {
4796
- const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
4797
- this.data = normalized;
4798
- if (window.jqhtml?.debug?.verbose) {
4799
- console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4800
- }
4801
- }
4802
- // Freeze this.data
4898
+ this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
4803
4899
  this.__data_frozen = true;
4900
+ if (window.jqhtml?.debug?.verbose) {
4901
+ console.log(`[Cache] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
4902
+ }
4804
4903
  // SSR data capture: record this component's data for preloading
4805
4904
  if (is_capture_enabled() && this._has_on_load()) {
4806
4905
  capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
@@ -4886,9 +4985,11 @@ class Jqhtml_Component {
4886
4985
  callback();
4887
4986
  return Promise.resolve();
4888
4987
  }
4889
- // Return promise that resolves when ready event fires
4988
+ // Return promise that resolves when ready event fires.
4989
+ // once(), not on(): a promise can only settle once, so a permanent listener
4990
+ // just accumulates on the component - one per ready() call, forever.
4890
4991
  return new Promise((resolve) => {
4891
- this.on('ready', () => {
4992
+ this.once('ready', () => {
4892
4993
  if (callback)
4893
4994
  callback();
4894
4995
  resolve();
@@ -4946,9 +5047,18 @@ class Jqhtml_Component {
4946
5047
  if (child._ready_state >= 4) {
4947
5048
  continue;
4948
5049
  }
4949
- // Create promise that resolves when child reaches ready
5050
+ // A stopped child will never fire 'ready' again - waiting on it would wedge
5051
+ // this parent (and everything awaiting its ready()) forever.
5052
+ if (child._stopped) {
5053
+ continue;
5054
+ }
5055
+ // Create promise that resolves when child reaches ready.
5056
+ // once(), not on(): a permanent listener would be appended to the child on
5057
+ // every render/reload cycle of a polling parent and never removed.
5058
+ // Raced against 'stop' so a child stopped mid-wait releases the parent.
4950
5059
  const ready_promise = new Promise((resolve) => {
4951
- child.on('ready', () => resolve());
5060
+ child.once('ready', () => resolve());
5061
+ child.once('stop', () => resolve());
4952
5062
  });
4953
5063
  ready_promises.push(ready_promise);
4954
5064
  }
@@ -4974,22 +5084,42 @@ class Jqhtml_Component {
4974
5084
  // Create promises for each child that hasn't completed on_render yet
4975
5085
  const render_promises = [];
4976
5086
  for (const child of children) {
4977
- // If child already completed on_render post-on_load, skip
4978
- if (child._on_render_complete) {
5087
+ // If child already completed on_render post-on_load (or is dead), skip
5088
+ if (child._on_render_complete || child._stopped) {
4979
5089
  continue;
4980
5090
  }
4981
- // Create promise that resolves when child completes on_render
5091
+ // Event-driven, not polled: the child sets _on_render_complete just before it
5092
+ // fires 'render', and 'stop' ends the wait for a child that dies first.
5093
+ //
5094
+ // A plain once('render') would be wrong: events are sticky, and this child has
5095
+ // already rendered at least once (the pre-on_load render), so once() would fire
5096
+ // immediately off that stale marker and let the parent snapshot half-rendered DOM.
5097
+ // The handler therefore re-checks the flag and stays registered until it is set.
4982
5098
  const render_promise = new Promise((resolve) => {
4983
- // Poll for completion (simple approach - could use events for more efficiency)
4984
- const check = () => {
4985
- if (child._on_render_complete || child._stopped) {
4986
- resolve();
4987
- }
4988
- else {
4989
- setTimeout(check, 10);
5099
+ let settled = false;
5100
+ const on_child_event = () => {
5101
+ if (settled)
5102
+ return;
5103
+ if (!child._on_render_complete && !child._stopped)
5104
+ return; // pre-load render
5105
+ settled = true;
5106
+ // There is no off() in the event API; splice this handler out of both queues
5107
+ // the way once() does, so a long-lived child does not accumulate handlers
5108
+ // across repeated waits.
5109
+ for (const event_name of ['render', 'stop']) {
5110
+ const callbacks = child._lifecycle_callbacks.get(event_name);
5111
+ if (callbacks) {
5112
+ const idx = callbacks.indexOf(on_child_event);
5113
+ if (idx !== -1)
5114
+ callbacks.splice(idx, 1);
5115
+ }
4990
5116
  }
5117
+ resolve();
4991
5118
  };
4992
- check();
5119
+ // 'stop' first: registering 'render' can fire the handler synchronously (sticky),
5120
+ // and the handler must be able to deregister both queues when it does.
5121
+ child.on('stop', on_child_event);
5122
+ child.on('render', on_child_event);
4993
5123
  });
4994
5124
  render_promises.push(render_promise);
4995
5125
  }
@@ -5093,8 +5223,17 @@ class Jqhtml_Component {
5093
5223
  this.next_reload_force_refresh = null;
5094
5224
  this._render();
5095
5225
  await this._wait_for_children_ready();
5226
+ // Stopped while waiting for children: no hook, no event on a dead component.
5227
+ if (this._stopped)
5228
+ return;
5096
5229
  await this._call_lifecycle('on_ready');
5230
+ // on_ready() may itself have awaited long enough for a stop().
5231
+ if (this._stopped)
5232
+ return;
5097
5233
  this._fire_viewport_resize();
5234
+ // Restore the ready state (_render() dropped it to 3) before the trigger
5235
+ this._ready_state = 4;
5236
+ this._update_debug_attrs();
5098
5237
  this.trigger('ready');
5099
5238
  this._log_lifecycle('reload', 'complete (no on_load)');
5100
5239
  return;
@@ -5122,10 +5261,16 @@ class Jqhtml_Component {
5122
5261
  // STEP 2: Call on_load() on detached proxy
5123
5262
  // This uses the same detached execution as _load() - on_load runs isolated
5124
5263
  // and result is applied via sequential queue
5125
- const { data: result_data } = await this._execute_on_load_detached(false); // false = don't use Load_Coordinator
5264
+ const { data: result_data } = await this._execute_on_load_detached(null); // null = no coordination
5265
+ // Stopped during on_load(): don't write data or re-render a dead component.
5266
+ if (this._stopped)
5267
+ return;
5126
5268
  // Apply result via sequential queue
5127
5269
  // This handles normalization, caching, and setting _is_dynamic
5128
5270
  await this._apply_load_result(result_data, data_before_load);
5271
+ // The apply queue can yield; a stop() there must abort the render/ready tail.
5272
+ if (this._stopped)
5273
+ return;
5129
5274
  // Re-read data state after apply (for render decision below)
5130
5275
  const data_after_load = JSON.stringify(this.data);
5131
5276
  const data_changed = data_after_load !== data_before_load;
@@ -5134,6 +5279,15 @@ class Jqhtml_Component {
5134
5279
  const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
5135
5280
  // Track if we need to render
5136
5281
  let should_render = false;
5282
+ // HTML CACHE MODE: a cache injection above replaced the subtree with inert markup -
5283
+ // a snapshot string, with no live child components in it. It must be re-rendered
5284
+ // from the template regardless of what the load decided, exactly as _should_rerender()
5285
+ // forces it at boot. Without this, a reload whose data came back unchanged left dead
5286
+ // HTML (stopped children, no instances) on screen.
5287
+ const used_cached_html = this._used_cached_html;
5288
+ if (used_cached_html) {
5289
+ this._used_cached_html = false;
5290
+ }
5137
5291
  if (force_refresh) {
5138
5292
  // reload(true) or reload() - always render if we haven't yet, OR data changed
5139
5293
  should_render = !rendered_from_cache || data_changed;
@@ -5154,6 +5308,11 @@ class Jqhtml_Component {
5154
5308
  }
5155
5309
  }
5156
5310
  // STEP 3: Perform second render if needed
5311
+ // The cache injection's inert markup is not optional to replace - this overrides both
5312
+ // the reload() and the refresh()/reload(false) decision above.
5313
+ if (used_cached_html) {
5314
+ should_render = true;
5315
+ }
5157
5316
  if (should_render) {
5158
5317
  this._render();
5159
5318
  }
@@ -5168,11 +5327,33 @@ class Jqhtml_Component {
5168
5327
  // STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
5169
5328
  if (rendered_from_cache || should_render) {
5170
5329
  await this._wait_for_children_ready();
5330
+ // Stopped while waiting for children: no hook, no event on a dead component.
5331
+ if (this._stopped)
5332
+ return;
5171
5333
  await this._call_lifecycle('on_ready');
5334
+ // on_ready() may itself have awaited long enough for a stop().
5335
+ if (this._stopped)
5336
+ return;
5172
5337
  this._fire_viewport_resize();
5338
+ // Restore the ready state (the load phase dropped it to 2, _render() to 3)
5339
+ // before the trigger, so handlers see a ready component.
5340
+ this._ready_state = 4;
5341
+ this._update_debug_attrs();
5173
5342
  // Trigger ready event so parent callbacks fire on subsequent reloads
5174
5343
  this.trigger('ready');
5175
5344
  }
5345
+ else {
5346
+ // refresh()/reload(false) found unchanged data: no re-render, and on_ready()
5347
+ // is deliberately NOT called (documented behaviour). But the component IS
5348
+ // ready - and this cycle deleted the sticky 'ready' marker at the top of
5349
+ // _reload() and dropped _ready_state to 2 in the load phase. Without
5350
+ // restoring both, ready() never resolves again and a parent re-entering
5351
+ // _wait_for_children_ready() hangs on this child forever (the documented
5352
+ // polling pattern breaks after its first quiet refresh).
5353
+ this._ready_state = 4;
5354
+ this._update_debug_attrs();
5355
+ this.trigger('ready');
5356
+ }
5176
5357
  this._log_lifecycle('reload', 'complete');
5177
5358
  }
5178
5359
  /**
@@ -5197,26 +5378,28 @@ class Jqhtml_Component {
5197
5378
  this.__gate_resume = null;
5198
5379
  resume();
5199
5380
  }
5200
- // Early bailout: skip expensive cleanup if no handlers registered
5201
- // Only matters for aborting boot() lifecycle - minimal cleanup sufficient
5202
- const has_custom_stop = this.on_stop !== Jqhtml_Component.prototype.on_stop;
5203
- const has_stop_callbacks = this._on_registered('stop');
5204
- if (!has_custom_stop && !has_stop_callbacks) {
5205
- // Fast path: no cleanup logic defined, just mark as stopped
5206
- this._lifecycle_manager.unregister_component(this);
5207
- this._ready_state = 99;
5208
- return;
5209
- }
5210
- // Full cleanup path: component has custom stop logic
5381
+ // There is deliberately NO fast path for components without stop logic. The
5382
+ // marker class, the parent de-registration and the 'stop' event are not cleanup
5383
+ // the component asked for, they are how the rest of the framework learns this
5384
+ // component is dead: the class is the documented, observable stopped state; a
5385
+ // child left in _dom_parent._dom_children is a dead component every later
5386
+ // _wait_for_children_ready() and _get_dom_children() walks; and 'stop' is what
5387
+ // releases a parent (and boot's failure path) waiting on a child that will never
5388
+ // fire 'ready'. Skipping them for the common no-on_stop() child was what leaked
5389
+ // stopped children into the parent's registry.
5211
5390
  this._log_lifecycle('destroy', 'start');
5212
5391
  this.$.addClass('_Component_Stopped');
5213
5392
  // Unregister from lifecycle manager
5214
5393
  this._lifecycle_manager.unregister_component(this);
5215
- // Call user's on_stop() hook with authorization (sync)
5216
- const stopResult = this._call_lifecycle_sync('on_stop');
5217
- if (stopResult && typeof stopResult.then === 'function') {
5218
- console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5219
- `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5394
+ // Call user's on_stop() hook with authorization (sync) - only when actually
5395
+ // overridden, since _call_lifecycle_sync() on the base no-op costs per child
5396
+ // on every re-render.
5397
+ if (this.on_stop !== Jqhtml_Component.prototype.on_stop) {
5398
+ const stopResult = this._call_lifecycle_sync('on_stop');
5399
+ if (stopResult && typeof stopResult.then === 'function') {
5400
+ console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_stop(). ` +
5401
+ `on_stop() must be synchronous code. Remove 'async' from the function declaration.`);
5402
+ }
5220
5403
  }
5221
5404
  // Fire registered stop callbacks
5222
5405
  this.trigger('stop');
@@ -5492,6 +5675,48 @@ class Jqhtml_Component {
5492
5675
  _generate_cid() {
5493
5676
  return uid();
5494
5677
  }
5678
+ /**
5679
+ * Re-scope every scoped id in an HTML-cache snapshot so it belongs to THIS instance.
5680
+ *
5681
+ * A snapshot is the inner HTML of some component instance, so it contains ids of the
5682
+ * form `<name>:<cid>`: the snapshotting component's own elements carry its cid, and
5683
+ * elements rendered by nested children carry each child's cid. Replayed verbatim into
5684
+ * a second instance those ids are duplicates of the originals, and $sid() resolves via
5685
+ * getElementById(), which returns whichever matching element is first in the document -
5686
+ * so one instance could read and write another instance's DOM.
5687
+ *
5688
+ * Mapping: the snapshot owner's cid becomes this._cid (so $sid() finds the elements
5689
+ * this component is about to treat as its own), and every other cid becomes a fresh
5690
+ * uid(), consistently within one injection. Two instances hydrated from one snapshot
5691
+ * therefore share no id at all.
5692
+ *
5693
+ * The ':' separator is written only by the compiler ($sid) and the instruction
5694
+ * processor (a scoped plain id), and cids are uid() output - [a-z][a-z0-9]* - so the
5695
+ * pattern is exact. A plain unscoped id has no ':' and is left alone.
5696
+ *
5697
+ * data-cid is the debug mirror of a component's own _cid (config.ts debug_attributes).
5698
+ * The snapshot's values are stale cids belonging to components that no longer exist,
5699
+ * so they go through the same mapping rather than being left to lie in DevTools.
5700
+ *
5701
+ * @private
5702
+ */
5703
+ _rescope_cached_html(snapshot) {
5704
+ const cid_map = new Map();
5705
+ cid_map.set(snapshot.cid, this._cid);
5706
+ const remap = (old_cid) => {
5707
+ let new_cid = cid_map.get(old_cid);
5708
+ if (new_cid === undefined) {
5709
+ new_cid = uid();
5710
+ cid_map.set(old_cid, new_cid);
5711
+ }
5712
+ return new_cid;
5713
+ };
5714
+ return snapshot.html
5715
+ // id="<name>:<cid>" - the name half may itself contain ':' (an author-written
5716
+ // id="a:b" is scoped as "a:b:cid"), so the cid is anchored at the end.
5717
+ .replace(/ id="([^"]*):([a-z][a-z0-9]*)"/g, (_match, name, old_cid) => ` id="${name}:${remap(old_cid)}"`)
5718
+ .replace(/ data-cid="([a-z][a-z0-9]*)"/g, (_match, old_cid) => ` data-cid="${remap(old_cid)}"`);
5719
+ }
5495
5720
  _apply_css_classes() {
5496
5721
  const hierarchy = this.constructor.get_class_hierarchy();
5497
5722
  // If component name differs from class name, add component name to hierarchy
@@ -5531,26 +5756,29 @@ class Jqhtml_Component {
5531
5756
  }
5532
5757
  if (!template)
5533
5758
  return;
5534
- // Walk the extends chain to collect all defaultAttributes from parent templates
5535
- // Apply from parent to child so child attributes take precedence
5759
+ // Walk the inheritance chain to collect defaultAttributes from parent templates,
5760
+ // applying parent first so the child's own attributes take precedence.
5761
+ //
5762
+ // This uses resolve_parent_template(), the same helper the slot-only render path
5763
+ // uses, so a <Define>'s class/attrs resolve through EXACTLY the chain its markup
5764
+ // does. It previously followed only `extends=`, so a slot-only JS subclass
5765
+ // (class Users_Grid extends Grid_Abstract) inherited the parent's template but
5766
+ // none of the parent <Define>'s class="" or attributes.
5536
5767
  const templateChain = [];
5537
5768
  let currentTemplate = template;
5538
- // Build chain from child to parent
5539
- while (currentTemplate) {
5769
+ let currentClass = this.constructor;
5770
+ const seen = new Set();
5771
+ while (currentTemplate && !seen.has(currentTemplate)) {
5772
+ seen.add(currentTemplate);
5540
5773
  templateChain.unshift(currentTemplate); // Add to front so parent comes first
5541
- // Check if this template extends another
5542
- if (currentTemplate.extends) {
5543
- try {
5544
- currentTemplate = get_template(currentTemplate.extends);
5545
- }
5546
- catch (error) {
5547
- // Parent template not found, stop chain
5548
- break;
5549
- }
5550
- }
5551
- else {
5774
+ const parent = resolve_parent_template(currentTemplate, currentClass);
5775
+ if (!parent)
5552
5776
  break;
5553
- }
5777
+ currentTemplate = parent.template;
5778
+ // Advance the prototype walk too: the next parent must be resolved relative to
5779
+ // the class we just landed on, not the original one. A template-only parent has
5780
+ // no class, which is correct - its own chain can only be an explicit extends=.
5781
+ currentClass = get_component_class(parent.name) || null;
5554
5782
  }
5555
5783
  // Apply defaultAttributes from each template in the chain (parent first, child last)
5556
5784
  for (const tmpl of templateChain) {
@@ -5627,8 +5855,10 @@ class Jqhtml_Component {
5627
5855
  }
5628
5856
  }
5629
5857
  else {
5630
- // Regular attributes - apply directly if not already set
5631
- if (!this.$.attr(key)) {
5858
+ // Regular attributes - apply directly if not already set.
5859
+ // === undefined, not falsy: tabindex="0" and an empty-string attribute
5860
+ // written at the invocation are SET, and a Define default must not win.
5861
+ if (this.$.attr(key) === undefined) {
5632
5862
  this.$.attr(key, value);
5633
5863
  }
5634
5864
  }
@@ -6289,13 +6519,12 @@ function init_jquery_plugin(jQuery) {
6289
6519
  const _jqhtml_original_jquery = jQuery;
6290
6520
  // Override jQuery constructor to handle component instances
6291
6521
  const JQueryWithComponentSupport = function (selector, context) {
6292
- // Check if selector is a JQHTML component instance
6293
- if (selector &&
6294
- typeof selector === 'object' &&
6295
- selector.$ &&
6296
- typeof selector.$sid === 'function' &&
6297
- typeof selector.id === 'function') {
6298
- // Return the component's jQuery element
6522
+ // Unwrap a component instance to its root element, so $(component) behaves
6523
+ // exactly like component.$ (addClass, find, [0] and every other jQuery method).
6524
+ // The check is an instanceof on the class itself: the previous duck-type tested
6525
+ // for an id() method, which Jqhtml_Component has never had, so this branch was
6526
+ // dead and $(component) wrapped the plain object instead.
6527
+ if (selector instanceof Jqhtml_Component) {
6299
6528
  return selector.$;
6300
6529
  }
6301
6530
  // Otherwise, call original jQuery constructor
@@ -6371,13 +6600,12 @@ function init_jquery_plugin(jQuery) {
6371
6600
  // Check if component already exists on this element
6372
6601
  const existingComponent = element.data('_component');
6373
6602
  if (existingComponent) {
6374
- // Stop existing component (with error handling to continue on failure)
6375
- try {
6376
- existingComponent.stop();
6377
- }
6378
- catch (error) {
6379
- console.warn('[JQHTML] Error stopping existing component during replacement:', error);
6380
- }
6603
+ // Stop the existing component. A throwing stop() is NOT swallowed: the caller
6604
+ // asked to replace a live component, and overwriting one whose on_stop() failed
6605
+ // would leave its timers, intervals and listeners running against DOM that is
6606
+ // about to be destroyed. Let the throw reach the caller with the old component
6607
+ // still in place, so the failure is visible and recoverable.
6608
+ existingComponent.stop();
6381
6609
  // Remove component classes (any class starting with a capital letter, or a
6382
6610
  // component name with the reserved underscore prefix - except BEM classes)
6383
6611
  const classes = element.attr('class');
@@ -6393,7 +6621,15 @@ function init_jquery_plugin(jQuery) {
6393
6621
  const capital_first = cls[0] === cls[0].toUpperCase() && cls[0] !== cls[0].toLowerCase();
6394
6622
  return !capital_first && !is_component_name(cls);
6395
6623
  });
6396
- element.attr('class', nonComponentClasses.join(' '));
6624
+ // Drop the attribute entirely when nothing survived the strip: attr('class', '')
6625
+ // leaves a `class=""` in the markup, which is noise in the DOM and in snapshot
6626
+ // comparisons for an element that now has no classes at all.
6627
+ if (nonComponentClasses.length > 0) {
6628
+ element.attr('class', nonComponentClasses.join(' '));
6629
+ }
6630
+ else {
6631
+ element.removeAttr('class');
6632
+ }
6397
6633
  }
6398
6634
  // Remove component data
6399
6635
  element.removeData('_component');
@@ -6434,18 +6670,35 @@ function init_jquery_plugin(jQuery) {
6434
6670
  const expectedTag = args._tag || template.tag || 'div';
6435
6671
  const currentTag = element.prop('tagName').toLowerCase();
6436
6672
  if (currentTag !== expectedTag.toLowerCase()) {
6437
- // Tag mismatch
6438
- if (args._inner_html) {
6439
- // Replace element with correct tag (only when using server-rendered content)
6673
+ // Tag mismatch. Replace whenever we can actually do it: the element must be in
6674
+ // a document (replaceWith() on a parentless element is a silent no-op that would
6675
+ // strand the component on a detached node) and must not be <body>.
6676
+ //
6677
+ // This used to require a truthy args._inner_html, i.e. server-rendered content.
6678
+ // boot.ts always sets _inner_html - but it empties the placeholder first, so a
6679
+ // placeholder with no server content carries '' and fell through to a warning,
6680
+ // hydrating into the wrong tag. Hydration of an empty placeholder still works:
6681
+ // the copied innerHTML is simply ''.
6682
+ const oldEl = element[0];
6683
+ const can_replace = currentTag !== 'body' && !!(oldEl && oldEl.parentNode);
6684
+ if (can_replace) {
6440
6685
  const newElement = jQuery(`<${expectedTag}></${expectedTag}>`);
6441
6686
  // Copy all attributes from old element to new element
6442
- const oldEl = element[0];
6443
6687
  if (oldEl && oldEl.attributes) {
6444
6688
  for (let i = 0; i < oldEl.attributes.length; i++) {
6445
6689
  const attr = oldEl.attributes[i];
6446
6690
  newElement.attr(attr.name, attr.value);
6447
6691
  }
6448
6692
  }
6693
+ // Carry the jQuery data store across. $-args and anything the caller stashed
6694
+ // with .data() live there, and they are read back off the element during
6695
+ // component construction - dropping them silently changed the component's args.
6696
+ const old_data = element.data();
6697
+ if (old_data) {
6698
+ for (const key of Object.keys(old_data)) {
6699
+ newElement.data(key, old_data[key]);
6700
+ }
6701
+ }
6449
6702
  // Copy innerHTML
6450
6703
  newElement.html(element.html());
6451
6704
  // Replace in DOM
@@ -6760,12 +7013,14 @@ function init(jQuery, config) {
6760
7013
  }
6761
7014
  }
6762
7015
  // Version - will be replaced during build with actual version from package.json
6763
- const version = '2.3.67';
7016
+ const version = '2.3.68';
6764
7017
  // Default export with all functionality
6765
7018
  const jqhtml = {
6766
7019
  // Core
6767
7020
  Jqhtml_Component,
6768
7021
  LifecycleManager,
7022
+ // Internal - exposed for debugging and for tests that inspect coordination state
7023
+ Load_Coordinator,
6769
7024
  // Registry
6770
7025
  register,
6771
7026
  register_component,
@@ -6781,7 +7036,6 @@ const jqhtml = {
6781
7036
  // Template system
6782
7037
  process_instructions,
6783
7038
  extract_slots,
6784
- render_template,
6785
7039
  escape_html,
6786
7040
  escape_html_nl2br,
6787
7041
  // Version property - internal
@@ -6949,7 +7203,6 @@ exports.register = register;
6949
7203
  exports.register_cache_class = register_cache_class;
6950
7204
  exports.register_component = register_component;
6951
7205
  exports.register_template = register_template;
6952
- exports.render_template = render_template;
6953
7206
  exports.set_preload_data = set_preload_data;
6954
7207
  exports.start_data_capture = start_data_capture;
6955
7208
  exports.stop_data_capture = stop_data_capture;