Optimistic-UI Save Pattern (Angular)
Status: proposal for review. This guide documents a pattern several Angular surfaces have already adopted independently, plus a new framework hook (
EntitySaveOptions.OnValidated) that generalizes it. It includes two implementations of the same change — generic vs. inline — so reviewers can compare and pick one before the pattern is swept across the codebase. Nothing here changes server-side behavior, andBaseEntity.Save()’s contract is unchanged (the new hook is an optional, additive callback).
The problem: cumulative perceived latency
Section titled “The problem: cumulative perceived latency”On chat, list, and settings surfaces the user performs the same small action dozens of times a session — send a message, pin it, rate it, add a tag chip, toggle a setting. The common code shape is:
entity.SomeField = newValue;await entity.Save(); // <-- UI is frozen here for a full DB round-tripthis.list = [...this.list, entity]; // render only AFTER the server repliesEach await is one network round-trip of dead time before the user sees their own action. Individually
it’s 200–800 ms; across a session it’s the single most-felt sluggishness on these surfaces. The work is
already done client-side the instant the user clicks — we’re just waiting to show it.
What already makes this safe
Section titled “What already makes this safe”No new method and no change to Save()’s return contract are needed for the core pattern:
NewRecord()assigns the PK up front. For a single-columnuniqueidentifierprimary key,BaseEntity.NewRecord()generates and sets the UUID immediately — so the caller has a stableentity.IDbeforeSave()is ever called, and can render a real, addressable row optimistically.Save()returnsPromise<boolean>— fire it, render, then react to the boolean to confirm or roll back.Revert()restores every dirty field to itsOldValue— the UPDATE rollback path.- For CREATE rollback, the parent just removes the entity from its display collection.
The new hook: EntitySaveOptions.OnValidated (the generic option)
Section titled “The new hook: EntitySaveOptions.OnValidated (the generic option)”Inline Pattern A (below) renders before Save() runs validation, so an invalid entity flickers onto
the screen and then rolls back. The OnValidated callback closes that gap: it fires after all
pre-flight checks pass (Validate(), ValidateAsync(), and PreSave hooks) but before the database
write — so the UI renders only once the change is known to be valid, while still beating the network round-trip.
// EntitySaveOptionsOnValidated?: (entity: BaseEntity) => void;- Fires exactly once, only when the save will proceed (skipped for not-dirty /
ReplayOnly/ failed validation). - Errors thrown by the callback are swallowed and logged — a UI bug can never abort the persistence it accompanies.
- Implemented in
BaseEntity._InnerSave; covered bybaseEntity.onValidated.test.ts.
Alternative considered: a validated BaseEntityEvent (reviewers, weigh in)
Section titled “Alternative considered: a validated BaseEntityEvent (reviewers, weigh in)”MJ already has an entity event bus — BaseEntity.RaiseEvent(...) emits save_started (fires before
validation) and save (fires after persist) as BaseEntityEvent types, and BaseEngine.ObserveProperty
/ DataChange$ consume them. The validation-passed/pre-persist moment could instead be modeled as a new
'validated' event type on that bus. We use a per-call OnValidated option instead, for two reasons:
- Cardinality. Optimistic render is 1:1 and the initiator is the observer — the component that
called
Save()is the one that wants to render. A callback handed into that call is the correct shape for 1:1; an event is for 1:N, where parties other than the saver react. (A broadcast event is perfectly correlatable —BaseEntityEventcarries the entity — so “which save fired?” is not the obstacle; the mismatch is cardinality + cost: a globalvalidatedevent fires on every save and wakes every subscriber on a hot path, for a feature a handful of callers use. The per-call option costs nothing when the field is undefined.) - The 1:N pre-persist niche is already filled. MJ’s PreSave hooks (
RegisterDataHook('PreSave', …)) are exactly the broadcast, cross-cutting “about to persist — observe or gate it” mechanism. Avalidatedevent would largely duplicate them. The one gap neither hooks nor events fill cheaply is “let the saver render its own change” — which the per-call callback fills precisely.
The event-bus route stays the better shape only if a use case emerges where non-saver observers must react at validation time — and even then, PreSave hooks may already suffice.
Two sharp edges, called out so reviewers see they were considered:
- Naming.
OnValidatedfires afterValidate,ValidateAsync, and PreSave hooks — i.e. “all pre-flight gating passed, persist imminent.”OnPrePersistwould be more literal;OnValidatedis kept for friendliness since validation is the common case. The JSDoc states the exact firing point. (entity: BaseEntity)typing.interfaces.tsalready importsBaseEntity(it’s used in theSave/Load/Deletesignatures in the same file), so typing the parameter asBaseEntityadds no new cycle — it’s the existing pattern. Callers typically close over their own entity reference and ignore the parameter anyway (asPinMessagedoes).
The decision rule (apply per callsite)
Section titled “The decision rule (apply per callsite)”For each await x.Save() in packages/Angular/**, ask in order. The first match wins.
- Is the PK a single-column
uniqueidentifier? If not (int-identity or composite PK), there is no stable ID before save → do not optimistically render a CREATE with a real key. KEEP AWAIT. - Is the entity registered with a
TransactionGroup? → KEEP AWAIT — commit ordering depends on it. (Allcore-entity-forms/custom/**andbase-forms/base-form-component.tsbatched saves.) - Does the next line use the saved entity’s ID server-side (FK chain, a mutation that writes child
rows)? → KEEP AWAIT. (e.g.
analyze-artifact.service.ts,artifact-create-modal.component.ts, attachment services that write children.) - Is this inside a modal/dialog where failure must keep the dialog open? → KEEP AWAIT.
- Is there a retry/verification loop after the save? → KEEP AWAIT.
- Otherwise → convert to optimistic render (one of the two implementations below).
Anti-rules (do NOT do these)
Section titled “Anti-rules (do NOT do these)”- Never convert
await→ bare fire-and-forget. The await stays; only the render moves earlier. - Never fire-and-forget when a second
Save()may follow on the same entity.Save()debounces concurrent calls via_pendingSave$— the second call returns the first’s promise without re-saving, so mutations made between the two would be silently dropped. - Never apply to
Delete(). Optimistic deletes carry severe race risk (ghost rows, orphaned attachments). - Never apply to server-side code (resolvers, actions, hooks, CodeGen, MetadataSync) — all server
await Save()calls, and the README’s canonical example, stay as-is. - Never apply to
TransactionGroup-batched saves.
Two implementations, same change — compare and choose
Section titled “Two implementations, same change — compare and choose”The reference site is MessageItemComponent.PinMessage (a single-field UPDATE, no FK chain, no TG — a clean rule-5 case).
Implementation A — inline (no framework change)
Section titled “Implementation A — inline (no framework change)”Renders before validation; rolls back on a false result. This is the shape already shipped in several places today.
public async PinMessage(): Promise<void> { const previousValue = this.message.IsPinned; this.message.IsPinned = !previousValue; // optimistic render (BEFORE validation) this.cdRef.detectChanges();
const saved = await this.message.Save(); if (!saved) { this.message.IsPinned = previousValue; // manual rollback this.cdRef.detectChanges(); }}- Pros: zero framework change; obvious and local.
- Cons: renders before validation runs (invalid value can flicker in then revert); every callsite re-implements the render + rollback bookkeeping by hand (this is why five different one-off versions already exist in the repo).
Implementation B — generic, via OnValidated
Section titled “Implementation B — generic, via OnValidated”The render fires from inside Save(), only after validation passes; rollback still keyed off the boolean.
public async PinMessage(): Promise<void> { const previousValue = this.message.IsPinned; this.message.IsPinned = !previousValue;
const saved = await this.message.Save( Object.assign(new EntitySaveOptions(), { OnValidated: () => this.cdRef.detectChanges(), // render only once known-valid }), ); if (!saved) { this.message.IsPinned = previousValue; this.cdRef.detectChanges(); }}- Pros: no invalid-value flicker (render is gated on validation passing); the framework owns the “when is it safe to render” decision, so every callsite gets the same correct timing.
- Cons: the rollback half is still per-callsite (the framework can’t know what to revert in the UI); the win over inline is the render-timing correctness, not eliminating rollback code.
Recommendation
Section titled “Recommendation”Adopt B (OnValidated) as the default for new optimistic saves — the validation-gated render timing is a
genuine correctness improvement the framework should own, and it’s additive/backward-compatible. Keep A
acceptable for the already-shipped sites until they’re touched for other reasons. A future ergonomic layer
(an Angular helper that also captures the rollback closure) can sit on top of OnValidated without changing it.
Rollout (after this proposal is accepted)
Section titled “Rollout (after this proposal is accepted)”- Phase 1 — chat surface: the ~13 rule-5 sites in
Generic/conversations/components/message/**. - Phase 2 — high-traffic, low-risk:
list-management/tag-chips,explorer-settings/**autosaves. - Phase 3 — dashboards/admin: swept per component area, one PR each.
- Phase 4 — docs/tooling: fold the decision tree into the root and Angular
CLAUDE.md; evaluate an ESLint warning (not error) onawait x.Save();immediately followed bythis.<arr>.push(x)/emit(x).
Every converted component must have its rollback path exercised with a deliberate Save failure — the
mock-provider harness in baseEntity.onValidated.test.ts
shows how to make a save resolve false / reject without a database.
Out of scope
Section titled “Out of scope”BaseEntity.Save()’s return contract; server-side code; Delete(); TransactionGroup-batched form saves;
the README canonical example; React UI (one negligible site).