refactor(web): route remaining API calls through TanStack Query#4
Conversation
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)
| 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.
🔴 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.
| 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]); |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
2 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/src/components/ExportDialog.tsx">
<violation number="1" location="apps/web/src/components/ExportDialog.tsx:62">
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.</violation>
</file>
<file name="apps/web/src/pages/StackBuilderPage.tsx">
<violation number="1" location="apps/web/src/pages/StackBuilderPage.tsx:30">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| }).catch(() => { | ||
| // Error surfaced via scaffoldError below; nothing to do here. | ||
| }); | ||
| }, [open, exportData, isGenerating, generateScaffold, selectedTools]); |
There was a problem hiding this comment.
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>
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| await queryClient.invalidateQueries({ queryKey: ["catalog"] }); |
There was a problem hiding this comment.
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>
Summary
Phase 4 gap #3. Sweeps the two remaining imperative API call sites out of components into the
useApihook family so every component consumes the API through TanStack Query.Migrations
StackBuilderPage.tsxuseState+useEffect+ imperativeloadCatalog()useStackBuilderCatalog()hookExportDialog.tsxisGenerating/exportData/errorstate + imperativeapiClient.generateScaffold()useGenerateScaffold()mutation withmutateAsyncon openNew hooks in
useApi.ts:useCatalog()— raw/api/v1/catalogpayload for components that already have the tools and rules they need (used by the compatibility matrix).useStackBuilderCatalog()— wrapslib/catalog-loader'sloadCatalog()so the Stack Builder keeps its localStorage-cached static-fallback behavior when the API is down. The loader is thequeryFn; TanStack Query owns staleness / retry / deduplication.Intentional exceptions
Two
apiClient.call sites are kept outside TanStack Query on purpose:useRulesEngine.ts— the stack-analysis hook already owns a custom debounce + sequence-token race guard + deterministic fallback to the local rules-engine worker. Those guarantees don't map onto query/mutation lifecycles cleanly, and it's always been a hook so components never see the call directly.lib/catalog-loader.ts— this is the query function; its localStorage cache persists catalog data across full page reloads, complementing TSQ's in-memory cache.Evidence
New Playwright test
loads the Stack Builder catalog via TanStack Queryconfirms the refactored page renders the catalog (waits for Next.js to appear on/stack-builder).Full E2E suite:
Quality gate (local)
pnpm -r type-check— 0 errorspnpm -r lint— 0 errorspnpm -r test— 60 unit/API tests passpnpm -r build— web + api + packagespnpm test:e2e— 5/5 passedNotes
main; does not conflict with PR feat(web): convert Blueprint Builder to 4-step wizard #2 or feat(web): add compatibility matrix heatmap view #3 at the file level apart from the shared E2E timeout bump.Summary by cubic
Routes the last direct API calls through
@tanstack/react-queryby moving them intouseApihooks. Unifies data fetching/caching and keeps the Stack Builder’s offline/static fallback.useCatalog()(raw/api/v1/catalog) anduseStackBuilderCatalog()(wrapsloadCatalog()with localStorage/static fallback, plus stale time/retry).StackBuilderPagenow usesuseStackBuilderCatalogand the query client for cache invalidation; removed local state/effects; Retry shows refetching state.ExportDialognow usesuseGenerateScaffoldmutation (runs on open, resets on close); separates download errors and disables actions during work.useRulesEngine.tsandlib/catalog-loader.tsby design.Written for commit 55eb30f. Summary will update on new commits.