# feat: migrate components to TanStack Query v6 (Phase 2) ## Summary This PR migrates 4 components from the custom `createInfiniteScrollResource` pattern to TanStack Query v6's `createInfiniteQuery`: - **JobSkillSelectionSidebar** - Job skill search with infinite scroll and category filtering - **SearchContent** - Search modal for weapons/characters/summons with element/rarity/proficiency filters - **User Profile Page** (`[username]/+page.svelte`) - User's teams and favorites with tab switching - **Teams Explore Page** (`teams/explore/+page.svelte`) - Public teams listing All components now use: - TanStack Query v6 infinite query pattern with thunk for reactivity - `IsInViewport` from runed for intersection-based infinite scroll - Debounced search (debounce the value, not the query) - Proper loading, error, and empty states - SSR integration with `initialData` pattern ## Updates since last revision - **Fixed duplicate key error in SearchContent.svelte** - The API can return the same item across multiple pages during infinite scroll (e.g., due to items being added/removed between page fetches). This caused Svelte's keyed each block to throw `each_key_duplicate` errors. Fixed by deduplicating results by `id` using a Map before rendering. ## Review & Testing Checklist for Human This is a medium-risk change affecting core user-facing pages. Please verify: - [ ] **Infinite scroll works on all 4 pages** - Scroll to bottom and verify more items load automatically - [ ] **No duplicate items appear in search results** - After the fix, scrolling through many pages of search results should not show duplicates - [ ] **SSR hydration** - Verify no flash of loading state on initial page load (data should be pre-rendered) - [ ] **User profile tab switching** - Test switching between "Teams" and "Favorites" tabs; verify correct data loads - [ ] **Search debouncing** - Type quickly in JobSkillSelectionSidebar and SearchContent; verify queries aren't fired on every keystroke - [ ] **Error states** - Simulate network failure and verify retry button works **Recommended Test Plan:** 1. Navigate to `/teams/explore` - verify teams load and infinite scroll works 2. Navigate to a user profile page - verify teams load, switch to favorites tab, verify favorites load 3. Open the search sidebar - search for weapons/characters/summons, scroll through many pages, verify no duplicate key errors and no duplicate items 4. Open job skill selection - search and filter skills, verify results ### Notes - The Party component mutations migration (Follow-Up Prompt 5) was deferred to a follow-up PR due to complexity - Deprecated resource classes remain in codebase for now; removal planned for separate PR - Pre-existing paraglide module errors in build are unrelated to this PR - Type assertions (`as unknown as`, `as any`) are used to handle different query result structures between favorites and parties queries **Link to Devin run:** https://app.devin.ai/sessions/5aa7ea29edf34f569f95f13acee9e0d9 **Requested by:** Justin Edmund (justin@jedmund.com) / @jedmund --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Justin Edmund <justin@jedmund.com>
217 lines
7.9 KiB
Markdown
217 lines
7.9 KiB
Markdown
# TanStack Query Migration - Continuation Guide
|
|
|
|
This document provides context for continuing the TanStack Query v6 migration in hensei-web.
|
|
|
|
## Migration Status
|
|
|
|
### Completed (PR #441 - merged)
|
|
- Query options factories: `party.queries.ts`, `job.queries.ts`, `user.queries.ts`, `search.queries.ts`
|
|
- Mutation configurations: `party.mutations.ts`, `grid.mutations.ts`, `job.mutations.ts`
|
|
- SSR utilities: `withInitialData`, `prefetchQuery`, `prefetchInfiniteQuery`
|
|
- Example components: `JobSelectionSidebar.svelte`, `teams/[id]/+page.svelte`
|
|
|
|
### Completed (PR #442 - pending merge)
|
|
- `JobSkillSelectionSidebar.svelte` - Job skill search with infinite scroll
|
|
- `SearchContent.svelte` - Search modal for weapons/characters/summons
|
|
- `[username]/+page.svelte` - User profile page with teams/favorites tabs
|
|
- `teams/explore/+page.svelte` - Public teams listing
|
|
|
|
### Remaining Work
|
|
|
|
#### Follow-Up Prompt 5: Party Component Mutations
|
|
**Priority: High**
|
|
**Complexity: Large**
|
|
|
|
The `Party.svelte` component (1535 lines) needs to be migrated to use TanStack Query mutations instead of direct service calls.
|
|
|
|
**Files to modify:**
|
|
- `src/lib/components/party/Party.svelte`
|
|
|
|
**Current state:** Uses `PartyService`, `GridService`, `ConflictService`, and direct `partyAdapter` calls.
|
|
|
|
**Target state:** Use mutation hooks from:
|
|
- `src/lib/api/mutations/party.mutations.ts` - `useUpdateParty`, `useDeleteParty`, `useRemixParty`, `useFavoriteParty`, `useUnfavoriteParty`, `useRegeneratePreview`
|
|
- `src/lib/api/mutations/grid.mutations.ts` - `useCreateGridWeapon`, `useUpdateGridWeapon`, `useDeleteGridWeapon`, etc.
|
|
- `src/lib/api/mutations/job.mutations.ts` - `useUpdatePartyJob`, `useUpdatePartyJobSkills`, `useRemovePartyJobSkill`, `useUpdatePartyAccessory`
|
|
|
|
**Recommended sub-tasks:**
|
|
1. **5a: Party metadata mutations** - name, description, visibility using `useUpdateParty`
|
|
2. **5b: Grid weapon mutations** - add/update/delete weapons using grid mutations
|
|
3. **5c: Grid character mutations** - add/update/delete characters using grid mutations
|
|
4. **5d: Grid summon mutations** - add/update/delete summons using grid mutations
|
|
5. **5e: Job and skill mutations** - job selection, skill management using job mutations
|
|
|
|
**Key functions to migrate in Party.svelte:**
|
|
- `updatePartyDetails()` - replace `partyService.update()` with `useUpdateParty().mutate()`
|
|
- `toggleFavorite()` - replace `partyService.favorite()/unfavorite()` with `useFavoriteParty()/useUnfavoriteParty()`
|
|
- `remixParty()` - replace `partyService.remix()` with `useRemixParty()`
|
|
- `deleteParty()` - replace `partyService.delete()` with `useDeleteParty()`
|
|
- `handleSelectJob()` - replace `partyAdapter.updateJob()` with `useUpdatePartyJob()`
|
|
- `handleSelectJobSkill()` - replace `partyAdapter.updateJobSkills()` with `useUpdatePartyJobSkills()`
|
|
- Drag-drop operations - replace `gridService.moveWeapon/Character/Summon()` with appropriate mutations
|
|
|
|
#### Follow-Up Prompt 6: Remove Deprecated Resource Classes
|
|
**Priority: Low**
|
|
**Complexity: Small**
|
|
**Prerequisite:** All components migrated away from resource classes
|
|
|
|
**Files to delete:**
|
|
- `src/lib/api/adapters/resources/search.resource.svelte.ts`
|
|
- `src/lib/api/adapters/resources/party.resource.svelte.ts`
|
|
- `src/lib/api/adapters/resources/job.resource.svelte.ts`
|
|
- `src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts`
|
|
|
|
**Steps:**
|
|
1. Search for any remaining imports: `grep -r "from.*resources/" src/`
|
|
2. Migrate any remaining usages
|
|
3. Delete the resource files
|
|
4. Update any barrel exports (index.ts files)
|
|
5. Run build to verify no import errors
|
|
|
|
**Current blockers:** `InfiniteScroll.svelte` still imports `InfiniteScrollResource` type
|
|
|
|
## Patterns and Best Practices
|
|
|
|
### Infinite Query Pattern
|
|
```typescript
|
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
|
import { IsInViewport } from 'runed'
|
|
|
|
// Create the query with thunk for reactivity
|
|
const query = createInfiniteQuery(() => ({
|
|
...queryOptions.list(filters),
|
|
initialData: serverData ? {
|
|
pages: [{ results: serverData.items, page: 1, totalPages: serverData.totalPages }],
|
|
pageParams: [1]
|
|
} : undefined,
|
|
initialDataUpdatedAt: 0
|
|
}))
|
|
|
|
// Flatten and deduplicate results
|
|
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
|
|
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
|
|
|
|
// Infinite scroll with IsInViewport
|
|
let sentinelEl = $state<HTMLElement>()
|
|
const inViewport = new IsInViewport(() => sentinelEl, { rootMargin: '200px' })
|
|
|
|
$effect(() => {
|
|
if (inViewport.current && query.hasNextPage && !query.isFetchingNextPage && !query.isLoading) {
|
|
query.fetchNextPage()
|
|
}
|
|
})
|
|
```
|
|
|
|
### Debounced Search Pattern
|
|
```typescript
|
|
let searchQuery = $state('')
|
|
let debouncedSearchQuery = $state('')
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
$effect(() => {
|
|
const query = searchQuery
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => {
|
|
debouncedSearchQuery = query
|
|
}, 300)
|
|
return () => { if (debounceTimer) clearTimeout(debounceTimer) }
|
|
})
|
|
|
|
// Use debouncedSearchQuery in the query, not searchQuery
|
|
const query = createInfiniteQuery(() => queryOptions.search(debouncedSearchQuery))
|
|
```
|
|
|
|
### Type Assertions for Conditional Queries
|
|
When a query can return different types based on conditions, use type assertions:
|
|
```typescript
|
|
const query = createInfiniteQuery(() => {
|
|
if (condition) {
|
|
return queryOptionsA()
|
|
}
|
|
return queryOptionsB() as unknown as ReturnType<typeof queryOptionsA>
|
|
})
|
|
```
|
|
|
|
### Mutation Pattern
|
|
```typescript
|
|
import { useUpdateParty } from '$lib/api/mutations/party.mutations'
|
|
|
|
const updatePartyMutation = useUpdateParty()
|
|
|
|
function handleSave() {
|
|
updatePartyMutation.mutate(
|
|
{ partyId, updates },
|
|
{
|
|
onSuccess: () => { /* handle success */ },
|
|
onError: (error) => { /* handle error */ }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Use mutation state for UI
|
|
{#if updatePartyMutation.isPending}
|
|
<span>Saving...</span>
|
|
{/if}
|
|
```
|
|
|
|
## Known Issues
|
|
|
|
### Pre-existing Build Errors
|
|
The build has pre-existing errors unrelated to TanStack Query migration:
|
|
- `Cannot find module '$lib/paraglide/server'` in `hooks.server.ts`
|
|
- `Cannot find module '$lib/paraglide/runtime'` in `hooks.ts`
|
|
- `Cannot find module '$lib/paraglide/messages'` in various components
|
|
|
|
These are paraglide i18n setup issues and should be ignored when checking for migration-related errors.
|
|
|
|
### Duplicate Key Error Fix
|
|
When using infinite queries, the API may return duplicate items across pages. Always deduplicate:
|
|
```typescript
|
|
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
|
|
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
|
|
```
|
|
|
|
## File Locations
|
|
|
|
### Query Options Factories
|
|
- `src/lib/api/queries/party.queries.ts`
|
|
- `src/lib/api/queries/job.queries.ts`
|
|
- `src/lib/api/queries/user.queries.ts`
|
|
- `src/lib/api/queries/search.queries.ts`
|
|
|
|
### Mutation Hooks
|
|
- `src/lib/api/mutations/party.mutations.ts`
|
|
- `src/lib/api/mutations/grid.mutations.ts`
|
|
- `src/lib/api/mutations/job.mutations.ts`
|
|
|
|
### SSR Utilities
|
|
- `src/lib/query/ssr.ts`
|
|
|
|
### Reference Implementations
|
|
- `src/lib/components/sidebar/JobSelectionSidebar.svelte` - Simple infinite query
|
|
- `src/lib/components/sidebar/JobSkillSelectionSidebar.svelte` - Infinite query with search
|
|
- `src/lib/components/sidebar/SearchContent.svelte` - Infinite query with filters and deduplication
|
|
- `src/routes/teams/[id]/+page.svelte` - SSR with initialData
|
|
- `src/routes/[username]/+page.svelte` - Conditional queries (teams vs favorites)
|
|
- `src/routes/teams/explore/+page.svelte` - Simple infinite scroll page
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Run TypeScript check
|
|
pnpm run check
|
|
|
|
# Run development server
|
|
pnpm run dev
|
|
|
|
# Check for resource class imports
|
|
grep -r "from.*resources/" src/
|
|
|
|
# Check for createInfiniteScrollResource usage
|
|
grep -r "createInfiniteScrollResource" src/
|
|
```
|
|
|
|
## Branch Information
|
|
|
|
- Base branch: `svelte-main`
|
|
- PR #442 branch: `devin/1764405731-tanstack-query-migration-phase2`
|