feat(web): ES-module page lifecycle and one schema field model (stage 1) (#703)

Adds a native ES-module layer to the web UI (core/boot, registry, api, facade; window.LEDMatrix as the one global), a page lifecycle that the Cache tab is converted to as the reference, text/javascript serving and revalidation for unversioned module requests, and src/plugin_system/field_model.py with a parity test against the render_field macro. Also: the cache page toggles its grey 'Not configured' style instead of only adding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 10:00:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 795834811f
commit c6701ac00d
27 changed files with 3006 additions and 175 deletions
+3
View File
@@ -51,10 +51,13 @@ server has none.
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
| `dom/test_cache_page.js` | yes | The Cache tab as a page module (`js/pages/cache.js`) on the real partial: no inline script, one request per swap and per Refresh after repeated swaps, a cancelled request draws nothing, hostile keys stay text, delete/empty/error/login states |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
+196
View File
@@ -0,0 +1,196 @@
// The Cache tab as a page module (static/v3/js/pages/cache.js), in a real DOM
// (jsdom) with the real server-rendered partial and the real API's payload
// shape. The reference conversion for docs/WEB_FRONTEND_ARCHITECTURE.md, so
// this pins what every converted page must do:
//
// * the partial ships no <script>; its root is data-page="cache"
// * the page starts once per swap-in and stops on swap-out: repeated htmx
// swaps leave exactly one live set of listeners (one request per Refresh
// click, however many times the tab was reloaded)
// * a request still in flight when the page is swapped away is cancelled
// and draws nothing
// * server data reaches the page as text, never as markup
const http = require('http');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/cache');
const real = JSON.parse(await get('/api/v3/cache/list'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const cachePage = await load('pages/cache.js');
console.log('\n── Cache tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="cache"', /data-page="cache"/.test(partial));
ok('the real API answers in the shape the page reads',
real.status === 'success' && real.data && Array.isArray(real.data.cache_files), real);
// Real shape, plus entries the page must treat as text.
const HOSTILE = '<img src=x onerror="window.pwned=1">\'"&';
const sample = Object.assign({}, real.data, {
cache_dir: real.data.cache_dir || '/var/cache/ledmatrix',
cache_files: [
{ key: 'weather_current', filename: 'weather_current.json', age_seconds: 12,
age_display: '12s', size_display: '1.2 KB', modified_datetime: '2026-09-30T12:00:00' },
{ key: HOSTILE, filename: HOSTILE + '.json', age_seconds: 7200,
age_display: '2h', size_display: '3 B', modified_datetime: '2026-09-30T10:00:00' },
],
});
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="cache-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('cache-content');
// Controllable API.
let listBody = { status: 'success', data: sample };
let listMode = 'ok';
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body });
const respond = (status, body, headers = {}) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: n => headers[n] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/cache/delete') return respond(200, { status: 'success', message: 'Deleted it' });
if (listMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (listMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (listMode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(resolve);
init.signal.addEventListener('abort', () => {
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, listBody);
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('cache', cachePage);
const lists = () => requests.filter(r => r.url === '/api/v3/cache/list').length;
const $ = id => doc.getElementById(id);
const visible = id => !$(id).classList.contains('hidden');
// What htmx does around a swap of the tab panel.
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one list request on start', lists() === 1, lists());
const rows = doc.querySelectorAll('#cache-files-tbody tr');
ok('one row per cache file', rows.length === 2, rows.length);
ok('cache directory shown', $('cache-dir').textContent === sample.cache_dir, $('cache-dir').textContent);
ok('hostile key is shown as text', rows[1].textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#cache-files-tbody img') && !window.pwned);
const buttons = [...doc.querySelectorAll('#cache-files-tbody button[data-cache-key]')];
ok('delete buttons carry the exact key', buttons.map(b => b.dataset.cacheKey).join('|') === 'weather_current|' + HOSTILE);
ok('delete buttons have no inline handler', buttons.every(b => !b.getAttribute('onclick')));
ok('fresh entries are green, old ones red',
rows[0].querySelector('.text-green-600') && rows[1].querySelector('.text-red-600'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = $('refresh-cache-btn');
for (let i = 0; i < 5; i++) await swap();
ok('one list request per swap', lists() === 6, lists());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
const before = lists();
$('refresh-cache-btn').click();
await tick(20);
ok('Refresh makes exactly one request (no duplicate listeners)', lists() === before + 1, lists() - before);
oldRefresh.click();
await tick(20);
ok('a swapped-out button does nothing', lists() === before + 1, lists() - before);
// ── delete ──────────────────────────────────────────────────────────────
let asked = null;
window.confirm = msg => { asked = msg; return false; };
doc.querySelector('#cache-files-tbody button[data-cache-key]').click();
await tick(20);
ok('delete asks first', asked && asked.includes('weather_current'), asked);
ok('cancel sends nothing', !requests.some(r => r.url === '/api/v3/cache/delete'));
window.confirm = () => true;
const listsBeforeDelete = lists();
doc.querySelectorAll('#cache-files-tbody button[data-cache-key]')[1].click();
await tick(30);
const del = requests.filter(r => r.url === '/api/v3/cache/delete');
ok('one delete request', del.length === 1, del.length);
ok('it posts the exact key as JSON', del[0] && del[0].method === 'POST' && JSON.parse(del[0].body).key === HOSTILE);
ok('the server\'s message is shown', notes.some(n => n[0] === 'Deleted it' && n[1] === 'success'), notes);
ok('the list reloads after a delete', lists() === listsBeforeDelete + 1, lists() - listsBeforeDelete);
const viaAlias = await cachePage.deleteCacheFile('weather_current');
ok('the old deleteCacheFile(key) entry point still works', viaAlias === true);
// ── states ──────────────────────────────────────────────────────────────
listBody = { status: 'success', data: { cache_dir: null, cache_files: [] } };
$('refresh-cache-btn').click(); await tick(20);
ok('empty state shown', visible('cache-empty') && !visible('cache-error') && !doc.querySelector('#cache-files-tbody tr'));
ok('a missing cache directory says so', $('cache-dir').textContent === 'Not configured');
ok('a missing cache directory is greyed', $('cache-dir').classList.contains('text-gray-500'));
listBody = { status: 'success', data: { cache_dir: '/var/cache/ledmatrix', cache_files: [] } };
$('refresh-cache-btn').click(); await tick(20);
ok('a directory that appears later is not greyed', $('cache-dir').textContent === '/var/cache/ledmatrix'
&& !$('cache-dir').classList.contains('text-gray-500'));
listBody = { status: 'error', message: 'Cache unavailable' };
$('refresh-cache-btn').click(); await tick(20);
ok('an API error shows its message', visible('cache-error') && $('cache-error-message').textContent === 'Cache unavailable',
$('cache-error-message').textContent);
listMode = 'network';
$('refresh-cache-btn').click(); await tick(20);
ok('a network failure says so', $('cache-error-message').textContent === 'Error loading cache files: Failed to fetch',
$('cache-error-message').textContent);
listMode = 'ok'; listBody = { status: 'success', data: sample };
await swap();
listMode = 'login';
$('cache-error').classList.add('hidden');
$('refresh-cache-btn').click(); await tick(20);
ok('the login redirect draws no error', !visible('cache-error'));
// ── in flight when swapped away ─────────────────────────────────────────
listMode = 'hang';
$('refresh-cache-btn').click(); await tick(5);
ok('a request is in flight', pending.length >= 1);
listMode = 'ok';
await swap();
ok('the new page drew its own list', doc.querySelectorAll('#cache-files-tbody tr').length === 2);
ok('the cancelled request drew nothing', !visible('cache-error'));
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -2
View File
@@ -22,9 +22,10 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js'];
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
'dom/test_tools_sections.js', 'dom/test_cache_page.js'];
function reachable(url) {
return new Promise(res => {
+143
View File
@@ -0,0 +1,143 @@
// core/api.js and core/facade.js (web_interface/static/v3/js/core/).
//
// api.js: one fetch wrapper. Resolves to the parsed JSON body; rejects with
// an ApiError for HTTP errors, {"status": "error"} bodies, unreadable bodies
// and network failures; passes an AbortError through untouched; and turns the
// optional web login's 401 + X-LEDMatrix-Login (#683) into a quiet
// `loginRequired` error, since base.html's fetch wrapper is already sending
// the browser to the login page.
//
// facade.js: window.LEDMatrix, and deprecated aliases for moved globals.
//
// Plain node: imports the shipped ES modules, no DOM needed.
const path = require('path');
const { pathToFileURL } = require('url');
const CORE = path.resolve(__dirname, '../../../web_interface/static/v3/js/core');
const load = f => import(pathToFileURL(path.join(CORE, f)).href);
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra) : '')));
function response(status, body, headers = {}) {
const text = typeof body === 'string' ? body : JSON.stringify(body);
return {
status, ok: status >= 200 && status < 300,
headers: { get: n => headers[n] !== undefined ? headers[n] : null },
text: () => Promise.resolve(text),
};
}
async function rejection(promise) {
try { await promise; return null; } catch (e) { return e; }
}
(async () => {
const { createApi, ApiError, isLoginRedirect, isAbort } = await load('api.js');
const { createFacade, installFacade, defineDeprecatedAlias, FACADE_VERSION } = await load('facade.js');
const { createRegistry } = await load('registry.js');
console.log('\n1. api: requests go out as JSON, through fetch at call time');
{
const calls = [];
const api = createApi({ fetch: (url, init) => { calls.push([url, init]); return Promise.resolve(response(200, { status: 'success', data: { n: 1 } })); } });
const body = await api.get('/api/v3/cache/list');
ok('resolves to the parsed body', body.data.n === 1, body);
ok('GET has no body', calls[0][1].method === 'GET' && calls[0][1].body === undefined);
await api.post('/api/v3/cache/delete', { key: 'a"b' });
ok('POST sends JSON', calls[1][1].headers['Content-Type'] === 'application/json' && JSON.parse(calls[1][1].body).key === 'a"b');
const controller = new AbortController();
await api.get('/api/v3/x', { signal: controller.signal });
ok('the signal is passed to fetch', calls[2][1].signal === controller.signal);
// Default: window.fetch looked up per call, so base.html's login wrapper
// (installed before any module runs, or replaced later) is the one used.
const seen = [];
globalThis.fetch = () => { seen.push('first'); return Promise.resolve(response(200, { status: 'success' })); };
const live = createApi();
await live.get('/api/v3/a');
globalThis.fetch = () => { seen.push('second'); return Promise.resolve(response(200, { status: 'success' })); };
await live.get('/api/v3/b');
ok('uses whatever window.fetch is at call time', seen.join() === 'first,second', seen);
}
console.log('\n2. api: errors');
{
const api = r => createApi({ fetch: () => (r instanceof Error ? Promise.reject(r) : Promise.resolve(r)) });
let e = await rejection(api(response(500, { status: 'error', message: 'Disk full' })).get('/api/v3/x'));
ok('HTTP error carries status and message', e instanceof ApiError && e.status === 500 && e.message === 'Disk full', e && e.message);
e = await rejection(api(response(200, { status: 'error', message: 'Nope' })).get('/api/v3/x'));
ok('a 200 with status "error" is an error', e instanceof ApiError && e.status === 200 && e.message === 'Nope' && e.body.status === 'error');
e = await rejection(api(response(502, '<html>Bad gateway</html>')).get('/api/v3/x'));
ok('a non-JSON error page says the status', e instanceof ApiError && e.status === 502 && e.message === 'HTTP 502', e && e.message);
e = await rejection(api(response(200, 'not json')).get('/api/v3/x'));
ok('an unreadable 200 is an error', e instanceof ApiError && /Unreadable/.test(e.message));
e = await rejection(api(new TypeError('Failed to fetch')).get('/api/v3/x'));
ok('a network failure is flagged', e instanceof ApiError && e.network && e.status === 0 && e.message === 'Failed to fetch');
const abort = new Error('aborted'); abort.name = 'AbortError';
e = await rejection(api(abort).get('/api/v3/x'));
ok('an abort passes through untouched', e === abort && isAbort(e));
}
console.log('\n3. api: the optional web login (#683)');
{
const login = response(401, { status: 'error', message: 'Login required' }, { 'X-LEDMatrix-Login': '/login?next=/' });
ok('isLoginRedirect matches the wrapper in base.html', isLoginRedirect(login));
ok('...not a protocol-relative URL', !isLoginRedirect(response(401, {}, { 'X-LEDMatrix-Login': '//evil.example/' })));
ok('...not a 401 without the header', !isLoginRedirect(response(401, {})));
ok('...not another status', !isLoginRedirect(response(403, {}, { 'X-LEDMatrix-Login': '/login' })));
const e = await rejection(createApi({ fetch: () => Promise.resolve(login) }).get('/api/v3/x'));
ok('rejects quietly with loginRequired', e instanceof ApiError && e.loginRequired && e.status === 401);
}
console.log('\n4. api: only this server\'s paths');
{
const api = createApi({ fetch: () => Promise.resolve(response(200, { status: 'success' })) });
for (const bad of ['//evil.example/x', 'https://evil.example/x', 'api/v3/x', '/a b', '/a\\b']) {
const e = await rejection(api.get(bad));
ok(`refuses ${JSON.stringify(bad)}`, e instanceof TypeError, e && e.message);
}
}
console.log('\n5. facade: window.LEDMatrix');
{
const warnings = [];
const win = { console: { warn: m => warnings.push(m), log() {}, error() {} } };
const api = createApi({ fetch: () => Promise.resolve(response(200, { status: 'success' })) });
const reg = createRegistry({ document: { addEventListener() {}, removeEventListener() {}, querySelectorAll: () => [] } });
const facade = installFacade(win, createFacade(win, api, reg));
ok('installed as window.LEDMatrix', win.LEDMatrix === facade && facade.version === FACADE_VERSION);
ok('exposes api and pages', facade.api === api && typeof facade.pages.register === 'function' && typeof facade.pages.refresh === 'function');
ok('is frozen', Object.isFrozen(facade) && Object.isFrozen(facade.pages));
win.LEDEscape = { html: s => s };
win.LEDMatrixWidgets = { get() {} };
ok('escape and widgets read through at call time', facade.escape === win.LEDEscape && facade.widgets === win.LEDMatrixWidgets);
const notes = [];
win.showNotification = (m, t) => notes.push([m, t]);
facade.notify('saved', 'success');
win.showNotification = (m, t) => notes.push(['replaced', m, t]);
facade.notify('again');
ok('notify uses the current showNotification', JSON.stringify(notes) === JSON.stringify([['saved', 'success'], ['replaced', 'again', 'info']]), notes);
}
console.log('\n6. facade: deprecated aliases keep old globals working');
{
const warnings = [];
const win = {};
const logger = { warn: m => warnings.push(m) };
const calls = [];
defineDeprecatedAlias(win, 'deleteCacheFile', function(key) { calls.push([this, key]); return 'done'; }, 'the Delete buttons', logger);
ok('a function alias forwards its arguments and result', win.deleteCacheFile('k1') === 'done' && calls[0][1] === 'k1');
win.deleteCacheFile('k2');
ok('warns once, naming the replacement', warnings.length === 1 && /deleteCacheFile/.test(warnings[0]) && /the Delete buttons/.test(warnings[0]), warnings);
defineDeprecatedAlias(win, 'oldThing', { a: 1 }, 'LEDMatrix.thing', logger);
ok('a value alias is a getter', win.oldThing.a === 1 && warnings.length === 2);
Object.defineProperty(win, 'locked', { value: 1, configurable: false });
ok('a non-configurable global is left alone', defineDeprecatedAlias(win, 'locked', () => 2, null, logger) === false && win.locked === 1);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+2 -2
View File
@@ -115,8 +115,8 @@ const ESCAPERS = [
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['cache.html (escapeHtml)',
'templates/v3/partials/cache.html', 'function escapeHtml(text) {', 'escapeHtml', false],
// cache.html has no script any more: js/pages/cache.js builds its rows with
// textContent, and test/js/dom/test_cache_page.js checks a hostile key.
];
// The breakout payload: closes a double-quoted attribute and opens an event
+247
View File
@@ -0,0 +1,247 @@
// The page lifecycle (web_interface/static/v3/js/core/registry.js).
//
// A converted partial's root carries data-page="<name>"; the registry calls
// the page module's init(root, ctx) once when the root appears and
// destroy(root, ctx) when htmx swaps it away, aborting ctx.signal so every
// listener the page registered with it goes too. This is what replaces the
// inline <script> blocks that htmx-config.js re-ran on every swap.
//
// Imports the shipped ES module directly (js/core/package.json marks the
// directory "type": "module"). The DOM is a minimal shim, so this needs only
// node and runs under test/test_js_unit_suites.py as well as run_all.js.
const path = require('path');
const { pathToFileURL } = require('url');
const CORE = path.resolve(__dirname, '../../../web_interface/static/v3/js/core');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra) : '')));
// ── DOM shim: just what the registry touches ───────────────────────────────
class El extends EventTarget {
constructor(tag, attrs = {}) {
super();
this.tagName = tag.toUpperCase();
this.attrs = new Map(Object.entries(attrs));
this.children = [];
this.parentNode = null;
}
getAttribute(n) { return this.attrs.has(n) ? this.attrs.get(n) : null; }
setAttribute(n, v) { this.attrs.set(n, String(v)); }
appendChild(c) { if (c.parentNode) c.remove(); c.parentNode = this; this.children.push(c); return c; }
remove() { if (this.parentNode) { this.parentNode.children = this.parentNode.children.filter(x => x !== this); this.parentNode = null; } }
replaceChildren(...nodes) { this.children.slice().forEach(c => c.remove()); nodes.forEach(n => this.appendChild(n)); }
*descendants() { for (const c of this.children) { yield c; yield* c.descendants(); } }
// Only the one selector the registry uses: [attr]
matches(sel) { const m = /^\[([\w-]+)\]$/.exec(sel); return !!m && this.attrs.has(m[1]); }
querySelectorAll(sel) { return [...this.descendants()].filter(e => e.matches(sel)); }
contains(other) { for (let n = other; n; n = n.parentNode) if (n === this) return true; return false; }
get isConnected() { let n = this; while (n.parentNode) n = n.parentNode; return n instanceof Doc; }
}
class Doc extends El {
constructor() { super('#document'); this.documentElement = this.appendChild(new El('html')); this.body = this.documentElement.appendChild(new El('body')); }
}
const event = (type, detail) => { const e = new Event(type); e.detail = detail; return e; };
// htmx fires its events on the target and they bubble to the document, where
// the registry listens. Node's EventTarget has no tree, so walk it here.
function fire(target, type, detail) {
for (let n = target; n; n = n.parentNode) n.dispatchEvent(event(type, detail));
}
const tick = () => new Promise(r => setTimeout(r, 0));
// A page module that records its lifecycle, and checks ctx.signal works.
function recorder(log) {
return {
init(root, ctx) {
log.push(['init', root.getAttribute('id'), ctx.name]);
ctx.state.clicks = 0;
root.addEventListener('click', () => { ctx.state.clicks++; log.push(['click', root.getAttribute('id')]); }, { signal: ctx.signal });
ctx.signal.addEventListener('abort', () => log.push(['aborted', root.getAttribute('id')]));
if (ctx.service) log.push(['service', ctx.service]);
},
destroy(root, ctx) { log.push(['destroy', root.getAttribute('id'), ctx.signal.aborted]); },
};
}
(async () => {
const { createRegistry, PAGE_ATTRIBUTE } = await import(pathToFileURL(path.join(CORE, 'registry.js')).href);
const quiet = { error: () => {}, warn: () => {} };
console.log('\n1. mounts on start, once per root, with the shared context');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div', { id: 'cache-content' }));
const root = panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
const log = [];
const reg = createRegistry({ document: doc, context: { service: 'api' }, logger: quiet });
reg.register('demo', recorder(log));
await reg.start();
ok('init ran once on start', log.filter(e => e[0] === 'init').length === 1, log);
ok('ctx carries the page name', log[0][2] === 'demo', log);
ok('ctx carries the shared services', log.some(e => e[0] === 'service' && e[1] === 'api'), log);
await reg.refresh(); await reg.scan(); await reg.mount(root);
ok('refresh/scan/mount again do not re-init', log.filter(e => e[0] === 'init').length === 1, log);
root.dispatchEvent(new Event('click'));
ok('the page listener works', log.filter(e => e[0] === 'click').length === 1, log);
ok('list() reports the mounted page', reg.list().length === 1 && reg.list()[0].initialised === true, reg.list().length);
}
console.log('\n2. an htmx swap destroys the old page and starts the new one');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
const first = panel.appendChild(new El('div', { id: 'first', [PAGE_ATTRIBUTE]: 'demo' }));
const log = [];
const reg = createRegistry({ document: doc, logger: quiet });
reg.register('demo', recorder(log));
await reg.start();
for (let i = 0; i < 5; i++) {
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 'swap' + i, [PAGE_ATTRIBUTE]: 'demo' }));
fire(panel, 'htmx:afterSwap', { target: panel });
await tick();
}
const inits = log.filter(e => e[0] === 'init').map(e => e[1]);
const destroys = log.filter(e => e[0] === 'destroy').map(e => e[1]);
ok('one init per swapped-in root', JSON.stringify(inits) === JSON.stringify(['first', 'swap0', 'swap1', 'swap2', 'swap3', 'swap4']), inits);
ok('one destroy per swapped-out root', JSON.stringify(destroys) === JSON.stringify(['first', 'swap0', 'swap1', 'swap2', 'swap3']), destroys);
ok('destroy runs before the signal is aborted', log.filter(e => e[0] === 'destroy').every(e => e[2] === false), log);
ok('every destroyed page had its signal aborted', log.filter(e => e[0] === 'aborted').length === 5, log);
ok('only the live page is mounted', reg.list().length === 1 && reg.list()[0].root.getAttribute('id') === 'swap4');
// The old root's listener was registered with ctx.signal: gone.
first.dispatchEvent(new Event('click'));
ok('a destroyed page no longer hears its own events', !log.some(e => e[0] === 'click' && e[1] === 'first'), log);
}
console.log('\n3. a vetoed swap (shouldSwap false, e.g. an error response) keeps the page');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div'));
panel.appendChild(new El('div', { id: 'keep', [PAGE_ATTRIBUTE]: 'demo' }));
const log = [];
const reg = createRegistry({ document: doc, logger: quiet });
reg.register('demo', recorder(log));
await reg.start();
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: false });
fire(panel, 'htmx:afterSwap', { target: panel });
ok('not destroyed', !log.some(e => e[0] === 'destroy'), log);
ok('still mounted', reg.list().length === 1);
}
console.log('\n4. a swap elsewhere leaves the page alone');
{
const doc = new Doc();
const a = doc.body.appendChild(new El('div'));
const b = doc.body.appendChild(new El('div'));
a.appendChild(new El('div', { id: 'a-page', [PAGE_ATTRIBUTE]: 'demo' }));
const log = [];
const reg = createRegistry({ document: doc, logger: quiet });
reg.register('demo', recorder(log));
await reg.start();
fire(b, 'htmx:beforeSwap', { target: b, shouldSwap: true });
b.replaceChildren(new El('p'));
fire(b, 'htmx:afterSwap', { target: b });
ok('the other panel\'s page is untouched', log.filter(e => e[0] !== 'service').map(e => e[0]).join() === 'init', log);
}
console.log('\n5. content removed without htmx (Alpine x-if, outerHTML) is swept on the next swap or refresh');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div'));
const root = panel.appendChild(new El('div', { id: 'gone', [PAGE_ATTRIBUTE]: 'demo' }));
const log = [];
const reg = createRegistry({ document: doc, logger: quiet });
reg.register('demo', recorder(log));
await reg.start();
root.remove();
ok('nothing happens until the registry looks', !log.some(e => e[0] === 'destroy'));
await reg.refresh();
ok('refresh() destroys a detached root', log.some(e => e[0] === 'destroy' && e[1] === 'gone'), log);
// loadPartialDirect inserts HTML without htmx events and calls refresh().
panel.appendChild(new El('div', { id: 'direct', [PAGE_ATTRIBUTE]: 'demo' }));
await reg.refresh();
ok('refresh() starts a root inserted without htmx', log.some(e => e[0] === 'init' && e[1] === 'direct'), log);
}
console.log('\n6. lazy page modules: loaded on first use, once');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div'));
const log = [];
let loads = 0;
const reg = createRegistry({ document: doc, logger: quiet });
reg.register('lazy', () => { loads++; return Promise.resolve({ default: recorder(log) }); });
await reg.start();
ok('not loaded while no partial uses it', loads === 0);
for (let i = 0; i < 3; i++) {
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 'l' + i, [PAGE_ATTRIBUTE]: 'lazy' }));
fire(panel, 'htmx:afterSwap', { target: panel });
await tick(); await tick();
}
ok('loader called once', loads === 1, loads);
ok('a default export works', log.filter(e => e[0] === 'init').length === 3, log);
// Swapped away while its module is still loading: never initialised.
let release;
const slowLog = [];
reg.register('slow', () => new Promise(r => { release = r; }));
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 's', [PAGE_ATTRIBUTE]: 'slow' }));
fire(panel, 'htmx:afterSwap', { target: panel });
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('p'));
fire(panel, 'htmx:afterSwap', { target: panel });
release(recorder(slowLog));
await tick(); await tick();
ok('a page destroyed before its module arrived never runs init', slowLog.length === 0, slowLog);
}
console.log('\n7. a page registered after its partial arrived still starts');
{
const doc = new Doc();
doc.body.appendChild(new El('div', { id: 'early', [PAGE_ATTRIBUTE]: 'late' }));
const log = [];
const reg = createRegistry({ document: doc, logger: quiet });
await reg.start();
reg.register('late', recorder(log));
await tick();
ok('init ran on register', log.some(e => e[0] === 'init' && e[1] === 'early'), log);
}
console.log('\n8. a failing page is contained');
{
const doc = new Doc();
doc.body.appendChild(new El('div', { id: 'bad', [PAGE_ATTRIBUTE]: 'bad' }));
doc.body.appendChild(new El('div', { id: 'good', [PAGE_ATTRIBUTE]: 'demo' }));
const errors = [];
const log = [];
const reg = createRegistry({ document: doc, logger: { error: (...a) => errors.push(a.join(' ')), warn() {} } });
reg.register('bad', { init() { throw new Error('boom'); }, destroy() { log.push(['bad-destroy']); } });
reg.register('demo', recorder(log));
await reg.start();
ok('the error is logged with the page name', errors.length === 1 && /bad/.test(errors[0]), errors);
ok('the other page still started', log.some(e => e[0] === 'init' && e[1] === 'good'), log);
reg.stop();
ok('destroy is not called for a page whose init failed', !log.some(e => e[0] === 'bad-destroy'), log);
ok('stop() destroys every page', log.some(e => e[0] === 'destroy' && e[1] === 'good') && reg.list().length === 0, log);
}
console.log('\n9. register() rejects mistakes loudly');
{
const reg = createRegistry({ document: new Doc(), logger: quiet });
const throws = fn => { try { fn(); return false; } catch (e) { return true; } };
ok('no name', throws(() => reg.register('', { init() {} })));
ok('no init and not a loader', throws(() => reg.register('x', {})));
reg.register('dup', { init() {} });
ok('a duplicate name', throws(() => reg.register('dup', { init() {} })));
ok('has()', reg.has('dup') && !reg.has('nope'));
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });