fix(M8): make SSE-stream survive Solo Paneel mutations

Symptoom op feat/ST-801-realtime-triggers initial implementation:
elke task-update sloot de open SSE-stream af en triggerde een
herverbinding met backoff. In de tussentijd gemiste events.

Oorzaak: Server Actions in App Router doen een impliciete
route-tree refresh die client components remount; daarmee killt
React de useEffect die de EventSource beheert.

Fix in twee delen:

1. Hef de realtime-hook op naar de (app)-layout via een nieuwe
   `SoloRealtimeBridge`-component. Layouts overleven Server-
   Action-refreshes beter dan pages, en de bridge leest het
   product-id uit de URL via usePathname. Connection-status
   (status, showConnectingIndicator) gaat naar de solo-store
   zodat SoloBoard 'm uit een gedeelde plek kan lezen.

2. Vervang updateTaskStatusAction en updateTaskPlanAction in de
   Solo-componenten door fetch naar de bestaande Route Handler
   `PATCH /api/tasks/[id]`. Route Handlers triggeren geen
   page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts
   accepteert nu naast Bearer-tokens ook iron-session cookies
   zodat browser-fetches zonder token werken.

Bijkomend: actions/tasks.ts laat /solo bewust niet meer
revalideren (wordt nu via realtime gedekt). Sprint/planning blijft
wel revalidaten — geen realtime daar.

Toegevoegd:
- components/solo/realtime-bridge.tsx — mount in (app) layout
- scripts/realtime-mutate.ts — handige test-helper voor externe
  mutaties (alsof MCP/REST schrijft) tijdens acceptance

Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor
ST-806 acceptance; worden later gestript.

Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource
om de paar seconden vanwege de 6-connectie-limiet en retry-
heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2
multiplexing) zou beide browsers stabiel moeten houden — Vercel
preview test is volgende stap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 04:35:12 +02:00
parent f12e50d8cb
commit 847fc84faf
10 changed files with 254 additions and 74 deletions

View file

@ -0,0 +1,21 @@
// SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding
// blijft staan over Server Action-refreshes van de Solo-page heen.
//
// Leest het huidige product-id uit de URL (`/products/[id]/solo`).
// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream
// gesloten — geen onnodige verbinding open houden.
'use client'
import { usePathname } from 'next/navigation'
import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime'
const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/
export function SoloRealtimeBridge() {
const pathname = usePathname()
const match = pathname?.match(SOLO_PATH_RE)
const productId = match?.[1] ?? null
useSoloRealtime(productId)
return null
}

View file

@ -7,8 +7,8 @@ import {
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import { updateTaskStatusAction } from '@/actions/tasks'
import { useSoloRealtime, type RealtimeStatus } from '@/lib/realtime/use-solo-realtime'
import type { RealtimeStatus } from '@/stores/solo-store'
import { taskStatusToApi } from '@/lib/task-status'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { SoloColumn, type ColumnStatus } from './solo-column'
@ -90,14 +90,14 @@ export function SoloBoard({
productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const { status: realtimeStatus, showConnectingIndicator } = useSoloRealtime(productId)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(initialTasks)
@ -137,15 +137,29 @@ export function SoloBoard({
// Onderdruk realtime-echo van onze eigen write — de Postgres-trigger
// vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker
// zou de store nogmaals een set() doen of de optimistic state
// overschrijven. clearPending na de Server Action (succes of fail).
// overschrijven. clearPending na de fetch (succes of fail).
//
// We gebruiken bewust een fetch-based Route Handler in plaats van
// de updateTaskStatusAction Server Action — Server Actions
// triggeren een full route-tree refresh die de open SSE-stream van
// /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten
// en realtime-events missen.
markPending(taskId)
startTransition(async () => {
try {
const result = await updateTaskStatusAction(taskId, toStatus)
if (result && 'error' in result) {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status: taskStatusToApi(toStatus) }),
})
if (!res.ok) {
rollback(taskId, prevStatus)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
}
} catch {
rollback(taskId, prevStatus)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
} finally {
clearPending(taskId)
}

View file

@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { useSoloStore } from '@/stores/solo-store'
import { updateTaskPlanAction } from '@/actions/tasks'
import { cn } from '@/lib/utils'
import type { SoloTask } from './solo-board'
@ -56,16 +55,29 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
setSaveState('saving')
if (fadeTimer.current) clearTimeout(fadeTimer.current)
// fetch naar Route Handler i.p.v. Server Action — Server Actions
// kappen anders de open SSE-stream van het Solo Paneel af. Zie
// notitie in solo-board.tsx handleDragEnd.
startTransition(async () => {
const result = await updateTaskPlanAction(task.id, productId, localPlan)
if (result && 'error' in result) {
setSaveState('idle')
toast.error(typeof result.error === 'string' ? result.error : 'Implementatieplan opslaan mislukt')
} else {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ implementation_plan: localPlan }),
})
if (!res.ok) {
setSaveState('idle')
toast.error('Implementatieplan opslaan mislukt')
return
}
savedPlanRef.current = localPlan
updatePlan(task.id, localPlan || null)
setSaveState('saved')
fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000)
} catch {
setSaveState('idle')
toast.error('Implementatieplan opslaan mislukt')
}
})
}