Skip to the code
cf.

Abort on unmount

Build TeaDetails, the default export: a panel that shows one tea from a small tea shop. It takes an id prop (default 1), fetches /api/teas/ followed by the id, like /api/teas/3, and gets JSON like { id: 3, name: "Oolong", notes: "Roasted, with a hint of honey." }.

Show Loading… (with the single … character) while it waits, then the name in a heading and the notes in a paragraph. If the request fails (fetch rejects, or the response isn't ok), show Couldn't load this tea.

People click through teas fast and close the panel before it loads, so cancel what's no longer needed. Pass an AbortController's signal to fetch, and abort the request when the panel leaves the page and when id changes. The tests look at signal.aborted on the requests you make.

An aborted request isn't a failure. It must not show the error, not show its tea, and not log anything. Only the tea for the current id ever shows.

<TeaDetails id={3} />
 
// Loading…, then:
<h2>Oolong</h2>
<p>Roasted, with a hint of honey.</p>

What the tests check

  1. fetches /api/teas/:id and shows the tea
    const fetch = mockFetch(() => ({ id: 3, name: "Oolong", notes: "Roasted, with a hint of honey." }));
    render(<App id={3} />);
    expect(await screen.findByRole("heading", { name: "Oolong" })).toBeInTheDocument();
    expect(screen.getByText("Roasted, with a hint of honey.")).toBeInTheDocument();
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas/3"]);
    
  2. shows Loading… until the tea arrives
    const answer = deferred();
    mockFetch(() => answer.promise);
    render(<App id={3} />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    answer.resolve({ id: 3, name: "Oolong", notes: "Roasted, with a hint of honey." });
    expect(await screen.findByText("Oolong")).toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    
  3. passes a signal to fetch, not aborted while the panel is open
    const fetch = mockFetch(() => deferred().promise);
    render(<App id={3} />);
    await flush();
    const { signal } = fetch.calls[0].init;
    expect(signal).toBeTruthy();
    expect(signal.aborted).toBe(false);
    
  4. aborts the request when the panel leaves the page
    const fetch = mockFetch(() => deferred().promise);
    const { unmount } = render(<App id={3} />);
    unmount();
    expect(fetch.calls[0].init.signal.aborted).toBe(true);
    
  5. aborts the old request when the id changes
    const fetch = mockFetch(() => deferred().promise);
    const { rerender } = render(<App id={3} />);
    rerender(<App id={4} />);
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas/3", "/api/teas/4"]);
    expect(fetch.calls[0].init.signal.aborted).toBe(true);
    expect(fetch.calls[1].init.signal.aborted).toBe(false);
    
  6. an aborted request shows no error
    const first = deferred();
    mockFetch((url) => (url === "/api/teas/3" ? first.promise : deferred().promise));
    const { rerender } = render(<App id={3} />);
    rerender(<App id={4} />);
    await flush();
    first.resolve({ id: 3, name: "Oolong", notes: "Roasted." });
    await flush();
    expect(screen.queryByText("Couldn't load this tea.")).not.toBeInTheDocument();
    expect(screen.queryByText("Oolong")).not.toBeInTheDocument();
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    
  7. shows only the current tea when the old one answers late
    const first = deferred();
    mockFetch((url) => (url === "/api/teas/3" ? first.promise : { id: 4, name: "Assam", notes: "Malty and strong." }));
    const { rerender } = render(<App id={3} />);
    rerender(<App id={4} />);
    expect(await screen.findByText("Assam")).toBeInTheDocument();
    first.resolve({ id: 3, name: "Oolong", notes: "Roasted." });
    await flush();
    expect(screen.queryByText("Oolong")).not.toBeInTheDocument();
    expect(screen.queryByText("Couldn't load this tea.")).not.toBeInTheDocument();
    expect(screen.getByText("Assam")).toBeInTheDocument();
    
  8. logs nothing when a request is aborted
    const answer = deferred();
    mockFetch(() => answer.promise);
    const { unmount } = render(<App id={3} />);
    await flush();
    unmount();
    answer.resolve({ id: 3, name: "Oolong", notes: "Roasted." });
    await flush();
    expect(logs).toEqual([]);
    
  9. shows the error when the request fails
    mockFetch(() => fail(500));
    render(<App id={3} />);
    expect(await screen.findByText("Couldn't load this tea.")).toBeInTheDocument();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).