Transactions
A transaction is the unit an observer sees a change in. Every mutating operation runs inside one, so nothing has to be opted into: where you open no transaction, the operation is the transaction — a single atomic change.
Writes land in the elements as they are made; only the announcement waits. At the end of a transaction the net transitions are measured against what the elements last announced, and each is announced once.
import { transaction } from '@dynamicforms/vue-forms';
// two writes, one ValueChangedAction on the group instead of two
transaction(() => {
form.fields.firstName.value = 'Janez';
form.fields.lastName.value = 'Novak';
});transaction(fn)
function transaction<R>(fn: (tx: TransactionControl) => R): R | undefined;Runs fn as one atomic change and returns what fn returned. A call made while a transaction is already open joins it: nothing is committed until the outermost call returns, and the handle it passes fn is the handle of the transaction that was joined.
The handle is usable only for the duration of the call that received it. Keeping it and calling rollback() afterwards throws a TypeError: the transaction it names has closed, and the transaction open at that later moment is somebody else's.
fn must be synchronous. transaction() throws a TypeError the moment fn returns a thenable, so a transaction structurally cannot cross an await. Do the awaiting outside and open a transaction for each synchronous part; an asynchronous validator settling later opens one of its own at that moment and needs no coordination.
What is announced, and when
| what | when |
|---|---|
| validators | while the transaction is open, at the write that triggers them |
ValueChangedAction | at commit, over the value the element ends the transaction holding |
ValidChangedAction | at commit, over the verdict the element ends the transaction with |
ListItemAddedAction / ListItemRemovedAction | at commit, in the order the operations happened |
VisibilityChanging/Changed, EnabledChanging/Changed | at the write; a Changing action may alter or refuse the value, so it cannot wait |
Validators run during the transaction because the verdict they reach is what the commit announces. The consequence is that inside a transaction a validator reads the working state: a validator on one field that reads a sibling sees the sibling's new value, which is what makes cross-field rules work. Vue effects are scheduled after the turn, so a render sees the committed state.
The announcement runs deepest first — field, then row, then list — which is the order the change travelled in. Values are announced first and verdicts after, because a container's own validators run with its value announcement and the verdict they reach is what the validity pass then reports.
Value transitions coalesce; structural ones do not. A value that goes A → B → A within one transaction announces nothing, because the element ends where it started. ListItemAddedAction and ListItemRemovedAction state operations rather than states, so they have no net and are emitted in order.
const seen: string[] = [];
list.registerAction(new ListItemAddedAction((f, supr, item, index) => seen.push(`added@${index}`)));
list.registerAction(new ValueChangedAction(() => seen.push('value')));
transaction(() => {
list.push({ name: 'Janez' });
list.push({ name: 'Micka' });
});
// seen === ['added@0', 'added@1', 'value'] — two additions, one value changeRollback
The first time a transaction modifies an element it records the whole of that element's mutable state — value, originalValue, touched, errors, enabled, visibility, for a Group the names of its members and for a List its row array. A rollback puts all of it back, together with the actions the transaction registered or unregistered — including the validators a clearValidators() dropped — and announces nothing: from an observer's point of view the transaction never happened. An asynchronous validation the unregistration would have cancelled is put back with it: the cancellation waits for the commit, so a run in flight when the transaction opened goes on and the verdict it reaches counts.
A throw rolls back and rethrows. This is what makes atomicity real: a handler that fails halfway through a whole-group assignment leaves the group exactly as it was rather than half-applied.
try {
transaction(() => {
form.value = { a: 'x', b: 'y' }; // a handler on b throws
});
} catch (error) {
// form.value is what it was before the assignment
}tx.rollback() unwinds without an error. It unwinds from the point of the call, so nothing after it runs, and the transaction() call answers undefined.
const answer = transaction((tx) => {
row.value = edited;
if (!row.valid) tx.rollback();
return row.value;
});
// answer is undefined where the edit was rolled backThere are no savepoints. A nested call joins the transaction it found and rolls the whole of it back, not its own part: partial unwinding of a subtree would leave the ancestors' derived state computed over data that no longer exists.
A rollback restores state, never side effects. A handler that called a server during the transaction already did, and no snapshot reaches that. The same holds for a throw during the announcement: events already emitted have been received, and only the state goes back.
Two more things a rollback does not undo:
- an action registered while it was open stays registered. Registrations are chained closures, and one of them cannot be taken out again.
- an asynchronous validation it started is called off rather than undone. The
AbortSignalthe validation function was handed aborts, so work that honours it stops; work that does not runs to the end. Either way the verdict is discarded — the field is never left invalid over a value it was rolled back out of — andvalidatingstaystrueuntil the run settles, because a run in flight is a fact rather than a state. The counters behindvalidatingare therefore the one piece of state a rollback leaves alone: put back, they would no longer match the runs still to settle.
Cost
Recording an element's state costs one small object per element the transaction actually modifies, taken the first time it is written. A whole-list assignment over 1000 rows of 8 fields records about 9000 of them, and the whole assignment measures at 24 ms against 19 ms for the same fixture without transactions — most of that difference being the commit's own bookkeeping rather than the record. There is no way to switch the record off, and none is needed at that ratio.
See also: The model for where transactions sit among the rest of the library
