DEV Community

canonical
canonical

Posted on

Nop Chaos Flux: The Next-Generation Low-Code Rendering Engine After Baidu AMIS

1. Introduction — Why Another Low-Code Framework?

Baidu AMIS is an outstanding design. It is powerful, well-documented, widely used in various enterprise applications, and has a profound influence in the low‑code rendering space. However, AMIS has a long development history — through continuous iteration, its internal implementation has gradually become bloated and complex, and conceptual consistency is suboptimal in many places. Specifically, several issues stand out:

Inconsistent expression rules at the schema layer. Although AMIS automatically handles boolean expressions with the xxxOn suffix and template expressions with the xxxExpr suffix via a generic regex in getExprProperties, it does not generally support template interpolation on plain string properties (e.g., label: "Hello ${name}"), nor expressions inside deeply nested objects. At the same time, static and dynamic values use different field names in the schema: disabled / disabledOn, options / source. Whether a property supports expressions, and which syntax it uses, lacks uniform rules.

Over‑responsibility of the store at runtime, mixing data and behavior. AMIS's MST store acts as both a data container (data field) and a carrier for data operations (updateData, changeValue), API calls (fetchData, saveRemote), dialog management (openDialog, closeDialog) — behavior methods are attached directly to the store. Moreover, the store's data field itself is a scope object built on the prototype chain (Object.create(superProps)), where data inheritance is implicit in the object structure, interweaving reactive updates with variable lookup.

System environment dependencies passed layer by layer through props. All system‑level objects in AMIS — store, env, data, render functions — must be passed down through React props. The props interfaces of renderers grow increasingly bloated; even intermediate layers that do not use these objects must forward them, increasing coupling between components and making renderer interfaces heavy.

Previously, due to the enormous workload, I wrote only a few articles outlining thoughts on improving AMIS's design, never intending to rebuild a better low‑code runtime from scratch (see Why Baidu AMIS Framework is an Excellent Design and Revisiting Baidu AMIS Framework and Declarative Programming). But with the support of AI, a single architect can now directly implement a complex framework. Therefore, starting in April 2026, I designed and implemented the Flux architecture. Flux is a complete rewrite of AMIS, but the goal is not to change the technology stack — it is to address the structural limitations of AMIS at the schema and runtime layers.

Flux is the rendering layer of the Nop platform, not a standalone framework. The Nop platform, based on reversible computing principles, provides a series of structural transformation capabilities before the schema reaches the renderer — i18n substitution, permission pruning, module decomposition and inheritance, compile‑time metaprogramming. These concerns are addressed at the JSON structure level, without relying on any frontend framework runtime mechanisms. Section 10 of this article details these platform‑level capabilities. The rendering framework layer and the platform layer each fulfill their own responsibilities; this layering is an intentional architectural decision: problems that can be solved at the structural transformation layer are not carried into the rendering runtime.

At the same time, Flux is designed with the assumption that the primary way schemas are produced is shifting from human handwriting to AI generation. This premise influences several design decisions — notably the explicitness of the styling system and tolerance for schema redundancy. For AI, explicit and predictable interfaces are friendlier than implicit conventions; for humans, reviewing and locally modifying an explicitly declared schema is also more reliable than understanding implicit defaults. Of course, this does not mean the framework is unusable for human‑authored schemas — it merely tips the design trade‑off toward determinism.

2. Core Philosophy — Unified Value Semantics

The most fundamental difference between Flux and AMIS lies in how values are expressed.

AMIS's approach: a property can be either a static value or an expression. To distinguish the two, AMIS introduces a family of parallel fields in the base schema (amis-core/src/schema.ts). Every property that needs dynamic control is split into two: a static field and an expression variant with an On suffix — disabled / disabledOn, visible / visibleOn, hidden / hiddenOn, static / staticOn. At the form item level there is required / requiredOn, and in some renderers (like Table/CRUD) there are expression variants like classNameExpr.

This split creates difficulties when extending. The number of fields doubles, and schema authors have to remember which properties use the static form and which use the expression form. Worse, the two are mutually exclusive — writing disabled: true and disabledOn together is not allowed, but the framework does not enforce this. It all relies on conventions and documentation.

AMIS's parallel fields elevate the semantic difference inside a value — "this is static" vs. "this is dynamic" — to the structural level of the object, expressing that difference through distinct field names. This approach externalizes a judgment that should belong to the value as an object structure, forcing structural changes when extension is needed.

Flux's approach unifies under a single field name, letting the compiler distinguish the type of value.

disabled is just disabled. It can be a static boolean true, an expression ${$form.submitting}, or a template string containing an expression "${name} is disabled". How to distinguish these forms? Let the compiler decide, rather than having schema authors memorize various suffixes. This judgment is confined to the expression of the value and does not need to be elevated to the object structure level.

The type system behind this is as follows:

type CompiledValueNode<T> =
  | { kind: 'static-node'; value: T }
  | { kind: 'expression-node'; source: string; compiled: CompiledExpression<T> }
  | { kind: 'template-node'; source: string; compiled: CompiledStringTemplate<T> }
  | { kind: 'array-node'; items: CompiledValueNode[] }
  | { kind: 'object-node'; keys: string[]; entries: Record<string, CompiledValueNode> };
Enter fullscreen mode Exit fullscreen mode

At compile time, the compiler analyzes each field's value, determines which kind of node it is, and wraps the result into CompiledRuntimeValue at runtime — static nodes are directly wrapped as StaticRuntimeValue (zero‑cost return), dynamic nodes are wrapped as DynamicRuntimeValue (carrying evaluation closure and state tracking). CompiledValueNode at compile time is a pure data tree; CompiledRuntimeValue at runtime is an executable wrapper:

type CompiledRuntimeValue<T> =
  | { kind: 'static'; isStatic: true; node: StaticValueNode<T>; value: T }
  | {
      kind: 'dynamic';
      isStatic: false;
      node: DynamicValueNode<T>;
      createState(): RuntimeValueState<T>;
      exec(context, env, state): ValueEvaluationResult<T>;
    };
