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:
parent
22317c04a4
commit
c02a4e6b5d
7 changed files with 229 additions and 120 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
js/cf-webapp/src/app/api/optimization-events/route.ts
Normal file
89
js/cf-webapp/src/app/api/optimization-events/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
46
js/cf-webapp/src/app/api/optimization-prs/route.ts
Normal file
46
js/cf-webapp/src/app/api/optimization-prs/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue