Skip to the code
cf.

Tabs that wait with transitions

The tea shop's info page has tabs, and the Menu tab's code loads slowly. Right now clicking it blanks the page with a spinner. Build Tabs, the default export, so the old tab stays on screen until the new one is ready.

It takes tabs, an array of { id, label, content }, where content is the element to show. Some of them suspend while they load (a lazy component, say). The starter's default has three sample tabs with a slow Menu, so the Preview works.

Render one button per tab with role="tab" and the tab's label, inside an element with role="tablist". Below them, an element with role="tabpanel" shows only the selected tab's content, inside a Suspense boundary whose fallback is Loading… (with the single … character). The first tab is selected at first.

Switch tabs inside a transition. While a tab's content is still loading, the old tab's content stays on screen and keeps aria-selected="true", and the clicked tab's button gets aria-busy="true". No other button is busy. When the content is ready, it shows, the clicked tab gets aria-selected="true", and nothing is busy.

A click on another tab while one is loading wins: if that tab is ready, it shows right away, and the slow tab never takes over when it finishes loading. The fallback only shows when there's nothing old to keep, like a first tab that suspends.

const Menu = lazy(() => import("./Menu.jsx"));
 
<Tabs
  tabs={[
    { id: "about", label: "About", content: <p>A small tea shop.</p> },
    { id: "menu", label: "Menu", content: <Menu /> },
  ]}
/>
 
// Click Menu: "A small tea shop." stays, the Menu button has aria-busy="true".

What the tests check

  1. shows the first tab and marks it selected
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["About", "Menu", "Contact"]);
    expect(screen.getByRole("tab", { name: "About" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Menu" })).toHaveAttribute("aria-selected", "false");
    expect(screen.getByRole("tabpanel")).toHaveTextContent("A small tea shop.");
    
  2. switches to a tab that's ready at once
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    await user.click(screen.getByRole("tab", { name: "Contact" }));
    expect(screen.getByRole("tabpanel")).toHaveTextContent("Call 555 0100.");
    expect(screen.queryByText("A small tea shop.")).not.toBeInTheDocument();
    expect(screen.getByRole("tab", { name: "Contact" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "About" })).toHaveAttribute("aria-selected", "false");
    
  3. keeps the old tab on screen while a slow tab loads
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    await user.click(screen.getByRole("tab", { name: "Menu" }));
    expect(screen.getByText("A small tea shop.")).toBeVisible();
    expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
    expect(screen.getByRole("tab", { name: "About" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Menu" })).toHaveAttribute("aria-selected", "false");
    
  4. marks only the tab being loaded with aria-busy
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    await user.click(screen.getByRole("tab", { name: "Menu" }));
    expect(screen.getByRole("tab", { name: "Menu" })).toHaveAttribute("aria-busy", "true");
    expect(screen.getByRole("tab", { name: "About" })).not.toHaveAttribute("aria-busy", "true");
    expect(screen.getByRole("tab", { name: "Contact" })).not.toHaveAttribute("aria-busy", "true");
    
  5. shows the slow tab once it's ready
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    await user.click(screen.getByRole("tab", { name: "Menu" }));
    loadMenu();
    expect(await screen.findByText("Today: Sencha and Assam.")).toBeInTheDocument();
    expect(screen.queryByText("A small tea shop.")).not.toBeInTheDocument();
    expect(screen.getByRole("tab", { name: "Menu" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Menu" })).not.toHaveAttribute("aria-busy", "true");
    
  6. another click while a tab loads wins
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={tabs} />);
    await user.click(screen.getByRole("tab", { name: "Menu" }));
    await user.click(screen.getByRole("tab", { name: "Contact" }));
    expect(await screen.findByText("Call 555 0100.")).toBeInTheDocument();
    expect(screen.getByRole("tab", { name: "Contact" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Menu" })).not.toHaveAttribute("aria-busy", "true");
    loadMenu();
    await flush();
    await sleep(400);
    expect(screen.getByRole("tabpanel")).toHaveTextContent("Call 555 0100.");
    expect(screen.queryByText("Today: Sencha and Assam.")).not.toBeInTheDocument();
    
  7. shows Loading… when there's no old tab to keep
    const menuModule = deferred();
    const Menu = React.lazy(() => menuModule.promise);
    const loadMenu = () => menuModule.resolve({ default: () => <p>Today: Sencha and Assam.</p> });
    const tabs = [
      { id: "about", label: "About", content: <p>A small tea shop.</p> },
      { id: "menu", label: "Menu", content: <Menu /> },
      { id: "contact", label: "Contact", content: <p>Call 555 0100.</p> },
    ];
    render(<App tabs={[tabs[1], tabs[0]]} />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    expect(screen.getByRole("tab", { name: "About" })).toBeInTheDocument();
    loadMenu();
    expect(await screen.findByText("Today: Sencha and Assam.")).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).