Unsaved work: a form sent with htmx stops being guarded the moment it is sent, so a failed save can lose what was typed #1156

Closed
opened 2026-10-31 03:04:24 +00:00 by tiagoagueda · 0 comments
Owner

What exists today

static/js/app.js:1925-1993 keeps a per-form dirty mark (form.dataset.dirty) that beforeunload (2023-2032) and the in-page leave dialog (2200-2295) read. Two listeners clear it for an htmx form, and neither looks at the outcome:

  • app.js:1987-1993, the submit listener, deletes the mark for every submitted form. For a form with hx-post, htmx cancels the native submission but the event still bubbles to the document, so the mark is gone as the request leaves, before any answer.
  • app.js:1996-2001, htmx:afterRequest, deletes it again for event.target.closest("form"). In the vendored htmx 2.0.10 (node_modules/htmx.org/dist/htmx.js:4581, 4608, 4615, 4622) afterRequest is raised after a 4xx or 5xx (which responseHandling does not swap, lines 264-268), after a network error, an abort and a timeout, as well as after success.

So when the server refuses or the network drops, the form stays on the page with the person's text in it, the alert says the request failed, and the next link click or tab close leaves without asking. (In passing: the comment at app.js:221-223 says afterRequest is not raised when the request never reached the server; in htmx 2.0.10 it is. Harmless there, since the code listens to all four events, but the comment should be corrected.)

Guarded forms (method="post", no data-no-guard, not an autosubmit control) that post through htmx, found by scanning every template for a <form> with method="post" and an hx-post:

template form what can be typed
applications/partials/event_form.html:11 Add to the timeline (hx-target="#event-form", outerHTML) kind, date, summary, notes
applications/partials/status_card.html:47 the status card on an application status and its fields
cotton/dialog.html:40 any <c-dialog action=... live>, hx-swap="none" (Your details row removals) whatever the dialog body holds; usually a confirmation
cotton/reminder_actions.html:37, 61, 70 complete, snooze, delete a reminder, when target is set buttons only
jobs/company_form.html:41 the logo dialog's Find logo a button only

core/partials/theme_switch.html:9 is excluded by data-theme-switch; partials/table/cell.html:27, the cell editor, has no method and so is never guarded (markDirty, 1950-1952), which is outside this issue. The first two rows are where typed text is at risk.

What would change

  • The submit listener skips forms htmx sends (hx-post/hx-get on the form, or event.defaultPrevented after htmx's own listener), leaving their mark to the answer.
  • The afterRequest listener clears the mark only when event.detail.successful is true and the request was the form's own submission (event.detail.elt is the form, or its submitter). On a failure the mark stays, so leaving asks.
  • A form that a successful answer swaps out is replaced by a fresh one with no mark; one answered with hx-swap="none" (the live dialog) is cleared by the success branch. A refused save that the server redraws (a 2xx with errors, as event_form does) arrives as a new form element holding the typed values, unmarked, which matches the full-page behaviour where a refused save is a new page.
  • An e2e test: type into Add to the timeline, make the post fail (route abort in Playwright), click a sidebar link, expect the leave dialog.

What it saves or protects

reliability: text typed into the timeline form (summary and notes), a status note or a dialog is no longer lost without a question after a request that failed, timed out or was refused

Accessibility

must be checked: the leave question must still come in Postulo's own dialog with focus handed back on Cancel (#657), the failure must still be announced by the page's role="alert" region (data-htmx-alert, static/js/app.js:235-257), and a refused save that the server redraws must not ask twice. Guarded today by tests/e2e/test_unsaved_work.py (full-page forms only, no htmx-posted one) and tests/test_page_script.py:85-87 (the failure listeners exist); a new e2e case is needed for an htmx form whose request fails

Decisions

  • Keep the text guarded after a 4xx that htmx does not swap? Recommended: yes; the person has not seen it saved, and the alert already says it failed.

Found by the efficiency and reliability audit of 2026-10-30 (finding A11; index postulo/postulo#1108). Checked against main at daee30b15 (2026-10-30).

## What exists today `static/js/app.js:1925-1993` keeps a per-form dirty mark (`form.dataset.dirty`) that `beforeunload` (2023-2032) and the in-page leave dialog (2200-2295) read. Two listeners clear it for an htmx form, and neither looks at the outcome: - `app.js:1987-1993`, the `submit` listener, deletes the mark for every submitted form. For a form with `hx-post`, htmx cancels the native submission but the event still bubbles to the document, so the mark is gone as the request leaves, before any answer. - `app.js:1996-2001`, `htmx:afterRequest`, deletes it again for `event.target.closest("form")`. In the vendored htmx 2.0.10 (`node_modules/htmx.org/dist/htmx.js:4581, 4608, 4615, 4622`) `afterRequest` is raised after a 4xx or 5xx (which `responseHandling` does not swap, lines 264-268), after a network error, an abort and a timeout, as well as after success. So when the server refuses or the network drops, the form stays on the page with the person's text in it, the alert says the request failed, and the next link click or tab close leaves without asking. (In passing: the comment at `app.js:221-223` says `afterRequest` is not raised when the request never reached the server; in htmx 2.0.10 it is. Harmless there, since the code listens to all four events, but the comment should be corrected.) Guarded forms (`method="post"`, no `data-no-guard`, not an autosubmit control) that post through htmx, found by scanning every template for a `<form>` with `method="post"` and an `hx-post`: | template | form | what can be typed | | --- | --- | --- | | `applications/partials/event_form.html:11` | *Add to the timeline* (`hx-target="#event-form"`, outerHTML) | kind, date, summary, notes | | `applications/partials/status_card.html:47` | the status card on an application | status and its fields | | `cotton/dialog.html:40` | any `<c-dialog action=... live>`, `hx-swap="none"` (*Your details* row removals) | whatever the dialog body holds; usually a confirmation | | `cotton/reminder_actions.html:37, 61, 70` | complete, snooze, delete a reminder, when `target` is set | buttons only | | `jobs/company_form.html:41` | the logo dialog's *Find logo* | a button only | `core/partials/theme_switch.html:9` is excluded by `data-theme-switch`; `partials/table/cell.html:27`, the cell editor, has no `method` and so is never guarded (`markDirty`, 1950-1952), which is outside this issue. The first two rows are where typed text is at risk. ## What would change - The `submit` listener skips forms htmx sends (`hx-post`/`hx-get` on the form, or `event.defaultPrevented` after htmx's own listener), leaving their mark to the answer. - The `afterRequest` listener clears the mark only when `event.detail.successful` is true and the request was the form's own submission (`event.detail.elt` is the form, or its submitter). On a failure the mark stays, so leaving asks. - A form that a successful answer swaps out is replaced by a fresh one with no mark; one answered with `hx-swap="none"` (the live dialog) is cleared by the success branch. A refused save that the server redraws (a 2xx with errors, as `event_form` does) arrives as a new form element holding the typed values, unmarked, which matches the full-page behaviour where a refused save is a new page. - An e2e test: type into *Add to the timeline*, make the post fail (route abort in Playwright), click a sidebar link, expect the leave dialog. ## What it saves or protects reliability: text typed into the timeline form (summary and notes), a status note or a dialog is no longer lost without a question after a request that failed, timed out or was refused ## Accessibility must be checked: the leave question must still come in Postulo's own dialog with focus handed back on *Cancel* (#657), the failure must still be announced by the page's `role="alert"` region (`data-htmx-alert`, `static/js/app.js:235-257`), and a refused save that the server redraws must not ask twice. Guarded today by `tests/e2e/test_unsaved_work.py` (full-page forms only, no htmx-posted one) and `tests/test_page_script.py:85-87` (the failure listeners exist); a new e2e case is needed for an htmx form whose request fails ## Decisions - **Keep the text guarded after a 4xx that htmx does not swap?** Recommended: yes; the person has not seen it saved, and the alert already says it failed. --- Found by the efficiency and reliability audit of 2026-10-30 (finding A11; index postulo/postulo#1108). Checked against `main` at `daee30b15` (2026-10-30).
tiagoagueda added this to the 0.6.0 milestone 2026-10-31 03:04:24 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#1156
No description provided.