DEV Community

Cover image for Kinds of Reactive Fibers
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Kinds of Reactive Fibers

If a subscriber not only performs some effect, but changes some state, then it can act as a publisher, which allows it to be not only at the ends of the reactive graph, but also to form its body. And any fiber is just an extended PubSub.

// Meta Size: 64B+
// Edge Cost: 16B
// Allocations: 3
interface Fiber<
    Result = unknown,
    Host = unknown,
    Args = unknown[]
> extends PubSub { // 52B
    host: Host // 4B
    task: ( this: Host, args: Args )=> Result // 4B
    cache: Result // 4B
}
Enter fullscreen mode Exit fullscreen mode

There are two main types of fibers:

  • Task - one-time reactive calculation
  • Atom - long-lived reactive state

Reactive Tasks

Task has the same semantics as a regular function call. But if a normal function returns control only after has finished, this problem may be temporarily stopped halfway through and then continue to run in place, in response to some event.

Native JS runtime supports tasks only in the form of abstractions generator and asynchronous function, but:

It would be cool for the Fiber Proposal to be adopted into the standard, but it's been at stage -1 for several years now. So it's not what they don’t implement it, but on the contrary, even the extension for NodeJS node-fibers was recently completely broken.

But there is also work in accordance with convention, known as SuspenseAPI. First it was implemented in the $mol framework, where it is most actively used. So far it has been implemented in ReactJS, but with a bunch of prohibitions:

  • This only works during rendering.
  • It is unacceptable to use it in loops, branches, etc.

Later I’ll tell you how to implement this normally, but for now I’m writing about the main idea: the code is written normally, synchronously, but when you need to pause, a promise is thrown as an exception, and when the promise ends, the code is restarted.

This idea is quite fiery, but, like any workround, there are a number of limitations:

  • The code of the restarted task must be idempotent, otherwise the result will be incorrect.
  • The code up to the restart point should be either lightweight or memoized, otherwise everything will slow down.

These limitations are easily overcome: heavy or non-idempotent code is used in the project under tasks, which, if completed, are finalized and never restarted. And when the external task is restarted, they immediately return the value from the cache.

In this case, let's load the data by logging the process:

// Auto wrap method call to task
@act main() {

    // Convert async api to sync
    const syncFetch = $mol_wire_sync( fetch )

    this.log( 'Request' ) // 3 calls, 1 log
    const response = syncFetch( 'https://example.org' ) // Sync but non-blocking

    // Synchronize response too
    const syncResponse = $mol_wire_sync( response )

    this.log( 'Parse' ) // 2 calls, 1 log
    const response = syncResponse.json() // Sync but non-blocking

    this.log( 'Done' ) // 1 call, 1 log
}

// Auto wrap method call to sub-task
@act log( ... args: any[] ) {

    console.log( ... args )
    // No restarts because console api isn't idempotent

}
Enter fullscreen mode Exit fullscreen mode

The @act decorator automatically wraps the calling method in a task. But if we are already in some task, then it will try to reuse the same sequence for the task from the external fiber source. And if this succeeds, the last task either continues to run or immediately returns a value from it without a start method.

But what could go wrong?

  • Call another method.
  • Calling a method on another object.
  • Calling methods with other arguments (we check them using $mol_compare_deep).

In these cases, we cannot reuse the past task and also create a new. That is, even if the programmer allowed some non-idempotency, we will not start executing a task that does not correspond to the current call method - that would be simply crazy. Instead, we throw out old tasks and create new ones for a new scenario.

If we were not using object methods:

if( Math.random() > .5 ) {
    return useMemo( ()=> 'foo' )
} else {
    return useMemo( ()=> 'bar' )
}
Enter fullscreen mode Exit fullscreen mode

.. then a new function was created with each call, and we could not compare any function, nor the closure arguments, and therefore try to reuse a random task, which could lead, for example, to the fact that executing line 2 might return "bar" .

Reactive Atoms

An atom has the semantics of a mutable reactive variable encapsulated along with a formula for recalculating its value. Whenever we access the value of an atom, it always returns the current value. Well, either it asks you to wait, but this is also relevant, since it may not have the data yet.

If, when pulling a value from one atom, another is accessed, then they are automatically connected as a subscriber-publisher. If an atom is accessed from a task, then it is no longer possible to directly connect them, since pulling from an atom is not idempotent, because the atom can update its value between task restarts.

As an example, we can give the task of switching a flag:

@act toggle() {
    this.completed( !this.completed() ) // read then write
}

@mem completed( next = false ) {
    $mol_wait_timeout( 1000 ) // 1s debounce
    return next
}
Enter fullscreen mode Exit fullscreen mode

If a task subscribes to an atom that creates @mem under the hood, then every time it is restarted it will receive a new flag value, try to set the opposite value, and go to sleep until the next restart. And so on until the end of time.

Therefore, any access to an atom from a task is automatically wrapped in another task, which memoizes the work performed, and, like any task, upon completion, unsubscribes from all its dependencies. That is, when reading, we get, as it were, a snapshot of the value of the atom, which will never change for the current task. But with multiple reading, you get a lot of snapshots, each of which can have its own value.

Pushing a new value into an atom is similarly memoized via an intermediate task, since pushing is not idempotent. Even if the channel wrapped in an atom is trivial and simply returns the passed value, several tasks can be pushed into the atom competitively, and when restarted there should be no unexpected rollbacks of the states of the atoms.

Top comments (0)