Enter fullscreen mode Exit fullscreen mode

The compiler analyzes each field's value at compile time, determines which kind of node it is, and generates the corresponding compiled result. At runtime, only the compiled result is executed — no further determination is needed. Expression compilation also performs automatic optimization: if the expression's value is constant (e.g., "${1 + 2}" is optimized to the static value 3), it directly returns a static value without incurring dynamic evaluation overhead. Therefore, even if written as an expression, the compiler automatically eliminates dynamic evaluation cost when the result is constant. To output a literal $ symbol in a template string, use ${'$'} for escaping.

By limiting the semantic difference of "a value can be static or dynamic" to the value level via the five node types of CompiledValueNode, three benefits are achieved:

  • Simpler schema: the number of fields is halved; schema authors do not need to memorize xxxOn rules; disabled is just disabled.
  • Unambiguous composition and inheritance: each field name is unique; there is no priority conflict when both disabled and disabledOn appear; semantics remain clear when overriding layer by layer.
  • Compile‑time classification paves the way for later optimizations: static-node takes the zero‑cost fast path; expression-node and object-node independently track references, providing the structural prerequisite for full‑value tree compilation (Section 3).

This design also provides a structural foundation for type safety: each node has an explicit type tag, allowing TypeScript to provide effective type checking on most call paths — the implementation keeps a small number of type assertions for practicality; type safety is layered rather than absolute.

This distinction is not merely syntactic convenience; it sinks the semantic difference of values from the object structure down to the value level. What schema authors see is what exists at runtime, with no cognitive gap.

3. Full‑Value Tree Compilation — Static Fast Path and Dynamic Reuse

An important feature enabled by unified value semantics is that we can compile the entire schema value tree, not just the expressions inside it.

Traditional frameworks typically compile only expressions, using static values directly at runtime. Flux compiles the entire value tree structure.

Consider a static configuration object:

{
  "type": "button",
  "label": "Submit",
  "disabled": false,
  "className": "btn-primary"
}
Enter fullscreen mode Exit fullscreen mode

In a traditional framework, this object must be parsed and passed on every render. In Flux, when the schema is first passed to the renderer, the compiler triggers a one‑time JIT compilation — it recognizes this as a purely static node, generates the corresponding compiled result, and caches it. Every subsequent runtime access to this compiled result directly returns a reference to the original object, without any further decision making.

For an object containing dynamic parts:

{
  "type": "button",
  "label": "Submit",
  "disabled": "${$form.submitting}",
  "className": "${$form.submitting ? 'btn-disabled' : 'btn-primary'}"
}
Enter fullscreen mode Exit fullscreen mode

The compiler compiles this object into an object-node, where each expression field has its own evaluation state. At runtime, each field tracks its previous computed result independently; finally, the assembled object is compared with the previous result using shallowEqual — if none of the field references have changed, the previously computed object reference is returned directly without creating a new object.

RuntimeValueState maintains the previously computed value for each node; the reusedReference flag in ValueEvaluationResult tells the caller whether an old reference was reused. This mechanism works most stably for object structures defined by the schema (object-node): each field's expression is tracked separately; as long as the references of field values do not change, the outer object's reference does not change either.

This design has a significant impact on React performance. React's re‑rendering is based on reference equality; stable object references allow child components to skip unnecessary renders.

4. Scope Chain — Explicit Lexical Interface and Lazy Merging

In low‑code frameworks, scope is a core concept. Components need to access various contextual information such as form data, page parameters, and environment variables.

AMIS builds the scope chain via createObject (amis-core/src/utils/object.ts), which is implemented using JavaScript's prototype chain: child scopes are created via Object.create(parentScope), variable lookup travels upward along the prototype chain, and same‑named variables automatically shadow those in parent scopes. This mechanism is not inherently crude, but its problem lies in implicitness — everything is implemented through the JS engine's internal prototype chain, with no explicit interface contracts, and no way to accurately distinguish between "read only the current layer" and "read the entire chain" without understanding the internal implementation.

Flux adopts an explicit ScopeRef lexical lookup chain:

interface ScopeRef {
  id: string;
  path: string;
  parent?: ScopeRef;
  value: Record<string, any>;
  get(path: string): unknown;
  has(path: string): boolean;
  readOwn(): Record<string, any>;
  readVisible(): Record<string, any>;
  materializeVisible(): Record<string, any>;
}
Enter fullscreen mode Exit fullscreen mode

Each scope is linked via a parent pointer, forming a chain structure. When looking up a variable, the get(path) method walks up the chain until it finds the corresponding value or reaches the root.

This design has several key advantages:

  • Explicit interface semantics: readOwn() returns only the current layer's data; readVisible() returns the lexically visible view; materializeVisible() explicitly expands when a plain object is truly needed. The underlying implementation may use the prototype chain to optimize readVisible() performance, but the interface semantics are independent of the implicit behavior of the JS prototype chain — the boundaries between these operations are immediately clear, without needing to understand internal details.
  • Lazy expansion: only when a complete plain object is truly required does materializeVisible() execute; most of the time access goes through get() or readVisible() to access individual variables, avoiding unnecessary object construction.
  • Testability: ScopeRef is an ordinary interface that can be constructed and tested independently, without relying on the implicit behavior of the JS prototype chain.
  • Traceability: the id and path fields allow scopes to be precisely located during debugging and logging.

The usage pattern is also clear: scope.get(path) is the fast path for high‑frequency operations; scope.readVisible() is suitable for obtaining the lexically visible view; scope.materializeVisible() is the low‑frequency fallback for plain‑object needs.

5. Data, Actions, Components — Three Trees, Three Origins

Flux splits the runtime into three independent tree structures.

