Blog
useEffect in Produktion: veraltete Closures, Cleanup und falsch ausgelöste Effekte
Ein altes Abonnement ändert die neue Ansicht, ein Hintergrund-Update löscht den Entwurf. Abhängigkeiten und Lebensdauer von Effects klären und Callbacks sowie Timer gezielt testen.
Nach dem Filialwechsel erscheinen wieder die alten Meldungen
Eine Mitarbeiterin wechselt im Bibliotheksdashboard von harbour zu hill. Die Überschrift stimmt sofort. Kurz darauf zeigt der Zähler wieder die Störungen der vorherigen Filiale. Ein Neuladen hilft — bis zum nächsten Wechsel.
Als durchgängiges Beispiel dient hier ein fiktives Störungsdashboard für ein Bibliotheksnetz: mit Live-Aktualisierungen, optionalem Polling und einer Notiz zur Schichtübergabe. Das ist ein plausibler Produktionsfehler, kein Bericht über einen GiSoft-Vorfall. Eine lange Sitzung macht sichtbar, was beim kurzen lokalen Test verborgen bleibt: Ein für eine Filiale erzeugter Callback ist nach dem Wechsel weiterhin aktiv.
Zuerst würde ich klären, zu welcher Ansicht diese Aktualisierung gehört. Welche Filiale hat der Callback erfasst? Was hätte seine Zuständigkeit beim Wechsel beenden sollen?
Zeit Ansicht Arbeit außerhalb von React
t0 harbour Abonnement erfasst harbour
t1 hill alter Callback liegt noch in der Warteschlange
t2 hill Aktualisierung für harbour trifft ein
Fehler: harbour-Zähler unter der Überschrift hill
Korrektur: alter Callback verworfen; hill erhält eigene DatenSchnelle lokale Antworten und kurze Sitzungen verdecken die Überschneidung. Schwankende Latenz, Hintergrund-Tabs und wiederholte Navigation legen sie offen. Entscheidend ist die Lebensdauer der Arbeit, nicht eine vermeintlich falsche Reihenfolge von HTTP-Paketen.
Wofür braucht diese Ansicht einen Effect?
useEffect kann eine von React übernommene Ansicht mit einer externen Ressource synchronisieren: einem Abonnement, Browser-Listener, Timer oder imperativ angesprochenen Widget. Die Abhängigkeiten benennen Werte, bei deren Änderung die Synchronisierung erneuert werden muss. Vor dem Setup mit geänderten Abhängigkeiten führt React das vorherige Cleanup aus; beim Unmounten ebenfalls.
„Nach dem Rendern“ ist als Entwurfsregel zu ungenau. Ein Effect gehört zu einem bestätigten Renderdurchlauf, läuft auf dem Client und garantiert nicht allgemein, dass der Browser bereits gezeichnet hat. Eine Berechnung aus aktuellen Props braucht diese Grenze nicht. Für einen Klick gibt es einen Event-Handler. Das Laden von Serverdaten kann bereits einer Routing- oder API-Schicht gehören.
Die Beispiele verwenden React 19.2 und TypeScript. Das untersuchte Frontend nutzt Next.js 16 mit App Router, Vitest und React Testing Library. DeskFeed und DeskReader sind eigens für den Artikel definierte Verträge, keine React-APIs oder vorhandenen Projekt-Helper. Ein echter Adapter müsste Transportauthentifizierung, Dekodierung und Prüfung der Daten, Wiederverbindungen und Ereignisreihenfolge regeln.
export type DeskScope = {
branchId: string;
sessionEpoch: number;
};
export type DeskSnapshot = {
branchId: string;
waiting: number;
};
export interface DeskFeed {
watch(
scope: DeskScope,
receive: (snapshot: DeskSnapshot) => void,
): () => void;
}
export interface DeskReader {
read(scope: DeskScope, signal?: AbortSignal): Promise<DeskSnapshot>;
}sessionEpoch ist eine lokale Generation, die sich mit dem Authentifizierungskontext ändert. Sie enthält keine Zugangsdaten und vergibt keine Rechte. Damit verliert Arbeit aus einer vorherigen Sitzung ihre Zuständigkeit. Das Backend muss Anfragen und Abonnements weiterhin autorisieren.
Ein Timer kann am alten Renderdurchlauf festhalten
Dieser bewusst fehlerhafte Hook wirkt zunächst aufgeräumt: Er startet ein Intervall und entfernt es im Cleanup. Durch das leere Dependency-Array behält der Callback allerdings die ursprünglichen Werte von branchId, sessionEpoch und reader.
import { useEffect, useState } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useFrozenDesk(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
const timer = window.setInterval(() => {
void reader.read({ branchId, sessionEpoch })
.then(setSnapshot)
.catch(() => setSnapshot(null));
}, 20_000);
return () => window.clearInterval(timer);
}, []);
return snapshot;
}Eine neue Filialauswahl erzeugt hier keinen neuen Callback. Die zwanzig Sekunden sind ein Beispielwert, keine Zusicherung eines exakten Ausführungszeitpunkts. Hat ein Lesevorgang bereits begonnen, verhindert clearInterval auch nicht, dass dessen Antwort später setSnapshot aufruft.
Das ist eine Stale Closure: Ein Callback hält Werte eines Renderdurchlaufs länger fest, als sie für seine Aufgabe gültig sind. Closures sind ein normaler JavaScript-Mechanismus. Falsch ist die Erwartung, sie würden automatisch die Werte eines späteren Renderdurchlaufs übernehmen. Ein funktionales State-Update kann aus dem vorherigen Zustand den nächsten berechnen, ersetzt aber weder eine erfasste Filial-ID noch den Authentifizierungskontext.
Abhängigkeiten beschreiben die Synchronisierung
Die fehlenden Abhängigkeiten gehören hier in das Array. Das Problem laufender Anfragen bleibt trotzdem bestehen. Führt die Behebung einer Lint-Warnung zu ständigen Wiederverbindungen, lohnt sich ein Blick auf die Identität der Abhängigkeiten.
const scope = { branchId, sessionEpoch } im Komponentenrumpf erzeugt beispielsweise bei jedem Rendern ein neues Objekt. Ein Effect mit scope als Abhängigkeit startet neu, obwohl beide Felder gleich geblieben sind. React vergleicht mit Object.is, nicht anhand des Objektinhalts. Erzeuge dieses kleine Objekt innerhalb des Effects und verwende seine primitiven Eingaben als Abhängigkeiten.
Dasselbe gilt für Inline-Funktionen und immer neu erzeugte Clients. Ein Effect, der feed verwendet, hängt tatsächlich von dieser Instanz ab. Gib dem Client einen klaren Besitzer, statt ihn aus der Liste zu verschweigen. useMemo und useCallback können sinnvolle Werte stabilisieren. Einen Effect, der eigentlich eine Berechnung oder eine ausdrückliche Aktion sein sollte, reparieren sie nicht.
Das React-Hooks-Plugin für ESLint ist im untersuchten Stack über die Next.js-Werkzeuge installiert. Eine aktive ESLint-Konfiguration oder ein Lint-Skript war im Checkout jedoch nicht vorhanden. Die Installation beweist daher nicht, dass exhaustive-deps ausgeführt wird. Solche Prüfungen gehören in die vereinbarte Projektkonfiguration; Warnungen sollten nicht für die Beispiele unterdrückt werden.
Das Abonnement gehört zu Filiale und Sitzung
Die Live-Variante gibt jedem Abonnement einen eigenen Callback und eine Funktion zum Abmelden. Das Cleanup entzieht dem Callback zuerst das Recht, die Ansicht zu ändern, und gibt danach das Abonnement frei. So bleibt auch bereits eingereihte Arbeit wirkungslos.
'use client';
import { useEffect, useState } from 'react';
import type { DeskFeed, DeskSnapshot } from './deskContract';
type Props = {
branchId: string;
sessionEpoch: number;
feed: DeskFeed;
};
export function DeskActivity(props: Props) {
const identity = `${props.sessionEpoch}:${props.branchId}`;
return <ActivityScope key={identity} {...props} />;
}
function ActivityScope({ branchId, sessionEpoch, feed }: Props) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
let acceptsEvents = true;
const stop = feed.watch({ branchId, sessionEpoch }, next => {
if (acceptsEvents && next.branchId === branchId) {
setSnapshot(next);
}
});
return () => {
acceptsEvents = false;
stop();
};
}, [branchId, sessionEpoch, feed]);
return (
<section>
<h2>{branchId}</h2>
<p role="status">
{snapshot ? `${snapshot.waiting} offene Meldungen` : 'Warte auf Aktualisierung'}
</p>
</section>
);
}Der key setzt beim Wechsel von Filiale oder Sitzung nur den Teilbaum für den aktuellen Status zurück. Während die neue Verbindung aufgebaut wird, bleibt dadurch kein alter Snapshot unter der neuen Überschrift stehen. Der Schlüssel ersetzt das Cleanup nicht. Ein Formular mit ungespeichertem Text gehört außerhalb dieser Reset-Grenze, sofern sein Entwurf nicht ausdrücklich verworfen werden soll.
Die Filialprüfung fängt ein falsch zugeordnetes Ereignis ab. acceptsEvents blockiert einen stillgelegten Callback auch dann, wenn nur feed für dieselbe Filiale ersetzt wird. Das Beispiel setzt geordnete Snapshots innerhalb eines aktiven Abonnements voraus. Kann der Transport Revisionen vertauschen, braucht der Adaptervertrag zusätzlich eine Revisionsprüfung.
Welche Ressource die Ansicht besitzt, bestimmt das Cleanup:
Ressource der Ansicht Zugehöriges Cleanup
Filial-Abonnement genau dieses Topic abbestellen
Gemeinsamer WebSocket eigenes Abo lösen, andere erhalten
Timer geplanten Callback entfernen
Browser-Listener dieselbe Funktion entfernen
Leseanfrage Ergebnis verwerfen; ggf. abbrechen
Imperatives Widget dispose/destroy gemäß Widget-APISchließt ein Panel den gemeinsam genutzten Socket, können andere Panels ihre Verbindung verlieren. Erzeugt eine Komponente dagegen eine private Verbindung, ist sie normalerweise auch für deren Ende zuständig. Setup und Cleanup müssen denselben Besitzumfang meinen.
Ein Cleanup kann vorhanden sein und trotzdem nichts entfernen
Der folgende fehlerhafte Hook aktualisiert beim Wechsel der Tab-Sichtbarkeit. Das Setup registriert eine Funktion; das Cleanup übergibt eine neu erzeugte Funktion mit demselben Quelltext.
import { useEffect } from 'react';
export function useLeakingResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
document.addEventListener('visibilitychange', () => refresh(branchId));
return () => document.removeEventListener(
'visibilitychange', () => refresh(branchId),
);
}, [branchId, refresh]);
}removeEventListener braucht die registrierte Callback-Referenz und eine passende Capture-Einstellung. Bei wiederholten Filialwechseln sammeln sich hier Listener an. Beim Zurückkehren zum Tab starten mehrere Lesevorgänge, darunter solche für längst unsichtbare Filialen.
Halte den Callback im Gültigkeitsbereich des Setups fest und entferne genau diese Funktion:
import { useEffect } from 'react';
export function useDeskResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(branchId);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [branchId, refresh]);
}refresh ist hier ein synchroner Auslöser für einen Lesevorgang, der zur Funktionalität gehört. Seine Implementierung behandelt asynchrone Fehler und die Relevanz der Antwort; der Hook besitzt nur den Listener. Ändert sich die Funktionsreferenz bei jedem Rendern, wird der Listener sicher, aber unnötig ersetzt. Kläre zunächst den Besitzer dieses Auslösers, bevor du Memoisierung einführst.
Polling braucht eine begrenzte Lebensdauer
Ein langsamer Lesevorgang kann länger dauern als der Abstand zwischen zwei setInterval-Aufrufen. Polling ist in diesem Dashboard eine Alternative zu Live-Ereignissen, kein zweiter paralleler Schreiber. Erst nach Abschluss des vorherigen Vorgangs den nächsten zu planen, verhindert Überschneidungen innerhalb eines Polling-Lebenszyklus.
import { useEffect } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useDeskPolling(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
receive: (snapshot: DeskSnapshot) => void,
report: (error: unknown) => void,
pauseMs = 20_000,
) {
useEffect(() => {
let retired = false;
let timer: number | undefined;
const controller = new AbortController();
async function refresh() {
try {
const next = await reader.read(
{ branchId, sessionEpoch }, controller.signal,
);
if (!retired) receive(next);
} catch (error) {
if (!retired && !controller.signal.aborted) report(error);
} finally {
if (!retired) timer = window.setTimeout(refresh, pauseMs);
}
}
void refresh();
return () => {
retired = true;
controller.abort();
window.clearTimeout(timer);
};
}, [branchId, sessionEpoch, reader, receive, report, pauseMs]);
}Der Hook läuft innerhalb derselben Filial- und Sitzungsgrenze. receive und report sind stabile Callbacks der Funktionalität, etwa React-State-Setter. Der Reader liefert laut Vertrag einen Snapshot der angefragten Filiale. Das Intervall bezeichnet eine Mindestpause nach Abschluss; Hintergrund-Drosselung und Browserplanung können sie verlängern.
Das Cleanup entfernt den nächsten Timer, bricht unterstützte Transportarbeit ab und legt die Callbacks auch dann still, wenn der Adapter nicht abbrechen kann. Damit Polling fortgesetzt werden kann, muss ein Aufruf irgendwann enden oder den Abbruch beachten. Transport-Timeouts und Backoff implementiert dieser Ausschnitt nicht. Er setzt außerdem voraus, dass die Callbacks keine Exceptions werfen. Das sind Verträge von Adapter und Funktionalität, keine zusätzlichen Aufgaben eines allgemeinen Timer-Hooks.
Veraltete Daten, Fehler und Ladezustände haben dieselbe Ursache
Nach einer Abhängigkeitsänderung kann Anfrage B starten, während A noch läuft. B liefert gültige Daten, danach schlägt A fehl. Schreibt der Catch-Handler von A nun den Fehler in den aktuellen Zustand, verdrängt er die erfolgreiche Anzeige. Ein unbedingtes finally { setLoading(false) } erzeugt einen anderen Fehler: A endet, B läuft noch, doch die Ladeanzeige verschwindet.
Daten, Fehler und Ladezustand müssen derselben aktiven Operation zugeordnet sein. Prüfe diese Zuordnung bei allen drei Übergängen oder überlasse sie der vorhandenen Query-Schicht. Ein Boolean genügt für eine einzelne sequenzielle Operation, aber nicht für mehrere unabhängige Anfragen. Der Polling-Hook verhindert Überschneidungen innerhalb eines Lebenszyklus und blockiert alte Callbacks. Alle anderen Anfragen der Funktionalität koordiniert er nicht.
Ein beabsichtigter Abbruch bei der Navigation sollte normalerweise keine Serverfehler-Meldung anzeigen. Prüfe den Vertrag des HTTP-Adapters: Ein Wrapper kann den ursprünglichen Fehler umwandeln. Der Zustand des eigenen Signals oder ein ausdrückliches Abbruchergebnis kann zuverlässiger sein als die Annahme, jeder Client reiche AbortError unverändert weiter.
Ein Leseabbruch beendet das Interesse des Clients. Ein Mutationsabbruch beweist nicht, dass der Server die Änderung zurückgerollt hat. Rechte, Idempotenz und Nebenläufigkeitskontrolle bleiben Backend-Aufgaben. Ausführliche Tests zur Antwortreihenfolge und zu Mutationen behandelt Race Conditions in React: alte Antworten überschreiben den aktuellen Zustand. Hier geht es um die Lebensdauer des Effects.
Eine Schichtübergabe ist eine Aktion
Die Notiz über setShouldSend(true) und einen Effect auf shouldSend zu versenden, versteckt den Auslöser. Eine spätere Zustandswiederherstellung oder ein Remount kann dadurch Verhalten ausführen, das nur durch einen Klick entstehen sollte.
Ein ausdrücklicher Handler zeigt den Ablauf klarer. Dies ist ein Komponentenausschnitt: pending, draft, die Setter und sendHandover gehören zur Funktionalität; der Adapter ist keine Framework-API. Das Formular bleibt in diesem Beispiel bis zum Abschluss der Operation seiner Filiale zugeordnet.
async function handleHandover() {
if (pending) return;
setPending(true);
setError(null);
try {
await sendHandover({ branchId, note: draft });
setSent(true);
} catch {
setError('Die Schichtübergabe konnte nicht gesendet werden.');
} finally {
setPending(false);
}
}Binde den Handler an das Absenden des Formulars, verhindere gegebenenfalls die native Übermittlung und sperre weitere Eingaben zum Absenden während der Wartezeit. Der Boolean verbessert die Bedienung, schützt das Backend aber nicht vor Duplikaten. Darf die Filiale währenddessen wechseln, braucht das Ergebnis zusätzlich eine Operationsidentität oder einen eigenen Besitzer. Ein Timeout macht die Wiederholung einer folgenreichen Operation nicht automatisch sicher.
Ein Effect passt dazu, ein Abonnement an die sichtbare Filiale anzupassen. Der Befehl „Schicht übergeben“ hat seinen nachvollziehbaren Auslöser dagegen im Handler.
Eine Aktualisierung im Hintergrund darf den Entwurf nicht übernehmen
Die Zahl offener Meldungen ohne zuständige Person lässt sich beim Rendern aus der aktuellen Liste berechnen. Dafür eigenen State und einen aktualisierenden Effect anzulegen, verursacht ein weiteres Update und einen zweiten konsistent zu haltenden Wert. Eine kleine reine Funktion beschreibt die Regel:
type DeskIncident = {
status: 'open' | 'resolved';
assigneeId: string | null;
};
export function countUnassigned(incidents: readonly DeskIncident[]) {
return incidents.filter(incident =>
incident.status === 'open' && incident.assigneeId === null,
).length;
}Die Komponente verwendet const unassigned = countUnassigned(incidents). Es gibt weder zusätzlichen State zu synchronisieren noch ein Dependency-Array zu pflegen.
Bei einem Formularentwurf steht eine andere Entscheidung an. Ein Effect mit setDraft(handover.note) und [handover] wirkt wie Synchronisierung. Ersetzt eine Hintergrundaktualisierung das Objekt, kann sie den seit dem Öffnen eingegebenen Text löschen.
Definiere den Vertrag: Behält das Formular seinen Entwurf bis zum Absenden, ersetzen externe Änderungen ihn, oder zeigt eine neuere Serverrevision einen Konflikt an? Ein Reset beim Wechsel der Datensatzidentität kann sinnvoll sein, wenn die Navigation ungespeicherte Arbeit ausdrücklich behandelt. Eine neue Objektreferenz ist kein Reset-Wunsch des Nutzers. Auch Autosave-Antworten sollten die gespeicherte Entwurfsversion bestätigen, ohne später eingegebene Zeichen zu überschreiben.
Effect-Ketten verstecken einen Ablauf
Ein Filialwechsel könnte Berechtigungen laden, ein weiterer Effect daraus canReview setzen, der nächste Meldungen laden und ein vierter die erste Meldung auswählen und Details abrufen. Einzeln wirken diese Schritte unproblematisch. Zusammen entstehen Zwischenzustände und überholte Arbeit, deren Ursprung schwer zu erkennen ist.
Berechne canReview direkt, wenn es nur aus den Berechtigungen folgt. Für tatsächlich voneinander abhängige Anfragen eignen sich ein ausdrücklicher asynchroner Ablauf mit gemeinsamer Kontextidentität, die Datenebene des Routers oder die vorhandene Query-Abstraktion. Unabhängig verantwortete Abonnements können getrennt bleiben. Effects dürfen durchaus Eingaben anderer Effects beeinflussen. Verdächtig wird ein Geschäftsablauf, dessen Reihenfolge nur durch das Verfolgen von Settern im ganzen Baustein sichtbar wird.
Fasse auch Socket, Polling, Dokumenttitel und Analytics nicht allein deshalb in einem Effect zusammen, weil sie bei sichtbarem Panel gebraucht werden. Trenne nach externer Verantwortung und Cleanup-Lebensdauer, nicht nach der Zahl der Codezeilen.
Strict Mode prüft Annahmen über den Lebenszyklus
Bei React 19.2 mit Strict Mode an der Wurzel wird in der Entwicklung ein zusätzlicher Setup–Cleanup–Setup-Zyklus für Effects ausgeführt. App Router aktiviert Strict Mode in der untersuchten Next.js-Generation standardmäßig. Für eine Strict-Mode-Grenze nur innerhalb eines Teilbaums gelten Einschränkungen; das Verhalten der Wurzel ist keine Aussage über jede mögliche Konfiguration.
In Produktion findet dieser zusätzliche Zyklus nicht wegen derselben Entwicklungsprüfung statt. Abhängigkeitswechsel, Navigation und Remounts verlangen trotzdem korrektes Cleanup. Zwei Leseanfragen in der Entwicklung beweisen für sich genommen keinen Produktionsfehler. Zwei nach dem Setup verbleibende Abonnements sind ein deutlich stärkeres Signal.
Strict Mode abzuschalten kann dieses Signal verbergen. Prüfe stattdessen, ob nach Setup, Cleanup und erneutem Setup genau die vorgesehenen Ressourcen aktiv sind. Historische Warnungen über State-Updates nach dem Unmounten sind dafür keine verlässliche Grundlage: Lecks und falsche Zuständigkeiten sind auch ohne Warnmeldung relevant.
Next.js macht nicht jeden Datenzugriff zum Effect
Die interaktiven Beispiele gehören auf die Clientseite einer App-Router-Grenze. Effects laufen nicht während der Erzeugung von Server-HTML. Server Components, Routendaten und Browser-Abonnements haben unterschiedliche Aufgaben. Nutze für Daten mit bereits geklärter Zuständigkeit die bestehende Ladeschicht.
Dieses Repository hat einen eigenen API-Client und Feature-Services, aber keine konfigurierte Query-Bibliothek. Das öffentliche Frontend verwendet außerdem statischen Export, der Serverfunktionen zur Anfragezeit einschränkt. „Auf den Server verschieben“ muss zu diesem Deployment passen. Eine Query-Bibliothek kann andernorts Cache, Refetching und Abbruch bündeln; ob ein Hintergrund-Update den Entwurf ersetzen darf, entscheidet sie nicht.
Ein Hydration-Mismatch ist ein anderes Problem: Server und Client liefern unterschiedliche anfängliche Darstellungen. Browserzustand in einem Effect zu lesen kann zu einem konkreten Entwurf passen. Beliebige Renderlogik dorthin zu verschieben ist keine allgemeine Hydration-Reparatur. Ebenso wenig sollten Dutzende Komponenten Lade-, Fehler- und Abbruchlogik neu aufbauen, wenn eine Datenebene diese Aufgaben bereits übernimmt.
Den alten Callback ohne Netzwerkwartezeit reproduzieren
Für die Abonnement-Komponente reicht ein kleiner Testadapter. Der hier definierte ManualDeskFeed protokolliert Verbindungen und macht ihre Callbacks verfügbar. Einen gespeicherten Callback nach dem Abmelden aufzurufen, simuliert bewusst bereits eingereihte Arbeit. Es bedeutet nicht, dass ein korrekter Transport nach dem Abmelden weiter neue Ereignisse zustellen sollte.
import type { DeskFeed, DeskScope, DeskSnapshot } from './deskContract';
type Connection = {
scope: DeskScope;
receive: (snapshot: DeskSnapshot) => void;
closed: boolean;
};
export class ManualDeskFeed implements DeskFeed {
readonly connections: Connection[] = [];
watch(scope: DeskScope, receive: Connection['receive']) {
const connection = { scope, receive, closed: false };
this.connections.push(connection);
return () => { connection.closed = true; };
}
}Der Test verwendet Vitest, React Testing Library, die übliche jsdom-Umgebung und Matcher aus @testing-library/jest-dom/vitest. Die lokalen Imports verweisen auf die Artikelbeispiele. Geprüft werden die sichtbare Anzeige und die Freigabe beider Abonnements.
import { act, render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { DeskActivity } from './DeskActivity';
import { ManualDeskFeed } from './ManualDeskFeed';
it('verwirft den alten Filial-Callback und beendet beide Abonnements', () => {
const feed = new ManualDeskFeed();
const view = render(
<DeskActivity branchId="harbour" sessionEpoch={1} feed={feed} />,
);
const harbour = feed.connections[0]!;
act(() => harbour.receive({ branchId: 'harbour', waiting: 7 }));
view.rerender(
<DeskActivity branchId="hill" sessionEpoch={1} feed={feed} />,
);
const hill = feed.connections[1]!;
expect(harbour.closed).toBe(true);
expect(hill.closed).toBe(false);
expect(screen.getByRole('status')).toHaveTextContent('Warte auf Aktualisierung');
act(() => hill.receive({ branchId: 'hill', waiting: 2 }));
act(() => harbour.receive({ branchId: 'harbour', waiting: 99 }));
expect(screen.getByRole('heading')).toHaveTextContent('hill');
expect(screen.getByRole('status')).toHaveTextContent('2 offene Meldungen');
expect(screen.queryByText('99 offene Meldungen')).not.toBeInTheDocument();
view.unmount();
expect(hill.closed).toBe(true);
});Ergänze den Austausch von feed bei unveränderter Filiale. So wird die Sperre für stillgelegte Callbacks geprüft, ohne sich auf das Unmounten durch key zu verlassen. Ändere außerdem sessionEpoch: Arbeit aus dem alten Authentifizierungskontext darf auch bei gleicher Filiale nicht zuständig bleiben. Unter StrictMode an der Wurzel zählt ein aktives Abonnement nach dem Setup und keines nach dem Unmounten; eine pauschale Erwartung „Setup genau einmal“ wäre hier falsch.
Für den Listener: visibilitychange auslösen, mit anderer Filiale erneut rendern und das Ereignis wiederholen. Jedes Ereignis bei sichtbarem Tab soll genau einen Aufruf für die aktuelle Filiale erzeugen, nach dem Unmounten keinen. Beim Polling steuern vi.useFakeTimers() und vi.advanceTimersByTimeAsync() in Vitest die Zeit. Halte das Read-Promise offen, um einen weiteren Poll auszuschließen; löse es auf, verschiebe die Uhr um die Pause, wechsle den Kontext und liefere das alte Ergebnis oder den alten Fehler. Stelle nach jedem Test echte Timer wieder her. Reale Wartezeiten sind unnötig.
Jeder Test schützt eine bestimmte Grenze
Wo sich ein Defekt tatsächlich beobachten lässt, bestimmt die erste sinnvolle Prüfebene:
Risiko Erste sinnvolle Prüfung
Fehlende reaktive Abhängigkeit Hooks-Lint + Verhaltenstest
Entwurfs-/Reducer-Entscheidung Unit-Test der Entscheidung
Altes Abonnement Komponententest mit kontrolliertem Feed
Timer-Lebensdauer Komponententest mit künstlicher Zeit
Alte Daten/Fehler/Ladezustände Komponententest mit offenen Promises
Transportabbruch Adapter-Integrations-/Vertragstest
Doppelter Geschäftseffekt Backend-Funktions-/Integrationstest
Navigation zwischen Filialen ausgewählter BrowsertestLint und TypeScript erkennen manche Abhängigkeits- und Typfehler, beweisen aber nicht die Freigabe der richtigen Ressource. Adaptertests prüfen Topic-Zuordnung, Authentifizierungswechsel, Abbruch und öffentliche Fehler anhand kontrollierter Transportantworten. Ein Browsertest kann Navigation mit verzögerten Daten oder Ereignissen abdecken. Die genaue Reihenfolge lässt sich günstiger im Komponententest festlegen.
Schütze auch den Entwurf: Notiz eingeben, neues Serverobjekt liefern und das vereinbarte Entwurfs- oder Konfliktverhalten prüfen. Bei gesonderten Ansichten mit Leseanfragen gehören „B erfolgreich vor Fehler A“ und „A endet, während B noch lädt“ in eigene Assertions. Sie prüfen verschiedene Risiken, auch wenn derselbe Promise-Helper hilft. Next.js-Architektur, Fehlerfälle und Benutzerabläufe testen behandelt die umfassendere Testsuite. Sie muss hier nicht wiederholt und kein weiterer Runner installiert werden.
Zuständigkeit klären, bevor das Timing geändert wird
Protokolliere genug, um einen konkreten Fehler nachzuvollziehen: Filialkennung, eine nicht geheime Sitzungsgeneration, Setup- und Cleanup-Zeitpunkt, Subscription-Topic und Operations-ID. Vergleiche den Filialwechsel mit dem Abschluss des alten Callbacks. Zugangsdaten, Tokens, geschützte Payloads und unnötige personenbezogene Daten gehören nicht ins Protokoll.
Ein leeres Dependency-Array kann falsche Werte festhalten; deaktiviertes exhaustive-deps verdeckt das. Blind hinzugefügte, frisch erzeugte Objekte können ständige Wiederverbindungen auslösen. Memoisierung, isMounted oder ein Timeout zum „Warten auf State“ bestimmen nicht, welcher Filiale eine Aktualisierung gehört. Kläre den Anlass des Effects, dann seine Eingaben und seine Lebensdauer.
Vor dem Merge sollten fünf Fragen beantwortet sein:
- Welche externe Ressource folgt der bestätigten Ansicht? Würde eine Berechnung oder ein Klick-Handler genügen?
- Welche Werte bestimmen ihren Besitzer, auch bei einem Authentifizierungswechsel?
- Was erwirbt das Setup, und gibt das Cleanup genau diese Ressource frei?
- Kann stillgelegte Arbeit noch Daten, Ladezustand, Fehler oder Entwurf verändern?
- Welcher kontrollierte Callback, welches Promise oder welche Testuhr macht das nachprüfbar?
Im Bibliotheksdashboard besteht die Reparatur in einer klaren Lebensdauer: Harbour verliert die Zuständigkeit, sobald Hill übernimmt. Das Dependency-Array ist ein Teil dieser Entscheidung. Abmelden, eingereihte Callbacks und Entwurfsbesitz gehören ebenso dazu.
Technische Referenzen zu Lebenszyklus und Plattformverhalten:
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/react/StrictMode
- https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps
- https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
