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

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.tsx sets:
    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:

// 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:

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
  }
)

Immediate Fix (Quick)

  1. Update ScheduleGenerator.tsx to use router.refresh() after POST:

    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:

    // In GET handler
    const response = NextResponse.json({ rounds: tournament.rounds })
    response.headers.set('Cache-Control', 'no-store, max-age=0')
    return response
    
  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:

    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:

    npm run test:acceptance
    

References