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);
});
});