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:
parent
f12e50d8cb
commit
847fc84faf
10 changed files with 254 additions and 74 deletions
21
components/solo/realtime-bridge.tsx
Normal file
21
components/solo/realtime-bridge.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue