From 5578a0d52b705fac91d9d701931a66a1cab8c3c8 Mon Sep 17 00:00:00 2001 From: John Brecht Date: Sat, 13 Jun 2026 16:08:10 -0700 Subject: [PATCH] docs: document waitForResponse recipe for deferred mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard Next.js App Router transition (startTransition + Server Action + router.refresh()) dispatches its request a tick after the action returns, so settleUntil: 'networkidle' can resolve against the pre-mutation UI. When there's no committed DOM signal to name for waitFor but the request behind the change is identifiable, arm page.waitForResponse(...) before the action and await it — it can't resolve until the matching request has fired and returned, closing the gap with no settleMs guess. Adds the recipe to the deferred-mutation warning, reframes the three reliable options in precedence order (DOM signal > endpoint > network quiet), and updates the settling rule-of-thumb. Resolves #24 in favor of guidance over a new settleUntil: 'mutation' heuristic. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/writing-tutorials.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/writing-tutorials.md b/docs/writing-tutorials.md index fbf0341..6f861f8 100644 --- a/docs/writing-tutorials.md +++ b/docs/writing-tutorials.md @@ -67,10 +67,11 @@ After a step's action, the recording needs to wait until the app has visually ca | | What it waits on | Use when | |---|---|---| | **`waitFor`** | A DOM signal you choose (a locator appears, text changes, a spinner detaches) | There's a specific element/state that marks "done". This is the principled default — it waits exactly as long as needed, no more. | -| **`settleUntil`** | A page load-state signal (`networkidle` / `load` / `domcontentloaded`) | There's *no* clean DOM signal — e.g. a `router.refresh()` that just repaints, or a navigation whose result you don't want to assert on. `'networkidle'` waits for in-flight requests to quiesce. **Not** for `startTransition`-deferred Server Actions — see the warning below. | +| **`waitForResponse`** *(via `waitFor`/`run`)* | A specific network response | There's no clean DOM signal but you *know the request* behind the change — arm `page.waitForResponse(...)` before the action and await it. The principled choice for `startTransition`-deferred Server Actions; see the warning below. | +| **`settleUntil`** | A page load-state signal (`networkidle` / `load` / `domcontentloaded`) | There's *neither* a clean DOM signal *nor* a knowable endpoint — e.g. a `router.refresh()` that just repaints, or a navigation whose result you don't want to assert on. `'networkidle'` waits for in-flight requests to quiesce. **Not** for `startTransition`-deferred Server Actions when you can name the request — prefer `waitForResponse`; see the warning below. | | **`settleMs`** | A fixed number of milliseconds | A last resort, or a deliberate on-screen beat *after* readiness (e.g. let an animation finish, or hold the final frame a touch longer). | -Rule of thumb: **if you can name the thing you're waiting for, use `waitFor`.** If the only signal is "the network went quiet," use `settleUntil: 'networkidle'`. Only fall back to `settleMs` for a deliberate pause or when nothing else applies — and keep it small. +Rule of thumb: **if you can name the thing you're waiting for, use `waitFor`.** If you can't name a DOM signal but you know the request behind the change, arm `page.waitForResponse(...)` before the action (see the deferred-mutation warning below). If the only signal is "the network went quiet," use `settleUntil: 'networkidle'`. Only fall back to `settleMs` for a deliberate pause or when nothing else applies — and keep it small. ```ts // Best: wait on the concrete signal. @@ -86,7 +87,7 @@ step('The list refreshes with your new row.', async (page) => { `settleUntil` is **best-effort and bounded** (~5s): a page that never goes idle — websockets, polling, server-sent events — logs and proceeds rather than failing the render, so it's safe to use even when you're not sure the app quiesces. `settleUntil` and `settleMs` compose: the signal-based wait happens first, then `settleMs` adds its on-screen hold. -> **`networkidle` races React `startTransition` — prefer `waitFor` for deferred mutations.** The standard Next.js App Router mutation — a controlled input whose `onChange` runs `startTransition(async () => { await someServerAction(); router.refresh() })` — does **not** dispatch its request synchronously. At the instant your `selectOption`/`click` returns, React hasn't fired the action's fetch yet, so the page is *momentarily* network-idle and `settleUntil: 'networkidle'` can resolve against the **pre-mutation** UI (the screenshot shows the old value). It often lands correctly by luck (the request is usually in flight by the time Playwright polls), but it's timing-dependent. Don't reach for `networkidle` here — wait on the committed result instead: +> **`networkidle` races React `startTransition` — prefer `waitFor` for deferred mutations.** The standard Next.js App Router mutation — a controlled input whose `onChange` runs `startTransition(async () => { await someServerAction(); router.refresh() })` — does **not** dispatch its request synchronously. At the instant your `selectOption`/`click` returns, React hasn't fired the action's fetch yet, so the page is *momentarily* network-idle and `settleUntil: 'networkidle'` can resolve against the **pre-mutation** UI (the screenshot shows the old value). It often lands correctly by luck (the request is usually in flight by the time Playwright polls), but it's timing-dependent. Don't reach for `networkidle` here. Two reliable options, most precise first: > > ```ts > // ❌ races the transition — can settle before the server action even dispatches @@ -94,13 +95,25 @@ step('The list refreshes with your new row.', async (page) => { > await page.getByLabel('Status').selectOption('open'); > }, { settleUntil: 'networkidle' }) > -> // ✅ wait on the committed UI — exactly as long as the repaint takes, no race +> // ✅ best: wait on the committed UI — exactly as long as the repaint takes, no race > step('Switch the status to Tickets open.', async (page) => { > await page.getByLabel('Status').selectOption('open'); > }, { waitFor: (page) => page.getByText('Tickets open').waitFor() }) +> +> // ✅ when no committed DOM signal is nameable but you know the endpoint: +> // arm the response wait *before* the action, then await it. This closes the +> // transition gap without guessing — `waitForResponse` can't resolve until the +> // request it matches has actually fired and come back. +> step('Switch the status to Tickets open.', async (page) => { +> const saved = page.waitForResponse( +> (r) => r.url().includes('/status') && r.request().method() === 'POST', +> ); +> await page.getByLabel('Status').selectOption('open'); +> await saved; +> }) > ``` > -> Reserve `settleUntil: 'networkidle'` for cases with no committed-state signal to name — a `router.refresh()`/reload that only repaints existing data, or a navigation whose result you don't assert on. +> Reach for the `waitForResponse` recipe when the mutation repaints existing data (so there's no new element to name for `waitFor`) but you can identify its request — it's strictly more reliable than `settleUntil: 'networkidle'` for deferred transitions and needs no fixed `settleMs`. Reserve `settleUntil: 'networkidle'` for cases with **neither** a committed-state signal **nor** a knowable endpoint — a `router.refresh()`/reload that only repaints existing data, or a navigation whose result you don't assert on. ## Iterating on a step