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();
|
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);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchVehicles();
|
const data = await fetchVehicles();
|
||||||
setVehicles(data);
|
setVehicles(data);
|
||||||
} catch {
|
} catch {
|
||||||
setTableError(t('errors.loadVehiclesFailed'));
|
setTableError(loadErrorText);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []); // `t` intentionally excluded: `load` is a dependency of the
|
}, [loadErrorText]);
|
||||||
// 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.
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -135,6 +135,34 @@ describe('ResultsList', () => {
|
|||||||
expect(screen.getByText(/Keine Ausschreibungen gefunden/i)).toBeInTheDocument();
|
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)', () => {
|
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).
|
// any other source).
|
||||||
const [triage, setTriageMap] = useState<Record<string, TriageState>>({});
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -121,17 +128,20 @@ export function ResultsList({ refreshKey = 0 }: { refreshKey?: number } = {}) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : t('results.error'),
|
err instanceof Error ? err.message : loadErrorText,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [paramsKey, loadErrorText]);
|
||||||
}, [paramsKey, refreshKey]);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load, refreshKey]);
|
||||||
|
|
||||||
const currentSort = searchParams.get('sort') ?? 'published';
|
const currentSort = searchParams.get('sort') ?? 'published';
|
||||||
|
|
||||||
|
|||||||
@@ -304,4 +304,17 @@ describe('SavedSearchBar', () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(2));
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -122,7 +122,14 @@ export function SavedSearchBar() {
|
|||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
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);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -130,17 +137,16 @@ export function SavedSearchBar() {
|
|||||||
setProfiles(data);
|
setProfiles(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : t('savedSearch.errorLoad'),
|
err instanceof Error ? err.message : loadErrorText,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [loadErrorText]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, [load]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
|
|||||||
@@ -243,4 +243,33 @@ describe('TenderDetail', () => {
|
|||||||
screen.getByText(/Vergabeunterlagen werden nicht lokal gespiegelt/i),
|
screen.getByText(/Vergabeunterlagen werden nicht lokal gespiegelt/i),
|
||||||
).toBeInTheDocument();
|
).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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -106,7 +113,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [tenderId]);
|
}, [tenderId, detailErrorText]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+8
-3
@@ -29,17 +29,22 @@ export function DigestIntervalForm() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchNotificationPref()
|
fetchNotificationPref()
|
||||||
.then((pref) => setDigestInterval(pref.digestInterval))
|
.then((pref) => setDigestInterval(pref.digestInterval))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : t('settings.errorLoad'),
|
err instanceof Error ? err.message : loadErrorText,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadErrorText]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = async (value: string) => {
|
const handleChange = async (value: string) => {
|
||||||
const next = value as NotificationPref['digestInterval'];
|
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();
|
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"', () => {
|
describe('RssFeedListForm scope="platform"', () => {
|
||||||
|
|||||||
+12
-6
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
type RssFeedSource,
|
type RssFeedSource,
|
||||||
@@ -56,7 +56,14 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
|
|||||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
const [removeError, setRemoveError] = 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);
|
setIsLoading(true);
|
||||||
listRssFeeds()
|
listRssFeeds()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -65,16 +72,15 @@ export function RssFeedListForm({ scope }: RssFeedListFormProps) {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setLoadError(
|
setLoadError(
|
||||||
err instanceof Error ? err.message : t('rssFeeds.errorLoad'),
|
err instanceof Error ? err.message : loadErrorText,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
}, [loadErrorText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFeeds();
|
loadFeeds();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadFeeds]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
setAddError(null);
|
setAddError(null);
|
||||||
|
|||||||
+18
@@ -163,4 +163,22 @@ describe('SourceConfigForm', () => {
|
|||||||
});
|
});
|
||||||
expect(mockSaveSourceConfig).not.toHaveBeenCalled();
|
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 [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
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
|
// Load config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSourceConfig()
|
fetchSourceConfig()
|
||||||
@@ -51,14 +57,13 @@ export function SourceConfigForm() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
err instanceof Error ? err.message : t('sourceConfig.errorLoad'),
|
err instanceof Error ? err.message : loadErrorText,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadErrorText]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleIntervalChange = (value: string) => {
|
const handleIntervalChange = (value: string) => {
|
||||||
setPollIntervalMin(value);
|
setPollIntervalMin(value);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export function FavoritesWidget({
|
|||||||
[favorites],
|
[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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -106,7 +111,7 @@ export function FavoritesWidget({
|
|||||||
const data = await fetchFavorites(instanceId);
|
const data = await fetchFavorites(instanceId);
|
||||||
if (!cancelled) setFavorites(data);
|
if (!cancelled) setFavorites(data);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError(t('favorites.error'));
|
if (!cancelled) setError(favoritesErrorText);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,10 +122,7 @@ export function FavoritesWidget({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// instanceId is the only meaningful dependency — t is stable at runtime
|
}, [instanceId, favoritesErrorText]);
|
||||||
// and excluded to prevent re-fetching on every render (mock creates new fn)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [instanceId]);
|
|
||||||
|
|
||||||
function getFallbackLetter(title: string): string {
|
function getFallbackLetter(title: string): string {
|
||||||
const clean = title.trim();
|
const clean = title.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user