Skip to content

Commit 5e33ea2

Browse files
ryansolidclaude
andauthored
docs: when async work stops, and actions that outlive their page (#1668)
Adds a "Stop the work a run started" section to the async concept page: a never-committed run is cleaned up when superseded (so onCleanup + AbortController cancels an unfinished request), the run on screen stays live until its replacement lands, and waiting on that cleanup from inside the replacing update can deadlock. Mutations gains a common-problems entry for a save that keeps running after navigation (by design; guard post-yield steps, don't retire actions from onCleanup). Follows solid #4222 / #4223. Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30eca8f commit 5e33ea2

3 files changed

Lines changed: 41 additions & 0 deletions

File tree

‎src/routes/(2)concepts/(3)async-reactivity.mdx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,37 @@ Development builds turn that read into an error so it reaches `Errored` instead
496496
Read every reactive input at the top of the function, before the first `await`.
497497
:::
498498

499+
## Stop the work a run started
500+
501+
Solid drops a superseded run's result on its own, but the work the run started, such as a request, a timer, or a subscription, keeps going unless the run stops it.
502+
When a run's cleanups happen depends on whether the user has seen it:
503+
504+
- **A run that never committed** was never on screen, so it is cleaned up as soon as a newer run supersedes it.
505+
An `onCleanup` registered before the first `await` therefore cancels an unfinished request:
506+
507+
```ts
508+
const results = createMemo(async () => {
509+
const q = query();
510+
const controller = new AbortController();
511+
onCleanup(() => controller.abort()); // before the first await
512+
const response = await fetch(`/search?q=${q}`, { signal: controller.signal });
513+
return response.json();
514+
});
515+
```
516+
517+
- **The run on screen** stays fully live until its replacement lands.
518+
Its children keep reacting and its subscriptions and timers keep running, because it is still what the user sees.
519+
Its cleanups run when the replacement commits.
520+
521+
An iterable that the computation returns is different: it is closed as soon as the run is superseded, because nothing can show what it yields any more.
522+
To stop work when one input changes, tie that work to the input in a child computation rather than to the outer one, so it re-runs with the input instead of waiting for the whole update.
523+
524+
:::pitfall[Do not wait on a cleanup from inside the update that replaces it]
525+
The on-screen run's cleanups run only when the held update commits.
526+
Anything inside that update that waits for one of those cleanups can wait forever: an action that races a promise resolved from a memo's `onCleanup`, or a new run that awaits a lock the old run releases in `onCleanup`.
527+
Signal from the input instead: compare against the changed value, or hand the lock over when the new run acquires it.
528+
:::
529+
499530
## Recap
500531

501532
- Return a promise from a memo and read the result as a plain value; the type is `Product`, not `Promise<Product>` or `Product | undefined`.
@@ -506,6 +537,7 @@ Read every reactive input at the top of the function, before the first `await`.
506537
- Acknowledge a held update with `isPending` on the content and `latest` on the control the user touched.
507538
- Put `on={key()}` on a `Loading` boundary whose subject changed and should show a placeholder instead of the old content; `on` is tracked, and the fallback never appears when the same data is also read outside the boundary.
508539
- Read every reactive input before the first `await`.
540+
- Cancel a request with `onCleanup` and an `AbortController` registered before the first `await`; a run that never committed is cleaned up when it is superseded, and the run on screen when its replacement lands.
509541

510542
## Next steps
511543

‎src/routes/(2)concepts/(5)mutations.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,14 @@ Make the tentative write inside the `action` that sends the request, and suspend
341341
The store is a plain `createStore`, whose writes are durable even inside an action.
342342
Use `createOptimisticStore` for state that should revert when the request settles.
343343

344+
### The save keeps running after the user leaves the page
345+
346+
An action is not tied to the component that created it, so navigating away does not cancel it and the save still lands, like a form submit.
347+
That is the safe default: the action is often what navigates (save, then redirect), and cancelling it there would undo the write the user asked for.
348+
Its late writes are harmless, because nothing that was removed still reads them.
349+
Guard the steps after a `yield` that only make sense on the old page, for example by comparing against the current record id, and pass an `AbortSignal` to the request when it should really stop.
350+
Do not retire the action from a component's `onCleanup`; see [Stop the work a run started](/concepts/async-reactivity#stop-the-work-a-run-started).
351+
344352
### `flush()` inside an action throws `[FLUSH_IN_ACTION]`
345353

346354
An action's writes are held in its transaction until the action settles, so there is nothing for `flush()` to show.

‎src/routes/(5)guides/(6)data-fetching-patterns.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Three things this code does not do, because Solid does them:
8282
- It does not discard stale responses by hand.
8383
Only the answer to the current question is used; if the response for `mu` arrives after the one for `mug`, it is dropped.
8484
There is no request counter and no `AbortController`.
85+
The superseded request still finishes in the background; to cancel it, see [Stop the work a run started](/concepts/async-reactivity#stop-the-work-a-run-started).
8586
- It does not blank the list between keystrokes.
8687
The previous results stay visible with the `stale` class until the new ones land.
8788
- It does not show a spinner for an empty query.

0 commit comments

Comments
 (0)