Laat een kapot paneel geen kapotte applicatie meer zijn

Er was nergens een error boundary: geen componentDidCatch, geen
getDerivedStateFromError. Eén component die gooit nam de hele werkbank mee,
inclusief de kaart en de navigatie. Nu blijft de schil staan, ziet de gebruiker
welk werkblad het liet afweten, en wist wegnavigeren de fout.

Daarnaast een patroon dat op zeven plekken half was toegepast: een optionele
keten die te vroeg stopt. report?.items?.filter(...) stond geguard, twintig
regels verderop stond report.items.map(...) zonder. Half geguard is de
eigenlijke fout, want het wekt zekerheid zonder die te bieden. Waar de lijst op
meerdere plekken gebruikt wordt staat nu één normalisatie aan de kop.

De brondetails hadden geen enkele afhandeling en werden met .catch(() => null)
overgeslagen; die melden nu wel. De overige elf catch-blokken bleken bovenop
hooks te staan die de fout al zelf opvangen en tonen, wat ik heb nagemeten door
een verzoek te laten mislukken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-23 15:00:08 +02:00
co-authored by Claude Opus 5
parent 438d0ec083
commit f567a459b0
15 changed files with 294 additions and 26 deletions
@@ -136,7 +136,7 @@ export function ChangeDetectionPanel({
</div>
) : null}
{result?.warnings.length ? (
{result?.warnings?.length ? (
<div className="change-detection-warning-surface">
<strong>Aandachtspunten</strong>
<ul className="compact-list">
@@ -206,9 +206,9 @@ export function DetectionLab({
(profile) => profile.modelAssetId === selectedModelAssetId,
) ?? null
const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled &&
yoloPreflight.checks.dependencies_available &&
yoloPreflight.checks.model_file_exists,
yoloPreflight?.checks?.enabled &&
yoloPreflight.checks?.dependencies_available &&
yoloPreflight.checks?.model_file_exists,
)
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionHasDataset = selectedDetectionDatasetId.length > 0
@@ -308,7 +308,7 @@ export function DetectionLab({
<p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p>
</div>
<div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>PyTorch-runtime</span>
<span>Rekenomgeving</span>
<strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong>
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div>
@@ -189,7 +189,7 @@ export function WorkbenchInspector({
<InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} />
<InspectorField label="Status" value={latestQualityCheck?.status} />
<InspectorField label="Score" value={latestQualityCheck?.score} />
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics.length} />
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics?.length} />
<div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}>
Kwaliteit openen
@@ -328,13 +328,17 @@ export function useMapWorkspaceViewModel({
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id]
const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? []
// Eén normalisatie: hiervoor stond er een guard op de filter, geen op de
// reduce en geen op de some. Half geguard is de eigenlijke fout — het wekt
// zekerheid zonder die te bieden.
const dekkingsItems = coverage?.items ?? []
const activeCoverageItems = dekkingsItems.filter((item) => item.theme === activeCoverageTheme)
const coverageCounts = useMemo(
() => coverage?.items.reduce<Record<CoverageStatus, number>>(
() => dekkingsItems.reduce<Record<CoverageStatus, number>>(
(counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }),
{ operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
[coverage],
),
[dekkingsItems],
)
const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => {
const result: OnDemandMapProduct[] = []
@@ -534,7 +538,7 @@ export function useMapWorkspaceViewModel({
return false
}
const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id]
return coverage.items.some(
return dekkingsItems.some(
(item) => item.theme === coverageTheme && item.status === 'operational',
)
})
@@ -0,0 +1,41 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProviderPanel } from './ProviderPanel'
import type { ProviderCapability } from '../../types'
function props(overschrijf: Partial<Parameters<typeof ProviderPanel>[0]> = {}) {
return {
selectedProjectId: 'p-1',
providers: [] as ProviderCapability[],
loadingCapabilities: false,
capabilitiesError: null,
onRefresh: vi.fn(),
onOpenSources: vi.fn(),
onOpenStatus: vi.fn(),
onOpenMap: vi.fn(),
...overschrijf,
}
}
describe('ProviderPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat providers altijd meekomt. Dit paneel gebruikte die
// lijst op drie plekken en had er op één een guard; nu wordt hij aan de
// kop genormaliseerd. Een type is een belofte van de compiler, niet van
// het netwerk.
const zonder = props({ providers: undefined as unknown as ProviderCapability[] })
expect(() => render(<ProviderPanel {...zonder} />)).not.toThrow()
})
it('toont een lege toestand zonder koppelingen', () => {
render(<ProviderPanel {...props()} />)
expect(screen.getByText(/geen databronnen gemeld/i)).toBeTruthy()
})
it('meldt het wanneer de status niet opgehaald kon worden', () => {
render(<ProviderPanel {...props({ capabilitiesError: 'Bronservice niet bereikbaar' })} />)
expect(screen.getByText(/bronservice niet bereikbaar/i)).toBeTruthy()
})
})
@@ -52,7 +52,10 @@ export function ProviderPanel({
onOpenStatus,
onOpenMap,
}: ProviderPanelProps): JSX.Element {
const configuredCount = providers.filter((provider) => provider.configured).length
// Eén normalisatie aan de kop, in plaats van op elke gebruiksplek een guard.
// Een antwoord zonder deze lijst liet het paneel eerder vallen.
const koppelingen = providers ?? []
const configuredCount = koppelingen.filter((provider) => provider.configured).length
const [operations, setOperations] = useState<AoiOperation[]>([])
const [operationsError, setOperationsError] = useState<string | null>(null)
const [loadingOperations, setLoadingOperations] = useState(false)
@@ -93,7 +96,7 @@ export function ProviderPanel({
</div>
<div className="system-command-surface" aria-label="Systeemacties">
<div><span>Bronkoppelingen</span><strong>{configuredCount} van {providers.length} actief</strong></div>
<div><span>Bronkoppelingen</span><strong>{configuredCount} van {koppelingen.length} actief</strong></div>
<div><span>Werkmodus</span><strong>Begrensde bronopvraging</strong></div>
<div className="system-command-actions">
<button type="button" className="primary-action" onClick={onOpenMap}>Open operationele kaart</button>
@@ -115,7 +118,7 @@ export function ProviderPanel({
<p>{capabilitiesError}</p>
</div>
) : null}
{providers.length === 0 && !loadingCapabilities ? (
{koppelingen.length === 0 && !loadingCapabilities ? (
<div className="result-state result-state-empty">
<strong>Geen databronnen gemeld.</strong>
<p>Vernieuw de status zodra de backend bereikbaar is.</p>
@@ -157,7 +160,7 @@ export function ProviderPanel({
</div>
</div>
<ul className="system-provider-list">
{providers.map((provider) => (
{koppelingen.map((provider) => (
<li className="system-provider-card" key={provider.provider_name}>
<div className="system-provider-header">
<div>
@@ -197,7 +197,7 @@ export function DetectionReviewPanel({
) : null}
<ol className="detection-review-list">
{queue?.items.map((item) => {
{queue?.items?.map((item) => {
const key = reviewKey(item)
const decision = draftDecisions[key] ?? item.decision
return (
@@ -80,7 +80,7 @@ function qualityCheckTypeLabel(checkType: string | null | undefined): string {
}
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
return check?.metrics.find((metric) => metric.metric_key === metricKey)
return check?.metrics?.find((metric) => metric.metric_key === metricKey)
}
function findingEvidenceList(check: QualityCheckRead | null, key: string): Record<string, unknown>[] {
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkspaceErrorBoundary } from './WorkspaceErrorBoundary'
function Struikelt({ gooi }: { gooi: boolean }): JSX.Element {
if (gooi) {
throw new Error('checks is undefined')
}
return <p>werkblad staat</p>
}
describe('WorkspaceErrorBoundary', () => {
afterEach(() => cleanup())
it('houdt de schil overeind en biedt een nieuwe poging aan', () => {
// React logt de gevangen fout zelf ook; die ruis hoort niet in de uitvoer.
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
function Proef(): JSX.Element {
const [gooi, setGooi] = useState(true)
return (
<div>
<button type="button" onClick={() => setGooi(false)}>herstel de bron</button>
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi={gooi} />
</WorkspaceErrorBoundary>
</div>
)
}
render(<Proef />)
// De fout is opgevangen: de melding staat er, de schil eromheen ook.
expect(screen.getByTestId('workspace-error')).toBeTruthy()
expect(screen.getByText('Beeldanalyse kon niet worden getoond')).toBeTruthy()
expect(screen.getByText('checks is undefined')).toBeTruthy()
expect(screen.getByRole('button', { name: /herstel de bron/i })).toBeTruthy()
// Na herstel van de oorzaak brengt "Opnieuw proberen" het werkblad terug.
fireEvent.click(screen.getByRole('button', { name: /herstel de bron/i }))
fireEvent.click(screen.getByRole('button', { name: /opnieuw proberen/i }))
expect(screen.getByText('werkblad staat')).toBeTruthy()
stil.mockRestore()
})
it('wist de fout zodra de gebruiker naar een ander werkblad gaat', () => {
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender } = render(
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi />
</WorkspaceErrorBoundary>,
)
expect(screen.getByTestId('workspace-error')).toBeTruthy()
rerender(
<WorkspaceErrorBoundary resetKey="map" label="Kaart">
<Struikelt gooi={false} />
</WorkspaceErrorBoundary>,
)
expect(screen.getByText('werkblad staat')).toBeTruthy()
expect(screen.queryByTestId('workspace-error')).toBeNull()
stil.mockRestore()
})
})
@@ -0,0 +1,83 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { CircleAlert, RotateCcw } from 'lucide-react'
interface WorkspaceErrorBoundaryProps {
/** Verandert deze sleutel, dan probeert de grens het opnieuw. Zet hier de
actieve werkruimte in, zodat wegnavigeren de fout wist. */
resetKey: string
/** Naam van het werkblad, voor de melding. */
label: string
children: ReactNode
}
interface WorkspaceErrorBoundaryState {
error: Error | null
resetKey: string
}
/**
* Vangt een fout in één werkblad op.
*
* Zonder deze grens nam een enkele component die gooit de hele werkbank mee:
* React ontkoppelt dan de volledige boom en de operator houdt een leeg scherm
* over, inclusief de kaart en de navigatie. Dat is voor een operationeel
* gereedschap de verkeerde verhouding tussen oorzaak en gevolg.
*
* Nu blijft de schil staan. De gebruiker ziet welk werkblad het liet afweten,
* kan naar een ander werkblad, en kan dit werkblad opnieuw proberen.
*/
export class WorkspaceErrorBoundary extends Component<WorkspaceErrorBoundaryProps, WorkspaceErrorBoundaryState> {
constructor(props: WorkspaceErrorBoundaryProps) {
super(props)
this.state = { error: null, resetKey: props.resetKey }
}
static getDerivedStateFromError(error: Error): Partial<WorkspaceErrorBoundaryState> {
return { error }
}
static getDerivedStateFromProps(
props: WorkspaceErrorBoundaryProps,
state: WorkspaceErrorBoundaryState,
): Partial<WorkspaceErrorBoundaryState> | null {
if (props.resetKey !== state.resetKey) {
return { error: null, resetKey: props.resetKey }
}
return null
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// De stack blijft in de console beschikbaar voor wie meekijkt; er gaat
// niets naar buiten.
console.error(`Werkblad "${this.props.label}" is gestopt:`, error, info.componentStack)
}
private retry = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
const { error } = this.state
if (!error) {
return this.props.children
}
return (
<section className="workspace-error" role="alert" data-testid="workspace-error">
<CircleAlert aria-hidden="true" />
<div>
<strong>{this.props.label} kon niet worden getoond</strong>
<p>
De rest van de werkbank blijft bruikbaar. Ga naar een ander werkblad, of probeer dit werkblad
opnieuw te openen.
</p>
<p className="workspace-error-detail">{error.message}</p>
</div>
<button type="button" className="secondary-action" onClick={this.retry}>
<RotateCcw aria-hidden="true" />
<span>Opnieuw proberen</span>
</button>
</section>
)
}
}
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SourceFreshnessPanel } from './SourceFreshnessPanel'
import type { SourceFreshnessReport } from '../../types'
function props(overschrijf: Partial<Parameters<typeof SourceFreshnessPanel>[0]> = {}) {
return {
report: null,
loading: false,
error: null,
onRefresh: vi.fn(),
catalogReport: null,
catalogLoading: false,
catalogError: null,
onProbeCatalogs: vi.fn(),
grbRefreshPlan: null,
grbRefreshPlanLoading: false,
grbRefreshPlanError: null,
...overschrijf,
}
}
const leegRapport: SourceFreshnessReport = {
project_id: 'p-1',
generated_at: '2026-08-23T09:00:00Z',
summary: {
source_count: 0,
dataset_count: 0,
current_count: 0,
due_count: 0,
review_required_count: 0,
local_count: 0,
sources_with_integrity_issues: 0,
integrity_issue_count: 0,
},
items: [],
limitations: [],
}
describe('SourceFreshnessPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat items altijd meekomt, maar een type is een belofte van
// de compiler en geen garantie van het netwerk. Vandaar de optionele keten
// in de component; deze test legt vast dat die er blijft.
const zonderItems = { ...leegRapport, items: undefined } as unknown as SourceFreshnessReport
expect(() => render(<SourceFreshnessPanel {...props({ report: zonderItems })} />)).not.toThrow()
})
it('toont een leeg rapport zonder te struikelen', () => {
expect(() => render(<SourceFreshnessPanel {...props({ report: leegRapport })} />)).not.toThrow()
})
it('meldt een fout in plaats van hem te verzwijgen', () => {
render(<SourceFreshnessPanel {...props({ error: 'Kon de brondekking niet ophalen' })} />)
expect(screen.getByText(/kon de brondekking niet ophalen/i)).toBeTruthy()
})
})
@@ -142,7 +142,10 @@ export function SourceFreshnessPanel({
grbRefreshPlanLoading,
grbRefreshPlanError,
}: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? []
// Eén normalisatie aan de kop in plaats van een guard per gebruiksplek; de
// vorige versie had er één op de filter en geen op de map.
const bronnen = report?.items ?? []
const attentionItems = bronnen.filter((item) => item.status === 'due' || item.status === 'review_required')
const summary = report?.summary
return (
@@ -191,7 +194,7 @@ export function SourceFreshnessPanel({
<strong>gecontroleerd {formatDate(report.generated_at)}</strong>
</summary>
<div className="source-freshness-list">
{report.items.map((item) => <SourceRow item={item} key={item.source_name} />)}
{bronnen.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details>
+10 -3
View File
@@ -82,6 +82,13 @@ export function useDatasetWorkflow({
setErrorMessage,
isVectorDatasetType,
}: DatasetWorkflowOptions) {
// De detailgegevens van een bron werden bij een fout stilzwijgend
// overgeslagen; het paneel bleef dan leeg zonder dat iemand wist waarom.
const meldDetailFout = (fout: unknown) => {
setErrorMessage(fout instanceof Error ? fout.message : 'De brondetails konden niet worden geladen')
return null
}
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null)
const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null)
@@ -145,7 +152,7 @@ export function useDatasetWorkflow({
datasets.find((dataset) => dataset.status === 'ready') ??
datasets[0]
if (defaultDataset) {
loadDatasetDetails(selectedProjectId, defaultDataset).catch(() => null)
loadDatasetDetails(selectedProjectId, defaultDataset).catch(meldDetailFout)
}
}, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId])
@@ -284,7 +291,7 @@ export function useDatasetWorkflow({
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
if (refreshed) {
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
}
}
@@ -521,7 +528,7 @@ export function useDatasetWorkflow({
if (selectedDataset?.id === datasetId) {
setSelectedDataset(refreshed)
if (isVectorDatasetType(refreshed.dataset_type)) {
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null)
loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
}
}
}
+2 -2
View File
@@ -77,8 +77,8 @@ export function useDemoWorkflow({
loadQualityChecks(result.project_id),
analysisLoads,
])
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
const candidateDataset = projectData?.datasets?.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets?.find((dataset) => dataset.id === result.raster_dataset_id)
if (candidateDataset) {
await loadDatasetDetails(result.project_id, candidateDataset)
} else if (rasterDataset) {
+2 -2
View File
@@ -89,8 +89,8 @@ export function useMapWorkspaceState({
}
return 'Geen actieve kaartlaag'
}, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
const mapFeatureCount = mapFeatureCollection?.features?.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features?.length ?? 0
useEffect(() => {
setSelectedMapFeature(null)