Files
xamxam/docs/filepond-race-investigation.md
Pontoporeia 6d93199fa2 docs: HTMX/destroy race hypothesis investigation — REFUTED
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
2026-06-10 00:18:49 +02:00

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 4does 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):

  1. XHR onload fires → t(ot("load", status, blob, headers))createResponse object with .code, no .status
  2. Loader's "load" event fires → serverFileReference not set (meta handler didn't set it for the error response)
  3. else branch runs → LOAD_FILE filter chain receives the createResponse object (with type: "load")
  4. FileValidateType rejects because "load" is not a recognized MIME type → error callback → load-file-error
  5. But FileValidateType rejects with { status: { main, sub } } — a properly wrapped object
  6. t.status IS { 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:

  1. No HTMX swap targets the FilePond container on the edit page — every HTMX target is a sibling or distant element
  2. htmx:targetError fires without triggering htmx:beforeSwap, so even if it fires, it cannot trigger destroy
  3. Even if a destroy were triggered, the freeze mechanism prevents events from reaching the dispatch system
  4. The abort in znunoqpw is 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-errorDID_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-error which wraps properly
  • No createResponse objects (with .code instead 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.


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.