All files / lib/core/trace derived.js

96.52% Statements 111/115
80% Branches 16/20
66.66% Functions 2/3
96.52% Lines 111/115

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 116404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 5x 5x 5x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x 7x 7x     7x 7x 7x 3x 3x 3x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 4x 4x 3x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 404x 404x 404x 404x 404x 404x 404x 404x 404x 404x      
/**
 * @file derived.js
 * @description Trace hooks for derived values — computed properties and
 * bridge getters.
 *
 * Derived values are read far more often than they change. A component's
 * template may read `cart.total` on every render, and a bridge getter is
 * re-evaluated on every access because it has no cache of its own. Recording
 * every evaluation would bury the causal graph under noise that says nothing.
 *
 * So a derived value is recorded only when its result actually changed, which
 * is the only form in which it explains anything: `cart.total 24.00 → 36.00` is
 * a step in the causal chain, while `cart.total read` is not.
 * @module lib/core/trace/derived
 */
 
import { tracer } from './tracer.js';
import { TraceNodeType } from './schema.js';
 
/**
 * Last recorded value per derived key, so a re-evaluation that produced the
 * same answer can be dropped.
 *
 * Keyed by owner object so entries disappear with the component or bridge they
 * belong to, and populated only while a recording is running.
 * @type {WeakMap<object, Map<string, any>>}
 */
const lastValues = new WeakMap();
 
/**
 * Compares two derived results.
 *
 * Deliberately reference equality with a NaN special case, not a deep compare:
 * a deep compare on every evaluation of every computed would cost more than
 * the tracing it serves. A computed returning a fresh object each time is
 * therefore recorded on every evaluation, which is accurate — it did produce a
 * new value, and that is usually worth seeing.
 * @param {any} a - Previous value.
 * @param {any} b - Current value.
 * @returns {boolean} True when the value is unchanged.
 */
function unchanged(a, b) {
  return a === b || (Number.isNaN(a) && Number.isNaN(b));
}
 
/**
 * Records that a derived value was re-evaluated and produced a new result.
 * @param {object} owner - The component or bridge that owns the value.
 * @param {object} descriptor - What was evaluated.
 * @param {string} descriptor.name - The computed or getter name.
 * @param {string} [descriptor.kind] - `computed` for a component, `getter` for a bridge.
 * @param {string} [descriptor.owner] - The owning component or bridge name.
 * @param {string} [descriptor.expression] - The expression source, when Avenx has it.
 * @param {string[]} [descriptor.contracts] - Compiler contracts the owner declared.
 * @param {any} value - The value the evaluation produced.
 * @param {object} [known] - The caller's own record of the previous value.
 * @param {boolean} known.has - Whether the caller knows the previous value.
 * @param {any} known.value - The previous value.
 */
export function traceDerived(owner, descriptor, value, known) {
  const recorder = tracer.sink;
  if (!recorder) {
    return;
  }
 
  let seen = lastValues.get(owner);
  if (!seen) {
    seen = new Map();
    lastValues.set(owner, seen);
  }
 
  // A component's computed already has a cached previous value in its watcher,
  // and the caller passes it in. Without that, the first evaluation inside a
  // recording would only be able to establish a baseline — and the very first
  // change after recording started, which is usually the interesting one,
  // would go unreported.
  const had = known && known.has ? true : seen.has(descriptor.name);
  const previous = known && known.has ? known.value : seen.get(descriptor.name);
  seen.set(descriptor.name, value);
 
  // Where no previous value is knowable, the first evaluation establishes a
  // baseline rather than reporting a change: "undefined -> 24" on first render
  // is an artefact of when recording started, not something that happened.
  if (!had || unchanged(previous, value)) {
    return;
  }
 
  const path = descriptor.owner ? `${descriptor.owner}.${descriptor.name}` : descriptor.name;
  const kind = descriptor.kind || 'computed';
 
  tracer.record(TraceNodeType.COMPUTED, {
    name: descriptor.name,
    kind,
    // A bridge getter's owner is a bridge, not a component. Recording it in the
    // component slot would inflate the component count in a listing and make
    // `avenx trace view` claim a bridge is a component.
    ...(kind === 'getter' ? { bridge: descriptor.owner } : { component: descriptor.owner }),
    expression: descriptor.expression,
    contracts: descriptor.contracts,
    from: recorder.capture(previous, path),
    to: recorder.capture(value, path),
  });
}
 
/**
 * Forgets the baseline values for an owner.
 *
 * Called when a recording stops so a later recording of the same long-lived
 * bridge starts from a clean baseline rather than comparing against values
 * observed in a previous session.
 * @param {object} owner - The component or bridge.
 */
export function forgetDerived(owner) {
  lastValues.delete(owner);
}