fix(asks): one submit saves every ask on the page
Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.
Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.
- embed.js (verbatim reports): reloads only when nothing was refused and
nothing of ours is dirty. Otherwise a server-rendered status line in the
submit block says what did not save, and input stays. A form the server
took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
batch never reloads. Only forms the server took count as sent. In-flight
state and "just sent" are keyed by form identity (formKey) plus the fields
at the press, not the DOM node.
Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
This commit is contained in:
@@ -215,11 +215,77 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
|
||||
by the pick or form it holds. A flag that adds a tray row above a draft
|
||||
must not move the draft into the wrong box. The form just sent is the
|
||||
exception: its fields come back as the server rendered them, and its
|
||||
disclosure comes back folded.
|
||||
disclosure comes back folded — UNLESS it changed after the press, when it
|
||||
carries like any unsent form (amended 2026-09-27; a pick changed
|
||||
mid-flight came back as the saved copy of the earlier one). "Just sent"
|
||||
is the form's IDENTITY plus its fields as they stood at the press, never
|
||||
the DOM node: a queued save whose node an earlier swap replaced is still
|
||||
recognised, where a node test missed it and carried a saved note's text
|
||||
back as a draft.
|
||||
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
|
||||
before the next begins, so an older snapshot never lands after a newer one
|
||||
(three quick flags show three flags). A form already queued or in flight
|
||||
ignores another submit: a double-click writes one note, not two.
|
||||
ignores another submit: a double-click writes one note, not two. "In flight"
|
||||
is keyed by the form's IDENTITY (the same action + hidden-field key the
|
||||
carry uses), never marked on the DOM node, because a queued save's swap
|
||||
replaces the node with a fresh copy (amended 2026-09-27).
|
||||
3a. **Several picks at once (amended 2026-09-27).** A pick form is a
|
||||
`data-inplace` form carrying a hidden `ask` field. It is **dirty** when any
|
||||
control in it differs from its server-rendered default (`checked` vs
|
||||
`defaultChecked`, `value` vs `defaultValue`). A submit on a pick form while
|
||||
ANOTHER pick form of the same action is dirty sends every dirty pick form
|
||||
NOT already in flight — the pressed one only if it is dirty.
|
||||
- A form in flight still counts as "another dirty form", so a press on a
|
||||
clean pick during a batch is an empty batch: a no-op, never the blank
|
||||
one-form POST whose 400 would take step 4 mid-save.
|
||||
- One POST per form, to its own action, with its own fields read at the
|
||||
moment of the press, one after another in DOCUMENT ORDER of the forms. A
|
||||
refused POST does not stop the ones after it.
|
||||
- None refused: ONE GET and ONE swap, in which every form sent counts as
|
||||
"the form just sent": its fields come back as the server rendered them,
|
||||
its disclosure folded.
|
||||
- Any refused: ONE GET and ONE swap in which only the forms the server
|
||||
TOOK count as sent, so everything else carries by identity — the refused
|
||||
pick's own input and any draft on the page included — then the status
|
||||
line says how many saved and names each pick that did not, with the
|
||||
reason. (Nothing saved at all: no GET, just the words.) A pick withdrawn
|
||||
under the page has no form in the fresh page to carry into; the reason
|
||||
says so. This is the same rule as the verbatim half: a refusal never
|
||||
clears what the operator entered.
|
||||
- **A batch never reloads.** Where step 2 would reload — a failed GET, or a
|
||||
fresh page whose structure changed — a batch says so in the status line
|
||||
("reload to see it") and keeps the page, because a reload would take
|
||||
every unsent draft with it. Step 4's say-and-reload stays the one-form
|
||||
path's alone.
|
||||
- The status line is cleared when the next save starts, so a "not saved"
|
||||
never outlives the save that fixes it.
|
||||
- With no other dirty pick form, the submit takes steps 1–3 exactly as
|
||||
before.
|
||||
|
||||
Why: C3 already CARRIED an unsent pick across another save, so it survived
|
||||
— but it was never SAVED, and pressing the submit of a BLANK pick got a
|
||||
400, whose failure reload wiped every one. The operator's report
|
||||
(2026-09-27, relayed by infra-ops): one submit on a page must save every
|
||||
answer he filled in. The verbatim half of the same fix is U3's "Submitting
|
||||
several asks at once"; the two surfaces share the dirty rule, the order and
|
||||
the refusal rule, and differ only where their machinery does (this one
|
||||
swaps in place; the verbatim page reloads when nothing is left unsaved).
|
||||
*Falsifiable* (`tests/mutations/r2_submit_all.toml`): ignore the other pick
|
||||
forms and `test_one_submit_on_the_marks_page_saves_every_changed_pick`
|
||||
fails; send the pressed form even when blank and
|
||||
`test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it` fails;
|
||||
stop at the first refusal, reload on one, or count a refused pick as sent,
|
||||
and `test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing`
|
||||
fails; stop counting a form in flight as dirty and
|
||||
`test_pressing_a_clean_pick_during_a_batch_sends_nothing` fails; key "in
|
||||
flight" on the DOM node and
|
||||
`test_a_pick_in_flight_stays_in_flight_across_another_saves_swap` fails;
|
||||
leave the status line up and `test_a_later_save_clears_a_stale_not_saved_line`
|
||||
fails; reload when the refresh fails and
|
||||
`test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form
|
||||
as sent after it changed and
|
||||
`test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no
|
||||
form as sent and `test_a_saved_notes_box_comes_back_empty` fails.
|
||||
4. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||
the judgment: a duplicate note, or a re-dated answer.
|
||||
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
||||
|
||||
@@ -235,6 +235,86 @@ It is three lines, it costs nothing, and the sensitivity floor of that probe is
|
||||
guards against is a form the operator fills in whose controls reach no form,
|
||||
so the button does nothing.
|
||||
|
||||
## Submitting several asks at once (amended 2026-09-27)
|
||||
|
||||
**The defect.** One pick is one `<form>` is one POST to `/answer`, and that
|
||||
POST 303s back to the page. On a report carrying several picks, a submit sent
|
||||
exactly ONE of them, and the reload that followed wiped every pick the operator
|
||||
had made in the others. His report, relayed by infra-ops: *"I go through, submit
|
||||
a question and it only submits the last one and clears out the top ones."*
|
||||
Confirmed against the live `auk-audition` booth (three single-question picks,
|
||||
no anchors, so all three in the tail) before any code: the access log shows one
|
||||
POST at 15:02:23 that saved the LAST pick on the page, the reload, then a 400
|
||||
four seconds later — the submit of a pick the reload had just blanked — and the
|
||||
other two re-answered one at a time. **The server is not the defect**: every
|
||||
POST did exactly what `/answer` promises. The page gave him one button per
|
||||
form and no way to send them together.
|
||||
|
||||
**The rule.** embed.js listens for `submit` on the forms IT mounted (never an
|
||||
author's form). A form is **dirty** when any control it owns — `form.elements`,
|
||||
which includes every control bound to it by `form=` wherever it sits — differs
|
||||
from its server-rendered default: a radio or checkbox whose `checked` differs
|
||||
from `defaultChecked`, a textarea or text input whose `value` differs from
|
||||
`defaultValue`.
|
||||
|
||||
```
|
||||
submit on one of our forms F:
|
||||
a batch is in flight -> preventDefault; nothing else (never the
|
||||
browser's POST racing the batch)
|
||||
no OTHER of our forms is dirty -> do nothing; the browser's own POST and 303,
|
||||
exactly as before this amendment
|
||||
otherwise -> preventDefault, and send EVERY dirty form of
|
||||
ours, F included only if F is dirty
|
||||
send: one POST per form, to that form's own action, carrying that form's own
|
||||
FormData read AT THE PRESS, with `Accept: application/json` (the
|
||||
route's 204, r2 C3), one after another, in DOCUMENT ORDER of the <form>
|
||||
elements. A refused form does not stop the ones after it. A form the
|
||||
server took (204) gets a new baseline: what it sent. From then on it is
|
||||
dirty only if it differs from THAT.
|
||||
then: no refusal AND nothing -> reload the page (a GET), so what shows is
|
||||
of ours is dirty the server's record
|
||||
otherwise -> NO reload. The pressed form's submit block
|
||||
(a refusal, or a change says how many saved and what did not, with
|
||||
made during the flight) the server's reason, and everything the
|
||||
operator entered stays on the page.
|
||||
A refusal blocks the reload ON ITS OWN: a refused form set back to its
|
||||
first value reads clean, and "nothing dirty" alone reloaded over it.
|
||||
```
|
||||
|
||||
Five consequences, each deliberate:
|
||||
|
||||
- **A blank pick is skipped, never refused.** A pick with nothing entered is not
|
||||
dirty and is not sent, so pressing its button no longer produces the 400 page
|
||||
the log shows. Blanks stay legal, as `build_answer` has held since 2026-09-09.
|
||||
- **A pick nobody touched is not re-sent — including the one whose button was
|
||||
pressed, and including one this page already saved.** Re-sending an answer
|
||||
re-dates it, and a reading session would see a fresh answer that nobody gave.
|
||||
The moved baseline is what keeps a retry after a partial refusal to exactly
|
||||
the picks that did not save; it also means correcting a saved pick back to
|
||||
its first-rendered value counts as a change and is sent.
|
||||
- **One refused pick costs only itself.** A pick withdrawn or re-declared while
|
||||
the page was open is refused (404 / 400); the rest are saved regardless. This
|
||||
is the partial-answer ruling's reasoning applied one level up: refusing
|
||||
everything because one was stale throws away the ones that were made.
|
||||
- **The page is reloaded only when nothing would be lost by it.** The page
|
||||
stays live while the batch is in flight; a pick made or a note typed in that
|
||||
window is unsaved input, and a reload would clear it — the exact loss this
|
||||
amendment exists to stop. So the reload waits on "every POST succeeded" AND
|
||||
"nothing of ours is dirty" — each on its own. The saved picks' tags stay stale until he
|
||||
reloads, and the message says so. Nothing is ever re-sent without a fresh
|
||||
press; a press after a lost response may re-send (and re-date) a pick that
|
||||
did land, which is the price of never retrying on our own.
|
||||
- **A press during the flight is ignored.** Checked before anything else, so a
|
||||
press on a form with no other dirty form beside it cannot fall through to the
|
||||
browser's POST while the batch runs.
|
||||
|
||||
**What it does not change.** `/answer`, its fields, its 204 and its 303 are
|
||||
untouched: the server has no batch endpoint and no new request shape. A page
|
||||
whose only dirty form is the pressed one gets the plain form submission,
|
||||
byte-identical to before. The status line is server-rendered, empty and
|
||||
`hidden` in the `submit` macro; the script only sets its text, so embed.js
|
||||
still renders no markup of an ask.
|
||||
|
||||
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
|
||||
before the first fragment of each pick so the chip has somewhere to jump. The
|
||||
fragments already carry ids; document order in a live DOM is directly queryable;
|
||||
@@ -336,6 +416,32 @@ rather than strict.
|
||||
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||
with 2 of 4 radio groups present.
|
||||
|
||||
**INV-8 — One submit saves every pick on the page the operator changed
|
||||
(amended 2026-09-27).** Per "Submitting several asks at once": every dirty form
|
||||
of ours is sent, in document order; a clean one never is, nor a saved one
|
||||
again; a refused one stops nothing; the page reloads only when nothing was
|
||||
refused and nothing of ours is left unsaved; a press during the flight is ignored; and with no other dirty
|
||||
form the submit is the browser's own.
|
||||
*Falsifiable*, one change per clause, each in `tests/mutations/u3_submit_all.toml`:
|
||||
send only the pressed form and
|
||||
`test_one_submit_saves_every_answered_ask_on_the_page` fails; send the pressed
|
||||
form whether or not it is dirty and `test_a_blank_ask_is_skipped_never_refused`
|
||||
fails; send every form regardless and `test_an_ask_nobody_touched_is_not_re_sent`
|
||||
fails; intercept a lone dirty form and `test_one_changed_ask_still_submits_as_a_plain_form`
|
||||
fails; send in reverse and `test_the_asks_are_sent_in_document_order` fails;
|
||||
stop at the first refusal, reload on one, or never show the status line, and
|
||||
`test_a_refused_ask_costs_only_itself_and_clears_nothing` fails; listen to every
|
||||
form on the page, or trust our id prefix without the mounted-root check, and
|
||||
`test_an_authors_own_form_is_never_taken_over` fails; let a press in flight fall
|
||||
through and `test_a_press_inside_the_flight_never_fires_a_native_post` fails;
|
||||
reload when every POST succeeded regardless of what changed meanwhile and
|
||||
`test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press` fails;
|
||||
leave a saved form's baseline where it was and
|
||||
`test_a_retry_after_a_refusal_sends_only_what_did_not_save` fails; drop the
|
||||
class-level `[hidden]` rule and `test_the_empty_status_line_stays_hidden_under_host_css`
|
||||
fails; let "nothing dirty" alone decide the reload and
|
||||
`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails.
|
||||
|
||||
## Out of scope (deferred or never)
|
||||
|
||||
Named so a reviewer does not read them as drift.
|
||||
|
||||
Reference in New Issue
Block a user