fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import

Two-part fix for the reporter's "removed profiles still show on the slice
  menu" symptom.

  Local half (real bug). LocalProfilesView's import and delete mutations
  invalidated ['localPresets'] (the management view's own query) but not
  ['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
  A freshly-deleted preset kept rendering in the slice dropdown until that
  staleTime elapsed plus a refocus/remount. Both mutations now also call
  queryClient.invalidateQueries({queryKey: ['slicerPresets']}).

  Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
  per-(user, token) in-process cache (slicer_presets.py:69, balances
  "users see freshly-saved presets quickly" against "busy install doesn't
  hit Bambu Cloud once per modal open"). Users delete cloud presets in
  Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
  to invalidate on. Rather than shorten the TTL globally, the listing
  endpoint gains an opt-in ?refresh=true query param that bypasses both
  the cloud cache AND the 1-hour bundled-preset cache for that one call;
  the fresh result is still written back so subsequent normal callers
  keep hitting the cache.

  New SliceModal "Refresh" button. Lives in the preset section header
  next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
  and writes the fresh slots into the ['slicerPresets'] cache via
  queryClient.setQueryData so the spinner stops immediately rather than
  triggering a second refetch. RefreshCw icon spins while in-flight;
  disabled during slice enqueue to prevent double-fire.
This commit is contained in:
maziggy
2026-06-02 12:42:16 +02:00
parent 396e9aa09e
commit 171848a0fc
19 changed files with 495 additions and 142 deletions
@@ -1,11 +1,17 @@
/**
* Tests for LocalProfilesView component.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor, fireEvent, render as rawRender } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { server } from '../mocks/server';
import { render } from '../utils';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { LocalProfilesView } from '../../components/LocalProfilesView';
const mockLocalPresets = {
@@ -209,4 +215,62 @@ describe('LocalProfilesView', () => {
expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
});
it('invalidates the slicerPresets query after a delete (#1581)', async () => {
// Without this invalidation a preset deleted in Local Profiles still
// shows in the SliceModal until the modal's ['slicerPresets'] query
// staleTime (60s) expires + a refocus / remount. The bug report:
// "Removed local profiles still show on the slice menu even tho they
// have been deleted." We mirror the production provider tree but inject
// our own QueryClient so we can spy on invalidateQueries.
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
rawRender(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<LocalProfilesView />
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
});
// Open the delete confirmation, then confirm. The card-level delete
// icon buttons and the modal's Delete button both expose the accessible
// name "Delete"; the modal button is the last one to mount, so it's the
// tail of the findAllByRole result.
const deleteButtons = screen.getAllByTitle(/delete/i);
fireEvent.click(deleteButtons[0]);
await screen.findByText(/are you sure/i);
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(
invalidateSpy.mock.calls.some(
([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'slicerPresets',
),
).toBe(true);
});
// Sanity: the local-only invalidation is still there too.
expect(
invalidateSpy.mock.calls.some(
([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'localPresets',
),
).toBe(true);
});
});
+7 -2
View File
@@ -5796,8 +5796,13 @@ export const api = {
// Unified slicer-preset listing — cloud + local + standard, deduped by name.
// Used by the SliceModal; see UnifiedPresetsResponse for the shape and
// backend/app/api/routes/slicer_presets.py for the priority rules.
getSlicerPresets: () =>
request<UnifiedPresetsResponse>('/slicer/presets'),
// `refresh` bypasses the in-process cloud and bundled-preset caches on the
// backend; the SliceModal's Refresh button passes true so a preset deleted
// in Bambu Studio or Bambu Handy shows up without the 5-min TTL wait.
getSlicerPresets: (options?: { refresh?: boolean }) =>
request<UnifiedPresetsResponse>(
options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
),
// Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
// Single source of truth shared with backend (PRINTER_MODEL_MAP); the
@@ -245,6 +245,11 @@ export function LocalProfilesView() {
},
onSuccess: (results) => {
queryClient.invalidateQueries({ queryKey: ['localPresets'] });
// The SliceModal reads from a separate `slicerPresets` query that lists
// cloud + local + standard in one shot. Without this second invalidation
// freshly-imported profiles wouldn't appear in the SliceModal dropdown
// until that query's staleTime elapsed plus a refocus / remount (#1581).
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
let totalImported = 0;
let totalSkipped = 0;
let totalErrors = 0;
@@ -273,6 +278,11 @@ export function LocalProfilesView() {
mutationFn: (id: number) => api.deleteLocalPreset(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['localPresets'] });
// Match the import path: the SliceModal's `slicerPresets` query needs
// to be invalidated too, otherwise the deleted preset keeps appearing
// in the slice dropdown until its 60s staleTime expires plus a
// refocus / remount (#1581).
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
setDeleteConfirm(null);
showToast(t('profiles.localProfiles.toast.deleted'));
},
+45 -3
View File
@@ -1,7 +1,7 @@
import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
import { Cloud, CloudOff, Cog, Loader2, Package, RefreshCw, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
api,
type PresetRef,
@@ -312,6 +312,7 @@ function formatElapsed(seconds: number): string {
export function SliceModal({ source, onClose }: SliceModalProps) {
const { t } = useTranslation();
const { trackJob } = useSliceJobTracker();
const queryClient = useQueryClient();
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
@@ -431,6 +432,28 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
enabled: !platesQuery.isLoading && !needsPlatePicker,
});
// Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
// bundled cache for one call so users who deleted a preset in Bambu
// Studio / Bambu Handy see the change immediately (#1581). The cache write
// inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
// fresh result so subsequent normal callers still get cached responses.
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefreshPresets = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
const fresh = await api.getSlicerPresets({ refresh: true });
queryClient.setQueryData(['slicerPresets'], fresh);
} catch {
// Fall through to invalidate so React Query retries via its normal
// path on the next render — surfacing the failure through the existing
// presetsQuery.isError banner instead of duplicating error UI here.
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
} finally {
setIsRefreshing(false);
}
};
// Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
// configured / no bundles imported yet; the bundle picker hides itself
// in that case so users without bundles see the original modal layout.
@@ -723,7 +746,26 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
{presetsQuery.data && (
<>
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
</div>
<button
type="button"
onClick={handleRefreshPresets}
disabled={isRefreshing || isEnqueuing}
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={t('slice.refreshPresetsTitle')}
aria-label={t('slice.refreshPresets')}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
{t('slice.refreshPresets')}
</button>
</div>
{/* CloudStatusBanner above is hidden via flex-1 wrapper when
status === 'ok' (returns null in that case), but the Refresh
button stays visible regardless so users can pick up cloud /
bundled changes even when sign-in is healthy. */}
{/* Bundle picker — only renders when at least one .bbscfg has
been imported via Settings → Slicer Bundles. Lets the user
trade the cloud/local/standard tier for a single curated
+2
View File
@@ -3481,6 +3481,8 @@ export default {
noPresetsForSlot: 'Keine Profile verfügbar',
otherPrinters: 'Andere Drucker',
presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
refreshPresets: 'Aktualisieren',
refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
bundle: 'Slicer-Bundle',
bundleNone: '— Keines (Profile einzeln auswählen) —',
+2
View File
@@ -3484,6 +3484,8 @@ export default {
noPresetsForSlot: 'No presets available',
otherPrinters: 'Other printers',
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
refreshPresets: 'Refresh',
refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
allPresetsRequired: 'All presets must be selected',
bundle: 'Slicer bundle',
bundleNone: '— None (pick presets individually) —',
+2
View File
@@ -3484,6 +3484,8 @@ export default {
noPresetsForSlot: 'No hay preajustes disponibles',
otherPrinters: 'Otras impresoras',
presetsLoadFailed: 'Error al cargar los preajustes. Abra Ajustes → Perfiles para importarlos primero.',
refreshPresets: 'Actualizar',
refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
allPresetsRequired: 'Deben seleccionarse todos los preajustes',
bundle: 'Paquete del laminador',
bundleNone: '— Ninguno (elegir preajustes individualmente) —',
+2
View File
@@ -3470,6 +3470,8 @@ export default {
noPresetsForSlot: 'Aucun préréglage disponible',
otherPrinters: 'Autres imprimantes',
presetsLoadFailed: 'Échec du chargement des préréglages. Ouvrez Paramètres → Profils pour les importer d\'abord.',
refreshPresets: 'Actualiser',
refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
bundle: 'Pack de découpage',
bundleNone: '— Aucun (choisir les préréglages individuellement) —',
+2
View File
@@ -3469,6 +3469,8 @@ export default {
noPresetsForSlot: 'Nessun preset disponibile',
otherPrinters: 'Altre stampanti',
presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
refreshPresets: 'Aggiorna',
refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
allPresetsRequired: 'Tutti i preset devono essere selezionati',
bundle: 'Bundle slicer',
bundleNone: '— Nessuno (scegli i preset singolarmente) —',
+2
View File
@@ -3481,6 +3481,8 @@ export default {
noPresetsForSlot: 'プリセットなし',
otherPrinters: '他のプリンター',
presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
refreshPresets: '再読み込み',
refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
allPresetsRequired: 'すべてのプリセットを選択する必要があります',
bundle: 'スライサーバンドル',
bundleNone: '— なし(プリセットを個別に選択)—',
+2
View File
@@ -3469,6 +3469,8 @@ export default {
noPresetsForSlot: 'Nenhuma predefinição disponível',
otherPrinters: 'Outras impressoras',
presetsLoadFailed: 'Falha ao carregar predefinições. Abra Configurações → Perfis para importá-las primeiro.',
refreshPresets: 'Atualizar',
refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
bundle: 'Pacote do fatiador',
bundleNone: '— Nenhum (escolher predefinições individualmente) —',
+2
View File
@@ -3469,6 +3469,8 @@ export default {
noPresetsForSlot: '无可用预设',
otherPrinters: '其他打印机',
presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
refreshPresets: '刷新',
refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
allPresetsRequired: '必须选择所有预设',
bundle: '切片器套装',
bundleNone: '— 无(单独选择预设)—',
+2
View File
@@ -3469,6 +3469,8 @@ export default {
noPresetsForSlot: '無可用預設',
otherPrinters: '其他印表機',
presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
refreshPresets: '重新整理',
refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
allPresetsRequired: '必須選擇所有預設',
bundle: '切片器套裝',
bundleNone: '— 無(單獨選擇預設)—',