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
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:
parent
f177a505b4
commit
03a3e4e0c1
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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. */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user