Source: lib/core/reactive/scopeProxy.js

/**
 * @file scopeProxy.js
 * @description The evaluation scope handed to template expressions, computed
 * values and action bodies.
 *
 * ## What was wrong with building it as an object
 *
 * The scope used to be assembled with a spread:
 *
 * ```js
 * const scope = { state: this.state, ...this.bridges, ...this.state, ...methods, … };
 * ```
 *
 * `...this.state` reads every key on the reactive proxy, eagerly, every time a
 * scope is built — which is once per render, per action call and per computed
 * evaluation. Three separate defects followed from that one line.
 *
 * **Bare identifiers were not reactive.** The documented computed form
 * `<computed value="count * 2" />` reads `count` out of the *snapshot* the
 * spread produced, not out of the proxy, so no dependency was registered for
 * the expression itself. `state.count * 2` worked; the form in the README,
 * quickstart and state-management guide did not, and failed silently — the
 * first render was correct and the value then never changed again.
 *
 * **Computed values reported false cycles.** Spreading the state proxy reads
 * the computed key currently being evaluated. That re-entered the computed's
 * own watcher mid-collection, tripping the re-entrancy guard: every app using
 * computed properties logged `AVX_R04 Circular dependency detected` on every
 * re-render, for a cycle that did not exist. Worse, the re-entrant
 * `watcher.evaluate()` reset the watcher's dependency set part-way through
 * collection, which is what actually lost the `count` dependency above. The
 * bogus warning and the missing dependency were the same event.
 *
 * **Reactivity was component-granular.** Because the spread touched every key,
 * the render watcher depended on all of them. Changing a key no expression
 * mentioned still re-rendered the component, so the per-key precision the Proxy
 * layer computes was discarded one level above it.
 *
 * ## What this does instead
 *
 * Nothing is read until an expression actually names it. The scope is a Proxy
 * over ordered layers; a `get` resolves through them and, for a state or
 * computed key, reads the live reactive proxy — inside whichever watcher is
 * currently evaluating. So `count` in `count * 2` registers a dependency on
 * `count` and on nothing else.
 *
 * ## Derivation instead of spreading
 *
 * Several call sites layer extra names onto a scope — a `<@for>` loop variable,
 * an `event`, an action's `args`. Written as `{ ...scope, index }` that would
 * reintroduce the eager read this module exists to remove, so scopes derive
 * instead: {@link deriveScope} pushes another layer without materialising
 * anything. It falls back to a spread for plain objects, so external code that
 * passes an ordinary object still works.
 * @module lib/core/reactive/scopeProxy
 */

/**
 * Marks a value as one of this module's scope proxies.
 * @type {symbol}
 */
export const IS_REACTIVE_SCOPE = Symbol.for('avenx.reactiveScope');

/**
 * Derives a child scope from a scope proxy.
 * @type {symbol}
 */
export const SCOPE_DERIVE = Symbol.for('avenx.scopeDerive');

/**
 * Reports whether a value is a scope proxy created here.
 * @param {any} value - The value to test.
 * @returns {boolean} True for a reactive scope proxy.
 */
export function isReactiveScope(value) {
  return !!(value && typeof value === 'object' && value[IS_REACTIVE_SCOPE]);
}

/**
 * Layers extra bindings on top of an existing scope.
 *
 * Prefer this over `{ ...scope, extra }` anywhere a scope gains names. A spread
 * of a scope proxy reads every key it can enumerate, which is exactly the eager
 * read this module removes.
 * @param {object} scope - The scope to extend. May be a scope proxy or a plain object.
 * @param {object} extras - The bindings to layer on top.
 * @returns {object} A scope with the extras applied.
 */
export function deriveScope(scope, extras) {
  if (!extras) {
    return scope;
  }
  if (isReactiveScope(scope)) {
    return scope[SCOPE_DERIVE](extras);
  }
  return { ...scope, ...extras };
}

/**
 * @typedef {object} ScopeLayer
 * @property {function(string): boolean} has - Whether the layer binds a name.
 * @property {function(string): any} get - The value bound to a name.
 * @property {function(string, any): boolean} [set] - Assigns to a bound name.
 * @property {function(): string[]} keys - The names the layer binds.
 */

/**
 * Builds a layer over a plain object.
 * @param {object} source - The object to expose.
 * @returns {ScopeLayer} The layer.
 */
export function objectLayer(source) {
  const target = source || {};
  return {
    /**
     * @param {string} key - The name to test.
     * @returns {boolean} Whether the object binds it.
     */
    has(key) {
      return Object.prototype.hasOwnProperty.call(target, key);
    },
    /**
     * @param {string} key - The name to read.
     * @returns {any} The bound value.
     */
    get(key) {
      return target[key];
    },
    /**
     * @param {string} key - The name to write.
     * @param {any} value - The value to assign.
     * @returns {boolean} Always true.
     */
    set(key, value) {
      target[key] = value;
      return true;
    },
    /**
     * @returns {string[]} The names bound.
     */
    keys() {
      return Object.keys(target);
    },
  };
}

