Fix Party component interface and remove unstable_cache

- Update NewPartyClient and PartyPageClient to use correct Party props
- Remove unstable_cache from all data fetching functions
- Fix viewport metadata configuration in App Router
- Restore ToastViewport component in layout
- Fix import paths from 'types' to '~types' in Party components
- Add comprehensive PRD documenting the fixes

This addresses the interface mismatch between Party component and its
client wrappers that occurred during the App Router migration.
This commit is contained in:
Justin Edmund 2025-09-01 19:06:22 -07:00
parent 1775f3f5b6
commit 218a524b55
12 changed files with 410 additions and 231 deletions

5
.env.local Normal file
View file

@ -0,0 +1,5 @@
NEXT_PUBLIC_SIERO_API_URL=http://127.0.0.1:3000/api/v1
NEXT_PUBLIC_SIERO_OAUTH_URL=http://127.0.0.1:3000/oauth
NEXT_INTL_CONFIG_PATH=i18n/request.ts
DEBUG_API_URL=1
DEBUG_API_BODY=1

View file

@ -1,6 +1,6 @@
import { Metadata } from 'next'
import { Metadata, Viewport } from 'next'
import localFont from 'next/font/local'
import { Viewport } from '@radix-ui/react-toast'
import { Viewport as ToastViewport } from '@radix-ui/react-toast'
import '../styles/globals.scss'
@ -13,7 +13,13 @@ import UpdateToastClient from './components/UpdateToastClient'
export const metadata: Metadata = {
title: 'granblue.team',
description: 'Create, save, and share Granblue Fantasy party compositions',
viewport: 'viewport-fit=cover, width=device-width, initial-scale=1.0',
}
// Viewport configuration (Next.js 13+ requires separate export)
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
// Font
@ -35,7 +41,7 @@ export default function RootLayout({
<Header />
<UpdateToastClient />
<main>{children}</main>
<Viewport className="ToastViewport" />
<ToastViewport className="ToastViewport" />
</Providers>
</body>
</html>

View file

@ -1,8 +1,7 @@
import { unstable_cache } from 'next/cache';
import { fetchFromApi } from './api-utils';
// Cached server-side data fetching functions
// These are wrapped with React's cache function to deduplicate requests
// Server-side data fetching functions
// Next.js automatically deduplicates requests within the same render
// Get teams with optional filters
export async function getTeams({
@ -18,181 +17,132 @@ export async function getTeams({
page?: number;
username?: string;
}) {
const key = [
'getTeams',
String(element ?? ''),
String(raid ?? ''),
String(recency ?? ''),
String(page ?? 1),
String(username ?? ''),
];
const queryParams: Record<string, string> = {};
if (element) queryParams.element = element.toString();
if (raid) queryParams.raid_id = raid;
if (recency) queryParams.recency = recency;
if (page) queryParams.page = page.toString();
const run = unstable_cache(async () => {
const queryParams: Record<string, string> = {};
if (element) queryParams.element = element.toString();
if (raid) queryParams.raid_id = raid;
if (recency) queryParams.recency = recency;
if (page) queryParams.page = page.toString();
let endpoint = '/parties';
if (username) {
endpoint = `/users/${username}/parties`;
}
let endpoint = '/parties';
if (username) {
endpoint = `/users/${username}/parties`;
}
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) endpoint += `?${queryString}`;
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) endpoint += `?${queryString}`;
try {
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch teams', error);
throw error;
}
}, key, { revalidate: 60 });
return run();
try {
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch teams', error);
throw error;
}
}
// Get a single team by shortcode
export async function getTeam(shortcode: string) {
const key = ['getTeam', String(shortcode)];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi(`/parties/${shortcode}`);
return data;
} catch (error) {
console.error(`Failed to fetch team with shortcode ${shortcode}`, error);
throw error;
}
}, key, { revalidate: 60 });
return run();
try {
const data = await fetchFromApi(`/parties/${shortcode}`);
return data;
} catch (error) {
console.error(`Failed to fetch team with shortcode ${shortcode}`, error);
throw error;
}
}
// Get user info
export async function getUserInfo(username: string) {
const key = ['getUserInfo', String(username)];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi(`/users/info/${username}`);
return data;
} catch (error) {
console.error(`Failed to fetch user info for ${username}`, error);
throw error;
}
}, key, { revalidate: 60 });
return run();
try {
const data = await fetchFromApi(`/users/info/${username}`);
return data;
} catch (error) {
console.error(`Failed to fetch user info for ${username}`, error);
throw error;
}
}
// Get raid groups
export async function getRaidGroups() {
const key = ['getRaidGroups'];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi('/raids/groups');
return data;
} catch (error) {
console.error('Failed to fetch raid groups', error);
throw error;
}
}, key, { revalidate: 300 });
return run();
try {
const data = await fetchFromApi('/raids/groups');
return data;
} catch (error) {
console.error('Failed to fetch raid groups', error);
throw error;
}
}
// Get version info
export async function getVersion() {
const key = ['getVersion'];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi('/version');
return data;
} catch (error) {
console.error('Failed to fetch version info', error);
throw error;
}
}, key, { revalidate: 300 });
return run();
try {
const data = await fetchFromApi('/version');
return data;
} catch (error) {
console.error('Failed to fetch version info', error);
throw error;
}
}
// Get user's favorites/saved teams
export async function getFavorites() {
const key = ['getFavorites'];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi('/parties/favorites');
return data;
} catch (error) {
console.error('Failed to fetch favorites', error);
throw error;
}
}, key, { revalidate: 60 });
return run();
try {
const data = await fetchFromApi('/parties/favorites');
return data;
} catch (error) {
console.error('Failed to fetch favorites', error);
throw error;
}
}
// Get all jobs
export async function getJobs(element?: number) {
const key = ['getJobs', String(element ?? '')];
const run = unstable_cache(async () => {
try {
const queryParams: Record<string, string> = {};
if (element) queryParams.element = element.toString();
try {
const queryParams: Record<string, string> = {};
if (element) queryParams.element = element.toString();
let endpoint = '/jobs';
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) endpoint += `?${queryString}`;
let endpoint = '/jobs';
const queryString = new URLSearchParams(queryParams).toString();
if (queryString) endpoint += `?${queryString}`;
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch jobs', error);
throw error;
}
}, key, { revalidate: 300 });
return run();
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch jobs', error);
throw error;
}
}
// Get job by ID
export async function getJob(jobId: string) {
const key = ['getJob', String(jobId)];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi(`/jobs/${jobId}`);
return data;
} catch (error) {
console.error(`Failed to fetch job with ID ${jobId}`, error);
throw error;
}
}, key, { revalidate: 300 });
return run();
try {
const data = await fetchFromApi(`/jobs/${jobId}`);
return data;
} catch (error) {
console.error(`Failed to fetch job with ID ${jobId}`, error);
throw error;
}
}
// Get job skills
export async function getJobSkills(jobId?: string) {
const key = ['getJobSkills', String(jobId ?? '')];
const run = unstable_cache(async () => {
try {
const endpoint = jobId ? `/jobs/${jobId}/skills` : '/jobs/skills';
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch job skills', error);
throw error;
}
}, key, { revalidate: 300 });
return run();
try {
const endpoint = jobId ? `/jobs/${jobId}/skills` : '/jobs/skills';
const data = await fetchFromApi(endpoint);
return data;
} catch (error) {
console.error('Failed to fetch job skills', error);
throw error;
}
}
// Get job accessories
export async function getJobAccessories(jobId: string) {
const key = ['getJobAccessories', String(jobId)];
const run = unstable_cache(async () => {
try {
const data = await fetchFromApi(`/jobs/${jobId}/accessories`);
return data;
} catch (error) {
console.error(`Failed to fetch accessories for job ${jobId}`, error);
throw error;
}
}, key, { revalidate: 300 });
return run();
}
try {
const data = await fetchFromApi(`/jobs/${jobId}/accessories`);
return data;
} catch (error) {
console.error(`Failed to fetch accessories for job ${jobId}`, error);
throw error;
}
}

