All files / lib/core/runtime AvenxPage.js

91.26% Statements 303/332
83.07% Branches 54/65
90% Functions 9/10
91.26% Lines 303/332

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 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333304x 304x 304x 304x 304x 304x 304x 304x 304x 304x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 198x 198x 198x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 190x 190x 190x 124x 124x 124x 124x 124x 124x 124x 190x 190x 25x 25x 165x 165x 165x 165x 165x 165x 165x 165x 190x 1x 1x 165x 190x 51x 51x 51x 51x 51x 51x 74x 47x 47x 27x 27x 41x         74x 51x 190x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x 124x                 124x 124x 124x 124x 124x 42x 42x 2x 2x 2x     2x 2x 42x 42x 42x 1x 1x 42x 1x 1x 42x 124x 124x 124x 124x 124x 124x 198x 198x 173x 173x 173x 173x 173x 173x 173x 173x 198x 28x 2x 2x 2x 2x 2x 28x 173x 173x 173x 173x 50x 50x 50x 50x 45x 45x 50x 5x 5x 5x 5x     5x 3x 3x 5x 1x 1x 1x 5x         5x 50x 50x 49x 49x 49x 49x 49x 49x 49x 49x 49x 49x 49x 49x 49x 69x 19x 19x 19x 19x         19x 69x 49x 49x 49x 25x 25x 23x 23x 23x     25x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 49x 24x 24x 24x 24x 24x 50x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 173x 198x 124x 124x 124x 124x 124x 124x 124x 1x 1x 124x 124x 124x 124x 124x 124x 124x 173x 172x 172x 1x 1x 1x 1x 2x 2x 1x 2x 1x 1x 1x 1x 1x 1x 1x 1x   173x 124x  
import { AvenxComponent } from './AvenxComponent.js';
import { logger } from './AvenxLogger.js';
import { AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { queueFlushCallback } from '../reactive/scheduler.js';
 
/**
 * AvenxPage is a specialized component that can host child components.
 * It automatically mounts child components defined in its template via [data-avenx-comp].
 */
export class AvenxPage extends AvenxComponent {
  /** @type {Map<string, Function>} */
  #componentRegistry;
  /** @type {Map<Element, AvenxComponent>} */
  #childComponents = new Map();
 
  /**
   * Set while a child-prop reconciliation is already queued for this flush.
   * @type {boolean}
   */
  #childPropsQueued = false;
 
  /**
   * @param {object} initialState - Initial state.
   * @param {object} computed - Computed properties.
   * @param {object} bridges - Shared bridges.
   * @param {string} template - HTML template.
   * @param {object} methods - Component methods.
   * @param {Map<string, Function>} componentRegistry - Registry of available components.
   * @param {object} props - Component properties.
   * @param {object} styles - Component CSS variables.
   * @param {object} [resources] - Reactive resources.
   * @param {object} [options] - Component options and compiler contracts.
   */
  constructor(
    initialState = {},
    computed = {},
    bridges = {},
    template = '',
    methods = {},
    componentRegistry = new Map(),
    props = {},
    styles = {},
    resources = {},
    options = {},
  ) {
    super(initialState, computed, bridges, template, methods, props, styles, resources, options);
    this.#componentRegistry = componentRegistry;
  }
 
  /**
   * Updates the page and then mounts/updates child components.
   *
   * The child pass runs inside {@link AvenxPage#runUpdate}, not here, so that
   * the state a `data-props-*` expression reads is collected by the page's
   * render watcher. Mounting children after `super.update()` returned put
   * those reads outside the watcher, leaving the page with no dependency on
   * the state it passes down -- which only worked while every write scheduled
   * a re-render regardless of what read it.
   */
  update() {
    super.update();
    this.#mountChildComponents();
  }
 
  /**
   * Renders the page, and registers the state its children's props depend on.
   *
   * Mounting children stays outside the render watcher, in {@link
   * AvenxPage#update}: it *writes* to each child's props, and doing that inside
   * the watcher made the page depend on its children's props, so a child
   * setting its own prop re-ran the parent and overwrote the value.
   *
   * The reads still have to be inside the watcher, though, or the page has no
   * dependency on the state it passes down. `data-props-*` expressions are
   * therefore evaluated here for their dependencies and the results discarded.
   * That only mattered once unobserved writes stopped scheduling a render;
   * before, every write re-rendered every component regardless.
   * @override
   */
  runUpdate() {
    super.runUpdate();
    this.#trackChildPropDependencies();
  }
 
  /**
   * Evaluates child `data-props-*` expressions so the page's render watcher
   * records what they read. Results are discarded.
   * @private
   */
  #trackChildPropDependencies() {
    const root = this.$element;
    if (!root || typeof root.querySelectorAll !== 'function') {
      return;
    }
 
    // Queried separately rather than with a comma selector: this runs against
    // whatever DOM the host provides, and a selector list is one of the first
    // things a minimal implementation leaves out.
    const elements = [
      ...Array.from(root.querySelectorAll('[data-avenx-comp]')),
      ...Array.from(root.querySelectorAll('[data-avenx-comp-dynamic]')),
    ];
    if (root.hasAttribute && (root.hasAttribute('data-avenx-comp') || root.hasAttribute('data-avenx-comp-dynamic'))) {
      elements.unshift(root);
    }
 
    for (const el of elements) {
      if (!el.attributes) continue;
      // A compiled mount point has no prop attributes left to read, and needs
      // none: each prop is already a tracked effect of its own, which is a
      // strictly finer dependency than the page-wide one this pass records.
      if (el.__axProps) continue;
      for (const attr of el.attributes) {
        if (!attr.name.startsWith('data-props-') && attr.name !== 'data-avenx-comp-dynamic') {
          continue;
        }
        try {
          this._evaluate(attr.value);
        } catch {
          // A prop expression that throws is reported when it is used for
          // real, in #mountChildComponents. This pass exists only to collect
          // dependencies and must never change what the page reports.
        }
      }
    }
  }
 
  /**
   * Pushes changed compiled props into already-mounted children.
   *
   * On the compiled path each `data-props-*` expression is its own reactive
   * effect, so a prop that changed says so directly instead of the page
   * re-evaluating every prop of every child whenever anything changed.
   *
   * Coalesced to once per flush: several props of several children can change
   * in one tick, and the mount pass reconciles all of them in one go.
   * @override
   */
  __onChildPropsChanged() {
    if (this.#childPropsQueued) return;
    this.#childPropsQueued = true;
    queueFlushCallback(() => {
      this.#childPropsQueued = false;
      if (this.$isUnmounted) return;
      this.#mountChildComponents();
    });
  }
 
  /**
   * Unmounts the page and all child components.
   */
  unmount() {
    const promises = [];
    for (const compInstance of this.#childComponents.values()) {
      if (typeof compInstance.unmount === 'function') {
        const res = compInstance.unmount();
        if (res instanceof Promise) {
          promises.push(res);
        }
      }
    }
    this.#childComponents.clear();
    const superRes = super.unmount();
    if (superRes instanceof Promise) {
      promises.push(superRes);
    }
    if (promises.length > 0) {
      return Promise.all(promises).then(() => {});
    }
  }
 
  /**
   * Finds all mount points for child components and initializes or updates them.
   * @private
   */
  #mountChildComponents() {
    const root = this._getElement();
    if (!root) return;
 
    const mountPoints = [
      ...Array.from(root.querySelectorAll('[data-avenx-comp]')),
      ...Array.from(root.querySelectorAll('[data-avenx-comp-dynamic]')),
    ];
    const currentElements = new Set(mountPoints);
 
    // 1. Clean up/unmount child components whose elements are no longer in the DOM/page
    for (const [el, compInstance] of this.#childComponents.entries()) {
      if (!currentElements.has(el) || !root.contains(el)) {
        if (typeof compInstance.unmount === 'function') {
          compInstance.unmount();
        }
        this.#childComponents.delete(el);
      }
    }
 
    // 2. Instantiate new components or update existing ones
    const registry = this.#getRegistry();
    mountPoints.forEach((el) => {
      let CompClass = null;
      let compName = null;
 
      if (el.hasAttribute('data-avenx-comp')) {
        compName = el.getAttribute('data-avenx-comp');
        CompClass = registry.get(compName);
      } else if (el.hasAttribute('data-avenx-comp-dynamic')) {
        const dynamicExpr = el.getAttribute('data-avenx-comp-dynamic');
        try {
          let resolvedVal = this._evaluate(dynamicExpr);
          if (resolvedVal && resolvedVal[Symbol.for('rawTarget')]) {
            resolvedVal = resolvedVal[Symbol.for('rawTarget')];
          }
          if (typeof resolvedVal === 'string') {
            compName = resolvedVal;
            CompClass = registry.get(resolvedVal);
          } else if (typeof resolvedVal === 'function') {
            CompClass = resolvedVal;
            compName = resolvedVal.name;
          }
        } catch (e) {
          logger.warn(
            formatMessage(AvenxErrorCodes.PAGE_PROP_EVALUATION_FAILED, dynamicExpr, e.message || e)
          );
        }
      }
 
      if (CompClass) {
        // Props come from whichever mechanism owns them.
        //
        // On the compiled path the parent's program evaluated each
        // `data-props-*` expression as its own reactive effect and left the
        // result on the element, so the attributes are gone and re-evaluating
        // them here would find nothing. On the string path they are still
        // attributes holding expression source, evaluated per render.
        //
        // Checked per element rather than per page: a page can carry a program
        // while a child mount point added by other means does not.
        const props = el.__axProps ? { ...el.__axProps } : {};
        if (!el.__axProps) {
          for (const attr of el.attributes) {
            if (attr.name.startsWith('data-props-')) {
              const propName = attr.name.slice('data-props-'.length);
              try {
                props[propName] = this._evaluate(attr.value);
              } catch (e) {
                logger.warn(
                  formatMessage(AvenxErrorCodes.PAGE_PROP_EVALUATION_FAILED, attr.value, e.message || e)
                );
              }
            }
          }
        }
 
        if (this.#childComponents.has(el)) {
          const compInstance = this.#childComponents.get(el);
          if (compInstance.constructor === CompClass) {
            if (typeof compInstance.setProps === 'function') {
              compInstance.setProps(props);
            } else if (typeof compInstance.update === 'function') {
              compInstance.update();
            }
          } else {
            // Component class changed! Unmount the old one and mount the new one
            if (typeof compInstance.unmount === 'function') {
              compInstance.unmount();
            }
            // Clear content of el before mounting new one to avoid merge issues
            el.innerHTML = '';
            const newInstance = new CompClass(this._getBridges(), props);
            newInstance.$parent = this;
            newInstance.mount(el);
            this.#childComponents.set(el, newInstance);
          }
        } else {
          const compInstance = new CompClass(this._getBridges(), props);
          compInstance.$parent = this;
          compInstance.mount(el);
          this.#childComponents.set(el, compInstance);
        }
      } else {
        // If it was dynamic and is now null/undefined/unresolved, we should unmount any existing component
        if (this.#childComponents.has(el)) {
          const compInstance = this.#childComponents.get(el);
          if (typeof compInstance.unmount === 'function') {
            compInstance.unmount();
          }
          this.#childComponents.delete(el);
          el.innerHTML = '';
        }
        if (compName) {
          logger.warn(formatMessage(AvenxErrorCodes.PAGE_COMPONENT_NOT_REGISTERED, compName));
        }
      }
    });
  }
 
  /**
   * Retrieves the component registry.
   * @returns {Map<string, Function>}
   * @protected
   */
  _getComponentRegistry() {
    return this.#componentRegistry;
  }
 
  /**
   * Resolves the component registry dynamically by traversing parent page instances.
   * @returns {Map<string, Function>}
   * @private
   */
  #getRegistry() {
    if (this.#componentRegistry instanceof Map) {
      return this.#componentRegistry;
    }
    const root = this._getElement();
    if (root) {
      let parentEl = root.parentNode;
      while (parentEl) {
        if (
          parentEl.__avenx_comp_instance &&
          typeof parentEl.__avenx_comp_instance._getComponentRegistry === 'function'
        ) {
          const reg = parentEl.__avenx_comp_instance._getComponentRegistry();
          if (reg instanceof Map) {
            return reg;
          }
        }
        parentEl = parentEl.parentNode;
      }
    }
    return new Map();
  }
}