/** * Load trusted page modules on demand and own their Alpine mounting and teardown lifecycle. * * @author Laurent Declercq l.declercq@agon-innovation.ch * @version 20260921 */ /** * Parse a compiled page template without accepting multiple or missing route roots. * * @param {string} html - Trusted HTML exported by the application build. * @returns {Element} Detached page root ready for Alpine initialization. * @throws {Error} When the compiled page does not contain exactly one root element. */ export function pageRoot(html) { const template = document.createElement('template'); template.innerHTML = html; const root = template.content.firstElementChild; if (!root || root.nextElementSibling) { throw new Error('A page must contain exactly one root element.'); } return root; } /** * Create a route-owned outlet for a consumer's compiled template and controller module. * * @param {object} route - Consumer route metadata and optional capability. * @param {Record} pages - Build-generated dynamic page importers. * @param {object} context - Alpine runtime, preferences, and consumer-owned service dependencies. * @returns {object} Alpine provider with explicit loading, recovery, and cleanup state. */ export function pageOutlet(route, pages, context) { let generation = 0; let mounted = null; let permitted; return { loading: false, failed: false, /** * Recover a cached module failure while preserving the full current URL and its query state. * * @returns {void} Requests a fresh browser document at the current location. */ reload() { window.location.reload(); }, /** * Load the selected route and observe presentation permission changes. * * @returns {void} Installs the permission watcher and starts the first permitted load. */ init() { this.$watch( '$store.session.capabilities', /** * Reconcile permission changes without resetting an already permitted page. * * @returns {void} Updates the mounted page only when permission changes. */ () => this.syncPermission() ); this.syncPermission(); }, /** * Mount newly allowed content or tear down a page whose capability was removed. * * @returns {void} Starts a fresh lifecycle only when permission changes. */ syncPermission() { const allowed = this.$store.session.can(route.capability); if (allowed !== permitted) { permitted = allowed; this.load(); } }, /** * Release the mounted page's requests, timers, listeners, and Alpine effects. * * @returns {void} Removes only this outlet's owned page root. */ unmount() { if (mounted) { context.Alpine.mutateDom( /** * Run explicit teardown while the DOM observer is suspended. * * @returns {void} Destroys and removes the owned subtree exactly once. */ () => { context.Alpine.destroyTree(mounted); mounted.remove(); mounted = null; } ); } }, /** * Import one trusted page, register its controllers, and mount only the latest result. * Dynamic module imports cannot be aborted, so generation ownership rejects late completions. * * @returns {Promise} Resolves after mounting, permission refusal, or recoverable failure. */ async load() { const request = ++generation; this.unmount(); this.failed = false; this.loading = false; if (!this.$store.session.can(route.capability)) { return; } this.loading = true; try { const page = await pages[route.path](); if (request !== generation) { return; } const root = pageRoot(page.html); page.register?.(context); context.Alpine.mutateDom( /** * Mount compiled application markup after its named providers are available. * * @returns {void} Initializes the new page beneath the existing shell scope. */ () => { mounted = root; this.$refs.content.append(root); context.Alpine.initTree(root); } ); this.$nextTick( /** * Move keyboard focus after the newly loaded page finishes rendering. * * @returns {void} Focuses the page heading when navigation still owns this outlet. */ () => { if (request === generation) { root.querySelector('h1')?.focus(); } } ); } catch { if (request === generation) { this.unmount(); this.failed = true; } } finally { if (request === generation) { this.loading = false; } } }, /** * Invalidate pending module results and release the page before route removal. * * @returns {void} Ends ownership without affecting the persistent shell. */ destroy() { generation += 1; this.unmount(); } }; }