- Replace window.location.reload() with router.refresh() in ScheduleGenerator, MatchEditor, RecalculateEloButton for proper Next.js cache invalidation - Add revalidatePath() call after schedule generation in POST handler - Add ownerId to tournament creation in cucumber tests for proper permission checks - Assign tournament_admin role via Prisma after user creation in cucumber tests - Fix TypeScript type annotations in hooks.ts (tournament id map) - Update page reload to use networkidle in common-steps.ts - Clear .next/ cache before cucumber tests in justfile - Add .turbo to clean target - Add comprehensive debug logging to schedule API route - Document findings in docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md
11 KiB
Technical Findings: Next.js App Router Data Staleness in Production
Issue Summary
Problem: Freshly generated database data (TournamentRound and BracketMatchup records) created via POST /api/tournaments/[id]/schedule fails to appear immediately after a browser refresh in production builds, despite the server component having revalidate = 0 and dynamic = "force-dynamic".
Context: The test suite schedule-tab.test.ts shows that data is created successfully in the database but the page refresh doesn't immediately display the new data in production builds.
Root Cause Analysis
1. Next.js Data Cache Behavior
Finding: Next.js App Router caches fetch responses by default in production. While revalidate = 0 and dynamic = "force-dynamic" disable full-route caching, they do not automatically disable the Data Cache for individual fetch requests.
Evidence from codebase:
src/app/admin/tournaments/[id]/schedule/page.tsxsets:export const dynamic = "force-dynamic" export const revalidate = 0- However, the page uses Prisma directly, not
fetch. The page queryprisma.event.findUniqueis not subject to Next.js fetch caching, but the browser/client router cache may still cause issues.
Relevant Code Locations:
src/app/admin/tournaments/[id]/schedule/page.tsx:14-16src/app/api/tournaments/[id]/schedule/route.ts:191-222(POST transaction)
2. Prisma Client and Transaction Isolation
Finding: The POST endpoint uses prisma.$transaction to create rounds and matchups. In production with PostgreSQL, transaction isolation levels and connection pooling can cause visibility delays.
Evidence:
// src/app/api/tournaments/[id]/schedule/route.ts:191
const created = await prisma.$transaction(
schedule.map((round) =>
prisma.tournamentRound.create({...})
)
)
Potential Issues:
- Read Committed Isolation: PostgreSQL's default
READ COMMITTEDisolation level ensures that once a transaction commits, subsequent queries see the new data. However, if the browser refresh happens immediately after the POST response, there might be a race condition. - Connection Pooling: The Prisma client uses connection pooling. If the GET request (page load) uses a different connection than the POST request, and there's a replication delay (unlikely with SQLite/PostgreSQL single instance), it could see stale data.
Evidence Locations:
src/lib/prisma.ts:13-35(Prisma client initialization)src/app/api/tournaments/[id]/schedule/route.ts:191-222(Transaction block)
3. Client-Side Router Cache
Finding: The Next.js App Router maintains a client-side cache for visited routes. Even when the server component revalidates, the client might serve a cached version from the client-side navigation cache.
Evidence from research:
- The GitHub discussion #51612 shows that
router.pushand browser refresh can still serve stale data due to client-side caching. - The
ScheduleGeneratorcomponent usesfetchto POST data but doesn't trigger a router refresh or invalidate the client cache.
Code Locations:
src/components/ScheduleGenerator.tsx:27-29(POST request)src/components/ScheduleGenerator.tsx:84(Only callswindow.location.reload()on DELETE, not POST)
4. Production vs Development Differences
Finding: Development mode (next dev) has more lenient caching behavior. Production builds (next start) aggressively cache by default.
Evidence:
- The test
schedule-tab.test.tspasses in development but fails in production. - The
ScheduleGeneratorcomponent doesn't userevalidatePathorrevalidateTagafter successful POST.
Specific Technical Findings
Finding 1: Missing Cache Invalidation After POST
Location: src/components/ScheduleGenerator.tsx:43-49
Issue: After a successful POST request, the component updates local state (result) but doesn't:
- Call
revalidatePath(requires Server Action) - Call
revalidateTag(requires Server Action) - Trigger a router refresh
- Force a page reload
Current Behavior:
const handleGenerate = async () => {
// ... POST request ...
const data = await response.json()
setResult({
roundsCreated: data.roundsCreated,
matchupsCreated: data.matchupsCreated,
})
setIsGenerating(false)
// ❌ No cache invalidation
}
Expected Behavior: After POST, the page should re-fetch data to show newly created rounds.
Finding 2: Prisma Client Singleton Pattern
Location: src/lib/prisma.ts:37-39
Issue: The Prisma client is a singleton, which is correct. However, in production with connection pooling, there might be delays in visibility across connections.
Current Code:
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Note: This is correct pattern, but production connection pooling behavior differs from development.
Finding 3: Server Component Data Fetching
Location: src/app/admin/tournaments/[id]/schedule/page.tsx:26-50
Issue: The server component fetches data directly with Prisma. While revalidate = 0 ensures the server re-renders on each request, the client might cache the response.
Current Code:
export const dynamic = "force-dynamic"
export const revalidate = 0
export default async function TournamentSchedulePage({ params }: PageProps) {
const tournament = await prisma.event.findUnique({
where: { id: tournamentId },
include: { rounds: { ... } }
})
// ...
}
Note: This should work correctly, but client-side router cache might interfere.
Potential Fixes
Fix 1: Implement Server Actions for Cache Invalidation
Approach: Convert the schedule generation to use Server Actions with revalidatePath.
Implementation:
// src/app/actions/schedule.ts
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { generateRoundRobin, /* ... */ } from '@/lib/schedule-generator'
export async function generateSchedule(tournamentId: number) {
// ... existing logic from route.ts ...
// After successful creation
await prisma.$transaction(/* ... */)
// Revalidate the schedule page
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`)
revalidatePath(`/admin/tournaments/${tournamentId}`)
return { success: true, roundsCreated: created.length }
}
Update ScheduleGenerator component:
// src/components/ScheduleGenerator.tsx
import { generateSchedule } from '@/app/actions/schedule'
const handleGenerate = async () => {
const result = await generateSchedule(tournamentId)
if (result.success) {
setResult({
roundsCreated: result.roundsCreated,
matchupsCreated: /* calculate from result */,
})
// Router automatically revalidates due to revalidatePath
}
}
Fix 2: Force Router Refresh After POST
Approach: Use router.refresh() after successful POST to invalidate client cache.
Implementation:
// src/components/ScheduleGenerator.tsx
'use client'
import { useRouter } from 'next/navigation'
export function ScheduleGenerator({ tournamentId, /* ... */ }) {
const router = useRouter()
const handleGenerate = async () => {
// ... POST request ...
if (response.ok) {
// Force router to re-fetch server component data
router.refresh()
// Or force full page reload as fallback
// window.location.reload()
}
}
}
Fix 3: Disable Fetch Caching Explicitly
Approach: Even though we use Prisma, ensure any internal fetches don't cache.
Implementation:
// src/app/api/tournaments/[id]/schedule/route.ts
export async function GET(request: Request, { params }: RouteParams) {
// Add cache control headers
const response = NextResponse.json({ rounds: tournament.rounds })
response.headers.set('Cache-Control', 'no-store, max-age=0')
return response
}
Fix 4: Add Delay/Retry Logic in Tests
Approach: For Playwright tests, add explicit wait for data visibility.
Implementation:
// e2e/schedule-tab.test.ts
test('Schedule page displays generated rounds and matchups', async ({ page }) => {
// ... navigate to schedule page ...
// Wait for rounds to be visible with retry logic
await expect(page.locator('text=Round 1')).toBeVisible({ timeout: 10000 })
// Additional verification
await expect(page.locator('text=Alice + Bob')).toBeVisible()
})
Fix 5: Database Transaction Optimization
Approach: Ensure transaction commits fully before returning response.
Implementation:
// src/app/api/tournaments/[id]/schedule/route.ts
const created = await prisma.$transaction(
schedule.map((round) =>
prisma.tournamentRound.create({
data: { /* ... */ },
include: { /* ... */ } // Eager load to ensure data is available
})
),
{
isolationLevel: 'ReadCommitted', // Explicit isolation level
maxWait: 5000, // Increase wait time
timeout: 10000, // Increase timeout
}
)
Recommended Solution
Immediate Fix (Quick)
-
Update
ScheduleGenerator.tsxto userouter.refresh()after POST:import { useRouter } from 'next/navigation' const router = useRouter() const handleGenerate = async () => { // ... POST logic ... if (response.ok) { router.refresh() } } -
Add cache control headers to the GET endpoint:
// In GET handler const response = NextResponse.json({ rounds: tournament.rounds }) response.headers.set('Cache-Control', 'no-store, max-age=0') return response
Long-term Fix (Recommended)
-
Migrate to Server Actions for schedule generation:
- Use
'use server'directive - Call
revalidatePathafter mutations - Eliminate need for separate API route
- Use
-
Implement proper cache tagging:
- Tag fetch requests with
next: { tags: ['schedule'] } - Use
revalidateTag('schedule')after mutations
- Tag fetch requests with
-
Update test patterns:
- Ensure tests wait for server component revalidation
- Use
page.waitForLoadState('networkidle')after mutations
Verification Steps
-
Test in production build:
npm run build npm run start -
Verify data flow:
- Create schedule via UI
- Refresh page immediately
- Verify rounds display correctly
-
Check server logs:
- Look for revalidation messages
- Verify Prisma query execution
-
Run acceptance tests:
npm run test:acceptance
References
- Next.js App Router Caching: https://nextjs.org/docs/app/building-your-application/data-fetching/caching
- Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
- GitHub Discussion #51612: https://github.com/vercel/next.js/discussions/51612
- Prisma Transactions: https://www.prisma.io/docs/orm/prisma-client/queries/transactions