Rename after_load to on_loaded, add load_children option to load_detached_action, update npm

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-03-06 23:14:25 +00:00
parent d1ac456279
commit 198cd42ce1
26 changed files with 1024 additions and 145 deletions

View File

@@ -1087,6 +1087,10 @@ class Spa {
* These are merged with URL-extracted args (extra_args take precedence).
* Pass {use_cached_data: true} to have the action load with cached data
* without revalidation if cached data is available.
* @param {object} options - Optional behavior options
* @param {boolean} options.load_children - If true, renders the full component tree
* so all children (datagrids, views, etc.) also run their on_load(). Uses
* _load_render_only instead of _load_only. Useful for preloading/warming cache.
* @returns {Promise<Spa_Action|null>} The fully-loaded action instance, or null if route not found
*
* @example
@@ -1101,8 +1105,13 @@ class Spa {
* @example
* // With cached data (faster, no network request if cached)
* const action = await Spa.load_detached_action('/contacts/123', {use_cached_data: true});
*
* @example
* // Preload with children (warms cache for entire page tree)
* const action = await Spa.load_detached_action('/contacts/123', {}, {load_children: true});
* action.stop();
*/
static async load_detached_action(url, extra_args = {}) {
static async load_detached_action(url, extra_args = {}, options = {}) {
// Parse URL and match to route
const parsed = Spa.parse_url(url);
const url_without_hash = parsed.path + parsed.search;
@@ -1116,12 +1125,14 @@ class Spa {
const action_class = route_match.action_class;
const action_name = action_class.name;
// Merge URL args with extra_args (extra_args take precedence)
// Include _load_only to skip render/on_ready (detached, no DOM needed)
// _load_only: action on_load() only, no children
// _load_render_only: renders full tree, all children run on_load() too
const lifecycle_flag = options.load_children ? '_load_render_only' : '_load_only';
const args = {
...route_match.args,
...extra_args,
_load_only: true
[lifecycle_flag]: true
};
console_debug('Spa', `load_detached_action: Loading ${action_name} with args:`, args);

View File

@@ -0,0 +1,844 @@
PLAYWRIGHT_SPA(7) RSX Framework PLAYWRIGHT_SPA(7)
NAME
custom_playwright_tests_spa - writing custom Playwright tests for
SPA applications with jqhtml component lifecycle awareness
SYNOPSIS
node bin/my-test.js [auth-token]
Where auth-token is generated by the PHP helper:
php -r "
foreach (file('.env') as \$line) {
if (strpos(\$line, 'APP_KEY=') === 0) {
\$app_key = trim(substr(\$line, 8));
break;
}
}
\$payload = json_encode([
'url' => '/initial-route',
'user_id' => 1,
'portal' => false
]);
echo hash_hmac('sha256', \$payload, \$app_key);
"
DESCRIPTION
rsx:debug is designed for single-page-load diagnostics. For tests
that require multi-step SPA navigation (e.g., verifying cache
behavior across page transitions, testing component state
persistence), you need a custom Playwright script.
This document covers the patterns required to write reliable
Playwright tests against an RSX SPA application, including
authentication, SPA navigation, and observing jqhtml component
lifecycle events like loading spinners and data rendering.
AUTHENTICATION
RSX uses backdoor header-based auth in development. The same
mechanism that powers rsx:debug works for custom scripts.
Required Headers
X-Dev-Auth-User-Id User ID to authenticate as
X-Dev-Auth-Token HMAC-SHA256 signature (see SYNOPSIS)
X-Playwright-Test Set to '1' for plain-text error responses
CRITICAL: Auth Headers Only on First Document Request
Auth headers must ONLY be sent on the initial document request,
NOT on every request. Sending auth headers on Ajax/XHR calls
causes RsxAuth::login() to run repeatedly, which regenerates the
session on each request. This makes Rsx.validate_session() detect
a session_hash mismatch and trigger a full page reload via
location.replace(), breaking SPA navigation entirely.
After the initial document request sets a session cookie, all
subsequent requests authenticate via that cookie automatically.
let initial_doc_sent = false;
await page.route('**/*', async (route, request) => {
const url = request.url();
if (url.startsWith(BASE_URL)) {
const headers = { ...request.headers() };
if (!initial_doc_sent
&& request.resourceType() === 'document') {
Object.assign(headers, authHeaders);
initial_doc_sent = true;
} else {
headers['X-Playwright-Test'] = '1';
}
await route.continue({ headers });
} else {
await route.continue();
}
});
The X-Playwright-Test header is safe to send on every request
(it only affects error response formatting). The auth headers
(X-Dev-Auth-User-Id, X-Dev-Auth-Token) must be restricted.
Token Scope
The HMAC token is signed against a specific URL. For SPA tests,
only the initial page.goto() URL matters -- all subsequent SPA
navigations happen client-side and do not trigger new page loads.
Generate the token for the first URL you navigate to.
SESSION VALIDATION
After initial page load, RSX runs Rsx.validate_session() on
every SPA dispatch. This Ajax call compares the server's current
session_hash with the hash stored in window.rsxapp.session_hash.
If they differ, RSX does location.replace() to force a full
page reload.
In test environments using dev auth headers, the session
established by the header-based login may have a different hash
than what validate_session returns. This is a test environment
artifact -- in production, sessions are stable.
Patching validate_session
After the initial page loads and the SPA is ready, patch
validate_session to prevent false-positive reloads:
// After initial page.goto() and wait_for_rsx_ready()
await page.evaluate(() => {
Rsx.validate_session = async () => true;
});
This must happen AFTER the initial page is fully loaded (after
_debug_ready fires) but BEFORE any SPA dispatch calls.
Detecting Full Page Reloads
Track document requests after the initial load to catch
unexpected reloads caused by validate_session or other issues:
let page_reload_count = 0;
page.on('request', req => {
if (req.resourceType() === 'document'
&& initial_doc_sent) {
page_reload_count++;
console.log(`!! UNEXPECTED RELOAD: ${req.url()}`);
}
});
SPA NAVIGATION
RSX SPA applications use client-side routing via Spa.dispatch().
Understanding the difference between browser navigation and SPA
navigation is critical for writing correct tests.
Browser Navigation (page.goto)
Use ONLY for the initial page load:
await page.goto(`${BASE_URL}/contacts`, {
waitUntil: 'commit'
});
Use 'commit' rather than 'networkidle' -- the SPA will fire many
async requests during component lifecycle. Use RSX lifecycle
events (see LIFECYCLE EVENTS below) for precise ready detection.
SPA Navigation (Spa.dispatch)
For all subsequent navigation, use Spa.dispatch() directly:
await page.evaluate((path) => {
Spa.dispatch(path);
}, '/engagements');
Do NOT click sidebar links with page.evaluate + element.click().
The jQuery .click() override that prevents default and triggers
SPA dispatch may not fire correctly from Playwright's evaluation
context. Calling Spa.dispatch() directly is deterministic.
Context Destruction
SPA navigation replaces the DOM content area. Playwright's
execution context can be destroyed during this process. Any
page.evaluate() call during a SPA transition may throw:
Error: Execution context was destroyed
This is expected. Wrap evaluate calls in try/catch if they run
during or immediately after SPA navigation. The addInitScript
approach (see below) survives these transitions.
LIFECYCLE EVENTS
RSX provides two key events for determining when the application
is ready. These replace arbitrary timeouts with precise signals.
_debug_ready (Initial Page Load)
Fires once after ALL framework init phases complete, including
SPA action dispatch. This is the definitive "page is fully
loaded" signal for the initial page.goto():
async function wait_for_rsx_ready() {
await page.evaluate(() => {
return new Promise((resolve) => {
if (window.Rsx && window.Rsx.on) {
Rsx.on('_debug_ready', () => resolve());
} else {
// Fallback for non-RSX pages
document.addEventListener(
'DOMContentLoaded',
() => setTimeout(resolve, 200)
);
}
});
});
}
The _debug_ready event fires after these phases complete:
1. framework_init (framework bootstrap)
2. modules_init (manifest, SPA router setup)
3. app_init (application module init)
4. _spa_init (Spa.dispatch() -- awaits full action lifecycle)
5. app_ready (final ready hooks)
Because _spa_init awaits the SPA action's full lifecycle
(including on_load data fetching and on_ready), _debug_ready
guarantees the first action is fully rendered with data.
spa_dispatch_ready (SPA Navigation)
Fires after each SPA dispatch completes (action's on_ready
has fired and all children are ready):
async function spa_navigate(path) {
// Set up ready listener BEFORE dispatching
const ready = page.evaluate(() => {
return new Promise((resolve) => {
Rsx.on('spa_dispatch_ready', () => resolve());
});
});
// Dispatch navigation
try {
await page.evaluate((p) => {
Spa.dispatch(p);
}, path);
} catch(e) {}
// Wait for action to fully load
await ready;
// Optional: wait for dynamic height recalculation
await page.waitForTimeout(500);
}
IMPORTANT: Register the spa_dispatch_ready listener BEFORE
calling Spa.dispatch(). The promise must be waiting before
the event fires, or it will be missed.
Dynamic Height Settle Time
DataGrids with $rows="dynamic" recalculate per_page from
viewport height with a 200ms debounce after render. Add a
500ms wait after the ready event to allow this recalculation
to complete:
const DYNAMIC_HEIGHT_SETTLE = 500;
await ready_promise;
await page.waitForTimeout(DYNAMIC_HEIGHT_SETTLE);
OBSERVING STATE
jqhtml components have async lifecycles: on_create (sync) ->
render (sync) -> on_load (async) -> on_ready (async). A DataGrid
may show a loading spinner for 50-500ms before data appears.
Point-in-time checks miss these transitions.
The Wrong Way: Point-in-Time Checks
This misses fast transitions:
// BAD: spinner may appear and disappear between these checks
await page.waitForTimeout(500);
const visible = await page.evaluate(() => {
return !!document.querySelector('.spinner.is-visible');
});
The Right Way: addInitScript with Polling Observer
Inject a persistent polling loop via addInitScript. This runs on
every page load (including the initial one), survives SPA
navigations that destroy the execution context, and captures
every state transition:
await page.addInitScript(() => {
let _last_state = null;
let _step = 'init';
let _step_start = Date.now();
// Allow the test to label steps
window.__test_set_step = function(step) {
_step = step;
_step_start = Date.now();
_last_state = null;
console.log(`[TEST] === ${step} === (t=0)`);
};
// Poll every 10ms, log transitions via console
setInterval(() => {
const spinner = !!document.querySelector(
'.datagrid-loading-overlay.is-visible'
);
const rows = document.querySelectorAll(
'.DataGrid_Table tr:not(.placeholder-row)' +
':not(.empty-row):not(.loading-row)'
).length;
const key = `s=${spinner},r=${rows}`;
if (key !== _last_state) {
const elapsed = Date.now() - _step_start;
const label = spinner ? 'SPINNER'
: (rows > 0 ? `DATA(${rows})` : 'EMPTY');
console.log(`[TEST] ${elapsed}ms: ${label}`);
_last_state = key;
}
}, 10);
});
Capture these logs from the Playwright side:
const state_log = [];
page.on('console', msg => {
const text = msg.text();
if (text.startsWith('[TEST]')) {
state_log.push(text);
}
});
Setting Steps
Label each phase of your test so the log is readable:
async function set_step(label) {
try {
await page.evaluate((l) => {
if (window.__test_set_step) {
window.__test_set_step(l);
}
}, label);
} catch(e) {
// Context may be destroyed during SPA nav
}
}
Interpreting Output
A healthy cached SPA navigation looks like:
[TEST] === STEP 3 === (t=0)
[TEST] 30ms: DATA(17) <-- instant from cache
A broken/uncached navigation looks like:
[TEST] === STEP 3 === (t=0)
[TEST] 30ms: EMPTY
[TEST] 150ms: SPINNER
[TEST] 800ms: DATA(10) <-- had to fetch from server
[TEST] 1200ms: DATA(17) <-- dynamic resize reload
TRACKING XHR CALLS
Monitor Ajax requests to understand how many server round-trips
a navigation triggers:
let xhr_count = 0;
page.on('request', req => {
if (req.url().includes('datagrid_fetch')) xhr_count++;
});
// Reset before each step
xhr_count = 0;
await spa_navigate('/contacts');
console.log(`XHR calls: ${xhr_count}`);
This is essential for diagnosing redundant loads. A well-cached
SPA return navigation should trigger 1 XHR (background
revalidation) not 2-4.
VIEWPORT AND DYNAMIC SIZING
DataGrids in dynamic row mode calculate per_page from viewport
height. Inconsistent viewport sizes between test runs produce
different per_page values, which produce different cache keys,
which break cache hit expectations.
Always set an explicit viewport:
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1920, height: 1080 }
});
FULL EXAMPLE
A complete, runnable SPA navigation test. Copy and customize
the route paths, observer selectors, and step sequence for
your specific test scenario.
See also: bin/test-datagrid-cache.js (working reference)
#!/usr/bin/env node
/**
* SPA Navigation Test Template
*
* Tests multi-step SPA navigation with component state
* observation. Customize ROUTE_A, ROUTE_B, and the
* addInitScript observer for your specific scenario.
*
* AUTH: Headers only on first document request.
* WAIT: RSX lifecycle events (_debug_ready,
* spa_dispatch_ready) instead of arbitrary timeouts.
*/
const { chromium } = require(
'/var/www/html/system/node_modules/playwright'
);
const BASE_URL = 'https://myapp.dev.example.com';
const USER_ID = '1';
const TOKEN = process.argv[2] || null;
const ROUTE_A = '/contacts'; // Primary route to test
const ROUTE_B = '/engagements'; // Route to navigate away to
const DYNAMIC_HEIGHT_SETTLE = 500;
async function run() {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1920, height: 1080 }
});
const page = await context.newPage();
// -------------------------------------------------
// AUTH: headers only on first document request
// -------------------------------------------------
// Sending auth headers on every request causes
// RsxAuth::login() to regenerate the session,
// breaking validate_session(). After the first
// document response sets a cookie, all subsequent
// requests authenticate via that cookie.
const authHeaders = {
'X-Dev-Auth-User-Id': USER_ID,
'X-Playwright-Test': '1'
};
if (TOKEN) {
authHeaders['X-Dev-Auth-Token'] = TOKEN;
}
let initial_doc_sent = false;
await page.route('**/*', async (route, request) => {
const url = request.url();
if (url.startsWith(BASE_URL)) {
const headers = { ...request.headers() };
if (!initial_doc_sent
&& request.resourceType() === 'document'
) {
Object.assign(headers, authHeaders);
initial_doc_sent = true;
} else {
headers['X-Playwright-Test'] = '1';
}
await route.continue({ headers });
} else {
await route.continue();
}
});
// -------------------------------------------------
// Console log capture
// -------------------------------------------------
const state_log = [];
page.on('console', msg => {
const text = msg.text();
if (text.startsWith('[TEST]')) {
state_log.push(text);
}
});
// -------------------------------------------------
// Persistent observer via addInitScript
// -------------------------------------------------
// Survives SPA navigations. Polls every 10ms and
// logs state transitions via console. Customize the
// selectors for your component under test.
await page.addInitScript(() => {
let _last_state = null;
let _step_start = Date.now();
window.__test_set_step = function(step) {
_step_start = Date.now();
_last_state = null;
console.log(
`[TEST] === ${step} === (t=0)`
);
};
setInterval(() => {
// -- Customize these selectors --
const spinner = !!document.querySelector(
'.datagrid-loading-overlay.is-visible'
);
const tbody = document.querySelector(
'.DataGrid_Table'
);
let data_rows = 0;
if (tbody) {
data_rows = tbody.querySelectorAll(
'tr:not(.placeholder-row)' +
':not(.empty-row)' +
':not(.loading-row)'
).length;
}
// -- End customizable selectors --
const key = `s=${spinner},r=${data_rows}`;
if (key !== _last_state) {
const ms = Date.now() - _step_start;
const label = spinner
? 'SPINNER'
: (data_rows > 0
? `DATA(${data_rows})`
: 'EMPTY');
console.log(
`[TEST] ${String(ms).padStart(5)}`
+ `ms: ${label}`
);
_last_state = key;
}
}, 10);
});
// -------------------------------------------------
// XHR tracking
// -------------------------------------------------
let xhr_count = 0;
page.on('request', req => {
if (req.url().includes('datagrid_fetch')) {
xhr_count++;
}
});
// -------------------------------------------------
// Full page reload detection
// -------------------------------------------------
// Any document request after initial load means
// something broke SPA navigation (likely
// validate_session or auth header issue).
let reload_count = 0;
page.on('request', req => {
if (req.resourceType() === 'document'
&& initial_doc_sent
) {
reload_count++;
console.log(
` !! UNEXPECTED RELOAD: ${req.url()}`
);
}
});
// -------------------------------------------------
// Helpers
// -------------------------------------------------
// Label test steps for the observer log
async function set_step(label) {
try {
await page.evaluate((l) => {
if (window.__test_set_step) {
window.__test_set_step(l);
}
}, label);
} catch(e) {}
}
// Print and reset the observer log
function flush_log() {
console.log(state_log.join('\n'));
state_log.length = 0;
}
// Wait for RSX _debug_ready (initial page load)
async function wait_for_rsx_ready() {
await page.evaluate(() => {
return new Promise((resolve) => {
if (window.Rsx && window.Rsx.on) {
Rsx.on('_debug_ready',
() => resolve());
} else {
if (document.readyState
=== 'complete'
|| document.readyState
=== 'interactive'
) {
setTimeout(resolve, 200);
} else {
document.addEventListener(
'DOMContentLoaded',
() => setTimeout(
resolve, 200
)
);
}
}
});
});
}
// SPA navigate and wait for action ready
async function spa_navigate(path) {
xhr_count = 0;
// Listen BEFORE dispatching
const ready = page.evaluate(() => {
return new Promise((resolve) => {
Rsx.on('spa_dispatch_ready',
() => resolve());
});
});
try {
await page.evaluate((p) => {
Spa.dispatch(p);
}, path);
} catch(e) {}
await ready;
await page.waitForTimeout(
DYNAMIC_HEIGHT_SETTLE
);
}
// -------------------------------------------------
// Test steps
// -------------------------------------------------
console.log('');
console.log('=== SPA Navigation Test ===');
console.log(`Viewport: 1920x1080`);
console.log('');
// STEP 1: Cold start — first visit to ROUTE_A
console.log(
`--- STEP 1: First visit to ${ROUTE_A}`
+ ` (cold start) ---`
);
state_log.length = 0;
xhr_count = 0;
await set_step('STEP 1');
await page.goto(`${BASE_URL}${ROUTE_A}`, {
waitUntil: 'commit'
});
await wait_for_rsx_ready();
await page.waitForTimeout(DYNAMIC_HEIGHT_SETTLE);
await page.waitForLoadState('networkidle')
.catch(() => {});
// Patch validate_session after initial load.
// Dev auth sessions have a different hash than
// what validate_session returns — test artifact.
await page.evaluate(() => {
Rsx.validate_session = async () => true;
});
const s1_xhrs = xhr_count;
flush_log();
console.log(` XHR count: ${s1_xhrs}`);
console.log('');
// STEP 2: Navigate away to ROUTE_B
console.log(
`--- STEP 2: SPA navigate to`
+ ` ${ROUTE_B} ---`
);
await set_step('STEP 2');
await spa_navigate(ROUTE_B);
flush_log();
console.log('');
// STEP 3: Return to ROUTE_A (2nd visit)
console.log(
`--- STEP 3: SPA back to ${ROUTE_A}`
+ ` (2nd visit) ---`
);
xhr_count = 0;
await set_step('STEP 3');
await spa_navigate(ROUTE_A);
const s3_xhrs = xhr_count;
flush_log();
console.log(` XHR count: ${s3_xhrs}`);
console.log('');
// STEP 4: Navigate away again
console.log(
`--- STEP 4: SPA navigate to`
+ ` ${ROUTE_B} ---`
);
await set_step('STEP 4');
await spa_navigate(ROUTE_B);
flush_log();
console.log('');
// STEP 5: Return to ROUTE_A (3rd visit)
console.log(
`--- STEP 5: SPA back to ${ROUTE_A}`
+ ` (3rd visit) ---`
);
xhr_count = 0;
await set_step('STEP 5');
await spa_navigate(ROUTE_A);
const s5_xhrs = xhr_count;
flush_log();
console.log(` XHR count: ${s5_xhrs}`);
console.log('');
// -------------------------------------------------
// Summary
// -------------------------------------------------
console.log('=== SUMMARY ===');
console.log(
`Step 1 (cold): ${s1_xhrs} XHRs`
);
console.log(
`Step 3 (2nd visit): ${s3_xhrs} XHRs`
);
console.log(
`Step 5 (3rd visit): ${s5_xhrs} XHRs`
);
console.log(
`Page reloads: ${reload_count}`
+ ` (should be 0)`
);
console.log('');
if (reload_count > 0) {
console.log(
'FAIL: Full page reloads detected'
+ ' during SPA navigation.'
);
} else {
console.log(
'OK: No full page reloads'
+ ' — true SPA navigation achieved.'
);
}
console.log('');
await browser.close();
}
run().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
COMMON PITFALLS
Auth Headers on Every Request (Session Regeneration)
The most critical pitfall. Sending X-Dev-Auth-User-Id on every
request (not just the first document request) causes
RsxAuth::login() to regenerate the session on each hit. This
changes the session_hash, which Rsx.validate_session() detects
as a mismatch, triggering location.replace() -- a full page
reload that destroys SPA state.
Symptom: "Execution context was destroyed" errors, window
markers disappearing, document request count incrementing
after SPA dispatch calls.
Fix: Auth headers on first document request only. All
subsequent requests use the session cookie set by that first
response. See AUTHENTICATION section.
validate_session Causing Full Page Reloads
Even with correct auth header scoping, dev-auth-established
sessions may have a hash mismatch with what validate_session
returns. This is a test environment artifact.
Symptom: SPA dispatch triggers a full page reload despite
auth headers being correctly scoped.
Fix: Patch Rsx.validate_session after initial load. See
SESSION VALIDATION section.
Playwright Require Path
Playwright is installed in system/node_modules/, not in the
project root. If your script is outside system/, use an absolute
require path:
require('/var/www/html/system/node_modules/playwright')
SPA Navigation via Link Clicks
Do not use element.click() on sidebar links from page.evaluate().
jQuery's .click() override may not fire correctly through
Playwright's evaluation bridge. Use Spa.dispatch() directly.
Checking Visibility by DOM Presence
Many RSX components (loading overlays, spinners) are always in
the DOM. They toggle visibility via CSS classes like 'is-visible'
or opacity changes. Check for the class, not the element:
// WRONG: element always exists
!!document.querySelector('.datagrid-loading-overlay')
// RIGHT: check for the visibility class
!!document.querySelector('.datagrid-loading-overlay.is-visible')
Execution Context Destroyed
Any page.evaluate() during SPA navigation can throw
"Execution context was destroyed". This is normal -- the SPA
replaced the DOM. Use try/catch or the addInitScript approach
which is immune to context destruction.
addInitScript Reruns
addInitScript runs on every NEW page load, not on SPA navigation.
If you see your observer's timer reset to 0 mid-step, it means
a full page reload occurred (not an SPA transition). This is a
useful diagnostic signal -- SPA transitions should NOT cause
addInitScript to re-run.
Dynamic Row Recalculation
DataGrids with $rows="dynamic" measure the viewport after first
render and may change per_page, triggering a second load. This
is normal on first visit but should be cached on return visits.
Expect to see two data states (e.g., DATA(10) then DATA(17))
as the grid adjusts.
Arbitrary Timeouts Instead of Lifecycle Events
Do NOT use page.waitForTimeout() or page.waitForLoadState()
as primary wait mechanisms. Use RSX lifecycle events:
- _debug_ready for initial page load
- spa_dispatch_ready for SPA navigations
Only use waitForTimeout for the dynamic height settle period
(500ms after ready event), where no lifecycle event exists.
FILES
system/bin/route-debug.js
Reference implementation for Playwright auth and single-page
testing. Source for the _debug_ready wait pattern.
system/app/RSpade/Core/Js/Rsx.js
Framework core. Contains validate_session() logic,
_debug_ready trigger, and init phase orchestration.
system/app/RSpade/Core/SPA/Spa.js
SPA router. Contains _on_spa_init() (awaited during init),
Spa.dispatch(), and spa_dispatch_ready event trigger.
system/node_modules/@jqhtml/core/dist/jqhtml-core.esm.js
jqhtml component lifecycle, caching (Jqhtml_Local_Storage),
and Load_Coordinator. Understanding _load(), _reload(), and
the cache key generation is essential for diagnosing cache
miss issues.
rsx/theme/components/datagrid/datagrid_abstract.js
DataGrid state management, dynamic row calculation, and
load_page() orchestration. The parent component that drives
DataGrid_Table reloads.
rsx/theme/components/datagrid/datagrid_table.js
The child component whose on_load() actually fetches data.
Its args (per_page, sort, filter, etc.) form the cache key.
bin/test-datagrid-cache.js
Working reference test that validates DataGrid caching
across SPA navigations. Demonstrates all patterns from
this document.
SEE ALSO
rsx_debug, spa, jqhtml, caching
RSX Framework March 2026 PLAYWRIGHT_SPA(7)

