0

In our project we have a following class based resource

export class MinuteTimer extends Resource<MinuteTimerArgs> {
  @service
  declare dateManager: DateManagerService;
  @tracked localDateTime: LocalDateTime | undefined;
  valueSet = false;

  constructor(...args: ConstructorParameters<typeof Resource>) {
    super(...args);
    registerDestructor(this, () => {
      // final teardown
      this.tickLocalMinute.cancelAll();
    });
  }

  tickLocalMinute = dropTask(
    async (condition = () => true, testEnvironmentTimeoutMs: number) => {
      // this helps during the testing to update the localDateTime this number of times and then breaking
      let testCountdown = 3;

      do {
        const now = this.dateManager.getCurrentLocalDateTimeAtVenue();

        // update the localDateTime when a condition is true
        // but set it unconditionally for the 1st time
        if (!this.valueSet || condition(now)) {
          this.localDateTime = now;
          this.valueSet = true;
        }

        const startOfTheNextMinute = now
          .plusMinutes(1)
          .truncatedTo(ChronoUnit.MINUTES);

        if (config.APP['isNotTestEnvironment']) {
          await timeout(
            this.dateManager.differenceInMilliseconds(
              now,
              startOfTheNextMinute,
            ) + 50, // This buffer of 50ms is just to be on the safe side
          ); // adaptive timer that will continue when the next minute starts
        } else {
          await timeout(testEnvironmentTimeoutMs);
        }
      } while (config.APP['isNotTestEnvironment'] || --testCountdown);
    },
  );

  modify(
    _positional: Positional<MinuteTimerArgs>,
    {
      condition = () => true,
      testEnvironmentTimeoutMs,
    }: Named<MinuteTimerArgs>,
  ) {
    this.tickLocalMinute.perform(condition, testEnvironmentTimeoutMs);
  }
}

basically it's a timer which has a few additions: it updates the tracked value when a condition is met (optionally) and also we can tell it to break the cycle after a few attempts. This logic was implemented using an ember-concurrency task. A few days ago i found out that class-based resources are discouraged in the favor of function-based resources. I try to rewrite this resource like this

export function MinuteTimer(
  condition: (d: LocalDateTime | undefined) => boolean = () => true,
  testEnvironmentTimeoutMs: number = 100,
) {
  return resource(({ owner, on }) => {
    const dateManager = owner.lookup('service:date-manager');

    const localDateTime = cell<LocalDateTime>();

    let valueSet = false;

    const tickLocalMinute = task(
      async (
        condition: (d: LocalDateTime | undefined) => boolean = () => true,
        testEnvironmentTimeoutMs: number,
      ) => {
        // this helps during the testing to update the localDateTime this number of times and then breaking
        let testCountdown = 3;

        do {
          const now = dateManager.getCurrentLocalDateTimeAtVenue();

          // update the localDateTime when a condition is true
          // but set it unconditionally for the 1st time
          if (!valueSet || condition(now)) {
            localDateTime.current = now;
            valueSet = true;
          }

          const startOfTheNextMinute = now
            .plusMinutes(1)
            .truncatedTo(ChronoUnit.MINUTES);

          if (config.APP['isNotTestEnvironment']) {
            await timeout(
              dateManager.differenceInMilliseconds(now, startOfTheNextMinute) +
                50, // This buffer of 50ms is just to be on the safe side
            ); // adaptive timer that will continue when the next minute starts
          } else {
            await timeout(testEnvironmentTimeoutMs);
          }
        } while (config.APP['isNotTestEnvironment'] || --testCountdown);
      },
    );

    on.cleanup(() => {
      // final teardown
      tickLocalMinute.cancelAll();
    });

    // debugger;
    tickLocalMinute.perform(condition, testEnvironmentTimeoutMs);

    return () => {
      return localDateTime.current;
    };
  });
}

resourceFactory(MinuteTimer);

but i receive the following error:

error

So basically it tells me that i can't declare tasks in functions (which i guess is the case). I've got a few questions:

  1. Is my understanding correct: do i receive an error because i declare a task in the function?
  2. Is my other idea correct: we'd better should convert class-based resources into function-based resources?
  3. Can i do that anyhow and still keep an ember-concurrency task? I guess i'd be able to rewrite this particular one using plain js functions but I am asking mainly because we have a couple more resources where we use tasks to wrap network calls like data fetching when a tracked value changes.
  4. Does out approach make sense or do we completely misunderstand the concept of both tasks and resources?
2
  • can you provide the versions of the libraries you're using? thanks! Commented Aug 17 at 18:05
  • and please also include the imports you're using in your code snippets -- thank you!! Commented Aug 17 at 18:05

1 Answer 1

0

Couple things before I answer your questions <3

This is not needed unless you need to invoke MinuteTimer from a template (in the RFC that introduces resources, this won't be required at all, which will be nice -- it's an awkward thing atm)

resourceFactory(MinuteTimer);

Instead of

config.APP['isNotTestEnvironment']

You may be interested in

import { macroCondition, isTesting } from '@embroider/macros';

// ...

if (macroCondition(isTesting())) {
  await timeout(testEnvironmentTimeoutMs);
}

because macroCondition participates in dead-code elimination when you do a production build so that your test code doesn't make it to customers.

Now your questions:

  1. yes -- ember-concurrency doesn't support defining tasks outside of classes. Good news though -- it doesn't look like you actually need a task here and could get away with only an async IIFE (immediately invoked function (execution))

  2. yea -- tho it does not mean you can't use a class to hold your state -- it would be a custom class -- and if you need services on it, you can ease that with link: https://reactive.nullvoxpopuli.com/functions/link.link.html
    and here are some examples of how to create the class's stable reference: https://github.com/NullVoxPopuli/ember-resources/issues/1165

  3. tasks aren't really relevant at that point -- ember-concurrency is mostly for handling concurrent activities, like from a user where you want to debounce, drop, etc (the task behaviors) -- people tend to over-use ember-concurrency since it gets you free destruction protection. Some examples here: https://github.com/ember-cli/eslint-plugin-ember/pull/1421
    For data loading, are you using ember-data or fetch or something else?

  4. Potentially just tasks -- you do have a lifetime, and and timers fit really well into the resource mentality. In particular, since your task was defined inline, passing arguments to it is not needed.
    But! it's entirely possible I don't fully understand what your goal is. If you'd like to chat about this synchronously(ish), you could hop in to the ember community discord: https://discord.gg/emberjs

Here is an example of how I would write what you have (not that I didn't run it) -- but if you provide some tests we can iterate on it.


export function MinuteTimer(
  condition: (d: LocalDateTime | undefined) => boolean = () => true,
  testEnvironmentTimeoutMs: number = 100,
) {
  return resource(({ owner, on }) => {
    const dateManager = owner.lookup('service:date-manager');
    const localDateTime = cell<LocalDateTime>();

    let timeout;

    // This is the async IIFE, 
    // since we need to run something detached from evaluation of the 
    // outer context's tracking (it's also not important that it run synchronously
    //
    // This is also a good opportunity to extract a function and invoke that
    // (but not awaiting it)
    (async () => {
       // detach from auto-tracking, as anything afterwards here we don't want
       // to cause the resource to teardown and run again
       await Promise.resolve();
      
           let valueSet = false;
      let testCountdown = 3;
      
       function loop() {
         if (macroCondition(isTesting())) {
           if (testCountdown === 0) return;  
         }
         
          const now = dateManager.getCurrentLocalDateTimeAtVenue();

          if (!valueSet || condition(now)) {
            localDateTime.current = now;
            valueSet = true;
          }
         
          const startOfTheNextMinute = now
            .plusMinutes(1)
            .truncatedTo(ChronoUnit.MINUTES);
         
          let ms = dateManager.differenceInMilliseconds(now, startOfTheNextMinute);

          if (macroCondition(isTesting())) {
            ms = testEnvironmentTimeoutMs;
         }     
         
         // queue up the next go, will be cancelled if the resource is torn down
         // (via on.cleanup)
         timeout = setTimeout(loop, ms);
         testCountdown--;
       }
      
      // first go
      loop();
    })();


    on.cleanup(() => {
      clearTimeout(timeout);
    });

    return () => {
      return localDateTime.current;
    };
    // the above return is equiv to
    return localDateTime; // since localDateTime is a cell ('current' is lazily evaluated)
  });
}

If I break it up and extract the core logic to a function, it might look something like this (separating the lifetime management from the core logic)

import { resource } from "ember-resources";
import { macroCondition, isTesting, isProduction } from "@embroider/macros";

async function timer({ dateManager, condition, creatTimeout }) {
  await Promise.resolve();

  let valueSet;
  let testCountdown = 3;

  function loop() {
    if (macroCondition(isTesting())) {
      if (testCountdown === 0) return;
    }

    const now = dateManager.getCurrentLocalDateTimeAtVenue();

    if (!valueSet || condition(now)) {
      localDateTime.current = now;
      valueSet = true;
    }

    const startOfTheNextMinute = now.plusMinutes(1).truncatedTo(ChronoUnit.MINUTES);

    let ms = macroCondition(isTesting())
      ? testEnvironmentTimeoutMs
      : dateManager.differenceInMilliseconds(now, startOfTheNextMinute);

    testCountdown--;
    createTimeout(loop, ms);
  }

  // first go
  loop();
}

export function MinuteTimer(
  condition: (d: LocalDateTime | undefined) => boolean = () => true,
  testEnvironmentTimeoutMs: number = 100,
) {
  return resource(({ owner, on }) => {
    const dateManager = owner.lookup("service:date-manager");
    const localDateTime = cell<LocalDateTime>();

    let timeout;

    timer({
      dataManager,
      condition,
      createTimeout = (fn, ms) => void timeout = createTimeout(fn, ms);
    });

    on.cleanup(() => clearTimeout(timeout));

    return localDateTime;
  });
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.