caefb0dcc0
- 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
344 lines
11 KiB
Markdown
344 lines
11 KiB
Markdown
# 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.tsx` sets:
|
|
```typescript
|
|
export const dynamic = "force-dynamic"
|
|
export const revalidate = 0
|
|
```
|
|
- However, the page uses Prisma directly, not `fetch`. The page query `prisma.event.findUnique` is 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-16`
|
|
- `src/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**:
|
|
```typescript
|
|
// 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 COMMITTED` isolation 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.push` and browser refresh can still serve stale data due to client-side caching.
|
|
- The `ScheduleGenerator` component uses `fetch` to 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 calls `window.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.ts` passes in development but fails in production.
|
|
- The `ScheduleGenerator` component doesn't use `revalidatePath` or `revalidateTag` after 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:
|
|
1. Call `revalidatePath` (requires Server Action)
|
|
2. Call `revalidateTag` (requires Server Action)
|
|
3. Trigger a router refresh
|
|
4. Force a page reload
|
|
|
|
**Current Behavior**:
|
|
```typescript
|
|
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**:
|
|
```typescript
|
|
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**:
|
|
```typescript
|
|
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**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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**:
|
|
```typescript
|
|
// 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)
|
|
|
|
1. **Update `ScheduleGenerator.tsx`** to use `router.refresh()` after POST:
|
|
```typescript
|
|
import { useRouter } from 'next/navigation'
|
|
|
|
const router = useRouter()
|
|
|
|
const handleGenerate = async () => {
|
|
// ... POST logic ...
|
|
if (response.ok) {
|
|
router.refresh()
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Add cache control headers** to the GET endpoint:
|
|
```typescript
|
|
// 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)
|
|
|
|
1. **Migrate to Server Actions** for schedule generation:
|
|
- Use `'use server'` directive
|
|
- Call `revalidatePath` after mutations
|
|
- Eliminate need for separate API route
|
|
|
|
2. **Implement proper cache tagging**:
|
|
- Tag fetch requests with `next: { tags: ['schedule'] }`
|
|
- Use `revalidateTag('schedule')` after mutations
|
|
|
|
3. **Update test patterns**:
|
|
- Ensure tests wait for server component revalidation
|
|
- Use `page.waitForLoadState('networkidle')` after mutations
|
|
|
|
---
|
|
|
|
## Verification Steps
|
|
|
|
1. **Test in production build**:
|
|
```bash
|
|
npm run build
|
|
npm run start
|
|
```
|
|
|
|
2. **Verify data flow**:
|
|
- Create schedule via UI
|
|
- Refresh page immediately
|
|
- Verify rounds display correctly
|
|
|
|
3. **Check server logs**:
|
|
- Look for revalidation messages
|
|
- Verify Prisma query execution
|
|
|
|
4. **Run acceptance tests**:
|
|
```bash
|
|
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
|