(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();