Tracker.once helper

I’ve been working on a helper for Tracker to fix a common use case we seem to have, where we need a function to run reactively until some condition is met, then “pause” until some other condition is met, or some external event happens. Of course, this can be done without a helper using extra reactive variables, but I thought I’d have a crack at writing a helper for it. A better name might be Tracker.pausable

One such use case would be when a user is offline, you’d need to use a cached method result, but when they come back online you’d want to get the live result from the server and stop. However, if the data to the template changes, you’d want to start up again to get the latest value. in the onCreated callback in a template, it might look like this, though you could also pass in { useGuard: true } if you wanted to wrap the precondition in a guard statement:

const self = this;
this.thingIwantOnceComp = this.once(
  () => Template.currentData(),
  (comp, dataFromPrecondition) => {
    if (Meteor.status().connected) {
      Meteor.call("someMethod", dataFromPrecondition.whatever, (err, res) => {
        if (err) {
          //handle error
          return;
        }
        comp.pause(res);
      });
    }
    else {
        comp.result.set(someCachedResult);
    }
  }
);

// maybe some condition to trigger comp.resume() manually.
// somewhere else you can call templInstance.thingIwantOnceComp.result.get();

I’d be interested to see if other people have found that they come across similar situations, and how they solve it (we’ve been doing it adhoc until now) and if they see any problems with the below solution:

/**
  @param fnPrecondition function to re-enable reactivity (optional)
  @param fnBody the body to be called
  @param useGuard should Tracker.guard wrap the fnPrecondition? (optional)
  @param onError standard onError argument to autorun (optional)
  @this could be a templateInstance, or the Tracker class.

  @description Run the fnBody reactively until comp.pause() is called.
  At which point fnBody will not be called again.
  If comp.resume() is called, reactivity is resumed.
  If comp.resume(true) is called, reactivity is resumed, and fnBody is immediately called.
  If fnPrecondition is provided, reactivity in it will trigger comp.resume(true)
  The result of fnPrecondition is passed to fnBody as the second argument
  if useGuard == true, pfnPrecondition will be wrapped in Tracker.guard
*/
Tracker.once = function once(fnPrecondition, fnBody, { onError, useGuard = false } = {}) {
  if (!fnBody) {
    fnBody = fnPrecondition;
    fnPrecondition = null;
  }
  else if (!_.isFunction(fnBody)) {
    onError = fnBody.onError;
    useGuard = fnBody.useGuard;
    fnBody = fnPrecondition;
    fnPrecondition = null;
  }
  if (!fnBody) {
    throw new Meteor.Error("Expected at least one function as an argument");
  }
  const autorun = this.autorun || Tracker.autorun;
  const result = new ReactiveVar();
  const paused = new ReactiveVar(false);
  const forceResume = new Tracker.Dependency();
  useGuard = Tracker.guard && useGuard;
  let preconditionResult;
  if (fnPrecondition) {
    autorun.call(this, (comp) => {
      comp.result = result;
      comp.forceResume = forceResume;
      comp.paused = paused;
      preconditionResult = useGuard ? Tracker.guard(() => fnPrecondition(comp)) : fnPrecondition(comp);
      comp.resume(true);
    }, { onError });
  }
  return autorun.call(this, (comp) => {
    comp.result = result;
    comp.forceResume = forceResume;
    comp.paused = paused;
    forceResume.depend();
    if (!Tracker.nonreactive(() => paused.get())) {
      fnBody(comp, preconditionResult);
    }
  }, { onError });
};

Blaze.TemplateInstance.prototype.once = Tracker.once;

Tracker.Computation.prototype.pause = function pause(result) {
  this.result.set(result);
  this.paused.set(true);
};


Tracker.Computation.prototype.resume = function resume(force) {
  this.paused.set(false);
  if (force) {
    this.forceResume.changed();
  }
};