All files / lib/core/runtime ComponentScope.js

98.39% Statements 245/249
84.84% Branches 28/33
73.33% Functions 11/15
98.39% Lines 245/249

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 668x 403x 403x 403x 403x 403x 403x 403x 403x       403x 403x 403x 403x 403x 403x 403x 403x 35x 35x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 443x 443x 443x 443x 443x 443x 443x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 403x 609x 609x 609x 609x 609x 604x 604x 5x 609x 609x 4x 4x 609x 1x 1x   609x 403x 403x 403x 403x 403x 403x 403x 609x 609x 609x 609x 8x 8x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 609x 403x 403x 403x 403x 403x 403x 403x 403x 3709x 609x 609x 609x 3709x 3350x 3350x 359x 359x 359x 3709x 403x  
/**
 * @file ComponentScope.js
 * @description How a component resolves the names an expression uses.
 *
 * ## Why this is its own object
 *
 * Now that expressions are compiled at build time, the scope is what remains of
 * the compiler/runtime boundary inside the runtime: the compiler emits closures
 * that take one argument, and this is the thing it is given. Everything about
 * what a component's expressions can see — its state, its computed values, its
 * actions, its props, its bridges, its resources, the values injected into it,
 * the names its own module imported — is decided here and nowhere else.
 *
 * It used to be four private methods and a cache field spread across a
 * 2,900-line class, which made two things hard to see. It hid the ordering
 * rule, which is load-bearing and easy to get wrong. And it hid the fact that a
 * scope was being constructed *per evaluation*: on the compiled path that is
 * one allocation and one layer walk per binding, per update, for an object
 * whose contents almost never change.
 *
 * ## Precedence, and why it is what it is
 *
 * Layers are consulted highest first:
 *
 *   1. per-call extras — a `<@for>` item, an `event`, an action's `args`
 *   2. injected values (`provide` / `inject`)
 *   3. framework names — `props`, `styles`, `$route`, `$emit`, `$watch`, …
 *   4. the component's own actions
 *   5. state and computed values, read from the live reactive proxy
 *   6. resources
 *   7. bridges
 *   8. mixin properties
 *   9. `state` itself
 *  10. the names the component's module imported
 *
 * Bridges sit below the component's own declarations so a bridge cannot
 * silently shadow a `<state>` key or an action; the compiler reports such a
 * collision separately. Imports sit last because an import is the least
 * specific thing in scope.
 *
 * ## Nothing is read until it is named
 *
 * The layers are a Proxy, not a merged object. Spreading the reactive state
 * into a plain object — which is what this replaced — read every key eagerly,
 * which made bare identifiers non-reactive, reported cycles that did not exist,
 * and tied every render to every state key. A `get` here resolves through the
 * layers and, for a state or computed name, reads the live proxy inside
 * whichever watcher is evaluating, so an expression depends on exactly the
 * names it mentions.
 *
 * ## Caching
 *
 * The base scope — the one with no per-call extras — is built once and reused.
 * It is invalidated when something changes what a name resolves to: the set of
 * state keys, the action map, the injected values. A scope with extras derives
 * from it rather than rebuilding, which is why {@link deriveScope} exists: a
 * spread would reintroduce the eager read this whole design removes.
 * @module lib/core/runtime/ComponentScope
 */
 
import {
  createReactiveScope,
  objectLayer,
  stateLayer,
  getterLayer,
  deriveScope,
} from '../reactive/scopeProxy.js';
import { toRaw } from '../reactive/proxyHandler.js';
 
/**
 * The evaluation scope of one component.
 */
export class ComponentScope {
  /**
   * @param {object} owner - The component this scope belongs to.
   */
  constructor(owner) {
    /** @type {object} */
    this.owner = owner;
 
    /**
     * The base scope, built on first use and reused until invalidated.
     * @type {object|null}
     */
    this.base = null;
 
    /**
     * The names the state layer binds, cached because computing them is
     * `Object.keys()` over the whole state object and a scope used to be built
     * per expression evaluated.
     * @type {string[]|null}
     */
    this.stateKeys = null;
 
    /**
     * The method map the cached base scope was built against.
     *
     * A component's actions are established once, but `createMethodMap` hands
     * the scope builder the map it is still filling, so the first scopes are
     * built against an object that is not yet complete. Recording which map the
     * cache belongs to is what stops a stale one being reused.
     * @type {object|null}
     */
    this.methods = null;
  }
 
  /**
   * Discards the cached scope.
   *
   * Called when something changes what a name would resolve to. Cheap enough to
   * call speculatively: the cost is one scope rebuild on the next evaluation.
   */
  invalidate() {
    this.base = null;
    this.stateKeys = null;
  }
 
  /**
   * Discards only the cached state-key list.
   *
   * A key being added to or removed from state changes what the state layer
   * binds but not the shape of the scope, so the layers themselves survive.
   */
  invalidateKeys() {
    this.stateKeys = null;
  }
 
  /**
   * The names state and computed values bind.
   *
   * Read from the raw target and the compiler's computed list rather than by
   * enumerating the proxy, so building a scope registers no dependency and
   * evaluates no computed value.
   * @returns {string[]} The bound names.
   */
  keys() {
    if (this.stateKeys === null) {
      const raw = toRaw(this.owner.state);
      const names = raw && typeof raw === 'object' ? Object.keys(raw) : [];
      this.stateKeys = names.concat(this.owner.__computedKeys());
    }
    return this.stateKeys;
  }
 
  /**
   * The names `provide`/`inject` makes visible.
   *
   * The names are a property of the component's `inject` declaration and do not
   * change; the values behind them do, because a provider higher up the tree
   * can update one at any time. So the names are resolved once and the values
   * are read live -- caching the values is what made a child keep rendering the
   * theme its ancestor no longer provides.
   * @returns {string[]} The injected names.
   */
  #injectedNames() {
    const owner = this.owner;
    const option =
      owner.inject ||
      (typeof owner.constructor.inject === 'function' ? owner.constructor.inject() : owner.constructor.inject);
    if (!option) {
      return [];
    }
 
    const resolved = typeof option === 'function' ? option.call(owner) : option;
    if (Array.isArray(resolved)) {
      return resolved;
    }
    if (resolved && typeof resolved === 'object') {
      return Object.keys(resolved);
    }
    return [];
  }
 
  /**
   * Builds the layered scope.
   * @param {object} methods - The component's executable actions.
   * @returns {object} The scope proxy.
   */
  #build(methods) {
    const owner = this.owner;
 
    const injected = {};
    for (const name of this.#injectedNames()) {
      injected[name] = () => owner[name];
    }
 
    // The set of resources is fixed once the constructor has run, but a scope
    // can be built *during* construction -- `createMethodMap` asks for one --
    // so the names are resolved on read rather than snapshotted here.
    const resources = {
      /**
       * @param {string} key - The name to test.
       * @returns {boolean} Whether a resource is declared under it.
       */
      has: (key) => owner.__resourceNames().includes(key),
      /**
       * @param {string} key - The name to read.
       * @returns {any} The resource's current value.
       */
      get: (key) => owner.__readResource(key),
      /**
       * @returns {string[]} The declared resource names.
       */
      keys: () => owner.__resourceNames(),
    };
 
    return createReactiveScope([
      getterLayer(injected),
      objectLayer({
        props: owner.props,
        styles: owner.styles,
        $route: owner.$route,
        $emit: (eventName, detail) => owner.$emit(eventName, detail),
        $watch: (source, callback, options) => owner.$watch(source, callback, options),
        $watchEffect: (effect, options) => owner.$watchEffect(effect, options),
        $nextTick: (callback) => owner.$nextTick(callback),
      }),
      objectLayer(methods),
      stateLayer(owner.state, () => this.keys()),
      resources,
      owner.__isIsolated() ? null : objectLayer(owner.__bridgeValues()),
      objectLayer(owner._mixinProps),
      objectLayer({ state: owner.state }),
      // Omitted entirely when there are none, so a component that imports
      // nothing resolves through exactly the layers it did before imports
      // became part of a component's scope at all.
      owner.__hasImports() ? objectLayer(owner.__importValues()) : null,
    ]);
  }
 
  /**
   * The scope an expression should be evaluated against.
   * @param {object} [methods] - The action map to expose. Defaults to the owner's.
   * @param {object} [extras] - Per-call bindings layered on top.
   * @returns {object} The scope.
   */
  resolve(methods = this.owner.__methods(), extras = null) {
    if (this.base === null || this.methods !== methods) {
      this.base = this.#build(methods);
      this.methods = methods;
    }
    if (!extras) {
      return this.base;
    }
    // Derived rather than spread: a spread of a scope proxy reads every name it
    // can enumerate, which is the eager read the layering exists to remove.
    return deriveScope(this.base, extras);
  }
}