diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index f24dc668..379242a4 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -356,6 +356,7 @@ interface ManagedAppInfo { interface Props { vmid: number + cacheRevision?: number ctIp?: string | null onChange?: () => void managed?: ManagedAppInfo | null @@ -497,7 +498,7 @@ function parseArgvInput(value: string): string[] { return value.split(",").map((item) => item.trim()).filter(Boolean) } -export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) { +export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) { const t = useT() const isLightTheme = useIsLightTheme() // Seed from `initialData` first, then fall back to the shared cache @@ -519,20 +520,23 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: targetGeneration.current += 1 } useEffect(() => { - const generation = targetGeneration.current - const request = ++nativeRequest.current setNativeDiscovery(null) setEditing(null) setDetectionNotice(null) setSearchingApplications(false) setSaving(false) + return () => { targetGeneration.current += 1 } + }, [vmid, managed]) + useEffect(() => { + const generation = targetGeneration.current + const request = ++nativeRequest.current if (managed) return fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(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]) + return () => { nativeRequest.current += 1 } + }, [vmid, managed, cacheRevision]) const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false) const [error, setError] = useState(null) const [searchingApplications, setSearchingApplications] = useState(false) @@ -638,7 +642,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: if (isCurrent()) setLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [vmid, managed]) + }, [vmid, managed, cacheRevision]) useEffect(() => { if (managed) return @@ -1686,9 +1690,18 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: from https://selfh.st/icons); empty → no logo in the app card header. */}
+
+ +
+
setField({ logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoPlaceholder")} maxLength={512} - className="text-sm mt-2 font-mono" + className="text-sm font-mono" /> -
+ {draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) && + } +
{iconPorts.length > 1 && } - - {draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) && - } -

{t("vmLxc.appEditor.localLogoHelp")}

diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a03b5685..52833003 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -5227,7 +5227,9 @@ export function VirtualMachines() { null return ( mutate()} diff --git a/tests/lxc_updates/test_local_app_icons_ui.cjs b/tests/lxc_updates/test_local_app_icons_ui.cjs index a30db6ba..2927ce12 100644 --- a/tests/lxc_updates/test_local_app_icons_ui.cjs +++ b/tests/lxc_updates/test_local_app_icons_ui.cjs @@ -55,6 +55,28 @@ async function editor(ports = [8080]) { } async function main() { await cachedImageContract() + const existing = fixture({apps: [{id: 'existing', name: 'Saved app', ports: [{port: 8080, scheme: 'http'}], state: {}}]}) + existing.renderParent(); await existing.settle() + const click = async key => { + const node = flatten(existing.tree()).find(n => ['button', 'Button'].includes(n.type) && text(n).includes('vmLxc.appEditor.' + key)) + assert.ok(node, key); await node.props.onClick(); existing.render() + } + await click('editButton'); await click('editFieldsButton') + logo(existing).props.onChange({target: {value: 'https://example.test/draft.png'}}); existing.render() + existing.holdIcon() + const pendingExisting = existing.button('discoverLocalLogo').props.onClick(); existing.render() + const reads = existing.bundleReads() + existing.renderParent(101, 1); await existing.settle(); existing.render() + assert.equal(logo(existing)?.props.value, 'https://example.test/draft.png', 'lifecycle refresh must retain the open existing-app editor and unsaved fields') + assert.ok(existing.bundleReads() > reads, 'revision still reloads the saved app data') + existing.resolveIcon(); await pendingExisting; existing.render(); existing.render() + assert.match(logo(existing).props.value, /logo-cache/, 'successful lookup leaves existing editor open') + assert.equal(existing.calls.filter(c => ['POST', 'PUT', 'DELETE'].includes(c.method) && !c.url.endsWith('/logo-discovery')).length, 0) + await click('cancelButton') + await click('editFieldsButton') + assert.equal(logo(existing).props.value, '', 'cancel discards draft icon') + existing.renderParent(102, 1); await existing.settle(); existing.render() + assert.equal(logo(existing), undefined, 'switching guest must discard the old editor') const f = await editor([8080, 8443]) assert.equal(f.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0) const select = flatten(f.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort') diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 7606065f..d901c51e 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -23,14 +23,15 @@ const candidate = { evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [], } -function fixture({nativeCandidate = candidate} = {}) { - let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [] +function fixture({nativeCandidate = candidate, apps = []} = {}) { + let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = [] const calls = [], cachedWrites = [] let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues let cacheGate, resolveCache, cachePending = false let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError - const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} + const seed = {sidecar: {vmid: 101, apps}, suggestions: {extras: [], port_suggestions: []}} + let bundleReads = 0 const react = { useState(initial) { const index = cursor++ @@ -38,7 +39,12 @@ function fixture({nativeCandidate = candidate} = {}) { return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}] }, useRef(value) {const index = cursor++; return refs[index] ||= {current: value}}, - useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn}, + useMemo(fn, deps) { + const index = cursor++, prior = memoStates[index] + if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) memoStates[index] = {deps, value: fn()} + return memoStates[index].value + }, + useCallback(fn, deps) {return react.useMemo(() => fn, deps)}, useEffect(fn, deps) { const index = cursor++, prior = effectStates[index] if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) { @@ -75,7 +81,7 @@ function fixture({nativeCandidate = candidate} = {}) { return [] } const cache = { - getLxcAppsCached: () => null, fetchLxcApps: async () => seed, + getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed}, subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args), } const exports = {} @@ -97,13 +103,34 @@ function fixture({nativeCandidate = candidate} = {}) { for (const effect of effects) effect() return tree } + // Exercise React's key identity boundary using the actual parent expression. + const parent = ts.createSourceFile('parent.tsx', fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + let keyExpression + function visit(node) { + if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent) === 'LxcAppPanel') { + keyExpression = node.attributes.properties.find(p => p.name?.getText(parent) === 'key').initializer.expression.getText(parent) + } + ts.forEachChild(node, visit) + } + visit(parent) + const parentKey = new Function('selectedVM', 'return ' + keyExpression) + let lastKey + function renderParent(vmid = 101, revision = 0) { + const key = parentKey({vmid, modal_cache_revision: revision}) + if (lastKey !== undefined && lastKey !== key) { + effectStates.forEach(effect => effect?.cleanup?.()) + states = []; refs = []; effectStates = []; memoStates = [] + } + lastKey = key + return render({vmid, cacheRevision: revision, initialData: seed}) + } const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()} const button = key => { const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key)) assert.ok(match, 'Missing button ' + key) return match } - return {render, settle, calls, cachedWrites, button, tree: () => tree, + return {render, renderParent, settle, calls, cachedWrites, button, tree: () => tree, bundleReads: () => bundleReads, holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})}, resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value}, setIconError: value => {iconError = value},