Introduction
nop-chaos-flux is the rendering layer of the Nop platform—a low‑code runtime driven by declarative DSLs, designed to render and execute application pages described by Schema in the browser. Its DSL surface syntax resembles that of Baidu AMIS, but achieves conceptual unification (e.g., eliminating the xxxOn suffix family). Its internal compilation model, runtime architecture, and expression engine have all been redesigned.
Most low‑code platforms treat the Schema as the runtime input configuration. The runtime then simultaneously carries authoring‑time structures, domain semantics, and scheduling logic. Every new capability adds another set of primitives, a new global provider, another authoritative Schema channel, and the core keeps bloating.
Flux starts from a different path: elevating the DSL from a runtime input format to an independent, first‑class structural layer.
1. DSL First
The DSL is not an input format fed to the runtime; it is a first‑class artifact of the platform: an independent, operable structural layer. Before ever entering the runtime, it already possesses its own lifecycle, transformation space, and organization rules.
Many systems have a DSL, but it is only used as a runtime input format—there is no independent structural manipulation. Flux does not see it that way. Flux’s DSL is a structural layer that can be edited, composed, trimmed, and transformed even outside the runtime:
| Operation | Meaning |
|---|---|
| Edit | Source location retention, aliases, editor metadata, round‑trip fidelity |
| Merge / Inherit |
x:extends inheritance, override expansion, fragment composition |
| Trim | Permission trimming, feature flag trimming, profile assembly |
| Transform | i18n string replacement, static default expansion |
| Meta‑programming | Express variations through structural conventions (not by growing runtime interfaces) |
DSL transformations are layered: permission trimming, i18n replacement, and default expansion each operate independently. Removing authoring‑time metadata must not change runtime behavior.
2. Authoring–Execution Separation
Even when adopting a DSL‑first approach, many systems still maintain only one model, forcing the runtime to directly carry authoring‑time structures. Flux deliberately does not do this. Instead, it places the Authoring Model and the Execution Model on opposite sides of a pre‑compilation boundary.
The point of this principle is not “having two models” per se, but acknowledging that the two sides have different optimisation goals: authoring time serves understanding, editing, composition, and fidelity; execution time serves conceptual simplification, reduced runtime overhead, and stable execution semantics.
Two‑sided optimisation goals
| Aspect | Authoring Model | Execution Model |
|---|---|---|
| Optimisation goal | Understandability, domain expressiveness, editing fidelity | Performance, unified internal concepts, minimal runtime overhead |
| Structural form | Retains source location, aliases, editor metadata, domain‑specific edit structures | Final assembled Execution Schema, no redundancy |
| Correctness | Round‑trip fidelity, author intent never lost | Behavioural equivalence, deterministic execution |
| Replaceability | Many editors/designers/collaborative engines can produce the same DSL | The same Final Execution Schema can execute on different runtime hosts |
Significance of the boundary
The pre‑compilation boundary means more than “do some optimisation early”. It ensures that structural problems that should never land on the runtime surface are resolved at the structural layer:
- Compile‑time structural decisions — type resolution, renderer binding, default expansion are done during the loading phase; runtime overhead is zero.
- Compile‑time policy trimming — permission nodes, feature flag branches are removed before entering the runtime; the runtime never sees them.
-
Compile‑time action DAG assembly —
then/onError/parallelare assembled at compile time into a cycle‑free execution graph; no graph discovery or cycle detection at runtime. - Unified Value IR — All forms of Value (literal/expression/template/array/object) are compiled into a unified IR; evaluation is unified at runtime.
If a problem can be solved at the structural transformation layer, it must never be dragged onto the runtime surface.
3. Reactive Data‑Driven
Flux’s core execution model is reactive and declarative. The author does not need to explicitly build imperative linkage logic: whenever a dynamic value reads a path from the scope via a field path, name, or ${expr}, it automatically falls into the dependency graph and will be re‑evaluated when the dependency changes.
Basic rhythm: evaluate → collect dependencies → propagate changes → targeted re‑evaluation / invalidation → republish.
Dependency tracking is a built‑in design semantic of the Value primitive. Dependencies are automatically collected during evaluation, not declared statically beforehand. Value, Resource, and Reaction all use this mechanism, but with different consequences: Value is re‑evaluated, Resource is marked dirty and refreshed, a Reaction may trigger a Capability.
The current implementation uses React and useSyncExternalStore to interface with the rendering host, but the principle itself is not tied to any UI framework.
Read/Write and Effect Separation
- Read: Value / Resource published values / Host Projection snapshots are all read‑only via ScopeRef.
-
Write: User edits,
FormRuntime.setValue,ScopeRef.update, Resource publication—these modify owner‑owned data and belong to the data owner side; they will publish store/scope changes, but that is not equivalent to triggering an action. -
Command / Effect: Schema‑authored commands (API calls,
setValueaction, submission, navigation, host commands, etc.) are dispatched only through Capability. - Change → Effect: Data changes do not directly trigger actions; they must pass through a Reaction or a Semantic Lifecycle Entry.
Rendering Host Interfacing
The Store layer runs the reactive logic self‑consistently; React is merely a rendering host that subscribes to Store snapshots.
-
Settled Update Turnis a runtime‑store concept, not a ReactuseEffectordering concept. - React concurrent mode can interrupt, replay, and discard renders; Flux does not constrain such scheduling behaviour—it only defines when the store settles an update turn and when a stable snapshot is published.
- The rendering host consumes the published results; it does not directly hold reactive protocol objects.
4. Gradual Evolution
Complex capabilities in Flux should not be obtained by continually inventing new primitives; they should grow naturally from simpler existing forms. This principle constrains two things at the same time:
- The author‑visible DSL naturally extends from simple forms to complex forms without frequent mental model switches.
- Complex capabilities inside the runtime should be composed from existing primitives (derived system) as much as possible, instead of expanding the primitive set under pressure.
DSL‑level evolution: natural growth from simple forms
| Concept | Simple form | → | Complex form |
|---|---|---|---|
| Value | literal → expression → anonymous source | → | named data-source (Resource) |
| Action | single‑step dispatch | → |
when guard → then/onError branches → parallel fan‑out → compilable to DAG execution graph |
| Structure |
visible (display level) |
→ |
when (lifecycle activation) → loop (collection expansion) → dynamic-renderer (remote assembly) |
| Host write | semantic command | → | generic patch‑style applyPatch
|
Value evolution
The same property chooses the appropriate form based on complexity. How the consumer reads the value stays the same: ${countries} remains uniform from literal to data‑source.
// literal
{ "options": ["draft", "published", "archived"] }
// expression
{ "options": "${role === 'admin' ? adminOptions : userOptions}" }
// source: field‑level anonymous request, not published to scope
{ "options": {
"type": "source",
"action": "ajax",
"args": { "url": "/api/countries", "params": { "region": "${form.region}" } }
}}
// data-source: named Resource with producer lifecycle & scheduling policy
{ "type": "data-source", "name": "countries",
"action": "ajax",
"args": { "url": "/api/countries" },
"interval": 3000,
"stopWhen": "${countries.complete}" }
Action evolution
The compiler recursively assembles nested schemas into a CompiledActionNode DAG (flux-compiler/action-compiler.ts). The runtime simply traverses edges to execute; no graph discovery or cycle detection is needed.
// single-step dispatch
{ "action": "setValue", "args": { "path": "name", "value": "test" } }
// when guard: skip if condition not met, result marked skipped
{ "action": "setValue", "when": "${isEnabled}",
"args": { "path": "name", "value": "test" } }
// then/onError branches: different paths based on ActionResult triage
{ "action": "ajax", "args": { "url": "/api/users" },
"then": { "action": "showToast", "args": { "message": "Save successful" } },
"onError": { "action": "showToast", "args": { "message": "${error.message}" } } }
// parallel fan-out + onSettled aggregation
{ "action": "parallel",
"parallel": [
{ "action": "ajax", "args": { "url": "/api/notify/email" } },
{ "action": "ajax", "args": { "url": "/api/notify/sms" } }
],
"onSettled": { "action": "showToast", "args": { "message": "Notifications complete" } } }
// form submission: submitAction owned by the form node; button is only a thin component:submit trigger
{ "type": "form", "id": "profile-form",
"submitAction": {
"action": "ajax", "args": { "url": "/api/profile", "method": "post" } },
"onSubmitSuccess": { "action": "closeSurface" },
"onSubmitError": { "action": "showToast", "args": { "message": "${error.message}" } } }
Branch contexts (result / error / prevResult) are automatically injected into the evaluation environment during dispatching (flux-action-core/action-core.ts createBranchEvaluationBindings).
Structure evolution
visible and when are not synonyms: fields hidden by visible still participate in validation; subtrees with when=false are entirely inactive and do not participate in the lifecycle.
// visible: display‑level toggle, node still exists
{ "type": "input-text", "name": "adminCode", "visible": "${role === 'admin'}" }
// when: lifecycle activation, affects existence and subtree validation
{ "type": "fragment", "when": "${showSummary}",
"body": [{ "type": "text", "text": "Summary content" }] }
// loop: collection expansion, each iteration gets its own repeated‑item scope
{ "type": "loop", "items": "${users}", "itemName": "user", "indexName": "idx",
"body": [{ "type": "text", "text": "${idx + 1}. ${user.name}" }],
"empty": [{ "type": "text", "text": "No data" }] }
// dynamic-renderer: runtime remote assembly, decides which fragment to render
// Note: it is not a second Resource facet — data‑source produces named values, dynamic‑renderer assembles fragments
{ "type": "dynamic-renderer",
"loadAction": { "action": "ajax", "args": { "url": "/api/schema/${componentType}" } },
"body": { "type": "text", "text": "Loading..." } }
5. Lexical Ownership
This principle is an organisational constraint for Principle 3. Data, capabilities, resources, reactions, and runtime sidecars belong to the lexical scope or subtree boundary; they are not held by a global runtime mega‑object.
Three resolution mechanisms
Data lookup (ScopeRef), action lookup (ActionScope), and instance location (ComponentHandleRegistry) are architecturally separate resolution mechanisms, each with its own scoping rules.
Lexical shadowing
Child scopes override parent publications through natural lexical shadowing, not global overrides. Bindings with the same name can independently exist in different lexical scopes:
// page scope has items
{
"type": "page",
"data": { "items": ["a", "b"] },
"body": [
// dialog child scope also has items, shadows parent
{
"type": "dialog",
"data": { "items": ["x", "y"] },
"body": [{ "type": "text", "text": "${items}" }],
}, // → ["x", "y"]
],
}
Resource publication ownership
Within the same owning scope, the same binding target should not be long‑term co‑occupied by two simultaneously active publishing producers. This constraint applies to authoritative publication (continuous publication by a Resource), not ordinary writes:
// compliant: two forms write to the same path at different times
{ "type": "dialog", "body": [
{ "type": "form", "id": "createForm",
"onSubmitSuccess": { "action": "setValue", "args": { "path": "result", "value": "${result}" } } },
{ "type": "form", "id": "editForm",
"onSubmitSuccess": { "action": "setValue", "args": { "path": "result", "value": "${result}" } } }
]}
// violation: two data‑sources simultaneously claim to publish "status"
{ "type": "page", "body": [
{ "type": "data-source", "name": "status", "action": "ajax", "args": { "url": "/api/a" } },
{ "type": "data-source", "name": "status", "action": "ajax", "args": { "url": "/api/b" } }
]}
Runtime sidecars (Resource state, Reaction state, caches, diagnostics) follow lexical ownership, but they must not become methods or mutable protocol objects attached to a ScopeRef. The Scope hosts the data environment; it does not carry bridges, controllers, handles, or other command‑like objects.
6. Domain Isolation & Abstraction
The Flux core maintains a small, stable abstraction layer. Its goal is not to absorb all frontend domain semantics, but to provide a sufficiently stable execution kernel that allows different domains to grow outside the core.
The litmus test for this principle is not “can the core directly describe all complex systems?” but “can the core provide a stable embedding surface for complex systems without pushing domain complexity back into the core vocabulary?”
Isolation contract
Interactions between domain systems (Flow Designer, Report Designer, Spreadsheet Editor, collaborative engines, CRDT/OT, etc.) and the Flux core are narrowed down to:
| Direction | Mechanism | Meaning |
|---|---|---|
| Core → Domain (read) | Host Projection | Read‑only snapshot projection, host‑driven refresh |
| Domain → Core (write) | Capability | Namespaced command dispatch (e.g., designer:*) |
| Instance location | ComponentHandleRegistry | Explicit target component instance method invocation |
| Host‑private | DomainBridge |
getSnapshot/subscribe/dispatch, does NOT enter Schema‑visible Scope |
Why the core stays stable
- Graph algorithms, layout, collision detection, collaboration protocols, CRDT/OT, local‑first sync, gesture loops—these are all important, but they are domain systems and should not become core primitives. From Flux’s perspective, they are just production strategies behind a Resource, host snapshots behind a Host Projection, or command systems behind a Capability.
- New domains plug in by declaring a host type + projection fields + capability namespace, without needing to introduce new global provider families, environment registries, or new authoritative Schema channels.
- Cross‑domain generic write pattern for editable hosts: read Host Projection → write Capability (structured patch DTO) → DomainBridge host‑private.
Business semantics ownership
Business pipelines (form submission, dialog confirmation, page entry) belong to the node that owns that lifecycle boundary, not to the UI trigger. Concrete examples are given in Section 4 (Semantic Lifecycle Entry). This is a concrete manifestation of lexical ownership and domain isolation.
Summary
| # | Principle | One‑liner |
|---|---|---|
| 1 | DSL First | The DSL is a first‑class structural layer independent of the runtime: editable, composable, and transformable before execution. |
| 2 | Authoring–Execution Separation | Authoring and execution serve different optimisation goals; they should be separated by a pre‑compilation boundary, not mixed in the runtime. |
| 3 | Reactive Data‑Driven | The Value primitive has built‑in dependency tracking, read/write separation, and side effects are funnelled through Capability. |
| 4 | Gradual Evolution | Complexity should grow naturally from simple DSL forms and existing primitives, not by expanding the primitive set under pressure. |
| 5 | Lexical Ownership | Data, capabilities, resources, reactions, and their sidecars belong to lexical/subtree boundaries, not to a global runtime mega‑object. |
| 6 | Domain Isolation & Abstraction | The core provides a small, stable execution kernel; domain complexity stays outside the core and embeds through a narrow contract. |
nop-chaos-flux is open source:
Top comments (0)