Skip to content

refactor(web): route remaining API calls through TanStack Query#4

Merged
Nether403 merged 1 commit into
mainfrom
feat/phase-4-tanstack-query-sweep
May 12, 2026
Merged

refactor(web): route remaining API calls through TanStack Query#4
Nether403 merged 1 commit into
mainfrom
feat/phase-4-tanstack-query-sweep

Conversation

@Nether403

@Nether403 Nether403 commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 4 gap #3. Sweeps the two remaining imperative API call sites out of components into the useApi hook family so every component consumes the API through TanStack Query.

Migrations

Site Before After
StackBuilderPage.tsx useState + useEffect + imperative loadCatalog() useStackBuilderCatalog() hook
ExportDialog.tsx Local isGenerating/exportData/error state + imperative apiClient.generateScaffold() useGenerateScaffold() mutation with mutateAsync on open

New hooks in useApi.ts:

  • useCatalog() — raw /api/v1/catalog payload for components that already have the tools and rules they need (used by the compatibility matrix).
  • useStackBuilderCatalog() — wraps lib/catalog-loader's loadCatalog() so the Stack Builder keeps its localStorage-cached static-fallback behavior when the API is down. The loader is the queryFn; TanStack Query owns staleness / retry / deduplication.

Intentional exceptions

Two apiClient. call sites are kept outside TanStack Query on purpose:

  1. 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.
  2. 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 Query confirms the refactored page renders the catalog (waits for Next.js to appear on /stack-builder).

Full E2E suite:

5 passed (1.0m)
 ok searches the tool catalog (1.6s)
 ok loads the Stack Builder catalog via TanStack Query (1.7s)
 ok analyzes pairwise compatibility (1.4s)
 ok shows a basic migration path (723ms)
 ok generates an idea-to-stack blueprint (29.0s)

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
  • pnpm test:e2e — 5/5 passed

Notes


Open in Devin Review

Summary by cubic

Routes the last direct API calls through @tanstack/react-query by moving them into useApi hooks. Unifies data fetching/caching and keeps the Stack Builder’s offline/static fallback.

  • Refactors
    • Added useCatalog() (raw /api/v1/catalog) and useStackBuilderCatalog() (wraps loadCatalog() with localStorage/static fallback, plus stale time/retry).
    • StackBuilderPage now uses useStackBuilderCatalog and the query client for cache invalidation; removed local state/effects; Retry shows refetching state.
    • ExportDialog now uses useGenerateScaffold mutation (runs on open, resets on close); separates download errors and disables actions during work.
    • Left imperative calls in useRulesEngine.ts and lib/catalog-loader.ts by design.
    • Tests: added E2E check for catalog loading via TanStack Query; increased blueprint test timeout for stability.

Written for commit 55eb30f. Summary will update on new commits.

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)

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +54 to +62
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]);

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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>

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

@Nether403 Nether403 merged commit 80513cd into main May 12, 2026
2 checks passed
@Nether403 Nether403 deleted the feat/phase-4-tanstack-query-sweep branch May 12, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant