import { Fragment, h } from 'preact' import { useEffect, useMemo, useState } from 'preact/hooks' import type { TraceMessage, TraceRequestMessage, TraceSnapshotMessage, TraceUnitMessage } from '../hooks/useConnection' // --- Types --- interface StateViewProps { readonly trace: TraceMessage | undefined } interface PhaseSnapshot { readonly phase: string readonly answers: Record readonly data: Record } interface ChangeSet { readonly added: ReadonlySet readonly removed: ReadonlySet readonly changed: ReadonlySet } interface PhaseChange { readonly answers: ChangeSet readonly data: ChangeSet } // Answer values arrive as `{ current, mutations }` wrappers on the wire; entries that don't match // the shape are rendered as plain values. interface AnswerMutation { readonly value?: unknown readonly source: string } interface AnswerEntry { readonly current?: unknown readonly mutations?: readonly AnswerMutation[] } interface TreeChip { readonly text: string readonly kind: string } type SubTab = 'answers' | 'data' type RequestTab = 'post' | 'query' | 'params' | 'state' | 'headers' | 'cookies' | 'session' // The request item is pinned above the phase items and selects a distinct inspector. type PhaseSelection = number | 'request' // --- Value helpers --- function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function isAnswerEntry(value: unknown): value is AnswerEntry { if (!isPlainObject(value)) { return false } const mutations = value.mutations const hasValidMutations = mutations === undefined || (Array.isArray(mutations) && mutations.every(mutation => isPlainObject(mutation) && typeof mutation.source === 'string')) if (!hasValidMutations) { return false } return 'current' in value || Array.isArray(mutations) } function isExpandable(value: unknown): boolean { if (Array.isArray(value)) { return value.length > 0 } return isPlainObject(value) && Object.keys(value).length > 0 } interface ChildEntry { readonly label: string readonly value: unknown readonly isIndex: boolean } function childEntries(value: unknown): readonly ChildEntry[] { if (Array.isArray(value)) { return value.map((item, index) => ({ label: String(index), value: item, isIndex: true })) } if (isPlainObject(value)) { return Object.entries(value).map(([key, entry]) => ({ label: key, value: entry, isIndex: false })) } return [] } // --- Preview text --- const PREVIEW_STRING_MAX = 30 const PREVIEW_OBJECT_MAX = 58 function previewValueInline(value: unknown): string { if (typeof value === 'string') { const truncated = value.length > PREVIEW_STRING_MAX ? `${value.slice(0, PREVIEW_STRING_MAX)}…` : value return `"${truncated}"` } if (Array.isArray(value)) { return `Array(${value.length})` } if (isPlainObject(value)) { return '{…}' } if (value === undefined) { return 'undefined' } if (value === null) { return 'null' } return String(value) } function objectPreview(value: Record): string { const parts = Object.entries(value).map(([key, entry]) => `${key}: ${previewValueInline(entry)}`) const assembled = parts.reduce<{ text: string; truncated: boolean }>( (accumulator, part) => { if (accumulator.truncated) { return accumulator } const next = accumulator.text === '' ? part : `${accumulator.text}, ${part}` if (next.length > PREVIEW_OBJECT_MAX) { return { text: accumulator.text, truncated: true } } return { text: next, truncated: false } }, { text: '', truncated: false }, ) if (assembled.truncated) { const shown = assembled.text === '' ? parts[0].slice(0, 40) : assembled.text return `{${shown}, …}` } return `{${assembled.text}}` } function rowPreview(value: unknown): string { if (Array.isArray(value)) { return `Array(${value.length})` } if (isPlainObject(value)) { return Object.keys(value).length === 0 ? '{}' : objectPreview(value) } return '' } // --- Snapshots and change sets --- function findSnapshot(units: readonly TraceUnitMessage[]): TraceSnapshotMessage | undefined { const direct = units.find(unit => unit.snapshot !== undefined)?.snapshot if (direct !== undefined) { return direct } return units.map(unit => findSnapshot(unit.children ?? [])).find(snapshot => snapshot !== undefined) } function buildPhaseSnapshots(trace: TraceMessage): readonly PhaseSnapshot[] { return trace.trace.phases .map(phase => ({ phase: phase.phase, snapshot: findSnapshot(phase.units) })) .filter((entry): entry is { phase: string; snapshot: TraceSnapshotMessage } => entry.snapshot !== undefined) .map(({ phase, snapshot }) => ({ phase, answers: snapshot.answers ?? {}, data: snapshot.data ?? {} })) } function diffKeys(previous: Record, current: Record): ChangeSet { const added = new Set() const removed = new Set() const changed = new Set() Object.keys(current).forEach(key => { if (!(key in previous)) { added.add(key) return } if (JSON.stringify(previous[key]) !== JSON.stringify(current[key])) { changed.add(key) } }) Object.keys(previous).forEach(key => { if (!(key in current)) { removed.add(key) } }) return { added, removed, changed } } const EMPTY_SNAPSHOT: PhaseSnapshot = { phase: '', answers: {}, data: {} } function buildChanges(phaseSnapshots: readonly PhaseSnapshot[]): readonly PhaseChange[] { return phaseSnapshots.map((snapshot, index) => { const previous = index > 0 ? phaseSnapshots[index - 1] : EMPTY_SNAPSHOT return { answers: diffKeys(previous.answers, snapshot.answers), data: diffKeys(previous.data, snapshot.data), } }) } function wasChangedAt(change: ChangeSet, key: string): boolean { return change.added.has(key) || change.changed.has(key) } function hasAnyChange(change: ChangeSet): boolean { return change.added.size + change.removed.size + change.changed.size > 0 } // The phase name that most recently added or changed a data root at or before the selected phase. function lastWriterPhase( key: string, changes: readonly PhaseChange[], phaseSnapshots: readonly PhaseSnapshot[], selectedIndex: number, ): string | undefined { const writerIndex = Array.from({ length: selectedIndex + 1 }, (unused, index) => selectedIndex - index).find(index => wasChangedAt(changes[index].data, key), ) return writerIndex !== undefined ? phaseSnapshots[writerIndex].phase : undefined } // --- Chips --- function answerSourceKind(source: string): string { if (source === 'access' || source === 'submission' || source === 'default') { return source } return 'other' } function lastMutationSource(entry: AnswerEntry): string | undefined { const mutations = entry.mutations if (mutations === undefined || mutations.length === 0) { return undefined } return mutations[mutations.length - 1].source } // --- Rail badges --- interface RailBadge { readonly kind: string readonly text: string } function buildBadges(change: PhaseChange): readonly RailBadge[] { const badges: RailBadge[] = [] if (change.answers.added.size > 0) { badges.push({ kind: 'add', text: `+${change.answers.added.size}` }) } if (change.answers.removed.size > 0) { badges.push({ kind: 'del', text: `−${change.answers.removed.size}` }) } if (change.answers.changed.size > 0) { badges.push({ kind: 'chg', text: `~${change.answers.changed.size}` }) } if (hasAnyChange(change.data)) { badges.push({ kind: 'data', text: 'data' }) } if (badges.length === 0) { return [{ kind: 'none', text: '·' }] } return badges } // --- Tree --- function LeafValue({ value }: { readonly value: unknown }) { if (value === undefined) { return undefined } if (value === null) { return null } if (typeof value === 'string') { return "{value}" } if (typeof value === 'number') { return {value} } if (typeof value === 'boolean') { return {String(value)} } return {String(value)} } function TreeRow({ label, value, isIndex, chip, tinted, }: { readonly label: string readonly value: unknown readonly isIndex: boolean readonly chip?: TreeChip readonly tinted?: boolean }) { const [expanded, setExpanded] = useState(false) const expandable = isExpandable(value) const isContainer = Array.isArray(value) || isPlainObject(value) const keyClass = `state-view__tree-key${isIndex ? ' state-view__tree-key--index' : ''}` return (
setExpanded(previous => !previous) : undefined} > {expandable ? (expanded ? '▼' : '▶') : ''} {label} : {isContainer ? {rowPreview(value)} : } {chip !== undefined && ( {chip.text} )}
{expandable && expanded && (
{childEntries(value).map(entry => ( ))}
)}
) } // --- Rail --- function PhaseRail({ phaseSnapshots, changes, selectedIndex, hasRequest, isRequestSelected, onSelectPhase, onSelectRequest, }: { readonly phaseSnapshots: readonly PhaseSnapshot[] readonly changes: readonly PhaseChange[] readonly selectedIndex: number readonly hasRequest: boolean readonly isRequestSelected: boolean readonly onSelectPhase: (index: number) => void readonly onSelectRequest: () => void }) { return (
{hasRequest && (
request input
)} {phaseSnapshots.map((snapshot, index) => (
onSelectPhase(index)} > {snapshot.phase} {buildBadges(changes[index]).map((badge, badgeIndex) => ( {badge.text} ))}
))}
) } // --- Inspector --- function subLine(answersWritten: number, answersCleared: number, dataLoaded: number): string { const parts: string[] = [] if (answersWritten > 0) { parts.push(`${answersWritten} answer${answersWritten === 1 ? '' : 's'} written`) } if (answersCleared > 0) { parts.push(`${answersCleared} answer${answersCleared === 1 ? '' : 's'} cleared`) } if (dataLoaded > 0) { parts.push(`${dataLoaded} data root${dataLoaded === 1 ? '' : 's'} loaded`) } if (parts.length === 0) { return 'No changes at this phase' } return `${parts.join(' · ')} at this phase` } function AnswersTree({ snapshot, change }: { readonly snapshot: PhaseSnapshot; readonly change: PhaseChange }) { const keys = Object.keys(snapshot.answers) if (keys.length === 0) { return
No answers at this phase
} return (
{keys.map(key => { const raw = snapshot.answers[key] const entry = isAnswerEntry(raw) ? raw : undefined const value = entry !== undefined ? entry.current : raw const source = entry !== undefined ? lastMutationSource(entry) : undefined const chip = source !== undefined ? { text: source, kind: answerSourceKind(source) } : undefined return ( ) })}
) } function DataTree({ snapshot, changes, phaseSnapshots, selectedIndex, }: { readonly snapshot: PhaseSnapshot readonly changes: readonly PhaseChange[] readonly phaseSnapshots: readonly PhaseSnapshot[] readonly selectedIndex: number }) { const keys = Object.keys(snapshot.data) if (keys.length === 0) { return
No data at this phase
} return (
{keys.map(key => { const writer = lastWriterPhase(key, changes, phaseSnapshots, selectedIndex) const chip = writer !== undefined ? { text: writer, kind: 'phase' } : undefined return ( ) })}
) } function PhaseInspector({ phaseSnapshots, changes, selectedIndex, subTab, onSubTab, }: { readonly phaseSnapshots: readonly PhaseSnapshot[] readonly changes: readonly PhaseChange[] readonly selectedIndex: number readonly subTab: SubTab readonly onSubTab: (tab: SubTab) => void }) { const snapshot = phaseSnapshots[selectedIndex] const change = changes[selectedIndex] const answersWritten = change.answers.added.size + change.answers.changed.size const answersCleared = change.answers.removed.size const dataLoaded = change.data.added.size + change.data.changed.size return (
Snapshot after {snapshot.phase}
{subLine(answersWritten, answersCleared, dataLoaded)}
{subTab === 'answers' ? ( ) : ( )}
{subTab === 'answers' ? 'Green rows were written at this phase. Chips show each key’s latest write source.' : 'Green rows loaded at this phase; the border marks the subtree that arrived as part of that write.'}
) } // --- Request inspector --- function RecordTree({ record, emptyNote }: { readonly record: Record; readonly emptyNote: string }) { const keys = Object.keys(record) if (keys.length === 0) { return
{emptyNote}
} return (
{keys.map(key => ( ))}
) } const REQUEST_TABS: readonly { readonly id: RequestTab; readonly label: string; readonly emptyNote: string }[] = [ { id: 'post', label: 'Post', emptyNote: 'No POST body' }, { id: 'query', label: 'Query', emptyNote: 'No query parameters' }, { id: 'params', label: 'Params', emptyNote: 'No route params' }, { id: 'state', label: 'State', emptyNote: 'No request state' }, { id: 'headers', label: 'Headers', emptyNote: 'No headers' }, { id: 'cookies', label: 'Cookies', emptyNote: 'No cookies' }, { id: 'session', label: 'Session', emptyNote: 'No session state' }, ] function requestRecord(request: TraceRequestMessage, tab: RequestTab): Record { if (tab === 'post') { return request.post } if (tab === 'query') { return request.query } if (tab === 'params') { return request.params } if (tab === 'state') { return request.state } // Headers, cookies and session were added after the first request-inputs release; a buffered // trace from a not-yet-restarted server won't carry them, so fall back to an empty record. if (tab === 'headers') { return request.headers ?? {} } if (tab === 'cookies') { return request.cookies ?? {} } return request.session ?? {} } function RequestInspector({ request, tab, onTab, }: { readonly request: TraceRequestMessage readonly tab: RequestTab readonly onTab: (tab: RequestTab) => void }) { const active = REQUEST_TABS.find(entry => entry.id === tab) ?? REQUEST_TABS[0] return (
Request input
Values the adapter passed to this evaluation
{REQUEST_TABS.map(entry => ( ))}
) } // --- Container --- export default function StateView({ trace }: StateViewProps) { const [selection, setSelection] = useState(undefined) const [subTab, setSubTab] = useState('answers') const [requestTab, setRequestTab] = useState('post') // A new trace re-defaults selection to its final phase (landing view is the final state). useEffect(() => { setSelection(undefined) }, [trace]) const phaseSnapshots = useMemo(() => (trace ? buildPhaseSnapshots(trace) : []), [trace]) const changes = useMemo(() => buildChanges(phaseSnapshots), [phaseSnapshots]) if (!trace) { return
Select a trace to view details
} if (phaseSnapshots.length === 0) { return
No state snapshots in this trace
} const request = trace.request const isRequestSelected = selection === 'request' && request !== undefined const selectedIndex = typeof selection === 'number' && selection < phaseSnapshots.length ? selection : phaseSnapshots.length - 1 const selected = phaseSnapshots[selectedIndex] const answerCount = Object.keys(selected.answers).length const dataCount = Object.keys(selected.data).length return (
{trace.method} {trace.pathname} {isRequestSelected && request !== undefined ? ( {Object.keys(request.post).length} post · {Object.keys(request.query).length} query ·{' '} {Object.keys(request.params).length} params · {Object.keys(request.state).length} state ·{' '} {Object.keys(request.headers ?? {}).length} headers · {Object.keys(request.cookies ?? {}).length} cookies ·{' '} {Object.keys(request.session ?? {}).length} session keys ) : ( {answerCount} answers · {dataCount} data roots at selected phase )}
setSelection('request')} /> {isRequestSelected && request !== undefined ? ( ) : ( )}
) }