Files
pl_mail/assets/request_errors.js
Paul LütznerandClaude Opus 5.5 8289891830 feat: failed requests raise a toast instead of breaking the page
A failed Turbo response used to be rendered: the error page drawn as
the page (or merged under it — a picked PDF avatar left plMail's 500
hanging off the settings screen), or "Content missing" in a frame.

- request_errors.js catches every Turbo visit, form and frame. A failed
  response is not rendered; the page stays as it was and an error toast
  says what happened. 422 (a form with its errors), 401/403 (the
  session guard) and elements marked data-turbo-errors="self" (the
  compose dock) are left alone. A network failure gets its own words;
  a frame response of the wrong shape is visited as a page.
- requestFailed() gives controllers' own fetch() calls the same toast.
  An audit of the 60 calls fixed the 22 files whose user-initiated
  requests failed silently (star, archive, labels, drag and drop, bulk
  actions, reorders, appearance save, undo/cancel send, and more), and
  left background polling and preferences quiet on purpose.
- Every request gets an 8-hex id: X-Request-Id on the response, `id`
  on its log entries, "Ref. …" in the toast with a copy button, and a
  Reference filter in Admin → Logs. A proxy's own id is kept.
- toast.js clones the server's toast partial, so a script's toast is the
  same pane and theme as a streamed one. The sync button uses it, and its
  hardcoded English is now translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 18:22:21 +02:00

133 lines
4.6 KiB
JavaScript

/**
* A request that fails says so in a toast, and leaves the page alone.
*
* WHY THIS EXISTS
* ───────────────
* Turbo's answer to a failed response is to RENDER it. For a page visit or a
* form that is the error page drawn as the new page — and when the page Turbo
* was on and the error page it drew got merged, the settings screen came back
* with plMail's 500 page hanging off the bottom of it, which is how a picked
* PDF avatar was reported. A frame whose response is an error gets Turbo's
* "Content missing" written into it instead. Neither says what happened, and
* both break the screen the user was using.
*
* So every Turbo fetch passes through here first. A response that failed is
* not rendered: the page stays exactly as it was, and a toast says what went
* wrong, with the request's reference (RequestIdSubscriber) so the entry can
* be found in the admin log browser.
*
* WHAT IS LEFT ALONE
* ──────────────────
* - 422 — "here is the form again, with what is wrong on it". Turbo renders it
* and should; it is how every form in the app answers a refusal.
* - 401 and 403 — the session guard's (core--session-guard), and the
* controllers that asked have something more specific to say.
* - anything inside `[data-turbo-errors="self"]` — an element whose own
* controller explains its failures in place (the compose dock does). The
* attribute is the opt-out, and it is spelled out on the element rather than
* decided here, so the next one is a line of markup.
*
* `fetch()` calls a controller makes itself do not pass through Turbo; those
* report failures with requestFailed() below.
*/
import { showToast } from "./toast.js";
const LEFT_TO_RENDER = new Set([401, 403, 422]);
function words() {
return document.getElementById("toast-template-error")?.dataset ?? {};
}
function ownsItsErrors(target) {
return target instanceof Element && null !== target.closest('[data-turbo-errors="self"]');
}
/** The sentence for a status, or for no response at all (status 0). */
function messageFor(status) {
const w = words();
if (0 === status) {
return w.requestErrorOffline ?? "";
}
if (404 === status || 410 === status) {
return w.requestErrorNotFound ?? "";
}
if (status >= 500) {
return w.requestErrorServer ?? "";
}
return w.requestErrorRefused ?? "";
}
/**
* Tell the user a request failed.
*
* For a controller's own `fetch()`: pass the Response (or nothing, when the
* network never answered). Answers true when there was a failure to report.
*/
export function requestFailed(response = null) {
if (null !== response && true === response.ok) {
return false;
}
const status = null === response ? 0 : response.status;
const id = response?.headers?.get("X-Request-Id") ?? null;
showToast(messageFor(status), {
type: "error",
reference: null === id ? null : {
id,
label: words().requestErrorReference ?? "%id%",
copyLabel: words().requestErrorCopy ?? "",
},
});
return true;
}
document.addEventListener("turbo:before-fetch-response", (event) => {
const fetchResponse = event.detail?.fetchResponse;
if (!fetchResponse || true === fetchResponse.succeeded || true === event.defaultPrevented) {
return;
}
if (LEFT_TO_RENDER.has(fetchResponse.statusCode) || ownsItsErrors(event.target)) {
return;
}
event.preventDefault();
requestFailed(fetchResponse.response);
});
// The network never answered: no response to render, and Turbo's own reaction
// is a console error and a page that simply did not change.
document.addEventListener("turbo:fetch-request-error", (event) => {
if (ownsItsErrors(event.target)) {
return;
}
requestFailed(null);
});
// A frame whose response did not carry it. Turbo would write "Content
// missing" into the frame and throw. After the guard above, a response that
// arrives here and succeeded is a page of the wrong shape — a redirect to
// somewhere else — and is visited as the page it is, which is Turbo's own
// advice; one that failed is reported like any other.
document.addEventListener("turbo:frame-missing", (event) => {
if (ownsItsErrors(event.target)) {
return;
}
event.preventDefault();
const response = event.detail?.response ?? null;
if (null !== response && true === response.ok && "function" === typeof event.detail?.visit) {
event.detail.visit(response);
return;
}
requestFailed(response);
});