diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 24003d66..22154ef6 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -475,6 +475,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [nativeDiscovery, setNativeDiscovery] = useState(null) + const nativeRequest = useRef(0) const targetRef = useRef(vmid) targetRef.current = vmid const targetGeneration = useRef(0) @@ -485,6 +486,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } useEffect(() => { const generation = targetGeneration.current + const request = ++nativeRequest.current setNativeDiscovery(null) setEditing(null) setDetectionNotice(null) @@ -492,7 +494,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setSaving(false) if (managed) return fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { - if (targetRef.current === vmid && targetGeneration.current === generation) setNativeDiscovery(result) + if (targetRef.current === vmid && targetGeneration.current === generation + && nativeRequest.current === request) setNativeDiscovery(result) }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) return () => { targetGeneration.current += 1 } }, [vmid, managed]) @@ -787,7 +790,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const searchInstalledApplications = async () => { const generation = targetGeneration.current + const request = ++nativeRequest.current const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation + && nativeRequest.current === request const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug)) setSearchingApplications(true) setDetectionNotice(null) diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 06a77f2e..2ffa802e 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -28,6 +28,7 @@ function fixture({nativeCandidate = candidate} = {}) { const calls = [], cachedWrites = [] let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete' + let cacheGate, resolveCache, cachePending = false const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} const react = { useState(initial) { @@ -47,6 +48,12 @@ function fixture({nativeCandidate = candidate} = {}) { const api = async (url, options = {}) => { calls.push({url, ...options}) if (url.endsWith('/native-discovery')) { + if (options.method !== 'POST' && cachePending) { + cachePending = false + const oldSnapshot = structuredClone(native) + await cacheGate + return oldSnapshot + } if (options.method === 'POST') { if (scanGate) await scanGate native = {...native, status: scanStatus, candidates: [nativeCandidate]} @@ -92,6 +99,8 @@ function fixture({nativeCandidate = candidate} = {}) { } return {render, settle, calls, cachedWrites, button, tree: () => tree, setNative: value => {native = value}, + holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})}, + resolveInitialCache: () => resolveCache(), setScanStatus: value => {scanStatus = value}, holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(), holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(), @@ -133,6 +142,15 @@ async function main() { const hestiaPayload = JSON.parse(hestia.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST').body) assert.deepEqual(hestiaPayload.ports, [{port: 8083, scheme: 'https', web_path: ''}], 'a verified Hestia panel pre-fills only its fixed HTTPS endpoint') + const delayedCache = fixture() + delayedCache.holdInitialCache() + delayedCache.render(); await delayedCache.settle() + await delayedCache.button('searchApplications').props.onClick(); delayedCache.render() + assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server')) + delayedCache.resolveInitialCache(); await delayedCache.settle() + assert.ok(text(delayedCache.tree()).includes('/opt/handmade/server'), + 'a delayed initial cache read must not erase a completed explicit search') + const partial = fixture() partial.render(); await partial.settle(); partial.setScanStatus('partial') await partial.button('searchApplications').props.onClick(); partial.render()