fix(web): t-Falle in acht Ladeeffekten entschaerft, RssFeedListForm/SavedSearchBar-Ladefunktionen stabilisiert
Befunde 7-15 der Biome-Regel useExhaustiveDependencies (quick-260921-gof): - VehicleTable, TenderDetail, DigestIntervalForm, SourceConfigForm, favorites-widget: Ersatz-Fehlertext aus useTranslations wird jetzt vor dem Effekt/Rueckruf in eine Konstante gezogen und diese Konstante in die Abhaengigkeitsliste aufgenommen - `t` selbst kommt nirgends mehr in eine Liste. In diesem Projekt ist belegt, dass `t` bei jedem Durchlauf eine frische Funktion ist (Testattrappen), eine `t`- Abhaengigkeit haette den jeweiligen Mount-Abruf zur Schleife gemacht. - RssFeedListForm.tsx und SavedSearchBar.tsx: die Ladefunktionen waren gewoehnliche Funktionen im Rumpf (bei jedem Durchlauf neu) - jetzt in einen stabilen Rueckruf mit der Text-Konstante als einziger Abhaengigkeit eingepackt. - ResultsList.tsx: Befund 8 (t) wie oben, Befund 15 (refreshKey) in den Effekt verschoben, der `load` aufruft, statt in `load` selbst zu stehen - eine begruendete `biome-ignore`-Zeile (erste im Projekt) haelt fest, dass der Auffrisch-Ausloeser der Elternseite ohne diese Abhaengigkeit wirkungslos waere. - Sieben Testdateien um eine Zaehlprobe erweitert: erneutes Zeichnen mit unveraenderten Props darf keinen weiteren Abruf ausloesen; ResultsList zusaetzlich um eine Probe, dass ein refreshKey-Bump genau einen weiteren Abruf ausloest. - DigestIntervalForm hat keine Testdatei - nur am laufenden System auf Meine Quellen geprueft (siehe SUMMARY). - Wirkungslose eslint-disable-Zeilen fuer diese Regel entfallen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -293,4 +293,19 @@ describe('VehicleTable', () => {
|
||||
expect(screen.getByText('errors.loadVehiclesFailed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 7): erneutes Zeichnen ohne Prop-Aenderung loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockFetchVehicles.mockResolvedValue([]);
|
||||
|
||||
const { VehicleTable } = await import(
|
||||
'@/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable'
|
||||
);
|
||||
const { rerender } = render(<VehicleTable />);
|
||||
|
||||
await waitFor(() => expect(mockFetchVehicles).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<VehicleTable />);
|
||||
|
||||
await waitFor(() => expect(mockFetchVehicles).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,18 +179,24 @@ export function VehicleTable() {
|
||||
toastTimerRef.current = setTimeout(() => setToast(null), 4000);
|
||||
}, []);
|
||||
|
||||
// Befund 7 (quick-260921-gof): `t` selbst kommt nicht in die Liste — die
|
||||
// Uebersetzungs-Attrappe in den Tests liefert bei jedem Durchlauf eine
|
||||
// frische Funktion, das wuerde `load` neu erzeugen und den Mount-Effekt
|
||||
// unten (useEffect(() => load(), [load])) zu einer Abruf-Schleife machen.
|
||||
// Stattdessen wird der Text vorgezogen: als Zeichenkette vergleicht React
|
||||
// per Wert, die Liste bleibt ueber Durchlaeufe hinweg gleich.
|
||||
const loadErrorText = t('errors.loadVehiclesFailed');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchVehicles();
|
||||
setVehicles(data);
|
||||
} catch {
|
||||
setTableError(t('errors.loadVehiclesFailed'));
|
||||
setTableError(loadErrorText);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []); // `t` intentionally excluded: `load` is a dependency of the
|
||||
// mount-time useEffect below, and an unstable `t` identity would turn
|
||||
// that into a fetch-on-every-render loop instead of fetch-once-on-mount.
|
||||
}, [loadErrorText]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -135,6 +135,34 @@ describe('ResultsList', () => {
|
||||
expect(screen.getByText(/Keine Ausschreibungen gefunden/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 8): erneutes Zeichnen ohne Prop-Aenderung loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
const { rerender } = render(<ResultsList />);
|
||||
|
||||
await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<ResultsList />);
|
||||
|
||||
await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 15): ein Bump von refreshKey loest genau einen weiteren Abruf aus — belegt, dass die stehengelassene Abhaengigkeit tragend ist', async () => {
|
||||
mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 });
|
||||
mockFetchTriage.mockResolvedValue([]);
|
||||
|
||||
const { ResultsList } = await import('./ResultsList');
|
||||
const { rerender } = render(<ResultsList refreshKey={0} />);
|
||||
|
||||
await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<ResultsList refreshKey={1} />);
|
||||
|
||||
await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResultsList — triage (UI-03/04, Plan 11-05: batch-merge + read/favorite toggles)', () => {
|
||||
|
||||
@@ -86,6 +86,13 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
|
||||
// any other source).
|
||||
const [triage, setTriageMap] = useState<Record<string, TriageState>>({});
|
||||
|
||||
// Befund 8 (quick-260921-gof): `t` selbst kommt nicht in die Liste von
|
||||
// `load` — die Uebersetzungs-Attrappe in den Tests liefert bei jedem
|
||||
// Durchlauf eine frische Funktion, das wuerde `load` neu erzeugen und den
|
||||
// Mount-Effekt unten zu einer Abruf-Schleife machen. Der Ersatztext wird
|
||||
// deshalb vorgezogen: als Zeichenkette vergleicht React per Wert.
|
||||
const loadErrorText = t('results.error');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -121,17 +128,20 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t('results.error'),
|
||||
err instanceof Error ? err.message : loadErrorText,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paramsKey, refreshKey]);
|
||||
}, [paramsKey, loadErrorText]);
|
||||
|
||||
// Befund 15 (quick-260921-gof): `refreshKey` steht bewusst hier statt in
|
||||
// der Abhaengigkeitsliste von `load` selbst — so bleibt `load` vollstaendig
|
||||
// unter der Regel, und nur dieser winzige Effekt braucht die Ausnahme.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey ist der Auffrisch-Ausloeser der Elternseite nach "Jetzt abrufen" - ohne ihn bliebe die Trefferliste nach einem Abruf auf dem alten Stand.
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
}, [load, refreshKey]);
|
||||
|
||||
const currentSort = searchParams.get('sort') ?? 'published';
|
||||
|
||||
|
||||
@@ -304,4 +304,17 @@ describe('SavedSearchBar', () => {
|
||||
});
|
||||
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 13): erneutes Zeichnen ohne Prop-Aenderung loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockListSavedSearches.mockResolvedValue([]);
|
||||
|
||||
const { SavedSearchBar } = await import('./SavedSearchBar');
|
||||
const { rerender } = render(<SavedSearchBar />);
|
||||
|
||||
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<SavedSearchBar />);
|
||||
|
||||
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
@@ -122,7 +122,14 @@ export function SavedSearchBar() {
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
// Befund 13 (quick-260921-gof): der Ersatztext wird vorgezogen und `load`
|
||||
// selbst in einen stabilen Rueckruf eingepackt — als gewoehnliche Funktion
|
||||
// im Rumpf wuerde `load` bei jedem Durchlauf neu entstehen; als
|
||||
// Abhaengigkeit des Mount-Effekts eingetragen ergaebe das eine endlose
|
||||
// Abruf-Schleife gegen die Suchprofile.
|
||||
const loadErrorText = t('savedSearch.errorLoad');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -130,17 +137,16 @@ export function SavedSearchBar() {
|
||||
setProfiles(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t('savedSearch.errorLoad'),
|
||||
err instanceof Error ? err.message : loadErrorText,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [loadErrorText]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
}, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = newName.trim();
|
||||
|
||||
@@ -243,4 +243,33 @@ describe('TenderDetail', () => {
|
||||
screen.getByText(/Vergabeunterlagen werden nicht lokal gespiegelt/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 9): erneutes Zeichnen mit gleicher tenderId loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockGetTender.mockResolvedValue({
|
||||
id: 't6',
|
||||
sourcePortal: 'doe-opendata',
|
||||
title: 'Sanierung Turnhalle',
|
||||
buyerName: null,
|
||||
cpvCodes: [],
|
||||
region: null,
|
||||
plz: null,
|
||||
bundesland: null,
|
||||
deadlineAt: null,
|
||||
estimatedValue: null,
|
||||
procedureType: null,
|
||||
status: 'active',
|
||||
sourceUrl: 'https://example-portal.de/ausschreibung/t6',
|
||||
publishedAt: '2026-07-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const { TenderDetail } = await import('./TenderDetail');
|
||||
const onClose = () => {};
|
||||
const { rerender } = render(<TenderDetail tenderId="t6" onClose={onClose} />);
|
||||
|
||||
await waitFor(() => expect(mockGetTender).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<TenderDetail tenderId="t6" onClose={onClose} />);
|
||||
|
||||
await waitFor(() => expect(mockGetTender).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,13 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Befund 9 (quick-260921-gof): der Ersatztext fuer den Ladefehler wird vor
|
||||
// dem Effekt bestimmt und darueber in die Liste aufgenommen — `t` selbst
|
||||
// bleibt aussen vor, sonst wuerde eine bei jedem Durchlauf frische
|
||||
// Uebersetzungsfunktion den Detail-Abruf beim Oeffnen einer Ausschreibung
|
||||
// zur Schleife machen.
|
||||
const detailErrorText = t('detail.error');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
@@ -106,7 +113,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t('detail.error'),
|
||||
err instanceof Error ? err.message : detailErrorText,
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -117,7 +124,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tenderId]);
|
||||
}, [tenderId, detailErrorText]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
+8
-3
@@ -29,17 +29,22 @@ export function DigestIntervalForm() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Befund 10 (quick-260921-gof): Ersatztext vor dem Effekt bestimmt, `t`
|
||||
// selbst bleibt aussen vor — sonst wuerde eine bei jedem Durchlauf frische
|
||||
// Uebersetzungsfunktion das Laden der Zustell-Einstellung zur Schleife
|
||||
// machen.
|
||||
const loadErrorText = t('settings.errorLoad');
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationPref()
|
||||
.then((pref) => setDigestInterval(pref.digestInterval))
|
||||
.catch((err) => {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t('settings.errorLoad'),
|
||||
err instanceof Error ? err.message : loadErrorText,
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [loadErrorText]);
|
||||
|
||||
const handleChange = async (value: string) => {
|
||||
const next = value as NotificationPref['digestInterval'];
|
||||
|
||||
+13
@@ -247,6 +247,19 @@ describe('RssFeedListForm scope="personal"', () => {
|
||||
expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 12): erneutes Zeichnen mit gleichem scope loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockListRssFeeds.mockResolvedValue([]);
|
||||
|
||||
const { RssFeedListForm } = await import('./RssFeedListForm');
|
||||
const { rerender } = render(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => expect(mockListRssFeeds).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<RssFeedListForm scope="personal" />);
|
||||
|
||||
await waitFor(() => expect(mockListRssFeeds).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('RssFeedListForm scope="platform"', () => {
|
||||
|
||||
+12
-6
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
type RssFeedSource,
|
||||
@@ -56,7 +56,14 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
|
||||
const loadFeeds = () => {
|
||||
// Befund 12 (quick-260921-gof): der Ersatztext wird vorgezogen und
|
||||
// `loadFeeds` selbst in einen stabilen Rueckruf eingepackt — als
|
||||
// gewoehnliche Funktion im Rumpf wuerde `loadFeeds` bei jedem Durchlauf
|
||||
// neu entstehen; als Abhaengigkeit des Mount-Effekts eingetragen ergaebe
|
||||
// das eine endlose Abruf-Schleife gegen die Feed-Liste.
|
||||
const loadErrorText = t('rssFeeds.errorLoad');
|
||||
|
||||
const loadFeeds = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
listRssFeeds()
|
||||
.then((result) => {
|
||||
@@ -65,16 +72,15 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : t('rssFeeds.errorLoad'),
|
||||
err instanceof Error ? err.message : loadErrorText,
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
}, [loadErrorText]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeeds();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [loadFeeds]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
setAddError(null);
|
||||
|
||||
+18
@@ -163,4 +163,22 @@ describe('SourceConfigForm', () => {
|
||||
});
|
||||
expect(mockSaveSourceConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quick-260921-gof (Befund 11): erneutes Zeichnen ohne Prop-Aenderung loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
||||
mockFetchSourceConfig.mockResolvedValue({
|
||||
sourceType: 'doe-opendata',
|
||||
pollIntervalMin: 60,
|
||||
isActive: true,
|
||||
lastIngestedDay: null,
|
||||
});
|
||||
|
||||
const { SourceConfigForm } = await import('./SourceConfigForm');
|
||||
const { rerender } = render(<SourceConfigForm />);
|
||||
|
||||
await waitFor(() => expect(mockFetchSourceConfig).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<SourceConfigForm />);
|
||||
|
||||
await waitFor(() => expect(mockFetchSourceConfig).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
+8
-3
@@ -40,6 +40,12 @@ export function SourceConfigForm() {
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Befund 11 (quick-260921-gof): Ersatztext vor dem Effekt bestimmt, `t`
|
||||
// selbst bleibt aussen vor — sonst wuerde eine bei jedem Durchlauf frische
|
||||
// Uebersetzungsfunktion das Laden der Quellen-Konfiguration zur Schleife
|
||||
// machen.
|
||||
const loadErrorText = t('sourceConfig.errorLoad');
|
||||
|
||||
// Load config on mount
|
||||
useEffect(() => {
|
||||
fetchSourceConfig()
|
||||
@@ -51,14 +57,13 @@ export function SourceConfigForm() {
|
||||
})
|
||||
.catch((err) => {
|
||||
setSaveError(
|
||||
err instanceof Error ? err.message : t('sourceConfig.errorLoad'),
|
||||
err instanceof Error ? err.message : loadErrorText,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [loadErrorText]);
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
setPollIntervalMin(value);
|
||||
|
||||
Reference in New Issue
Block a user