View File

@@ -502,7 +502,7 @@ COMMENTS IN TEMPLATES
COMPONENT LIFECYCLE
Five-stage deterministic lifecycle:
on_create → render → on_render → on_load → after_load → on_ready
on_create → render → on_render → on_load → on_loaded → on_ready
1. on_create() (synchronous, runs BEFORE first render)
- Setup default state BEFORE template executes
@@ -535,7 +535,7 @@ COMPONENT LIFECYCLE
- If this.data changes, triggers automatic re-render
- Runtime enforces access restrictions with clear errors
5. after_load() (runs on REAL component, not detached proxy)
5. on_loaded() (runs on REAL component, not detached proxy)
- this.data is frozen (read-only)
- this.$, this.state, this.args are accessible
- Primary use case: clone this.data to this.state for widgets
@@ -561,7 +561,7 @@ COMPONENT LIFECYCLE
- on_load() runs in parallel for siblings (DOM unpredictable)
- Data changes during load trigger automatic re-render
- on_create(), on_render(), on_stop() must be synchronous
- on_load(), after_load(), and on_ready() can be async
- on_load(), on_loaded(), and on_ready() can be async
LIFECYCLE SKIP FLAGS
Two flags truncate the component lifecycle. Both cascade to children
@@ -573,7 +573,7 @@ LIFECYCLE SKIP FLAGS
Lifecycle:
on_create() → on_load() → READY
Skips: render, on_render, after_load, on_ready
Skips: render, on_render, on_loaded, on_ready
No child components are created (render never runs).
Use case: Loading data from a component without creating its UI.
@@ -589,7 +589,7 @@ LIFECYCLE SKIP FLAGS
Lifecycle:
on_create() → render → on_load() → re-render → READY
Skips: on_render, after_load, on_ready
Skips: on_render, on_loaded, on_ready
Children ARE created (render runs), and their on_load() fires too.
The full component tree is built with loaded data, but no DOM
interaction (event handlers, plugin init, layout measurement).
@@ -1353,7 +1353,7 @@ SYNCHRONOUS REQUIREMENTS
on_render() YES NO
on_stop() YES NO
on_load() NO (async allowed) YES
after_load() NO (async allowed) YES
on_loaded() NO (async allowed) YES
on_ready() NO (async allowed) YES
Framework needs predictable execution order for lifecycle coordination.

