From 8d51ddac6f3c5f80504951923c4c790c88fa204f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:00:01 +0000 Subject: [PATCH] docs: when async work stops, and actions that outlive their page 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. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .../(2)concepts/(3)async-reactivity.mdx | 32 +++++++++++++++++++ src/routes/(2)concepts/(5)mutations.mdx | 8 +++++ .../(5)guides/(6)data-fetching-patterns.mdx | 1 + 3 files changed, 41 insertions(+) diff --git a/src/routes/(2)concepts/(3)async-reactivity.mdx b/src/routes/(2)concepts/(3)async-reactivity.mdx index 3d510ea64..b4f942869 100644 --- a/src/routes/(2)concepts/(3)async-reactivity.mdx +++ b/src/routes/(2)concepts/(3)async-reactivity.mdx @@ -496,6 +496,37 @@ Development builds turn that read into an error so it reaches `Errored` instead Read every reactive input at the top of the function, before the first `await`. ::: +## Stop the work a run started + +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. +When a run's cleanups happen depends on whether the user has seen it: + +- **A run that never committed** was never on screen, so it is cleaned up as soon as a newer run supersedes it. + An `onCleanup` registered before the first `await` therefore cancels an unfinished request: + +```ts +const results = createMemo(async () => { + const q = query(); + const controller = new AbortController(); + onCleanup(() => controller.abort()); // before the first await + const response = await fetch(`/search?q=${q}`, { signal: controller.signal }); + return response.json(); +}); +``` + +- **The run on screen** stays fully live until its replacement lands. + Its children keep reacting and its subscriptions and timers keep running, because it is still what the user sees. + Its cleanups run when the replacement commits. + +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. +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. + +:::pitfall[Do not wait on a cleanup from inside the update that replaces it] +The on-screen run's cleanups run only when the held update commits. +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`. +Signal from the input instead: compare against the changed value, or hand the lock over when the new run acquires it. +::: + ## Recap - Return a promise from a memo and read the result as a plain value; the type is `Product`, not `Promise` or `Product | undefined`. @@ -506,6 +537,7 @@ Read every reactive input at the top of the function, before the first `await`. - Acknowledge a held update with `isPending` on the content and `latest` on the control the user touched. - 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. - Read every reactive input before the first `await`. +- 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. ## Next steps diff --git a/src/routes/(2)concepts/(5)mutations.mdx b/src/routes/(2)concepts/(5)mutations.mdx index 8269b9fdd..b510dccda 100644 --- a/src/routes/(2)concepts/(5)mutations.mdx +++ b/src/routes/(2)concepts/(5)mutations.mdx @@ -341,6 +341,14 @@ Make the tentative write inside the `action` that sends the request, and suspend The store is a plain `createStore`, whose writes are durable even inside an action. Use `createOptimisticStore` for state that should revert when the request settles. +### The save keeps running after the user leaves the page + +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. +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. +Its late writes are harmless, because nothing that was removed still reads them. +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. +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). + ### `flush()` inside an action throws `[FLUSH_IN_ACTION]` An action's writes are held in its transaction until the action settles, so there is nothing for `flush()` to show. diff --git a/src/routes/(5)guides/(6)data-fetching-patterns.mdx b/src/routes/(5)guides/(6)data-fetching-patterns.mdx index 5c9926bcc..69ce39fd6 100644 --- a/src/routes/(5)guides/(6)data-fetching-patterns.mdx +++ b/src/routes/(5)guides/(6)data-fetching-patterns.mdx @@ -82,6 +82,7 @@ Three things this code does not do, because Solid does them: - It does not discard stale responses by hand. Only the answer to the current question is used; if the response for `mu` arrives after the one for `mug`, it is dropped. There is no request counter and no `AbortController`. + 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). - It does not blank the list between keystrokes. The previous results stay visible with the `stale` class until the new ones land. - It does not show a spinner for an empty query.