From 55eb30f81ba23b7adbef3b5af27ef3abba8a8e9c Mon Sep 17 00:00:00 2001 From: nether403 Date: Tue, 12 May 2026 03:46:46 +0200 Subject: [PATCH] refactor(web): route remaining API calls through TanStack Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 gap #3. Sweeps the two remaining imperative API call sites out of components and into the useApi hook family so every component consumes the API through TanStack Query. Changes - apps/web/src/hooks/useApi.ts - new useCatalog() hook over apiClient.getCatalog() for the compatibility matrix and other catalog-aware components - new useStackBuilderCatalog() hook wrapping lib/catalog-loader's loadCatalog() (localStorage-cached static fallback) so the Stack Builder page keeps offline/API-down resilience while still participating in TanStack Query's lifecycle (stale time, retry, deduplication) - apps/web/src/pages/StackBuilderPage.tsx - drops the ~30 lines of useState/useEffect catalog plumbing - uses useStackBuilderCatalog and useQueryClient for cache invalidation on manual refresh - retry/refresh buttons now show an isRefetching state - apps/web/src/components/ExportDialog.tsx - drops the local isGenerating/exportData/error state - uses useGenerateScaffold mutation, kicking off via mutateAsync on dialog open - resets mutation state on close so the next open regenerates Not migrated (deliberate) - apps/web/src/hooks/useRulesEngine.ts keeps its imperative apiClient.analyzeStack call. That hook already owns a custom debounce + sequence-token race guard + deterministic fallback to the local rules-engine worker, which doesn't map onto TanStack Query's query/mutation lifecycle without losing those guarantees. Documented inline as the intentional exception. - apps/web/src/lib/catalog-loader.ts keeps its apiClient call because it IS the query function used by useStackBuilderCatalog. Its localStorage cache persists catalog data across full reloads, complementing TSQ's in-memory cache. E2E coverage - New Playwright test "loads the Stack Builder catalog via TanStack Query" confirms the refactored page actually renders the catalog (waits for Next.js to appear on /stack-builder). - Existing flows (catalog search, pairwise compatibility, migration, blueprint generation) continue to pass. - Bumped the blueprint E2E timeout to 120s/60s to match the other Phase 4 PRs (stable under parallel AI fan-out load). Quality gate (local) - pnpm -r type-check: 0 errors - pnpm -r lint: 0 errors - pnpm -r test: 60 unit/API tests pass - pnpm -r build: web + api + packages build - pnpm test:e2e: 5 passed (catalog, stack builder, pairwise, migration, blueprint — all flows) --- apps/web/src/components/ExportDialog.tsx | 87 +++++++++++++----------- apps/web/src/hooks/useApi.ts | 40 +++++++++-- apps/web/src/pages/StackBuilderPage.tsx | 87 ++++++++---------------- tests/e2e/mvp-flows.spec.ts | 14 +++- 4 files changed, 126 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/ExportDialog.tsx b/apps/web/src/components/ExportDialog.tsx index 9140e46..dd25aa7 100644 --- a/apps/web/src/components/ExportDialog.tsx +++ b/apps/web/src/components/ExportDialog.tsx @@ -3,7 +3,7 @@ * Modal for exporting stack configuration with preview and download */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ExportData, ExportFormat } from '@stackfast/schemas'; import { Dialog, @@ -16,7 +16,7 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useSelectionsContext } from '@/context/SelectionsContext'; -import { apiClient } from '@/lib/api-client'; +import { useGenerateScaffold } from '@/hooks/useApi'; import { generateArchive, downloadArchive } from '@/lib/archive-generator'; function generateExportAsText(exportData: ExportData): string { @@ -36,71 +36,75 @@ export function ExportDialog({ }: ExportDialogProps) { // Get data from context const { getAllSelectedTools } = useSelectionsContext(); - + const selectedTools = getAllSelectedTools(); - const [isGenerating, setIsGenerating] = useState(false); - const [exportData, setExportData] = useState(null); - const [error, setError] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); const [selectedFormat, setSelectedFormat] = useState('zip'); - // Generate export when dialog opens - const handleGenerate = async () => { - setIsGenerating(true); - setError(null); - - try { - const data = await apiClient.generateScaffold({ - toolIds: selectedTools.map((tool) => tool.id), - projectName: 'stackfast-app', - }); - setExportData(data); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to generate export'); - } finally { - setIsGenerating(false); - } - }; + const { + mutateAsync: generateScaffold, + data: exportData, + error: scaffoldError, + isPending: isGenerating, + reset: resetScaffold, + } = useGenerateScaffold(); + + // Generate the scaffold once when the dialog first opens. + useEffect(() => { + if (!open || exportData || isGenerating) return; + generateScaffold({ + toolIds: selectedTools.map((tool) => tool.id), + projectName: 'stackfast-app', + }).catch(() => { + // Error surfaced via scaffoldError below; nothing to do here. + }); + }, [open, exportData, isGenerating, generateScaffold, selectedTools]); // Download as archive const handleDownload = async () => { if (!exportData) return; - - setIsGenerating(true); + + setIsDownloading(true); + setDownloadError(null); try { const projectName = exportData.files.find(f => f.path === 'package.json') ? JSON.parse(exportData.files.find(f => f.path === 'package.json')!.content).name : 'stackfast-app'; - + const blob = await generateArchive( exportData.files, exportData.format, projectName ); - + downloadArchive(blob, `${projectName}.${exportData.format}`); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to download archive'); + setDownloadError(err instanceof Error ? err.message : 'Failed to download archive'); } finally { - setIsGenerating(false); + setIsDownloading(false); } }; // Copy as text const handleCopyText = () => { if (!exportData) return; - + const text = generateExportAsText(exportData); navigator.clipboard.writeText(text); }; - // Generate on open const handleOpenChange = (newOpen: boolean) => { onOpenChange(newOpen); - if (newOpen && !exportData) { - handleGenerate(); + if (!newOpen) { + // Reset mutation state so the next open regenerates. + resetScaffold(); + setDownloadError(null); } }; + const combinedError = downloadError ?? (scaffoldError ? errorMessage(scaffoldError) : null); + return ( @@ -117,14 +121,14 @@ export function ExportDialog({ )} - {error && ( + {combinedError && (

