Files
euchre_camp/docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md
david caefb0dcc0 fix: resolve schedule data staleness in production builds
- 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
2026-05-01 16:39:50 -07:00

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