feature: framework-level abort handling for page lifecycle

component.js now creates an AbortController for each page mount, passing
it to load(). On unmount, the controller is aborted to cancel in-flight
requests that would otherwise mutate unmounted state.

Page load functions consistently pass the signal to apiFetch and guard
state mutations with abort checks. This eliminates the need for per-page
abortController boilerplate and prevents stale errors from appearing on
rapid navigation.

Users page now guards catch block and loading state cleanup against
aborted requests, matching passkeys.js pattern.
This commit is contained in:
2026-07-28 02:52:28 +00:00
parent d52a0fad12
commit ca27ea5522
19 changed files with 43 additions and 34 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=10'; import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=11';
import DashboardPage from '/static/pages/dashboard.js?v=11'; import DashboardPage from '/static/pages/dashboard.js?v=11';
import InterfacesPage from '/static/pages/interfaces.js?v=9'; import InterfacesPage from '/static/pages/interfaces.js?v=9';
+13 -8
View File
@@ -70,17 +70,19 @@ export function mountComponent(key, renderer) {
if (entry) { if (entry) {
// Re-mount: component already exists with its state. // Re-mount: component already exists with its state.
// Don't re-run load — that re-render was triggered by a reactive update. // Abort previous in-flight load and re-run.
return; if (entry.abortController) entry.abortController.abort();
entry.abortController = null;
} else {
entry = { state: pd.state };
_mounted.set(key, entry);
pd.state.error = null;
} }
entry = { state: pd.state };
_mounted.set(key, entry);
pd.state.error = null;
if (pd.load) { if (pd.load) {
Promise.resolve().then(() => pd.load(pd.state)); const abortController = new AbortController();
entry.abortController = abortController;
Promise.resolve().then(() => pd.load(pd.state, abortController));
} }
} }
@@ -94,6 +96,9 @@ export function unmountComponent(key, renderer) {
const pd = renderer._pageDef; const pd = renderer._pageDef;
// Abort in-flight load requests so they don't mutate unmounted state
if (entry.abortController) entry.abortController.abort();
if (pd.onUnmount) { if (pd.onUnmount) {
try { pd.onUnmount(entry.state); } catch (_) {} try { pd.onUnmount(entry.state); } catch (_) {}
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export { html } from './html.js?v=9';
export { render } from './render.js?v=9'; export { render } from './render.js?v=9';
/* ── Component ───────────────────────────────────────────────── */ /* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js?v=9'; export { definePage, hComp } from './component.js?v=10';
/* ── Router ──────────────────────────────────────────────────── */ /* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js?v=9'; export { createRouter, Link } from './router.js?v=9';
+1 -1
View File
@@ -10,7 +10,7 @@ import {
_vnodeDom, createDom, getDom, patchNode, sweepDom, _vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn, setMountFn, setUnmountFn,
} from './vdom.js?v=9'; } from './vdom.js?v=9';
import { mountComponent, unmountComponent } from './component.js?v=9'; import { mountComponent, unmountComponent } from './component.js?v=10';
/** Container → previous root vnodes */ /** Container → previous root vnodes */
export const _renderSlots = new Map(); export const _renderSlots = new Map();
+1 -1
View File
@@ -1,4 +1,4 @@
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js?v=9'; import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js?v=10';
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js?v=9'; import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js?v=9';
import { _deleting } from '/static/hoover/components/data.js?v=9'; import { _deleting } from '/static/hoover/components/data.js?v=9';
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js?v=10';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9'; import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
function _accountCard(account) { function _accountCard(account) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js?v=10'; import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js?v=11';
function fmtBytes(bytes) { function fmtBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=9'; import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=10';
function makeAddRange(activeZones, interfaces) { function makeAddRange(activeZones, interfaces) {
const opts = [ const opts = [
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=10';
async function changeZone(name, zone, state) { async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
+2 -2
View File
@@ -16,7 +16,7 @@ import {
handleLoginSuccess, handleLoginSuccess,
webauthnSupported, webauthnSupported,
startAuthentication, startAuthentication,
} from '/static/hoover/index.js?v=12'; } from '/static/hoover/index.js?v=14';
function LoginPage() { function LoginPage() {
const hasWebAuthn = webauthnSupported(); const hasWebAuthn = webauthnSupported();
@@ -214,7 +214,7 @@ const Page = definePage({
if (getAuthToken()) { if (getAuthToken()) {
try { try {
const res = await apiFetch('/api/auth/session'); const res = await apiFetch('/api/auth/session', { signal: abortController?.signal });
if (res.ok) { if (res.ok) {
window.location.hash = '/dashboard'; window.location.hash = '/dashboard';
return; return;
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=10';
const logTabs = [ const logTabs = [
{ key: 'journal', label: 'Journal' }, { key: 'journal', label: 'Journal' },
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=10';
const addFwd = QuickModal({ const addFwd = QuickModal({
title: 'Add Port Forward', title: 'Add Port Forward',
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage } from '/static/hoover/index.js?v=9'; import { html, PageHeader, definePage } from '/static/hoover/index.js?v=10';
export default definePage({ export default definePage({
init() { init() {
+1 -1
View File
@@ -25,7 +25,7 @@ import {
webauthnSupported, webauthnSupported,
isModalProcessing, isModalProcessing,
setModalProcessing, setModalProcessing,
} from '/static/hoover/index.js?v=12'; } from '/static/hoover/index.js?v=14';
const state = reactive({ credentials: [], loading: true, refreshing: false, error: null }); const state = reactive({ credentials: [], loading: true, refreshing: false, error: null });
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, formAction } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, formAction } from '/static/hoover/index.js?v=10';
import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9'; import { openModal, formModal, closeModal } from '/static/hoover/components/modal.js?v=9';
import { openBackendModal } from '/static/pages/backends.js?v=11'; import { openBackendModal } from '/static/pages/backends.js?v=11';
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=10';
const addRule = QuickModal({ const addRule = QuickModal({
title: 'Add Rich Rule', title: 'Add Rich Rule',
+12 -8
View File
@@ -5,8 +5,8 @@
* Requires auth: rw permission. * Requires auth: rw permission.
*/ */
import { h, definePage, reactive } from '/static/hoover/index.js?v=11'; import { h, definePage, reactive } from '/static/hoover/index.js?v=13';
import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=11'; import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=13';
const SUBSYSTEMS = [ const SUBSYSTEMS = [
{ key: 'firewall', label: 'Firewall' }, { key: 'firewall', label: 'Firewall' },
@@ -40,8 +40,8 @@ async function loadUsers(abortController) {
try { try {
const [usersRes, countsRes] = await Promise.all([ const [usersRes, countsRes] = await Promise.all([
apiFetch('/api/auth/users'), apiFetch('/api/auth/users', { signal: abortController?.signal }),
apiFetch('/api/auth/webauthn/credential-counts'), apiFetch('/api/auth/webauthn/credential-counts', { signal: abortController?.signal }),
]); ]);
if (abortController?.signal?.aborted) return; if (abortController?.signal?.aborted) return;
@@ -55,11 +55,15 @@ async function loadUsers(abortController) {
state.error = usersRes.error || 'Failed to load users'; state.error = usersRes.error || 'Failed to load users';
} }
} catch (e) { } catch (e) {
state.error = 'Failed to load users'; if (!abortController?.signal?.aborted) {
state.error = 'Failed to load users';
}
} finally {
if (!abortController?.signal?.aborted) {
state.loading = false;
state.refreshing = false;
}
} }
state.loading = false;
state.refreshing = false;
}
function permissionLevel(perms, subsystem) { function permissionLevel(perms, subsystem) {
return perms[subsystem] || '—'; return perms[subsystem] || '—';
+1 -1
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */ /** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js?v=10';
/* ── LAN detection helper ────────────────────────────────────── */ /* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() { function getLanSubnets() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=9'; import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=10';
const addZone = QuickModal({ const addZone = QuickModal({
title: 'Add Zone', title: 'Add Zone',