Skip to content

Action busy state, onSettled hooks and submissions release before the transition commits #649

Description

@ryansolid

Summary

Three things an action does on the client happen when its generator finishes, not when its transition commits:

  • the form's aria-busy is removed (setFormBusy(form, -1) in the finally after settleActionResult(run(...)));
  • action.onSettled hooks fire;
  • the submission is recorded in useSubmissions.

action() in @solidjs/signals resolves its promise in done() — it removes the iterator, schedules a flush, and resolves immediately — so everything chained on that promise runs before the commit. When nothing holds the transition, release and commit land in back-to-back microtasks and it looks right. When something does, it doesn't.

Separately, aria-busy is stripped by any server-component morph of the form (server HTML never carries it), and nothing reapplies it while the submission is still in flight.

Where it shows

The invariant we want: a form is busy from submit until the commit that makes its outcome visible, whatever that commit was merged with.

case today
independent action, transition not held correct by timing luck (adjacent microtasks)
transition held by async (e.g. the refetch after a response without single-flight data) mark clears while the old markup still shows; the update pops in later
transition merged through shared optimistic writes mark clears while the prediction still shows
action merged with a pending navigation releases before the navigation commits
another response's morph touches the form aria-busy stripped mid-flight, not reapplied

The same timing affects onSettled users: todos-server clears the add input in addTodo.onSettled, which can run before the new row appears.

Constraints on a fix

  • No shared reactive state for busy. A router-level optimistic store (inflight[url] = true, auto-reverting) is tempting, but it is a shared optimistic write — it would entangle overlapping submissions that otherwise land independently. An indicator must never change when results land.
  • Key by the form's action URL, not the element. .with() makes the URL unique per row. A WeakMap<HTMLFormElement, number> loses the mark when a morph replaces the node, and misses programmatic calls of the same request (e.g. submission.retry() shows no mark today).
  • Reapply on re-claim. The frames runtime already re-claims form[action] after a morph changes its attributes; a claim handler that reads the busy record restores the attribute.

Candidate

Release from a commit callback rather than the promise. An unowned onSettled enqueues onto the global queue; registered from the action's final slice while its transition is active, it should fire when that transition commits — merged or not — and, being an effect rather than a write, cannot entangle anything. Then:

  • busy: a non-reactive Map<string, number> keyed by URL, incremented on submit, decremented in that callback, projected onto form[action="<url>"] and reapplied by the claim handler;
  • onSettled hooks and the submission record move to the same callback.

To verify first (signals-level test): an onSettled registered in an action's final slice fires only when the transition commits — when held by a pending async read, and when merged with a second action.

Open decision: whether core action() should resolve its promise at commit instead (making await action() mean "it's on screen"). That changes documented behaviour of a core public API, so it's a framework decision, not a router detail.

Context

Found while rebuilding the todos-server example (solidjs/solid#3717) around router-driven intent (aria-busy + :has() CSS). An earlier attempt that only reapplied aria-busy on re-claim was dropped from the claims fix in favour of fixing the release point properly.

Activity

  1. ryansolid commented on Oct 2, 2026

    @ryansolid
    MemberAuthor

    Fixed on next in 1b12229 + 94f0c6d (rides the next prerelease). No core change: the router now releases at the action transition's commit using core's public onSettled.

    • Commit-time release: a form's aria-busy, the submission record, and onSettled hooks now release when the action's transition commits — i.e. when its result (revalidated data, redirect target) is on screen — instead of when the action body's promise resolves. Holds when async reads keep the transition pending, when transitions merge, and for nested yield call() (released at the outer commit). The action's own promise (await submit(), useAction) still means "body finished", so composition doesn't deadlock.
    • Hooks: the hooks that run for a submission are the ones registered when its body finished (same set as before), run at the commit — including a hook owned by a page the commit unmounts, e.g. on redirect.
    • Morphs: busy state is keyed by the form's action URL, and re-claiming a form re-applies aria-busy while its action is in flight, so server-component morphs and replaced forms keep it. Author-set aria-busy is left alone.
    • Failures outside the mutation (a throwing onSubmit hook, an undecodable response) are recorded on the submission.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions