fix: PartySegmentedControl rep components now update reactively

The WeaponRep (and other rep components) weren't visually updating when
new items were added to the party. The issue was that Svelte's reactivity
wasn't properly propagating through the {#each} blocks.

Changes:
- PartySegmentedControl: Add derived values for party sub-properties to
  ensure reactivity propagates through snippet boundaries
- WeaponRep: Pre-compute rows as explicit $derived value and use keyed
  {#each} blocks for proper change detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-11-30 00:23:44 -08:00
parent f457343e26
commit 2275daec61
4 changed files with 41 additions and 70 deletions

View file

@ -85,7 +85,6 @@ export function useCreateGridWeapon() {
gridAdapter.createWeapon(params, headers) gridAdapter.createWeapon(params, headers)
), ),
onSuccess: (_data, params) => { onSuccess: (_data, params) => {
// Invalidate the party to refetch with new weapon
invalidateParty(queryClient, params.partyId) invalidateParty(queryClient, params.partyId)
} }
})) }))

View file

@ -76,19 +76,11 @@
characters: [] characters: []
} }
// Initialize party state with proper validation // Derive party directly from prop - single source of truth from TanStack Query cache
let party = $state<Party>( let party = $derived(
initial?.id && initial?.id !== 'new' && Array.isArray(initial?.weapons) ? initial : defaultParty initial?.id && initial?.id !== 'new' && Array.isArray(initial?.weapons) ? initial : defaultParty
) )
// Sync local party state with prop changes (for query refetches)
$effect(() => {
// Only update if we have valid party data from props
if (initial && initial.id && initial.id !== 'new' && Array.isArray(initial.weapons)) {
party = initial
}
})
let activeTab = $state<GridType>(GridType.Weapon) let activeTab = $state<GridType>(GridType.Weapon)
let loading = $state(false) let loading = $state(false)
let error = $state<string | null>(null) let error = $state<string | null>(null)
@ -155,20 +147,16 @@
try { try {
loading = true loading = true
let updated: Party | null = null
if (operation.type === 'swap') { if (operation.type === 'swap') {
// Handle swapping items between positions // Handle swapping items between positions
updated = await handleSwap(source, target) await handleSwap(source, target)
} else if (operation.type === 'move') { } else if (operation.type === 'move') {
// Handle moving to empty position // Handle moving to empty position
updated = await handleMove(source, target) await handleMove(source, target)
} }
// Update party with returned data from API // Party will be updated via cache invalidation from mutations
if (updated) {
party = updated
}
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to update party' error = err.message || 'Failed to update party'
console.error('Drag operation failed:', err) console.error('Drag operation failed:', err)
@ -178,7 +166,7 @@
} }
} }
async function handleSwap(source: any, target: any): Promise<Party> { async function handleSwap(source: any, target: any): Promise<void> {
if (!party.id || party.id === 'new') { if (!party.id || party.id === 'new') {
throw new Error('Cannot swap items in unsaved party') throw new Error('Cannot swap items in unsaved party')
} }
@ -198,11 +186,9 @@
} else if (source.type === 'summon') { } else if (source.type === 'summon') {
await swapSummons.mutateAsync(swapParams) await swapSummons.mutateAsync(swapParams)
} }
return party
} }
async function handleMove(source: any, target: any): Promise<Party> { async function handleMove(source: any, target: any): Promise<void> {
if (!party.id || party.id === 'new') { if (!party.id || party.id === 'new') {
throw new Error('Cannot move items in unsaved party') throw new Error('Cannot move items in unsaved party')
} }
@ -221,8 +207,6 @@
} else if (source.type === 'summon') { } else if (source.type === 'summon') {
await updateSummon.mutateAsync(updateParams) await updateSummon.mutateAsync(updateParams)
} }
return party
} }
// Localized name helper: accepts either an object with { name: { en, ja } } // Localized name helper: accepts either an object with { name: { en, ja } }
@ -276,11 +260,8 @@
error = null error = null
// Update party title via API // Update party title via API
const updated = await updatePartyDetails({ name: editingTitle }) await updatePartyDetails({ name: editingTitle })
if (updated) { editDialogOpen = false
party = updated
editDialogOpen = false
}
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to update party title' error = err.message || 'Failed to update party title'
} finally { } finally {
@ -290,7 +271,7 @@
// Party operations // Party operations
async function updatePartyDetails(updates: Partial<Party>) { async function updatePartyDetails(updates: Partial<Party>) {
if (!canEdit()) return null if (!canEdit()) return
loading = true loading = true
error = null error = null
@ -299,10 +280,8 @@
// Use TanStack Query mutation to update party // Use TanStack Query mutation to update party
await updatePartyMutation.mutateAsync({ shortcode: party.shortcode, updates }) await updatePartyMutation.mutateAsync({ shortcode: party.shortcode, updates })
// Party will be updated via cache invalidation // Party will be updated via cache invalidation
return party
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to update party' error = err.message || 'Failed to update party'
return null
} finally { } finally {
loading = false loading = false
} }
@ -317,11 +296,10 @@
try { try {
if (party.favorited) { if (party.favorited) {
await unfavoritePartyMutation.mutateAsync({ shortcode: party.shortcode }) await unfavoritePartyMutation.mutateAsync({ shortcode: party.shortcode })
party.favorited = false
} else { } else {
await favoritePartyMutation.mutateAsync({ shortcode: party.shortcode }) await favoritePartyMutation.mutateAsync({ shortcode: party.shortcode })
party.favorited = true
} }
// Party will be updated via cache invalidation
} catch (err: any) { } catch (err: any) {
error = err.message || 'Failed to update favorite status' error = err.message || 'Failed to update favorite status'
} finally { } finally {
@ -405,8 +383,8 @@
try { try {
// Update job via API (use shortcode for party identification) // Update job via API (use shortcode for party identification)
const updated = await partyAdapter.updateJob(party.shortcode, job.id) await partyAdapter.updateJob(party.shortcode, job.id)
party = updated // Party will be updated via cache invalidation
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to update job' error = e instanceof Error ? e.message : 'Failed to update job'
console.error('Failed to update job:', e) console.error('Failed to update job:', e)
@ -444,11 +422,8 @@
console.log('[Party] Skills array to send:', skillsArray) console.log('[Party] Skills array to send:', skillsArray)
const updated = await partyAdapter.updateJobSkills( await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
party.shortcode, // Party will be updated via cache invalidation
skillsArray
)
party = updated
} catch (e: any) { } catch (e: any) {
error = extractErrorMessage(e, 'Failed to update skill') error = extractErrorMessage(e, 'Failed to update skill')
console.error('Failed to update skill:', e) console.error('Failed to update skill:', e)
@ -474,11 +449,8 @@
console.log('[Party] Skills array to send after removal:', skillsArray) console.log('[Party] Skills array to send after removal:', skillsArray)
const updated = await partyAdapter.updateJobSkills( await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
party.shortcode, // Party will be updated via cache invalidation
skillsArray
)
party = updated
} catch (e: any) { } catch (e: any) {
error = extractErrorMessage(e, 'Failed to remove skill') error = extractErrorMessage(e, 'Failed to remove skill')
console.error('Failed to remove skill:', e) console.error('Failed to remove skill:', e)
@ -504,11 +476,8 @@
// Convert skills object to array format expected by API // Convert skills object to array format expected by API
const skillsArray = transformSkillsToArray(updatedSkills) const skillsArray = transformSkillsToArray(updatedSkills)
const updated = await partyAdapter.updateJobSkills( await partyAdapter.updateJobSkills(party.shortcode, skillsArray)
party.shortcode, // Party will be updated via cache invalidation
skillsArray
)
party = updated
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove skill' error = e instanceof Error ? e.message : 'Failed to remove skill'
console.error('Failed to remove skill:', e) console.error('Failed to remove skill:', e)
@ -591,8 +560,7 @@
partyId, partyId,
partyShortcode: party.shortcode partyShortcode: party.shortcode
}) })
// Return updated party from cache after mutation // Party will be updated via cache invalidation
return party
} catch (err) { } catch (err) {
console.error('Failed to remove weapon:', err) console.error('Failed to remove weapon:', err)
throw err throw err
@ -605,7 +573,6 @@
partyId, partyId,
partyShortcode: party.shortcode partyShortcode: party.shortcode
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to remove summon:', err) console.error('Failed to remove summon:', err)
throw err throw err
@ -618,7 +585,6 @@
partyId, partyId,
partyShortcode: party.shortcode partyShortcode: party.shortcode
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to remove character:', err) console.error('Failed to remove character:', err)
throw err throw err
@ -631,7 +597,6 @@
partyShortcode: party.shortcode, partyShortcode: party.shortcode,
updates updates
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update weapon:', err) console.error('Failed to update weapon:', err)
throw err throw err
@ -644,7 +609,6 @@
partyShortcode: party.shortcode, partyShortcode: party.shortcode,
updates updates
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update summon:', err) console.error('Failed to update summon:', err)
throw err throw err
@ -662,7 +626,6 @@
partyShortcode: party.shortcode, partyShortcode: party.shortcode,
updates updates
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update character:', err) console.error('Failed to update character:', err)
throw err throw err
@ -681,7 +644,6 @@
uncapLevel, uncapLevel,
transcendenceStep transcendenceStep
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update character uncap:', err) console.error('Failed to update character uncap:', err)
throw err throw err
@ -700,7 +662,6 @@
uncapLevel, uncapLevel,
transcendenceStep transcendenceStep
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update weapon uncap:', err) console.error('Failed to update weapon uncap:', err)
throw err throw err
@ -719,7 +680,6 @@
uncapLevel, uncapLevel,
transcendenceStep transcendenceStep
}) })
return party
} catch (err) { } catch (err) {
console.error('Failed to update summon uncap:', err) console.error('Failed to update summon uncap:', err)
throw err throw err
@ -730,7 +690,6 @@
// Provide services to child components via context // Provide services to child components via context
setContext('party', { setContext('party', {
getParty: () => party, getParty: () => party,
updateParty: (p: Party) => (party = p),
canEdit: () => canEdit(), canEdit: () => canEdit(),
getEditKey: () => editKey, getEditKey: () => editKey,
services: { services: {

View file

@ -1,4 +1,6 @@
<!-- PartySegmentedControl Component --> <!-- PartySegmentedControl Component -->
<svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party' import type { Party, GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
@ -19,6 +21,14 @@
let { selectedTab = GridType.Character, onTabChange, party, class: className }: Props = $props() let { selectedTab = GridType.Character, onTabChange, party, class: className }: Props = $props()
// Derived values to ensure reactivity propagates through snippet boundaries
// When party updates from TanStack Query cache, these will trigger re-renders
const weapons = $derived(party.weapons)
const summons = $derived(party.summons)
const characters = $derived(party.characters)
const job = $derived(party.job)
const element = $derived(party.element)
// Handle value changes // Handle value changes
let value = $state(selectedTab) let value = $state(selectedTab)
@ -45,10 +55,10 @@
selected={value === GridType.Character} selected={value === GridType.Character}
> >
<CharacterRep <CharacterRep
job={party.job} job={job}
element={party.element} element={element}
gender={userGender} gender={userGender}
characters={party.characters} characters={characters}
/> />
</RepSegment> </RepSegment>
@ -57,7 +67,7 @@
label={m.party_segmented_control_weapons()} label={m.party_segmented_control_weapons()}
selected={value === GridType.Weapon} selected={value === GridType.Weapon}
> >
<WeaponRep weapons={party.weapons} /> <WeaponRep weapons={weapons} />
</RepSegment> </RepSegment>
<RepSegment <RepSegment
@ -65,7 +75,7 @@
label={m.party_segmented_control_summons()} label={m.party_segmented_control_summons()}
selected={value === GridType.Summon} selected={value === GridType.Summon}
> >
<SummonRep summons={party.summons} /> <SummonRep summons={summons} />
</RepSegment> </RepSegment>
</SegmentedControl> </SegmentedControl>
</nav> </nav>

View file

@ -15,7 +15,10 @@
const grid = $derived( const grid = $derived(
Array.from({ length: 9 }, (_, i) => weapons.find((w: GridWeapon) => w?.position === i)) Array.from({ length: 9 }, (_, i) => weapons.find((w: GridWeapon) => w?.position === i))
) )
// Pre-compute rows as explicit derived to ensure reactivity propagates to template
const rows = $derived(
Array.from({ length: 3 }, (_, rowIndex) => grid.slice(rowIndex * 3, (rowIndex + 1) * 3))
)
function weaponImageUrl(w?: GridWeapon, isMain = false): string { function weaponImageUrl(w?: GridWeapon, isMain = false): string {
const variant = isMain ? 'main' : 'grid' const variant = isMain ? 'main' : 'grid'
@ -35,9 +38,9 @@
/>{/if} />{/if}
</div> </div>
<div class="weapons"> <div class="weapons">
{#each Array.from( { length: 3 }, (_, rowIndex) => grid.slice(rowIndex * 3, (rowIndex + 1) * 3) ) as row, rowIndex} {#each rows as row, rowIndex (rowIndex)}
<ul class="weapon-row"> <ul class="weapon-row">
{#each row as w, colIndex} {#each row as w, colIndex (rowIndex * 3 + colIndex)}
<li class="weapon" class:empty={!w}> <li class="weapon" class:empty={!w}>
{#if w}<img alt="Weapon" src={weaponImageUrl(w)} loading="lazy" decoding="async" />{/if} {#if w}<img alt="Weapon" src={weaponImageUrl(w)} loading="lazy" decoding="async" />{/if}
</li> </li>