The tea shop's support chat feels slow: nothing appears until the server answers. Build Chat, the default export, so a sent message shows up straight away with useOptimistic, and the saved message replaces it when the server answers.
Chat takes initialMessages (an array of { id, text }) and send(text), which returns a promise of the saved message, { id, text }, or rejects when it fails. Both have defaults in the starter: two sample messages, and a pretend server that answers after a second and fails every third message.
Show the messages in order in a list, one list item each. Below it, a form with a text box labelled Message (its name is text) and a Send button. Send the form with a form action. Trim the text, and don't send anything when it's empty.
While a message is on its way, its list item shows the text and Sending… (with the single … character). When send resolves, the saved message takes its place, so each message shows once. The box empties once the action finishes.
When send rejects, the message stays in the list with Not sent and a Retry button instead of Sending…. Retry sends the same text again and shows Sending… again while it waits; if it works, the saved message takes the failed one's place, and if it fails, it shows Not sent again.
Several messages can be on their way at once. However the answers arrive, a message must never show twice.
<Chat initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />
// Is the shop open?
// Tea at 4? Sending…
// And cake? Not sent [Retry]
// [Message: ______] [Send]
render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }, { id: 2, text: "Yes, until four." }]} send={() => new Promise(() => {})} />);
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("Is the shop open?");
expect(items[1]).toHaveTextContent("Yes, until four.");
const answer = deferred();
const send = spy(() => answer.promise);
render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(send).toHaveBeenCalledWith("Tea at 4?");
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[1]).toHaveTextContent("Tea at 4?");
expect(items[1]).toHaveTextContent("Sending…");
answer.resolve({ id: 2, text: "Tea at 4?" });
await flush();
const answer = deferred();
render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={() => answer.promise} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
answer.resolve({ id: 2, text: "Tea at 4?" });
await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[1]).toHaveTextContent("Tea at 4?");
const answer = deferred();
render(<App initialMessages={[]} send={() => answer.promise} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
answer.resolve({ id: 2, text: "Tea at 4?" });
await waitFor(() => expect(screen.getByLabelText("Message")).toHaveValue(""));
const send = spy(() => new Promise(() => {}));
render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />);
await user.type(screen.getByLabelText("Message"), " ");
await user.click(screen.getByRole("button", { name: "Send" }));
await flush();
expect(send).toHaveBeenCalledTimes(0);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
const answers = [];
render(<App initialMessages={[]} send={() => {
const answer = deferred();
answers.push(answer);
return answer.promise;
}} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
answers[0].reject(new Error("offline"));
expect(await screen.findByText(/Not sent/)).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(1);
const item = screen.getByRole("listitem");
expect(item).toHaveTextContent("Tea at 4?");
expect(item).not.toHaveTextContent("Sending…");
expect(within(item).getByRole("button", { name: "Retry" })).toBeInTheDocument();
const answers = [];
const send = spy(() => {
const answer = deferred();
answers.push(answer);
return answer.promise;
});
render(<App initialMessages={[]} send={send} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
answers[0].reject(new Error("offline"));
await user.click(await screen.findByRole("button", { name: "Retry" }));
expect(send).toHaveBeenCalledTimes(2);
expect(send.calls[1].args).toEqual(["Tea at 4?"]);
const item = screen.getByRole("listitem");
expect(item).toHaveTextContent("Tea at 4?");
expect(item).toHaveTextContent("Sending…");
expect(item).not.toHaveTextContent("Not sent");
answers[1].resolve({ id: 9, text: "Tea at 4?" });
await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
expect(screen.queryByText(/Not sent/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(1);
const answers = [];
render(<App initialMessages={[]} send={() => {
const answer = deferred();
answers.push(answer);
return answer.promise;
}} />);
await user.type(screen.getByLabelText("Message"), "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
answers[0].reject(new Error("offline"));
await user.click(await screen.findByRole("button", { name: "Retry" }));
answers[1].reject(new Error("still offline"));
expect(await screen.findByText(/Not sent/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(1);
const answers = {};
render(<App initialMessages={[]} send={(text) => {
answers[text] = deferred();
return answers[text].promise;
}} />);
const itemsWith = (text) => screen.getAllByRole("listitem").filter((item) => item.textContent.includes(text));
const box = screen.getByLabelText("Message");
await user.type(box, "Tea at 4?");
await user.click(screen.getByRole("button", { name: "Send" }));
await user.clear(box);
await user.type(box, "And cake?");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(itemsWith("Sending…")).toHaveLength(2);
answers["Tea at 4?"].resolve({ id: 2, text: "Tea at 4?" });
await flush();
expect(itemsWith("Tea at 4?")).toHaveLength(1);
answers["And cake?"].resolve({ id: 3, text: "And cake?" });
await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
expect(itemsWith("Tea at 4?")).toHaveLength(1);
expect(itemsWith("And cake?")).toHaveLength(1);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
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).
I'm here to help with Optimistic chat messages. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.