View Refresh¶
Layer: frontend
Status — Wave 3a done (2026-08-29): the engine is live and the CRUD core, bulk imports, and the admin editors + IAM are migrated onto it. Node CRUD, the relation family, imports (
import-model/import-data), and now IAM grant/revoke (iam), rulebook rule save/delete (rulebook), and property-catalog save (property-catalog) all callEidosCore.dispatchMutation. Migrating the admin writes fixed a real gap — a rulebook rule change now refreshes the open node's inherited/propagated properties (it did not before). The legacy_postCommitfunction and theeidos:tree-refreshCustomEvent bridge are now DELETED — the per-tick coalescing +_refreshTreeFromServer's_initGenguard replace the old commitGen race guard. The 02.01 ratchet is at its floor for these:_postCommitandeidos:tree-refreshare both 0. Wave 3b then built thedeferred-mode stale affordance: a deferred surface that is open when a related change lands elsewhere renders an accessible "stale — refresh" badge (viaonStale+_mountStaleBadge) instead of silently showing old data — wired first to the Rule Editor. Remaining later waves: IIS/interfaces, search, and the rest of the report-like admin panels. Thetree-chipsreconciler still computes its own counts on refetch; it starts consuming the Metrics Engine catalog in that engine's Wave 3.
Overview — the canonical owner of post-mutation screen refresh¶
When a user creates, updates, imports, adds, or deletes something, the on-screen
data must become correct without a browser reload. The View-Refresh engine
(frontend/mutation-refresh.js) is the single canonical owner of that
responsibility — the frontend counterpart to the backend's canonical engines
(mutation_store, graph_merge, eidos_loader).
It exists because that responsibility had drifted across eight parallel refresh mechanisms and several duplicated per-call-site implementations of the same mutation (relation-create was refreshed three different ways depending on which dialog started it). That is a violation of the canonical-engines invariant (GCF Contract 02.01, R20–R24). This engine consolidates the responsibility behind one entry point so that no view is ever silently left stale and no two call-sites refresh the same change differently.
The engine is loaded from index.html after app.js, so it augments
window.EidosCore (adding its methods) rather than being clobbered by app.js's
own EidosCore setup.
The contract — declare the change, not the views¶
Every mutation, after a green commit, calls exactly one function:
window.EidosCore.dispatchMutation(outcome)
The outcome is a MutationOutcome — a declaration of what changed, never a
list of which views to poke:
{
kinds: [ /* one or more of the closed vocabulary below */ ],
domainBase, // which domain the mutation targeted
affects: {
nodes: [ids], // nodes whose data changed
parents: [ids], // parents whose child-set changed
relations:[ids], // relations added / removed
treeStructure: bool // insert / remove / move in the tree
},
optimistic?: (ctx) => void, // synchronous local patch; applied at once, overwritable
origin // call-site tag, for telemetry / debugging
}
kinds is a set. Most mutations are single-kind; some are composite — a
relation-delete that also removes a document emits ['relation','document']; an
importer that both creates a domain and fills it emits
['import-data','import-model'].
The closed change-vocabulary¶
Adding a view never touches this list; adding a new kind of change is a deliberate, reviewed extension. The eleven kinds:
| kind | meaning |
|---|---|
node |
node create / update / delete |
relation |
relation add / remove |
property-value |
node engineering data changed (drives propagation + inventory) |
property-catalog |
catalog definition changed (global) |
rulebook |
a rule changed (global; also invalidates inherited properties) |
iam |
access rule changed (global; badges, team, tree-banner) |
document |
document attach / detach |
import-model |
bulk content into an existing domain (Excel, DocuGraph) — domain/subtree |
import-data |
domain-data import (JSON domain / snapshot) — creates/replaces a whole domain — global/domain |
snapshot |
snapshot list changed |
domain-config |
domain visibility / registry metadata changed |
import-model vs import-data are split because their blast radius differs:
import-model adds content inside an existing domain; import-data changes the
set of domains/trees (so it also invalidates the domain tabs, domain-visibility,
the snapshot list, and the domain registry).
Reconcilers — views declare their interest¶
Each view registers a reconciler for the surface it owns, with a declarative descriptor (not imperative filtering scattered in listeners):
window.EidosCore.registerReconciler('specifications', {
interestedIn: ['node', 'property-value', 'rulebook'],
scope: 'node', // dirty only when the outcome touches the shown node
mode: 'live', // open panel → reconcile at once
getNodeId: () => currentNodeId, // scope resolvers
}, reconcileFn);
The dispatcher marks a surface dirty when
outcome.kinds ∩ descriptor.interestedIn ≠ ∅ and the scope matches:
global→ always;domain→descriptor.getDomain()equalsoutcome.domainBase(or the outcome carries no domain);node→descriptor.getNodeId()is inoutcome.affects.nodes.
A reconciler is authoritative: given the affected ids it re-fetches from its own endpoint and re-renders. Reconciles are coalesced per tick (one microtask), so rapid successive mutations produce one fetch per surface, and are idempotent.
The two reconcile modes¶
live— reconcile immediately (used when the surface is mounted, and always for the direct target of the user's own action, e.g. the repo list after the user imports a repo).deferred— for report-like / aggregate / heavy surfaces (model statistics, activity heatmap, audit tables, interface lists). They do not auto-refetch on every mutation even while open; they show a visible "stale — refresh" affordance and reconcile on explicit refresh or next open.
The default rule subsumes both: a surface that is mounted + interested goes
live; a surface that is unmounted is marked dirty and reconciles on next
open via reconcileDirty(surfaceId). The visible stale affordance on a deferred
surface satisfies the UX-orientation contract (15, R26–R33) and the
no-silent-failure contract (05): a stale view is marked, never a silent lie.
Data flow¶
- Commit returns 2xx → the call-site builds a
MutationOutcomeand callsdispatchMutation. This replaces the bespoke_postCommit/loadNodeDetail/refreshTreeChips/_eidosRefreshGraphcombinations. dispatchMutationrunsoutcome.optimistic?(ctx)synchronously — instant local feedback (the in-place tree/chip primitives become the optimistic building blocks, gathered in one place). The patch may return an array of surface-ids it fully reconciled in place — a successful_insertTreeNodeInPlaceproduces exactly what a/treerefetch would, so it returns["tree-structure"]and that surface skips the authoritative reconcile below (preserving the BL-FE-118 in-place optimisation). Returning[]means "reconcile everything".- It partitions the remaining interested surfaces into
live(queued, coalesced) vsdeferred/unmounted (dirty-flagged). - On the next tick the live queue flushes: each reconciler fetches authoritatively and re-renders, overwriting the optimistic state.
The engine is the single invalidation owner of nodeCache: optimistic writes
are provisional, reconcile does the authoritative replace.
Registered CRUD-core reconcilers (Wave 1)¶
app.js _registerViewRefresh() (called from init) seeds the optimistic ctx
with the in-place primitives and registers five reconcilers:
| surface-id | interestedIn | reconcile |
|---|---|---|
tree-structure |
node |
full /tree reconcile (_refreshTreeFromServer) — skipped when the optimistic in-place tree edit satisfied it. Relations never restructure the tree, so they are not interested here. |
tree-chips |
node, relation, property-value |
_loadChipsForCurrentTree + _scheduleTreeChips |
detail-panel |
node, relation, property-value, document |
loadNodeDetail(shownNode) |
graphs |
relation, document |
window._eidosRefreshGraph (header Cytoscape + React spider) |
documents-list |
document, relation |
window._eidosRefreshDocuments — the DocumentsZone owns a private useNodeDocuments fetch that loadNodeDetail does not touch, so it exposes its reload for the engine to poke. |
This is the convergence: every relation write refreshes the same set
(chips + detail + graphs + documents-list) regardless of which of the three entry
points started it, and none of them does a needless full /tree refetch.
Bulk imports (Wave 2)¶
The five reconcilers also declare interest in import-model and import-data, so
a bulk import refreshes everything with one outcome — the tree, the shown node's
detail, chips, graphs, and documents-list all reconcile authoritatively (no
optimistic patch). An earlier revision wiped the whole nodeCache on import, but
that blanked node labels in the Add-Information picker and the breadcrumb (they
resolve names from nodeCache), so it was dropped — loadNodeDetail re-fetches
any node the moment it is opened, so no full wipe is needed:
import-model— relation / property / Excel / product import (content into an existing domain). Outcome carriesdomainBase+affects.nodes = [selectedNode]treeStructure: true.import-data— a domain-snapshot import (/domain-import/commit). Before Wave 2 this refreshed only the snapshot list, so the tree stayed stale; theimport-dataoutcome now drives the full reconcile.
Admin editors + IAM (Wave 3)¶
Admin writes name no specific node, so they use global-scoped reconcilers that refresh whatever is on screen:
iam(grant/revoke) →tree-accessrefetches the rule's-domain tree (access banners /effective_role), anddetail-on-global-adminreloads the shown node's role badge + team-users.rulebook(rule save/delete) →detail-on-global-adminreloads the shown node so its inherited/propagated properties recompute;tree-chipsrefreshes.property-catalog(catalog save) → theproperty-editorreconciler drops the detail-panel hook caches and fireseidos:catalog-updatedso the property-editor picker re-derives.
With the admin writes migrated, the legacy _postCommit function and the
eidos:tree-refresh CustomEvent bridge are deleted — the last dispatchers were
retired here. Staleness from rapid mutations is handled by the engine's per-tick
coalescing plus _refreshTreeFromServer's _initGen guard.
Error handling (GCF 05)¶
- A reconciler that throws is caught, logged, and does not block the other reconcilers in the same flush.
- A throwing
optimisticpatch is caught and logged, and reconcilers still run. - Logging itself never throws.
This deliberately mirrors the backend's _post_invalidate_hooks pattern (swallow +
log), so one bad surface cannot poison the refresh of the others.
The 02.01 ratchet¶
Because migration is incremental, both the engine and the legacy mechanisms
coexist during Waves 1–4. tests/test_frontend_refresh_canonical.py is the
enforcement: it freezes the baseline occurrence counts of the legacy refresh
primitives across app.js and the detail-panel plugins. A new occurrence (a
fresh bypass) fails the build; migration lowers the baselines toward zero. When
every baseline reaches zero, 02.01 is fully satisfied for view refresh. The count
is deliberately not line-based, so unrelated churn in app.js never breaks it.
Cross-user seam (not built)¶
The engine covers only the acting user's own actions. The same
dispatchMutation entry point can later be fed by a transport (version-poll / SSE)
that synthesizes MutationOutcomes from server change notifications; the
reconcilers would not change. No transport is built yet.
Related files¶
frontend/mutation-refresh.js— the engine.tests-frontend/mutation-refresh.test.js— behavioural unit tests.tests-frontend/mutation-refresh.wiring.test.js— page-wiring invariants.tests/test_frontend_refresh_canonical.py— the 02.01 ratchet.docs/canonical-view-refresh/design.md— the full design spec.frontend-crud.explanation.md— the preview→commit write path that produces the commits this engine reacts to.