Inconsistent event ordering during model validation failure
0 reputation · 11 Oct 2021, 14:00 UTC
0 reputation · 11 Oct 2021, 14:00 UTC
In Backbone models, the validate callback is designed to intercept attribute changes during set() or save(). If the callback returns an object or throws an error, the model is supposed to prevent the update and trigger the error event.
However, there is technical ambiguity regarding the deterministic sequence of event firing when validation fails. While the attribute change is expected to be rolled back, the temporal relationship between the error event and any potential internal state changes or secondary listeners is not strictly defined across all browser environments in the Backbone 1.x branch.
This behavior creates race conditions when writing unit tests that rely on the state of the model immediately following a failed set operation.
error event guaranteed to fire before any change events if validation fails?validate function throws a exception exception versus returning an error object?34775 reputation · 11 Oct 2021, 17:14 UTC
1. Is the error event guaranteed to fire before any change events if validation fails? Yes. When validate returns an error object, Backbone aborts the attribute merge before entering the change-event loop. The error event fires, and no change, change:attribute, or change:* events are emitted.
2. Does the model remain entirely untouched if validate throws an exception versus returning an error object? Returning an error object leaves the model pristine—attributes are not merged. Throwing an exception is not guaranteed to trigger the error event; the exception may bubble out of set()/save() before Backbone’s internal error handler runs, leaving the model unchanged but without a predictable event signal.
Backbone’s set() (used by save()) follows this sequence:
validate(attrs, options).validate returns a truthy value, treat it as an error object, fire error, and return false without mutating this.attributes.validate throws, the exception escapes set() unless caught by a wrapper. Backbone 1.x does not wrap the call in a try/catch that converts thrown errors into the error event.change events.Therefore, no change events can fire when validation fails via return value. The race conditions in tests usually come from asynchronous save() wrappers (e.g., wait: true or custom success/error callbacks) rather than the synchronous validation path.
const Model = Backbone.Model.extend({
validate(attrs) {
if (attrs.forbidden) return 'forbidden value';
}
});
const m = new Model({a: 1});
m.on('error', (model, err) => console.log('error:', err));
m.on('change', () => console.log('change fired'));
m.on('change:a', () => console.log('change:a fired'));
console.log('before:', m.attributes);
const result = m.set({forbidden: true});
console.log('set returned:', result);
console.log('after:', m.attributes);
Expected output: error: forbidden value, set returned: false, model still {a: 1}, no change logs.
const ThrowModel = Backbone.Model.extend({
validate() { throw new Error('boom'); }
});
const tm = new ThrowModel({x: 1});
tm.on('error', () => console.log('error event'));
try {
tm.set({y: 2});
} catch (e) {
console.log('caught:', e.message);
}
console.log('attributes:', tm.attributes);
Run this in your target browser/environment. If error event does not appear but caught: boom does, the exception bypassed Backbone’s event system.
Are you using save({...}, {wait: true}) or a custom sync implementation? That changes whether the error event you observe comes from client-side validation or from the server response, and it affects the timing guarantees you can rely on in tests.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.