Blog
Race conditions w React: gdy starsze żądanie nadpisuje nowszy stan
Filtr wskazuje archiwum, a lista znów pokazuje oczekujące wpisy. Jak kontrolować kolejność odpowiedzi, anulowanie, ładowanie i błędy oraz testować te wyścigi bez przypadkowych opóźnień.
Filtr wskazuje „Archiwum”, ale lista pokazuje oczekujące wpisy
Moderator przegląda podpisy zdjęć w lokalnym archiwum historycznym. Otwiera kolejkę oczekujących, a przed nadejściem odpowiedzi przełącza się na archiwum. Pojawiają się zarchiwizowane podpisy. Chwilę później zastępują je oczekujące wpisy, choć wybrany filtr nadal wskazuje „Archiwum”. Użytkownik podejmuje teraz decyzje na podstawie mylącego ekranu.
Ten fikcyjny moduł ilustruje błąd poprawności spotykany w aplikacjach produkcyjnych, nie udokumentowany incydent GiSoft. Obie odpowiedzi mogą być prawidłowe. Problem polega na przyjęciu wyniku, który nie pasuje już do bieżącego wyboru.
Moment Wybór Zakończenie i widoczny wynik
0 pending Start A
1 archived Start B
2 archived Koniec B: wpisy archiwalne
3 archived Koniec A: nadpisanie wpisami oczekującymi
Po sprawdzeniu właściciela w 3: A pominięte, archiwum pozostaje.Kolejność wysłania nie określa kolejności zakończenia
A rusza przed B, ale nie musi zakończyć się wcześniej. Chybienie cache, wolniejsze zapytanie do bazy czy zmienne warunki sieci mobilnej mogą wydłużyć starszą operację. Chodzi o kolejność zakończenia pracy asynchronicznej, nie o twierdzenie, że kolejność pakietów HTTP bezpośrednio steruje stanem React.
Localhost, małe fixtures i czekanie między kliknięciami potrafią ukryć błąd. Użytkownicy produkcyjni piszą i nawigują, gdy żądania nadal trwają. Ograniczenie przepustowości pomaga w diagnozie, ale test regresji powinien bezpośrednio kontrolować kolejność odpowiedzi.
To samo zdarza się w handlerach zdarzeń, loaderach routingu, własnych hookach, callbackach mutacji i wspólnym cache. useEffect jest jednym z miejsc uruchamiania pracy, nie przyczyną problemu. Kilka operacji dostało prawo do zapisu tego samego stanu bez reguły rozstrzygającej, która nadal jest właściwa.
Wiarygodny kod, któremu brakuje reguły kolejności
Kontrakt przykładu to lista skróconych danych podpisów. Typy należą do fikcyjnego API artykułu; aby połączyć fragmenty, umieść je w queueContract.ts. Kod odpowiada używanym w repozytorium React 19 i TypeScript 5.9.
export type Bucket = 'pending' | 'archived';
export type QueueQuery = { bucket: Bucket; term: string };
export type Caption = { id: string; label: string };
export type QueueReader = (
query: QueueQuery, signal?: AbortSignal,
) => Promise<Caption[]>;
export type QueueView =
| { phase: 'idle' | 'loading' }
| { phase: 'ready'; rows: Caption[] }
| { phase: 'failed'; message: string };Poniższy useUnsafeQueue.ts jest celowo błędny. Wczytywanie rozpoczyna wybór kolejki, nie samo zamontowanie komponentu. Zależność read będzie adapterem HTTP pokazanym dalej.
import { useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useUnsafeQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
async function search(next: QueueQuery) {
setQuery(next);
setView({ phase: 'loading' });
try {
const rows = await read(next);
setView({ phase: 'ready', rows });
} catch {
setView({ phase: 'failed', message: 'Nie udało się wczytać podpisów. Spróbuj ponownie.' });
}
}
return { query, view, search };
}Każde wywołanie przechwytuje własne next, lecz wszystkie mogą zastąpić view. Spóźniony sukces pokazuje niewłaściwe wiersze, a spóźniony błąd może ukryć nowszy sukces. Dodanie zależności do Effectu lub opakowanie funkcji w useMemo nie ustali, który wynik jest aktualny.
Prawo do zapisu należy do najnowszej intencji
Na tym ekranie nowy wybór kolejki lub zmiana tekstu wyszukiwania zastępuje poprzednią intencję. Każda dostaje odrębną tożsamość i tylko ona może opublikować wynik. Samo porównanie parametrów nie wystarczy: użytkownik może wybrać oczekujące, archiwum i ponownie oczekujące. Pierwsze i trzecie żądanie mają te same parametry, ale dotyczą różnych interakcji.
Pominięcie starego wyniku nie zatrzymuje pracy. Odbiera jedynie operacji możliwość zmiany tego widoku. To przydatne również dla klienta bez anulowania lub współdzielonego żądania, którego odpowiedź nadal przyda się innemu odbiorcy cache.
useCaptionQueue.ts łączy tę regułę z anulowaniem zbędnych odczytów. Obiekt kontrolera pełni również rolę unikalnego lokalnego znacznika. commit() sprawdza prawo do zapisu, natomiast abort() osobno sygnalizuje zatrzymanie pracy. Timer posłuży do autouzupełniania.
import { useEffect, useRef, useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useCaptionQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
const owner = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => {
const previous = owner.current;
owner.current = null;
previous?.abort();
clearTimeout(timer.current);
}, []);
function search(next: QueueQuery, delay = 0) {
const previous = owner.current;
const ticket = new AbortController();
owner.current = ticket;
previous?.abort();
clearTimeout(timer.current);
setQuery(next);
setView({ phase: 'loading' });
function commit(value: QueueView) {
if (owner.current === ticket && !ticket.signal.aborted) {
setView(value);
}
}
async function run() {
try {
const rows = await read(next, ticket.signal);
commit({ phase: 'ready', rows });
} catch {
if (!ticket.signal.aborted) {
commit({ phase: 'failed', message: 'Nie udało się wczytać podpisów. Spróbuj ponownie.' });
}
}
}
if (delay > 0) timer.current = setTimeout(() => { void run(); }, delay);
else void run();
}
return { query, view, search };
}Właściciel zmienia się synchronicznie po przekazaniu nowej intencji do search(), jeszcze przed odczekaniem debounce. Sukces i błąd przechodzą przez tę samą kontrolę. Cleanup odbiera prawo do zapisu po odmontowaniu, również gdy adapter ignoruje anulowanie. Flaga isMounted nie odróżni dwóch konkurujących żądań w nadal zamontowanym komponencie.
Hook odpowiada za jeden widok w kontekście jednego archiwum i użytkownika. Nie jest ogólnym cache zapytań. Adapter i zakres archiwum muszą pozostawać spójne dla tej instancji; zmiana archiwum lub konta wymaga jej unieważnienia albo jawnego rozpoczęcia nowego zakresu. Obiekt zapytania traktujemy jako niezmieniany zapis wyboru, nie wspólny obiekt modyfikowany przez kolejne callbacki.
Anuluj zbędne odczyty, nie ukrywając rzeczywistych błędów
Anulowanie dotrze do fetch tylko wtedy, gdy adapter przekaże sygnał. Oto captionApi.ts. Endpoint przykładu zwraca tablicę obiektów { id, label } z unikalnymi identyfikatorami. Niewielka kontrola runtime sprawdza typy pól i wybiera pola publiczne; nie zastępuje pełnego systemu walidacji schematu.
import type { QueueReader } from './queueContract';
export const readCaptionQueue: QueueReader = async (query, signal) => {
const params = new URLSearchParams({ bucket: query.bucket, q: query.term });
const response = await fetch(`/api/caption-queue?${params}`, {
signal, credentials: 'same-origin', headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(`QUEUE_HTTP_${response.status}`);
const payload: unknown = await response.json();
if (!Array.isArray(payload) || payload.some((row) =>
row === null || typeof row !== 'object'
|| typeof row.id !== 'string' || typeof row.label !== 'string'
)) throw new Error('QUEUE_SHAPE');
return payload.map((row) => ({ id: row.id, label: row.label }));
};Do komponentu można przekazać readCaptionQueue. Adapter korzysta z endpointu pod tym samym originem i istniejącego kontraktu sesji aplikacji. Nie ustanawia uwierzytelnienia ani nie osłabia kontroli dostępu.
Domyślne anulowanie w przeglądarce zwykle odrzuca obietnicę błędem AbortError. Własny powód anulowania lub wrapper HTTP może zachowywać się inaczej. Hook sprawdza swój sygnał, bo wie, dlaczego anulował operację, zamiast uznawać każdą określoną nazwę wyjątku za niegroźną. Rzeczywista awaria bieżącego żądania nadal trafia do stanu błędu. Timeout, o którym użytkownik powinien wiedzieć, wymaga własnej polityki obsługi, a nie cichego potraktowania jako nieaktualna praca.
Anulowanie żądania w przeglądarce nie cofa transakcji serwera. Backend mógł już przyjąć mutację. Nawet dla odczytu nie daje to obietnicy zatrzymania każdego zapytania bazy i pośrednika. Odrzucaj nieaktualne zapisy do UI niezależnie od tego, czy anulowanie oszczędzi zasoby.
Debounce ogranicza liczbę żądań, nie rozstrzyga aktualności
Moderator szuka podpisów zawierających „kanał”: k, ka, kan, kana, kanał. Wolniejszy wynik dla kan nie może zastąpić wyniku dla kanał. Debounce określa, kiedy uruchomić kolejne żądanie. Kontrola właściciela określa, które wyniki wolno wyświetlić.
Łatwo przeoczyć przerwę między tymi zdarzeniami: żądanie dla kan już trwa, użytkownik wpisuje kanał, a stara odpowiedź przychodzi podczas nowego okresu debounce. Unieważnienie poprzedniej operacji dopiero przy starcie następnego fetch nastąpiłoby za późno. Nasz hook robi to już podczas obsługi zmiany tekstu.
Kompletny CaptionQueue.tsx uruchamia odczyt od razu po wyborze kolejki, a dla tekstu stosuje przykładowe 180 ms opóźnienia. Podczas oczekiwania celowo usuwa poprzednie wiersze. Zachowanie ich też może być decyzją produktową, ale trzeba oznaczyć je jako wcześniejsze wyniki, zamiast sugerować zgodność z nowym filtrem.
import type { QueueReader } from './queueContract';
import { useCaptionQueue } from './useCaptionQueue';
export function CaptionQueue({ read }: { read: QueueReader }) {
const { query, view, search } = useCaptionQueue(read);
return (
<section>
<button type="button" onClick={() => search({ ...query, bucket: 'pending' })}>
Oczekujące
</button>
<button type="button" onClick={() => search({ ...query, bucket: 'archived' })}>
Archiwum
</button>
<output aria-label="Wybrana kolejka">
{query.bucket === 'pending' ? 'Oczekujące' : 'Archiwum'}
</output>
<input aria-label="Treść podpisu" value={query.term}
onChange={(event) => search({ ...query, term: event.target.value }, 180)} />
{view.phase === 'loading' && <p role="status">Wczytywanie podpisów…</p>}
{view.phase === 'failed' && <p role="alert">{view.message}</p>}
{view.phase === 'ready' && <ul>
{view.rows.map((row) => <li key={row.id}>{row.label}</li>)}
</ul>}
</section>
);
}Timer hooka steruje rzeczywistym zachowaniem; test nie musi czekać czasu rzeczywistego. Debounce sprawdzaj kontrolowanym zegarem testowym, a kolejność zakończenia — kontrolowanymi obietnicami. Ustawienie wszystkich wyszukiwań w szeregu usunęłoby nakładanie operacji kosztem oczekiwania na niepotrzebny już wynik po każdym wpisanym znaku.
Dane, ładowanie i błędy potrzebują wspólnego właściciela
A startuje, potem B. A kończy się, gdy B nadal trwa. Bezwarunkowe finally { setLoading(false) } wyłączyłoby wskaźnik ładowania należący do B. W poprawionym hooku stara operacja nie może zapisać żadnej fazy; widok pozostaje w loading, dopóki nie zakończy się jego własna operacja.
Na dashboardzie pobierającym trzy niezależne panele właściwszy może być licznik aktywnych operacji albo osobny stan każdego panelu. Jedna globalna reguła „wygrywa ostatnie żądanie” odrzucałaby tam potrzebne dane.
Błędy podlegają temu samemu wyścigowi. B może zakończyć się sukcesem, zanim A zwróci błąd. Spóźniony błąd A nie powinna zastąpić poprawnego archiwum komunikatem „Nie udało się wczytać”. Traktuj dane, błąd i ładowanie jako spójny stan operacji. Niezależne aktualizowanie tych pól bez kontroli właściciela odtwarza ten sam problem w mniej oczywistej postaci.
Nawigacja, zależne odczyty i przechwycone wartości
Żądanie poprzedniej trasy może zakończyć się po nawigacji. Cleanup obejmuje lokalny stan tego hooka, ale trwały layout lub wspólny store może przeżyć stronę. Tożsamość widoku lub klucz cache powinny uwzględniać archiwum, wybrany obiekt, filtr i istotny kontekst użytkownika. Unieważnij poprzedni zakres, gdy nawigacja zmienia jego znaczenie; zmiana ścieżki nie zawsze oznacza odmontowanie komponentu.
Podobnej dyscypliny wymagają żądania zależne. Jeśli po wyborze zdjęcia najpierw pobierasz metadane, a potem podpisy, cały łańcuch musi zachować tożsamość tego zdjęcia. Sprawdź aktualność przed uruchomieniem drugiego odczytu i przed publikacją wyniku; przekaż też sygnał anulowania tam, gdzie jest obsługiwany.
Callback widzi wartości z renderu, w którym powstał. setSelectedPhoto(next) wywołane przed loadCaptions(selectedPhoto.id) nie sprawia, że drugie wywołanie użyje już nowego ID. Dla tej akcji przekaż jawnie next.id. Funkcyjna aktualizacja stanu pomaga w obliczeniach zależnych od poprzedniej wartości, ale nie rozstrzyga, do którego zdjęcia należy odpowiedź serwera. Wyłączenie ostrzeżenia lintera o zależnościach nie rozwiązuje żadnego z tych problemów.
Aktualizacje optymistyczne potrzebują innego kontraktu
Moderator zmienia priorytet weryfikacji podpisu ze zwykłego na wysoki, a potem na pilny. UI pokazuje obie zmiany od razu. Jeśli odpowiedź dla wysokiego priorytetu nadejdzie ostatnia i zastąpi wiersz, interfejs cofnie się do starszej wartości. Ogólny rollback po błędzie pierwszej mutacji również może usunąć późniejszą edycję.
Możliwe rozwiązania to wersjonowanie mutacji konkretnego zasobu, rejestr oczekujących operacji albo ich kolejkowanie dla tego samego obiektu. Wycofanie powinno dotyczyć własnej operacji, nie przywracać całego starego snapshotu na nowszy stan. Biblioteka cache może zapewniać część mechanizmu, lecz jej semantyka mutacji nadal musi pasować do funkcji.
Pominięcie starej odpowiedzi chroni ekran, nie końcowy stan serwera. Odświeżenie zaraz po najnowszej mutacji klienta też nie wystarczy, jeśli wcześniejszy zapis może zatwierdzić się później. Uzgodnij stan po zakończeniu istotnych zapisów albo oprzyj operację na wersjach serwera i określonej obsłudze konfliktów. Nie ponawiaj ślepo starego payloadu nad zaakceptowaną zmianą innego użytkownika.
Anulowanie, ponowienie i wykonanie operacji biznesowej to różne rzeczy
Wyłączenie przycisku zmniejsza ryzyko przypadkowego podwójnego kliknięcia. Nie koordynuje dwóch kart, dwóch komponentów ani drugiego użytkownika. Duplikaty mogą pochodzić także z jawnych ponowień klienta, odświeżenia strony, skonfigurowanych ponowień proxy lub ponownego dostarczania na backendzie. Zależy to od systemu, nie od jednej uniwersalnej reguły przeglądarki.
Rozważ POST /api/captions/28/approve. Użytkownik przechodzi dalej, a przeglądarka przestaje czekać. Zatwierdzenie mogło już zostać zapisane i zlecić powiadomienie. Przed powtórzeniem operacji o niepewnym wyniku sprawdź jej rezultat, jeśli API udostępnia taką możliwość, i stosuj kontrakt ponawiania.
Rozróżniaj trzy tożsamości. Request ID łączy zdarzenia jednej próby transportowej. Identyfikator intencji widoku wskazuje wybór aktualnie uprawniony do zmiany ekranu. Identyfikator operacji biznesowej/klucz idempotencji oznacza jedno logiczne zatwierdzenie podczas kolejnych prób. Nowy znacznik widoku nie zastępuje stabilnego klucza ponowienia. Backend musi egzekwować zakres klucza, zgodność payloadu i obsługę duplikatów.
Podpis odczytany w rewizji 12 może mieć już rewizję 13 w chwili zapisu. Sprawdzenie oczekiwanej wersji lub zapis warunkowy może odrzucić nieaktualną zmianę; odpowiedź konfliktu określa kontrakt API. Uprawnienia, unikalność i atomowe zmiany bazy pozostają odpowiedzialnością serwera. Anulowanie w UI ich nie zastąpi. Szczegółowe mechanizmy ponownego dostarczania opisuje osobny artykuł GiSoft o Messengerze.
Ustal jedno źródło prawdy dla stanu ekranu
Jeżeli filtrem zarządza URL, kontrolki i klucz zapytania powinny go odzwierciedlać. Jeśli formularz przechowuje niezapisane zmiany, odświeżenie w tle nie może ich po cichu nadpisać. Cache zapytań może zarządzać kopiami odpowiedzi, a backend pozostaje rozstrzygający dla zatwierdzonego stanu biznesowego. Trzymanie niezależnych kopii tych samych wierszy w Context, komponencie i cache utrudnia uzgadnianie danych.
Biblioteka zapytań może zapewniać deduplikację, klucze cache, anulowanie i mechanizmy mutacji. Żadna nie naprawi niepełnego klucza: oczekujące i archiwalne wpisy nie powinny trafiać do jednego nierozróżnialnego wpisu cache. Repozytorium nie deklaruje TanStack Query ani SWR, więc przykłady ich nie dodają. Gdy projekt już używa takiej biblioteki do zarządzania danymi, korzystaj z jej wspieranych mechanizmów.
W Next.js App Router trzeba uzgodnić filtry klienckie z parametrami trasy i odświeżaniem serwera. Odświeżenie trasy może zachować niezmieniony stan kliencki i samo nie unieważnia cache serwerowego. Zachowanie zależy od trybu routingu, wersji i konfiguracji. Fragmenty klienckie należą do poddrzewa klienta; read dostarcza kod kliencki, nie zwykła funkcja przekazana przez granicę Server Component.
Transitions dotyczą priorytetu renderowania; ręcznie koordynowana praca asynchroniczna nadal wymaga reguły kolejności. Mechanizmy Actions wyższego poziomu mogą mieć własną semantykę porządkowania. Samo opakowanie dowolnego fetch w startTransition nie oznacza, że wygra ostatnia akcja użytkownika.
Odwróć kolejność odpowiedzi w deterministycznym teście
Musimy móc wybrać chwilę udostępnienia każdej odpowiedzi. ReplyGate.ts to pomocnicza klasa testowa przygotowana dla tych przykładów, nie API Vitest ani React. Pozwala rozstrzygnąć jedną obietnicę sukcesem lub błędem:
export class ReplyGate<T> {
private accept!: (value: T) => void;
private decline!: (reason: Error) => void;
readonly result = new Promise<T>((resolve, reject) => {
this.accept = resolve;
this.decline = reject;
});
deliver(value: T) { this.accept(value); }
fail(reason: Error) { this.decline(reason); }
}Test korzysta z Vitest, React Testing Library, user-event i środowiska DOM, z @testing-library/jest-dom/vitest w setupie. Importuje pokazane wcześniej komponent i kontrakt. Atrapa adaptera celowo ignoruje anulowanie: sprawdzamy kontrolę właściciela również wtedy, gdy operacja mimo wszystko się zakończy.
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CaptionQueue } from './CaptionQueue';
import type { Caption, QueueReader } from './queueContract';
import { ReplyGate } from './ReplyGate';
it('zachowuje archiwum po spóźnionej odpowiedzi kolejki oczekujących', async () => {
const inbox = new ReplyGate<Caption[]>();
const archive = new ReplyGate<Caption[]>();
const read = vi.fn<QueueReader>((query) =>
query.bucket === 'pending' ? inbox.result : archive.result,
);
const user = userEvent.setup();
render(<CaptionQueue read={read} />);
await user.click(screen.getByRole('button', { name: 'Oczekujące' }));
await user.click(screen.getByRole('button', { name: 'Archiwum' }));
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[0][1]?.aborted).toBe(true);
await act(async () => {
archive.deliver([{ id: 'caption-28', label: 'Kładka nad kanałem' }]);
});
expect(screen.getByLabelText('Wybrana kolejka')).toHaveTextContent('Archiwum');
expect(screen.getByText('Kładka nad kanałem')).toBeVisible();
await act(async () => {
inbox.deliver([{ id: 'caption-11', label: 'Podpis przystanku tramwajowego' }]);
});
expect(screen.getByText('Kładka nad kanałem')).toBeVisible();
expect(screen.queryByText('Podpis przystanku tramwajowego')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});Kolejne asercje mają znaczenie: najpierw potwierdzamy widoczność B, następnie dostarczamy A i sprawdzamy, że B pozostało. Sam końcowy napis filtra nie wykryłby podmienionych wierszy. O kolejności nie decydują prawdziwa sieć ani przypadkowy czas oczekiwania.
Pozostałe wyścigi sprawdzaj na najtańszym wiarygodnym poziomie
Te same obietnice wystarczą do trzech osobnych przypadków. Dla ładowania dostarcz A przy nierozstrzygniętym B i sprawdź, że wskaźnik nadal istnieje; po B powinien zniknąć. Dla błędu dostarcz B, odrzuć A i potwierdź widoczność B bez alertu. Dla anulowania niech atrapa adaptera odrzuci obietnicę po sygnale abort; ogólny błąd nie powinien się pojawić, a B nadal może zakończyć się sukcesem. Sprawdź także cleanup przy odmontowaniu, bez polegania na historycznych ostrzeżeniach React.
Przy autouzupełnianiu celowo przesuwaj zegar testowy: uruchom kan, wpisz kanał, rozstrzygnij kan przed nowym timerem i upewnij się, że wynik nie został pokazany. Dopiero potem przesuń czas o skonfigurowane opóźnienie i dostarcz bieżący wynik. Osobny przypadek oczekujące → archiwum → oczekujące chroni przed uznaniem równych parametrów za tę samą intencję.
Ryzyko Przydatny poziom testu
Właściciel / reducer Jednostkowy, jeśli wydzielono logikę
Stare dane, błędy, ładowanie Komponent React
Sygnał i mapowanie odpowiedzi Adapter / kontrakt
Ponowione zatwierdzenie/zapis Backend: funkcjonalny / integracyjny
Przebieg moderacji Wybrany test przeglądarkowyTest jednostkowy ma sens dla czystego koordynatora lub reducera; nie wydzielaj go wyłącznie dla zwiększenia liczby testów. Testy adaptera powinny sprawdzać przekazanie sygnału, walidację odpowiedzi, kody błędów i nagłówki wersji lub idempotencji, jeśli występują w kontrakcie. Nie dowodzą działania deduplikacji backendu — to wymaga testów serwerowych, w razie potrzeby także współbieżności.
Test przeglądarkowy może sprawdzić rzeczywisty moduł z kontrolowanymi odpowiedziami przechwyconej sieci, jeśli obecne narzędzia to wspierają. Dokładną kolejność badaj głównie w testach komponentów. Szeroka tolerancja czasu i arbitralne opóźnienia milisekundowe słabo zastępują jawną kontrolę odpowiedzi. Szersze granice omawiają artykuły GiSoft Jak testować architekturę, błędy i procesy użytkownika w Next.js oraz Jak testować API Symfony za pomocą Pest.
Odtwórz sekwencję, zanim zmienisz harmonogram
Zbieraj ograniczone metadane wystarczające do odtworzenia intencji i zakończeń: lokalny numer sekwencji, identyfikator korelacji żądania, bezpieczną kategorię zapytania lub klucz bez wrażliwych danych, zakres trasy, początek i koniec oraz decyzję o przyjęciu bądź pominięciu wyniku. W razie potrzeby dodaj rewizję odpowiedzi lub identyfikator operacji biznesowej. Pominięty stary wynik może oznaczać prawidłowe zachowanie, nie awarię serwera.
Tekst wyszukiwania, opisy zdjęć i dane kont mogą być wrażliwe. Nie zapisuj poświadczeń, tokenów, pełnych payloadów ani danych osobowych tylko po to, by zbadać kolejność. Czasy żądań z przeglądarki połączone z lokalną sekwencją często mówią więcej niż zrzut całych odpowiedzi.
Kilka kuszących poprawek rozwiązuje inny problem. Dłuższy debounce zmniejsza liczbę wywołań, lecz nie wyklucza nakładania. useMemo nie porządkuje obietnic. isMounted rozróżnia cykl życia, nie konkurujące intencje. Bezwarunkowe finally może wyłączyć ładowanie cudzego żądania. Zablokowany przycisk poprawia UX, lecz nie zapewnia idempotencji backendu. Anulowanie każdej mutacji może pozbawić klienta informacji o już zapisanym wyniku. Każde z tych narzędzi potrzebuje konkretnej odpowiedzialności.
Krótki przegląd przed wdrożeniem
- Nazwij bieżącą intencję i stan, który ma prawo zastąpić.
- Odbierz poprzedniej operacji to prawo przy zmianie intencji, również podczas debounce.
- Stosuj wspólną kontrolę do wierszy, ładowania i błędów; anuluj odczyty tam, gdzie to pomaga.
- Ustal, jak backend rozstrzyga niepewne mutacje, ponowienia i równoczesne edycje.
- Testuj odwróconą kolejność, spóźniony błąd i nakładające się ładowanie kontrolowanymi odpowiedziami.
Odpowiedź, która kończy się ostatnia, nie dostaje automatycznie prawa do zmiany ekranu. Przyznaj to prawo jawnie, a skutki biznesowe chroń niezależnie na serwerze.
Dokumentacja techniczna
Fikcyjny moduł i przykłady opracowano dla tego artykułu. Zachowanie frameworków i anulowania sprawdzono w dokumentacji:
- React, cleanup Effectu: https://react.dev/reference/react/useEffect
- React, kolejność asynchronicznych Transitions: https://react.dev/reference/react/useTransition
- MDN, semantyka AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
- Next.js App Router, odświeżanie: https://nextjs.org/docs/app/api-reference/functions/use-router
