Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions docs/writing-tutorials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -86,21 +87,33 @@ 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
> step('Switch the status to Tickets open.', 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

Expand Down
Loading