fix(web): resolve feature install status sync and mutual exclusivity (#214)

This commit is contained in:
SnapOtter
2026-06-13 13:44:37 +08:00
parent 4e14e00a4c
commit 492da820f8
4 changed files with 40 additions and 13 deletions
@@ -97,6 +97,12 @@ export function FeatureInstallPrompt({
installBundle(bundle.id); installBundle(bundle.id);
} }
// Defensive guard: if the bundle is already installed (status may have
// been refreshed after mount), never render the install prompt.
if (bundle.status === "installed") {
return null;
}
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4"> <div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4">
+21 -5
View File
@@ -178,7 +178,13 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
startTimes: {}, startTimes: {},
fetch: async () => { fetch: async () => {
if (get().loaded && !get().loadError) return; if (get().loaded && !get().loadError) {
// Already loaded successfully before. Refresh in the background
// so navigating between tool pages picks up status changes
// (e.g. a bundle installed from the settings page).
refreshBundles();
return;
}
try { try {
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features"); const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
set({ bundles: data.bundles, loaded: true, loadError: false }); set({ bundles: data.bundles, loaded: true, loadError: false });
@@ -251,13 +257,23 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
} catch (err) { } catch (err) {
const installing = { ...get().installing }; const installing = { ...get().installing };
delete installing[bundleId]; delete installing[bundleId];
const message = err instanceof Error ? err.message : "Failed to start installation";
const isAlreadyInstalled = /already installed/i.test(message);
if (isAlreadyInstalled) {
// 409 "already installed": clear the error and refresh status
// so the UI transitions to the installed state silently.
const errors = { ...get().errors };
delete errors[bundleId];
set({ installing, errors });
await refreshBundles();
} else {
set({ set({
installing, installing,
errors: { errors: { ...get().errors, [bundleId]: message },
...get().errors,
[bundleId]: err instanceof Error ? err.message : "Failed to start installation",
},
}); });
}
resolveCompletion(bundleId); resolveCompletion(bundleId);
} }
}, },
+6 -3
View File
@@ -123,12 +123,15 @@ describe("useFeaturesStore", () => {
expect(useFeaturesStore.getState().loadError).toBe(true); expect(useFeaturesStore.getState().loadError).toBe(true);
}); });
it("skips fetch if already loaded without error", async () => { it("does a background refresh if already loaded without error", async () => {
useFeaturesStore.setState({ loaded: true, loadError: false }); const bundles = [makeBundleState({ id: "bundle-a" })];
useFeaturesStore.setState({ loaded: true, loadError: false, bundles });
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch(); await useFeaturesStore.getState().fetch();
expect(apiGetMock).not.toHaveBeenCalled(); // Should call apiGet for a background refresh (not short-circuit)
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
}); });
it("retries fetch if previously loaded with error", async () => { it("retries fetch if previously loaded with error", async () => {
+4 -2
View File
@@ -2010,10 +2010,12 @@ describe("useFeaturesStore", () => {
expect(s.loaded).toBe(true); expect(s.loaded).toBe(true);
}); });
it("fetch skips when already loaded without error", async () => { it("fetch triggers background refresh when already loaded without error", async () => {
const bundles = [{ id: "ocr", name: "OCR", status: "installed" }];
mockApiGet.mockResolvedValueOnce({ bundles });
useFeaturesStore.setState({ loaded: true, loadError: false }); useFeaturesStore.setState({ loaded: true, loadError: false });
await useFeaturesStore.getState().fetch(); await useFeaturesStore.getState().fetch();
expect(mockApiGet).not.toHaveBeenCalled(); expect(mockApiGet).toHaveBeenCalledWith("/v1/features");
}); });
it("fetch sets loaded=true and loadError=true on error", async () => { it("fetch sets loaded=true and loadError=true on error", async () => {