fix: use createPortal for DetailPanel instead of context+useEffect
Some checks failed
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 53s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled

The previous approach used useEffect+context to hoist DetailPanel
content to the AppShell level, but the dependency-free useEffect
caused a re-render loop that broke sidebar navigation.

Replace with createPortal: pages render DetailPanel inline in their
JSX but portal it to a target div (#detail-panel-portal) at the
AppShell level. No state lifting, no re-render loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 18:38:59 +01:00
parent 5de792744e
commit f3241e904f
4 changed files with 106 additions and 192 deletions

View File

@@ -1,23 +0,0 @@
import { createContext, useContext, useState, type ReactNode } from 'react';
interface DetailPanelContextValue {
detail: ReactNode;
setDetail: (node: ReactNode) => void;
}
const DetailPanelContext = createContext<DetailPanelContextValue | null>(null);
export function DetailPanelProvider({ children }: { children: ReactNode }) {
const [detail, setDetail] = useState<ReactNode>(null);
return (
<DetailPanelContext.Provider value={{ detail, setDetail }}>
{children}
</DetailPanelContext.Provider>
);
}
export function useDetailPanelSlot(): DetailPanelContextValue {
const ctx = useContext(DetailPanelContext);
if (!ctx) throw new Error('useDetailPanelSlot must be used within DetailPanelProvider');
return ctx;
}

View File

@@ -5,7 +5,6 @@ import { useRouteCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents';
import { useAuthStore } from '../auth/auth-store';
import { useMemo, useCallback } from 'react';
import { DetailPanelProvider, useDetailPanelSlot } from './DetailPanelContext';
function healthToColor(health: string): string {
switch (health) {
@@ -69,7 +68,6 @@ function LayoutContent() {
const { data: agents } = useAgents();
const { username, logout } = useAuthStore();
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const { detail } = useDetailPanelSlot();
const sidebarApps: SidebarApp[] = useMemo(() => {
if (!catalog) return [];
@@ -124,7 +122,6 @@ function LayoutContent() {
apps={sidebarApps}
/>
}
detail={detail}
>
<TopBar
breadcrumb={breadcrumb}
@@ -140,6 +137,8 @@ function LayoutContent() {
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />
</main>
{/* Portal target for DetailPanel — pages use createPortal to render here */}
<div id="detail-panel-portal" />
</AppShell>
);
}
@@ -149,9 +148,7 @@ export function LayoutShell() {
<ToastProvider>
<CommandPaletteProvider>
<GlobalFilterProvider>
<DetailPanelProvider>
<LayoutContent />
</DetailPanelProvider>
<LayoutContent />
</GlobalFilterProvider>
</CommandPaletteProvider>
</ToastProvider>

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useParams, Link } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, ProgressBar,
@@ -9,7 +10,6 @@ import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import type { AgentInstance } from '../../api/types';
import { useDetailPanelSlot } from '../../components/DetailPanelContext';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -219,7 +219,6 @@ export default function AgentHealth() {
const { appId } = useParams();
const { data: agents } = useAgents(undefined, appId);
const { data: events } = useAgentEvents(appId);
const { setDetail } = useDetailPanelSlot();
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
@@ -505,41 +504,18 @@ export default function AgentHealth() {
</div>
)}
{/* Detail panel — hoisted to AppShell via context */}
<AgentDetailPanelSlot
open={panelOpen}
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
selectedInstance={selectedInstance}
detailTabs={detailTabs}
setDetail={setDetail}
/>
{/* Detail panel — portaled to AppShell level for proper slide-in */}
{selectedInstance && document.getElementById('detail-panel-portal') &&
createPortal(
<DetailPanel
open={panelOpen}
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
title={selectedInstance.name ?? selectedInstance.id}
tabs={detailTabs}
/>,
document.getElementById('detail-panel-portal')!,
)
}
</div>
);
}
function AgentDetailPanelSlot({
open, onClose, selectedInstance, detailTabs, setDetail,
}: {
open: boolean
onClose: () => void
selectedInstance: AgentInstance | null
detailTabs: any
setDetail: (node: React.ReactNode) => void
}) {
useEffect(() => {
if (!selectedInstance) {
setDetail(null);
return;
}
setDetail(
<DetailPanel
open={open}
onClose={onClose}
title={selectedInstance.name ?? selectedInstance.id}
tabs={detailTabs}
/>
);
return () => setDetail(null);
});
return null;
}

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import { useState, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router'
import {
DataTable,
@@ -22,7 +23,6 @@ import {
import { useDiagramLayout } from '../../api/queries/diagrams'
import type { ExecutionSummary } from '../../api/types'
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
import { useDetailPanelSlot } from '../../components/DetailPanelContext'
import styles from './Dashboard.module.css'
// Row type extends ExecutionSummary with an `id` field for DataTable
@@ -175,7 +175,6 @@ const SHORTCUTS = [
export default function Dashboard() {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
const navigate = useNavigate()
const { setDetail } = useDetailPanelSlot()
const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false)
const [sortField, setSortField] = useState<string>('startTime')
@@ -416,128 +415,93 @@ export default function Dashboard() {
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
{/* Detail panel — hoisted to AppShell via context */}
<DashboardDetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
selectedRow={selectedRow}
detail={detail}
errorClass={errorClass}
errorMsg={errorMsg}
routeNodes={routeNodes}
flatProcs={flatProcs}
navigate={navigate}
setDetail={setDetail}
/>
{/* Detail panel — portaled to AppShell level for proper slide-in */}
{selectedRow && detail && document.getElementById('detail-panel-portal') &&
createPortal(
<DetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
>
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
>
Open full details &#x2192;
</button>
</div>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(detail.status)} />
<span>{statusLabel(detail.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
</div>
</div>
</div>
{errorMsg && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div>
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{errorClass}</div>
<div className={styles.errorMessage}>{errorMsg}</div>
</div>
</div>
)}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{routeNodes.length > 0 ? (
<RouteFlow nodes={routeNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}
</div>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
</div>
{flatProcs.length > 0 ? (
<ProcessorTimeline
processors={flatProcs}
totalMs={detail.durationMs}
/>
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
)}
</div>
</DetailPanel>,
document.getElementById('detail-panel-portal')!,
)
}
</>
)
}
/** Renders the DetailPanel into the AppShell detail slot via context. */
function DashboardDetailPanel({
open, onClose, selectedRow, detail, errorClass, errorMsg, routeNodes, flatProcs, navigate, setDetail,
}: {
open: boolean
onClose: () => void
selectedRow: Row | undefined
detail: any
errorClass: string
errorMsg: string
routeNodes: RouteNode[]
flatProcs: any[]
navigate: (path: string) => void
setDetail: (node: React.ReactNode) => void
}) {
useEffect(() => {
if (!selectedRow || !detail) {
setDetail(null)
return
}
setDetail(
<DetailPanel
open={open}
onClose={onClose}
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
>
<div className={styles.panelSection}>
<button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
>
Open full details &#x2192;
</button>
</div>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span>
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(detail.status)} />
<span>{statusLabel(detail.status)}</span>
</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span>
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Agent</span>
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
</div>
</div>
</div>
{errorMsg && (
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div>
<div className={styles.errorBlock}>
<div className={styles.errorClass}>{errorClass}</div>
<div className={styles.errorMessage}>{errorMsg}</div>
</div>
</div>
)}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Route Flow</div>
{routeNodes.length > 0 ? (
<RouteFlow nodes={routeNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
)}
</div>
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>
Processor Timeline
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
</div>
{flatProcs.length > 0 ? (
<ProcessorTimeline
processors={flatProcs}
totalMs={detail.durationMs}
/>
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
)}
</div>
</DetailPanel>
)
return () => setDetail(null)
})
return null
}