Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 48 additions & 39 deletions apps/web/src/components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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]);
Comment on lines +54 to +62

Copy link
Copy Markdown

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 generateScaffold fails, the useEffect at line 54 re-fires and retriggers the mutation in an infinite loop. After failure: open is true, exportData is undefined, and isGenerating is false — so the guard if (!open || exportData || isGenerating) return does not stop execution. Since scaffoldError is never checked, generateScaffold is called again immediately, which fails again, and so on. This is further aggravated by selectedTools (ExportDialog.tsx:40) being a new array reference on every render (via Array.from() in SelectionsContext.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.

Suggested change
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]);
// Generate the scaffold once when the dialog first opens.
useEffect(() => {
if (!open || exportData || isGenerating || scaffoldError) return;
generateScaffold({
toolIds: selectedTools.map((tool) => tool.id),
projectName: 'stackfast-app',
}).catch(() => {
// Error surfaced via scaffoldError below; nothing to do here.
});
}, [open, exportData, isGenerating, scaffoldError, generateScaffold, selectedTools]);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The scaffold-generation effect depends on an unstable array (selectedTools), which can trigger repeated mutation calls (especially after errors) and cause an unintended request loop.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/components/ExportDialog.tsx, line 62:

<comment>The scaffold-generation effect depends on an unstable array (`selectedTools`), which can trigger repeated mutation calls (especially after errors) and cause an unintended request loop.</comment>

<file context>
@@ -36,71 +36,75 @@ export function ExportDialog({
+    }).catch(() => {
+      // Error surfaced via scaffoldError below; nothing to do here.
+    });
+  }, [open, exportData, isGenerating, generateScaffold, selectedTools]);
 
   // Download as archive
</file context>


// 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">
Expand All @@ -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>
Expand Down Expand Up @@ -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';
}
40 changes: 35 additions & 5 deletions apps/web/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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],
Expand Down
87 changes: 30 additions & 57 deletions apps/web/src/pages/StackBuilderPage.tsx
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"] });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: invalidateQueries already refetches active catalog queries, so calling refetch() immediately after causes duplicate refresh work. Use a single targeted invalidation (or only refetch) to avoid double query execution.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/pages/StackBuilderPage.tsx, line 30:

<comment>`invalidateQueries` already refetches active catalog queries, so calling `refetch()` immediately after causes duplicate refresh work. Use a single targeted invalidation (or only `refetch`) to avoid double query execution.</comment>

<file context>
@@ -1,63 +1,34 @@
-    } finally {
-      setIsLoading(false);
-    }
+    await queryClient.invalidateQueries({ queryKey: ["catalog"] });
+    await refetch();
   };
</file context>

await refetch();
};

if (isLoading) {
Expand Down Expand Up @@ -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
Expand All @@ -123,7 +96,7 @@ export function StackBuilderPage() {
catalogVersion={catalog.manifest.version}
catalogUpdatedAt={catalog.manifest.updatedAt}
/>

<ExportDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
Expand Down
14 changes: 13 additions & 1 deletion tests/e2e/mvp-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,27 @@ test.describe("Stackfast MVP flows", () => {
await expect(page.getByRole("heading", { name: "Next.js" })).toBeVisible();
});

test("loads the Stack Builder catalog via TanStack Query", async ({ page }) => {
await page.goto("/stack-builder");

// The loading spinner shows "Loading StackFast"; we wait for it to clear
// and the StackBuilder component to render category sections from the
// catalog. Next.js lives under the "Frontend" category and is always
// present in the registry, so its name is a stable signal that data
// flowed through useStackBuilderCatalog.
await expect(page.getByText("Next.js", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
});

test("generates an idea-to-stack blueprint", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/blueprint");

await page.getByLabel(/What are you building/i).fill(
"A subscription dashboard with login, billing, and email notifications",
);
await page.getByRole("button", { name: /Generate Blueprint/i }).click();

await expect(page.getByRole("heading", { name: "Recommended Architecture" })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("heading", { name: "Recommended Architecture" })).toBeVisible({ timeout: 60_000 });
await expect(page.getByText("Primary Stack")).toBeVisible();
await expect(page.getByText("Harmony Score")).toBeVisible();
});
Expand Down
Loading