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:
@@ -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);
|
||||
|
||||
844
app/RSpade/man/custom_playwright_tests_spa.txt
Executable file
844
app/RSpade/man/custom_playwright_tests_spa.txt
Executable 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user