Client lifecycle
The end-to-end browser-client journey of a Dédalo UI element — page bootstrap → build instance → request data → render DOM → wire events → user edits → save → refresh/destroy — and how the four client cornerstones (
instances.js,data_manager.js, the render layer,event_manager.js) compose to deliver it.See also: RQO · section_list · Components · Events · UI building blocks · api
This page is the integration reference for the client. The four cornerstone modules each have their own behavior (factory/registry, transport, render, bus); this document ties them together into the one lifecycle every component, section, area, widget, service and tool follows, and then walks through editing a single field end to end.
Role
The Dédalo client is a thin DOM builder over a server that is the single
source of truth. The browser never invents structure: for every element the
server ships a ddo (Dédalo data object) — a context (model, label, view,
permissions, properties, tools, request_config, css, ddo_map) plus data (the
record values). The client instantiates one JS class per element, feeds it the
ddo, and renders the standard three-layer DOM.
Three singletons make this work, and every interaction flows through exactly one of them:
| concern | singleton | file |
|---|---|---|
| transport — all server traffic | data_manager |
core/common/js/data_manager.js |
| bus — all inter-module messages | event_manager |
core/common/js/event_manager.js |
| registry — all live objects | instances_map (via get_instance) |
core/common/js/instances.js |
The shared object machinery (init / build / render / refresh /
destroy, RQO construction, context wiring) lives in common.js and is
specialised per model (component_common.js, section.js, area_*, …).
Reading order
If you only read one other page first, read RQO — it defines
the request envelope ({action, source, sqo|data}) that the request and
save steps below assemble. For how a section composes its children into a
list, read section_list.
The lifecycle in one sequence
The status field on every instance walks a fixed machine:
initializing → initialized → building → built → rendering → rendered → destroyed
driven by the methods init → build → render → (events) → refresh → destroy.
sequenceDiagram
autonumber
participant DOM as #main (page/index.js)
participant INST as get_instance (instances.js)
participant DM as data_manager
participant API as api/v1/json
participant REN as render layer (common.render + ui.*)
participant EM as event_manager
DOM->>INST: get_instance({model:'page'})
INST->>INST: key_instances_builder → instances_map
INST->>INST: new page(); init(options)
DOM->>INST: page_instance.build(true)
INST->>DM: request({body: start RQO})
DM->>API: POST (X-Dedalo-Csrf-Token)
API-->>DM: {result:{context,data}}, csrf_token
DM-->>INST: ddo
INST->>EM: publish built_<id>
DOM->>REN: page_instance.render()
REN->>REN: self.edit()/list() → ui.* builders
REN->>EM: publish render_<id>
REN-->>DOM: wrapper node (replaces "Starting…")
Note over INST,EM: section composes children via get_instance per component
EM->>EM: user edits → change_value → save RQO
EM->>REN: refresh (content level) / destroy
Step by step
1. Page bootstrap
core/page/js/index.js is the first module evaluated. Its async IIFE:
- Initialises
window.page_globals(withapi_errors: [],request_message: null,csrf_token: null),window.get_label, and debug flags. - Calls
events_init()(fromevents.js) to attach application-level listeners (save,visibilitychange). - Injects a
Starting.. Please wait.placeholder into#main. await get_instance({model:'page'})— first call dynamically importspage.jsand constructs the singleton.await page_instance.build(true)— fires thestartAPI action that authenticates the session and downloads the application context.await page_instance.render()— produces the full shell, then swaps the placeholder for the rendered node and removes thehideclass.
The very first start request goes out without a CSRF header (the server
exempts start); every subsequent request carries
X-Dedalo-Csrf-Token, refreshed from each response (see
step 3).
2. Build the instance — instances.js
get_instance(options) is the unified async factory and the only correct way
to obtain a live element. It:
- Resolves
model/lang. Ifoptions.modelis absent it callsdata_manager.get_element_contextand injects the returnedcontextintooptionssoinit/buildneed no second context call; it also adopts the server's authoritativelang. - Builds the canonical key via
key_instances_builder, which joins non-empty values in the fixedkey_ordersequence:model, tipo, section_tipo, section_id, mode, lang, parent, matrix_id, id_variant, column_id. - Returns the cached entry from the module-private
instances_mapon a hit. - On a miss, checks the in-flight builds registry: if another caller is
already building this key, its build Promise is returned as-is and nothing
is constructed. See Client instances for the full contract,
including whose
optionswin when callers race. - On a miss with no build in flight, dynamically
import()s the ES module from a model-prefix-derived path —tool_*→ tools root (or aDEDALO_TOOLS_URLSabsolute URL),service_*→core/services/<model>/js/<model>.js, elsecore/<model>/js/<model>.js. Itnew-constructs the export named exactly like the model, setsinstance.id = keyandinstance.id_base = [section_tipo, section_id, tipo].join('_'),awaitsinstance.init(options), registers it ininstances_map, releases the in-flight entry, then resolves.
common.init (in common.js) seeds the baseline properties from options
(model / tipo / section_tipo / section_id / mode / lang /
context / data / datum), plus empty events_tokens and ar_instances,
the caller pointer, and standalone (default true); status goes
initializing → initialized.
Synchronous registry helpers: get_all_instances, get_instances_custom_map,
add_instance, get_instance_by_id (also on window for iframes),
find_instances (linear scan on tipo/section_tipo/section_id/mode/lang),
delete_instance(key), delete_instances(options) (wildcard bulk removal).
3. Request data — data_manager + RQO
build hydrates the instance. When autoload === true,
component_common.build assembles an RQO and calls the transport:
const rqo = {
source : create_source(self, 'get_data'),
action : 'read'
}
const api_response = await data_manager.request({ body: rqo })
When the caller (a section_record, a portal, …) injects context/data,
autoload is false and no request is made.
create_source(self, action) (in common.js) builds the
{typo:'source', type, action, model, tipo, section_tipo, section_id, mode, view, lang}
descriptor; build_rqo_show clones the request_config, attaches the source,
resolves the sqo (pagination + an auto filter_by_locators from
section_tipo/section_id) into {action:'read', source, sqo}; build_rqo_search
walks the ddo_map (get_ar_inverted_paths) into a filter_free. See
RQO for the full envelope.
data_manager.request(options):
- serializes
options.bodyto JSON and attachesX-Dedalo-Csrf-Tokenfrompage_globals.csrf_token(without overwriting a caller-set header); - injects
recovery_modefrom page_globals and resetspage_globals.api_errors/request_message; - dispatches through
_fetch_with_retry_and_timeout(default 5 retries, 500 ms base delay, 5 s timeout) with exponential backoff and a per-attemptAbortController; a mid-attemptcheck_server_healthprobe cancels the abort when the server is alive-but-busy; only statuses[408, 429, 500, 502, 503, 504]are retried; progress surfaces throughrender_msg_to_inspector; - refreshes
page_globals.csrf_tokenfrom the response; non-fatalerrorspublishapi_response_errors; fatal errors are recorded via_record_api_errorfor the page renderer.
A single transparent retry handles the bootstrap CSRF race (csrf_failed).
Specialised actions: get_element_context (with prevent_lock:true),
resolve_model (cached in page_globals.models),
get_matrix_ontology_locator (cached in page_globals.ontology_info),
get_page_element, and streaming via
request_stream / request_fetch_stream + read_stream (SSE / NDJSON,
readers tracked in page_globals.stream_readers and released by
release_stream_reader — that registry drains on navigation, so a consumer that
stops following earlier must give its connection back itself).
Local caching (IndexedDB)
get_local_db opens the dedalo DB (v11) with stores rqo, context,
status, data, ontology, pagination. A request carrying
cache_handler:{handler:'localdb', id} is short-circuit-read before the
network and written back on idle. UI state (last selection from
activate/deactivate, section_group collapse, stream PIDs) is persisted
to the status store. worker_data.js is a minimal self-contained replica
of request for an optional background Worker (currently deactivated in
request).
After the response lands, build runs set_context_vars(self), which wires
view / properties / permissions as getters/setters backed by
self.context (keeping the context object the single source of truth),
assembles show_interface (merging the component override over
default_show_interface), subscribes events, and publishes built_<id>.
show_interface is always an object OWNED BY THE INSTANCE: the override is
spread into a fresh object before the missing defaults are filled in, so the
render paths that write into it (e.g. view_indexation_edit_portal.js forcing
button_delete_link_and_record = false) can never mutate the
context.properties.show_interface / request_config_object.show.interface
they came from.
4. Render — ddo to standard DOM
common.prototype.render(options) is the dispatcher. It:
- Guards — renders
render_server_response_errorwhenpage_globals.api_errorsis non-empty; aninvalid contexterror for a component with no context; ano_accessspan whenpermissions < 1. - Status machine with smart concurrency —
buildingwaits forbuilt_<id>then re-calls;renderingjoins the in-progress waiter for an identical request or queues the latest for a differing one (last-write-wins);renderedreturns the existing node when the level matches. - Delegates to the mode-named method on the instance —
self.edit()/self.list()/self.search()/self.tm(), falling back tolistwhen no method matches. - Publishes
render_<id>with the result node and, in edit mode, schedulesui.activate_tooltips.
The queued render must re-enter with status built
A queued (last-write-wins) render is handed off by the render_<id>
subscriber, which sets the status back and re-calls render(). That status
MUST be built — the only value that falls through the status machine into
a real render. Restoring the status the queued call saw (rendering)
sends it back into the waiter branch, where it parks its options and waits
for a render_<id> event that nothing will publish, because no render is
running: the instance's render pipeline wedges permanently and the newest
options are never applied.
Gate: test/unit/client_render_queue_deadlock.test.ts (it fails by timing
out on the deadlock).
render_level is full (build the whole wrapper into self.node,
replaceWith the old node) or content (regenerate only
self.node.content_data and splice it in — used by refresh).
Each mode method delegates to per-component render files
(render_<mode>_<component>.js) and view files (view_<view>_<mode>.js),
which use the ui.* builders to emit the standard three-layer DOM:
wrapper_<type> (classes: <model>, <tipo>, <section_tipo>_<tipo>, <mode>, view_<view>)
├── label
├── buttons_container (only when permissions > 1)
├── filter / paginator (optional)
└── content_data (classes: content_data + type + context.css.content_data)
└── content_value (the actual editable/displayed value)
The wrapper is built by ui.component.build_wrapper_edit /
build_wrapper_list / build_wrapper_mini / build_wrapper_search (and
ui.area/tool/widget.build_wrapper_edit); ui.component.build_content_data
builds the content_data node; ontology CSS (context.css) is injected via
set_element_css. The wrapper keeps live pointers (wrapper.label,
wrapper.content_data) so content-level re-renders swap just the inner node.
ui.create_dom_element is the universal node factory (class / style / dataset /
inner_html / text_content with XSS-safe text_node); ui.update_node_content
clears and reinserts content; ui.add_tools materializes instance.tools[]
into the buttons container. See UI building blocks.
5. Wire events — event_manager
A single event_manager_class instance (also window.event_manager for
iframes) keeps eventMap (event_name → Set<callback>) and tokenMap
(token → {event_name, callback}) for O(1) publish and unsubscribe.
subscribe(name, cb)returns an opaqueevent_Ntoken;subscribe_onceself-unsubscribes before firing.publish(name, data)invokes callbacks synchronously in insertion order, returns the array of return values orfalsewhen there are no subscribers (callbacks are not try/caught).unsubscribe(token),clear_event,clear_all,event_exists,event_name_exists, and counters round it out.
This is the observer/observable model: instances never reference each other;
they publish/subscribe. Lifecycle events are keyed by instance id —
built_<id>, render_<id>, destroy_<id>. Subscription tokens are stored in
self.events_tokens and unsubscribed in do_delete_self. Common application
events: activate_component / deactivate_component (from
ui.component.activate/deactivate), change_value / update_value /
update_data, sync_data_<id_base_lang> (TM/sibling refresh),
change_search_element, api_response_errors, and notification (driven by
render_msg_to_inspector). See Events.
6. User edits → save
When the user changes a value, the component calls
component_common.change_value(options):
- Queues overlapping calls (
status === 'changing') to avoid server concurrency; for aremoveaction it raises a confirm dialog. - Applies each
changed_dataitem to the in-memory instance viaupdate_data_value. - Calls
self.save(changed_data). - On success, resets
self.data.changed_data = [], and (when not standalone)update_datum(api_response.result). - Optionally
refreshes, then publishessync_data_<id_base_lang>(refresh sibling/TM DOM) andupdate_value_<id_base>(fire ontology-configured observers).
component_common.save(new_changed_data) is the persistence chokepoint:
const data = clone(self.data); data.changed_data = changed_data
const source = create_source(self, null)
const rqo = { action: 'save', source: source, data: data }
const api_response = await data_manager.request({ use_worker:false, body:rqo })
It guards against double-saves (self.saving), skips when an update batch is
unchanged (is_equal against db_data.entries), toggles saving / loading
/ error / save_success classes on self.node for UI feedback, and on the
not_logged error subscribes to login_successful and retries the save. The
save RQO is the same {action, source, data} envelope described in
RQO.
This is what makes an expired session non-destructive
A save that lands after the session died is not lost. The server answers
401 / not_logged, page.js raises the re-login modal in place — no
navigation, no page state lost — and this handler replays the save once
login_successful fires. data_manager deliberately does not treat 401
as a transport error, so the envelope reaches the event that drives all of
it. Users are also warned ahead of time; see
login → expiry as the client sees it.
7. Refresh / destroy
refresh(options) runs destroy-dependencies → build → render at content
level (optionally reusing an injected tmp_api_response), so the wrapper and
its DOM position survive while the inner value is regenerated.
destroy(delete_self, delete_dependencies, remove_dom):
- unsubscribes every token in
self.events_tokens; - tears down paginator / services / inspector / filter;
- recursively destroys
ar_instances; - removes itself from
instances_map(viado_delete_self→delete_instance(self.id)) and fromcaller.ar_instances; - nulls heavy references and publishes
destroy_<id>.
This keeps both the registry and the event bus leak-free.
How a section composes its components
A section composes its components on the client by reading its
request_config / ddo_map, deriving columns with get_columns_map
(line / mosaic / default grouping plus the synthetic ddinfo column), and
calling get_instance per child component — passing the section as caller
and pushing each child into section.ar_instances. Children render their own
wrapper / content_data via the ui.* builders and append into the section DOM;
deferred placements (e.g. component_filter into the inspector) use
ui.place_element, which appends immediately when the target is rendered or
defers via a render_<target.id> subscription otherwise.
Tearing down the section cascades destroy to all ar_instances. See
section_list for the list-mode composition in
detail.
flowchart TB
SEC["section instance (caller)"] -->|get_columns_map| COLS["columns_map"]
SEC -->|get_instance per child| C1["component A"]
SEC -->|get_instance per child| C2["component B"]
C1 -->|push| AR["section.ar_instances"]
C2 -->|push| AR
C1 -->|ui.* builders| DOMA["wrapper + content_data"]
C2 -->|ui.* builders| DOMB["wrapper + content_data"]
SEC -.destroy cascades.-> C1
SEC -.destroy cascades.-> C2
Worked example: editing one component_input_text
A user is editing a record in section rsc197, and changes the Summary
field rsc110 from "old" to "new".
-
The field already exists. During the section's list/edit build,
get_instance({ model:'component_input_text', tipo:'rsc110', section_tipo:'rsc197', section_id:1, mode:'edit', lang:'lg-eng' })produced the instance withid = component_input_text_rsc110_rsc197_1_edit_lg-engandid_base = rsc197_1_rsc110. Its<input>lives insidecontent_data → content_value. -
Focus → activate. On focus,
ui.component.activatepublishesactivate_component; the previously active component receivesdeactivate_component, validates, and saves itself if dirty. The bus is synchronous, so this completes before the user types. -
Blur → change_value. On change, the component calls
change_valuewithchanged_data: [{ action:'update', key:0, value:['new'], lang:'lg-eng' }].update_data_valuewrites['new']intoself.datain memory. -
Save RQO.
saveclonesself.data, attacheschanged_data, buildssource = create_source(self, null)and the envelope{ action:'save', source, data }, and callsdata_manager.request:{ "action": "save", "source": { "typo": "source", "type": "save", "model": "component_input_text", "tipo": "rsc110", "section_tipo": "rsc197", "section_id": 1, "mode": "edit", "lang": "lg-eng" }, "data": { "tipo": "rsc110", "section_tipo": "rsc197", "section_id": 1, "lang": "lg-eng", "changed_data": [ { "action": "update", "key": 0, "value": ["new"], "lang": "lg-eng" } ] } }The request carries
X-Dedalo-Csrf-Token; the server validates permissions, persists through the section'ssection_record, returns{ result: {...}, csrf_token: "…" }, and the client refreshespage_globals.csrf_token. The<input>wrapper flashessavingthen clears it. -
Propagate.
change_valueresetsself.data.changed_data = [], then publishessync_data_rsc197_1_rsc110_lg-eng(sibling/TM DOM refresh) andupdate_value_rsc197_1_rsc110(any ontology-configured observer — e.g. an info component that recomputes — reacts here). -
No teardown. Because nothing requested
refresh:true, the wrapper stays in place; only the value and the live pointers changed. When the section is closed, itsdestroycascade unsubscribes this component, removescomponent_input_text_rsc110_rsc197_1_edit_lg-engfrominstances_map, and publishesdestroy_<id>.
Files & functions
| file | key symbols |
|---|---|
core/page/js/index.js |
bootstrap IIFE: page_globals init, events_init, get_instance({model:'page'}), build(true), render() |
core/common/js/instances.js |
get_instance, key_instances_builder, key_order, get_instance_by_id, find_instances, delete_instance, delete_instances, instances_map |
core/common/js/common.js |
init, build, set_context_vars, render, refresh, destroy, create_source, build_rqo_show, build_rqo_search, do_delete_self |
core/component_common/js/component_common.js |
init, build / do_build, change_value, save, update_data_value, save_unsaved_components |
core/common/js/data_manager.js |
request, _fetch_with_retry_and_timeout, check_server_health, get_element_context, resolve_model, get_matrix_ontology_locator, get_page_element, request_stream / request_fetch_stream / read_stream, get_local_db, _record_api_error |
core/common/js/event_manager.js |
event_manager_class, subscribe, subscribe_once, publish, unsubscribe, clear_event, clear_all, event_exists |
core/common/js/render_common.js, core/common/js/ui.js |
render delegates and the ui.* builders (create_dom_element, build_wrapper_*, build_content_data, add_tools, place_element, activate/deactivate) |
core/common/js/worker_data.js |
self-contained request replica for the optional background Worker (deactivated in request) |
Always go through the three singletons
Do not fetch directly (use data_manager.request), do not hold direct
references between instances (use event_manager), and do not new a
component class yourself (use get_instance, so the canonical key,
id_base, registry entry and event cleanup are all wired correctly).
Bypassing any of these is the usual source of leaked subscriptions and
duplicate instances.
Related
- RQO — the request envelope (
{action, source, sqo|data}) assembled by the request and save steps. - section_list — how a section composes its child components into a list on the client.
- Components — the per-element classes, the ddo
(
context+data), and the standard DOM structure. - Events — the
event_managerbus and the active application events. - UI building blocks — the
ui.*render builders the mode methods delegate to. - api — the server-side dispatcher that answers every
data_manager.request.