feat(02-04): widen KEEP_ALIVE_PATHS to the full audited main-tab set
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m49s

Task 2 of 02-04 — finishes the remaining tabs' lifecycle audit (Apps.vue,
Discover.vue) and widens registration from the 02-02 tracer's single seed
path to every main tab the profiling pass showed remounting.

- Apps.vue: the 15s "unable to connect" timer follows activate/deactivate
  (idempotent re-arm, cleared on exit) and resets connectionError on entry so
  a since-reconnected node doesn't show a stale error instantly; the intro
  flag stays once-per-session.
- Discover.vue: loadCommunityMarketplace/loadBitcoinPruneStatus now route
  through the same shared 'app-catalog'/'bitcoin.prune-status' cache keys
  Marketplace.vue introduced in 02-02, rather than duplicating the fetch;
  RefreshIndicator wired to the catalog resource's loadState. Discover's own
  dynamic-catalog-first fetcher (fetchAppCatalog with a curated-list
  fallback) is preserved as this key's fetcher for this view — both views
  are valid producers of the same shared cache entry.
- Fleet.vue: confirmed no lifecycle side effects (grep for the five tokens
  found none) — left unchanged, registered as-is.
- keepAliveRoutes.ts: KEEP_ALIVE_PATHS now derives from TAB_ORDER (single
  source of truth) plus /dashboard/discover, deliberately withholding
  /dashboard/settings even though it's in TAB_ORDER — Settings.vue's child
  sections (SystemDangerZone's reboot poll interval,
  VpnStatusSection/KioskDisplaySection/TransportPrefsCard/ClaudeAuthSection's
  one-shot onMounted fetches) were never in this plan's file scope and would
  misbehave under KeepAlive exactly as this plan exists to prevent. Every
  other TAB_ORDER path measured Remounted:true or was unmeasured in
  02-FINDINGS.md, so per the plan's literal exclusion rule (only a measured
  Remounted:false excludes) they all stay registered, including Mesh and
  Chat.
- keepAliveLifecycle.test.ts extended (in the prior commit) with
  shouldKeepAlive true/false assertions across every registered path and six
  secondary-screen paths, plus the KEEP_ALIVE_MAX+2 eviction test against the
  real DashboardRouterView + KEEP_ALIVE_PATHS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 15:06:03 -04:00
parent f177a505b4
commit 03a3e4e0c1
3 changed files with 143 additions and 49 deletions

View File

