Source

tracking.js

/**
 * ```javascript
 * // Unminified
 * import { tracked } from 'https://fancy-pants.js.org/tracking.js';
 *
 * // Minified
 * import { tracked } from 'https://fancy-pants.js.org/min/tracking.js';
 * ```
 *
 * This is the auto-tracking system and a use case for
 * [scheduleRender]{@link module:rendering.scheduleRender}.
 * @module tracking
 */
let $REVISION = 0;
let CURRENT_TRACKER = null;
let scheduleRerender = () => {};
const TRACKABLES = new WeakMap();

/**
 * Represents a value that is tracked. When the value is set it will dirty
 * itself and schedule a render (if applicable). The dirty-ness is taken into
 * account when an instance of Cache consumes and instance of Tracked.
 */
export class Tracked {
  /**
   * @property {number} the latest revision number for this Tracked
   */
  revision = $REVISION;

  #value;

  /**
   * @param {any} [initialValue] an optional initial value for this Tracked
   * instance
   */
  constructor(initialValue) {
    this.#value = initialValue;
  }

  /**
   * This is the property to access the tracked value. Reading this property
   * will consume it. Setting this property will dirty it.
   * @property {any}
   */
  get value() {
    this.consume();
    return this.#value;
  }

  set value(newValue) {
    if (this.#value === newValue) return;
    this.#value = newValue;
    this.dirty();
  }

  /**
   * Will dirty this Tracked instance. It will not change the value.
   */
  dirty() {
    this.revision = ++$REVISION;
    scheduleRerender();
  }

  /**
   * Will mark this as consumed. This does not affect the value property.
   */
  consume() {
    CURRENT_TRACKER?.add(this);
  }

  /**
   * Grab a cached Tracked instance for a particular property on a specific
   * owner. This is used to allow tracking of something without modifying the
   * owner itself. Useful for things like getAttribute/setAttribute on DOM
   * nodes.
   *
   * @prop {Object} owner the object that we want to associate the Tracked with
   * @prop {string} prop the property name we want to associate the Tracked with
   * @prop {Function} [initializer] a function that returns the Tracked initial
   * value if a Tracked hasn't already been created previously
   * @return {Tracked} the cached (or newly cached) Tracked instance for the
   * associated property and owner
   */
  static for(owner, prop, initializer = () => {}) {
    let trackablesForOwner = TRACKABLES.get(owner) ?? new Map();
    let tracked = trackablesForOwner.get(prop) ?? new Tracked(initializer());
    trackablesForOwner.set(prop, tracked);
    TRACKABLES.set(owner, trackablesForOwner);
    return tracked;
  }

  /**
   * Convenience function to make a proxy over an object such that any access
   * to one of its properties will be tracked.
   *
   * @example
   * let obj = { foo: 'FOO' };
   * let trackedProxy = Tracked.proxy(obj);
   * trackedProxy.foo = 'BAR'; // will trigger a render (becomes dirty)
   * obj.foo = 'BAZ'; // will NOT trigger a render
   *
   * @param {Object} [owner] an object to proxy. Default is an empty Object.
   * @return {Proxy}
   */
  static proxy(owner = {}) {
    return new Proxy(owner, {
      get(target, prop) {
        Tracked.for(target, prop).consume();
        return Reflect.get(...arguments);
      },
      set(target, prop) {
        Tracked.for(target, prop).dirty();
        return Reflect.set(...arguments);
      },
    });
  }

  /**
   * Will define a property on an owner that is tracked.
   *
   * ```javascript
   * let obj = {};
   * Tracked.defineProperty(obj, 'foobar', new Tracked('FOO'));
   * ```
   *
   * Is the same as
   *
   * ```javascript
   * let obj = Tracked.activate({
   *   foobar: new Tracked('FOO')
   * });
   * ```
   *
   * @prop {Object} owner the object that we want to define a property on
   * @prop {string} prop the property name we want to define
   * @prop {Tracked} [tracked] a Tracked instance. Default is new Tracked()
   */
  static defineProperty(owner, prop, tracked = new Tracked()) {
    Object.defineProperty(owner, prop, {
      get() {
        return tracked.value;
      },
      set(newValue) {
        tracked.value = newValue;
      },
    });
  }

  /**
   * Loop over the properties of the owner and any properties that are
   * a Tracked instance convert them to a getter/setter.
   *
   * @example
   * class Foo {
   *   bar = new Tracked();
   * }
   * let foo = Tracked.activate(new Foo());
   * foo.bar = 'BAR'; // will trigger a render (becomes dirty)
   *
   * @example
   * let obj = Tracked.activate({ bar: new Tracked('FOO') });
   * foo.bar = 'BAR'; // will trigger a render (becomes dirty)
   *
   * @prop {Object} owner the object er want to convert all Tracked values to
   * a getter/setter
   * @return {Object} the owner
   */
  static activate(owner) {
    for (let prop of Reflect.ownKeys(owner)) {
      let { value: tracked } = Reflect.getOwnPropertyDescriptor(owner, prop);
      if (tracked instanceof Tracked) {
        Tracked.defineProperty(owner, prop, tracked);
      }
    }
    return owner;
  }
}

/**
 * Represents a cache for a tracked value. It uses a function to find the
 * actual value and caches it. Then as long as none of the Tracked instances it
 * encounters during the execution of the function have not dirtied then the
 * result of this Cache's value remains the same. Only when Tracked instances
 * are dirtied would this re-execute the original function to update the cached
 * value.
 *
 * This handles auto-tracking which means that while it is executing the
 * function it is keeping track of any Tracked consumed. It then knows which
 * Tracked to check with when it determines if it needs to re-evaluate the
 * cached value.
 */
export class Cache {
  #cachedValue;
  #cachedRevision = -1;
  #deps = [];

  /**
   * @param {Function} valueFn the function called to update the cached value
   */
  constructor(valueFn) {
    this.valueFn = valueFn;
  }

  /**
   * Execute the value function if it needs to be updated based on any
   * dependent Tracked it encounters.
   * @return {any} the cached value of the previous execution
   */
  execute(...args) {
    if (this.revision > this.#cachedRevision) {
      let { valueFn } = this;
      let currentTracker = new Set();
      let prevTracker = CURRENT_TRACKER;
      setCurrentTracker(currentTracker);

      try {
        this.#cachedValue = valueFn(...args);
      } finally {
        setCurrentTracker(prevTracker);
        this.#deps = Array.from(currentTracker);
        this.#cachedRevision = this.revision;
      }
    }

    CURRENT_TRACKER?.add(this);

    return this.#cachedValue;
  }

  /**
   * @property {any} the cached value
   */
  get value() {
    return this.execute();
  }

  /**
   * @property {number} the latest revision number for this Cache
   */
  get revision() {
    return Math.max(...this.#deps.map(d => d.revision), 0);
  }

  /**
   * Convenience method to wrap a function in a Cache so that it is memoized.
   * @prop {Function} fn the function to be wrapped
   * @return {Function} a memoized version of the function
   */
  static memoize(fn) {
    let cachedFunction = new Cache(fn);
    return function cachedFunctionWrapper(...args) {
      return cachedFunction.execute(...args);
    };
  }
}

/**
 * Used for testing Tracked.consume() method.
 * @private
 * @prop {Set} tracker
 */
export function setCurrentTracker(tracker) {
  CURRENT_TRACKER = tracker;
}

/**
 * Register a function to be called when a Tracked instance is dirtied.
 * There can only be one. This is not used when working with the
 * [renderer]{@link module:rendering} or {@link Component} class as they mange
 * this for you.
 *
 * You would only use this if you were interfacing with this module on your own.
 *
 * @param {function} callback the function to call when tags are dirtied
 */
export function setScheduleRerender(scheduleRerenderFn) {
  scheduleRerender = scheduleRerenderFn;
}

/**
 * Wrap a function to be memoized based on the auto-tracking system.
 * @param {function} fn the function to memoized
 * @return {function} a memoized version of the function
 */
export function memoizeFunction(fn) {
  return Cache.memoize(fn);
}

/**
 * Create a Tracked instance.
 * @see [Tracked]{@link module:tracking.Tracked}
 *
 * @example
 * class Foo {
 *   bar = tracked();
 * }
 * let foo = new Foo();
 * foo.bar.revision; // 0
 * foo.bar.value = 'BAR';
 * foo.bar.revision; // 1
 *
 * @example
 * let foo = { bar: tracked('FOO') };
 * foo.bar.revision; // 0
 * foo.bar.value = 'BAR';
 * foo.bar.revision; // 1
 *
 * @param {any} [initialValue]
 * @return {Tracked}
 */
export function tracked(initialValue) {
  return new Tracked(initialValue);
}

/**
 * This is how all [tracked]{@link module:tracking.tracked} properties are
 * converted from the place holder TrackedProperty object into actual tracked
 * getter/setters. Any object that uses [tracked]{@link module:tracking.tracked}
 * must be passed through `activateTracking` otherwise the properties are
 * useless.
 * @see [Tracked.activate]{@link module:tracking.Tracked.activate}
 *
 * @example
 * class Foo {
 *   bar = tracked();
 * }
 * let foo = activateTracking(new Foo());
 * foo.bar = 'BAR'; // will trigger a render (becomes dirty)
 *
 * @example
 * let obj = activateTracking({ bar: tracked('FOO') });
 * foo.bar = 'BAR'; // will trigger a render (becomes dirty)
 *
 * @param {Object} owner the object to search and replace any Tracked
 * objects.
 * @return {Object} the same owner object passed in to support chaining
 */
export function activateTracking(owner) {
  return Tracked.activate(owner);
}