## Summary - Fixes periodic production crashes (undici ECONNREFUSED ::1) by bounding server cache size/lifetime and hardening server HTTP client. ### Root cause - React server cache (cache(...)) held axios responses indefinitely across many parameter combinations, causing slow memory growth until the Next.js app router worker was OOM-killed. The main server then failed IPC to the worker (ECONNREFUSED ::1:<port>). ### Changes - `app/lib/data.ts`: Replace unbounded cache(...) with unstable_cache and explicit keys; TTLs: 60s for teams/detail/favorites/user, 300s for meta (jobs/skills/accessories/raids/version). - `app/lib/api-utils.ts`: Add shared Axios instance with 15s timeout and keepAlive http/https agents; apply to GET/POST/PUT/DELETE helpers. - `pages/api/preview/[shortcode].ts`: Remove duplicate handler to dedupe route; retain the .tsx variant using `NEXT_PUBLIC_SIERO_API_URL`. ### Notes - Build currently has pre-existing app/pages route duplication errors; out of scope here but unrelated to this fix. - Ensure `NEXT_PUBLIC_SIERO_API_URL` and `NEXT_PUBLIC_SIERO_OAUTH_URL` are set on Railway. ### Risk/impact - Low risk; behavior is unchanged aside from bounded caching and resilient HTTP. - Cache TTLs can be tuned later if needed. ### Test plan - Verify saved/teams/user pages load and revalidate after TTL. - Validate API routes still proxy correctly; timeouts occur after ~15s for hung upstreams. - Monitor memory over several days; expect stable usage without steady growth.
248 lines
No EOL
6.6 KiB
TypeScript
248 lines
No EOL
6.6 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useState } from 'react'
|
|
import { useTranslation } from 'next-i18next'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import InfiniteScroll from 'react-infinite-scroll-component'
|
|
|
|
// Hooks
|
|
import { useFavorites } from '~/hooks/useFavorites'
|
|
import { useTeamFilter } from '~/hooks/useTeamFilter'
|
|
|
|
// Utils
|
|
import { appState } from '~/utils/appState'
|
|
import { defaultFilterset } from '~/utils/defaultFilters'
|
|
import { CollectionPage } from '~/utils/enums'
|
|
|
|
// Components
|
|
import FilterBar from '~/components/filters/FilterBar'
|
|
import GridRep from '~/components/reps/GridRep'
|
|
import GridRepCollection from '~/components/reps/GridRepCollection'
|
|
import LoadingRep from '~/components/reps/LoadingRep'
|
|
import ErrorSection from '~/components/ErrorSection'
|
|
|
|
// Types
|
|
interface Party {
|
|
id: string;
|
|
shortcode: string;
|
|
name: string;
|
|
element: number;
|
|
// Add other properties as needed
|
|
}
|
|
|
|
interface Pagination {
|
|
current_page: number;
|
|
total_pages: number;
|
|
record_count: number;
|
|
}
|
|
|
|
interface Props {
|
|
initialData: {
|
|
teams: Party[];
|
|
raidGroups: any[];
|
|
pagination: Pagination;
|
|
};
|
|
initialElement?: number;
|
|
initialRaid?: string;
|
|
initialRecency?: string;
|
|
error?: boolean;
|
|
}
|
|
|
|
const TeamsPageClient: React.FC<Props> = ({
|
|
initialData,
|
|
initialElement,
|
|
initialRaid,
|
|
initialRecency,
|
|
error = false
|
|
}) => {
|
|
const { t } = useTranslation('common')
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
|
|
// State management
|
|
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
|
const [currentPage, setCurrentPage] = useState(initialData.pagination.current_page)
|
|
const [totalPages, setTotalPages] = useState(initialData.pagination.total_pages)
|
|
const [recordCount, setRecordCount] = useState(initialData.pagination.record_count)
|
|
const [loaded, setLoaded] = useState(true)
|
|
const [fetching, setFetching] = useState(false)
|
|
const [element, setElement] = useState(initialElement || 0)
|
|
const [raid, setRaid] = useState(initialRaid || '')
|
|
const [recency, setRecency] = useState(initialRecency || '')
|
|
const [advancedFilters, setAdvancedFilters] = useState({})
|
|
|
|
const { toggleFavorite } = useFavorites(parties, setParties)
|
|
|
|
// Initialize app state with raid groups
|
|
useEffect(() => {
|
|
if (initialData.raidGroups.length > 0) {
|
|
appState.raidGroups = initialData.raidGroups
|
|
}
|
|
}, [initialData.raidGroups])
|
|
|
|
// Update URL when filters change
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
|
|
// Update or remove parameters based on filter values
|
|
if (element) {
|
|
params.set('element', element.toString())
|
|
} else {
|
|
params.delete('element')
|
|
}
|
|
|
|
if (raid) {
|
|
params.set('raid', raid)
|
|
} else {
|
|
params.delete('raid')
|
|
}
|
|
|
|
if (recency) {
|
|
params.set('recency', recency)
|
|
} else {
|
|
params.delete('recency')
|
|
}
|
|
|
|
// Only update URL if filters are changed
|
|
const newQueryString = params.toString()
|
|
const currentQuery = searchParams.toString()
|
|
|
|
if (newQueryString !== currentQuery) {
|
|
router.push(`/teams${newQueryString ? `?${newQueryString}` : ''}`)
|
|
}
|
|
}, [element, raid, recency, router, searchParams])
|
|
|
|
// Load more teams when scrolling
|
|
async function loadMoreTeams() {
|
|
if (fetching || currentPage >= totalPages) return
|
|
|
|
setFetching(true)
|
|
|
|
try {
|
|
// Construct URL for fetching more data
|
|
const url = new URL('/api/parties', window.location.origin)
|
|
url.searchParams.set('page', (currentPage + 1).toString())
|
|
|
|
if (element) url.searchParams.set('element', element.toString())
|
|
if (raid) url.searchParams.set('raid', raid)
|
|
if (recency) url.searchParams.set('recency', recency)
|
|
|
|
const response = await fetch(url.toString())
|
|
const data = await response.json()
|
|
|
|
if (data.parties && Array.isArray(data.parties)) {
|
|
setParties([...parties, ...data.parties])
|
|
setCurrentPage(data.pagination?.current_page || currentPage + 1)
|
|
setTotalPages(data.pagination?.total_pages || totalPages)
|
|
setRecordCount(data.pagination?.record_count || recordCount)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading more teams', error)
|
|
} finally {
|
|
setFetching(false)
|
|
}
|
|
}
|
|
|
|
// Receive filters from the filter bar
|
|
function receiveFilters(filters: FilterSet) {
|
|
if ('element' in filters) {
|
|
setElement(filters.element || 0)
|
|
}
|
|
if ('recency' in filters) {
|
|
setRecency(filters.recency || '')
|
|
}
|
|
if ('raid' in filters) {
|
|
setRaid(filters.raid || '')
|
|
}
|
|
|
|
// Reset to page 1 when filters change
|
|
setCurrentPage(1)
|
|
}
|
|
|
|
function receiveAdvancedFilters(filters: FilterSet) {
|
|
setAdvancedFilters(filters)
|
|
// Reset to page 1 when filters change
|
|
setCurrentPage(1)
|
|
}
|
|
|
|
// Methods: Navigation
|
|
function goTo(shortcode: string) {
|
|
router.push(`/p/${shortcode}`)
|
|
}
|
|
|
|
// Page component rendering methods
|
|
function renderParties() {
|
|
return parties.map((party, i) => (
|
|
<GridRep
|
|
party={party}
|
|
key={`party-${i}`}
|
|
loading={fetching}
|
|
onClick={() => goTo(party.shortcode)}
|
|
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
|
|
/>
|
|
))
|
|
}
|
|
|
|
function renderLoading(number: number) {
|
|
return (
|
|
<GridRepCollection>
|
|
{Array.from({ length: number }, (_, i) => (
|
|
<LoadingRep key={`loading-${i}`} />
|
|
))}
|
|
</GridRepCollection>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<ErrorSection
|
|
status={{
|
|
code: 500,
|
|
text: 'internal_server_error'
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const renderInfiniteScroll = (
|
|
<>
|
|
{parties.length === 0 && !loaded && renderLoading(3)}
|
|
{parties.length === 0 && loaded && (
|
|
<div className="notFound">
|
|
<h2>{t('teams.not_found')}</h2>
|
|
</div>
|
|
)}
|
|
{parties.length > 0 && (
|
|
<InfiniteScroll
|
|
dataLength={parties.length}
|
|
next={loadMoreTeams}
|
|
hasMore={totalPages > currentPage}
|
|
loader={renderLoading(3)}
|
|
>
|
|
<GridRepCollection>{renderParties()}</GridRepCollection>
|
|
</InfiniteScroll>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<FilterBar
|
|
defaultFilterset={defaultFilterset}
|
|
onFilter={receiveFilters}
|
|
onAdvancedFilter={receiveAdvancedFilters}
|
|
persistFilters={true}
|
|
element={element}
|
|
raid={raid}
|
|
raidGroups={initialData.raidGroups}
|
|
recency={recency}
|
|
>
|
|
<h1>{t('teams.title')}</h1>
|
|
</FilterBar>
|
|
|
|
<section>{renderInfiniteScroll}</section>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default TeamsPageClient |