fix: stop dashboard and review-optimizations infinite refetch loops (#2584)

Summary
                                                                  
- Replace server action calls with API Route Handlers in
OptimizationPRsTable and OptimizationsTable to break the Next.js RSC
refresh cycle that caused continuous endpoint polling
- Create /api/optimization-prs and /api/optimization-events route
handlers that read auth from session cookies
- Remove accountPayload prop threading from both table components and
their parent pages
- Add key-based remount on OptimizationsTable to ensure data refreshes
correctly on org switch
Root Cause
Next.js server actions always trigger an RSC page refresh (GET) after
completion. When a client component calls a server action inside a
useEffect, it creates an infinite loop: server action POST → RSC refresh
→ component
re-renders → effect fires again → repeat. This was happening in both
OptimizationPRsTable (dashboard) and OptimizationsTable
(review-optimizations).
Solution
   
Regular fetch() calls to Route Handlers do not trigger RSC refreshes,
breaking the loop entirely. Auth is handled server-side in the route
handlers by reading the session cookie — same mechanism as
getAccountContext().

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Codeflash Bot <bot@codeflash.ai>
Co-authored-by: Kevin Turcios <106575910+KRRT7@users.noreply.github.com>
This commit is contained in:
Hesham Mohamed 2026-04-09 06:26:35 +02:00 committed by GitHub
parent 22317c04a4
commit c02a4e6b5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 229 additions and 120 deletions

View file

@ -183,7 +183,6 @@ const StatisticsTab = ({
dateRangeDisplay,
isMobile,
repositoryId,
accountPayload,
}: {
optimizationStats: { totalAttempts: number; successfulAttempts: number }
optimizationsTrend: number[]
@ -202,7 +201,6 @@ const StatisticsTab = ({
dateRangeDisplay: string
isMobile: boolean
repositoryId: string
accountPayload: AccountPayload | null
}) => {
return (
<div className="space-y-6">
@ -256,11 +254,9 @@ const StatisticsTab = ({
</div>
{/* Optimization PRs Table */}
{accountPayload && (
<div>
<OptimizationPRsTable payload={accountPayload} repositoryId={repositoryId} />
</div>
)}
<div>
<OptimizationPRsTable repositoryId={repositoryId} />
</div>
</div>
)
}
@ -516,8 +512,6 @@ function RepositoryDetail() {
const [successfulOptimizationsTrendDates, setSuccessfulOptimizationsTrendDates] = useState<
string[]
>([])
const [accountPayload, setAccountPayload] = useState<AccountPayload | null>(null)
const [isMobile, setIsMobile] = useState<boolean>(false)
useEffect(() => {
@ -565,9 +559,6 @@ function RepositoryDetail() {
? { orgId: currentOrg.id }
: { userId: data.userId, username: data.username }
// Store payload for the PR table component
setAccountPayload(payload)
const currentRepo = await getRepositoryById(payload, repositoryId)
if (!currentRepo) {
@ -767,7 +758,6 @@ function RepositoryDetail() {
dateRangeDisplay={dateRangeDisplay}
isMobile={isMobile}
repositoryId={repositoryId}
accountPayload={accountPayload}
/>
) : (
<MembersTab repoId={repositoryId} currentUserId={currentUserId} />

View file

@ -33,10 +33,8 @@ import {
import { formatDistanceToNow } from "date-fns"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { getAllOptimizationEvents } from "../action"
import Image from "next/image"
import { ReviewQualityBadge } from "@/components/ui/quality_badge"
import type { AccountPayload } from "@codeflash-ai/common"
interface Repository {
id: string
@ -82,7 +80,6 @@ interface OptimizationsTableProps {
initialEvents: OptimizationEvent[]
initialTotalCount: number
availableRepositories: Array<{ id: string; full_name: string }>
accountPayload: AccountPayload
}
function TableSkeleton() {
@ -210,7 +207,6 @@ export function OptimizationsTable({
initialEvents,
initialTotalCount,
availableRepositories,
accountPayload,
}: OptimizationsTableProps) {
const router = useRouter()
@ -233,78 +229,57 @@ export function OptimizationsTable({
const isInitialMount = useRef(true)
const debounceTimer = useRef<NodeJS.Timeout>(undefined)
const loadEvents = useCallback(async () => {
setIsLoading(true)
setError(null)
const loadEvents = useCallback(
(signal?: AbortSignal) => {
setIsLoading(true)
setError(null)
try {
const filter: Record<string, string | null | { not: null }> = {}
if (filters.repositoryId === "none") {
filter.repository_id = null
} else if (filters.repositoryId) {
filter.repository_id = filters.repositoryId
}
if (filters.status !== "all") {
filter.status = filters.status
}
if (filters.eventType !== "all") {
filter.event_type = filters.eventType
}
if (filters.reviewQuality !== "all") {
filter.review_quality = filters.reviewQuality
}
const [sortField, sortDirection] = filters.sortBy.split("_").reduce(
(acc, part, index, arr) => {
if (index === arr.length - 1 && (part === "asc" || part === "desc")) {
return [acc[0], part]
}
return [acc[0] ? `${acc[0]}_${part}` : part, acc[1]]
},
["", "desc"] as [string, string],
)
const sort: Record<string, "asc" | "desc"> = {
[sortField]: sortDirection as "asc" | "desc",
}
const data = await getAllOptimizationEvents({
payload: accountPayload,
const params = new URLSearchParams({
page: String(filters.page),
pageSize: String(pageSize),
search: filters.search,
filter,
sort,
page: filters.page,
pageSize,
status: filters.status,
eventType: filters.eventType,
reviewQuality: filters.reviewQuality,
sortBy: filters.sortBy,
})
if (filters.repositoryId) params.set("repositoryId", filters.repositoryId)
type RawEvent = OptimizationEvent & {
repository?: { id: string; full_name?: string; name?: string } | null
}
const transformedEvents: OptimizationEvent[] = (data?.events || []).map(
(event: RawEvent) => ({
...event,
metadata: event.metadata as EventMetadata | null | undefined,
repository: event.repository
? {
id: event.repository.id,
full_name: event.repository.full_name || event.repository.name,
}
: null,
}),
)
setEvents(transformedEvents)
setTotalCount(data?.totalCount || 0)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events")
} finally {
setIsLoading(false)
}
}, [filters, accountPayload, pageSize])
fetch(`/api/optimization-events?${params}`, { signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
type RawEvent = OptimizationEvent & {
repository?: { id: string; full_name?: string; name?: string } | null
}
const transformedEvents: OptimizationEvent[] = (data?.events || []).map(
(event: RawEvent) => ({
...event,
metadata: event.metadata as EventMetadata | null | undefined,
repository: event.repository
? {
id: event.repository.id,
full_name: event.repository.full_name || event.repository.name,
}
: null,
}),
)
setEvents(transformedEvents)
setTotalCount(data?.totalCount || 0)
})
.catch(err => {
if (err instanceof DOMException && err.name === "AbortError") return
console.error("Failed to fetch optimization events:", err)
setError(err instanceof Error ? err.message : "Failed to load events")
})
.finally(() => {
if (!signal?.aborted) setIsLoading(false)
})
},
[filters, pageSize],
)
// Load events when filters change (skip initial mount — server provided that data)
useEffect(() => {
@ -313,6 +288,8 @@ export function OptimizationsTable({
return
}
const controller = new AbortController()
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
@ -320,13 +297,14 @@ export function OptimizationsTable({
const hasSearchChanged = filters.search !== ""
if (hasSearchChanged) {
debounceTimer.current = setTimeout(() => {
loadEvents()
loadEvents(controller.signal)
}, 300)
} else {
loadEvents()
loadEvents(controller.signal)
}
return () => {
controller.abort()
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
@ -655,7 +633,7 @@ export function OptimizationsTable({
<Button
variant="outline"
size="sm"
onClick={loadEvents}
onClick={() => loadEvents()}
className="mt-2"
disabled={isLoading}
>

View file

@ -4,6 +4,7 @@ import { OptimizationsTable } from "./_components/OptimizationsTable"
export default async function ReviewOptimizationsPage() {
const accountPayload = await getAccountContext()
const accountKey = "orgId" in accountPayload ? accountPayload.orgId : accountPayload.userId
const [initialData, availableRepositories] = await Promise.all([
getAllOptimizationEvents({
@ -28,10 +29,10 @@ export default async function ReviewOptimizationsPage() {
return (
<OptimizationsTable
key={accountKey}
initialEvents={initialEvents}
initialTotalCount={initialData?.totalCount || 0}
availableRepositories={availableRepositories}
accountPayload={accountPayload}
/>
)
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server"
import { auth0 } from "@/lib/auth0"
import { cookies } from "next/headers"
import { getUserOrganizations } from "@/components/dashboard/action"
import { getAllOptimizationEvents } from "@/app/(dashboard)/review-optimizations/action"
import type { AccountPayload } from "@codeflash-ai/common"
export async function GET(request: NextRequest) {
try {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Build AccountPayload from session + org cookie (same as getAccountContext)
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
let payload: AccountPayload
if (orgId) {
const result = await getUserOrganizations(session.user.sub)
if (result.success && result.organizations?.some(org => org.id === orgId)) {
payload = { orgId }
} else {
payload = { userId: session.user.sub, username: session.user.nickname }
}
} else {
payload = { userId: session.user.sub, username: session.user.nickname }
}
const { searchParams } = request.nextUrl
const page = parseInt(searchParams.get("page") || "1", 10)
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10)
const search = searchParams.get("search") || ""
const repositoryId = searchParams.get("repositoryId") || undefined
const status = searchParams.get("status") || "all"
const eventType = searchParams.get("eventType") || "all"
const reviewQuality = searchParams.get("reviewQuality") || "all"
const sortBy = searchParams.get("sortBy") || "created_at_desc"
// Build filter object
const filter: Record<string, string | null | { not: null }> = {}
if (repositoryId === "none") {
filter.repository_id = null
} else if (repositoryId) {
filter.repository_id = repositoryId
}
if (status !== "all") {
filter.status = status
}
if (eventType !== "all") {
filter.event_type = eventType
}
if (reviewQuality !== "all") {
filter.review_quality = reviewQuality
}
// Build sort object
const [sortField, sortDirection] = sortBy.split("_").reduce(
(acc, part, index, arr) => {
if (index === arr.length - 1 && (part === "asc" || part === "desc")) {
return [acc[0], part]
}
return [acc[0] ? `${acc[0]}_${part}` : part, acc[1]]
},
["", "desc"] as [string, string],
)
const sort: Record<string, "asc" | "desc"> = {
[sortField]: sortDirection as "asc" | "desc",
}
const data = await getAllOptimizationEvents({
payload,
search: search || undefined,
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort,
page,
pageSize,
})
return NextResponse.json(data)
} catch (error) {
console.error("Failed to fetch optimization events:", error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Internal server error" },
{ status: 500 },
)
}
}

View file

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import { auth0 } from "@/lib/auth0"
import { cookies } from "next/headers"
import { getUserOrganizations } from "@/components/dashboard/action"
import { getOptimizationPRs } from "@/app/dashboard/action"
import type { AccountPayload } from "@codeflash-ai/common"
export async function GET(request: NextRequest) {
try {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Build AccountPayload from session + org cookie (same as getAccountContext)
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
let payload: AccountPayload
if (orgId) {
const result = await getUserOrganizations(session.user.sub)
if (result.success && result.organizations?.some(org => org.id === orgId)) {
payload = { orgId }
} else {
payload = { userId: session.user.sub, username: session.user.nickname }
}
} else {
payload = { userId: session.user.sub, username: session.user.nickname }
}
const { searchParams } = request.nextUrl
const page = parseInt(searchParams.get("page") || "1", 10)
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10)
const eventTypeFilter = searchParams.get("eventTypeFilter") || "all"
const repositoryId = searchParams.get("repositoryId") || undefined
const data = await getOptimizationPRs(payload, page, pageSize, eventTypeFilter, repositoryId)
return NextResponse.json(data)
} catch (error) {
console.error("Failed to fetch optimization PRs:", error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Internal server error" },
{ status: 500 },
)
}
}

View file

@ -102,7 +102,7 @@ export default async function DashboardPage({
{/* Optimization PRs Table */}
<div className="mb-6 sm:mb-8">
<OptimizationPRsTable payload={accountPayload} />
<OptimizationPRsTable />
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-5 mb-6 sm:mb-8">

View file

@ -33,15 +33,9 @@ import {
} from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import Image from "next/image"
import {
getOptimizationPRs,
OptimizationPREvent,
OptimizationPRsResponse,
} from "@/app/dashboard/action"
import { AccountPayload } from "@codeflash-ai/common"
import type { OptimizationPREvent, OptimizationPRsResponse } from "@/app/dashboard/action"
interface OptimizationPRsTableProps {
payload: AccountPayload
className?: string
repositoryId?: string
}
@ -301,37 +295,48 @@ Pagination.displayName = "Pagination"
// Main table component
export const OptimizationPRsTable: React.FC<OptimizationPRsTableProps> = memo(
({ payload, className, repositoryId }) => {
({ className, repositoryId }) => {
const [data, setData] = useState<OptimizationPRsResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [eventTypeFilter, setEventTypeFilter] = useState("all")
const [refreshKey, setRefreshKey] = useState(0)
const pageSize = 10
const fetchData = useCallback(async () => {
useEffect(() => {
const controller = new AbortController()
setIsLoading(true)
setError(null)
try {
const result = await getOptimizationPRs(
payload,
currentPage,
pageSize,
eventTypeFilter,
repositoryId,
)
setData(result)
} catch (err) {
console.error("Failed to fetch optimization PRs:", err)
setError(err instanceof Error ? err.message : "Failed to load optimization PRs")
} finally {
setIsLoading(false)
}
}, [payload, currentPage, eventTypeFilter, repositoryId])
useEffect(() => {
fetchData()
}, [fetchData])
const params = new URLSearchParams({
page: String(currentPage),
pageSize: String(pageSize),
eventTypeFilter,
})
if (repositoryId) params.set("repositoryId", repositoryId)
fetch(`/api/optimization-prs?${params}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<OptimizationPRsResponse>
})
.then(result => setData(result))
.catch(err => {
if (err instanceof DOMException && err.name === "AbortError") return
console.error("Failed to fetch optimization PRs:", err)
setError(err instanceof Error ? err.message : "Failed to load optimization PRs")
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false)
})
return () => controller.abort()
}, [currentPage, eventTypeFilter, repositoryId, refreshKey])
const handleRefresh = useCallback(() => {
setRefreshKey(k => k + 1)
}, [])
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page)
@ -379,7 +384,7 @@ export const OptimizationPRsTable: React.FC<OptimizationPRsTableProps> = memo(
<Button
variant="ghost"
size="sm"
onClick={fetchData}
onClick={handleRefresh}
disabled={isLoading}
className="h-8 w-8 p-0"
>
@ -395,7 +400,7 @@ export const OptimizationPRsTable: React.FC<OptimizationPRsTableProps> = memo(
<Button
variant="outline"
size="sm"
onClick={fetchData}
onClick={handleRefresh}
className="mt-2"
disabled={isLoading}
>