View File

@@ -931,39 +931,41 @@ COMMON PATTERNS
class Frontend_Contacts_Action extends Spa_Action { }
DETACHED ACTION LOADING
Spa.load_detached_action() loads an action without affecting the live SPA state.
The action is instantiated on a detached DOM element with the _load_only flag
(on_create + on_load only, no render/children), and returns the component
instance for inspection.
Spa.load_detached_action(url, extra_args, options) loads an action without
affecting the live SPA state. Returns a fully-loaded component instance.
Parameters:
url - URL to resolve and load
extra_args - Optional args merged with URL-extracted params
options - Optional behavior options:
load_children: false (default) Action on_load() only (_load_only)
load_children: true Full tree: render children, all on_load()s
fire (_load_render_only). Use for preloading.
Use cases:
- Extracting action metadata (titles, breadcrumbs) for navigation UI
- Pre-fetching action data before navigation
- Inspecting action state without rendering it visibly
- Preloading entire page tree to warm ORM/Ajax cache
Basic Usage:
const action = await Spa.load_detached_action('/contacts/123');
if (action) {
const title = action.get_title?.() ?? action.constructor.name;
const breadcrumbs = action.get_breadcrumbs?.();
console.log('Page title:', title);
// IMPORTANT: Clean up when done to prevent memory leaks
action.stop();
}
With Cached Data:
// Skip network request if cached data available
const action = await Spa.load_detached_action('/contacts/123', {
use_cached_data: true
});
Extra Arguments:
// Pass additional args merged with URL-extracted params
const action = await Spa.load_detached_action('/contacts/123', {
some_option: true,
use_cached_data: true
});
Preload With Children (warm full page cache):
// Renders children (datagrids, views, etc.) so their on_load()
// fires and populates the cache. No DOM hooks execute.
const action = await Spa.load_detached_action('/contacts/123',
{}, { load_children: true });
action.stop();
What It Does NOT Affect:
- Spa.action() (current live action remains unchanged)

