Investigation verdict: - HTMX does NOT swap the FilePond container on the edit page; the htmx:targetError in the crash log is unrelated noise - The pre-destroy abort in destroyFilePondsIn has a wrong status check (filters for nonexistent status 4, misses status 7 LOADING) but is moot because no HTMX swap targets the FilePond container - The load-file-error -> DID_THROW_ITEM_INVALID path is vulnerable (passes t.status directly, unlike every other error handler which wraps it), but for local files the LOAD_FILE plugins always wrap rejections properly - Likely actual trigger: Firefox XHR abort edge case in server.load for the existing cover file, racing with addition of a new local file that replaces it in the single-file queue
14 KiB
HTMX/destroy race hypothesis — investigation report
HTMX destroy triggers
What fires destroy() and when, relative to HTMX swap lifecycle
The only code path that destroys FilePond instances is destroyFilePondsIn(el), called by the htmx:beforeSwap listener:
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
// → onHtmxBeforeSwap(evt) { destroyFilePondsIn(evt.detail.target); }
On the edit page (/admin/edit.php), the following HTMX targets exist on page load:
| Element | Trigger | Target selector | Scope |
|---|---|---|---|
#toast-region |
load |
#toast-region |
Footer <aside> |
.licence-license-choice (hidden input) |
load |
.licence-license-choice |
Inside licence fieldset |
| Language checkboxes | change |
#languages-required-asterisk |
A <span> |
| File browser buttons | click |
#relink-modal-body |
Modal dialog body |
| Jury autocomplete | change |
various small targets | Form fields |
| Tag search input | input |
pill list container | Form field |
| Licence radio buttons | change |
.licence-license-choice |
Inside licence fieldset |
None of these targets are ancestors of the #format-fichiers-block div, which contains all FilePond inputs including the cover queue. Every HTMX swap target is either:
- A sibling fieldset (licence), or
- A small DOM fragment inside a fieldset (language asterisk), or
- The toast region in the footer, or
- A modal dialog body
Therefore: no HTMX swap on the edit page can trigger destroyFilePondsIn on the FilePond container during normal operation.
The htmx:targetError in the crash log is confirmed to be unrelated noise. htmx fires targetError when a response targets a missing element. On targetError, htmx does not fire htmx:beforeSwap — no DOM swap occurs. The error most likely originates from the toast-region's load-triggered request if the toast region is somehow malformed, or from an internal htmx processing issue unrelated to FilePond.
Verdict: HTMX does not swap the FilePond container. The race hypothesis as stated is refuted.
In-flight state at file-pick time
Is any HTMX request in flight when the crash occurs?
On the edit page at file-pick time, the toast-region's hx-get="/admin/toast-fragment.php" fires on page load but completes quickly (sub-second HTTP request returning 204 when empty, or a small HTML fragment). By the time a human user clicks "Parcourir" and selects a file, this request has long completed.
Other HTMX triggers (change, click) require explicit user interaction and are not triggered by file selection. The browser's native file picker dialog is modal and blocks the main thread while open, preventing any HTMX polling or background requests from completing during the dialog interaction.
Conclusion: no HTMX request is in flight when the user selects a file.
znunoqpw abort analysis
Does the existing abort resolve or reject the filter chain promise?
Commit znunoqpw added a pre-destroy abort in destroyFilePondsIn:
var files = pond.getFiles();
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.status === 4 || f.status === 2 || f.status === 3) {
try { pond.removeFile(f); } catch (_abort) {}
}
}
pond.destroy();
The status check is incorrect. FilePond 4.32.12 internal status constants are:
INIT: 1,IDLE: 2,PROCESSING: 3,PROCESSING_COMPLETE: 5,LOADING: 7,LOAD_ERROR: 8,PROCESSING_QUEUED: 9
The check f.status === 4 || f.status === 2 || f.status === 3 catches:
- Status
2= IDLE (already idle, no-op) - Status
3= PROCESSING (upload in progress) - Status
4— does not exist in FilePond 4.32.12
Status 7 (LOADING) — which is the state of a file in the LOAD_FILE filter chain — is not caught. Therefore, for a file being loaded (after blob read, during LOAD_FILE validation), removeFile is never called.
pond.destroy() then calls ABORT_ALL, which freezes items and calls abortLoad():
ABORT_ALL: function() {
Pe(n.items).forEach(function(e) {
e.freeze();
e.abortLoad();
e.abortProcessing();
});
}
abortLoad() checks i.activeLoader:
abortLoad: function() {
i.activeLoader ? i.activeLoader.abort() : (u(Ie.INIT), l("load-abort"));
}
Critical finding: activeLoader is set to null before the LOAD_FILE filter chain starts (inside the loader's "load" event handler). So when abortLoad() is called during the LOAD_FILE chain phase, activeLoader is already null → the ELSE branch runs → status is set to INIT and load-abort event fires.
The LOAD_FILE filter chain Promise continues running because JavaScript Promises are independent of the item lifecycle. However, the item is now frozen (i.frozen = true), which gates the event dispatcher:
l = function(e) {
if (!i.released && !i.frozen) {
f.fire.apply(f, [e].concat(n));
}
};
When the LOAD_FILE filter chain eventually resolves or rejects, its .then() or .catch() callbacks execute, but they call l(...) which is suppressed by the freeze gate. No events reach the dispatch system after freeze.
Therefore: the abort mechanism does NOT cancel the LOAD_FILE filter chain, but it DOES prevent its results from dispatching events. The chain is orphaned (still resolves/rejects in memory) but cannot cause a crash because its event callbacks are gated.
However: znunoqpw's abort only helps when destroyFilePondsIn is actually called. Since HTMX never swaps the FilePond container (see above), destroyFilePondsIn is never called during the standard reproduction. The abort mechanism is not exercised in the crash scenario.
Line 6878 catch reachability
Can load-file-error fire during destroy, and with what value of e.status?
Two code paths dispatch DID_THROW_ITEM_INVALID, which reaches the file-status view writer Wt:
// file-status view writer (minified equivalent of line 7847)
Wt = function(e) {
var t = e.root, n = e.action;
Nt(t.ref.main, n.status.main); // ← crashes if n.status is undefined
Nt(t.ref.sub, n.status.sub);
};
Path A: load-request-error → SAFE
Dispatched by the loader's error event (XHR onerror). The handler PROPERLY wraps the rejection:
v.on("load-request-error", function(t) {
var r = Dt(n.options.labelFileLoadError)(t);
if (t.code >= 400 && t.code < 500)
return e("DID_THROW_ITEM_INVALID", {
id: h, error: t,
status: { main: r, sub: t.code + " (" + t.body + ")" } // ✅ wrapped
});
e("DID_THROW_ITEM_LOAD_ERROR", {
id: h, error: t,
status: { main: r, sub: n.options.labelTapToRetry } // ✅ wrapped
});
});
Both branches create status: { main, sub } objects. No crash possible from this path.
Path B: load-file-error → VULNERABLE
Dispatched by the LOAD_FILE filter chain rejection. The handler passes t.status directly, without wrapping:
v.on("load-file-error", function(t) {
e("DID_THROW_ITEM_INVALID", {
id: h,
error: t.status, // t.status passed through directly
status: t.status, // ← crash if t.status is undefined
});
f({ error: t.status, file: Te(v) });
});
For local files, the LOAD_FILE chain processes the File/Blob object. The only registered LOAD_FILE filters are FileValidateType and FileValidateSize. Both plugins reject with:
{ status: { main: "label...", sub: "details..." } }
This produces a valid t.status = { main, sub } — no crash on normal local file selection.
For server-loaded files (existing DB files), the Ot XHR loader creates createResponse objects with .code (HTTP status), not .status. When the server returns an error blob (e.g., HTML error page on 404):
- XHR
onloadfires →t(ot("load", status, blob, headers))→createResponseobject with.code, no.status - Loader's "load" event fires →
serverFileReferencenot set (meta handler didn't set it for the error response) elsebranch runs → LOAD_FILE filter chain receives thecreateResponseobject (withtype: "load")- FileValidateType rejects because
"load"is not a recognized MIME type → error callback →load-file-error - But FileValidateType rejects with
{ status: { main, sub } }— a properly wrapped object t.statusIS{ main, sub }→ no crash
Caveat with znunoqpw: After znunoqpw, server.load is configured with custom onload/onerror handlers. The custom onerror returns a string, which the ut factory routes to the loader's error callback → load-request-error → SAFE. The custom onload returns the blob directly, which bypasses the createResponse wrapping path for successful loads.
Can it fire during destroy?
During pond.destroy() → ABORT_ALL → items frozen → event dispatch gated. Even if the LOAD_FILE chain completes after destroy, events are suppressed. The crash cannot fire after destroy() is called.
Does the .catch handler at line 6878 fire?
The .catch handler on the item's before-add validation chain:
.catch(function(t) {
if (!t || !t.error || !t.status) return r(!1); // guarded ✅
e("DID_THROW_ITEM_INVALID", {id: h, error: t.error, status: t.status});
})
Has an explicit guard and returns early on missing status. Cannot crash.
Verdict
HTMX race hypothesis: REFUTED
The hypothesis that HTMX swaps out the DOM containing the FilePond instance while a LOAD_FILE filter chain is in flight is not supported by the evidence:
- No HTMX swap targets the FilePond container on the edit page — every HTMX target is a sibling or distant element
htmx:targetErrorfires without triggeringhtmx:beforeSwap, so even if it fires, it cannot trigger destroy- Even if a destroy were triggered, the freeze mechanism prevents events from reaching the dispatch system
- The abort in
znunoqpwis insufficient but also unnecessary — the event gate alone prevents the crash after destroy
Actual crash cause: INDETERMINATE (but narrowed)
The only vulnerable code path is load-file-error → DID_THROW_ITEM_INVALID with status: undefined reaching the file-status view writer Wt. For the standard LOCAL file selection reproduction, I cannot identify how status becomes undefined:
- LOAD_FILE plugin rejections are properly wrapped with
{ status: { main, sub } } - Server-load errors go through
load-request-errorwhich wraps properly - No
createResponseobjects (with.codeinstead of.status) enter the LOAD_FILE chain for local files - The before-add catch handler is guarded
However, the vulnerability is real and the crash is reproducible. The most likely explanation is a race between an existing file's server.load XHR completing at the same moment a new file is added (replacing the existing one in a single-file queue). When the existing file's server.load XHR completes with an error AFTER the item has been removed but BEFORE it is frozen, the load-file-error event fires.
The server.load for the existing cover file returns a createResponse object (with .code, no .status). If serverFileReference is NOT set by the meta handler (which happens for error responses), the loader's "load" handler routes the createResponse to the LOAD_FILE filter chain. The FileValidateType filter rejects it because the type is "load" (not a MIME type), but wraps it with {status: {main, sub}} → safe.
However, if the existing file's server.load request is ABORTED (by the removal of the existing file when a new one is added), Firefox may fire XHR onload with status: 0 or a malformed response. If We(n.response, name) throws (because n.response is null), the exception propagates as an unhandled error, not through the normal filter rejection path.
This is the most likely trigger for the crash — a Firefox-specific XHR abort edge case in the server.load path for the existing cover file, racing with the addition of a new local file.
Recommended next step
Add targeted console.log instrumentation to file-upload-filepond.js at the server.load object to determine whether the crash correlates with an in-flight server.load request:
// In buildServerConfig(), inside the load: { ... } block:
load: {
url: `${base}/load.php?id=`,
method: "GET",
onload: (response) => {
console.log("[filepond:diag] server.load onload | response type=" + (response ? response.constructor.name : "null") + " | size=" + (response ? response.size : 0));
return response;
},
onerror: (response) => {
var body = typeof response === 'string' ? response : (response && response.body ? response.body : String(response || ''));
console.error("[filepond:diag] server.load onerror | body=" + body + " | raw type=" + typeof response);
return body || "Fichier introuvable.";
},
},
Additionally, add a global listener to trap the crash before it propagates:
document.addEventListener("FilePond:error", (e) => {
if (!e.detail || !e.detail.error) {
console.error("[filepond:diag] FilePond:error with null/undefined error detail", e.detail);
}
});
window.addEventListener("error", (e) => {
if (e.filename && e.filename.includes("filepond")) {
console.error("[filepond:diag] UNCAUGHT error from filepond", {
message: e.message,
lineno: e.lineno,
colno: e.colno,
stack: e.error ? e.error.stack : null,
});
}
});
Then reproduce with just dev in Firefox. If the diagnostic logs show server.load onload firing immediately before the crash, the race theory is confirmed and the fix is to replace server.load with a custom fetch-based function (Option B from the crash analysis) that never routes server responses through the LOAD_FILE filter chain.