Source: lib/core/reactive/Resource.js

import { AvenxWatcher } from './watcher.js';
import { tracer } from '../trace/tracer.js';
import {
  takeRecordedResponse,
  traceResourceStart,
  traceResourceSettle,
} from '../trace/resource.js';

/**
 * A reactive resource that evaluates a handler function asynchronously.
 * Supports Suspense and Error Boundaries by throwing Promises/Errors during render.
 */
export class Resource {
  /**
   * @param {string} name - Resource name.
   * @param {function(AbortSignal): any} handlerFn - The function to execute (e.g., fetch call).
   * @param {object} [componentContext] - The component instance context.
   * @param {object} [options] - Resource options (e.g., pollInterval).
   */
  constructor(name, handlerFn, componentContext, options = {}) {
    this.name = name;
    this.handlerFn = handlerFn;

    let ctx = componentContext;
    let opts = options;
    if (
      componentContext &&
      typeof componentContext === 'object' &&
      !('renderWatcher' in componentContext || 'update' in componentContext || '$app' in componentContext || 'state' in componentContext) &&
      'pollInterval' in componentContext
    ) {
      opts = componentContext;
      ctx = null;
    }

    this.componentContext = ctx || null;
    this.options = opts || {};

    this.status = 'idle'; // 'idle' | 'pending' | 'resolved' | 'rejected'
    this.value = undefined;
    this.error = undefined;
    this.promise = null;

    // Race-condition guard and cancellation tokens
    this._requestId = 0;
    this._abortController = null;

    this.pollTimer = null;
    this.pollInterval = this.options.pollInterval ? Number(this.options.pollInterval) : 0;

    /**
     * The trace node for the request currently in flight, so its settlement can
     * point back at it.
     * @type {object|null}
     */
    this.traceRequest = null;

    // Create a watcher that tracks reactive dependencies inside handlerFn
    this.watcher = new AvenxWatcher(
      () => {
        // During replay the recorded settlement stands in for the handler
        // entirely, rather than running it and discarding the result: the
        // handler *is* the network call, so calling it would hit the network.
        // The cost is that a replayed resource does not re-track the
        // dependencies its handler reads. Any behaviour that depends on it
        // shows up as a divergence during replay rather than passing quietly.
        const recorded = takeRecordedResponse(this.name);
        if (recorded) {
          return recorded.status === 'rejected'
            ? Promise.reject(Object.assign(new Error(recorded.error?.message || 'Recorded failure'), {
              name: recorded.error?.name || 'Error',
            }))
            : Promise.resolve(recorded.value);
        }
        // Evaluate the handler to track dependencies
        return this._executeHandler();
      },
      (newResult) => {
        // Triggered when reactive dependencies change
        this.fetch(newResult);
      },
      { name: `Resource#${name}` }
    );

    // Initiate the first fetch manually with the initial evaluated value
    this.fetch(this.watcher.value);

    if (this.pollInterval > 0) {
      this.pollTimer = setInterval(() => {
        const val = typeof this.watcher.get === 'function' ? this.watcher.get() : this.watcher.value;
        this.fetch(val);
      }, this.pollInterval);
    }
  }

  // --- Non-throwing Status Accessors ---

  /**
   * Indicates whether the resource is currently pending.
   * @returns {boolean}
   */
  get loading() {
    return this.status === 'pending';
  }

  // --- Internal Helper Methods ---

  /**
   * Invokes the user handler passing the active abort signal.
   * @private
   */
  _executeHandler() {
    if (this._abortController) {
      this._abortController.abort();
    }
    this._abortController = new AbortController();
    return this.handlerFn.call(this.componentContext, this._abortController.signal);
  }

  /**
   * Notifies the parent component to schedule a re-render.
   * @private
   */
  _notifyComponent() {
    if (this.componentContext) {
      if (this.componentContext.renderWatcher) {
        this.componentContext.renderWatcher.dirty = true;
      }
      if (typeof this.componentContext.update === 'function') {
        this.componentContext.update();
      }
    }
  }

  /**
   * Evaluates the resource result and updates internal state with request ID guard.
   * @param {any} result - The result from the handler (Promise or sync value).
   * @returns {Promise<any>}
   */
  fetch(result) {
    const currentId = ++this._requestId;
    this.status = 'pending';
    this.error = undefined;

    const pending = tracer.on ? traceResourceStart(this.name, this.componentContext, this.pollInterval) : null;
    this.traceRequest = pending;

    if (result && typeof result.then === 'function') {
      this.promise = result.then(
        (val) => {
          if (tracer.on) {
            traceResourceSettle(pending, this.name, 'resolved', val);
          }
          // Ignore stale responses if a newer request was issued
          if (currentId !== this._requestId) return val;

          this.status = 'resolved';
          this.value = val;
          this._notifyComponent();
          return val;
        },
        (err) => {
          if (tracer.on) {
            traceResourceSettle(pending, this.name, 'rejected', err);
          }
          // Ignore stale or intentionally aborted responses
          if (currentId !== this._requestId) return;
          if (err && (err.name === 'AbortError' || err.message === 'canceled')) return;

          this.status = 'rejected';
          this.error = err;
          this._notifyComponent();
        }
      );
    } else {
      // Synchronous result handling
      this.status = 'resolved';
      this.value = result;
      this.promise = Promise.resolve(result);
      if (tracer.on) {
        traceResourceSettle(pending, this.name, 'resolved', result);
      }
      this._notifyComponent();
    }

    return this.promise;
  }

  // --- Imperative API ---

  /**
   * Imperatively triggers a re-fetch, bypassing watcher dependency updates.
   * @returns {Promise<any>}
   */
  refetch() {
    const result = this._executeHandler();
    return this.fetch(result);
  }

  /**
   * Imperatively sets the local value without a network call (useful for optimistic UI).
   * @param {any} nextValue
   */
  mutate(nextValue) {
    // Invalidate any in-flight requests so a pending fetch doesn't override this mutation
    this._requestId++;
    if (this._abortController) {
      this._abortController.abort();
      this._abortController = null;
    }

    this.status = 'resolved';
    this.value = nextValue;
    this.error = undefined;
    this.promise = Promise.resolve(nextValue);

    this._notifyComponent();
  }

  /**
   * Reads the resource value.
   * Throws Promise if pending (Suspense).
   * Throws Error if rejected (ErrorBoundary).
   * Returns value if resolved.
   * @returns {any}
   */
  read() {
    if (this.status === 'pending' && this.promise) {
      throw this.promise;
    }
    if (this.status === 'rejected') {
      throw this.error;
    }
    return this.value;
  }

  /**
   * Cleans up the watcher, aborts pending in-flight requests, and clears the polling timer.
   */
  teardown() {
    this._requestId++;
    if (this._abortController) {
      this._abortController.abort();
      this._abortController = null;
    }
    if (this.pollTimer) {
      clearInterval(this.pollTimer);
      this.pollTimer = null;
    }
    if (this.watcher) {
      this.watcher.teardown();
    }
  }
}