View File

@@ -598,7 +598,7 @@ class Toggle_Button extends Component {
2. **render** → Template executes (top-down: parent before children)
3. **on_render()** → Fires after render, BEFORE children ready (top-down, sync)
4. **on_load()** → Fetch data into `this.data` (bottom-up, parallel siblings, async)
5. **after_load()** → Runs on real component (not proxy). `this.data` frozen, `this.$`/`this.state`/`this.args` accessible. Clone `this.data``this.state` for complex in-memory manipulations.
5. **on_loaded()** → Runs on real component (not proxy). `this.data` frozen, `this.$`/`this.state`/`this.args` accessible. Clone `this.data``this.state` for complex in-memory manipulations.
6. **on_ready()** → All children guaranteed ready (bottom-up, async)
7. **on_stop()** → Teardown when destroyed (sync)

24
node_modules/.package-lock.json generated vendored
View File

@@ -2224,9 +2224,9 @@
}
},
"node_modules/@jqhtml/core": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.38.tgz",
"integrity": "sha512-7yjkqgYAPuyl9bjw67nm7+NwDTR4nCuem9IHmW99SO5o/iJIuJUB9txuBQBo8l2g+pNbDFFI4wvUxJbFn3fznA==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.39.tgz",
"integrity": "sha512-qyxOBcoFCaf35etqvNOSJppqT4WQLfD9O2b8bAv5la4oSpRUmXSjVJFdv3cSMIK8qClXbupN8bm4FLbAalJqog==",
"license": "MIT",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
@@ -2250,9 +2250,9 @@
}
},
"node_modules/@jqhtml/parser": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.38.tgz",
"integrity": "sha512-kIb9u3p01FDTvQbq7LKmSBaGd5JZzJGZNL7oHQXzjSkvpL6/iTE2NXkOHI/0yiSfMR5s8tsMABnHQX8M8bzZJw==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.39.tgz",
"integrity": "sha512-DLPwZf1X7enf2lVOaFaIWlu8vQYMgk/+Lioup2w4F07oXFx2+MnFgcJ/Ie9Pf6VUnMT1IOIZQxOd/5QugwFFDA==",
"license": "MIT",
"dependencies": {
"@types/jest": "^29.5.11",
@@ -2290,9 +2290,9 @@
}
},
"node_modules/@jqhtml/ssr": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.38.tgz",
"integrity": "sha512-C0hFuzMVwAoKGGj2UvBkUqvNa2Q2Q95DYHuGMx+4cD0kCwxA2bpo5MnjcmtSQh2YX4UODKTaqmpHL+03MYOp7g==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.39.tgz",
"integrity": "sha512-//MaIub8tel8w6l3AiqvoW021Aj9JR8BlVrZsezAO7svAIgsMFTeFdLKUud1+rg8I5Nxe4DE8CiGHz+f3Ts0kA==",
"license": "MIT",
"dependencies": {
"jquery": "^3.7.1",
@@ -2386,9 +2386,9 @@
}
},
"node_modules/@jqhtml/vscode-extension": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.38.tgz",
"integrity": "sha512-/npaSwR6ibKl3z8xFlnMO1salWyzQIgs+1D7JH2QFi62o9TtIJWDhKKO/n2njc6PdUrJcDy6ElT1jYZh4eoNGg==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.39.tgz",
"integrity": "sha512-Zi0iS5/t+5IhQoZP54J1/OOFB2OdoM6TM3g37SMJmPKjIDBUt883M3POszKFJwfj8+lrBV5OeJPOmPu3m9RYOQ==",
"license": "MIT",
"engines": {
"vscode": "^1.74.0"

View File

@@ -51,6 +51,6 @@ export declare function write_html_cache_snapshot(component: any): void;
* @param component - The component instance
* @param data_changed - Whether this.data changed during on_load
*/
export declare function write_cache_after_load(component: any, data_changed: boolean): void;
export declare function write_cache_on_loaded(component: any, data_changed: boolean): void;
export {};
//# sourceMappingURL=component-cache.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"component-cache.d.ts","sourceRoot":"","sources":["../src/component-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;GAEG;AACH,UAAU,gBAAgB;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,GAAG,GAAG,gBAAgB,CAanE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,GAAG,GAAG,IAAI,CA+FzD;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAqD7D;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,GAAG,GAAG,IAAI,CA2B9D;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,GAAG,EAAE,YAAY,EAAE,OAAO,GAAG,IAAI,CA4BlF"}
{"version":3,"file":"component-cache.d.ts","sourceRoot":"","sources":["../src/component-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;GAEG;AACH,UAAU,gBAAgB;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,GAAG,GAAG,gBAAgB,CAanE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,GAAG,GAAG,IAAI,CA+FzD;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAqD7D;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,GAAG,GAAG,IAAI,CA2B9D;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,GAAG,EAAE,YAAY,EAAE,OAAO,GAAG,IAAI,CA4BjF"}

View File

@@ -52,6 +52,7 @@ export declare class Jqhtml_Component {
private _use_cached_data_hit;
private _load_only;
private _load_render_only;
_is_detached: boolean;
private _has_rendered;
private _load_queue;
private __has_custom_on_load;
@@ -310,7 +311,7 @@ export declare class Jqhtml_Component {
on_render(): void;
on_create(): void;
on_load(): void | Promise<void>;
after_load(): void | Promise<void>;
on_loaded(): void | Promise<void>;
on_ready(): Promise<void>;
on_stop(): void;
/**

View File

@@ -1 +1 @@
{"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"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"}

View File

@@ -39,8 +39,8 @@ class LifecycleManager {
* Called when component is created
*
* Supports lifecycle truncation flags:
* - _load_only: on_create + on_load only. No render, no children, no on_render, no after_load, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no after_load, no on_ready.
* - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
*/
async boot_component(component) {
this.active_components.add(component);
@@ -55,9 +55,10 @@ class LifecycleManager {
// Check for lifecycle truncation flags
const load_only = component._load_only;
const load_render_only = component._load_render_only;
const is_detached = component._is_detached;
let render_id;
if (load_only) {
// _load_only: skip render entirely, no children created
if (load_only || is_detached) {
// _load_only or detached: skip initial render entirely
render_id = 0;
component._render_count = 0;
}
@@ -84,9 +85,6 @@ class LifecycleManager {
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
}
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load
if (component._stopped)
return;
@@ -98,8 +96,9 @@ class LifecycleManager {
component.trigger('ready');
return;
}
// If data changed during load, re-render
if (component._should_rerender()) {
// Detached elements always render here (initial render was skipped)
// Normal elements re-render only if data changed during on_load
if (is_detached || component._should_rerender()) {
// Disable animations during re-render to prevent visual artifacts
// from CSS transitions/animations firing on newly rendered elements
const $el = component.$;
@@ -2659,7 +2658,7 @@ function write_html_cache_snapshot(component) {
* @param component - The component instance
* @param data_changed - Whether this.data changed during on_load
*/
function write_cache_after_load(component, data_changed) {
function write_cache_on_loaded(component, data_changed) {
if (!data_changed || !component._cache_key) {
return;
}
@@ -2842,10 +2841,13 @@ class Jqhtml_Component {
this._on_render_complete = false; // True after on_render() has been called post-on_load
// use_cached_data feature - skip on_load() when cache hit occurs
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
// _load_only: create + load only. No render, no children, no on_render, no after_load, no on_ready.
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
this._load_only = false;
// _load_render_only: create + render + load + re-render. No on_render, no after_load, no on_ready.
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
this._load_render_only = false;
// Detached optimization: true when element is not in the DOM at boot time.
// Skips initial render and cache read — just on_load then render.
this._is_detached = false;
// rendered event - fires once after the synchronous render chain completes
// (after on_load's re-render if applicable, or after first render if no on_load)
this._has_rendered = false;
@@ -3390,11 +3392,11 @@ class Jqhtml_Component {
Jqhtml_Local_Storage.set(cache_key, this.data);
}
}
// Call after_load() on the real component (this.data frozen, full access to this.$, this.state)
// Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
});
return data_changed;
@@ -3414,12 +3416,17 @@ class Jqhtml_Component {
`on_create() must be synchronous code. Remove 'async' from the function declaration.`);
// Don't await - on_create MUST be sync. The warning is enough.
}
// Detect detached elements — skip cache and initial render for elements not in DOM
this._is_detached = !this.$[0].isConnected;
// OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
// Components without on_load() don't fetch data, so nothing to cache or restore
if (this.__has_custom_on_load) {
// CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
// Skip cache for detached elements — no point hydrating from cache when not in DOM
// @see component-cache.ts for full implementation
read_cache_in_create(this);
if (!this._is_detached) {
read_cache_in_create(this);
}
// Snapshot this.data after on_create() completes
// This will be restored before each on_load() execution to reset state
this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
@@ -3455,8 +3462,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3535,8 +3542,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (follower)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3605,18 +3612,18 @@ class Jqhtml_Component {
// Used by HTML cache mode for synchronization - static parents don't block children
this._is_dynamic = data_changed && data_after_load !== '{}';
// CACHE WRITE - @see component-cache.ts
write_cache_after_load(this, this._is_dynamic);
write_cache_on_loaded(this, this._is_dynamic);
this._ready_state = 2;
this._update_debug_attrs();
this._log_lifecycle('load', 'complete');
// Emit lifecycle event
this.trigger('load');
// Call after_load() - runs on the REAL component (not detached proxy)
// Call on_loaded() - runs on the REAL component (not detached proxy)
// this.data is frozen (read-only), but this.$, this.state, this.args are accessible
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
}
finally {
@@ -4021,7 +4028,7 @@ class Jqhtml_Component {
on_render() { }
on_create() { }
on_load() { } // Override to fetch data asynchronously
after_load() { } // Override to process loaded data (e.g., clone this.data to this.state)
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
async on_ready() { }
on_stop() { }
/**
@@ -5273,7 +5280,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.38';
const version = '2.3.39';
// Default export with all functionality
const jqhtml = {
// Core

File diff suppressed because one or more lines are too long

View File

@@ -35,8 +35,8 @@ class LifecycleManager {
* Called when component is created
*
* Supports lifecycle truncation flags:
* - _load_only: on_create + on_load only. No render, no children, no on_render, no after_load, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no after_load, no on_ready.
* - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
*/
async boot_component(component) {
this.active_components.add(component);
@@ -51,9 +51,10 @@ class LifecycleManager {
// Check for lifecycle truncation flags
const load_only = component._load_only;
const load_render_only = component._load_render_only;
const is_detached = component._is_detached;
let render_id;
if (load_only) {
// _load_only: skip render entirely, no children created
if (load_only || is_detached) {
// _load_only or detached: skip initial render entirely
render_id = 0;
component._render_count = 0;
}
@@ -80,9 +81,6 @@ class LifecycleManager {
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
}
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load
if (component._stopped)
return;
@@ -94,8 +92,9 @@ class LifecycleManager {
component.trigger('ready');
return;
}
// If data changed during load, re-render
if (component._should_rerender()) {
// Detached elements always render here (initial render was skipped)
// Normal elements re-render only if data changed during on_load
if (is_detached || component._should_rerender()) {
// Disable animations during re-render to prevent visual artifacts
// from CSS transitions/animations firing on newly rendered elements
const $el = component.$;
@@ -2655,7 +2654,7 @@ function write_html_cache_snapshot(component) {
* @param component - The component instance
* @param data_changed - Whether this.data changed during on_load
*/
function write_cache_after_load(component, data_changed) {
function write_cache_on_loaded(component, data_changed) {
if (!data_changed || !component._cache_key) {
return;
}
@@ -2838,10 +2837,13 @@ class Jqhtml_Component {
this._on_render_complete = false; // True after on_render() has been called post-on_load
// use_cached_data feature - skip on_load() when cache hit occurs
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
// _load_only: create + load only. No render, no children, no on_render, no after_load, no on_ready.
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
this._load_only = false;
// _load_render_only: create + render + load + re-render. No on_render, no after_load, no on_ready.
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
this._load_render_only = false;
// Detached optimization: true when element is not in the DOM at boot time.
// Skips initial render and cache read — just on_load then render.
this._is_detached = false;
// rendered event - fires once after the synchronous render chain completes
// (after on_load's re-render if applicable, or after first render if no on_load)
this._has_rendered = false;
@@ -3386,11 +3388,11 @@ class Jqhtml_Component {
Jqhtml_Local_Storage.set(cache_key, this.data);
}
}
// Call after_load() on the real component (this.data frozen, full access to this.$, this.state)
// Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
});
return data_changed;
@@ -3410,12 +3412,17 @@ class Jqhtml_Component {
`on_create() must be synchronous code. Remove 'async' from the function declaration.`);
// Don't await - on_create MUST be sync. The warning is enough.
}
// Detect detached elements — skip cache and initial render for elements not in DOM
this._is_detached = !this.$[0].isConnected;
// OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
// Components without on_load() don't fetch data, so nothing to cache or restore
if (this.__has_custom_on_load) {
// CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
// Skip cache for detached elements — no point hydrating from cache when not in DOM
// @see component-cache.ts for full implementation
read_cache_in_create(this);
if (!this._is_detached) {
read_cache_in_create(this);
}
// Snapshot this.data after on_create() completes
// This will be restored before each on_load() execution to reset state
this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
@@ -3451,8 +3458,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3531,8 +3538,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (follower)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3601,18 +3608,18 @@ class Jqhtml_Component {
// Used by HTML cache mode for synchronization - static parents don't block children
this._is_dynamic = data_changed && data_after_load !== '{}';
// CACHE WRITE - @see component-cache.ts
write_cache_after_load(this, this._is_dynamic);
write_cache_on_loaded(this, this._is_dynamic);
this._ready_state = 2;
this._update_debug_attrs();
this._log_lifecycle('load', 'complete');
// Emit lifecycle event
this.trigger('load');
// Call after_load() - runs on the REAL component (not detached proxy)
// Call on_loaded() - runs on the REAL component (not detached proxy)
// this.data is frozen (read-only), but this.$, this.state, this.args are accessible
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
}
finally {
@@ -4017,7 +4024,7 @@ class Jqhtml_Component {
on_render() { }
on_create() { }
on_load() { } // Override to fetch data asynchronously
after_load() { } // Override to process loaded data (e.g., clone this.data to this.state)
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
async on_ready() { }
on_stop() { }
/**
@@ -5269,7 +5276,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.38';
const version = '2.3.39';
// Default export with all functionality
const jqhtml = {
// Core

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
/**
* JQHTML Core v2.3.38
* JQHTML Core v2.3.39
* (c) 2025 JQHTML Team
* Released under the MIT License
*/
@@ -40,8 +40,8 @@ class LifecycleManager {
* Called when component is created
*
* Supports lifecycle truncation flags:
* - _load_only: on_create + on_load only. No render, no children, no on_render, no after_load, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no after_load, no on_ready.
* - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
*/
async boot_component(component) {
this.active_components.add(component);
@@ -56,9 +56,10 @@ class LifecycleManager {
// Check for lifecycle truncation flags
const load_only = component._load_only;
const load_render_only = component._load_render_only;
const is_detached = component._is_detached;
let render_id;
if (load_only) {
// _load_only: skip render entirely, no children created
if (load_only || is_detached) {
// _load_only or detached: skip initial render entirely
render_id = 0;
component._render_count = 0;
}
@@ -85,9 +86,6 @@ class LifecycleManager {
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
}
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load
if (component._stopped)
return;
@@ -99,8 +97,9 @@ class LifecycleManager {
component.trigger('ready');
return;
}
// If data changed during load, re-render
if (component._should_rerender()) {
// Detached elements always render here (initial render was skipped)
// Normal elements re-render only if data changed during on_load
if (is_detached || component._should_rerender()) {
// Disable animations during re-render to prevent visual artifacts
// from CSS transitions/animations firing on newly rendered elements
const $el = component.$;
@@ -2660,7 +2659,7 @@ function write_html_cache_snapshot(component) {
* @param component - The component instance
* @param data_changed - Whether this.data changed during on_load
*/
function write_cache_after_load(component, data_changed) {
function write_cache_on_loaded(component, data_changed) {
if (!data_changed || !component._cache_key) {
return;
}
@@ -2843,10 +2842,13 @@ class Jqhtml_Component {
this._on_render_complete = false; // True after on_render() has been called post-on_load
// use_cached_data feature - skip on_load() when cache hit occurs
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
// _load_only: create + load only. No render, no children, no on_render, no after_load, no on_ready.
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
this._load_only = false;
// _load_render_only: create + render + load + re-render. No on_render, no after_load, no on_ready.
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
this._load_render_only = false;
// Detached optimization: true when element is not in the DOM at boot time.
// Skips initial render and cache read — just on_load then render.
this._is_detached = false;
// rendered event - fires once after the synchronous render chain completes
// (after on_load's re-render if applicable, or after first render if no on_load)
this._has_rendered = false;
@@ -3391,11 +3393,11 @@ class Jqhtml_Component {
Jqhtml_Local_Storage.set(cache_key, this.data);
}
}
// Call after_load() on the real component (this.data frozen, full access to this.$, this.state)
// Call on_loaded() on the real component (this.data frozen, full access to this.$, this.state)
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
});
return data_changed;
@@ -3415,12 +3417,17 @@ class Jqhtml_Component {
`on_create() must be synchronous code. Remove 'async' from the function declaration.`);
// Don't await - on_create MUST be sync. The warning is enough.
}
// Detect detached elements — skip cache and initial render for elements not in DOM
this._is_detached = !this.$[0].isConnected;
// OPTIMIZATION: Skip cache operations and snapshot if no custom on_load()
// Components without on_load() don't fetch data, so nothing to cache or restore
if (this.__has_custom_on_load) {
// CACHE CHECK - Read from cache based on cache mode ('data' or 'html')
// Skip cache for detached elements — no point hydrating from cache when not in DOM
// @see component-cache.ts for full implementation
read_cache_in_create(this);
if (!this._is_detached) {
read_cache_in_create(this);
}
// Snapshot this.data after on_create() completes
// This will be restored before each on_load() execution to reset state
this.__initial_data_snapshot = JSON.parse(JSON.stringify(this.data));
@@ -3456,8 +3463,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (use_cached_data - skipped on_load)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3536,8 +3543,8 @@ class Jqhtml_Component {
this._log_lifecycle('load', 'complete (follower)');
this.trigger('load');
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
return;
}
@@ -3606,18 +3613,18 @@ class Jqhtml_Component {
// Used by HTML cache mode for synchronization - static parents don't block children
this._is_dynamic = data_changed && data_after_load !== '{}';
// CACHE WRITE - @see component-cache.ts
write_cache_after_load(this, this._is_dynamic);
write_cache_on_loaded(this, this._is_dynamic);
this._ready_state = 2;
this._update_debug_attrs();
this._log_lifecycle('load', 'complete');
// Emit lifecycle event
this.trigger('load');
// Call after_load() - runs on the REAL component (not detached proxy)
// Call on_loaded() - runs on the REAL component (not detached proxy)
// this.data is frozen (read-only), but this.$, this.state, this.args are accessible
// Suppressed by _load_only and _load_render_only flags (preloading mode)
if (!this._load_only && !this._load_render_only) {
await this._call_lifecycle('after_load');
this.trigger('after_load');
await this._call_lifecycle('on_loaded');
this.trigger('on_loaded');
}
}
finally {
@@ -4022,7 +4029,7 @@ class Jqhtml_Component {
on_render() { }
on_create() { }
on_load() { } // Override to fetch data asynchronously
after_load() { } // Override to process loaded data (e.g., clone this.data to this.state)
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
async on_ready() { }
on_stop() { }
/**
@@ -5274,7 +5281,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.38';
const version = '2.3.39';
// Default export with all functionality
const jqhtml = {
// Core

File diff suppressed because one or more lines are too long

View File

@@ -27,8 +27,8 @@ export declare class LifecycleManager {
* Called when component is created
*
* Supports lifecycle truncation flags:
* - _load_only: on_create + on_load only. No render, no children, no on_render, no after_load, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no after_load, no on_ready.
* - _load_only: on_create + on_load only. No render, no children, no on_render, no on_loaded, no on_ready.
* - _load_render_only: on_create + render + on_load + re-render. No on_render, no on_loaded, no on_ready.
*/
boot_component(component: Jqhtml_Component): Promise<void>;
/**

View File

@@ -1 +1 @@
{"version":3,"file":"lifecycle-manager.d.ts","sourceRoot":"","sources":["../src/lifecycle-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAmB;IAC1C,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,MAAM,CAAC,YAAY,IAAI,gBAAgB;;IAevC;;;;;;;OAOG;IACG,cAAc,CAAC,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IA+IhE;;OAEG;IACH,oBAAoB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIvD;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;CAetC"}
{"version":3,"file":"lifecycle-manager.d.ts","sourceRoot":"","sources":["../src/lifecycle-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAmB;IAC1C,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,MAAM,CAAC,YAAY,IAAI,gBAAgB;;IAevC;;;;;;;OAOG;IACG,cAAc,CAAC,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IA6IhE;;OAEG;IACH,oBAAoB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIvD;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;CAetC"}

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/core",
"version": "2.3.38",
"version": "2.3.39",
"description": "Core runtime library for JQHTML",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1385,7 +1385,7 @@ export class CodeGenerator {
for (const [name, component] of this.components) {
code += `// Component: ${name}\n`;
code += `jqhtml_components.set('${name}', {\n`;
code += ` _jqhtml_version: '2.3.38',\n`; // Version will be replaced during build
code += ` _jqhtml_version: '2.3.39',\n`; // Version will be replaced during build
code += ` name: '${name}',\n`;
code += ` tag: '${component.tagName}',\n`;
code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/parser",
"version": "2.3.38",
"version": "2.3.39",
"description": "JQHTML template parser - converts templates to JavaScript",
"type": "module",
"main": "dist/index.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/ssr",
"version": "2.3.38",
"version": "2.3.39",
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
"main": "src/index.js",
"bin": {

View File

@@ -1 +1 @@
2.3.38
2.3.39

View File

@@ -2,7 +2,7 @@
"name": "@jqhtml/vscode-extension",
"displayName": "JQHTML",
"description": "Syntax highlighting and language support for JQHTML template files",
"version": "2.3.38",
"version": "2.3.39",
"publisher": "jqhtml",
"license": "MIT",
"publishConfig": {

24
package-lock.json generated
View File

@@ -2676,9 +2676,9 @@
}
},
"node_modules/@jqhtml/core": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.38.tgz",
"integrity": "sha512-7yjkqgYAPuyl9bjw67nm7+NwDTR4nCuem9IHmW99SO5o/iJIuJUB9txuBQBo8l2g+pNbDFFI4wvUxJbFn3fznA==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.39.tgz",
"integrity": "sha512-qyxOBcoFCaf35etqvNOSJppqT4WQLfD9O2b8bAv5la4oSpRUmXSjVJFdv3cSMIK8qClXbupN8bm4FLbAalJqog==",
"license": "MIT",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
@@ -2702,9 +2702,9 @@
}
},
"node_modules/@jqhtml/parser": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.38.tgz",
"integrity": "sha512-kIb9u3p01FDTvQbq7LKmSBaGd5JZzJGZNL7oHQXzjSkvpL6/iTE2NXkOHI/0yiSfMR5s8tsMABnHQX8M8bzZJw==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.39.tgz",
"integrity": "sha512-DLPwZf1X7enf2lVOaFaIWlu8vQYMgk/+Lioup2w4F07oXFx2+MnFgcJ/Ie9Pf6VUnMT1IOIZQxOd/5QugwFFDA==",
"license": "MIT",
"dependencies": {
"@types/jest": "^29.5.11",
@@ -2742,9 +2742,9 @@
}
},
"node_modules/@jqhtml/ssr": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.38.tgz",
"integrity": "sha512-C0hFuzMVwAoKGGj2UvBkUqvNa2Q2Q95DYHuGMx+4cD0kCwxA2bpo5MnjcmtSQh2YX4UODKTaqmpHL+03MYOp7g==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.39.tgz",
"integrity": "sha512-//MaIub8tel8w6l3AiqvoW021Aj9JR8BlVrZsezAO7svAIgsMFTeFdLKUud1+rg8I5Nxe4DE8CiGHz+f3Ts0kA==",
"license": "MIT",
"dependencies": {
"jquery": "^3.7.1",
@@ -2838,9 +2838,9 @@
}
},
"node_modules/@jqhtml/vscode-extension": {
"version": "2.3.38",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.38.tgz",
"integrity": "sha512-/npaSwR6ibKl3z8xFlnMO1salWyzQIgs+1D7JH2QFi62o9TtIJWDhKKO/n2njc6PdUrJcDy6ElT1jYZh4eoNGg==",
"version": "2.3.39",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.39.tgz",
"integrity": "sha512-Zi0iS5/t+5IhQoZP54J1/OOFB2OdoM6TM3g37SMJmPKjIDBUt883M3POszKFJwfj8+lrBV5OeJPOmPu3m9RYOQ==",
"license": "MIT",
"engines": {
"vscode": "^1.74.0"