@ -380,7 +380,7 @@ let appsAnimationDone = false
</script>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@ -541,8 +541,19 @@ const isCheckingContainers = computed(() => (
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
// Entry-scoped guard: under a surviving instance the 15s "unable to connect"
// timer would otherwise arm once on the first visit and never again, so its
// diagnosis of a fresh entry would go stale. Re-armed on every activation
// (clearing any prior handle first idempotent, so two consecutive
// activations never double-arm) and cleared on deactivation; the
// onBeforeUnmount clear stays for the non-cached mount path.
function armConnectionGuard() {
if (connectionTimer) clearTimeout(connectionTimer)
// A stale error from a previous visit must not persist into a fresh entry
// otherwise a since-reconnected node would keep showing "Unable to
// connect" instantly (no fresh 15s grace period) once this instance
// survives deactivation instead of fully remounting.
connectionError.value = ''
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
@ -550,6 +561,21 @@ onMounted(() => {
}
}, 15000)
}
}
onActivated(() => armConnectionGuard())
// Once-per-session: the intro stagger flag. Also arms the connection guard
// directly here onActivated is a no-op outside a <KeepAlive> boundary, so
// a bare mount (a unit test, or any future non-KeepAlive usage) must not
// silently skip it. armConnectionGuard() is idempotent, so the harmless
// extra pass this causes on a KeepAlive-wrapped first mount costs nothing.
onMounted(() => {
appsAnimationDone = true
armConnectionGuard()
})
onDeactivated(() => {
if (connectionTimer) clearTimeout(connectionTimer)
})
onBeforeUnmount(() => {

View File

@ -57,6 +57,7 @@
data-controller-no-submit
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
<!-- Mobile: categories + search -->
@ -69,6 +70,7 @@
<div class="flex items-center gap-2 mb-3">
<span class="discover-terminal-tag">discover</span>
<h1 class="text-lg font-bold text-white">App Store</h1>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button
@ -229,7 +231,7 @@ let discoverAnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
@ -239,6 +241,8 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
@ -258,7 +262,59 @@ const appLauncher = useAppLauncherStore()
const selectedCategory = ref('all')
const searchQuery = ref('')
const bitcoinPruned = ref(false)
// Community marketplace + Bitcoin prune-status routed through the SAME
// shared cache keys the 02-02 tracer introduced on Marketplace.vue
// ('app-catalog', 'bitcoin.prune-status') rather than duplicating the
// fetch (02-04). Both views populate the same cache entry, whichever one
// happens to trigger a given fetch the store doesn't care which caller's
// fetcher ran, only the resulting cached value.
const catalogFeatured = ref<CatalogFeatured | null>(null)
const catalogResource = useCachedResource<MarketplaceApp[]>({
key: 'app-catalog',
// Preserves this view's existing dynamic-catalog-first behavior (Marketplace's
// own fetcher for this key is the simpler getCuratedAppList()-only form
// both are valid producers of the same shared cache entry).
fetcher: async () => {
const catalog = await fetchAppCatalog()
if (catalog) {
catalogFeatured.value = catalog.featured
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
return catalog.apps
}
catalogFeatured.value = null
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
return getCuratedAppList()
},
ttlMs: 300_000,
persist: true,
})
const communityApps = computed(() => catalogResource.data.value ?? [])
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here content stays on screen either way.
const communityError = computed(() => catalogResource.error.value ?? '')
function loadCommunityMarketplace() {
return catalogResource.refresh()
}
interface BitcoinStatusResponse {
blockchain_info?: { pruned?: boolean }
}
const pruneStatusResource = useCachedResource<BitcoinStatusResponse | null>({
key: 'bitcoin.prune-status',
fetcher: async (signal) => {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal })
if (!res.ok) throw new Error(`bitcoin-status responded ${res.status}`)
return res.json()
},
persist: true,
})
const bitcoinPruned = computed(() => pruneStatusResource.data.value?.blockchain_info?.pruned === true)
function loadBitcoinPruneStatus() {
return pruneStatusResource.refresh()
}
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const discoverHeaderRef = ref<HTMLElement | null>(null)
const discoverPrimaryRef = ref<HTMLElement | null>(null)
@ -295,10 +351,8 @@ function selectDiscoverCategory(categoryId: string) {
navigateToMarketplace(categoryId)
}
// Community & Nostr marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
const communityApps = ref<MarketplaceApp[]>([])
// Nostr community marketplace state (community/catalog state is declared
// above, alongside the shared catalogResource/pruneStatusResource)
const nostrApps = ref<MarketplaceApp[]>([])
const nostrLoading = ref(false)
const nostrError = ref('')
@ -523,17 +577,6 @@ onBeforeUnmount(() => {
const toast = useToast()
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
}
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
@ -595,29 +638,17 @@ async function installCommunityApp(app: MarketplaceApp, versionOverride?: string
}
}
// Once-per-session: the intro stagger flag. The catalog/prune-status loads
// need no onMounted or onActivated call of their own catalogResource and
// pruneStatusResource are useCachedResource-backed with `immediate` at its
// default (true), so each fetches itself on setup and revalidates itself,
// staleness-gated, on every later reactivation via the composable's own
// onActivated hook (same precedent as Marketplace.vue in 02-02).
onMounted(() => {
discoverAnimationDone = true
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
const catalogFeatured = ref<CatalogFeatured | null>(null)
async function loadCommunityMarketplace() {
loadingCommunity.value = true
communityError.value = ''
// Try dynamic catalog first, fall back to hardcoded
const catalog = await fetchAppCatalog()
if (catalog) {
communityApps.value = catalog.apps
catalogFeatured.value = catalog.featured
if (import.meta.env.DEV) console.log('Loaded app catalog from registry:', catalog.apps.length, 'apps')
} else {
communityApps.value = getCuratedAppList()
if (import.meta.env.DEV) console.log('Using hardcoded app list (catalog.json unavailable)')
}
loadingCommunity.value = false
}
// Exposed for tests only (mirrors Marketplace.vue's own defineExpose for the
// same shared-key resources).
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
</script>

View File

@ -8,21 +8,58 @@
// `server/openwrt`, `web5/credentials`, `goals/:goalId` and `app-session/:appId`.
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { TAB_ORDER } from './useRouteTransitions'
/** Maximum number of main-tab component instances kept alive at once (D-03).
* Bounds memory on low-power fleet nodes the oldest unused instance evicts
* once this cap is exceeded (Vue's own LRU eviction). */
* once this cap is exceeded (Vue's own LRU eviction). Stays at 6 against the
* ~11 registered paths below (FA-D) plan 02-08 tunes it against real
* on-device memory, not guessed at again here. */
export const KEEP_ALIVE_MAX = 6
/** Paths whose component instance survives a tab switch (D-01).
/** TAB_ORDER paths deliberately withheld from the instance cache this plan
* (02-04) would otherwise register. Two distinct reasons populate this set
* kept in one place so `KEEP_ALIVE_PATHS` stays a single derivation:
*
* Seeded with only the tracer tab (Marketplace) for this plan plan 02-04
* widens this set after auditing every main tab's mount/activate lifecycle.
* Do not add paths here without doing that audit first: a view with an
* un-audited side effect in `onMounted` that should have moved to
* `onActivated`/`onDeactivated` would misbehave the moment it's kept alive. */
* 1. Measured `already fast` in 02-FINDINGS.md with `Remounted: false` no
* instance cache to gain (D-02). (None as of 02-04: every TAB_ORDER path
* measured `Remounted: true` (Home, Apps, Marketplace, Cloud, Web5,
* Fleet) or was `unmeasured` (Mesh, Chat) neither unmeasured row has a
* `Remounted: false`, so per the plan's literal exclusion rule both stay
* registered, and their lifecycle was audited in 02-04's Task 1.)
* 2. Unaudited-risk (02-04 Rule-3 deviation, not in 02-FINDINGS.md at all):
* `/dashboard/settings` is in TAB_ORDER but neither Settings.vue nor any
* of its child sections are in this plan's Task 1/2 file lists. A grep
* across `neode-ui/src/views/settings/*.vue` found real un-audited side
* effects that would misbehave under KeepAlive exactly as this plan
* warns against: `SystemDangerZone.vue`'s reboot poll/elapsed intervals,
* and one-shot `onMounted`-only fetches in `VpnStatusSection.vue`,
* `KioskDisplaySection.vue`, `TransportPrefsCard.vue` and
* `ClaudeAuthSection.vue` that would never refresh again once cached.
* Registering Settings without that audit would ship the exact
* off-screen-timer/stale-data bug this plan exists to prevent excluded
* until a future plan audits it the way Task 1 did for Home/Web5/Chat/
* Cloud/Server/Mesh. */
const WITHHELD_FROM_CACHE: ReadonlySet<string> = new Set([
'/dashboard/settings',
])
/** Paths whose component instance survives a tab switch (D-01), derived from
* the single-sourced TAB_ORDER main-tab list (plus `/dashboard/discover`,
* the App Store's second tab, measured `remount storm` in 02-FINDINGS.md but
* not part of TAB_ORDER) rather than restating every path literally a
* future tab addition to TAB_ORDER is registered automatically instead of
* silently missing registration.
*
* 02-04 widened this from the 02-02 tracer's single seed path after
* auditing every main tab's mount/activate/deactivate lifecycle (see
* 02-04-SUMMARY.md's per-view side-effect table) — every registered path's
* timers, subscriptions and window listeners are now placed for an
* activate/deactivate lifecycle, so it's safe for their instances to
* survive a tab switch. */
export const KEEP_ALIVE_PATHS: ReadonlySet<string> = new Set([
'/dashboard/marketplace',
...TAB_ORDER.filter((path) => !WITHHELD_FROM_CACHE.has(path)),
'/dashboard/discover',
])
/** True only for an exact path match against KEEP_ALIVE_PATHS. */