LearnNewsExamplesServices

The Config System & Circular Dependencies

With the class blueprint compiled and our reactive API (getters and setters) generated by Neo.setupClass, we face a complex runtime challenge. Complex user interfaces have intertwined state. Updating these properties naively leads to circular dependencies, infinite loops, and broken lifecycle hooks.

The Neo.mjs runtime engine provides a robust, predictable Config System designed specifically to handle complex state mutations gracefully.

The Trailing Underscore Convention

As established during the compilation phase, the core mechanism for defining a reactive property is the trailing underscore in the static config block:

class MyComponent extends Base {
    static config = {
        // Non-reactive (applied directly to prototype)
        className: 'MyApp.MyComponent',
        
        // Reactive (engine generates getter/setter and hooks)
        title_: 'Default Title',
        isActive_: false
    }
}

Config Descriptors

Sometimes, a simple default value isn't enough. You might need to instruct the engine on how to handle a specific config when it is merged, cloned, or compared for equality. For advanced use cases, Neo.mjs allows you to define a reactive config using a Descriptor Object instead of a primitive value.

You create a descriptor using the [Neo.core.ConfigSymbols.isDescriptor] symbol:

import {isDescriptor} from '../core/ConfigSymbols.mjs';

class MyList extends Component {
    static config = {
        // A complex reactive config defined via a descriptor
        items_: {
            [isDescriptor]: true,
            clone         : 'shallow',    // How to clone the value when setting
            cloneOnGet    : 'none',       // How to clone the value when reading
            isEqual       : () => false,  // Custom equality function
            merge         : 'deepArrays', // Strategy for merging configs during setup
            value         : []            // The actual default value
        }
    }
}

When Neo.setupClass encounters a descriptor, it extracts the rules and uses the value property as the actual default. The underlying Neo.core.Config instance then uses these rules to govern how the property behaves at runtime (e.g., bypassing deep equality checks for performance, or defining custom array merging logic).

Push-Based Lifecycle Hooks

When you assign a new value to a reactive config at runtime (this.title = 'New Title'), the generated setter automatically pushes that change through a sequence of optional lifecycle hooks:

  1. beforeSetTitle(value, oldValue): Runs before the value is applied. This is the gatekeeper. It is useful for validation or data transformation. Returning undefined from this hook cancels the update entirely.
  2. afterSetTitle(value, oldValue): Runs after the value is successfully applied and validated. This is where you trigger imperative side effects (e.g., updating the DOM or fetching new data).

The undefined Sentinel Value

In Neo.mjs, undefined is a strict sentinel value indicating "initial instantiation".

When an instance is constructed, its afterSet hooks will fire for the very first time as default values are applied. In this initial execution, the oldValue argument will always be exactly undefined.

afterSetTitle(value, oldValue) {
    if (oldValue !== undefined) {
        // This logic will ONLY run on subsequent runtime updates,
        // safely skipping the initial setup phase.
        this.update();
    }
}

Because of this specific architectural meaning, you should never set a config to undefined after it has been initialized. If you need to clear a value or reset state, explicitly use null.

Solving Circular Dependencies (The configSymbol)

The true test of a config system is how it handles batch updates where configs depend on each other. Imagine setting two configs at the same time where the validation logic of one depends on the new value of the other.

The Temporary Holding Zone

src/core/Base.mjs solves this using a configSymbol (a hidden ES6 Symbol property on the instance) as a temporary holding zone.

When you call this.set() to perform a batch update with multiple properties, all new reactive values are placed into this[configSymbol]. The engine then processes them sequentially.

The brilliance of this mechanism lies in the generated getter. The getter for any reactive config is programmed to check this[configSymbol] before checking the internal backing property. As a result, any lifecycle hook will immediately receive the new, pending value of a sibling config, even if that property hasn't fully processed its own beforeSet/afterSet cycle yet.

Example: Cross-Dependent Configs

Consider this simplified scenario based on Neo.examples.core.config.MainContainer:

Without the configSymbol buffering these values, when afterSetA runs, this.b would still be null, resulting in a broken calculation. The holding zone guarantees that during a batch set(), all hooks operate against the "future state" of the instance, eliminating timing bugs and resolving circular read dependencies gracefully.

Preventing Infinite Write Loops

While the configSymbol solves circular reads safely, what happens if afterSetA writes to b, and afterSetB writes to a? Could they bounce back and forth infinitely?

This is where the low-level Neo.core.Compare utility acts as the ultimate safety net. Before an afterSet hook is fired, the generated setter passes the new value through the deep comparison engine. An afterSet hook is ONLY executed if the value has actually changed. If your mathematical calculations stabilize and produce the same result, the loop is broken immediately.

The configSymbol elegantly solves local component state mutations. But this push-based model requires writing explicit afterSet hooks. As an application scales, manually wiring hooks to synchronize global state leads to unmaintainable "spaghetti code." The core engine needs a brain to track dependencies automatically.