From e2c508cff5332a187c82cee9842b89124cd259de Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 12:29:05 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- .../settings/components/VehicleTable.test.tsx | 15 ++++++++++ .../settings/components/VehicleTable.tsx | 14 ++++++--- .../components/ResultsList.test.tsx | 28 ++++++++++++++++++ .../tender-radar/components/ResultsList.tsx | 18 +++++++++--- .../components/SavedSearchBar.test.tsx | 13 +++++++++ .../components/SavedSearchBar.tsx | 18 ++++++++---- .../components/TenderDetail.test.tsx | 29 +++++++++++++++++++ .../tender-radar/components/TenderDetail.tsx | 11 +++++-- .../components/DigestIntervalForm.tsx | 11 +++++-- .../components/RssFeedListForm.test.tsx | 13 +++++++++ .../settings/components/RssFeedListForm.tsx | 18 ++++++++---- .../components/SourceConfigForm.test.tsx | 18 ++++++++++++ .../settings/components/SourceConfigForm.tsx | 11 +++++-- .../widgets/favorites-widget.test.tsx | 14 +++++++++ .../dashboard/widgets/favorites-widget.tsx | 12 ++++---- 15 files changed, 210 insertions(+), 33 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx index df7f957..40974e5 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.test.tsx @@ -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(); + + await waitFor(() => expect(mockFetchVehicles).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockFetchVehicles).toHaveBeenCalledTimes(1)); + }); }); diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx index 7c3a678..58a842f 100644 --- a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx @@ -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(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx index 9783de5..1c3112b 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx @@ -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(); + + await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(1)); + + rerender(); + + 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(); + + await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockListTenders).toHaveBeenCalledTimes(2)); + }); }); describe('ResultsList — triage (UI-03/04, Plan 11-05: batch-merge + read/favorite toggles)', () => { diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx index 5b0f84c..0bad2ea 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx @@ -86,6 +86,13 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) { // any other source). const [triage, setTriageMap] = useState>({}); + // 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'; diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx index 1d85bb9..3b802cf 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx @@ -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(); + + await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(1)); + }); }); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx index 39f635a..df37e26 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx @@ -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(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(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx index 5e579b8..013efe6 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx @@ -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(); + + await waitFor(() => expect(mockGetTender).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockGetTender).toHaveBeenCalledTimes(1)); + }); }); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx index 2a5fc1a..7918587 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx @@ -93,6 +93,13 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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 (
(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']; diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx index 1ffac73..7257cc7 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx @@ -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(); + + await waitFor(() => expect(mockListRssFeeds).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockListRssFeeds).toHaveBeenCalledTimes(1)); + }); }); describe('RssFeedListForm scope="platform"', () => { diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx index 51821db..048326a 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx @@ -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(null); const [removeError, setRemoveError] = useState(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); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx index 77c261a..6ac8031 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx @@ -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(); + + await waitFor(() => expect(mockFetchSourceConfig).toHaveBeenCalledTimes(1)); + + rerender(); + + await waitFor(() => expect(mockFetchSourceConfig).toHaveBeenCalledTimes(1)); + }); }); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx index 7a6b021..ebcaefc 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx @@ -40,6 +40,12 @@ export function SourceConfigForm() { const [saveError, setSaveError] = useState(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); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx index 89a6f20..de2933f 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -559,4 +559,18 @@ describe('FavoritesWidget', () => { }); }); }); + + it('quick-260921-gof (Befund 14): erneutes Zeichnen mit gleicher instanceId 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 () => { + const { rerender } = render( + , + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + rerender( + , + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + }); }); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index 9810e58..fb8220f 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -96,6 +96,11 @@ export function FavoritesWidget({ [favorites], ); + // Befund 14 (quick-260921-gof): Ersatztext vor dem Effekt bestimmt, `t` + // selbst bleibt aussen vor — die Testattrappe liefert bei jedem Durchlauf + // eine frische Funktion, das wuerde den Mount-Abruf zur Schleife machen. + const favoritesErrorText = t('favorites.error'); + useEffect(() => { let cancelled = false; @@ -106,7 +111,7 @@ export function FavoritesWidget({ const data = await fetchFavorites(instanceId); if (!cancelled) setFavorites(data); } catch { - if (!cancelled) setError(t('favorites.error')); + if (!cancelled) setError(favoritesErrorText); } finally { if (!cancelled) setLoading(false); } @@ -117,10 +122,7 @@ export function FavoritesWidget({ return () => { cancelled = true; }; - // instanceId is the only meaningful dependency — t is stable at runtime - // and excluded to prevent re-fetching on every render (mock creates new fn) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [instanceId]); + }, [instanceId, favoritesErrorText]); function getFallbackLetter(title: string): string { const clean = title.trim();