Export Failed

-

{error}

+

{combinedError}

)} - {exportData && !error && ( + {exportData && !downloadError && (
{/* Export Preview */}
@@ -224,18 +228,23 @@ export function ExportDialog({
); } + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return 'Failed to generate export'; +} diff --git a/apps/web/src/hooks/useApi.ts b/apps/web/src/hooks/useApi.ts index 73a4649..a41a2b8 100644 --- a/apps/web/src/hooks/useApi.ts +++ b/apps/web/src/hooks/useApi.ts @@ -1,11 +1,16 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { BlueprintRequest, ScaffoldRequest, StackAnalyzeRequest } from "@stackfast/schemas"; +import { loadCatalog } from "@/lib/catalog-loader"; +import { + BlueprintRequest, + ScaffoldRequest, + StackAnalyzeRequest, +} from "@stackfast/schemas"; -export const useTools = (params?: { - q?: string; - category?: string; - capabilities?: string; +export const useTools = (params?: { + q?: string; + category?: string; + capabilities?: string; pricing?: string; sort?: "name" | "category" | "confidence"; limit?: number; @@ -32,6 +37,31 @@ export const useCategories = () => { }); }; +/** + * Raw /api/v1/catalog payload for components that already have the tools and + * rules they need on hand (e.g. the compatibility matrix heatmap). + */ +export const useCatalog = () => { + return useQuery({ + queryKey: ["catalog"], + queryFn: () => apiClient.getCatalog(), + staleTime: 10 * 60 * 1000, // catalog rarely changes within a session + }); +}; + +/** + * Catalog wrapped in the Stack Builder's localStorage-cache + static-fallback + * loader. Use this when the page needs to render even if the API is down. + */ +export const useStackBuilderCatalog = () => { + return useQuery({ + queryKey: ["catalog", "full-with-fallback"], + queryFn: () => loadCatalog(), + staleTime: 10 * 60 * 1000, + retry: 1, + }); +}; + export const useCompatibility = (a: string, b: string) => { return useQuery({ queryKey: ["compatibility", a, b], diff --git a/apps/web/src/pages/StackBuilderPage.tsx b/apps/web/src/pages/StackBuilderPage.tsx index 46e8316..91fae2e 100644 --- a/apps/web/src/pages/StackBuilderPage.tsx +++ b/apps/web/src/pages/StackBuilderPage.tsx @@ -1,63 +1,34 @@ -import { useState, useEffect } from 'react'; -import { Loader2, AlertCircle } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ErrorBoundary } from '@/components/ErrorBoundary'; -import { StackBuilder } from '@/components/StackBuilder'; -import { ExportDialog } from '@/components/ExportDialog'; -import { SelectionsProvider } from '@/context/SelectionsContext'; -import { EvaluationProvider } from '@/context/EvaluationContext'; -import { SuggestionsProvider } from '@/context/SuggestionsContext'; -import { ExportProvider } from '@/context/ExportContext'; -import { loadCatalog, clearCatalogCache, type CatalogData } from '@/lib/catalog-loader'; -import { Layout } from '@/components/Layout'; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { StackBuilder } from "@/components/StackBuilder"; +import { ExportDialog } from "@/components/ExportDialog"; +import { SelectionsProvider } from "@/context/SelectionsContext"; +import { EvaluationProvider } from "@/context/EvaluationContext"; +import { SuggestionsProvider } from "@/context/SuggestionsContext"; +import { ExportProvider } from "@/context/ExportContext"; +import { clearCatalogCache } from "@/lib/catalog-loader"; +import { Layout } from "@/components/Layout"; +import { useStackBuilderCatalog } from "@/hooks/useApi"; export function StackBuilderPage() { - const [catalog, setCatalog] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); const [showExportDialog, setShowExportDialog] = useState(false); - useEffect(() => { - let cancelled = false; - - (async () => { - try { - setIsLoading(true); - setError(null); - const data = await loadCatalog(); - - if (!cancelled) { - setCatalog(data); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err : new Error('Failed to load catalog')); - } - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, []); + const { + data: catalog, + isLoading, + error, + refetch, + isRefetching, + } = useStackBuilderCatalog(); const handleRefreshCatalog = async () => { clearCatalogCache(); - setIsLoading(true); - setError(null); - - try { - const data = await loadCatalog(); - setCatalog(data); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to load catalog')); - } finally { - setIsLoading(false); - } + await queryClient.invalidateQueries({ queryKey: ["catalog"] }); + await refetch(); }; if (isLoading) { @@ -91,12 +62,14 @@ export function StackBuilderPage() {

Failed to Load Catalog

- {error?.message || 'Unable to load the tool catalog. Please check your connection and try again.'} + {error instanceof Error + ? error.message + : "Unable to load the tool catalog. Please check your connection and try again."}

-