feat(webui): phase 3 (first feature) — migrate Backup to a feature module
Introduces the kernel lifecycle and migrates the first real page to the feature-module contract: - kernel/lifecycle.js: MountContext (loadScripts/loadFragment/setContent + an AbortController/unsub teardown ledger so mounts can't leak listeners or live streams). - features/backup/index.js: Backup Center as a self-contained module (LP.features.register with mount/unmount); heavy backup-page.js stays lazy-loaded on first mount. - spa.js: routes whose feature has a registered mount() are driven through the kernel; everything else still uses its legacy handleX(). navigate() unmounts the current feature first. Both fall back to the legacy handler if a module is missing or mount throws. Strangler step: /backup now flows manifest -> registry -> mount/unmount. The other pages are untouched. handleBackup remains as the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
cab04108d3
commit
182be8c33d
41
containers/libreportal/frontend/features/backup/index.js
Normal file
41
containers/libreportal/frontend/features/backup/index.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// features/backup/index.js — Backup Center as a self-contained feature module.
|
||||||
|
//
|
||||||
|
// FIRST page migrated to the feature-module contract (docs/frontend-modularization.md).
|
||||||
|
// The kernel drives mount()/unmount() for the /backup route instead of
|
||||||
|
// spa.js's handleBackup(). The heavy controller (backup-page.js, ~129KB) is
|
||||||
|
// still lazy-loaded on first mount, so cold-load cost is unchanged.
|
||||||
|
//
|
||||||
|
// Snap-out demo: deleting this folder removes the backup route's module
|
||||||
|
// registration; the manifest entry then falls back to the legacy handler
|
||||||
|
// (and, once handleBackup is retired, to the kernel's not-found route). The
|
||||||
|
// full decomposition of backup-page.js into per-tab modules is Phase 5.
|
||||||
|
LP.features.register({
|
||||||
|
id: 'backup',
|
||||||
|
routes: ['/backup', '/backup*'],
|
||||||
|
// Controllers the feature needs; lazy-loaded on first mount (idempotent).
|
||||||
|
scripts: [
|
||||||
|
'/js/components/backup/backup-page.js',
|
||||||
|
'/js/components/backup/backup-app-card.js',
|
||||||
|
],
|
||||||
|
|
||||||
|
async mount(ctx) {
|
||||||
|
await ctx.loadScripts(this.scripts);
|
||||||
|
const html = await ctx.loadFragment('/html/backup-content.html');
|
||||||
|
ctx.setContent(html, 'Backups');
|
||||||
|
if (typeof BackupPage === 'undefined') {
|
||||||
|
throw new Error('BackupPage controller failed to load');
|
||||||
|
}
|
||||||
|
window.backupPage = new BackupPage();
|
||||||
|
await window.backupPage.init();
|
||||||
|
},
|
||||||
|
|
||||||
|
async unmount() {
|
||||||
|
// Best-effort teardown. BackupPage self-guards stale work via
|
||||||
|
// (window.backupPage === this), so nulling the global neutralises any
|
||||||
|
// pending task-refresh repaint; we also drop its coordinator registration.
|
||||||
|
// A proper dispose() (removing the leaked document listeners) lands with
|
||||||
|
// the Phase 5 backup decomposition.
|
||||||
|
try { window.taskRefresh && window.taskRefresh.unregister && window.taskRefresh.unregister('backups'); } catch (_) {}
|
||||||
|
window.backupPage = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -108,6 +108,10 @@
|
|||||||
loads the page manifest; spa.js consults it for routing. See
|
loads the page manifest; spa.js consults it for routing. See
|
||||||
docs/frontend-modularization.md. -->
|
docs/frontend-modularization.md. -->
|
||||||
<script src="/kernel/feature-registry.js"></script>
|
<script src="/kernel/feature-registry.js"></script>
|
||||||
|
<script src="/kernel/lifecycle.js"></script>
|
||||||
|
<!-- Feature modules self-register here (eager, tiny). Their heavy controllers
|
||||||
|
are still lazy-loaded by the module's mount(). -->
|
||||||
|
<script src="/features/backup/index.js"></script>
|
||||||
<!--
|
<!--
|
||||||
Page-specific controllers are loaded on demand by spa.js / config-manager.js
|
Page-specific controllers are loaded on demand by spa.js / config-manager.js
|
||||||
when the user navigates to the relevant route. Keeping them out of the
|
when the user navigates to the relevant route. Keeping them out of the
|
||||||
|
|||||||
@ -113,7 +113,13 @@ class LibrePortalSPAClean {
|
|||||||
}
|
}
|
||||||
this.routes.clear();
|
this.routes.clear();
|
||||||
for (const f of entries) {
|
for (const f of entries) {
|
||||||
const handler = () => this[f.handler]();
|
// A feature that has registered a module with a mount() is driven
|
||||||
|
// through the kernel lifecycle; everything else still runs its legacy
|
||||||
|
// handleX() method (strangler migration — both coexist).
|
||||||
|
const mod = (window.LP && window.LP.features) ? window.LP.features.get(f.id) : null;
|
||||||
|
const handler = (mod && typeof mod.mount === 'function')
|
||||||
|
? () => this._mountFeature(f)
|
||||||
|
: () => this[f.handler]();
|
||||||
for (const route of (f.routes || [])) {
|
for (const route of (f.routes || [])) {
|
||||||
this.routes.set(route, handler);
|
this.routes.set(route, handler);
|
||||||
}
|
}
|
||||||
@ -126,6 +132,38 @@ class LibrePortalSPAClean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drive a feature module's mount() through the kernel lifecycle. Falls back
|
||||||
|
// to the feature's legacy handler if no module is registered or mount throws,
|
||||||
|
// so a broken/absent module can never leave the route dead.
|
||||||
|
async _mountFeature(f) {
|
||||||
|
const mod = (window.LP && window.LP.features) ? window.LP.features.get(f.id) : null;
|
||||||
|
if (!mod || typeof mod.mount !== 'function') {
|
||||||
|
return (typeof this[f.handler] === 'function') ? this[f.handler]() : this.showError('Page not found');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ctx = new window.LP.kernel.MountContext(this, { path: window.location.pathname + window.location.search });
|
||||||
|
await mod.mount(ctx);
|
||||||
|
this._mountedFeature = { id: f.id, mod, ctx, handler: f.handler };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[kernel] mount("${f.id}") failed, falling back to handler:`, err);
|
||||||
|
this._mountedFeature = null;
|
||||||
|
if (typeof this[f.handler] === 'function') return this[f.handler]();
|
||||||
|
this.showError('Failed to load ' + f.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmount the currently-mounted feature (if any) before navigating away, so
|
||||||
|
// its listeners/streams are released. Legacy handlers don't go through here,
|
||||||
|
// so this only fires when leaving a migrated feature.
|
||||||
|
async _unmountCurrentFeature() {
|
||||||
|
const cur = this._mountedFeature;
|
||||||
|
if (!cur) return;
|
||||||
|
this._mountedFeature = null;
|
||||||
|
try { if (typeof cur.mod.unmount === 'function') await cur.mod.unmount(cur.ctx); }
|
||||||
|
catch (e) { console.error(`[kernel] unmount("${cur.id}") error:`, e); }
|
||||||
|
try { cur.ctx && cur.ctx.teardown(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
async loadCoreData() {
|
async loadCoreData() {
|
||||||
//console.log('📊 Loading core data...');
|
//console.log('📊 Loading core data...');
|
||||||
|
|
||||||
@ -223,6 +261,10 @@ class LibrePortalSPAClean {
|
|||||||
|
|
||||||
this.currentRoute = path;
|
this.currentRoute = path;
|
||||||
|
|
||||||
|
// Release the previously-mounted feature module (if any) before rendering
|
||||||
|
// the next route, so live streams/listeners don't outlive their page.
|
||||||
|
await this._unmountCurrentFeature();
|
||||||
|
|
||||||
// Find and execute route handler
|
// Find and execute route handler
|
||||||
const handler = this.findRouteHandler(path);
|
const handler = this.findRouteHandler(path);
|
||||||
if (handler) {
|
if (handler) {
|
||||||
|
|||||||
68
containers/libreportal/frontend/kernel/lifecycle.js
Normal file
68
containers/libreportal/frontend/kernel/lifecycle.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// kernel/lifecycle.js — the MountContext a feature module receives.
|
||||||
|
//
|
||||||
|
// Part of the feature-module architecture (docs/frontend-modularization.md §2.4).
|
||||||
|
// A feature exports mount(ctx)/unmount(ctx); the SPA shell drives them and hands
|
||||||
|
// in this context. In this first form the content helpers delegate to the SPA
|
||||||
|
// shell (so rendering is byte-identical to the legacy handlers), plus a teardown
|
||||||
|
// ledger: any listener registered via ctx.on() or subscription via ctx.sub() is
|
||||||
|
// auto-released on unmount, so a feature can't leak document listeners or live
|
||||||
|
// streams across navigations. Shared-service injection (ctx.services) lands with
|
||||||
|
// the Phase 2 DI container.
|
||||||
|
(function () {
|
||||||
|
const LP = (window.LP = window.LP || {});
|
||||||
|
|
||||||
|
class MountContext {
|
||||||
|
constructor(shell, route) {
|
||||||
|
this.shell = shell; // the LibrePortalSPAClean instance
|
||||||
|
this.route = route || {};
|
||||||
|
this.path = this.route.path || (location.pathname + location.search);
|
||||||
|
this._ac = new AbortController(); // backs ctx.on() — abort kills all
|
||||||
|
this._unsubs = []; // backs ctx.sub() — SSE/bus/timer handles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-load the feature's controller scripts (idempotent across re-mounts).
|
||||||
|
loadScripts(list) {
|
||||||
|
return Promise.all((list || []).map(src => this.shell.loadScript(src)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch an HTML fragment (same path the legacy handlers used).
|
||||||
|
loadFragment(url) {
|
||||||
|
return this.shell.fetchContent(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject fragment HTML into #main-content + set the document title +
|
||||||
|
// refresh nav highlighting — identical to the legacy loadContent().
|
||||||
|
setContent(html, title) {
|
||||||
|
return this.shell.loadContent(html, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA navigation (for redirects from within a feature).
|
||||||
|
nav(path) {
|
||||||
|
return this.shell.navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// addEventListener bound to this mount's AbortController — auto-removed on
|
||||||
|
// teardown. target = window | document | a bus | an element.
|
||||||
|
on(target, event, fn, opts) {
|
||||||
|
target.addEventListener(event, fn, Object.assign({}, opts, { signal: this._ac.signal }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track an arbitrary unsubscribe fn (SSE handle, bus off(), clearInterval
|
||||||
|
// wrapper) so it's called on teardown.
|
||||||
|
sub(unsub) {
|
||||||
|
if (typeof unsub === 'function') this._unsubs.push(unsub);
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release everything opened during this mount. Called by the shell after
|
||||||
|
// the feature's own unmount().
|
||||||
|
teardown() {
|
||||||
|
try { this._ac.abort(); } catch (_) {}
|
||||||
|
this._unsubs.forEach(u => { try { u(); } catch (_) {} });
|
||||||
|
this._unsubs.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LP.kernel = LP.kernel || {};
|
||||||
|
LP.kernel.MountContext = MountContext;
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user