15 KiB
FilePond crash analysis — TFE upload forms
Status: unresolved — analysis complete, root cause identified in vendor code.
Hand this doc (and the whole repo) to another agent for implementing the fix.
Errors observed (Firefox, dev server 127.0.0.1:8000)
Trigger: adding an image to "Image de couverture" (cover queue) or any TFE file upload form.
InstallTrigger is deprecated and will be removed in the future. content.js:1
Failed to execute 'postMessage' on 'DOMWindow': target origin mismatch 2 20260609-...
htmx:targetError htmx.min.js:1
Uncaught TypeError: can't access property "main", n.status is undefined filepond.min.js:9
Wt filepond.min.js:9
A filepond.min.js:9
A filepond.min.js:9
_write filepond.min.js:9 (×16)
<anonymous> filepond.min.js:9
<anonymous> filepond.min.js:9
e filepond.min.js:9
e filepond.min.js:9
u filepond.min.js:9 (× many — retry loop)
e filepond.min.js:9
u ...
[filepond:event] error Object { pond: {…}, error: null, file: {…} } file-upload-filepond.js:587
Rows 1–3 are noise: InstallTrigger/postMessage are Firefox internals; htmx:targetError is an unrelated HTMX issue. The real crash is rows 4+.
Root cause
The crash
At filepond.min.js:9:60852, FilePond 4.32.12 crashes inside its view system's _write method. Two view writers dereference action.status.main:
FilePond unminified (file-status view), line 7847:
var error = function error(_ref8) {
var root = _ref8.root,
action = _ref8.action;
text(root.ref.main, action.status.main); // ← crashes if action.status is undefined
text(root.ref.sub, action.status.sub);
};
FilePond unminified (assistant view), line 10735:
var itemError = function itemError(_ref6) {
var root = _ref6.root,
action = _ref6.action;
var item = root.query('GET_ITEM', action.id);
var filename = item.filename;
assist(root, action.status.main + ' ' + filename + ' ' + action.status.sub);
};
Neither function guards against action.status === undefined.
How action.status becomes undefined
FilePond's internal response objects use the property name code, not status:
// line 4700
var createResponse = function createResponse(type, code, body, headers) {
return {
type: type,
code: code, // ← "code", not "status"
body: body,
headers: headers,
};
};
But the load-file-error event handler accesses error.status:
// line 6777-6784
item.on('load-file-error', function(error) {
dispatch('DID_THROW_ITEM_INVALID', {
id: id,
error: error.status, // ← .status is undefined on createResponse objects!
status: error.status, // ← dispatches undefined as the status
});
failure({ error: error.status, file: createItemAPI(item) });
});
Because the error object has .code (not .status), both error: error.status and status: error.status are undefined. When the dispatched action reaches the view writer, action.status is undefined → crash.
When does load-file-error fire?
The load-file-error event is emitted in the item _load method when the LOAD_FILE filter chain rejects:
// line 5855-5863
loader.on('load', function(file) {
var error = function error(result) {
state.file = file;
fire('load-meta');
setStatus(ItemStatus.LOAD_ERROR);
fire('load-file-error', result); // ← fires when filter chain rejects
};
if (state.serverFileReference) {
success(file); // ← existing files take this safe path
return;
}
onload(file, success, error); // ← new files take this path
});
For existing DB files (edit mode), state.serverFileReference is set → success(file) is called directly → load-file-error never fires.
For newly added files (no serverId yet), onload(file, success, error) runs the LOAD_FILE filter chain. The FilePond FileValidateType plugin (v1.2.8) registers a LOAD_FILE filter:
// plugin line 132
addFilter('LOAD_FILE', function(file, _ref3) {
// ...
var handleRejection = function handleRejection() {
reject({
status: { main: '...', sub: '...' } // ← plugin rejects with proper status object
});
};
// ...
});
The plugin rejects with { status: { main, sub } }. This is CORRECT. The rejected value flows into the error(result) callback → result.status IS { main, sub }. So when load-file-error fires from a plugin rejection, error.status is actually a proper object, NOT undefined. This particular path is safe.
However, load-file-error can also fire from the DID_LOAD_ITEM filter chain catch handler at line 6878:
.catch(function(e) {
if (!e || !e.error || !e.status) return handleAdd(false);
dispatch('DID_THROW_ITEM_INVALID', {
id: id,
error: e.error,
status: e.status, // ← e.status could be anything
});
});
This dispatches directly to DID_THROW_ITEM_INVALID (bypassing load-file-error), but it still copies e.status into the action. If e.status is undefined or not an object with main/sub, same crash.
How raw createResponse objects reach load-file-error
There IS one path where a raw createResponse object (with .code, no .status) reaches load-file-error:
When the server returns an HTTP error for a load request but the XHR onload handler treats it as success:
// line 4652
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
api.onload(xhr); // → blob processed
} else {
api.onerror(xhr); // → error callback
}
};
If xhr.status is 0 (aborted XHR), it goes to api.onerror. But if the XHR is in some intermediate state, or if there's a race, it might not reach either path cleanly. Firefox's behavior with aborted XHRs and responseType: 'blob' can produce edge cases where xhr.response is a malformed blob and FilePond's blob processing triggers internal errors that propagate differently.
Summary of the bug
| Component | Issue |
|---|---|
createResponse() (line 4700) |
Uses .code, not .status |
load-file-error handler (line 6777) |
Reads .status on a createResponse object → undefined |
| Error view writer (line 7847) | No guard: crashes on undefined.status.main |
| Assistant view writer (line 10735) | Same: crashes on undefined.status.main |
The bug is in FilePond 4.32.12 vendor code. We cannot modify filepond.min.js.
Proposed fix
Option A: Patch the minified JS (risky but direct)
Find the load-file-error → DID_THROW_ITEM_INVALID dispatch in filepond.min.js and add a guard. Difficult because the code is minified and version-pinned via cache-busting query params.
Option B: Replace server.load with a custom function (cleanest)
In file-upload-filepond.js, replace the server.load URL string with a custom function that:
- Makes its own
fetch/XHR to load.php - On success: calls
load(blob)— safe becauseserverFileReferenceis set for existing files - On error: calls
error('message')— safe because this goes throughload-request-error(NOTload-file-error) which properly creates{ status: { main, sub } } - Never lets FilePond's internal
createFetchFunctioncreate a createResponse object with.code
This completely bypasses the buggy code path.
load: function(source, load, error, progress, abort, headers) {
var xhr = new XMLHttpRequest();
var url = base + '/load.php?id=' + encodeURIComponent(source);
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
load(xhr.response);
} else {
error('Fichier introuvable (HTTP ' + xhr.status + ')');
}
};
xhr.onerror = function() {
error('Erreur réseau');
};
xhr.onabort = abort;
xhr.onprogress = function(e) {
if (e.lengthComputable) progress(e.lengthComputable, e.loaded, e.total);
};
xhr.send();
return { abort: function() { xhr.abort(); } };
},
Option C: Abort in-flight loads before destroying (defense in depth)
The destroyFilePondsIn() function in file-upload-filepond.js should abort in-flight loads/processing before calling pond.destroy(). Already partially attempted in commit znunoqpw but needs clean implementation.
Files involved
| File | Role |
|---|---|
app/public/assets/js/vendor/filepond.min.js |
FilePond 4.32.12 — contains the bug (unmodifiable) |
app/public/assets/js/app/file-upload-filepond.js |
Our FilePond wrapper — where the fix goes |
app/src/FilepondHandler.php |
Server-side FilePond endpoints (process, load, revert, remove) |
app/public/admin/actions/filepond/load.php |
Admin load endpoint |
app/public/admin/actions/filepond/process.php |
Admin process endpoint |
app/public/partage/actions/filepond/load.php |
Partage load endpoint |
app/public/partage/actions/filepond/process.php |
Partage process endpoint |
Reproduction
just dev(PHP dev server on 127.0.0.1:8000)- Open Firefox (Firefox triggers this more readily than Chromium due to different XHR abort behavior)
- Go to
/admin/edit.php?id=<any>or/admin/add.php - Click "Parcourir" on the "Image de couverture" FilePond input
- Select an image file → crash in console
- Or: drag a file to the "TFE" FilePond input → same crash if the load fails or races with HTMX swaps
What commit znunoqpw already did (insufficient)
- Added
Content-Type: text/plainheaders to all FilepondHandler error responses - Fixed
server.process.onerrorto not access.statuson a string - Converted
server.loadfrom a URL string to an object with onload/onerror - Added pre-destroy abort in
destroyFilePondsIn()
These changes address server response format and cleanup ordering, but do not bypass the buggy load-file-error → action.status path inside FilePond's internal code. The crash still reproduces.
HTMX/destroy race investigation (merged from filepond-race-investigation.md)
This section narrows the crash's trigger. It was formerly a separate doc
(filepond-race-investigation.md).
HTMX destroy triggers
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 HTMX targets on page load are:
| Element | Trigger | Target selector | Scope |
|---|---|---|---|
#toast-region |
load |
#toast-region |
Footer <aside> |
.licence-license-choice (hidden input) |
load |
.licence-license-choice |
Licence fieldset |
| Language checkboxes | change |
#languages-required-asterisk |
A <span> |
| File browser buttons | click |
#relink-modal-body |
Modal body |
| Jury autocomplete | change |
small targets | Form field |
| Tag search input | input |
pill list container | Form field |
| Licence radio buttons | change |
.licence-license-choice |
Licence fieldset |
None of these targets are ancestors of the #format-fichiers-block div
(which contains all FilePond inputs including the cover queue). 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 noise: targetError does
not fire htmx:beforeSwap, so no DOM swap occurs.
Verdict: HTMX does NOT swap the FilePond container. The race hypothesis as stated is refuted.
In-flight state at file-pick time
No HTMX request is in flight when the crash occurs: the toast-region's
hx-get completes quickly (sub-second, 204 or small fragment) long before a
human clicks "Parcourir" and selects a file. Other triggers require explicit
user interaction; the native file picker is modal and blocks the main thread.
znunoqpw abort analysis
Commit znunoqpw added a pre-destroy abort in destroyFilePondsIn:
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 catches 2 (IDLE, no-op),
3 (PROCESSING), and 4 (does not exist). Status 7 (LOADING) is not
caught, so a file in the LOAD_FILE filter chain is never removed.
However, pond.destroy() → ABORT_ALL freezes items and calls abortLoad().
Since activeLoader is null during the LOAD_FILE chain, the else branch sets
status INIT + fires load-abort; the chain Promise still runs but the freeze
gate (i.frozen) suppresses event dispatch. So the abort mechanism prevents
the crash after destroy, but only when destroyFilePondsIn is actually called
— which it never is in the standard repro (HTMX never swaps the container).
Line 6878 catch reachability
Two paths dispatch DID_THROW_ITEM_INVALID to the file-status view writer:
Wt = function(e) {
var t = e.root, n = e.action;
Nt(t.ref.main, n.status.main); // ← crashes if n.status undefined
Nt(t.ref.sub, n.status.sub);
};
- Path A —
load-request-error→ SAFE. Both branches wrap rejection in{ status: { main, sub } }. No crash. - Path B —
load-file-error→ VULNERABLE. Passest.statusthrough unguarded. For local files, LOAD_FILE plugins (FileValidateType/FileValidateSize) reject with a proper{ status: { main, sub } }. For server-loaded files with error responses,createResponsehas.codenot.status, but the FileValidateType filter still wraps its rejection correctly → still safe. .catchhandler (line 6878) has an explicit!t.statusguard → cannot crash.
Verdict
- HTMX race hypothesis: REFUTED (no swap targets the FilePond container; freeze gate prevents post-destroy dispatch).
- Actual crash cause: INDETERMINATE (but narrowed). The only vulnerable path
is
load-file-error→DID_THROW_ITEM_INVALIDwithstatus: undefined. For local file selection, the exact path toundefinedstatus isn't identified. The most likely trigger is a Firefox-specific XHR abort edge case in the existing cover file'sserver.load, racing with adding a new local file.
Recommended next step
Add console.log instrumentation to server.load's onload/onerror and a global
FilePond:error / window.error trap, then reproduce in Firefox. If
server.load onload fires immediately before the crash, the race is confirmed
and the fix is Option B (custom fetch-based server.load that never
routes server responses through the LOAD_FILE filter chain).