librelad 182be8c33d 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>
2026-05-29 23:02:24 +01:00

69 lines
2.7 KiB
JavaScript

// 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;
})();