View file

@ -1,11 +1,10 @@
'use client'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/navigation'
// Components
import NewHead from '~/components/head/NewHead'
import Party from '~/components/party/Party'
import ErrorSection from '~/components/ErrorSection'
@ -13,6 +12,7 @@ import ErrorSection from '~/components/ErrorSection'
import { appState, initialAppState } from '~/utils/appState'
import { accountState } from '~/utils/accountState'
import clonedeep from 'lodash.clonedeep'
import { GridType } from '~/utils/enums'
interface Props {
raidGroups: any[]; // Replace with proper RaidGroup type
@ -26,6 +26,9 @@ const NewPartyClient: React.FC<Props> = ({
const { t } = useTranslation('common')
const router = useRouter()
// State for tab management
const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon)
// Initialize app state for a new party
useEffect(() => {
// Reset app state for new party
@ -40,41 +43,18 @@ const NewPartyClient: React.FC<Props> = ({
}
}, [raidGroups])
// Handle save action
async function handleSave(shouldNavigate = true) {
try {
// Prepare party data
const party = {
name: appState.parties[0]?.name || '',
description: appState.parties[0]?.description || '',
visibility: appState.parties[0]?.visibility || 'public',
element: appState.parties[0]?.element || 1, // Default to Wind
raid_id: appState.parties[0]?.raid?.id
}
// Save the party
const response = await fetch('/api/parties', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ party })
})
const data = await response.json()
if (data && data.shortcode && shouldNavigate) {
// Navigate to the new party page
router.push(`/p/${data.shortcode}`)
}
return data
} catch (error) {
console.error('Error saving party', error)
return null
}
// Handle tab change
const handleTabChanged = (value: string) => {
const tabType = parseInt(value) as GridType
setSelectedTab(tabType)
}
// Navigation helper for Party component
const pushHistory = (path: string) => {
router.push(path)
}
if (error) {
return (
<ErrorSection
@ -87,14 +67,13 @@ const NewPartyClient: React.FC<Props> = ({
}
return (
<>
<NewHead />
<Party
party={appState.parties[0] || { name: t('new_party'), element: 1 }}
isNew={true}
onSave={handleSave}
/>
</>
<Party
new={true}
selectedTab={selectedTab}
raidGroups={raidGroups}
handleTabChanged={handleTabChanged}
pushHistory={pushHistory}
/>
)
}

View file

@ -1,11 +1,12 @@
'use client'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { useRouter } from 'next/navigation'
// Utils
import { appState } from '~/utils/appState'
import { GridType } from '~/utils/enums'
// Components
import Party from '~/components/party/Party'
@ -21,6 +22,9 @@ const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
const router = useRouter()
const { t } = useTranslation('common')
// State for tab management
const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon)
// Initialize app state
useEffect(() => {
if (party) {
@ -29,45 +33,18 @@ const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
}
}, [party, raidGroups])
// Handle remix action
async function handleRemix() {
if (!party || !party.shortcode) return
try {
const response = await fetch(`/api/parties/${party.shortcode}/remix`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (data && data.shortcode) {
// Navigate to the new remixed party
router.push(`/p/${data.shortcode}`)
}
} catch (error) {
console.error('Error remixing party', error)
}
// Handle tab change
const handleTabChanged = (value: string) => {
const tabType = parseInt(value) as GridType
setSelectedTab(tabType)
}
// Handle deletion action
async function handleDelete() {
if (!party || !party.shortcode) return
try {
await fetch(`/api/parties/${party.shortcode}`, {
method: 'DELETE'
})
// Navigate to teams page after deletion
router.push('/teams')
} catch (error) {
console.error('Error deleting party', error)
}
// Navigation helper (not used for existing parties but required by interface)
const pushHistory = (path: string) => {
router.push(path)
}
// Error case
if (!party) {
return (
@ -83,9 +60,11 @@ const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
return (
<>
<Party
party={party}
onRemix={handleRemix}
onDelete={handleDelete}
team={party}
selectedTab={selectedTab}
raidGroups={raidGroups}
handleTabChanged={handleTabChanged}
pushHistory={pushHistory}
/>
<PartyFooter party={party} />
</>

View file

@ -21,7 +21,7 @@ import SwitchTableField from '~components/common/SwitchTableField'
import TableField from '~components/common/TableField'
import capitalizeFirstLetter from '~utils/capitalizeFirstLetter'
import type { DetailsObject } from 'types'
import type { DetailsObject } from '~types'
import type { DialogProps } from '@radix-ui/react-dialog'
import type { JSONContent } from '@tiptap/core'

View file

@ -20,7 +20,7 @@ import api from '~utils/api'
import { appState } from '~utils/appState'
import { youtube } from '~utils/youtube'
import type { DetailsObject } from 'types'
import type { DetailsObject } from '~types'
import RemixIcon from '~public/icons/Remix.svg'
import EditIcon from '~public/icons/Edit.svg'

View file

@ -31,7 +31,7 @@ import SaveIcon from '~public/icons/Save.svg'
import PrivateIcon from '~public/icons/Private.svg'
import UnlistedIcon from '~public/icons/Unlisted.svg'
import type { DetailsObject } from 'types'
import type { DetailsObject } from '~types'
import styles from './index.module.scss'

View file

@ -11,7 +11,7 @@ import DialogHeader from '~components/common/DialogHeader'
import DialogFooter from '~components/common/DialogFooter'
import DialogContent from '~components/common/DialogContent'
import type { DetailsObject } from 'types'
import type { DetailsObject } from '~types'
import type { DialogProps } from '@radix-ui/react-dialog'
import { appState } from '~utils/appState'

17
i18n/request.ts Normal file
View file

@ -0,0 +1,17 @@
import {getRequestConfig} from 'next-intl/server'
import {locales, defaultLocale, type Locale} from '../i18n.config'
// next-intl v4: global request config used by getMessages()
export default getRequestConfig(async ({requestLocale}) => {
let locale = (await requestLocale) as Locale | null;
if (!locale || !locales.includes(locale)) {
locale = defaultLocale;
}
// Load only i18n namespaces; exclude content data with dotted keys
const common = (await import(`../public/locales/${locale}/common.json`)).default;
const about = (await import(`../public/locales/${locale}/about.json`)).default;
const messages = {common, about} as const;
return {locale, messages};
});

2
next-env.d.ts vendored
View file

@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View file

@ -0,0 +1,243 @@
# Product Requirements Document: Fix Party Component Interface Mismatch
## Problem Statement
During the App Router migration, the client components (`NewPartyClient` and `PartyPageClient`) were created with an incorrect interface for the `Party` component. This causes an "Element type is invalid" error preventing pages from loading.
## Root Cause Analysis
### What Went Wrong
1. **Interface Mismatch**: The Party component expects props like `selectedTab`, `raidGroups`, and `handleTabChanged`, but client components are passing `party`, `isNew`, and `onSave`
2. **Incomplete Migration**: During the Pages-to-App Router migration, new client wrapper components were created without matching the existing Party component's interface
3. **Missing Context**: The migration didn't account for the Party component being a complete, self-contained system that handles its own state management
### Current Party Component Architecture
The Party component is a comprehensive system that:
- Manages its own save/create/update logic via internal functions (`createParty()` and `updateParty()`)
- Uses global `appState` from Valtio for state management
- Handles tab switching between Character/Weapon/Summon grids
- Manages edit permissions and authentication
- Contains PartyHeader, PartySegmentedControl, Grid components, and PartyFooter
### Expected Interface
```typescript
interface Props {
new?: boolean
team?: Party
selectedTab: GridType
raidGroups: RaidGroup[]
handleTabChanged: (value: string) => void
pushHistory?: (path: string) => void
}
```
### Current (Incorrect) Usage
```typescript
// In NewPartyClient.tsx
<Party
party={appState.parties[0] || { name: t('new_party'), element: 1 }}
isNew={true}
onSave={handleSave}
/>
// In PartyPageClient.tsx
<Party
party={party}
onRemix={handleRemix}
onDelete={handleDelete}
/>
```
## Solution Options
### Option 1: Fix Client Components (Recommended)
Update the client wrapper components to match the Party component's expected interface.
**Pros:**
- Maintains existing Party component architecture
- Minimal changes required
- Preserves all existing functionality
- Lower risk of breaking existing features
**Cons:**
- Client components need to manage additional state
### Option 2: Create Adapter Components
Create intermediate adapter components that translate between the two interfaces.
**Pros:**
- Keeps existing client component logic
- Provides flexibility for future changes
**Cons:**
- Adds complexity
- Extra layer of abstraction
- Performance overhead
### Option 3: Refactor Party Component
Change the Party component to accept the simpler interface the client components expect.
**Pros:**
- Simpler client components
- More explicit prop passing
**Cons:**
- Major refactoring required
- Risk of breaking existing functionality
- Would need to extract and relocate save/update logic
- Affects multiple components
## Implementation Plan (Option 1 - Recommended)
### Phase 1: Fix Critical Props Interface
#### Task List
- [x] Remove unstable_cache from data.ts (completed)
- [x] Fix viewport metadata in layout.tsx (completed)
- [x] Fix Radix Toast import (completed)
- [ ] Fix NewPartyClient props interface
- [ ] Fix PartyPageClient props interface
- [ ] Add selectedTab state management to both components
- [ ] Implement handleTabChanged functions
- [ ] Add pushHistory navigation wrapper
- [ ] Ensure raidGroups are passed correctly
- [ ] Remove next/head usage from Head components
#### Files to Modify
##### Critical Files (Must Change)
```typescript
/app/new/NewPartyClient.tsx
- Add: useState for selectedTab (default: GridType.Weapon)
- Add: handleTabChanged function
- Change: party → team prop
- Change: isNew → new prop
- Remove: onSave prop (Party handles internally)
- Add: raidGroups prop from parent
- Add: pushHistory function using router.push
/app/p/[party]/PartyPageClient.tsx
- Add: useState for selectedTab (default: GridType.Weapon)
- Add: handleTabChanged function
- Change: party → team prop
- Remove: onRemix and onDelete props (Party handles internally)
- Add: raidGroups prop from parent
- Add: pushHistory function using router.push
/app/new/page.tsx
- Verify: raidGroups are fetched correctly
- Ensure: raidGroups are passed to NewPartyClient
/app/p/[party]/page.tsx
- Add: Fetch raidGroups data
- Pass: raidGroups to PartyPageClient
```
##### Head Component Cleanup
```typescript
/components/head/NewHead/index.tsx
- Remove entirely (metadata handled in page.tsx)
/components/head/ProfileHead/index.tsx
- Remove entirely (metadata handled in page.tsx)
/components/head/SavedHead/index.tsx
- Remove entirely (metadata handled in page.tsx)
/components/head/TeamsHead/index.tsx
- Remove entirely (metadata handled in page.tsx)
/components/party/PartyHead/index.tsx
- Refactor to not use next/head
- Or remove if not needed in App Router
```
### Phase 2: Restore Toast Viewport
```typescript
/app/layout.tsx
- Uncomment and fix ToastViewport component
- Ensure proper import from @radix-ui/react-toast
```
## Testing Requirements
### Functional Tests
1. **Page Loading**
- [ ] /new page loads without errors
- [ ] /p/[party] pages load without errors
- [ ] No "Element type is invalid" errors in console
2. **Party Creation**
- [ ] Can create a new party
- [ ] Party name can be edited
- [ ] Party description can be added
- [ ] Save functionality works
3. **Party Editing**
- [ ] Can edit existing party
- [ ] Changes persist after save
- [ ] Edit permissions work correctly
4. **Tab Navigation**
- [ ] Can switch between Characters tab
- [ ] Can switch to Weapons tab
- [ ] Can switch to Summons tab
- [ ] Tab state persists during editing
5. **State Management**
- [ ] appState updates correctly
- [ ] Valtio state management works
- [ ] No state inconsistencies
### Browser Testing
- [ ] Chrome/Edge
- [ ] Firefox
- [ ] Safari
## Success Criteria
- No runtime errors on party pages
- All party creation/editing functionality works
- Tab navigation functions correctly
- Pages load successfully in development and production
- Metadata is properly rendered for SEO
- No console warnings about invalid props
## Risk Mitigation
1. **Testing Strategy**
- Test each change incrementally
- Verify functionality after each component update
- Use development server for immediate feedback
2. **Rollback Plan**
- Create focused commits for each component
- Keep changes minimal and isolated
- Document any discovered issues
3. **Communication**
- Document any unexpected behavior
- Note any additional required changes
- Update PRD if scope changes
## Timeline
- **Phase 1**: Immediate (blocking issue)
- Estimated: 1-2 hours
- Priority: Critical
- **Phase 2**: Follow-up
- Estimated: 30 minutes
- Priority: Medium
## Dependencies
- GridType enum from utils/enums.tsx
- RaidGroup type definitions
- Valtio state management
- Next.js App Router navigation
## Open Questions
1. Should PartyHead component be refactored or removed?
2. Are there other pages using the Party component that need updates?
3. Should we add TypeScript interfaces for better type safety?
## Notes
- The Party component is well-designed as a self-contained system
- The issue is purely an interface mismatch, not a design flaw
- This pattern (self-contained components) might be worth documenting for future migrations