Starting from the fundamental structure of object‑oriented GUI systems, a complete GUI runtime essentially contains three orthogonal dimensions: ComponentTree (the organization of components on the interface), StateTree (the distribution and flow of data and state), and ActionTree (the operations that can be performed and their naming/resolution rules). Although these three dimensions collaborate within the same UI, they have fundamentally different growth patterns and lifecycles. Traditional object‑oriented design mixes them into a single tree, which is the root of complexity.

In Flux, these three conceptual dimensions correspond to concrete runtime carriers: StateTree ≈ ScopeRef (lexical data visibility chain), ActionTree ≈ ActionScope (namespace action resolution chain), ComponentTree ≈ compiled Template structure (immutable component tree description). ComponentHandleRegistry is a separate instance‑level location layer responsible for looking up component handles by id/name; it supports the resolution of instance‑targeted actions like component:<method>.

This divide‑and‑conquer approach stems from a fundamental observation about object‑oriented GUI systems: the core essence of OO technology in the GUI domain lies in the organizational relationship between ComponentTree, StateTree, and ActionTree — components form the static structure, data flows in the state tree, and events bubble along the action tree. Flux formalizes this observation into three independent runtime structures, each with explicit lexical lookup semantics.

Particularly noteworthy is the isomorphism between lexical scoping and event bubbling: if we stipulate that event names passed upward are exactly function names, then the event bubbling process can be seen as a function name resolution process in the lexical scope. xui:imports creates different lexical scopes at different levels; action resolution always starts from the nearest scope and, if not found, looks up to the parent scope — exactly the same as variable resolution rules in programming languages. The separation of the three trees is not for its own sake, but because they have fundamentally different growth patterns and lifecycles; forcibly unifying them would only introduce unnecessary coupling.

In traditional low‑code frameworks like AMIS, data and behavior are mixed in the same scope object. Need to access data? Take it from the scope. Need to invoke an action? Also take it from the scope. This design is convenient for simple scenarios, but it masks an essential difference: the composition sources of data and behavior are fundamentally different.

Data is structural. Data flows in naturally as the component tree renders — when a form component mounts, its field values enter the scope; when a data-source component receives a response, the result is written into the scope. The growth of the data scope is strictly bound to the rendering process of the component tree — it is passive and follows the structure.

Behavior is declaratively imported. Through xui:imports, a container can import capabilities from external libraries — demo-lib, spreadsheet-lib. These libraries have no relation to the position of the current component in the tree; they are loaded asynchronously and have their own initialization and destruction lifecycles. The source of behavior lies outside the component tree.

If data and behavior are placed in the same tree, fundamental lifecycle conflicts arise: the data scope follows the mounting/unmounting of components; libraries loaded via xui:imports may need asynchronous initialization, reference counting to manage multiple imports, and independent teardown when unmounted. These requirements cannot be uniformly managed by the same mechanism.

Therefore, Flux splits the runtime into three independent trees:

ScopeRef handles the data layer: values, variables, form state, and other pure data information. get(path) walks up the chain to look up a variable name; readOwn() reads only the current layer; readVisible() returns the lexically visible view — the semantics of the three operations are directly expressed by the interface names, rather than reliant on implementation details.

ActionScope handles action capabilities, i.e., executable operations. Actions are organized by namespaces — designer:addNode, spreadsheet:setCellValue — namespaces are dynamically registered via xui:imports, completely isolated from the data scope, so adding or removing actions does not affect variable lookup. The action resolution order in the action dispatcher has a clear priority: first built‑in platform actions (setValue, ajax, dialog), then component‑targeted actions (component:submit, component:validate), and finally namespace actions (designer:export, spreadsheet:mergeRange). This hierarchy ensures both flexibility and sensible defaults.

ComponentHandleRegistry handles the location and access of component instances. id is a stable and unique anchor within a page; name is a local logical name suitable for reuse within different local boundaries — but if duplicated within the same resolution boundary, an explicit ambiguity error is triggered. Schema authors use component:<method> together with componentId or componentName to specify the target component; the runtime may perform internal index optimizations for statically resolvable targets to reduce common lookup costs.

These three trees share the same design intuition: chain lexical lookup. ScopeRef.get(path) resolves variable names; ActionScope.resolve('demo:open') looks up the demo namespace along the chain (just like lexical scope resolution of function names in programming languages); ComponentHandleRegistry.resolve(target) locates component handles. Each tree maintains its own independent lifecycle semantics, but the lookup logic inside each tree follows the same upward‑chain principle, preserving overall design consistency.

This "three‑tree separation" design continues the separation‑of‑concerns thinking from classic architectures like MVC, but grounds it as a self‑consistent implementation scheme for the low‑code domain.

5.1 xui:actions — Schema‑Local Named Action Chains

The separation of the three trees makes the origin of actions clear and controllable, but a recurring issue remains in practice: how can reusable action sequences be expressed in a schema?

In previous implementations, if multiple schema nodes needed to execute the same action sequence (e.g., a set of ajax calls with fixed parameters), the only options were to define those actions at a higher‑level node and reference them by name, or to duplicate the definitions on each node. The former moves action definitions far from their usage sites; the latter creates redundancy.

xui:actions solves this problem. Schema nodes can now declare named action chains via xui:actions:

{
  "type": "container",
  "xui:actions": {
    "myAction": [
      { "action": "ajax", "args": { "url": "/api/submit" } },
      { "action": "notify", "args": { "message": "Submitted successfully" } }
    ]
  },
  "body": [
    {
      "type": "button",
      "onClick": {
        "action": "myAction"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

At compile time, action chains declared in xui:actions are compiled and stored on the template node. At runtime, they are accessible via ActionScope — a synthetic namespace driven by the compilation results, not requiring an explicit import via xui:imports.

Lexical inheritance is the key design feature: when the requested action name is not found at the current level, the namespace provider falls back upward to the parent scope. This means child nodes automatically inherit named actions defined by parent nodes, without needing to redeclare them. This behavior is exactly consistent with the lexical lookup model of the data scope — although the origin of actions differs from data, the lookup semantics follow the same design intuition.

The action resolution order thus expands to:

  1. Built‑in platform actions (setValue, ajax, dialog, etc.)
  2. Component‑targeted actions (component:submit, component:validate)
  3. Named actions (local action chains defined by xui:actions)
  4. Namespace actions (external libraries imported via xui:imports)
  5. Resolution failure: error reported if none of the above match

With the addition of xui:actions, "named actions" become first‑class citizens in the resolution chain, standing alongside built‑in actions and namespace actions. Each layer has its own origin and priority. This resolves the previous practical pain point: reusable action sequences can be defined locally, inherited lexically, and no longer require a binary choice between global definitions and per‑node duplication.

6. xui:imports — Declarative Capability Import

xui:imports is a direct manifestation of the separation between behavior and data. It addresses a very practical problem: schema authors cannot write import statements; schemas may be dynamically loaded from the server. How can a schema fragment declare the external capabilities it depends on without causing global pollution?

In traditional frameworks, third‑party capabilities are usually introduced through global registration. The problem is that global registration easily leads to conflicts and offers no control over scope.

Flux draws inspiration from ES module imports and introduces the xui:imports declaration:

{
  "type": "container",
  "xui:imports": [{ "from": "demo-lib", "as": "demo" }],
  "body": [
    {
      "type": "button",
      "onClick": {
        "action": "demo:open",
        "args": { "id": "${id}" }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

from specifies the library to import; as specifies the namespace prefix after import. The imported library is registered into the ActionScope of the current container, becoming an action namespace available to that container and its descendants. Complex hosts such as Flow Designer, Report Designer, and Spreadsheet also follow the same lexical boundary discipline: each host page establishes its own local ActionScope, and then registers the host namespace within that boundary.

xui:imports has several key characteristics:

  • Declarative: dependencies are explicitly declared in the schema, without requiring code‑side registration.
  • Lexical visibility: child containers see imports from parent containers, but sibling containers do not see each other's imports — this is intuitive and avoids naming conflicts.
  • Idempotency and automatic deduplication: when the same library is imported at multiple levels, module loading is deduplicated by a normalized import key; registration on the scope side follows the container's lifecycle. The current implementation already has frame‑level reference counting and release paths — repeated installation increments refCount, and on release the import stack is popped and the corresponding registration is cleaned up.
  • Security: the from value is resolved by the host‑provided env.importLoader; the framework itself does not perform any URL resolution or script loading. The security boundary is clearly defined: the framework is responsible for managing lexical visibility and lifecycle (registration and teardown) of imports; the host decides which libraries can be loaded and how. It is recommended that hosts implement a whitelist mechanism, allowing only pre‑registered trusted library identifiers rather than accepting arbitrary URLs. In the current implementation, if the host does not provide an importLoader, imports are not silently ignored but enter an explicit failure state, exposing the wiring error via env.notify('error', ...) and monitor diagnostics; subsequent calls to that namespace return a failure result. Namespace isolation ensures that imported libraries cannot override built‑in platform actions (setValue, ajax, etc.); namespace actions always have lower resolution priority than built‑in actions.

This also explains why ActionScope must be separate from ScopeRef: library loading is asynchronous and has its own registration, reference counting, and teardown semantics, which are fundamentally incompatible with the synchronous, structural growth pattern of the data scope.

7. Data Fetching and Dynamic Schema — Splitting the Service

In AMIS, Service is a "do‑everything" component: it simultaneously takes on two responsibilities — fetching data via api (store.fetchData) and dynamically loading a Schema via schemaApi (store.fetchSchema). These two paths share the same component instance and the same store, tightly coupling their lifecycles. If you want to load both a schema and data, and even add polling, properties like initFetch, initFetchSchema, interval, stopAutoRefreshWhen must be combined, making the intent of the schema fuzzy.

Moreover, the way AMIS merges API request data with the scope is implicit — internally, it uses createObject to merge the current data scope into request parameters, making it difficult for users to precisely control which data is sent to the server. Schema authors must understand these implicit behaviors to use them correctly.

From a computational model perspective, api and schemaApi are two fundamentally different computation patterns. api is essentially a reactive asynchronous computation: it establishes a mapping from "state → remote value", retriggering the request when the state changes — it is an asynchronous version of computed. The input is the current state in the scope; the output is a remote value that updates as the state changes. schemaApi, on the other hand, is a one‑time structure initialization: it fires when the component first mounts, and the result is a description of the render tree, used to decide "what to render", not "what data to show". Mixing the two in the same component couples lifecycles and conflates computational semantics.

Flux splits these two responsibilities into separate renderers. Remote calls uniformly enter the runtime via action dispatch:

{
  "type": "container",
  "body": [
    {
      "type": "data-source",
      "action": "ajax",
      "args": {
        "url": "/api/user/${userId}",
        "includeScope": ["userId"]
      },
      "name": "user",
      "interval": 3000,
      "stopWhen": "${user.loaded}"
    },
    {
      "type": "text",
      "text": "Hello, ${user.name}"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

data-source is dedicated to declarative data fetching. It is a side‑effect node that does not directly render UI: it fetches data via the action mechanism, publishes the result into the current scope under name, and optionally polls via interval + stopWhen. It returns null, so loading, empty, or error states are handled by sibling nodes in the same scope or by the host notification mechanism.

{
  "type": "dynamic-renderer",
  "loadAction": {
    "action": "ajax",
    "args": { "url": "/api/schema/${pageId}" }
  },
  "body": {
    "type": "text",
    "text": "Loading..."
  }
}
Enter fullscreen mode Exit fullscreen mode

dynamic-renderer is dedicated to dynamic schema loading. It declares the loading action via loadAction (unified with the action mechanism used by data-source), obtains the remote schema, and renders it — focusing solely on the "what to render" concern.

This split brings several benefits. First, concerns are clearer: each component does one thing, making intent easier to understand. Second, lifecycles are independent — polling in data-source does not affect schema loading in dynamic-renderer. Finally, the semantics of the request description object become more precise: includeScope explicitly declares which lexical scope variables are injected into the request; params clearly distinguishes URL query parameters; the actual execution path is uniformly converged to the ajax action and the runtime request preparation flow.

8. Field Metadata Driven — Compiler Makes Decisions, Not Renderers

A title field might be a string "Hello", or a rendering fragment {"type": "text", "text": "Hello ${name}"}, or even an event handler. A renderer would have to determine the field type each time and dispatch logic accordingly — error‑prone and performance‑impacting.

Flux's approach: let renderers define field metadata, and let the compiler perform normalization during compilation.

Field metadata includes the kind of field: meta, prop, region, value-or-region, event, ignored, etc. The most interesting is value-or-region, which allows the same field name to be compiled differently depending on the input type:

{
  "type": "card",
  "title": "Simple Title"
}
Enter fullscreen mode Exit fullscreen mode

Here title is a string, compiled and passed directly to the renderer as props.title.

{
  "type": "card",
  "title": {
    "type": "text",
    "text": "${name} - ${status}"
  }
}
Enter fullscreen mode Exit fullscreen mode

Here title is a schema fragment. Compiled and placed into regions.title, the renderer will render the content of this fragment.

The renderer's code becomes extremely simple, requiring no manual decisions:

const titleContent = props.regions.title?.render() ?? props.props.title;
Enter fullscreen mode Exit fullscreen mode

The compiler has already moved the complex decision logic forward, keeping the renderer simple — it only consumes the compiled output.

9. Styling System — Configurable Explicit Styles

Hardcoding styles in renderers (e.g., a container adding gap‑4 by default) makes it hard for schema authors to adjust because they cannot see the default values; providing no styling at all forces every usage scenario to repeat configuration.

Flux's styling system draws from shadcn/ui's design philosophy, dividing styling responsibilities into two layers: visual defaults at the component library layer and structural arrangement at the renderer layer. The underlying UI components (shadcn/ui based on Base UI) come with sensible visual defaults — borders, rounded corners, focus rings, spacing — so that a {"type": "input-text"} renders as a consistently styled input without any additional class names. The renderer layer does not inject extra visual preferences on top of the component library's defaults; className in the schema is used for layout customization and visual tailoring, not for providing basic appearance.

The core principle is: the renderer layer does not inject default visual styles unrelated to the schema's intent. The basic visual appearance of primitive components is provided by shadcn/ui; the renderer is only responsible for translating structural semantics (flex direction, alignment, etc.). This means that when you look at the schema for a component, the className you see is a declaration of layout and customization, not a repetition of basic appearance. Components like nop-container and nop-flex are only responsible for structural semantics and do not carry fixed visual styles. When a renderer maps semantic attributes like direction, align to utility classes like flex, items-center, this is a direct translation of the schema's intent, not an implicit preference introduced outside the schema.

This approach has several practical benefits:

  • Full controllability: all renderer‑layer styles are explicitly visible in the schema; there is no confusion like "where did this spacing come from?"
  • Unambiguous overrides: because the renderer does not inject implicit styles, any custom class names take effect directly, without needing !important to fight framework defaults.
  • Project‑level reusability: the same Tailwind configuration can be shared across pages, ensuring visual consistency.
  • AI‑friendly: an explicit, predictable style interface is more beneficial for AI generation scenarios — the AI does not need to "know" what styles the renderer internally injects; the generated result is WYSIWYG.

When the same set of Tailwind class names appears repeatedly in multiple places, classAliases provides an abstraction for reuse:

{
  "classAliases": {
    "card": "bg-white rounded-lg shadow-md p-4",
    "card-hover": "hover:shadow-lg hover:border-blue-300",
    "btn-primary": "bg-blue-500 text-white hover:bg-blue-600"
  }
}
Enter fullscreen mode Exit fullscreen mode

In components, these aliases are referenced directly:

{
  "type": "card",
  "className": "card card-hover",
  "body": [
    {
      "type": "button",
      "className": "btn-primary",
      "label": "Submit"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

classAliases is a naming abstraction on top of Tailwind — it does not change the principle of "explicitly declared styles". The schema still writes className: "card card-hover"; no style is silently injected by the renderer. It solves the problem of repetitive writing, rather than introducing implicit behavior.

The scope inheritance mechanism is also natural: child components can override aliases of the same name from parent components. For example, if a different card style is needed in a specific region, it can be redefined in that region's classAliases without affecting other places.

10. Layered Responsibilities — What the Renderer Does Not Need to Know

A common design tendency in low‑code frameworks is to stuff every concern into the rendering layer: i18n using the t('key') function, permissions using conditional rendering like v-if="hasPermission", modularization using global registration. This blurs the responsibility boundary of the rendering framework and leads to growing internal complexity.

Flux adopts a different strategy: explicitly divide which concerns belong to the rendering framework, which belong to the platform layer, and which belong to the component library layer.

Nop Platform Layer (structural transformation)
  → i18n text substitution (@i18n: prefix, pure JSON operations)
  → permission pruning (xui:roles / xui:permissions, delete nodes without permission)
  → module decomposition and merging (x:extends / x:gen-extends)
  → compile‑time metaprogramming (XPL template language)
  → XML/JSON bidirectional conversion
         ↓ outputs processed, clean JSON
Flux Rendering Framework Layer
  → unified value compilation, scope management, action dispatch, rendering coordination
         ↓
shadcn/ui / Radix UI Component Layer
  → component‑level visual defaults
  → component‑level accessibility support (ARIA roles, focus management, keyboard navigation)
Enter fullscreen mode Exit fullscreen mode

The core principle of this layering is: each layer solves only its own problems and does not leak responsibilities to adjacent layers.

10.1 Internationalization (i18n) — Structural Transformation at the Platform Layer

i18n is handled at the platform layer via JSON structural transformation, without involving any frontend framework runtime mechanisms. The Nop platform provides two complementary syntaxes:

Inline value approach — compact, suitable for new schemas:

{
  "label": "@i18n:common.batchDelete|Batch delete"
}
Enter fullscreen mode Exit fullscreen mode

The @i18n: prefix identifies the internationalization key to replace; the text after | serves as both a fallback and provides readability — even without looking up an i18n dictionary, one can understand what the schema is saying.

Companion property approach — non‑invasive to the original value, suitable for retrofitting i18n into existing schemas:

{
  "label": "Batch Delete",
  "@i18n:label": "common.batchDelete"
}
Enter fullscreen mode Exit fullscreen mode

Add a corresponding @i18n:key property for each key that needs internationalization; the original value is not modified.

After processing, @i18n: markers no longer exist in the JSON; what the rendering framework sees is the final text for the current locale. This means that even if the rendering layer is replaced — Flux, AMIS, or even native React — i18n continues to work.

10.2 Permission Control — Structural Pruning over Runtime Hiding

Permission control is also handled at the platform layer. The Nop platform defines permission‑related properties such as xui:roles and xui:permissions. Upon receiving the JSON page data, it automatically verifies whether the permission conditions are satisfied and deletes all nodes that do not meet the permission requirements. This processing takes place on the JSON structure, without involving any frontend‑framework‑specific knowledge.

This is safer than runtime conditional hiding: sensitive structures never leave the server. The rendering framework never sees content that the user is not authorized to access, eliminating the risk of "the component is hidden in the DOM but the data has already reached the client".

10.3 Schema Modularization — Decomposition and Merging via Reversible Computing

The Nop platform implements a generic decomposition and merging mechanism for JSON and XML based on reversible computing theory. It can decompose a very large JSON file into multiple small files according to generic rules, effectively supplementing low‑code schemas with module organization syntax.

The two most commonly used syntaxes are: x:extends for inheriting an external file, and x:gen-extends for dynamically generating an inheritable JSON object:

x:gen-extends: |
  <web:GenPage view="NopAuthDept.view.xml" page="main"
               xpl:lib="/nop/web/xlib/web.xlib" />

body:
  name: crud-grid
  actions:
    - type: button
      id: test-button
      label: 'Test'
      onClick:
        action: dialog
        args:
          'x:extends': test.page.yaml
          title: 'Test Dialog'
Enter fullscreen mode Exit fullscreen mode

The above example means: first dynamically generate a CRUD page based on the configuration of NopAuthDept.view.xml, then add a Test button to the batch action button area. The dialog popped up by the button reuses the existing test.page.yaml file; the title property overrides the inherited content from x:extends, setting the dialog title to Test Dialog.

x:extends acts as a generic operator on tree structures, similar to object‑oriented inheritance. This directly solves the modularization difficulty of "one big JSON file per large page" that plagues most low‑code frameworks.

x:gen-extends further allows dynamic generation of the inherited structure at compile time using the XPL template language, enabling compile‑time metaprogramming. For any external file in JSON format, simply replace the ordinary loading function with a call to the Nop platform's ResourceLoader, and the decomposition/merging operations defined by reversible computing are automatically available.

These capabilities are completed before the JSON reaches the renderer; Flux does not need — and should not — embed a module system.

10.4 Bidirectional Conversion between XML and JSON

When writing and reading manually, the XML format has certain advantages over JSON, especially when integrating with external template engines for dynamic generation. The Nop platform adds an XML syntax representation for low‑code schemas, implementing bidirectional conversion between XML and JSON based on a few simple rules:

  1. The type property corresponds to the tag name.
  2. Simple‑type properties correspond to XML attributes.
  3. Complex‑type properties correspond to XML child nodes.
  4. For list types, mark the node with j:list=true.
  5. The body property is specially recognized and does not need explicit j:list.

For example, the following JSON:

{
  "type": "operation",
  "label": "Operation",
  "buttons": [
    {
      "label": "Details",
      "type": "button",
      "level": "link",
      "onClick": {
        "action": "openDialog",
        "args": {
          "title": "View Details",
          "body": {
            "type": "form",
            "body": [
              {
                "type": "input-text",
                "name": "browser",
                "label": "Browser"
              }
            ]
          }
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Corresponds to the XML format:

<operation label="Operation">
  <buttons j:list="true">
    <button label="Details" level="link">
      <onClick action="openDialog">
        <args title="View Details">
          <body>
            <input-text name="browser" label="Browser" />
          </body>
        </args>
      </onClick>
    </button>
  </buttons>
</operation>
Enter fullscreen mode Exit fullscreen mode

The XPL template language in the Nop platform provides many simplifications for dynamically generating XML:

<button xpl:if="xxx" label="${'$'}{grade}" icon="${icon}">
</button>
Enter fullscreen mode Exit fullscreen mode

xpl:if is a conditional expression; the entire node is generated only if it returns true. During generation, if any XML attribute value is null, it is automatically omitted from the final output — leveraging this null attribute filtering mechanism, one can concisely control which attributes are generated.

Schemas converted to XML become very close to ordinary HTML or Vue templates, greatly improving the experience of manual writing and reading.

10.5 Accessibility (a11y) — Responsibility of the Component Library Layer

Accessibility is handled by the underlying component library. shadcn/ui is built on Base UI, which provides complete WAI‑ARIA support:

  • Dialog: automatic focus trapping, Escape to close, focus restoration.
  • Select / Combobox: full ARIA roles, arrow key navigation.
  • Menu: roving tabindex, aria‑expanded state management.
  • Form controls: automatic association between labels and inputs, aria‑describedby for error messages.

The responsibility of the Flux renderer is not to break these existing accessibility capabilities — pass through aria‑* attributes, do not replace semantic elements with div, do not swallow keyboard events. This is about "not doing wrong things" rather than "actively doing things".

10.6 GraphQL Simplification

GraphQL always requires specifying the list of fields to return. However, for a low‑code platform, which fields exist in a form can be derived from the model. The Nop platform supports directly calling a GraphQL backend via REST‑style configuration:

{
  "url": "/r/NopAuthUser__get",
  "data": { "id": "${id}" },
  "gql:selection": "xxx,yyy"
}
Enter fullscreen mode Exit fullscreen mode

gql:selection automatically generates the GraphQL field selection set based on the current form's field definitions, reducing the amount of information schema authors need to manually maintain.

10.7 Summary

This layering allows Flux to maintain a smaller responsibility scope and a simpler internal structure. The rendering framework is a pure schema interpreter; it does not need to know about i18n, permissions, or modularity — those concerns have already been resolved before it sees the JSON. a11y is guaranteed by the component library layer; the renderer only needs to ensure it does not break existing accessibility capabilities. The responsibility boundary of each layer is explicit: problems that can be solved at the structural transformation layer are not carried into the runtime; problems that can be solved at the component library layer are not re‑implemented in the renderer.

11. Technology Stack and Layered Architecture

Flux uses React 19, TypeScript 6.0 strict mode, Zustand 5, Vite 8, TailwindCSS 4, pnpm workspace. The choice of Zustand vanilla stores has a clear architectural motivation: the store is decoupled from the React lifecycle, can be created, updated, and subscribed to independently, and is not tied to the component tree — this directly addresses the tight coupling between the store tree and the component tree in AMIS's MST architecture.

The entire project adopts a layered architecture with clear dependencies between packages:

flux-core (type definitions, contracts, pure utilities)
  → flux-formula (expression compiler, evaluator)
    → flux-compiler (schema compilation, diagnostics, template graph construction)
      → flux-action-core (action compilation and dispatch semantics)
        → flux-runtime (stores, lifecycle, runtime bridging)
          → flux-react (React hooks, rendering layer)
            → flux-renderers-* / designer / editor
              → apps/playground

flux-core
  → flux-i18n
    → flux-react / @nop-chaos/ui
Enter fullscreen mode Exit fullscreen mode

Each layer has clear responsibilities; upper layers depend on lower layers, not vice versa. The dependency direction is one‑way, and package boundaries are clean.

The React integration approach is also carefully designed: explicit at the boundaries, implicit in the middle.

  • Renderer props contain renderer‑local data: schema, node, props, meta, regions, events.
  • Hooks provide access to runtime state: useRendererRuntime, useScopeSelector, useCurrentForm.

This division makes component interfaces clearer — which data comes from props and which is obtained via hooks is immediately obvious.

12. Host Contract — RendererEnv

The host contract is not an invention of Flux. AMIS already designed RendererEnv: renderers do not call fetch() directly, but call env.fetcher(); they do not manipulate routing directly, but call env.jumpTo(). The core insight of this pattern is: a low‑code framework is essentially an interpreter, and its "system calls" should be implemented by the host, not hardcoded by the framework. The framework only specifies the interface; the concrete implementation is left to the host — this allows the same framework to be embedded into different host environments, leaving the choice of HTTP library and routing library on the host side.

Flux makes two improvements on this foundation.

The first is the introduction of env.importLoader. xui:imports is a new capability introduced by Flux; AMIS has no corresponding mechanism, so naturally AMIS's RendererEnv does not have this interface. importLoader takes over the library loading logic for xui:imports declarations — which module identifier to load, how to cache, how to handle loading failures — all these decisions are fully controlled by the host, while the framework is only responsible for calling it at the right time and managing lexical visibility, registration, and release. The security boundary is thus established: the framework is responsible for executing declarations in the schema that have entered the runtime boundary; the loading and execution of external libraries are completely controlled by the host via importLoader.

The second improvement is the clarification of the minimal required interface. Flux's RendererEnv reduces the interface to two required fields — fetcher (network requests) and notify (message notifications) — all other capabilities (navigate, confirm, functions, filters, importLoader, monitor) are optional. Importantly, the fetcher no longer receives an author‑facing ApiSchema, but an ExecutableApiRequest after runtime request preparation; that is, the evaluation of request expressions, includeScope merging, params normalization, and adaptor application occur before fetcher, so the host sees the final executable request.

Together, these two improvements allow Flux's host contract to be summarized by a formula:

output = FluxPage(schema, env)
Enter fullscreen mode Exit fullscreen mode

schema is a declaration describing "what to render"; env is the execution environment provided by the host; FluxPage is the interpreter. As long as RendererEnv is implemented, Flux can be embedded into any host framework without making any assumptions about the host's routing library, request library, or module system. This is the direction of abstraction that AMIS established, and Flux follows it more thoroughly.

13. Performance Design Principles

In Flux, performance is an architectural decision, not a post‑facto optimization. Flux's performance design follows several prioritized principles:

  1. Preserve the static fast path: access to static values should be zero‑cost, directly returning object references.
  2. Preserve dynamic reference reuse: if the result of a dynamic computation has not changed, return the same object reference.
  3. Avoid constructing merged objects in hot paths: prefer scope.get() over scope.materializeVisible().
  4. Keep selector subscriptions precise: subscribe only to the data that is truly needed.
  5. Apply debouncing or cancellation to high‑frequency actions.

useScopeSelector(selector, equalityFn) is an important tool. It allows selective access to data in the scope instead of subscribing to the entire scope. This avoids cascading re‑renders.

The splitting of React contexts is also critical. Different contexts — runtime, scope, action‑scope, component‑registry, node‑meta, form, page — have different change frequencies. Splitting them reduces unnecessary re‑renders.

These designs are framework‑level general guarantees, not dependent on specific scenarios.

14. Error Handling and Developer Experience

Compilation phase: Under the current baseline, failures in compiling expressions and templates are no longer treated as a default "silent degradation" path; compilation/evaluation errors should be understood and handled as potentially throwing exceptions. Structural issues like unknown renderer types should also be explicitly exposed by the compiler or runtime, rather than relying on implicit fallbacks.

Expression evaluation: Errors during runtime expression evaluation propagate upwards. The error tolerance boundary lies at the host side or in React's ErrorBoundary, not inside the framework.

Dynamic schema loading: When the loadAction request of dynamic-renderer fails, an inline error message is rendered (Error: {message}); the body region is rendered as placeholder content before the schema loading completes. Retrying requires changing the loadAction dependencies to trigger re‑mounting.

Development tools: nop-debugger currently provides a floating debug panel to view timelines for compile, render, action, api, notify, error events, along with a network view and node inspection capabilities based on data-cid. With component handles and form stores, it can already show some component state and form state snapshots. The namespace chain of ActionScope and the complete internal index of ComponentHandleRegistry are not yet exposed as stable debug UI; the expression evaluation panel also remains disabled. Dynamic nodes (expression-node, template-node) retain the source field after compilation, allowing tracing back to the original expression text during debugging; static nodes have no source field.

Strict validation mode: Schema authors can enable strictValidation: true on SchemaRenderer to activate strict compilation mode. In strict mode, the compiler performs stricter checks on schema properties: for renderers with a closed‑prop model, unknown schema properties are reported as errors; for open renderers, unknown properties are reported as warnings. This distinction ensures strictness without affecting renderer types that are designed to accept arbitrary properties.

The practical value of strict mode lies in forward‑compatibility safety: when a renderer's schema contract changes (e.g., a property is renamed or removed), strict mode immediately catches residual old properties instead of silently ignoring them. This immediate feedback avoids subtle issues where "the schema looks correct but the behavior is not as expected". Strict mode can also be toggled at runtime via the debugger, allowing real‑time schema validation without redeployment.

15. Validation Owner Hierarchy

Flux's validation system is organized around a hierarchical Owner structure, where each Owner manages a set of validation contracts with independent lifecycles.

Form owner: the primary validation Owner within a form scope, responsible for managing form‑level field validation. When a renderer detects type: "form", it automatically creates a Form owner and takes over the validation lifecycle of all fields under it.

Page‑root owner: SchemaRenderer creates a validation Owner at the page root level, responsible for handling validation of top‑level fields that do not belong to any form. This ensures that even without an explicit form wrapper, top‑level fields still receive validation support.

Surface owner: a separate validation Owner created by a hosted dialog or drawer surface, providing scoped validation for the content carried by the surface. The lifecycle of a Surface owner is bound to the opening/closing of the surface; when closed, all validation states under it are automatically destroyed.

Detail‑child owner: child validation contracts created by detail‑field / detail‑view, attached to a parent Owner, supporting staged editing and draft validation. Child owners can validate independently without affecting the validation state of the parent Owner; only when submitting are the validation results merged into the parent.

Each Owner has explicit lifecycle stages: bootstrappingactiverefreshingdisposed. The bootstrapping stage completes field registration and mounting of initial validation rules; the active stage accepts user interactions and real‑time validation; the refreshing stage handles synchronization of validation state due to scope data refreshes; the disposed stage cleans up all validation resources.

The formId mechanism makes cross‑form operations possible: actions like setValue / setValues / submitForm can precisely specify the target form via formId, and explicitly fail when formId does not match — avoiding the subtle issue of actions being mistakenly sent to the wrong form.

16. Summary — Design Philosophy

Looking back over the entire Flux design, several consistent core principles can be seen:

  1. Unified semantics over parallel field families. The semantic difference inside a value is determined at the value level, not elevated to object structure; one field, multiple forms, compiler decides, unique field name ensures unambiguous composition.

  2. Compile once, static optimization, dynamic reuse. A one‑time JIT compilation is triggered when the schema loads; at runtime, the benefits of static analysis and dynamic reuse are both enjoyed.

  3. Lexical scope chain, explicit interface, lazy merging. get() is the fast path; readVisible() provides the visible view; materializeVisible() is the low‑frequency fallback; interface semantics are clear and testable.

  4. GUI three‑tree orthogonal separation. ComponentTree, StateTree, ActionTree are independent, sharing the design intuition of lexical lookup but maintaining their own lifecycle semantics.

  5. Declarative import and lexical visibility. ES‑module‑style imports, in‑scope capability visibility, independent lifecycle management (idempotent loading + reference counting + release).

  6. Field‑metadata‑driven compilation; renderers do not guess. The compiler handles complex logic up front; renderers consume normalized output.

  7. Single responsibility, clear computational semantics. data-source is a runtime‑owned value‑producing node; loadAction is a one‑time structure initialization; remote calls uniformly enter the runtime via action dispatch, while the request description object is only responsible for the transport contract.

  8. Configurable explicit styles. The renderer layer does not inject default visual styles unrelated to the schema's intent; basic component visual appearance is provided by shadcn/ui; layout and customization are explicitly declared in the schema via Tailwind utility classes and classAliases, balancing predictability and reusability.

  9. Carry forward and improve the host contract. RendererEnv is the direction of abstraction established by AMIS; Flux builds on it by introducing env.importLoader to support xui:imports and narrowing the fetcher to the final executable request boundary, making the framework a pure schema runtime.

  10. Layered resolution, no cross‑boundary handling. i18n, permissions, modularization are solved at the platform layer via JSON structural transformation; a11y is solved at the component library layer via Radix UI; the rendering framework layer only handles its own concerns — compilation, scoping, actions, rendering coordination. The responsibility boundary of each layer is explicit: problems that can be solved at the structural transformation layer are not carried into the runtime; problems that can be solved at the component library layer are not re‑implemented in the renderer.

  11. Performance designed into the architecture up front. Not post‑facto patching, but a design constraint from the first line of code.

These principles have dependencies among them: unified value semantics enables full‑value tree compilation; value‑level determination makes field composition unambiguous; the three‑tree orthogonal separation allows the asynchronous lifecycle of xui:imports to be managed independently; the computational semantic separation of data-source and dynamic-renderer provides a more solid theoretical foundation for splitting the Service; RendererEnv gives the outermost layer of the layered architecture a clear host integration boundary. Flux does not need to be a do‑everything framework; it only needs to get the rendering layer right, leaving the rest to the platform layer and the component library layer, each fulfilling its own role.

nop-chaos-flux is open source:

Top comments (0)