@jqhtml/core 2.3.64 → 2.3.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/component-cache.d.ts +18 -0
- package/dist/component-cache.d.ts.map +1 -1
- package/dist/component-events.d.ts +11 -6
- package/dist/component-events.d.ts.map +1 -1
- package/dist/component-name.d.ts +7 -0
- package/dist/component-name.d.ts.map +1 -1
- package/dist/component-queue.d.ts +23 -12
- package/dist/component-queue.d.ts.map +1 -1
- package/dist/component.d.ts +48 -0
- package/dist/component.d.ts.map +1 -1
- package/dist/data-proxy.d.ts +5 -2
- package/dist/data-proxy.d.ts.map +1 -1
- package/dist/debug-overlay/index.d.ts +35 -0
- package/dist/debug-overlay/index.d.ts.map +1 -0
- package/dist/escape.d.ts +13 -0
- package/dist/escape.d.ts.map +1 -0
- package/dist/index.cjs +1853 -1022
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +18 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1849 -1022
- package/dist/index.js.map +1 -1
- package/dist/instruction-processor.d.ts.map +1 -1
- package/dist/jqhtml-core.esm.js +1850 -1023
- package/dist/jqhtml-core.esm.js.map +1 -1
- package/dist/jquery-plugin.d.ts.map +1 -1
- package/dist/lifecycle-manager.d.ts +0 -4
- package/dist/lifecycle-manager.d.ts.map +1 -1
- package/dist/load-coordinator.d.ts +18 -9
- package/dist/load-coordinator.d.ts.map +1 -1
- package/dist/local-storage.d.ts +41 -8
- package/dist/local-storage.d.ts.map +1 -1
- package/dist/preload-data.d.ts +8 -0
- package/dist/preload-data.d.ts.map +1 -1
- package/dist/value-printers.d.ts +47 -0
- package/dist/value-printers.d.ts.map +1 -0
- package/package.json +2 -1
- package/dist/template-renderer.d.ts +0 -22
- package/dist/template-renderer.d.ts.map +0 -1
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
|
-
//
|
|
54
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
/**
|
|
@@ -217,6 +417,23 @@ const COMPONENT_NAME_RULE = 'must start with a capital letter, optionally preced
|
|
|
217
417
|
function is_component_name(name) {
|
|
218
418
|
return COMPONENT_NAME_PATTERN.test(name);
|
|
219
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* The render-time check behind <{expression}>: the expression must evaluate
|
|
422
|
+
* to a component name string that a literal tag would accept - no more, no
|
|
423
|
+
* less. A valid name that names nothing renders the ordinary placeholder
|
|
424
|
+
* component, exactly as an undefined literal tag does.
|
|
425
|
+
*/
|
|
426
|
+
function dynamic_component_name(value) {
|
|
427
|
+
if (typeof value !== 'string' || value === '') {
|
|
428
|
+
const shown = value === '' ? 'an empty string' : typeof value === 'string' ? `'${value}'` : `${value === null ? 'null' : typeof value}`;
|
|
429
|
+
throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to ${shown}; expected a non-empty component name string`);
|
|
430
|
+
}
|
|
431
|
+
if (!is_component_name(value)) {
|
|
432
|
+
throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to '${value}', which is not a valid component name: ` +
|
|
433
|
+
`a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
|
|
434
|
+
}
|
|
435
|
+
return value;
|
|
436
|
+
}
|
|
220
437
|
|
|
221
438
|
/**
|
|
222
439
|
* JQHTML v2 Component Registry
|
|
@@ -646,30 +863,32 @@ function process_instructions(instructions, target, context, slots) {
|
|
|
646
863
|
// Set innerHTML once - this is the performance win
|
|
647
864
|
// Use native innerHTML for better performance
|
|
648
865
|
target[0].innerHTML = html.join('');
|
|
649
|
-
// Second pass: initialize special attributes and events
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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) {
|
|
656
877
|
// Bind to the component whose template wrote this element, which for
|
|
657
878
|
// spliced content is not the component being rendered.
|
|
658
|
-
apply_attributes(
|
|
879
|
+
apply_attributes($(el), tagData.attrs, tagData.context);
|
|
659
880
|
}
|
|
660
881
|
}
|
|
661
882
|
// Third pass: initialize and boot components in parallel
|
|
662
883
|
// Like v1, all sibling components at this level boot simultaneously
|
|
663
884
|
// DO NOT await - let children boot in background while parent continues
|
|
664
|
-
for (const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
const element = $(el);
|
|
669
|
-
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) {
|
|
670
889
|
// Boot this component (which will render and boot its children recursively)
|
|
671
890
|
// Fire and forget - don't wait for boot to complete
|
|
672
|
-
initialize_component(
|
|
891
|
+
initialize_component($(el), compData);
|
|
673
892
|
}
|
|
674
893
|
}
|
|
675
894
|
}
|
|
@@ -734,21 +953,13 @@ function process_tag_to_html(instruction, html, tagElements, components, context
|
|
|
734
953
|
if (!key.startsWith('$') && !key.startsWith('on') && !key.startsWith('@') &&
|
|
735
954
|
!key.startsWith('data-bind-') && !key.startsWith('data-__-on-') &&
|
|
736
955
|
(typeof value === 'string' || typeof value === 'number')) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
}
|
|
745
|
-
else {
|
|
746
|
-
html.push(` id="${value}:${context._cid}"`);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
html.push(` ${key}="${value}"`);
|
|
751
|
-
}
|
|
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)}"`);
|
|
752
963
|
}
|
|
753
964
|
}
|
|
754
965
|
// Close opening tag
|
|
@@ -759,6 +970,16 @@ function process_tag_to_html(instruction, html, tagElements, components, context
|
|
|
759
970
|
html.push('>');
|
|
760
971
|
}
|
|
761
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, '"');
|
|
982
|
+
}
|
|
762
983
|
/**
|
|
763
984
|
* Process a component instruction to HTML
|
|
764
985
|
*/
|
|
@@ -1115,13 +1336,10 @@ async function initialize_component(element, compData) {
|
|
|
1115
1336
|
if (slots) {
|
|
1116
1337
|
options._slots = slots;
|
|
1117
1338
|
}
|
|
1118
|
-
// Always pass
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
if (ComponentClass.name !== name) {
|
|
1123
|
-
options._component_name = name;
|
|
1124
|
-
}
|
|
1339
|
+
// Always pass the invocation name: it is what component_name() reports, it
|
|
1340
|
+
// selects the template when the class is inherited via an extends chain or
|
|
1341
|
+
// is the bare Jqhtml_Component, and it is the class the element carries.
|
|
1342
|
+
options._component_name = name;
|
|
1125
1343
|
// Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
|
|
1126
1344
|
// Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
|
|
1127
1345
|
if (props._load_only === true || props['$_load_only'] === true) {
|
|
@@ -1137,210 +1355,19 @@ async function initialize_component(element, compData) {
|
|
|
1137
1355
|
// Boot the component immediately - this runs its full lifecycle
|
|
1138
1356
|
await instance._boot();
|
|
1139
1357
|
}
|
|
1140
|
-
/**
|
|
1141
|
-
* Utility to extract slots from instructions
|
|
1142
|
-
*/
|
|
1143
|
-
function extract_slots(instructions) {
|
|
1144
|
-
const slots = {};
|
|
1145
|
-
for (const instruction of instructions) {
|
|
1146
|
-
if (typeof instruction === 'object' && 'slot' in instruction) {
|
|
1147
|
-
const [name] = instruction.slot;
|
|
1148
|
-
slots[name] = instruction;
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
return slots;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* JQHTML Debug Module
|
|
1156
|
-
*
|
|
1157
|
-
* Provides comprehensive debugging capabilities for JQHTML components
|
|
1158
|
-
*/
|
|
1159
|
-
// Global debug state
|
|
1160
|
-
let performanceMetrics = new Map();
|
|
1161
|
-
/**
|
|
1162
|
-
* Development warning helper
|
|
1163
|
-
* Warnings are suppressed in production builds or when JQHTML_SUPPRESS_WARNINGS is set
|
|
1164
|
-
*/
|
|
1165
|
-
function devWarn(message) {
|
|
1166
|
-
// Check if warnings are suppressed
|
|
1167
|
-
if (typeof window !== 'undefined' && window.JQHTML_SUPPRESS_WARNINGS) {
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
// Check if in production mode
|
|
1171
|
-
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') {
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
console.warn(`[JQHTML Dev Warning] ${message}`);
|
|
1175
|
-
}
|
|
1176
|
-
// Get global jqhtml object
|
|
1177
|
-
function getJqhtml() {
|
|
1178
|
-
if (typeof window !== 'undefined' && window.jqhtml) {
|
|
1179
|
-
return window.jqhtml;
|
|
1180
|
-
}
|
|
1181
|
-
// Fallback: try to get from global if available
|
|
1182
|
-
if (typeof globalThis !== 'undefined' && globalThis.jqhtml) {
|
|
1183
|
-
return globalThis.jqhtml;
|
|
1184
|
-
}
|
|
1185
|
-
throw new Error('FATAL: window.jqhtml is not defined. The JQHTML runtime must be loaded before using debug features. ' +
|
|
1186
|
-
'Import and initialize @jqhtml/core before attempting to use debug functionality.');
|
|
1187
|
-
}
|
|
1188
|
-
// Visual flash effect
|
|
1189
|
-
function flashComponent(component, eventType) {
|
|
1190
|
-
const jqhtml = getJqhtml();
|
|
1191
|
-
if (!jqhtml?.debug?.flashComponents)
|
|
1192
|
-
return;
|
|
1193
|
-
const duration = jqhtml.debug.flashDuration || 500;
|
|
1194
|
-
const colors = jqhtml.debug.flashColors || {};
|
|
1195
|
-
const color = colors[eventType] || (eventType === 'create' ? '#3498db' :
|
|
1196
|
-
eventType === 'render' ? '#27ae60' :
|
|
1197
|
-
'#9b59b6');
|
|
1198
|
-
// Store original border
|
|
1199
|
-
const originalBorder = component.$.css('border');
|
|
1200
|
-
// Apply flash border
|
|
1201
|
-
component.$.css({
|
|
1202
|
-
'border': `2px solid ${color}`,
|
|
1203
|
-
'transition': `border ${duration}ms ease-out`
|
|
1204
|
-
});
|
|
1205
|
-
// Remove after duration
|
|
1206
|
-
setTimeout(() => {
|
|
1207
|
-
component.$.css('border', originalBorder || '');
|
|
1208
|
-
}, duration);
|
|
1209
|
-
}
|
|
1210
|
-
// Log lifecycle event
|
|
1211
|
-
function logLifecycle(component, phase, status) {
|
|
1212
|
-
const jqhtml = getJqhtml();
|
|
1213
|
-
if (!jqhtml?.debug)
|
|
1214
|
-
return;
|
|
1215
|
-
const shouldLog = jqhtml.debug.logFullLifecycle ||
|
|
1216
|
-
(jqhtml.debug.logCreationReady && (phase === 'create' || phase === 'ready'));
|
|
1217
|
-
if (!shouldLog)
|
|
1218
|
-
return;
|
|
1219
|
-
const componentName = component.constructor.name;
|
|
1220
|
-
const timestamp = new Date().toISOString();
|
|
1221
|
-
const prefix = `[JQHTML ${timestamp}]`;
|
|
1222
|
-
if (status === 'start') {
|
|
1223
|
-
console.log(`${prefix} ${componentName}#${component._cid} → ${phase} starting...`);
|
|
1224
|
-
// Start performance tracking
|
|
1225
|
-
if (jqhtml.debug.profilePerformance) {
|
|
1226
|
-
performanceMetrics.set(`${component._cid}_${phase}`, Date.now());
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
else {
|
|
1230
|
-
let message = `${prefix} ${componentName}#${component._cid} ✓ ${phase} complete`;
|
|
1231
|
-
// Add performance data
|
|
1232
|
-
if (jqhtml.debug.profilePerformance) {
|
|
1233
|
-
const startTime = performanceMetrics.get(`${component._cid}_${phase}`);
|
|
1234
|
-
if (startTime) {
|
|
1235
|
-
const duration = Date.now() - startTime;
|
|
1236
|
-
message += ` (${duration}ms)`;
|
|
1237
|
-
// Highlight slow renders
|
|
1238
|
-
if (phase === 'render' && jqhtml.debug.highlightSlowRenders &&
|
|
1239
|
-
duration > jqhtml.debug.highlightSlowRenders) {
|
|
1240
|
-
console.warn(`${prefix} SLOW RENDER: ${componentName}#${component._cid} took ${duration}ms`);
|
|
1241
|
-
component.$.css('outline', '2px dashed red');
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
console.log(message);
|
|
1246
|
-
// Visual feedback
|
|
1247
|
-
if (jqhtml.debug.flashComponents && (phase === 'create' || phase === 'render' || phase === 'ready')) {
|
|
1248
|
-
flashComponent(component, phase);
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
// Update component tree if enabled
|
|
1252
|
-
if (jqhtml.debug.showComponentTree) {
|
|
1253
|
-
updateComponentTree();
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
// Apply delays based on lifecycle phase
|
|
1257
|
-
function applyDebugDelay(phase) {
|
|
1258
|
-
const jqhtml = getJqhtml();
|
|
1259
|
-
if (!jqhtml?.debug)
|
|
1260
|
-
return;
|
|
1261
|
-
let delayMs = 0;
|
|
1262
|
-
switch (phase) {
|
|
1263
|
-
case 'component':
|
|
1264
|
-
delayMs = jqhtml.debug.delayAfterComponent || 0;
|
|
1265
|
-
break;
|
|
1266
|
-
case 'render':
|
|
1267
|
-
delayMs = jqhtml.debug.delayAfterRender || 0;
|
|
1268
|
-
break;
|
|
1269
|
-
case 'rerender':
|
|
1270
|
-
delayMs = jqhtml.debug.delayAfterRerender || 0;
|
|
1271
|
-
break;
|
|
1272
|
-
}
|
|
1273
|
-
if (delayMs > 0) {
|
|
1274
|
-
console.log(`[JQHTML Debug] Applying ${delayMs}ms delay after ${phase}`);
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
// Log instruction processing
|
|
1278
|
-
function logInstruction(type, data) {
|
|
1279
|
-
const jqhtml = getJqhtml();
|
|
1280
|
-
if (!jqhtml?.debug?.logInstructionProcessing)
|
|
1281
|
-
return;
|
|
1282
|
-
console.log(`[JQHTML Instruction] ${type}:`, data);
|
|
1283
|
-
}
|
|
1284
|
-
// Log data changes
|
|
1285
|
-
function logDataChange(component, property, oldValue, newValue) {
|
|
1286
|
-
const jqhtml = getJqhtml();
|
|
1287
|
-
if (!jqhtml?.debug?.traceDataFlow)
|
|
1288
|
-
return;
|
|
1289
|
-
console.log(`[JQHTML Data] ${component.constructor.name}#${component._cid}.data.${property}:`, { old: oldValue, new: newValue });
|
|
1290
|
-
}
|
|
1291
|
-
// Update component tree visualization
|
|
1292
|
-
function updateComponentTree() {
|
|
1293
|
-
// This would update a debug panel if implemented
|
|
1294
|
-
// For now, just log the tree structure periodically
|
|
1295
|
-
console.log('[JQHTML Tree] Component hierarchy updated');
|
|
1296
|
-
}
|
|
1297
|
-
// Router dispatch logging
|
|
1298
|
-
function logDispatch(url, route, params, verbose = false) {
|
|
1299
|
-
const jqhtml = getJqhtml();
|
|
1300
|
-
if (!jqhtml?.debug)
|
|
1301
|
-
return;
|
|
1302
|
-
const shouldLog = jqhtml.debug.logDispatch || jqhtml.debug.logDispatchVerbose;
|
|
1303
|
-
if (!shouldLog)
|
|
1304
|
-
return;
|
|
1305
|
-
const isVerbose = jqhtml.debug.logDispatchVerbose || verbose;
|
|
1306
|
-
if (isVerbose) {
|
|
1307
|
-
console.group(`[JQHTML Router] Dispatching: ${url}`);
|
|
1308
|
-
console.log('Matched route:', route);
|
|
1309
|
-
console.log('Extracted params:', params);
|
|
1310
|
-
console.log('Route component:', route.component);
|
|
1311
|
-
console.log('Route layout:', route.layout);
|
|
1312
|
-
console.log('Route meta:', route.meta);
|
|
1313
|
-
console.groupEnd();
|
|
1314
|
-
}
|
|
1315
|
-
else {
|
|
1316
|
-
console.log(`[JQHTML Router] ${url} → ${route.component} (params: ${JSON.stringify(params)})`);
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
// Check if sequential processing is enabled
|
|
1320
|
-
function isSequentialProcessing() {
|
|
1321
|
-
const jqhtml = getJqhtml();
|
|
1322
|
-
return jqhtml?.debug?.sequentialProcessing || false;
|
|
1323
|
-
}
|
|
1324
|
-
// Error handling with break on error
|
|
1325
|
-
function handleComponentError(component, phase, error) {
|
|
1326
|
-
const jqhtml = getJqhtml();
|
|
1327
|
-
console.error(`[JQHTML Error] ${component.constructor.name}#${component._cid} failed in ${phase}:`, error);
|
|
1328
|
-
if (jqhtml?.debug?.breakOnError) {
|
|
1329
|
-
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
|
+
}
|
|
1330
1368
|
}
|
|
1369
|
+
return slots;
|
|
1331
1370
|
}
|
|
1332
|
-
// Additional debug suggestions that could be implemented:
|
|
1333
|
-
//
|
|
1334
|
-
// 1. Component Inspector - Click on any component to see its data/args/state
|
|
1335
|
-
// 2. Time Travel Debugging - Record state changes and replay them
|
|
1336
|
-
// 3. Network Request Tracking - Log all AJAX calls made during load()
|
|
1337
|
-
// 4. Memory Leak Detection - Track component creation/destruction
|
|
1338
|
-
// 5. Template Compilation Debugging - Show compiled template functions
|
|
1339
|
-
// 6. Event Flow Visualization - Show event bubbling through components
|
|
1340
|
-
// 7. Dependency Graph - Show which components depend on which data
|
|
1341
|
-
// 8. Hot Reload Support - Reload components without losing state
|
|
1342
|
-
// 9. Performance Budgets - Warn when components exceed size/time limits
|
|
1343
|
-
// 10. Accessibility Auditing - Check for missing ARIA attributes
|
|
1344
1371
|
|
|
1345
1372
|
/**
|
|
1346
1373
|
* JQHTML Cache Key Serializer
|
|
@@ -1609,15 +1636,12 @@ class Load_Coordinator {
|
|
|
1609
1636
|
/**
|
|
1610
1637
|
* Check if a component should execute on_load() or wait for existing request
|
|
1611
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.
|
|
1612
1643
|
*/
|
|
1613
|
-
static should_execute_on_load(component) {
|
|
1614
|
-
const { key } = this.generate_invocation_key(component.component_name(), component.args);
|
|
1615
|
-
// No identity means no coordination. Without this, every un-keyable component
|
|
1616
|
-
// would share the single `null` registry entry and adopt an unrelated
|
|
1617
|
-
// component's data - the worst failure this system can produce.
|
|
1618
|
-
if (key === null) {
|
|
1619
|
-
return true;
|
|
1620
|
-
}
|
|
1644
|
+
static should_execute_on_load(component, key) {
|
|
1621
1645
|
const entry = this._registry.get(key);
|
|
1622
1646
|
if (!entry) {
|
|
1623
1647
|
// No existing request - this component becomes the leader
|
|
@@ -1636,26 +1660,27 @@ class Load_Coordinator {
|
|
|
1636
1660
|
* Register a leader component that will execute on_load()
|
|
1637
1661
|
* Creates coordination entry and returns a function to call when data is ready
|
|
1638
1662
|
*
|
|
1663
|
+
* @param key - The dedup key captured by the caller (see should_execute_on_load)
|
|
1639
1664
|
* @returns A function that accepts the final data and completes coordination
|
|
1640
1665
|
*/
|
|
1641
|
-
static register_leader(component,
|
|
1642
|
-
|
|
1643
|
-
// Un-keyable component: run alone, register nothing. See should_execute_on_load().
|
|
1644
|
-
if (key === null) {
|
|
1645
|
-
return () => { };
|
|
1646
|
-
}
|
|
1647
|
-
// 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
|
|
1648
1668
|
let resolve_promise;
|
|
1649
|
-
|
|
1669
|
+
let reject_promise;
|
|
1670
|
+
const coordination_promise = new Promise((resolve, reject) => {
|
|
1650
1671
|
resolve_promise = resolve;
|
|
1672
|
+
reject_promise = reject;
|
|
1651
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(() => { });
|
|
1652
1677
|
const entry = {
|
|
1653
1678
|
status: 'loading',
|
|
1654
|
-
promise: coordination_promise, // Followers await THIS promise, not
|
|
1679
|
+
promise: coordination_promise, // Followers await THIS promise, not on_load's
|
|
1655
1680
|
resolve_promise,
|
|
1681
|
+
reject_promise,
|
|
1656
1682
|
leader_component: component,
|
|
1657
1683
|
leader_data: null,
|
|
1658
|
-
leader_error: null,
|
|
1659
1684
|
waiting: []
|
|
1660
1685
|
};
|
|
1661
1686
|
this._registry.set(key, entry);
|
|
@@ -1664,10 +1689,9 @@ class Load_Coordinator {
|
|
|
1664
1689
|
}
|
|
1665
1690
|
/**
|
|
1666
1691
|
* Get the coordination promise for a follower component
|
|
1667
|
-
* Returns a promise that resolves when the leader completes
|
|
1692
|
+
* Returns a promise that resolves when the leader completes, or rejects if it failed
|
|
1668
1693
|
*/
|
|
1669
|
-
static get_coordination_promise(
|
|
1670
|
-
const { key } = this.generate_invocation_key(component.component_name(), component.args);
|
|
1694
|
+
static get_coordination_promise(key) {
|
|
1671
1695
|
const entry = this._registry.get(key);
|
|
1672
1696
|
if (!entry || entry.status !== 'loading') {
|
|
1673
1697
|
return null;
|
|
@@ -1679,7 +1703,7 @@ class Load_Coordinator {
|
|
|
1679
1703
|
* Stores leader's data, marks entry as completed, and resolves the promise.
|
|
1680
1704
|
* Followers retrieve the data themselves via get_leader_data().
|
|
1681
1705
|
*
|
|
1682
|
-
* @param key - The coordination key
|
|
1706
|
+
* @param key - The coordination key captured at registration
|
|
1683
1707
|
* @param leader - The leader component
|
|
1684
1708
|
* @param final_data - The final data after on_load and normalization
|
|
1685
1709
|
* @private
|
|
@@ -1689,14 +1713,14 @@ class Load_Coordinator {
|
|
|
1689
1713
|
if (!entry) {
|
|
1690
1714
|
return;
|
|
1691
1715
|
}
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
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.
|
|
1694
1718
|
try {
|
|
1695
|
-
entry.leader_data = JSON.
|
|
1719
|
+
entry.leader_data = JSON.stringify(final_data);
|
|
1696
1720
|
}
|
|
1697
1721
|
catch (error) {
|
|
1698
|
-
//
|
|
1699
|
-
entry.leader_data =
|
|
1722
|
+
// Unserializable data cannot be shared; followers load independently.
|
|
1723
|
+
entry.leader_data = null;
|
|
1700
1724
|
}
|
|
1701
1725
|
entry.status = 'completed';
|
|
1702
1726
|
if (window.jqhtml?.debug?.verbose) {
|
|
@@ -1704,22 +1728,22 @@ class Load_Coordinator {
|
|
|
1704
1728
|
}
|
|
1705
1729
|
// Resolve the promise - followers waiting on it will now wake up
|
|
1706
1730
|
entry.resolve_promise();
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
|
|
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
|
+
}
|
|
1710
1736
|
}
|
|
1711
1737
|
/**
|
|
1712
1738
|
* Get leader's data for a follower component
|
|
1713
1739
|
* Called by follower after coordination promise resolves
|
|
1714
|
-
* Returns
|
|
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
|
|
1715
1743
|
*/
|
|
1716
|
-
static get_leader_data(component) {
|
|
1717
|
-
const { key } = this.generate_invocation_key(component.component_name(), component.args);
|
|
1718
|
-
if (!key) {
|
|
1719
|
-
return null;
|
|
1720
|
-
}
|
|
1744
|
+
static get_leader_data(component, key) {
|
|
1721
1745
|
const entry = this._registry.get(key);
|
|
1722
|
-
if (!entry || entry.status !== 'completed') {
|
|
1746
|
+
if (!entry || entry.status !== 'completed' || entry.leader_data === null) {
|
|
1723
1747
|
return null;
|
|
1724
1748
|
}
|
|
1725
1749
|
// Remove this follower from the waiting list
|
|
@@ -1727,6 +1751,8 @@ class Load_Coordinator {
|
|
|
1727
1751
|
if (follower_index !== -1) {
|
|
1728
1752
|
entry.waiting.splice(follower_index, 1);
|
|
1729
1753
|
}
|
|
1754
|
+
// One parse per follower: each gets its OWN object, never a shared reference.
|
|
1755
|
+
const data = JSON.parse(entry.leader_data);
|
|
1730
1756
|
if (window.jqhtml?.debug?.verbose) {
|
|
1731
1757
|
console.log(`[Load Coordinator] Follower ${component._cid} retrieving data from leader ${entry.leader_component._cid}`, { key, remaining_followers: entry.waiting.length });
|
|
1732
1758
|
}
|
|
@@ -1737,29 +1763,25 @@ class Load_Coordinator {
|
|
|
1737
1763
|
console.log(`[Load Coordinator] Coordination complete for key: ${key}`, { registry_size: this._registry.size });
|
|
1738
1764
|
}
|
|
1739
1765
|
}
|
|
1740
|
-
return
|
|
1766
|
+
return data;
|
|
1741
1767
|
}
|
|
1742
1768
|
/**
|
|
1743
1769
|
* Handle leader on_load() error
|
|
1744
|
-
*
|
|
1770
|
+
* Rejects every waiting follower and clears the entry so the next component retries
|
|
1771
|
+
*
|
|
1772
|
+
* @param key - The dedup key captured at registration
|
|
1745
1773
|
*/
|
|
1746
|
-
static handle_leader_error(
|
|
1747
|
-
const { key } = this.generate_invocation_key(component.component_name(), component.args);
|
|
1774
|
+
static handle_leader_error(key, error) {
|
|
1748
1775
|
const entry = this._registry.get(key);
|
|
1749
1776
|
if (!entry) {
|
|
1750
1777
|
return;
|
|
1751
1778
|
}
|
|
1752
|
-
entry.leader_error = error;
|
|
1753
1779
|
entry.status = 'failed';
|
|
1754
|
-
console.error(`[Load Coordinator] Leader ${
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
console.error(`[Load Coordinator] Follower ${follower._cid} failed due to leader error`, error);
|
|
1760
|
-
// The follower's lifecycle will handle the error naturally
|
|
1761
|
-
// as the promise rejection will propagate
|
|
1762
|
-
}
|
|
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);
|
|
1763
1785
|
// Clear coordination entry so future requests can retry
|
|
1764
1786
|
this._registry.delete(key);
|
|
1765
1787
|
if (window.jqhtml?.debug?.verbose) {
|
|
@@ -1858,6 +1880,31 @@ Load_Coordinator._registry = new Map();
|
|
|
1858
1880
|
* - Transient UI state (convenience, not required)
|
|
1859
1881
|
*
|
|
1860
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
|
+
* ---------------------------------------------------------------------------
|
|
1861
1908
|
*/
|
|
1862
1909
|
// ============================================================================
|
|
1863
1910
|
// Class Registry for Serialization
|
|
@@ -1866,7 +1913,7 @@ Load_Coordinator._registry = new Map();
|
|
|
1866
1913
|
const class_registry = Object.create(null);
|
|
1867
1914
|
// Markers for serialized class instances (unique to avoid collisions with user data)
|
|
1868
1915
|
// Replaced at build time by rollup (see rollup.config.js).
|
|
1869
|
-
const CORE_VERSION = '2.3.
|
|
1916
|
+
const CORE_VERSION = '2.3.68';
|
|
1870
1917
|
const CLASS_MARKER = '__jqhtml_class__';
|
|
1871
1918
|
const PROPS_MARKER = '__jqhtml_props__';
|
|
1872
1919
|
/**
|
|
@@ -1882,21 +1929,20 @@ function register_cache_class(klass) {
|
|
|
1882
1929
|
}
|
|
1883
1930
|
class_registry[klass.name] = klass;
|
|
1884
1931
|
}
|
|
1885
|
-
// ============================================================================
|
|
1886
|
-
// Serialization (Object → String)
|
|
1887
|
-
// ============================================================================
|
|
1888
1932
|
/**
|
|
1889
1933
|
* Serialize a value to JSON string, handling ES6 class instances recursively.
|
|
1890
1934
|
* Returns null if serialization fails for any reason.
|
|
1891
1935
|
*
|
|
1892
1936
|
* @param value - The value to serialize
|
|
1893
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
|
|
1894
1940
|
* @returns Serialized JSON string, or null if serialization failed
|
|
1895
1941
|
*/
|
|
1896
|
-
function serialize_value(value, verbose) {
|
|
1942
|
+
function serialize_value(value, verbose, path = '', collector) {
|
|
1897
1943
|
try {
|
|
1898
1944
|
const seen = new WeakSet();
|
|
1899
|
-
const processed = process_for_serialization(value, verbose, seen);
|
|
1945
|
+
const processed = process_for_serialization(value, verbose, seen, path, collector);
|
|
1900
1946
|
if (processed === undefined) {
|
|
1901
1947
|
// Serialization failed - undefined signals failure
|
|
1902
1948
|
return null;
|
|
@@ -1912,14 +1958,23 @@ function serialize_value(value, verbose) {
|
|
|
1912
1958
|
}
|
|
1913
1959
|
/**
|
|
1914
1960
|
* Recursively process a value for serialization.
|
|
1915
|
-
*
|
|
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.
|
|
1916
1969
|
*
|
|
1917
1970
|
* @param value - The value to process
|
|
1918
1971
|
* @param verbose - Whether to log warnings
|
|
1919
1972
|
* @param seen - WeakSet to detect circular references
|
|
1920
|
-
* @
|
|
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
|
|
1921
1976
|
*/
|
|
1922
|
-
function process_for_serialization(value, verbose, seen) {
|
|
1977
|
+
function process_for_serialization(value, verbose, seen, path = '', collector) {
|
|
1923
1978
|
// Handle null and undefined
|
|
1924
1979
|
if (value === null) {
|
|
1925
1980
|
return null;
|
|
@@ -1934,12 +1989,44 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
1934
1989
|
return value;
|
|
1935
1990
|
}
|
|
1936
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
|
+
}
|
|
1937
2001
|
if (verbose) {
|
|
1938
2002
|
console.warn(`[JQHTML Cache] Cannot serialize ${typeof value} - value will be omitted`);
|
|
1939
2003
|
}
|
|
1940
2004
|
// Return undefined to omit this value (not fail entire serialization)
|
|
1941
2005
|
return undefined;
|
|
1942
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
|
+
}
|
|
1943
2030
|
// Handle circular references.
|
|
1944
2031
|
// `seen` tracks only the current ancestor path (the DFS stack): an object is
|
|
1945
2032
|
// added on the way down and removed on the way back up (see finally below).
|
|
@@ -1948,10 +2035,11 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
1948
2035
|
// JSON round-trip semantics do not preserve identity anyway, so emitting a
|
|
1949
2036
|
// copy per reference is correct; silently omitting the property is not.
|
|
1950
2037
|
if (seen.has(value)) {
|
|
2038
|
+
collector?.(path, 'circular');
|
|
1951
2039
|
if (verbose) {
|
|
1952
|
-
console.warn('[JQHTML Cache] Circular reference detected -
|
|
2040
|
+
console.warn('[JQHTML Cache] Circular reference detected - cutting the cycle');
|
|
1953
2041
|
}
|
|
1954
|
-
return undefined; //
|
|
2042
|
+
return undefined; // Cut the cycle here; the rest of the tree still serializes
|
|
1955
2043
|
}
|
|
1956
2044
|
seen.add(value);
|
|
1957
2045
|
try {
|
|
@@ -1960,7 +2048,7 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
1960
2048
|
const result = [];
|
|
1961
2049
|
for (let i = 0; i < value.length; i++) {
|
|
1962
2050
|
const item = value[i];
|
|
1963
|
-
const processed = process_for_serialization(item, verbose, seen);
|
|
2051
|
+
const processed = process_for_serialization(item, verbose, seen, collector ? `${path}[${i}]` : '', collector);
|
|
1964
2052
|
// For arrays, we keep undefined as null to preserve indices
|
|
1965
2053
|
result.push(processed === undefined ? null : processed);
|
|
1966
2054
|
}
|
|
@@ -1977,8 +2065,9 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
1977
2065
|
if (value instanceof Map) {
|
|
1978
2066
|
const entries = [];
|
|
1979
2067
|
for (const [k, v] of value) {
|
|
1980
|
-
const
|
|
1981
|
-
const
|
|
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);
|
|
1982
2071
|
entries.push([processedKey, processedValue]);
|
|
1983
2072
|
}
|
|
1984
2073
|
return {
|
|
@@ -1989,8 +2078,9 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
1989
2078
|
// Handle Set objects
|
|
1990
2079
|
if (value instanceof Set) {
|
|
1991
2080
|
const items = [];
|
|
2081
|
+
let index = 0;
|
|
1992
2082
|
for (const item of value) {
|
|
1993
|
-
items.push(process_for_serialization(item, verbose, seen));
|
|
2083
|
+
items.push(process_for_serialization(item, verbose, seen, collector ? `${path}[Set ${index++}]` : '', collector));
|
|
1994
2084
|
}
|
|
1995
2085
|
return {
|
|
1996
2086
|
[CLASS_MARKER]: 'Set',
|
|
@@ -2001,58 +2091,115 @@ function process_for_serialization(value, verbose, seen) {
|
|
|
2001
2091
|
const ctor = value.constructor;
|
|
2002
2092
|
// Handle registered class instances
|
|
2003
2093
|
if (ctor && ctor.name && class_registry[ctor.name]) {
|
|
2004
|
-
const props = {};
|
|
2005
|
-
// Extract own enumerable properties (bypass any toJSON method)
|
|
2006
|
-
for (const key of Object.keys(value)) {
|
|
2007
|
-
const propValue = value[key];
|
|
2008
|
-
const processed = process_for_serialization(propValue, verbose, seen);
|
|
2009
|
-
// Only include if not undefined (failed serialization)
|
|
2010
|
-
if (processed !== undefined || propValue === undefined) {
|
|
2011
|
-
props[key] = processed;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
2094
|
return {
|
|
2015
2095
|
[CLASS_MARKER]: ctor.name,
|
|
2016
|
-
[PROPS_MARKER]:
|
|
2096
|
+
[PROPS_MARKER]: process_own_properties(value, verbose, seen, path, collector)
|
|
2017
2097
|
};
|
|
2018
2098
|
}
|
|
2019
2099
|
// Handle plain objects (constructor is Object or no constructor)
|
|
2020
2100
|
if (ctor === Object || ctor === undefined || ctor === null) {
|
|
2021
|
-
|
|
2022
|
-
for (const key of Object.keys(value)) {
|
|
2023
|
-
const propValue = value[key];
|
|
2024
|
-
const processed = process_for_serialization(propValue, verbose, seen);
|
|
2025
|
-
// Only include if not undefined (unless original was undefined)
|
|
2026
|
-
if (processed !== undefined || propValue === undefined) {
|
|
2027
|
-
result[key] = processed;
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
return result;
|
|
2101
|
+
return process_own_properties(value, verbose, seen, path, collector);
|
|
2031
2102
|
}
|
|
2032
|
-
// Unregistered class instance - convert to plain object for hot/cold parity
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
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);
|
|
2035
2108
|
if (verbose) {
|
|
2036
2109
|
console.warn(`[JQHTML Cache] Converting unregistered class "${ctor.name}" to plain object. ` +
|
|
2037
2110
|
`Methods will be lost. Call jqhtml.register_cache_class(${ctor.name}) to preserve the class.`);
|
|
2038
2111
|
}
|
|
2039
|
-
|
|
2040
|
-
const result = {};
|
|
2041
|
-
for (const key of Object.keys(value)) {
|
|
2042
|
-
const propValue = value[key];
|
|
2043
|
-
const processed = process_for_serialization(propValue, verbose, seen);
|
|
2044
|
-
// Only include if not undefined (unless original was undefined)
|
|
2045
|
-
if (processed !== undefined || propValue === undefined) {
|
|
2046
|
-
result[key] = processed;
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
return result;
|
|
2112
|
+
return process_own_properties(value, verbose, seen, path, collector);
|
|
2050
2113
|
}
|
|
2051
2114
|
finally {
|
|
2052
2115
|
// Pop this object off the ancestor path now that its subtree is done.
|
|
2053
2116
|
seen.delete(value);
|
|
2054
2117
|
}
|
|
2055
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
|
+
}
|
|
2056
2203
|
// ============================================================================
|
|
2057
2204
|
// Deserialization (String → Object)
|
|
2058
2205
|
// ============================================================================
|
|
@@ -2396,36 +2543,36 @@ class Jqhtml_Local_Storage {
|
|
|
2396
2543
|
/**
|
|
2397
2544
|
* Perform a serialize/deserialize round-trip on a value.
|
|
2398
2545
|
*
|
|
2399
|
-
*
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
2402
|
-
*
|
|
2403
|
-
*
|
|
2404
|
-
* 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.
|
|
2405
2551
|
*
|
|
2406
2552
|
* @param {any} value - The value to normalize
|
|
2407
|
-
* @
|
|
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.
|
|
2408
2556
|
*/
|
|
2409
|
-
static normalize_for_cache(value) {
|
|
2557
|
+
static normalize_for_cache(value, component_name) {
|
|
2410
2558
|
const verbose = this._is_verbose();
|
|
2559
|
+
const collector = make_strip_collector(component_name);
|
|
2411
2560
|
// Serialize to JSON string
|
|
2412
|
-
const serialized = serialize_value(value, verbose);
|
|
2561
|
+
const serialized = serialize_value(value, verbose, 'this.data', collector);
|
|
2413
2562
|
if (serialized === null) {
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
}
|
|
2419
|
-
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 {};
|
|
2420
2567
|
}
|
|
2421
2568
|
// Deserialize back to object
|
|
2422
2569
|
const deserialized = deserialize_value(serialized, verbose);
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
return
|
|
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 {};
|
|
2429
2576
|
}
|
|
2430
2577
|
return deserialized;
|
|
2431
2578
|
}
|
|
@@ -2448,7 +2595,10 @@ class Jqhtml_Local_Storage {
|
|
|
2448
2595
|
console.warn('[JQHTML Local Storage] Quota exceeded, clearing JQHTML keys and retrying');
|
|
2449
2596
|
// Clear only JQHTML keys and retry once
|
|
2450
2597
|
this._clear_jqhtml_keys();
|
|
2451
|
-
|
|
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);
|
|
2452
2602
|
try {
|
|
2453
2603
|
localStorage.setItem(scoped_key, serialized);
|
|
2454
2604
|
}
|
|
@@ -2489,17 +2639,22 @@ Jqhtml_Local_Storage._initialized = false;
|
|
|
2489
2639
|
* Extracted from component.ts - handles lifecycle event registration,
|
|
2490
2640
|
* triggering, and state tracking.
|
|
2491
2641
|
*
|
|
2492
|
-
*
|
|
2493
|
-
*
|
|
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.
|
|
2494
2647
|
*/
|
|
2495
2648
|
/**
|
|
2496
2649
|
* Register a callback for an event.
|
|
2497
2650
|
*
|
|
2498
|
-
*
|
|
2499
|
-
*
|
|
2500
|
-
* 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.
|
|
2501
2654
|
*
|
|
2502
|
-
*
|
|
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').
|
|
2503
2658
|
*
|
|
2504
2659
|
* @param component - The component instance
|
|
2505
2660
|
* @param event_name - Name of the event
|
|
@@ -2631,6 +2786,20 @@ function event_invalidate(component, event_name) {
|
|
|
2631
2786
|
* - Data freeze/unfreeze enforcement via Proxy
|
|
2632
2787
|
* - Detached on_load() execution with restricted access
|
|
2633
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
|
+
}
|
|
2634
2803
|
/**
|
|
2635
2804
|
* Set up the `this.data` property on a component using Object.defineProperty
|
|
2636
2805
|
* with a Proxy that enforces freeze/unfreeze semantics.
|
|
@@ -2644,9 +2813,78 @@ function event_invalidate(component, event_name) {
|
|
|
2644
2813
|
*/
|
|
2645
2814
|
function setup_data_property(component) {
|
|
2646
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
|
+
};
|
|
2647
2874
|
// Helper to create frozen proxy for data object
|
|
2648
2875
|
const create_proxy = (obj) => {
|
|
2649
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
|
+
},
|
|
2650
2888
|
set: (target, prop, value) => {
|
|
2651
2889
|
if (component.__data_frozen) {
|
|
2652
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` +
|
|
@@ -2716,10 +2954,13 @@ function setup_data_property(component) {
|
|
|
2716
2954
|
* All other property access (this.$, this.$sid, etc.) throws errors.
|
|
2717
2955
|
*
|
|
2718
2956
|
* @param component - The component instance
|
|
2719
|
-
* @param
|
|
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.
|
|
2720
2961
|
* @returns The resulting data and optional coordination completion function
|
|
2721
2962
|
*/
|
|
2722
|
-
async function execute_on_load_detached(component,
|
|
2963
|
+
async function execute_on_load_detached(component, dedup_key = null) {
|
|
2723
2964
|
// Clone this.data from the snapshot captured after on_create()
|
|
2724
2965
|
const data_clone = component.__initial_data_snapshot
|
|
2725
2966
|
? JSON.parse(JSON.stringify(component.__initial_data_snapshot))
|
|
@@ -2727,14 +2968,23 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
|
|
|
2727
2968
|
// Create a read-only proxy for args that blocks all modifications
|
|
2728
2969
|
// Can't use JSON clone because args may contain functions (_slots, callbacks)
|
|
2729
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();
|
|
2730
2976
|
const create_readonly_proxy = (obj, path = 'this.args') => {
|
|
2731
2977
|
if (obj === null || typeof obj !== 'object')
|
|
2732
2978
|
return obj;
|
|
2733
|
-
|
|
2979
|
+
const existing = readonly_wrappers.get(obj);
|
|
2980
|
+
if (existing)
|
|
2981
|
+
return existing;
|
|
2982
|
+
const proxy = new Proxy(obj, {
|
|
2734
2983
|
get(target, prop) {
|
|
2735
2984
|
const value = target[prop];
|
|
2736
|
-
// Recursively wrap nested objects
|
|
2737
|
-
|
|
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') {
|
|
2738
2988
|
return create_readonly_proxy(value, `${path}.${String(prop)}`);
|
|
2739
2989
|
}
|
|
2740
2990
|
return value;
|
|
@@ -2756,6 +3006,8 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
|
|
|
2756
3006
|
`this.args is read-only in on_load().`);
|
|
2757
3007
|
}
|
|
2758
3008
|
});
|
|
3009
|
+
readonly_wrappers.set(obj, proxy);
|
|
3010
|
+
return proxy;
|
|
2759
3011
|
};
|
|
2760
3012
|
// Create a detached context object that on_load will operate on
|
|
2761
3013
|
const detached_context = {
|
|
@@ -2815,27 +3067,22 @@ async function execute_on_load_detached(component, use_load_coordinator = false)
|
|
|
2815
3067
|
`Only this.data can be modified in on_load().`);
|
|
2816
3068
|
}
|
|
2817
3069
|
});
|
|
2818
|
-
//
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
await component._call_lifecycle('on_load', restricted_this);
|
|
2822
|
-
}
|
|
2823
|
-
catch (error) {
|
|
2824
|
-
if (use_load_coordinator) {
|
|
2825
|
-
// Handle error and notify coordinator
|
|
2826
|
-
Load_Coordinator.handle_leader_error(component, error);
|
|
2827
|
-
}
|
|
2828
|
-
throw error;
|
|
2829
|
-
}
|
|
2830
|
-
})();
|
|
2831
|
-
// If using Load_Coordinator, register as leader
|
|
2832
|
-
// Note: We store the completion function but DON'T call it here
|
|
2833
|
-
// 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.
|
|
2834
3073
|
let complete_coordination = null;
|
|
2835
|
-
if (
|
|
2836
|
-
complete_coordination = Load_Coordinator.register_leader(component,
|
|
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;
|
|
2837
3085
|
}
|
|
2838
|
-
await on_load_promise;
|
|
2839
3086
|
// Note: We don't validate args changes here because external code (like parent
|
|
2840
3087
|
// components calling reload()) is allowed to modify component.args during on_load().
|
|
2841
3088
|
// The read-only proxy only prevents code INSIDE on_load() from modifying args.
|
|
@@ -2937,6 +3184,21 @@ function warn_uncacheable_component(component, uncacheable_property, uncacheable
|
|
|
2937
3184
|
all_args: component.args ? Object.keys(component.args) : [],
|
|
2938
3185
|
});
|
|
2939
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
|
+
}
|
|
2940
3202
|
/**
|
|
2941
3203
|
* Read cache during create() phase.
|
|
2942
3204
|
* Sets component._cache_key, _cached_html, _use_cached_data_hit as side effects.
|
|
@@ -2968,11 +3230,11 @@ function read_cache_in_create(component) {
|
|
|
2968
3230
|
if (cache_mode === 'html') {
|
|
2969
3231
|
// HTML cache mode - check for cached HTML to inject on first render
|
|
2970
3232
|
const html_cache_key = `${cache_key}::html`;
|
|
2971
|
-
const cached_html =
|
|
2972
|
-
if (cached_html !== null
|
|
3233
|
+
const cached_html = read_html_snapshot(html_cache_key);
|
|
3234
|
+
if (cached_html !== null) {
|
|
2973
3235
|
component._cached_html = cached_html;
|
|
2974
3236
|
if (window.jqhtml?.debug?.verbose) {
|
|
2975
|
-
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 });
|
|
2976
3238
|
}
|
|
2977
3239
|
}
|
|
2978
3240
|
else {
|
|
@@ -3029,10 +3291,10 @@ function check_cache_on_reload(component) {
|
|
|
3029
3291
|
if (cache_mode === 'html') {
|
|
3030
3292
|
// HTML cache mode - check for cached HTML
|
|
3031
3293
|
const html_cache_key = `${cache_key}::html`;
|
|
3032
|
-
const cached_html =
|
|
3033
|
-
if (cached_html !== null
|
|
3294
|
+
const cached_html = read_html_snapshot(html_cache_key);
|
|
3295
|
+
if (cached_html !== null) {
|
|
3034
3296
|
if (window.jqhtml?.debug?.verbose) {
|
|
3035
|
-
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 });
|
|
3036
3298
|
}
|
|
3037
3299
|
component._cached_html = cached_html;
|
|
3038
3300
|
// Call _render() directly (not render()) since _reload() manages the full lifecycle
|
|
@@ -3073,7 +3335,11 @@ function write_html_cache_snapshot(component) {
|
|
|
3073
3335
|
component._should_cache_html_after_ready = false;
|
|
3074
3336
|
const html = component.$.html();
|
|
3075
3337
|
const html_cache_key = `${component._cache_key}::html`;
|
|
3076
|
-
|
|
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 });
|
|
3077
3343
|
if (window.jqhtml?.debug?.verbose) {
|
|
3078
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 });
|
|
3079
3345
|
}
|
|
@@ -3113,16 +3379,158 @@ function write_cache_on_loaded(component, data_changed) {
|
|
|
3113
3379
|
}
|
|
3114
3380
|
}
|
|
3115
3381
|
|
|
3382
|
+
/**
|
|
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.
|
|
3390
|
+
*/
|
|
3391
|
+
const NEEDS_ESCAPE = /[&<>]/;
|
|
3392
|
+
const ESCAPE_ALL = /[&<>]/g;
|
|
3393
|
+
const ENTITY = { '&': '&', '<': '<', '>': '>' };
|
|
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;
|
|
3400
|
+
}
|
|
3401
|
+
/** Escaped, then newlines become <br /> - the <%br= %> construct. */
|
|
3402
|
+
function escape_html_nl2br(value) {
|
|
3403
|
+
return escape_html(value).replace(/\n/g, '<br />');
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* Value printers: how <%= %> renders an OBJECT.
|
|
3408
|
+
*
|
|
3409
|
+
* Primitives never come here - the generated template code routes only
|
|
3410
|
+
* non-null, non-array objects to print_object(), so a hundred-row list of
|
|
3411
|
+
* strings costs exactly what it did before. An object reaching an
|
|
3412
|
+
* interpolation is walked through the printer chain in registration order;
|
|
3413
|
+
* the first printer to return anything but undefined handles it. A printer
|
|
3414
|
+
* returns a string (escaped or not according to the construct, exactly as a
|
|
3415
|
+
* string literal at that site) or a component descriptor:
|
|
3416
|
+
*
|
|
3417
|
+
* { component: { name: 'Rich_Text_Display', args: { value }, attrs: { class: 'x' } } }
|
|
3418
|
+
*
|
|
3419
|
+
* which jqhtml mounts as if the template had written the tag - live in the
|
|
3420
|
+
* browser, server-rendered under SSR - with no environment awareness on the
|
|
3421
|
+
* printer's side. A descriptor carries no content.
|
|
3422
|
+
*
|
|
3423
|
+
* Attribute position (<div title="<%= v %>">) compiles through a different
|
|
3424
|
+
* path and never consults printers.
|
|
3425
|
+
*/
|
|
3426
|
+
const printers = [];
|
|
3427
|
+
/**
|
|
3428
|
+
* Register a printer at the end of the chain. Printers run in registration
|
|
3429
|
+
* order; return undefined to decline, a string, or a component descriptor.
|
|
3430
|
+
*/
|
|
3431
|
+
function add_object_printer(printer) {
|
|
3432
|
+
if (typeof printer !== 'function') {
|
|
3433
|
+
throw new Error('[JQHTML] add_object_printer() expects a function (value) => undefined | string | descriptor');
|
|
3434
|
+
}
|
|
3435
|
+
printers.push(printer);
|
|
3436
|
+
}
|
|
3437
|
+
/** The registered printers, in order. Read-only view for diagnostics. */
|
|
3438
|
+
function get_object_printers() {
|
|
3439
|
+
return printers;
|
|
3440
|
+
}
|
|
3441
|
+
/**
|
|
3442
|
+
* Render an object at an interpolation site. Called by compiled templates for
|
|
3443
|
+
* every non-null, non-array object reaching <%= %>, <%!= %> or <%br= %>.
|
|
3444
|
+
* Throws when nothing handles the value or a printer returns garbage.
|
|
3445
|
+
*/
|
|
3446
|
+
function print_object(value, mode) {
|
|
3447
|
+
if (value === null || typeof value !== 'object') {
|
|
3448
|
+
throw new TypeError(`[JQHTML] print_object() is the object branch of interpolation; got ${typeof value}`);
|
|
3449
|
+
}
|
|
3450
|
+
for (let i = 0; i < printers.length; i++) {
|
|
3451
|
+
const result = printers[i](value);
|
|
3452
|
+
if (result === undefined)
|
|
3453
|
+
continue;
|
|
3454
|
+
if (typeof result === 'string')
|
|
3455
|
+
return print_string(result, mode);
|
|
3456
|
+
if (is_descriptor(result))
|
|
3457
|
+
return descriptor_to_instruction(result, i);
|
|
3458
|
+
throw new Error(`[JQHTML] Object printer #${i + 1} of ${printers.length} returned ${describe(result)}; ` +
|
|
3459
|
+
`a printer must return undefined (decline), a string, or a { component: { name, args, attrs } } descriptor`);
|
|
3460
|
+
}
|
|
3461
|
+
throw new Error(`[JQHTML] Cannot interpolate a ${constructor_name(value)} object: ` +
|
|
3462
|
+
(printers.length
|
|
3463
|
+
? `none of the ${printers.length} registered object printer(s) handled it. `
|
|
3464
|
+
: `no object printers are registered. `) +
|
|
3465
|
+
`Register one with jqhtml.add_object_printer(fn), or interpolate a primitive.`);
|
|
3466
|
+
}
|
|
3467
|
+
function print_string(text, mode) {
|
|
3468
|
+
if (mode === 'escape')
|
|
3469
|
+
return escape_html(text);
|
|
3470
|
+
if (mode === 'nl2br')
|
|
3471
|
+
return escape_html_nl2br(text);
|
|
3472
|
+
return text;
|
|
3473
|
+
}
|
|
3474
|
+
function is_descriptor(value) {
|
|
3475
|
+
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
|
3476
|
+
!!value.component && typeof value.component === 'object' && !Array.isArray(value.component);
|
|
3477
|
+
}
|
|
3478
|
+
function descriptor_to_instruction(descriptor, index) {
|
|
3479
|
+
const { name, args, attrs } = descriptor.component;
|
|
3480
|
+
const where = `Object printer #${index + 1}`;
|
|
3481
|
+
if (typeof name !== 'string' || !is_component_name(name)) {
|
|
3482
|
+
throw new Error(`[JQHTML] ${where} returned a descriptor whose component name ${describe(name)} is invalid: ` +
|
|
3483
|
+
`a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
|
|
3484
|
+
}
|
|
3485
|
+
const props = {};
|
|
3486
|
+
if (attrs !== undefined) {
|
|
3487
|
+
if (!is_plain_object(attrs))
|
|
3488
|
+
throw new Error(`[JQHTML] ${where}: descriptor attrs must be a plain object`);
|
|
3489
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
3490
|
+
if (key.startsWith('$'))
|
|
3491
|
+
throw new Error(`[JQHTML] ${where}: descriptor attrs key '${key}' - component arguments go in args, without the $ sigil`);
|
|
3492
|
+
props[key] = value;
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
if (args !== undefined) {
|
|
3496
|
+
if (!is_plain_object(args))
|
|
3497
|
+
throw new Error(`[JQHTML] ${where}: descriptor args must be a plain object`);
|
|
3498
|
+
for (const [key, value] of Object.entries(args)) {
|
|
3499
|
+
if (key.startsWith('$'))
|
|
3500
|
+
throw new Error(`[JQHTML] ${where}: descriptor args key '${key}' - the $ sigil is template syntax, not part of the argument name`);
|
|
3501
|
+
props[`$${key}`] = value;
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
return { comp: [name, props] };
|
|
3505
|
+
}
|
|
3506
|
+
function is_plain_object(value) {
|
|
3507
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
3508
|
+
}
|
|
3509
|
+
function constructor_name(value) {
|
|
3510
|
+
const ctor = value.constructor;
|
|
3511
|
+
return ctor && ctor.name ? ctor.name : 'Object';
|
|
3512
|
+
}
|
|
3513
|
+
function describe(value) {
|
|
3514
|
+
if (value === null)
|
|
3515
|
+
return 'null';
|
|
3516
|
+
if (typeof value === 'string')
|
|
3517
|
+
return `'${value}'`;
|
|
3518
|
+
if (typeof value === 'object')
|
|
3519
|
+
return Array.isArray(value) ? 'an array' : `a ${constructor_name(value)} object without a valid component property`;
|
|
3520
|
+
return `${typeof value} ${String(value)}`;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3116
3523
|
/**
|
|
3117
3524
|
* JQHTML Component Lifecycle Queue
|
|
3118
3525
|
*
|
|
3119
|
-
* Serializes lifecycle operations (render, reload, refresh) per component.
|
|
3526
|
+
* Serializes lifecycle operations (render, reload, refresh, load) per component.
|
|
3120
3527
|
* Replaces _create_debounced_function with a proper queue that:
|
|
3121
3528
|
*
|
|
3122
3529
|
* - At most one operation runs at a time per component
|
|
3123
|
-
* - At most one
|
|
3124
|
-
* - Same-type pending operations collapse
|
|
3125
|
-
*
|
|
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
|
|
3126
3534
|
*
|
|
3127
3535
|
* Boot bypasses this queue entirely - it runs the lifecycle directly.
|
|
3128
3536
|
*/
|
|
@@ -3130,92 +3538,106 @@ class Component_Queue {
|
|
|
3130
3538
|
constructor() {
|
|
3131
3539
|
/** Currently executing operation */
|
|
3132
3540
|
this._current = null;
|
|
3133
|
-
/**
|
|
3134
|
-
|
|
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 = [];
|
|
3135
3550
|
}
|
|
3136
3551
|
/**
|
|
3137
3552
|
* Enqueue a lifecycle operation.
|
|
3138
3553
|
*
|
|
3139
3554
|
* Behavior:
|
|
3140
3555
|
* - Nothing running → execute immediately
|
|
3141
|
-
* - Something running, no pending →
|
|
3142
|
-
* - Something running, same type pending → collapse
|
|
3143
|
-
*
|
|
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.
|
|
3144
3562
|
*
|
|
3145
3563
|
* @param type - Operation type ('render', 'reload', 'refresh', 'load')
|
|
3146
3564
|
* @param executor - The async function to execute
|
|
3147
|
-
* @returns Promise that
|
|
3565
|
+
* @returns Promise that settles with the result of the execution that ran
|
|
3148
3566
|
*/
|
|
3149
3567
|
enqueue(type, executor) {
|
|
3150
|
-
// 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.
|
|
3151
3572
|
if (!this._current) {
|
|
3152
|
-
return
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
if (this._pending) {
|
|
3156
|
-
if (this._pending.type === type) {
|
|
3157
|
-
// Same type pending → collapse (fan-in)
|
|
3158
|
-
// All callers share the same future execution
|
|
3159
|
-
return new Promise((resolve, reject) => {
|
|
3160
|
-
this._pending.resolvers.push(resolve);
|
|
3161
|
-
this._pending.rejecters.push(reject);
|
|
3162
|
-
});
|
|
3163
|
-
}
|
|
3164
|
-
else {
|
|
3165
|
-
// Different type pending → replace
|
|
3166
|
-
// Old pending callers join the new operation
|
|
3167
|
-
// (their operation was superseded by a different type)
|
|
3168
|
-
const old_resolvers = this._pending.resolvers;
|
|
3169
|
-
const old_rejecters = this._pending.rejecters;
|
|
3170
|
-
return new Promise((resolve, reject) => {
|
|
3171
|
-
this._pending = {
|
|
3172
|
-
type,
|
|
3173
|
-
executor,
|
|
3174
|
-
resolvers: [...old_resolvers, resolve],
|
|
3175
|
-
rejecters: [...old_rejecters, reject]
|
|
3176
|
-
};
|
|
3177
|
-
});
|
|
3178
|
-
}
|
|
3573
|
+
return new Promise((resolve, reject) => {
|
|
3574
|
+
this._run({ type, executor, resolvers: [resolve], rejecters: [reject] });
|
|
3575
|
+
});
|
|
3179
3576
|
}
|
|
3180
|
-
|
|
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
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
// No pending entry of this type yet — queue one behind whatever is already waiting.
|
|
3181
3591
|
return new Promise((resolve, reject) => {
|
|
3182
|
-
this._pending
|
|
3183
|
-
type,
|
|
3184
|
-
executor,
|
|
3185
|
-
resolvers: [resolve],
|
|
3186
|
-
rejecters: [reject]
|
|
3187
|
-
};
|
|
3592
|
+
this._pending.push({ type, executor, resolvers: [resolve], rejecters: [reject] });
|
|
3188
3593
|
});
|
|
3189
3594
|
}
|
|
3190
3595
|
/**
|
|
3191
|
-
*
|
|
3596
|
+
* Run one entry, settle its callers, then start the next pending entry.
|
|
3192
3597
|
* @private
|
|
3193
3598
|
*/
|
|
3194
|
-
|
|
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 };
|
|
3195
3604
|
const promise = (async () => {
|
|
3196
3605
|
try {
|
|
3197
|
-
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);
|
|
3198
3610
|
}
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
const pending = this._pending;
|
|
3204
|
-
this._pending = null;
|
|
3205
|
-
try {
|
|
3206
|
-
await this._execute(pending.type, pending.executor);
|
|
3207
|
-
for (const resolve of pending.resolvers)
|
|
3208
|
-
resolve();
|
|
3209
|
-
}
|
|
3210
|
-
catch (err) {
|
|
3211
|
-
for (const reject of pending.rejecters)
|
|
3212
|
-
reject(err);
|
|
3213
|
-
}
|
|
3214
|
-
}
|
|
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);
|
|
3215
3615
|
}
|
|
3216
3616
|
})();
|
|
3217
|
-
|
|
3218
|
-
|
|
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
|
+
});
|
|
3219
3641
|
}
|
|
3220
3642
|
/**
|
|
3221
3643
|
* Check if any operation is currently running.
|
|
@@ -3227,7 +3649,7 @@ class Component_Queue {
|
|
|
3227
3649
|
* Check if there's a pending operation waiting.
|
|
3228
3650
|
*/
|
|
3229
3651
|
get has_pending() {
|
|
3230
|
-
return this._pending
|
|
3652
|
+
return this._pending.length > 0;
|
|
3231
3653
|
}
|
|
3232
3654
|
}
|
|
3233
3655
|
|
|
@@ -3278,11 +3700,16 @@ function capture_component_data(component_name, args, data, cache_key) {
|
|
|
3278
3700
|
try {
|
|
3279
3701
|
const cloned_args = JSON.parse(JSON.stringify(args));
|
|
3280
3702
|
const cloned_data = JSON.parse(JSON.stringify(data));
|
|
3281
|
-
|
|
3703
|
+
const entry = {
|
|
3282
3704
|
component: component_name,
|
|
3283
3705
|
args: cloned_args,
|
|
3284
3706
|
data: cloned_data,
|
|
3285
|
-
}
|
|
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);
|
|
3286
3713
|
}
|
|
3287
3714
|
catch (error) {
|
|
3288
3715
|
// Non-serializable data — skip capture for this component
|
|
@@ -3328,10 +3755,15 @@ function set_preload_data(entries) {
|
|
|
3328
3755
|
return;
|
|
3329
3756
|
_preload_cache.clear();
|
|
3330
3757
|
for (const entry of entries) {
|
|
3331
|
-
//
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
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);
|
|
3335
3767
|
}
|
|
3336
3768
|
}
|
|
3337
3769
|
}
|
|
@@ -3444,9 +3876,69 @@ function init_viewport_resize(jQuery) {
|
|
|
3444
3876
|
* - Scoped IDs using _cid pattern
|
|
3445
3877
|
* - Event emission and CSS class hierarchy
|
|
3446
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
|
+
};
|
|
3447
3890
|
// WeakMap storage for protected lifecycle method implementations (Option 2)
|
|
3448
3891
|
// See docs/internal/lifecycle-method-protection.md for design details
|
|
3449
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
|
+
}
|
|
3450
3942
|
class Jqhtml_Component {
|
|
3451
3943
|
constructor(element, args = {}) {
|
|
3452
3944
|
this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
|
|
@@ -3471,7 +3963,10 @@ class Jqhtml_Component {
|
|
|
3471
3963
|
// Cache mode properties
|
|
3472
3964
|
this._cache_key = null; // Cache key for caching
|
|
3473
3965
|
// 'html' mode caching
|
|
3474
|
-
|
|
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
|
|
3475
3970
|
this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
|
|
3476
3971
|
this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
|
|
3477
3972
|
this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
|
|
@@ -3546,6 +4041,14 @@ class Jqhtml_Component {
|
|
|
3546
4041
|
// Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
|
|
3547
4042
|
const defineArgs = template_for_args?.defineArgs || {};
|
|
3548
4043
|
this.args = { ...defineArgs, ...dataAttrs, ...args };
|
|
4044
|
+
// The name this instance was invoked as. Every invocation path passes it
|
|
4045
|
+
// in args._component_name (template tag, $(el).component('Name'), boot
|
|
4046
|
+
// placeholder); a class handed directly to $(el).component(Class) has
|
|
4047
|
+
// only its own name to offer.
|
|
4048
|
+
const ctor = this.constructor;
|
|
4049
|
+
this._component_name = (typeof args._component_name === 'string' && args._component_name)
|
|
4050
|
+
|| ctor.component_name
|
|
4051
|
+
|| ctor.name;
|
|
3549
4052
|
// Set lifecycle truncation flags from args
|
|
3550
4053
|
if (this.args._load_only === true) {
|
|
3551
4054
|
this._load_only = true;
|
|
@@ -3707,41 +4210,12 @@ class Jqhtml_Component {
|
|
|
3707
4210
|
return child._render(null, options);
|
|
3708
4211
|
}
|
|
3709
4212
|
this._log_lifecycle('render', 'start');
|
|
3710
|
-
//
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
this.$[0].innerHTML = this._cached_html;
|
|
3717
|
-
// Mark that we used cached HTML (forces re-render after on_load)
|
|
3718
|
-
this._used_cached_html = true;
|
|
3719
|
-
// Clear cached HTML so next render uses template
|
|
3720
|
-
this._cached_html = null;
|
|
3721
|
-
// Mark first render complete
|
|
3722
|
-
this._did_first_render = true;
|
|
3723
|
-
this._log_lifecycle('render', 'complete (cached HTML)');
|
|
3724
|
-
// Call on_render() after cache inject (unless suppressed by _load_render_only)
|
|
3725
|
-
if (!options.skip_on_render) {
|
|
3726
|
-
const cacheRenderResult = this._call_lifecycle_sync('on_render');
|
|
3727
|
-
if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
|
|
3728
|
-
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
3729
|
-
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
3730
|
-
}
|
|
3731
|
-
this._fire_viewport_resize();
|
|
3732
|
-
}
|
|
3733
|
-
// Emit lifecycle event
|
|
3734
|
-
this.trigger('render');
|
|
3735
|
-
// Store args/data snapshots for later comparison
|
|
3736
|
-
try {
|
|
3737
|
-
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
3738
|
-
}
|
|
3739
|
-
catch (error) {
|
|
3740
|
-
this._args_on_last_render = null;
|
|
3741
|
-
}
|
|
3742
|
-
this._data_on_last_render = JSON.stringify(this.data);
|
|
3743
|
-
return current_render_id;
|
|
3744
|
-
}
|
|
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.
|
|
3745
4219
|
// Determine child-finding strategy: If component is off-DOM, children can't register
|
|
3746
4220
|
// via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
|
|
3747
4221
|
// If attached to DOM, children register normally and we can use the fast _dom_children path.
|
|
@@ -3760,21 +4234,60 @@ class Jqhtml_Component {
|
|
|
3760
4234
|
if (child && !child._stopped) {
|
|
3761
4235
|
child._stop(); // Stop just the component, DOM will be cleared next
|
|
3762
4236
|
}
|
|
3763
|
-
});
|
|
3764
|
-
// Clear the DOM
|
|
3765
|
-
this.$[0].innerHTML = '';
|
|
3766
|
-
}
|
|
3767
|
-
else {
|
|
3768
|
-
this._did_first_render = true;
|
|
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 ---------------------------------------------
|
|
4250
|
+
// HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
|
|
4251
|
+
if (this._cached_html !== null) {
|
|
4252
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
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);
|
|
4261
|
+
// Mark that we used cached HTML (forces re-render after on_load)
|
|
4262
|
+
this._used_cached_html = true;
|
|
4263
|
+
// Clear cached HTML so next render uses template
|
|
4264
|
+
this._cached_html = null;
|
|
4265
|
+
this._log_lifecycle('render', 'complete (cached HTML)');
|
|
4266
|
+
// Call on_render() after cache inject (unless suppressed by _load_render_only)
|
|
4267
|
+
if (!options.skip_on_render) {
|
|
4268
|
+
const cacheRenderResult = this._call_lifecycle_sync('on_render');
|
|
4269
|
+
if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
|
|
4270
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
4271
|
+
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
4272
|
+
}
|
|
4273
|
+
this._fire_viewport_resize();
|
|
4274
|
+
}
|
|
4275
|
+
// Emit lifecycle event
|
|
4276
|
+
this.trigger('render');
|
|
4277
|
+
// Store args/data snapshots for later comparison
|
|
4278
|
+
try {
|
|
4279
|
+
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
4280
|
+
}
|
|
4281
|
+
catch (error) {
|
|
4282
|
+
this._args_on_last_render = null;
|
|
4283
|
+
}
|
|
4284
|
+
this._data_on_last_render = JSON.stringify(this.data);
|
|
4285
|
+
return current_render_id;
|
|
3769
4286
|
}
|
|
3770
|
-
// Remove _Component_Stopped class if present (allows re-render after stop)
|
|
3771
|
-
this.$.removeClass('_Component_Stopped');
|
|
3772
4287
|
// Capture data state before first render for comparison later
|
|
3773
4288
|
if (this._data_before_render === null) {
|
|
3774
4289
|
this._data_before_render = JSON.stringify(this.data);
|
|
3775
4290
|
}
|
|
3776
|
-
// Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
|
|
3777
|
-
this._dom_children.clear();
|
|
3778
4291
|
// Get template and render it
|
|
3779
4292
|
let template_def;
|
|
3780
4293
|
// If we have a component name, try looking up by name first (for unregistered components)
|
|
@@ -3786,20 +4299,8 @@ class Jqhtml_Component {
|
|
|
3786
4299
|
template_def = get_template_by_class(this.constructor);
|
|
3787
4300
|
}
|
|
3788
4301
|
if (template_def && template_def.render) {
|
|
3789
|
-
//
|
|
3790
|
-
const jqhtml =
|
|
3791
|
-
escape_html: (str) => {
|
|
3792
|
-
const div = document.createElement('div');
|
|
3793
|
-
div.textContent = String(str);
|
|
3794
|
-
return div.innerHTML;
|
|
3795
|
-
},
|
|
3796
|
-
escape_html_nl2br: (str) => {
|
|
3797
|
-
const div = document.createElement('div');
|
|
3798
|
-
div.textContent = String(str);
|
|
3799
|
-
// Escape HTML first, then replace newlines with <br />
|
|
3800
|
-
return div.innerHTML.replace(/\n/g, '<br />');
|
|
3801
|
-
}
|
|
3802
|
-
};
|
|
4302
|
+
// Utilities the compiled render function calls (see render_utilities)
|
|
4303
|
+
const jqhtml = render_utilities;
|
|
3803
4304
|
// Execute template function
|
|
3804
4305
|
// Commented out debug logging
|
|
3805
4306
|
// console.log('[Component] About to call render, this:', this);
|
|
@@ -3838,69 +4339,48 @@ class Jqhtml_Component {
|
|
|
3838
4339
|
// Check for template inheritance (slot-only OR explicit extends)
|
|
3839
4340
|
// If instructions is {_slots: {...}}, find parent template and invoke it
|
|
3840
4341
|
if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
|
|
3841
|
-
const
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
//
|
|
3846
|
-
if (
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
}
|
|
3851
|
-
// If no explicit extends, walk the prototype chain to find parent class with template
|
|
3852
|
-
if (!parentTemplate) {
|
|
3853
|
-
let currentClass = Object.getPrototypeOf(this.constructor);
|
|
3854
|
-
while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
|
|
3855
|
-
const className = currentClass.name;
|
|
3856
|
-
console.log(`[JQHTML] Checking parent: ${className}`);
|
|
3857
|
-
try {
|
|
3858
|
-
const classTemplate = get_template(className);
|
|
3859
|
-
if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
|
|
3860
|
-
console.log(`[JQHTML] Found parent template: ${className}`);
|
|
3861
|
-
parentTemplate = classTemplate;
|
|
3862
|
-
parentTemplateName = className;
|
|
3863
|
-
break;
|
|
3864
|
-
}
|
|
3865
|
-
}
|
|
3866
|
-
catch (error) {
|
|
3867
|
-
console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
|
|
3868
|
-
}
|
|
3869
|
-
currentClass = Object.getPrototypeOf(currentClass);
|
|
3870
|
-
}
|
|
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.`);
|
|
3871
4351
|
}
|
|
3872
|
-
//
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
const
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
// Return in render function format: [instructions, context]
|
|
3883
|
-
// The template expression handler expects this format
|
|
3884
|
-
return [slotInstructions, slotContext];
|
|
3885
|
-
}
|
|
3886
|
-
// Slot not found, return empty
|
|
3887
|
-
return '';
|
|
3888
|
-
};
|
|
3889
|
-
// Invoke parent template with child's slots as content function
|
|
3890
|
-
const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
|
|
3891
|
-
jqhtml);
|
|
3892
|
-
console.log(`[JQHTML] Parent template invoked successfully`);
|
|
3893
|
-
instructions = parentInstructions;
|
|
3894
|
-
context = parentContext;
|
|
3895
|
-
}
|
|
3896
|
-
catch (error) {
|
|
3897
|
-
console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
|
|
3898
|
-
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];
|
|
3899
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;
|
|
3900
4376
|
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
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;
|
|
3904
4384
|
}
|
|
3905
4385
|
}
|
|
3906
4386
|
// Process instructions to generate DOM. ['_content', ...] markers from
|
|
@@ -3921,10 +4401,33 @@ class Jqhtml_Component {
|
|
|
3921
4401
|
}
|
|
3922
4402
|
this._fire_viewport_resize();
|
|
3923
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
|
+
}
|
|
3924
4428
|
// Emit lifecycle event
|
|
3925
4429
|
this.trigger('render');
|
|
3926
4430
|
// Apply debug delay after render
|
|
3927
|
-
const isRerender = this._ready_state >= 3; // Already rendered once
|
|
3928
4431
|
applyDebugDelay(isRerender ? 'rerender' : 'render');
|
|
3929
4432
|
// Store args snapshot for reload() comparison (skip if non-serializable)
|
|
3930
4433
|
try {
|
|
@@ -3936,12 +4439,6 @@ class Jqhtml_Component {
|
|
|
3936
4439
|
}
|
|
3937
4440
|
// Store data snapshot for refresh() comparison
|
|
3938
4441
|
this._data_on_last_render = JSON.stringify(this.data);
|
|
3939
|
-
// HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
|
|
3940
|
-
// This signals to parent components that this component's DOM is fully rendered
|
|
3941
|
-
// with fresh data and ready for HTML snapshot
|
|
3942
|
-
if (this._ready_state >= 2) {
|
|
3943
|
-
this._on_render_complete = true;
|
|
3944
|
-
}
|
|
3945
4442
|
// Return the render ID so callers can check if this render is still current
|
|
3946
4443
|
return current_render_id;
|
|
3947
4444
|
}
|
|
@@ -3961,10 +4458,12 @@ class Jqhtml_Component {
|
|
|
3961
4458
|
render(id = null) {
|
|
3962
4459
|
if (this._stopped)
|
|
3963
4460
|
return Promise.resolve();
|
|
3964
|
-
//
|
|
3965
|
-
//
|
|
3966
|
-
|
|
3967
|
-
//
|
|
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.
|
|
3968
4467
|
if (id) {
|
|
3969
4468
|
const $element = this.$sid(id);
|
|
3970
4469
|
if ($element.length === 0) {
|
|
@@ -3979,14 +4478,23 @@ class Jqhtml_Component {
|
|
|
3979
4478
|
}
|
|
3980
4479
|
return child.render();
|
|
3981
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');
|
|
3982
4484
|
// Enqueue the full render lifecycle through the component queue
|
|
3983
4485
|
// Return the queue promise so `await this.render()` resolves when the
|
|
3984
4486
|
// full render lifecycle (DOM update, children ready, on_ready) completes
|
|
3985
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;
|
|
3986
4491
|
// Execute render phase synchronously and capture render ID
|
|
3987
4492
|
const render_id = this._render();
|
|
3988
4493
|
// Wait for all child components to be ready
|
|
3989
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;
|
|
3990
4498
|
// Check if this render is still current before calling on_ready
|
|
3991
4499
|
// If _render_count changed, another render happened and we should skip on_ready
|
|
3992
4500
|
if (this._render_count !== render_id) {
|
|
@@ -3994,7 +4502,15 @@ class Jqhtml_Component {
|
|
|
3994
4502
|
}
|
|
3995
4503
|
// Call on_ready hook with authorization
|
|
3996
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;
|
|
3997
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();
|
|
3998
4514
|
// Trigger ready event
|
|
3999
4515
|
this.trigger('ready');
|
|
4000
4516
|
});
|
|
@@ -4024,40 +4540,54 @@ class Jqhtml_Component {
|
|
|
4024
4540
|
async load() {
|
|
4025
4541
|
if (this._stopped)
|
|
4026
4542
|
return false;
|
|
4027
|
-
//
|
|
4028
|
-
|
|
4029
|
-
|
|
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;
|
|
4030
4557
|
// Snapshot current data for change detection
|
|
4031
4558
|
const data_before = JSON.stringify(this.data);
|
|
4032
4559
|
// Execute on_load() on detached proxy (restores data to on_create snapshot)
|
|
4033
|
-
const { data: result_data } = await this._execute_on_load_detached(
|
|
4034
|
-
//
|
|
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().
|
|
4035
4566
|
this.__data_frozen = false;
|
|
4036
|
-
this.data = result_data;
|
|
4037
|
-
const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
|
|
4038
|
-
if (cache_mode === 'data') {
|
|
4039
|
-
const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
|
|
4040
|
-
this.data = normalized;
|
|
4041
|
-
}
|
|
4567
|
+
this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
|
|
4042
4568
|
this.__data_frozen = true;
|
|
4569
|
+
const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
|
|
4043
4570
|
// Detect if data changed
|
|
4044
4571
|
const data_after = JSON.stringify(this.data);
|
|
4045
|
-
data_changed = data_before !== data_after;
|
|
4572
|
+
const data_changed = data_before !== data_after;
|
|
4046
4573
|
// Update cache with fresh data if changed
|
|
4047
4574
|
if (data_changed) {
|
|
4048
|
-
//
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
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);
|
|
4053
4587
|
}
|
|
4054
|
-
|
|
4055
|
-
}
|
|
4056
|
-
else {
|
|
4057
|
-
const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args);
|
|
4058
|
-
cache_key = result.key;
|
|
4588
|
+
warn_uncacheable_component(this, uncacheable_property, uncacheable_reason);
|
|
4059
4589
|
}
|
|
4060
|
-
if (
|
|
4590
|
+
else if (cache_mode !== 'html') {
|
|
4061
4591
|
Jqhtml_Local_Storage.set(cache_key, this.data);
|
|
4062
4592
|
}
|
|
4063
4593
|
}
|
|
@@ -4065,10 +4595,13 @@ class Jqhtml_Component {
|
|
|
4065
4595
|
// Suppressed by _load_only and _load_render_only flags (preloading mode)
|
|
4066
4596
|
if (!this._load_only && !this._load_render_only) {
|
|
4067
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;
|
|
4068
4601
|
this.trigger('loaded');
|
|
4069
4602
|
}
|
|
4603
|
+
return data_changed;
|
|
4070
4604
|
});
|
|
4071
|
-
return data_changed;
|
|
4072
4605
|
}
|
|
4073
4606
|
/**
|
|
4074
4607
|
* Create phase - Quick setup, prepare UI
|
|
@@ -4091,15 +4624,18 @@ class Jqhtml_Component {
|
|
|
4091
4624
|
// OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
|
|
4092
4625
|
// Components without on_load() don't fetch data, so nothing to cache or restore
|
|
4093
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));
|
|
4094
4633
|
// CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
|
|
4095
4634
|
// Skip cache for detached elements — no point hydrating from cache when not in DOM
|
|
4096
4635
|
// @see component-cache.ts for full implementation
|
|
4097
4636
|
if (!this._is_detached) {
|
|
4098
4637
|
read_cache_in_create(this);
|
|
4099
4638
|
}
|
|
4100
|
-
// Snapshot this.data after on_create() completes
|
|
4101
|
-
// This will be restored before each on_load() execution to reset state
|
|
4102
|
-
this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
|
|
4103
4639
|
}
|
|
4104
4640
|
// Freeze this.data after on_create() - only on_load() can modify it now
|
|
4105
4641
|
this.__data_frozen = true;
|
|
@@ -4230,26 +4766,7 @@ class Jqhtml_Component {
|
|
|
4230
4766
|
// follower never runs on_load() and adopts the leader's data with NO
|
|
4231
4767
|
// revalidation, so a wrong key here is permanently wrong data. Redundant
|
|
4232
4768
|
// concurrent requests are the cheaper failure.
|
|
4233
|
-
|
|
4234
|
-
let uncacheable_property;
|
|
4235
|
-
let uncacheable_reason;
|
|
4236
|
-
if (typeof this.cache_id === 'function') {
|
|
4237
|
-
try {
|
|
4238
|
-
const custom_cache_id = this.cache_id();
|
|
4239
|
-
cache_key = `${this.component_name()}::${String(custom_cache_id)}`;
|
|
4240
|
-
}
|
|
4241
|
-
catch (error) {
|
|
4242
|
-
// cache_id() threw error - disable caching
|
|
4243
|
-
uncacheable_property = 'cache_id()';
|
|
4244
|
-
uncacheable_reason = 'cache-id-threw';
|
|
4245
|
-
}
|
|
4246
|
-
}
|
|
4247
|
-
else {
|
|
4248
|
-
const result = Load_Coordinator.generate_invocation_key(this.component_name(), this.args, { allow_content_serialization: true });
|
|
4249
|
-
cache_key = result.key;
|
|
4250
|
-
uncacheable_property = result.uncacheable_property;
|
|
4251
|
-
uncacheable_reason = result.uncacheable_reason;
|
|
4252
|
-
}
|
|
4769
|
+
const { cache_key, uncacheable_property, uncacheable_reason } = generate_cache_key(this);
|
|
4253
4770
|
const dedup_key = Load_Coordinator.generate_invocation_key(this.component_name(), this.args).key;
|
|
4254
4771
|
// SSR preload check: if preloaded data exists for this component+args, use it
|
|
4255
4772
|
if (cache_key !== null && has_preload_data()) {
|
|
@@ -4280,24 +4797,26 @@ class Jqhtml_Component {
|
|
|
4280
4797
|
if (window.jqhtml?.debug?.verbose) {
|
|
4281
4798
|
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) has no dedup key - loading independently`);
|
|
4282
4799
|
}
|
|
4283
|
-
const { data: result_data } = await this._execute_on_load_detached();
|
|
4800
|
+
const { data: result_data } = await this._execute_on_load_detached(null);
|
|
4284
4801
|
await this._apply_load_result(result_data, data_before_load);
|
|
4285
4802
|
return;
|
|
4286
4803
|
}
|
|
4287
4804
|
// Check if this component should execute on_load() or wait for existing request
|
|
4288
|
-
const should_execute = Load_Coordinator.should_execute_on_load(this);
|
|
4805
|
+
const should_execute = Load_Coordinator.should_execute_on_load(this, dedup_key);
|
|
4289
4806
|
if (!should_execute) {
|
|
4290
4807
|
// This component is a follower - wait for leader to complete
|
|
4291
4808
|
if (window.jqhtml?.debug?.verbose) {
|
|
4292
4809
|
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is a follower, waiting for leader`, { args: this.args });
|
|
4293
4810
|
}
|
|
4294
|
-
|
|
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);
|
|
4295
4814
|
if (coordination_promise) {
|
|
4296
4815
|
try {
|
|
4297
|
-
// Wait for leader to complete
|
|
4816
|
+
// Wait for leader to complete (rejects if the leader's on_load() threw)
|
|
4298
4817
|
await coordination_promise;
|
|
4299
4818
|
// Retrieve leader's data from coordinator
|
|
4300
|
-
const leader_data = Load_Coordinator.get_leader_data(this);
|
|
4819
|
+
const leader_data = Load_Coordinator.get_leader_data(this, dedup_key);
|
|
4301
4820
|
if (leader_data !== null) {
|
|
4302
4821
|
// Apply leader's data using the same method as leader
|
|
4303
4822
|
// This properly handles freeze/unfreeze and normalization
|
|
@@ -4315,15 +4834,11 @@ class Jqhtml_Component {
|
|
|
4315
4834
|
throw error;
|
|
4316
4835
|
}
|
|
4317
4836
|
}
|
|
4318
|
-
//
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
this.
|
|
4322
|
-
this.
|
|
4323
|
-
if (!this._load_only && !this._load_render_only) {
|
|
4324
|
-
await this._call_lifecycle('on_loaded');
|
|
4325
|
-
this.trigger('loaded');
|
|
4326
|
-
}
|
|
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);
|
|
4327
4842
|
return;
|
|
4328
4843
|
}
|
|
4329
4844
|
// This component is a leader - execute on_load() on detached proxy
|
|
@@ -4331,7 +4846,7 @@ class Jqhtml_Component {
|
|
|
4331
4846
|
console.log(`[Load Deduplication] Component ${this._cid} (${this.component_name()}) is the leader`, { args: this.args });
|
|
4332
4847
|
}
|
|
4333
4848
|
// Execute on_load on detached proxy with Load_Coordinator registration
|
|
4334
|
-
const { data: result_data, complete_coordination } = await this._execute_on_load_detached(
|
|
4849
|
+
const { data: result_data, complete_coordination } = await this._execute_on_load_detached(dedup_key);
|
|
4335
4850
|
// Apply result via sequential queue
|
|
4336
4851
|
await this._apply_load_result(result_data, data_before_load);
|
|
4337
4852
|
// Complete coordination AFTER this.data is updated
|
|
@@ -4345,8 +4860,8 @@ class Jqhtml_Component {
|
|
|
4345
4860
|
* @see data-proxy.ts for full implementation
|
|
4346
4861
|
* @private
|
|
4347
4862
|
*/
|
|
4348
|
-
async _execute_on_load_detached(
|
|
4349
|
-
return execute_on_load_detached(this,
|
|
4863
|
+
async _execute_on_load_detached(dedup_key = null) {
|
|
4864
|
+
return execute_on_load_detached(this, dedup_key);
|
|
4350
4865
|
}
|
|
4351
4866
|
/**
|
|
4352
4867
|
* Apply the result of on_load() to this.data via the sequential queue.
|
|
@@ -4369,21 +4884,22 @@ class Jqhtml_Component {
|
|
|
4369
4884
|
await my_turn;
|
|
4370
4885
|
try {
|
|
4371
4886
|
// Now it's our turn to set this.data
|
|
4372
|
-
//
|
|
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).
|
|
4373
4897
|
this.__data_frozen = false;
|
|
4374
|
-
this.data = result_data;
|
|
4375
|
-
// DATA MODE: Normalize this.data through serialize/deserialize round-trip
|
|
4376
|
-
// This ensures "hot" data (fresh from on_load) behaves identically to "cold" data
|
|
4377
|
-
const cache_mode = Jqhtml_Local_Storage.get_cache_mode();
|
|
4378
|
-
if (cache_mode === 'data') {
|
|
4379
|
-
const normalized = Jqhtml_Local_Storage.normalize_for_cache(this.data);
|
|
4380
|
-
this.data = normalized;
|
|
4381
|
-
if (window.jqhtml?.debug?.verbose) {
|
|
4382
|
-
console.log(`[Cache data] Component ${this._cid} (${this.component_name()}) normalized this.data after on_load()`, { data: this.data });
|
|
4383
|
-
}
|
|
4384
|
-
}
|
|
4385
|
-
// Freeze this.data
|
|
4898
|
+
this.data = Jqhtml_Local_Storage.normalize_for_cache(result_data, this.component_name());
|
|
4386
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
|
+
}
|
|
4387
4903
|
// SSR data capture: record this component's data for preloading
|
|
4388
4904
|
if (is_capture_enabled() && this._has_on_load()) {
|
|
4389
4905
|
capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
|
|
@@ -4469,9 +4985,11 @@ class Jqhtml_Component {
|
|
|
4469
4985
|
callback();
|
|
4470
4986
|
return Promise.resolve();
|
|
4471
4987
|
}
|
|
4472
|
-
// 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.
|
|
4473
4991
|
return new Promise((resolve) => {
|
|
4474
|
-
this.
|
|
4992
|
+
this.once('ready', () => {
|
|
4475
4993
|
if (callback)
|
|
4476
4994
|
callback();
|
|
4477
4995
|
resolve();
|
|
@@ -4529,9 +5047,18 @@ class Jqhtml_Component {
|
|
|
4529
5047
|
if (child._ready_state >= 4) {
|
|
4530
5048
|
continue;
|
|
4531
5049
|
}
|
|
4532
|
-
//
|
|
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.
|
|
4533
5059
|
const ready_promise = new Promise((resolve) => {
|
|
4534
|
-
child.
|
|
5060
|
+
child.once('ready', () => resolve());
|
|
5061
|
+
child.once('stop', () => resolve());
|
|
4535
5062
|
});
|
|
4536
5063
|
ready_promises.push(ready_promise);
|
|
4537
5064
|
}
|
|
@@ -4557,22 +5084,42 @@ class Jqhtml_Component {
|
|
|
4557
5084
|
// Create promises for each child that hasn't completed on_render yet
|
|
4558
5085
|
const render_promises = [];
|
|
4559
5086
|
for (const child of children) {
|
|
4560
|
-
// If child already completed on_render post-on_load, skip
|
|
4561
|
-
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) {
|
|
4562
5089
|
continue;
|
|
4563
5090
|
}
|
|
4564
|
-
//
|
|
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.
|
|
4565
5098
|
const render_promise = new Promise((resolve) => {
|
|
4566
|
-
|
|
4567
|
-
const
|
|
4568
|
-
if (
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
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
|
+
}
|
|
4573
5116
|
}
|
|
5117
|
+
resolve();
|
|
4574
5118
|
};
|
|
4575
|
-
|
|
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);
|
|
4576
5123
|
});
|
|
4577
5124
|
render_promises.push(render_promise);
|
|
4578
5125
|
}
|
|
@@ -4676,8 +5223,17 @@ class Jqhtml_Component {
|
|
|
4676
5223
|
this.next_reload_force_refresh = null;
|
|
4677
5224
|
this._render();
|
|
4678
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;
|
|
4679
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;
|
|
4680
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();
|
|
4681
5237
|
this.trigger('ready');
|
|
4682
5238
|
this._log_lifecycle('reload', 'complete (no on_load)');
|
|
4683
5239
|
return;
|
|
@@ -4705,10 +5261,16 @@ class Jqhtml_Component {
|
|
|
4705
5261
|
// STEP 2: Call on_load() on detached proxy
|
|
4706
5262
|
// This uses the same detached execution as _load() - on_load runs isolated
|
|
4707
5263
|
// and result is applied via sequential queue
|
|
4708
|
-
const { data: result_data } = await this._execute_on_load_detached(
|
|
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;
|
|
4709
5268
|
// Apply result via sequential queue
|
|
4710
5269
|
// This handles normalization, caching, and setting _is_dynamic
|
|
4711
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;
|
|
4712
5274
|
// Re-read data state after apply (for render decision below)
|
|
4713
5275
|
const data_after_load = JSON.stringify(this.data);
|
|
4714
5276
|
const data_changed = data_after_load !== data_before_load;
|
|
@@ -4717,6 +5279,15 @@ class Jqhtml_Component {
|
|
|
4717
5279
|
const force_refresh = this.next_reload_force_refresh !== null ? this.next_reload_force_refresh : true;
|
|
4718
5280
|
// Track if we need to render
|
|
4719
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
|
+
}
|
|
4720
5291
|
if (force_refresh) {
|
|
4721
5292
|
// reload(true) or reload() - always render if we haven't yet, OR data changed
|
|
4722
5293
|
should_render = !rendered_from_cache || data_changed;
|
|
@@ -4737,6 +5308,11 @@ class Jqhtml_Component {
|
|
|
4737
5308
|
}
|
|
4738
5309
|
}
|
|
4739
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
|
+
}
|
|
4740
5316
|
if (should_render) {
|
|
4741
5317
|
this._render();
|
|
4742
5318
|
}
|
|
@@ -4751,11 +5327,33 @@ class Jqhtml_Component {
|
|
|
4751
5327
|
// STEP 3.5 & 4: Wait for children and call on_ready (only if we rendered)
|
|
4752
5328
|
if (rendered_from_cache || should_render) {
|
|
4753
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;
|
|
4754
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;
|
|
4755
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();
|
|
4756
5342
|
// Trigger ready event so parent callbacks fire on subsequent reloads
|
|
4757
5343
|
this.trigger('ready');
|
|
4758
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
|
+
}
|
|
4759
5357
|
this._log_lifecycle('reload', 'complete');
|
|
4760
5358
|
}
|
|
4761
5359
|
/**
|
|
@@ -4780,26 +5378,28 @@ class Jqhtml_Component {
|
|
|
4780
5378
|
this.__gate_resume = null;
|
|
4781
5379
|
resume();
|
|
4782
5380
|
}
|
|
4783
|
-
//
|
|
4784
|
-
//
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
}
|
|
4793
|
-
// 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.
|
|
4794
5390
|
this._log_lifecycle('destroy', 'start');
|
|
4795
5391
|
this.$.addClass('_Component_Stopped');
|
|
4796
5392
|
// Unregister from lifecycle manager
|
|
4797
5393
|
this._lifecycle_manager.unregister_component(this);
|
|
4798
|
-
// Call user's on_stop() hook with authorization (sync)
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
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
|
+
}
|
|
4803
5403
|
}
|
|
4804
5404
|
// Fire registered stop callbacks
|
|
4805
5405
|
this.trigger('stop');
|
|
@@ -4884,8 +5484,15 @@ class Jqhtml_Component {
|
|
|
4884
5484
|
/**
|
|
4885
5485
|
* Get component name for debugging
|
|
4886
5486
|
*/
|
|
5487
|
+
/**
|
|
5488
|
+
* The name this component was invoked as - the tag written in the template
|
|
5489
|
+
* (<User_Card> -> 'User_Card'), the name given to $(el).component('User_Card'),
|
|
5490
|
+
* or the boot placeholder's data-component-init-name. For a template-only
|
|
5491
|
+
* component this is the template name, not 'Jqhtml_Component'. Falls back to
|
|
5492
|
+
* the class's static component_name, then its JS class name.
|
|
5493
|
+
*/
|
|
4887
5494
|
component_name() {
|
|
4888
|
-
return this.
|
|
5495
|
+
return this._component_name;
|
|
4889
5496
|
}
|
|
4890
5497
|
/**
|
|
4891
5498
|
* Register event callback - delegates to component-events.ts
|
|
@@ -5068,6 +5675,48 @@ class Jqhtml_Component {
|
|
|
5068
5675
|
_generate_cid() {
|
|
5069
5676
|
return uid();
|
|
5070
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
|
+
}
|
|
5071
5720
|
_apply_css_classes() {
|
|
5072
5721
|
const hierarchy = this.constructor.get_class_hierarchy();
|
|
5073
5722
|
// If component name differs from class name, add component name to hierarchy
|
|
@@ -5107,26 +5756,29 @@ class Jqhtml_Component {
|
|
|
5107
5756
|
}
|
|
5108
5757
|
if (!template)
|
|
5109
5758
|
return;
|
|
5110
|
-
// Walk the
|
|
5111
|
-
//
|
|
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.
|
|
5112
5767
|
const templateChain = [];
|
|
5113
5768
|
let currentTemplate = template;
|
|
5114
|
-
|
|
5115
|
-
|
|
5769
|
+
let currentClass = this.constructor;
|
|
5770
|
+
const seen = new Set();
|
|
5771
|
+
while (currentTemplate && !seen.has(currentTemplate)) {
|
|
5772
|
+
seen.add(currentTemplate);
|
|
5116
5773
|
templateChain.unshift(currentTemplate); // Add to front so parent comes first
|
|
5117
|
-
|
|
5118
|
-
if (
|
|
5119
|
-
try {
|
|
5120
|
-
currentTemplate = get_template(currentTemplate.extends);
|
|
5121
|
-
}
|
|
5122
|
-
catch (error) {
|
|
5123
|
-
// Parent template not found, stop chain
|
|
5124
|
-
break;
|
|
5125
|
-
}
|
|
5126
|
-
}
|
|
5127
|
-
else {
|
|
5774
|
+
const parent = resolve_parent_template(currentTemplate, currentClass);
|
|
5775
|
+
if (!parent)
|
|
5128
5776
|
break;
|
|
5129
|
-
|
|
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;
|
|
5130
5782
|
}
|
|
5131
5783
|
// Apply defaultAttributes from each template in the chain (parent first, child last)
|
|
5132
5784
|
for (const tmpl of templateChain) {
|
|
@@ -5203,8 +5855,10 @@ class Jqhtml_Component {
|
|
|
5203
5855
|
}
|
|
5204
5856
|
}
|
|
5205
5857
|
else {
|
|
5206
|
-
// Regular attributes - apply directly if not already set
|
|
5207
|
-
|
|
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) {
|
|
5208
5862
|
this.$.attr(key, value);
|
|
5209
5863
|
}
|
|
5210
5864
|
}
|
|
@@ -5296,278 +5950,416 @@ Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() det
|
|
|
5296
5950
|
// A bare component name, as accepted by closest() in place of a class selector
|
|
5297
5951
|
Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
|
|
5298
5952
|
|
|
5953
|
+
var light_css = "html[data-jqhtml-debug] .jqhtml-debug-hit {\n outline: 2px solid #e5322d;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-0 {\n outline-color: #e5322d;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-1 {\n outline-color: #2f6fe4;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-2 {\n outline-color: #1f9d55;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-3 {\n outline-color: #d97706;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-4 {\n outline-color: #7c3aed;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-5 {\n outline-color: #0e7490;\n}";
|
|
5954
|
+
|
|
5955
|
+
var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed;\n box-sizing: border-box;\n top: 0;\n left: 0;\n width: 100vw;\n height: 100vh;\n z-index: 2147483647;\n pointer-events: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.4;\n color: #1c1c1c;\n text-align: left;\n text-transform: none;\n text-indent: 0;\n text-shadow: none;\n letter-spacing: normal;\n word-spacing: normal;\n white-space: normal;\n word-break: normal;\n overflow-wrap: anywhere;\n hyphens: none;\n tab-size: 4;\n direction: ltr;\n visibility: visible;\n cursor: default;\n list-style: none;\n quotes: none;\n}\n\n.jqhtml-debug-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label {\n position: absolute;\n box-sizing: border-box;\n display: block;\n padding: 1px 5px;\n min-height: 18px;\n color: #ffffff;\n background: #e5322d;\n border-radius: 0 0 3px 0;\n font-size: 11px;\n line-height: 16px;\n white-space: normal;\n overflow-wrap: anywhere;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-0 {\n background: #e5322d;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-1 {\n background: #2f6fe4;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-2 {\n background: #1f9d55;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-3 {\n background: #d97706;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-4 {\n background: #7c3aed;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-5 {\n background: #0e7490;\n}\n\n.jqhtml-debug-label-name {\n font-weight: bold;\n}\n\n.jqhtml-debug-label-args {\n opacity: 0.9;\n margin-left: 6px;\n}\n\n.jqhtml-debug-modal {\n display: none;\n position: absolute;\n box-sizing: border-box;\n top: 24px;\n right: 24px;\n width: 520px;\n max-width: calc(100% - 48px);\n max-height: calc(100% - 48px);\n overflow: auto;\n padding: 0 0 12px 0;\n background: #ffffff;\n color: #1c1c1c;\n border: 1px solid #d0d0d0;\n border-radius: 6px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n pointer-events: auto;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-open {\n display: block;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n position: sticky;\n top: 0;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-title-name {\n flex: 1 1 auto;\n font-size: 13px;\n}\n\n.jqhtml-debug-button {\n all: unset;\n display: inline-block;\n box-sizing: border-box;\n padding: 2px 8px;\n border: 1px solid rgba(255, 255, 255, 0.6);\n border-radius: 3px;\n color: #ffffff;\n font-size: 11px;\n line-height: 16px;\n cursor: pointer;\n user-select: none;\n}\n\n.jqhtml-debug-button:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.jqhtml-debug-section {\n padding: 8px 12px 0 12px;\n}\n\n.jqhtml-debug-h {\n margin: 4px 0;\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-table {\n display: grid;\n grid-template-columns: max-content max-content 1fr;\n column-gap: 12px;\n row-gap: 2px;\n align-items: baseline;\n}\n\n.jqhtml-debug-key {\n color: #005cc5;\n white-space: nowrap;\n}\n\n.jqhtml-debug-type {\n color: #666666;\n font-size: 11px;\n white-space: nowrap;\n}\n\n.jqhtml-debug-value {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.jqhtml-debug-empty {\n color: #666666;\n font-style: italic;\n}\n\n.jqhtml-debug-list {\n display: block;\n margin: 0;\n padding: 0;\n}\n\n.jqhtml-debug-link {\n display: block;\n padding: 2px 4px;\n margin: 0 -4px;\n border-radius: 3px;\n cursor: pointer;\n}\n\n.jqhtml-debug-link:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-link-cid {\n color: #666666;\n margin-left: 6px;\n}\n\n.jqhtml-debug-note {\n margin-top: 4px;\n color: #92400e;\n font-size: 11px;\n}";
|
|
5956
|
+
|
|
5299
5957
|
/**
|
|
5300
|
-
*
|
|
5301
|
-
*
|
|
5302
|
-
* Connects compiled templates to components
|
|
5303
|
-
* Processes instruction arrays and handles bindings
|
|
5304
|
-
*/
|
|
5305
|
-
/**
|
|
5306
|
-
* Process slot-only template inheritance
|
|
5958
|
+
* Component debug overlay.
|
|
5307
5959
|
*
|
|
5308
|
-
*
|
|
5309
|
-
*
|
|
5960
|
+
* `jqhtml.debug_overlay.enable()` turns the page into an inspector: hovering
|
|
5961
|
+
* any element outlines the component under the pointer and every component
|
|
5962
|
+
* above it, each with a small tab naming the component and its simple args;
|
|
5963
|
+
* clicking opens a modal describing the innermost component (identity, args,
|
|
5964
|
+
* data, state, ancestry, instantiator) instead of firing the click. Alt+click
|
|
5965
|
+
* passes through. `disable()` puts everything back.
|
|
5310
5966
|
*
|
|
5311
|
-
*
|
|
5312
|
-
*
|
|
5313
|
-
*
|
|
5967
|
+
* Non-invasiveness, the design goal (details in ./CLAUDE.md):
|
|
5968
|
+
* - The overlay's own UI lives in a shadow root on a host element appended to
|
|
5969
|
+
* <body>, styled by shadow.scss. Page CSS cannot select into it and :host
|
|
5970
|
+
* cuts inheritance, so its look is fixed whatever the page does.
|
|
5971
|
+
* - The only thing added to application elements is a pair of classes,
|
|
5972
|
+
* .jqhtml-debug-hit / .jqhtml-debug-depth-N, which light.scss paints as an
|
|
5973
|
+
* inset outline (no layout effect) and only while <html data-jqhtml-debug>.
|
|
5974
|
+
* - Listeners are attached on enable and removed on disable. The stylesheets
|
|
5975
|
+
* and the (detached) host stay once installed: inert, and cheap to re-enable.
|
|
5976
|
+
* - Nothing is registered per component, so components created after enable
|
|
5977
|
+
* are covered automatically.
|
|
5314
5978
|
*/
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5979
|
+
const PREFIX = 'jqhtml-debug-';
|
|
5980
|
+
const ROOT_ATTR = 'data-jqhtml-debug'; // on <html> while enabled - gates light.scss
|
|
5981
|
+
const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
|
|
5982
|
+
const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
|
|
5983
|
+
const HIT_CLASS = `${PREFIX}hit`;
|
|
5984
|
+
const DEPTH_CLASS = `${PREFIX}depth-`;
|
|
5985
|
+
const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
|
|
5986
|
+
const LABEL_HEIGHT = 18;
|
|
5987
|
+
const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
|
|
5988
|
+
const state = {
|
|
5989
|
+
enabled: false,
|
|
5990
|
+
host: null,
|
|
5991
|
+
shadow: null,
|
|
5992
|
+
layer: null,
|
|
5993
|
+
modal: null,
|
|
5994
|
+
chain: [],
|
|
5995
|
+
hit_elements: [],
|
|
5996
|
+
modal_component: null,
|
|
5997
|
+
};
|
|
5998
|
+
// ---------------------------------------------------------------------------
|
|
5999
|
+
// Public API
|
|
6000
|
+
// ---------------------------------------------------------------------------
|
|
6001
|
+
const debug_overlay = {
|
|
6002
|
+
/** Turn the overlay on. Returns false where there is no DOM (SSR). */
|
|
6003
|
+
enable() {
|
|
6004
|
+
if (typeof document === 'undefined' || !document.documentElement)
|
|
6005
|
+
return false;
|
|
6006
|
+
if (state.enabled)
|
|
6007
|
+
return true;
|
|
6008
|
+
install();
|
|
6009
|
+
if (!state.host.isConnected)
|
|
6010
|
+
(document.body || document.documentElement).appendChild(state.host);
|
|
6011
|
+
document.documentElement.setAttribute(ROOT_ATTR, '');
|
|
6012
|
+
document.addEventListener('mouseover', on_mouseover, true);
|
|
6013
|
+
document.addEventListener('mouseout', on_mouseout, true);
|
|
6014
|
+
document.addEventListener('click', on_click, true);
|
|
6015
|
+
document.addEventListener('keydown', on_keydown, true);
|
|
6016
|
+
document.addEventListener('scroll', on_reposition, true);
|
|
6017
|
+
window.addEventListener('resize', on_reposition);
|
|
6018
|
+
state.enabled = true;
|
|
6019
|
+
return true;
|
|
6020
|
+
},
|
|
6021
|
+
/** Turn the overlay off. Styles stay installed but match nothing. */
|
|
6022
|
+
disable() {
|
|
6023
|
+
if (!state.enabled)
|
|
6024
|
+
return;
|
|
6025
|
+
document.removeEventListener('mouseover', on_mouseover, true);
|
|
6026
|
+
document.removeEventListener('mouseout', on_mouseout, true);
|
|
6027
|
+
document.removeEventListener('click', on_click, true);
|
|
6028
|
+
document.removeEventListener('keydown', on_keydown, true);
|
|
6029
|
+
document.removeEventListener('scroll', on_reposition, true);
|
|
6030
|
+
window.removeEventListener('resize', on_reposition);
|
|
6031
|
+
clear_hover();
|
|
6032
|
+
close_modal();
|
|
6033
|
+
document.documentElement.removeAttribute(ROOT_ATTR);
|
|
6034
|
+
if (state.host && state.host.isConnected)
|
|
6035
|
+
state.host.remove();
|
|
6036
|
+
state.enabled = false;
|
|
6037
|
+
},
|
|
6038
|
+
is_enabled() {
|
|
6039
|
+
return state.enabled;
|
|
6040
|
+
},
|
|
6041
|
+
/**
|
|
6042
|
+
* Open the modal for a component, an element inside one, or a jQuery
|
|
6043
|
+
* object, without needing a click. Handy from the console and from tests.
|
|
6044
|
+
*/
|
|
6045
|
+
inspect(target) {
|
|
6046
|
+
if (!state.enabled)
|
|
6047
|
+
return false;
|
|
6048
|
+
const component = target instanceof Jqhtml_Component
|
|
6049
|
+
? target
|
|
6050
|
+
: chain_for(target && target.jquery ? target[0] : target)[0];
|
|
6051
|
+
if (!component)
|
|
6052
|
+
return false;
|
|
6053
|
+
open_modal(component);
|
|
6054
|
+
return true;
|
|
6055
|
+
},
|
|
6056
|
+
};
|
|
6057
|
+
// ---------------------------------------------------------------------------
|
|
6058
|
+
// Installation (once)
|
|
6059
|
+
// ---------------------------------------------------------------------------
|
|
6060
|
+
function install() {
|
|
6061
|
+
if (state.host)
|
|
6062
|
+
return;
|
|
6063
|
+
if (!document.getElementById(LIGHT_STYLE_ID)) {
|
|
6064
|
+
const style = document.createElement('style');
|
|
6065
|
+
style.id = LIGHT_STYLE_ID;
|
|
6066
|
+
style.textContent = light_css;
|
|
6067
|
+
document.head.appendChild(style);
|
|
6068
|
+
}
|
|
6069
|
+
const host = document.createElement('div');
|
|
6070
|
+
host.setAttribute(HOST_ATTR, '');
|
|
6071
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
6072
|
+
const style = document.createElement('style');
|
|
6073
|
+
style.textContent = shadow_css;
|
|
6074
|
+
shadow.appendChild(style);
|
|
6075
|
+
const layer = el('div', 'layer');
|
|
6076
|
+
shadow.appendChild(layer);
|
|
6077
|
+
const modal = el('div', 'modal');
|
|
6078
|
+
shadow.appendChild(modal);
|
|
6079
|
+
state.host = host;
|
|
6080
|
+
state.shadow = shadow;
|
|
6081
|
+
state.layer = layer;
|
|
6082
|
+
state.modal = modal;
|
|
6083
|
+
}
|
|
6084
|
+
// ---------------------------------------------------------------------------
|
|
6085
|
+
// Component chain
|
|
6086
|
+
// ---------------------------------------------------------------------------
|
|
6087
|
+
/** Components from the one containing `node` outward to the root. */
|
|
6088
|
+
function chain_for(node) {
|
|
6089
|
+
const chain = [];
|
|
6090
|
+
let element = node instanceof Element ? node : (node && node.parentElement) || null;
|
|
6091
|
+
while (element) {
|
|
6092
|
+
const owner = element.closest('.Component');
|
|
6093
|
+
if (!owner)
|
|
6094
|
+
break;
|
|
6095
|
+
const component = $(owner).data('_component');
|
|
6096
|
+
if (component instanceof Jqhtml_Component && !component.$.hasClass('_Component_Stopped'))
|
|
6097
|
+
chain.push(component);
|
|
6098
|
+
element = owner.parentElement;
|
|
5353
6099
|
}
|
|
5354
|
-
|
|
5355
|
-
console.warn(`[JQHTML] No parent template found after walking chain`);
|
|
5356
|
-
return null;
|
|
6100
|
+
return chain;
|
|
5357
6101
|
}
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
if (
|
|
5366
|
-
const template_def = get_template_by_class(component.constructor);
|
|
5367
|
-
render_fn = template_def.render;
|
|
5368
|
-
}
|
|
5369
|
-
if (!render_fn) {
|
|
5370
|
-
// No template, component should handle its own rendering
|
|
6102
|
+
function is_ours(event) {
|
|
6103
|
+
return !!state.host && event.composedPath().includes(state.host);
|
|
6104
|
+
}
|
|
6105
|
+
// ---------------------------------------------------------------------------
|
|
6106
|
+
// Hover
|
|
6107
|
+
// ---------------------------------------------------------------------------
|
|
6108
|
+
function on_mouseover(event) {
|
|
6109
|
+
if (is_ours(event))
|
|
5371
6110
|
return;
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
// Templates receive: (data, args, content)
|
|
5377
|
-
// Provide a default empty content function if none provided
|
|
5378
|
-
const defaultContent = () => '';
|
|
5379
|
-
let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
|
|
5380
|
-
);
|
|
5381
|
-
// Check for slot-only template inheritance
|
|
5382
|
-
// If instructions is {_slots: {...}}, this is a slot-only template
|
|
5383
|
-
// We need to find the parent template and invoke it with these slots
|
|
5384
|
-
if (instructions && typeof instructions === 'object' && instructions._slots) {
|
|
5385
|
-
console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
|
|
5386
|
-
const result = await process_slot_inheritance(component, instructions._slots);
|
|
5387
|
-
if (result) {
|
|
5388
|
-
console.log(`[JQHTML] Parent template found, using parent instructions`);
|
|
5389
|
-
instructions = result[0];
|
|
5390
|
-
context = result[1];
|
|
5391
|
-
}
|
|
5392
|
-
else {
|
|
5393
|
-
console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
|
|
5394
|
-
// No parent template found - return empty
|
|
5395
|
-
instructions = [];
|
|
5396
|
-
}
|
|
5397
|
-
}
|
|
5398
|
-
// Process instructions to generate DOM
|
|
5399
|
-
await process_instructions(instructions, component.$, component);
|
|
5400
|
-
// Process bindings after DOM is created
|
|
5401
|
-
await process_bindings(component);
|
|
5402
|
-
// Attach event handlers
|
|
5403
|
-
await attach_event_handlers(component);
|
|
6111
|
+
const chain = chain_for(event.target);
|
|
6112
|
+
if (chain[0] === state.chain[0])
|
|
6113
|
+
return;
|
|
6114
|
+
set_hover(chain);
|
|
5404
6115
|
}
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
async function process_bindings(component) {
|
|
5409
|
-
// Find all elements with data-bind-* attributes
|
|
5410
|
-
component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
|
|
5411
|
-
const el = $(element);
|
|
5412
|
-
const attrs = element.attributes;
|
|
5413
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
5414
|
-
const attr = attrs[i];
|
|
5415
|
-
if (attr.name.startsWith('data-bind-')) {
|
|
5416
|
-
const binding_type = attr.name.substring(10); // Remove 'data-bind-'
|
|
5417
|
-
const expression = attr.value;
|
|
5418
|
-
try {
|
|
5419
|
-
// Evaluate expression in component context
|
|
5420
|
-
const value = evaluate_expression(expression, component);
|
|
5421
|
-
// Apply binding based on type
|
|
5422
|
-
switch (binding_type) {
|
|
5423
|
-
case 'prop':
|
|
5424
|
-
// Generic property binding
|
|
5425
|
-
const prop_name = el.attr('data-bind-prop-name') || 'value';
|
|
5426
|
-
el.prop(prop_name, value);
|
|
5427
|
-
break;
|
|
5428
|
-
case 'value':
|
|
5429
|
-
el.val(value);
|
|
5430
|
-
break;
|
|
5431
|
-
case 'text':
|
|
5432
|
-
el.text(value);
|
|
5433
|
-
break;
|
|
5434
|
-
case 'html':
|
|
5435
|
-
el.html(value);
|
|
5436
|
-
break;
|
|
5437
|
-
case 'class':
|
|
5438
|
-
if (typeof value === 'object') {
|
|
5439
|
-
// Object syntax: {active: true, disabled: false}
|
|
5440
|
-
Object.entries(value).forEach(([className, enabled]) => {
|
|
5441
|
-
el.toggleClass(className, !!enabled);
|
|
5442
|
-
});
|
|
5443
|
-
}
|
|
5444
|
-
else {
|
|
5445
|
-
// String syntax
|
|
5446
|
-
el.addClass(String(value));
|
|
5447
|
-
}
|
|
5448
|
-
break;
|
|
5449
|
-
case 'style':
|
|
5450
|
-
if (typeof value === 'object') {
|
|
5451
|
-
el.css(value);
|
|
5452
|
-
}
|
|
5453
|
-
else {
|
|
5454
|
-
el.attr('style', String(value));
|
|
5455
|
-
}
|
|
5456
|
-
break;
|
|
5457
|
-
default:
|
|
5458
|
-
// Custom binding - set as attribute
|
|
5459
|
-
el.attr(binding_type, value);
|
|
5460
|
-
}
|
|
5461
|
-
}
|
|
5462
|
-
catch (error) {
|
|
5463
|
-
console.error(`Error evaluating binding "${expression}":`, error);
|
|
5464
|
-
}
|
|
5465
|
-
}
|
|
5466
|
-
}
|
|
5467
|
-
});
|
|
6116
|
+
function on_mouseout(event) {
|
|
6117
|
+
if (!event.relatedTarget)
|
|
6118
|
+
clear_hover(); // pointer left the document
|
|
5468
6119
|
}
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
const event_name = attr.name.substring(8); // Remove 'data-on-'
|
|
5481
|
-
const handler_expr = attr.value;
|
|
5482
|
-
// Remove the attribute to avoid re-processing
|
|
5483
|
-
el.removeAttr(attr.name);
|
|
5484
|
-
// Attach event handler
|
|
5485
|
-
el.on(event_name, function (event) {
|
|
5486
|
-
try {
|
|
5487
|
-
// Create handler function in component context
|
|
5488
|
-
const handler = evaluate_handler(handler_expr, component);
|
|
5489
|
-
if (typeof handler === 'function') {
|
|
5490
|
-
// Call with component as 'this' and pass event
|
|
5491
|
-
handler.call(component, event);
|
|
5492
|
-
}
|
|
5493
|
-
else {
|
|
5494
|
-
// Expression to execute
|
|
5495
|
-
evaluate_expression(handler_expr, component, { $event: event });
|
|
5496
|
-
}
|
|
5497
|
-
}
|
|
5498
|
-
catch (error) {
|
|
5499
|
-
console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
|
|
5500
|
-
}
|
|
5501
|
-
});
|
|
5502
|
-
}
|
|
5503
|
-
}
|
|
6120
|
+
function on_reposition() {
|
|
6121
|
+
if (state.chain.length)
|
|
6122
|
+
render_labels();
|
|
6123
|
+
}
|
|
6124
|
+
function set_hover(chain) {
|
|
6125
|
+
clear_hover();
|
|
6126
|
+
state.chain = chain;
|
|
6127
|
+
chain.forEach((component, depth) => {
|
|
6128
|
+
const element = component.$[0];
|
|
6129
|
+
element.classList.add(HIT_CLASS, DEPTH_CLASS + (depth % DEPTH_COLORS));
|
|
6130
|
+
state.hit_elements.push(element);
|
|
5504
6131
|
});
|
|
6132
|
+
render_labels();
|
|
6133
|
+
}
|
|
6134
|
+
function clear_hover() {
|
|
6135
|
+
for (const element of state.hit_elements) {
|
|
6136
|
+
element.classList.remove(HIT_CLASS);
|
|
6137
|
+
for (let i = 0; i < DEPTH_COLORS; i++)
|
|
6138
|
+
element.classList.remove(DEPTH_CLASS + i);
|
|
6139
|
+
}
|
|
6140
|
+
state.hit_elements = [];
|
|
6141
|
+
state.chain = [];
|
|
6142
|
+
if (state.layer)
|
|
6143
|
+
state.layer.textContent = '';
|
|
5505
6144
|
}
|
|
5506
6145
|
/**
|
|
5507
|
-
*
|
|
6146
|
+
* One tab per component in the chain, pinned to the component's top-left
|
|
6147
|
+
* corner and no wider than the component. Outermost first so the innermost
|
|
6148
|
+
* paints on top; tabs that share a corner (a child filling its parent, the
|
|
6149
|
+
* usual case) stack downward instead of hiding each other.
|
|
5508
6150
|
*/
|
|
5509
|
-
function
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
6151
|
+
function render_labels() {
|
|
6152
|
+
const layer = state.layer;
|
|
6153
|
+
layer.textContent = '';
|
|
6154
|
+
const taken = new Map();
|
|
6155
|
+
for (let depth = state.chain.length - 1; depth >= 0; depth--) {
|
|
6156
|
+
const component = state.chain[depth];
|
|
6157
|
+
const rect = component.$[0].getBoundingClientRect();
|
|
6158
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
6159
|
+
continue;
|
|
6160
|
+
const left = Math.max(0, Math.round(rect.left));
|
|
6161
|
+
const top = Math.max(0, Math.round(rect.top));
|
|
6162
|
+
const key = `${left},${top}`;
|
|
6163
|
+
const stacked = taken.get(key) || 0;
|
|
6164
|
+
taken.set(key, stacked + 1);
|
|
6165
|
+
const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
|
|
6166
|
+
label.style.left = `${left}px`;
|
|
6167
|
+
label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
|
|
6168
|
+
label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
|
|
6169
|
+
label.appendChild(el('span', 'label-name', component.component_name()));
|
|
6170
|
+
const args = simple_args(component);
|
|
6171
|
+
if (args)
|
|
6172
|
+
label.appendChild(el('span', 'label-args', args));
|
|
6173
|
+
layer.appendChild(label);
|
|
5528
6174
|
}
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
6175
|
+
}
|
|
6176
|
+
/** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
|
|
6177
|
+
function simple_args(component) {
|
|
6178
|
+
const parts = [];
|
|
6179
|
+
for (const [key, value] of Object.entries(component.args || {})) {
|
|
6180
|
+
if (key.startsWith('_'))
|
|
6181
|
+
continue;
|
|
6182
|
+
if (typeof value === 'string')
|
|
6183
|
+
parts.push(`$${key}=${JSON.stringify(truncate(value, 24))}`);
|
|
6184
|
+
else if (typeof value === 'number' || typeof value === 'boolean')
|
|
6185
|
+
parts.push(`$${key}=${value}`);
|
|
5532
6186
|
}
|
|
6187
|
+
return parts.join(' ');
|
|
5533
6188
|
}
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
function
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
6189
|
+
// ---------------------------------------------------------------------------
|
|
6190
|
+
// Click -> modal
|
|
6191
|
+
// ---------------------------------------------------------------------------
|
|
6192
|
+
function on_click(event) {
|
|
6193
|
+
if (is_ours(event) || event.altKey)
|
|
6194
|
+
return;
|
|
6195
|
+
event.preventDefault();
|
|
6196
|
+
event.stopPropagation();
|
|
6197
|
+
event.stopImmediatePropagation();
|
|
6198
|
+
const chain = chain_for(event.target);
|
|
6199
|
+
if (!chain.length) {
|
|
6200
|
+
close_modal();
|
|
6201
|
+
return;
|
|
6202
|
+
}
|
|
6203
|
+
open_modal(chain[0]);
|
|
6204
|
+
}
|
|
6205
|
+
function on_keydown(event) {
|
|
6206
|
+
if (event.key === 'Escape' && state.modal_component) {
|
|
6207
|
+
close_modal();
|
|
6208
|
+
event.stopPropagation();
|
|
6209
|
+
}
|
|
6210
|
+
}
|
|
6211
|
+
function close_modal() {
|
|
6212
|
+
if (!state.modal)
|
|
6213
|
+
return;
|
|
6214
|
+
state.modal.classList.remove(`${PREFIX}modal-open`);
|
|
6215
|
+
state.modal.textContent = '';
|
|
6216
|
+
state.modal_component = null;
|
|
6217
|
+
}
|
|
6218
|
+
function open_modal(component) {
|
|
6219
|
+
const modal = state.modal;
|
|
6220
|
+
modal.textContent = '';
|
|
6221
|
+
state.modal_component = component;
|
|
6222
|
+
const chain = chain_for(component.$[0]);
|
|
6223
|
+
const ancestors = chain.slice(1);
|
|
6224
|
+
const element = component.$[0];
|
|
6225
|
+
// Title bar
|
|
6226
|
+
const title = el('div', 'title');
|
|
6227
|
+
title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
|
|
6228
|
+
const log = el('button', 'button', 'Log to console');
|
|
6229
|
+
log.addEventListener('click', () => {
|
|
6230
|
+
console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
|
|
6231
|
+
});
|
|
6232
|
+
title.appendChild(log);
|
|
6233
|
+
const close = el('button', 'button', 'Close');
|
|
6234
|
+
close.addEventListener('click', close_modal);
|
|
6235
|
+
title.appendChild(close);
|
|
6236
|
+
modal.appendChild(title);
|
|
6237
|
+
// Identity
|
|
6238
|
+
const ctor = component.constructor;
|
|
6239
|
+
const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
|
|
6240
|
+
const identity = table();
|
|
6241
|
+
row(identity, 'name', '', component.component_name());
|
|
6242
|
+
row(identity, 'class', '', hierarchy.join(' → '));
|
|
6243
|
+
row(identity, 'element', '', describe_element(element));
|
|
6244
|
+
row(identity, '_cid', '', String(component._cid));
|
|
6245
|
+
row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
|
|
6246
|
+
const nocache = element.getAttribute('data-nocache');
|
|
6247
|
+
if (nocache)
|
|
6248
|
+
row(identity, 'cache', '', `declined: ${nocache}`);
|
|
6249
|
+
modal.appendChild(section('Identity', identity));
|
|
6250
|
+
// Args / data / state
|
|
6251
|
+
modal.appendChild(section('Args', object_table(component.args)));
|
|
6252
|
+
modal.appendChild(section('Data', object_table(component.data)));
|
|
6253
|
+
modal.appendChild(section('State', object_table(component.state)));
|
|
6254
|
+
// Ancestry
|
|
6255
|
+
const list = el('div', 'list');
|
|
6256
|
+
if (!ancestors.length)
|
|
6257
|
+
list.appendChild(el('div', 'empty', 'none - this is a root component'));
|
|
6258
|
+
for (const ancestor of ancestors) {
|
|
6259
|
+
const link = el('div', 'link');
|
|
6260
|
+
link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
|
|
6261
|
+
link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
|
|
6262
|
+
link.addEventListener('click', () => open_modal(ancestor));
|
|
6263
|
+
list.appendChild(link);
|
|
6264
|
+
}
|
|
6265
|
+
modal.appendChild(section('Ancestry (DOM, nearest first)', list));
|
|
6266
|
+
// Instantiator - the component whose template wrote this tag. Differs from
|
|
6267
|
+
// the DOM parent for markup written in a slot body or default content.
|
|
6268
|
+
const instantiator = component.instantiator ? component.instantiator() : null;
|
|
6269
|
+
const inst = el('div', 'list');
|
|
6270
|
+
if (instantiator) {
|
|
6271
|
+
const link = el('div', 'link');
|
|
6272
|
+
link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
|
|
6273
|
+
link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
|
|
6274
|
+
link.addEventListener('click', () => open_modal(instantiator));
|
|
6275
|
+
inst.appendChild(link);
|
|
6276
|
+
if (ancestors[0] && instantiator !== ancestors[0]) {
|
|
6277
|
+
inst.appendChild(el('div', 'note', 'Written in this component\'s template but rendered inside a different parent (slot or default content): handlers and $sid bind here.'));
|
|
6278
|
+
}
|
|
5541
6279
|
}
|
|
5542
|
-
|
|
6280
|
+
else {
|
|
6281
|
+
inst.appendChild(el('div', 'empty', 'none - created directly'));
|
|
6282
|
+
}
|
|
6283
|
+
modal.appendChild(section('Instantiator', inst));
|
|
6284
|
+
modal.classList.add(`${PREFIX}modal-open`);
|
|
6285
|
+
}
|
|
6286
|
+
// ---------------------------------------------------------------------------
|
|
6287
|
+
// DOM helpers (shadow side)
|
|
6288
|
+
// ---------------------------------------------------------------------------
|
|
6289
|
+
function el(tag, classes, text) {
|
|
6290
|
+
const node = document.createElement(tag);
|
|
6291
|
+
node.className = classes.split(' ').map((c) => (c.startsWith(PREFIX) ? c : PREFIX + c)).join(' ');
|
|
6292
|
+
if (text !== undefined)
|
|
6293
|
+
node.textContent = text;
|
|
6294
|
+
return node;
|
|
6295
|
+
}
|
|
6296
|
+
function section(heading, body) {
|
|
6297
|
+
const wrap = el('div', 'section');
|
|
6298
|
+
wrap.appendChild(el('div', 'h', heading));
|
|
6299
|
+
wrap.appendChild(body);
|
|
6300
|
+
return wrap;
|
|
6301
|
+
}
|
|
6302
|
+
function table() {
|
|
6303
|
+
return el('div', 'table');
|
|
6304
|
+
}
|
|
6305
|
+
function row(t, key, type, value) {
|
|
6306
|
+
t.appendChild(el('span', 'key', key));
|
|
6307
|
+
t.appendChild(el('span', 'type', type));
|
|
6308
|
+
t.appendChild(el('span', 'value', value));
|
|
6309
|
+
}
|
|
6310
|
+
function object_table(source) {
|
|
6311
|
+
const entries = source && typeof source === 'object' ? Object.entries(source) : [];
|
|
6312
|
+
if (!entries.length)
|
|
6313
|
+
return el('div', 'empty', 'empty');
|
|
6314
|
+
const t = table();
|
|
6315
|
+
for (const [key, value] of entries)
|
|
6316
|
+
row(t, key, type_of(value), preview(value));
|
|
6317
|
+
return t;
|
|
6318
|
+
}
|
|
6319
|
+
function type_of(value) {
|
|
6320
|
+
if (value === null)
|
|
6321
|
+
return 'null';
|
|
6322
|
+
if (Array.isArray(value))
|
|
6323
|
+
return `array(${value.length})`;
|
|
6324
|
+
if (value instanceof Jqhtml_Component)
|
|
6325
|
+
return 'component';
|
|
6326
|
+
if (value && value.jquery)
|
|
6327
|
+
return `jQuery(${value.length})`;
|
|
6328
|
+
if (typeof value === 'object' && value instanceof Element)
|
|
6329
|
+
return 'element';
|
|
6330
|
+
return typeof value;
|
|
6331
|
+
}
|
|
6332
|
+
function preview(value) {
|
|
6333
|
+
if (value === undefined)
|
|
6334
|
+
return 'undefined';
|
|
6335
|
+
if (value === null)
|
|
6336
|
+
return 'null';
|
|
6337
|
+
if (typeof value === 'string')
|
|
6338
|
+
return JSON.stringify(truncate(value, 200));
|
|
6339
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint')
|
|
6340
|
+
return String(value);
|
|
6341
|
+
if (typeof value === 'function')
|
|
6342
|
+
return `ƒ ${value.name || 'anonymous'}()`;
|
|
6343
|
+
if (value instanceof Jqhtml_Component)
|
|
6344
|
+
return `<${value.component_name()}> ${value._cid}`;
|
|
6345
|
+
if (value.jquery)
|
|
6346
|
+
return value.length ? describe_element(value[0]) : 'empty';
|
|
6347
|
+
if (value instanceof Element)
|
|
6348
|
+
return describe_element(value);
|
|
5543
6349
|
try {
|
|
5544
|
-
return
|
|
5545
|
-
const { data, args, $, emit, $sid } = this;
|
|
5546
|
-
${expression}
|
|
5547
|
-
`).bind(component);
|
|
6350
|
+
return truncate(JSON.stringify(value), 200);
|
|
5548
6351
|
}
|
|
5549
|
-
catch
|
|
5550
|
-
|
|
5551
|
-
return null;
|
|
6352
|
+
catch {
|
|
6353
|
+
return `{${Object.keys(value).join(', ')}}`;
|
|
5552
6354
|
}
|
|
5553
6355
|
}
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
const div = document.createElement('div');
|
|
5559
|
-
div.textContent = str;
|
|
5560
|
-
return div.innerHTML;
|
|
6356
|
+
function describe_element(element) {
|
|
6357
|
+
const id = element.id ? `#${element.id}` : '';
|
|
6358
|
+
const classes = element.classList.length ? `.${Array.from(element.classList).join('.')}` : '';
|
|
6359
|
+
return `${element.tagName.toLowerCase()}${id}${classes}`;
|
|
5561
6360
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
* Used by <%br= %> syntax for displaying user-entered text with line breaks
|
|
5565
|
-
*/
|
|
5566
|
-
function escape_html_nl2br(str) {
|
|
5567
|
-
const div = document.createElement('div');
|
|
5568
|
-
div.textContent = str;
|
|
5569
|
-
// Escape HTML first, then replace newlines with <br />
|
|
5570
|
-
return div.innerHTML.replace(/\n/g, '<br />');
|
|
6361
|
+
function truncate(text, max) {
|
|
6362
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
5571
6363
|
}
|
|
5572
6364
|
|
|
5573
6365
|
/**
|
|
@@ -5727,13 +6519,12 @@ function init_jquery_plugin(jQuery) {
|
|
|
5727
6519
|
const _jqhtml_original_jquery = jQuery;
|
|
5728
6520
|
// Override jQuery constructor to handle component instances
|
|
5729
6521
|
const JQueryWithComponentSupport = function (selector, context) {
|
|
5730
|
-
//
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
// 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) {
|
|
5737
6528
|
return selector.$;
|
|
5738
6529
|
}
|
|
5739
6530
|
// Otherwise, call original jQuery constructor
|
|
@@ -5809,13 +6600,12 @@ function init_jquery_plugin(jQuery) {
|
|
|
5809
6600
|
// Check if component already exists on this element
|
|
5810
6601
|
const existingComponent = element.data('_component');
|
|
5811
6602
|
if (existingComponent) {
|
|
5812
|
-
// Stop existing component (
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
}
|
|
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();
|
|
5819
6609
|
// Remove component classes (any class starting with a capital letter, or a
|
|
5820
6610
|
// component name with the reserved underscore prefix - except BEM classes)
|
|
5821
6611
|
const classes = element.attr('class');
|
|
@@ -5831,7 +6621,15 @@ function init_jquery_plugin(jQuery) {
|
|
|
5831
6621
|
const capital_first = cls[0] === cls[0].toUpperCase() && cls[0] !== cls[0].toLowerCase();
|
|
5832
6622
|
return !capital_first && !is_component_name(cls);
|
|
5833
6623
|
});
|
|
5834
|
-
|
|
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
|
+
}
|
|
5835
6633
|
}
|
|
5836
6634
|
// Remove component data
|
|
5837
6635
|
element.removeData('_component');
|
|
@@ -5858,8 +6656,11 @@ function init_jquery_plugin(jQuery) {
|
|
|
5858
6656
|
}
|
|
5859
6657
|
}
|
|
5860
6658
|
else {
|
|
5861
|
-
// Direct class reference
|
|
6659
|
+
// Direct class reference. The invocation name is the class's own; it is
|
|
6660
|
+
// recorded for component_name() but does not drive the template tag
|
|
6661
|
+
// check below, which is for named (template-backed) invocations.
|
|
5862
6662
|
ComponentClass = componentOrName;
|
|
6663
|
+
args = { ...args, _component_name: componentOrName.component_name || componentOrName.name };
|
|
5863
6664
|
}
|
|
5864
6665
|
// Check if element tag matches expected tag from template
|
|
5865
6666
|
let targetElement = element;
|
|
@@ -5869,18 +6670,35 @@ function init_jquery_plugin(jQuery) {
|
|
|
5869
6670
|
const expectedTag = args._tag || template.tag || 'div';
|
|
5870
6671
|
const currentTag = element.prop('tagName').toLowerCase();
|
|
5871
6672
|
if (currentTag !== expectedTag.toLowerCase()) {
|
|
5872
|
-
// Tag mismatch
|
|
5873
|
-
|
|
5874
|
-
|
|
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) {
|
|
5875
6685
|
const newElement = jQuery(`<${expectedTag}></${expectedTag}>`);
|
|
5876
6686
|
// Copy all attributes from old element to new element
|
|
5877
|
-
const oldEl = element[0];
|
|
5878
6687
|
if (oldEl && oldEl.attributes) {
|
|
5879
6688
|
for (let i = 0; i < oldEl.attributes.length; i++) {
|
|
5880
6689
|
const attr = oldEl.attributes[i];
|
|
5881
6690
|
newElement.attr(attr.name, attr.value);
|
|
5882
6691
|
}
|
|
5883
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
|
+
}
|
|
5884
6702
|
// Copy innerHTML
|
|
5885
6703
|
newElement.html(element.html());
|
|
5886
6704
|
// Replace in DOM
|
|
@@ -6195,12 +7013,14 @@ function init(jQuery, config) {
|
|
|
6195
7013
|
}
|
|
6196
7014
|
}
|
|
6197
7015
|
// Version - will be replaced during build with actual version from package.json
|
|
6198
|
-
const version = '2.3.
|
|
7016
|
+
const version = '2.3.68';
|
|
6199
7017
|
// Default export with all functionality
|
|
6200
7018
|
const jqhtml = {
|
|
6201
7019
|
// Core
|
|
6202
7020
|
Jqhtml_Component,
|
|
6203
7021
|
LifecycleManager,
|
|
7022
|
+
// Internal - exposed for debugging and for tests that inspect coordination state
|
|
7023
|
+
Load_Coordinator,
|
|
6204
7024
|
// Registry
|
|
6205
7025
|
register,
|
|
6206
7026
|
register_component,
|
|
@@ -6216,7 +7036,6 @@ const jqhtml = {
|
|
|
6216
7036
|
// Template system
|
|
6217
7037
|
process_instructions,
|
|
6218
7038
|
extract_slots,
|
|
6219
|
-
render_template,
|
|
6220
7039
|
escape_html,
|
|
6221
7040
|
escape_html_nl2br,
|
|
6222
7041
|
// Version property - internal
|
|
@@ -6226,6 +7045,14 @@ const jqhtml = {
|
|
|
6226
7045
|
// Integration configuration - see config.ts
|
|
6227
7046
|
configure,
|
|
6228
7047
|
get_config,
|
|
7048
|
+
// Value printers: how <%= %> renders an object (see value-printers.ts).
|
|
7049
|
+
// print_object and dynamic_component_name are called by compiled templates.
|
|
7050
|
+
add_object_printer,
|
|
7051
|
+
print_object,
|
|
7052
|
+
dynamic_component_name,
|
|
7053
|
+
// Component debug overlay: hover outlines + click-to-inspect modal.
|
|
7054
|
+
// jqhtml.debug_overlay.enable() / .disable() / .is_enabled() / .inspect(x)
|
|
7055
|
+
debug_overlay,
|
|
6229
7056
|
// Debug settings
|
|
6230
7057
|
debug: {
|
|
6231
7058
|
enabled: false,
|
|
@@ -6338,13 +7165,16 @@ exports.Jqhtml_LifecycleManager = LifecycleManager;
|
|
|
6338
7165
|
exports.Jqhtml_Local_Storage = Jqhtml_Local_Storage;
|
|
6339
7166
|
exports.LifecycleManager = LifecycleManager;
|
|
6340
7167
|
exports.Load_Coordinator = Load_Coordinator;
|
|
7168
|
+
exports.add_object_printer = add_object_printer;
|
|
6341
7169
|
exports.applyDebugDelay = applyDebugDelay;
|
|
6342
7170
|
exports.boot = boot;
|
|
6343
7171
|
exports.clear_preload_data = clear_preload_data;
|
|
6344
7172
|
exports.configure = configure;
|
|
6345
7173
|
exports.create_component = create_component;
|
|
7174
|
+
exports.debug_overlay = debug_overlay;
|
|
6346
7175
|
exports.default = jqhtml;
|
|
6347
7176
|
exports.devWarn = devWarn;
|
|
7177
|
+
exports.dynamic_component_name = dynamic_component_name;
|
|
6348
7178
|
exports.escape_html = escape_html;
|
|
6349
7179
|
exports.escape_html_nl2br = escape_html_nl2br;
|
|
6350
7180
|
exports.extract_slots = extract_slots;
|
|
@@ -6352,6 +7182,7 @@ exports.get_captured_data = get_captured_data;
|
|
|
6352
7182
|
exports.get_component_class = get_component_class;
|
|
6353
7183
|
exports.get_component_names = get_component_names;
|
|
6354
7184
|
exports.get_config = get_config;
|
|
7185
|
+
exports.get_object_printers = get_object_printers;
|
|
6355
7186
|
exports.get_registered_templates = get_registered_templates;
|
|
6356
7187
|
exports.get_template = get_template;
|
|
6357
7188
|
exports.get_template_by_class = get_template_by_class;
|
|
@@ -6366,12 +7197,12 @@ exports.logDataChange = logDataChange;
|
|
|
6366
7197
|
exports.logDispatch = logDispatch;
|
|
6367
7198
|
exports.logInstruction = logInstruction;
|
|
6368
7199
|
exports.logLifecycle = logLifecycle;
|
|
7200
|
+
exports.print_object = print_object;
|
|
6369
7201
|
exports.process_instructions = process_instructions;
|
|
6370
7202
|
exports.register = register;
|
|
6371
7203
|
exports.register_cache_class = register_cache_class;
|
|
6372
7204
|
exports.register_component = register_component;
|
|
6373
7205
|
exports.register_template = register_template;
|
|
6374
|
-
exports.render_template = render_template;
|
|
6375
7206
|
exports.set_preload_data = set_preload_data;
|
|
6376
7207
|
exports.start_data_capture = start_data_capture;
|
|
6377
7208
|
exports.stop_data_capture = stop_data_capture;
|