DEV Community

canonical
canonical

Posted on

Design Principles of nop-chaos-flux, a Next-Generation Low-Code Rendering Framework

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:

  1. Compile‑time structural decisions — type resolution, renderer binding, default expansion are done during the loading phase; runtime overhead is zero.
  2. Compile‑time policy trimming — permission nodes, feature flag branches are removed before entering the runtime; the runtime never sees them.
  3. Compile‑time action DAG assemblythen / onError / parallel are assembled at compile time into a cycle‑free execution graph; no graph discovery or cycle detection at runtime.
  4. 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, setValue action, 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 Turn is a runtime‑store concept, not a React useEffect ordering 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}" }
Enter fullscreen mode Exit fullscreen mode

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}" } } }
Enter fullscreen mode Exit fullscreen mode

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..." } }
Enter fullscreen mode Exit fullscreen mode

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"]
  ],
}
Enter fullscreen mode Exit fullscreen mode

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" } }
]}
Enter fullscreen mode Exit fullscreen mode

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)