/**
 * Builds a layer over the component's reactive state.
 *
 * The names are known without reading anything: they come from the raw target
 * and the compiler's computed list. A value is read from the proxy only when an
 * expression asks for it, which is what makes the read land inside the watcher
 * that is currently evaluating.
 * @param {object} state - The reactive state proxy.
 * @param {function(): string[]} listKeys - Returns the bound names.
 * @returns {ScopeLayer} The layer.
 */
export function stateLayer(state, listKeys) {
  let cached = null;
  return {
    /**
     * @param {string} key - The name to test.
     * @returns {boolean} Whether state binds it.
     */
    has(key) {
      if (!cached) {
        cached = new Set(listKeys());
      }
      return cached.has(key);
    },
    /**
     * @param {string} key - The name to read.
     * @returns {any} The value, read through the reactive proxy.
     */
    get(key) {
      return state[key];
    },
    /**
     * @param {string} key - The name to write.
     * @param {any} value - The value to assign.
     * @returns {boolean} Always true.
     */
    set(key, value) {
      state[key] = value;
      return true;
    },
    /**
     * @returns {string[]} The names bound.
     */
    keys() {
      if (!cached) {
        cached = new Set(listKeys());
      }
      return [...cached];
    },
  };
}

/**
 * Builds a layer of lazily evaluated bindings.
 *
 * Used for resources, whose value is produced by calling `read()` and must not
 * be produced merely because a scope was built.
 * @param {Object<string, function(): any>} getters - Name to getter.
 * @returns {ScopeLayer} The layer.
 */
export function getterLayer(getters) {
  const source = getters || {};
  return {
    /**
     * @param {string} key - The name to test.
     * @returns {boolean} Whether a getter is bound.
     */
    has(key) {
      return Object.prototype.hasOwnProperty.call(source, key);
    },
    /**
     * @param {string} key - The name to read.
     * @returns {any} The getter's result.
     */
    get(key) {
      return source[key]();
    },
    /**
     * @returns {string[]} The names bound.
     */
    keys() {
      return Object.keys(source);
    },
  };
}

/**
 * Creates the evaluation scope.
 *
 * Layers are given in precedence order, highest first: the first layer that
 * binds a name wins. That mirrors the object-literal precedence the previous
 * implementation had, where later spreads overwrote earlier ones.
 * @param {ScopeLayer[]} layers - The layers, highest precedence first.
 * @returns {object} A scope proxy.
 */
export function createReactiveScope(layers) {
  const active = layers.filter(Boolean);

  /**
   * Finds the layer that binds a name.
   * @param {string} key - The name to resolve.
   * @returns {ScopeLayer|null} The binding layer, or null.
   */
  function resolve(key) {
    for (const layer of active) {
      if (layer.has(key)) {
        return layer;
      }
    }
    return null;
  }

  const handler = {
    /**
     * @param {object} target - Unused backing object.
     * @param {string|symbol} key - The name to test.
     * @returns {boolean} Whether the scope binds it.
     */
    has(target, key) {
      if (key === IS_REACTIVE_SCOPE || key === SCOPE_DERIVE) return true;
      if (typeof key === 'symbol') return false;
      return resolve(key) !== null;
    },

    /**
     * @param {object} target - Unused backing object.
     * @param {string|symbol} key - The name to read.
     * @returns {any} The bound value, or undefined.
     */
    get(target, key) {
      if (key === IS_REACTIVE_SCOPE) return true;
      if (key === SCOPE_DERIVE) {
        return (extras) => createReactiveScope([objectLayer(extras), ...active]);
      }
      if (typeof key === 'symbol') return undefined;
      const layer = resolve(key);
      return layer ? layer.get(key) : undefined;
    },

    /**
     * @param {object} target - Unused backing object.
     * @param {string|symbol} key - The name to write.
     * @param {any} value - The value to assign.
     * @returns {boolean} Whether the write was accepted.
     */
    set(target, key, value) {
      if (typeof key === 'symbol') return false;
      const layer = resolve(key);
      if (layer && typeof layer.set === 'function') {
        return layer.set(key, value);
      }
      // An undeclared name assigned from an action lands on the highest layer
      // that can hold it, matching the previous behaviour where
      // `scope[key] = value` created a key on the scope object.
      for (const candidate of active) {
        if (typeof candidate.set === 'function') {
          return candidate.set(key, value);
        }
      }
      return false;
    },

    /**
     * Enumerating a scope materialises it, which is what spreading one does.
     * Present so that external code holding a scope still behaves; internal
     * call sites use {@link deriveScope} instead.
     * @returns {string[]} Every bound name.
     */
    ownKeys() {
      const names = new Set();
      for (const layer of active) {
        for (const key of layer.keys()) {
          names.add(key);
        }
      }
      return [...names];
    },

    /**
     * @param {object} target - Unused backing object.
     * @param {string|symbol} key - The name to describe.
     * @returns {object|undefined} A descriptor for a bound name.
     */
    getOwnPropertyDescriptor(target, key) {
      if (typeof key === 'symbol') return undefined;
      if (!resolve(key)) return undefined;
      return { configurable: true, enumerable: true, writable: true, value: this.get(target, key) };
    },
  };

  return new Proxy(Object.create(null), handler);
}