chore(debug): add Layer 2 — mini Zustand-store + dispatch toggles

Test of SSE-event → store → render-pipeline werkt buiten de Solo
Paneel context. Mirrort het patroon van solo-store maar minimaal.

- debug-store.ts: kleine Zustand-store met tasks + applyEvent +
  applyCount/skipCount-tellers
- store-panel.tsx: rendert store-state in een tabel met statuskleuring
- client.tsx: drie layer-toggles (dispatch / flushSync / startView-
  Transition) + lift dispatch in onmessage. Zo kunnen we elke
  combinatie isoleren

Bevestigd: alle drie de toggles werken op het bare /debug-realtime
endpoint. Volgende laag is Server Action revalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 12:27:53 +02:00
parent dbbd20f3a9
commit 6c2a75a1dd
3 changed files with 289 additions and 0 deletions

View file

@ -1,6 +1,9 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useDebugStore, type DebugRealtimeEvent } from './debug-store'
import { StorePanel } from './store-panel'
type RowType = 'ready' | 'message' | 'error' | 'open' | 'close'
@ -40,6 +43,20 @@ export function DebugRealtimeClient() {
const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all')
const [tickNow, setTickNow] = useState(() => Date.now())
// Layer-toggles — elke combinatie isoleert een potentiële bron van bugs.
// - dispatchToStore: voert applyEvent uit op een mini Zustand-store
// - useFlushSync: forceert React om synchroon te renderen tijdens dispatch
// - useViewTransition: wrap dispatch in document.startViewTransition
const [dispatchToStore, setDispatchToStore] = useState(true)
const [useFlushSync, setUseFlushSync] = useState(false)
const [useViewTransition, setUseViewTransition] = useState(false)
const dispatchToStoreRef = useRef(dispatchToStore)
const useFlushSyncRef = useRef(useFlushSync)
const useViewTransitionRef = useRef(useViewTransition)
useEffect(() => { dispatchToStoreRef.current = dispatchToStore }, [dispatchToStore])
useEffect(() => { useFlushSyncRef.current = useFlushSync }, [useFlushSync])
useEffect(() => { useViewTransitionRef.current = useViewTransition }, [useViewTransition])
const sourceRef = useRef<EventSource | null>(null)
const lastEventTimeRef = useRef<number | null>(null)
@ -90,6 +107,30 @@ export function DebugRealtimeClient() {
source.onmessage = (e) => {
const parsed = safeParse(e.data ?? '')
append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed })
// Dispatch naar de mini Zustand-store, optioneel met flushSync
// en/of startViewTransition om de echte solo-flow te mimeken.
if (dispatchToStoreRef.current && parsed) {
const dispatch = () =>
useDebugStore.getState().applyEvent(parsed as DebugRealtimeEvent)
const wrapWithFlush = useFlushSyncRef.current
? () => flushSync(dispatch)
: dispatch
if (
useViewTransitionRef.current &&
typeof document !== 'undefined' &&
typeof (document as Document & { startViewTransition?: unknown })
.startViewTransition === 'function'
) {
;(
document as Document & {
startViewTransition: (cb: () => void) => unknown
}
).startViewTransition(wrapWithFlush)
} else {
wrapWithFlush()
}
}
}
source.onerror = () => {
@ -171,6 +212,15 @@ export function DebugRealtimeClient() {
onEmit={emitTestEvent}
onReset={reset}
/>
<LayerToggles
dispatchToStore={dispatchToStore}
setDispatchToStore={setDispatchToStore}
useFlushSync={useFlushSync}
setUseFlushSync={setUseFlushSync}
useViewTransition={useViewTransition}
setUseViewTransition={setUseViewTransition}
/>
{dispatchToStore && <StorePanel />}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 12 }}>
<thead>
<tr style={{ background: '#f0f0f0', textAlign: 'left' }}>
@ -337,6 +387,63 @@ function ControlBar({
)
}
function LayerToggles({
dispatchToStore,
setDispatchToStore,
useFlushSync,
setUseFlushSync,
useViewTransition,
setUseViewTransition,
}: {
dispatchToStore: boolean
setDispatchToStore: (v: boolean) => void
useFlushSync: boolean
setUseFlushSync: (v: boolean) => void
useViewTransition: boolean
setUseViewTransition: (v: boolean) => void
}) {
return (
<div
style={{
marginTop: 12,
padding: 8,
background: '#fff8e1',
border: '1px solid #ffe0a0',
borderRadius: 4,
fontSize: 12,
}}
>
<strong style={{ marginRight: 12 }}>Layers:</strong>
<label style={{ marginRight: 16 }}>
<input
type="checkbox"
checked={dispatchToStore}
onChange={(e) => setDispatchToStore(e.target.checked)}
/>{' '}
dispatch naar mini-store
</label>
<label style={{ marginRight: 16 }}>
<input
type="checkbox"
checked={useFlushSync}
onChange={(e) => setUseFlushSync(e.target.checked)}
disabled={!dispatchToStore}
/>{' '}
wrap in flushSync
</label>
<label>
<input
type="checkbox"
checked={useViewTransition}
onChange={(e) => setUseViewTransition(e.target.checked)}
disabled={!dispatchToStore}
/>{' '}
wrap in startViewTransition
</label>
</div>
)
}
function Stat({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>

View file

@ -0,0 +1,71 @@
// Mini Zustand-store voor de debug-pagina. Mirrort het patroon van
// stores/solo-store.ts maar dan minimaal: alleen tasks-record + applyEvent.
// Doel: testen of de SSE-event → store → component-render keten werkt
// zonder de complexiteit van de echte Solo Paneel.
import { create } from 'zustand'
export interface DebugTask {
id: string
status: string
title: string
story_id: string
updated_at: string
}
export interface DebugRealtimeEvent {
op: 'I' | 'U' | 'D'
entity: 'task' | 'story'
id: string
story_id?: string
task_status?: string
task_title?: string
debug?: boolean
emitted_at?: string
[key: string]: unknown
}
interface DebugStore {
tasks: Record<string, DebugTask>
applyCount: number
skipCount: number
applyEvent: (event: DebugRealtimeEvent) => void
reset: () => void
}
export const useDebugStore = create<DebugStore>((set, get) => ({
tasks: {},
applyCount: 0,
skipCount: 0,
applyEvent: (event) => {
if (event.entity !== 'task') {
set((s) => ({ skipCount: s.skipCount + 1 }))
return
}
if (event.op === 'D') {
set((s) => {
const next = { ...s.tasks }
delete next[event.id]
return { tasks: next, applyCount: s.applyCount + 1 }
})
return
}
// INSERT/UPDATE — schrijf altijd, ongeacht of de task al bestond
set((s) => ({
tasks: {
...s.tasks,
[event.id]: {
id: event.id,
status: event.task_status ?? get().tasks[event.id]?.status ?? '?',
title: event.task_title ?? get().tasks[event.id]?.title ?? '(no title)',
story_id: event.story_id ?? get().tasks[event.id]?.story_id ?? '?',
updated_at: new Date().toISOString(),
},
},
applyCount: s.applyCount + 1,
}))
},
reset: () => set({ tasks: {}, applyCount: 0, skipCount: 0 }),
}))

View file

@ -0,0 +1,111 @@
'use client'
import { useDebugStore } from './debug-store'
export function StorePanel() {
const tasks = useDebugStore((s) => s.tasks)
const applyCount = useDebugStore((s) => s.applyCount)
const skipCount = useDebugStore((s) => s.skipCount)
const reset = useDebugStore((s) => s.reset)
const taskList = Object.values(tasks)
return (
<div style={{ marginTop: 16 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 8,
background: '#eef',
border: '1px solid #cce',
borderRadius: 4,
fontSize: 12,
}}
>
<strong>Store layer:</strong>
<span>
applyCount: <code>{applyCount}</code>
</span>
<span>
skipCount: <code>{skipCount}</code>
</span>
<span>
taskCount: <code>{taskList.length}</code>
</span>
<button
onClick={reset}
style={{
marginLeft: 'auto',
padding: '4px 10px',
background: '#eee',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 12,
cursor: 'pointer',
}}
>
reset store
</button>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 8 }}>
<thead>
<tr style={{ background: '#f0f0f0', textAlign: 'left' }}>
<th style={{ padding: 6, border: '1px solid #ddd', width: 220 }}>id</th>
<th style={{ padding: 6, border: '1px solid #ddd', width: 100 }}>status</th>
<th style={{ padding: 6, border: '1px solid #ddd' }}>title</th>
<th style={{ padding: 6, border: '1px solid #ddd', width: 220 }}>updated_at (lokaal)</th>
</tr>
</thead>
<tbody>
{taskList.length === 0 ? (
<tr>
<td colSpan={4} style={{ padding: 8, textAlign: 'center', color: '#888' }}>
Store is leeg. Trigger een event en kijk of de tabel hier vult.
</td>
</tr>
) : (
taskList.map((t) => (
<tr key={t.id}>
<td style={{ padding: 6, border: '1px solid #ddd' }}>
<code>{t.id}</code>
</td>
<td
style={{
padding: 6,
border: '1px solid #ddd',
fontWeight: 'bold',
color: statusColor(t.status),
}}
>
{t.status}
</td>
<td style={{ padding: 6, border: '1px solid #ddd' }}>{t.title}</td>
<td style={{ padding: 6, border: '1px solid #ddd', whiteSpace: 'nowrap' }}>
{t.updated_at}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}
function statusColor(status: string) {
switch (status) {
case 'TO_DO':
return '#888'
case 'IN_PROGRESS':
return '#0070cc'
case 'REVIEW':
return '#cc7a00'
case 'DONE':
return 'green'
default:
return 'inherit'
}
}