-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(web): route remaining API calls through TanStack Query #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ExportData | null>(null); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [isDownloading, setIsDownloading] = useState(false); | ||
| const [downloadError, setDownloadError] = useState<string | null>(null); | ||
| const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('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]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The scaffold-generation effect depends on an unstable array ( Prompt for AI agents |
||
|
|
||
| // 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 ( | ||
| <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> | ||
|
|
@@ -117,14 +121,14 @@ export function ExportDialog({ | |
| </div> | ||
| )} | ||
|
|
||
| {error && ( | ||
| {combinedError && ( | ||
| <div className="rounded-lg bg-destructive/10 p-4 text-destructive"> | ||
| <p className="font-semibold">Export Failed</p> | ||
| <p className="text-sm mt-1">{error}</p> | ||
| <p className="text-sm mt-1">{combinedError}</p> | ||
| </div> | ||
| )} | ||
|
|
||
| {exportData && !error && ( | ||
| {exportData && !downloadError && ( | ||
| <div className="space-y-4"> | ||
| {/* Export Preview */} | ||
| <div> | ||
|
|
@@ -224,18 +228,23 @@ export function ExportDialog({ | |
| <Button | ||
| variant="outline" | ||
| onClick={handleCopyText} | ||
| disabled={!exportData || isGenerating} | ||
| disabled={!exportData || isGenerating || isDownloading} | ||
| > | ||
| Copy as Text | ||
| </Button> | ||
| <Button | ||
| onClick={handleDownload} | ||
| disabled={!exportData || isGenerating} | ||
| disabled={!exportData || isGenerating || isDownloading} | ||
| > | ||
| {isGenerating ? 'Generating...' : 'Download'} | ||
| {isGenerating || isDownloading ? 'Generating...' : 'Download'} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
|
|
||
| function errorMessage(error: unknown): string { | ||
| if (error instanceof Error) return error.message; | ||
| return 'Failed to generate export'; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CatalogData | null>(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [error, setError] = useState<Error | null>(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"] }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| await refetch(); | ||
| }; | ||
|
|
||
| if (isLoading) { | ||
|
|
@@ -91,12 +62,14 @@ export function StackBuilderPage() { | |
| <div className="space-y-2"> | ||
| <h2 className="text-2xl font-bold">Failed to Load Catalog</h2> | ||
| <p className="text-muted-foreground"> | ||
| {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."} | ||
| </p> | ||
| </div> | ||
| <div className="flex gap-2 justify-center"> | ||
| <Button onClick={handleRefreshCatalog}> | ||
| Retry | ||
| <Button onClick={handleRefreshCatalog} disabled={isRefetching}> | ||
| {isRefetching ? "Retrying..." : "Retry"} | ||
| </Button> | ||
| <Button onClick={() => window.location.reload()} variant="outline"> | ||
| Refresh Page | ||
|
|
@@ -123,7 +96,7 @@ export function StackBuilderPage() { | |
| catalogVersion={catalog.manifest.version} | ||
| catalogUpdatedAt={catalog.manifest.updatedAt} | ||
| /> | ||
|
|
||
| <ExportDialog | ||
| open={showExportDialog} | ||
| onOpenChange={setShowExportDialog} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 useEffect missing scaffoldError guard causes infinite API retry loop on failure
When
generateScaffoldfails, theuseEffectat line 54 re-fires and retriggers the mutation in an infinite loop. After failure:openistrue,exportDataisundefined, andisGeneratingisfalse— so the guardif (!open || exportData || isGenerating) returndoes not stop execution. SincescaffoldErroris never checked,generateScaffoldis called again immediately, which fails again, and so on. This is further aggravated byselectedTools(ExportDialog.tsx:40) being a new array reference on every render (viaArray.from()inSelectionsContext.tsx:125), which means the effect's dependency array always appears changed, causing the effect to re-run on every render even without other state changes.Was this helpful? React with 👍 or 👎 to provide feedback.