Hacking Angular Signals

oz

Evgeniy OZ

Posted on February 20, 2024

Hacking Angular Signals

Let’s check out what Angular Signals have under the hood, and how we can use it for extending their functionality and debugging.

What are Angular Signals

Angular Signals are functions with methods:

    const $val = signal(0);

    // function:
    const value = $val();

    // object:
    $val.set(4);
    $val.update((v) => v * 2);
Enter fullscreen mode Exit fullscreen mode

How is this possible? Because in JavaScript, functions are objects:

    // declaring a function
    const double = (val: number) => val * 2;

    // adding a method
    double.isEven = (number: number) => number % 2 === 0;

    // use as object
    double.isEven(15) // false

    // use as function
    double(8) // 16
Enter fullscreen mode Exit fullscreen mode

How can we use it?

We can:

  • add new methods;
  • override methods set() and update() to intercept modifications;
  • read internal state variables and methods of signal;
  • create a proxy signal that will intercept reads.

Extending our Signals

Sometimes you need a variable to be a Signal, but some parts of your code would like to use it as an Observable instead.

Fortunately, interfaces of Signals and Observables have no collisions in method names. So let’s create a hybrid:

    export function toObservableSignal<T>(
      s: Signal<T>, 
      options?: ToObservableOptions
    ) {
      if (isDevMode() && !options?.injector) {
        assertInInjectionContext(toObservableSignal);
      }

      // create an observable
      const obs = toObservable(s, options);

      // add methods of observable to our signal object
      for (const obsKey in obs) {
        (s as any)[obsKey] = (obs as any)[obsKey];
      }
      return s;
    }
Enter fullscreen mode Exit fullscreen mode

Usage example:

    @Component({
      //...
      template: `
        <h4>Signal A: {{ a() }}</h4>
        <h4>Observable A: {{a|async}}</h4>
        <h4>Signal C (computed() A*3): {{c()}}</h4>

        {{quote()}}
      `,
    })
    export class App {
      a = toObservableSignal(signal<number>(1));

      // use as Observable
      b = this.a.pipe(
        debounceTime(500),
        distinctUntilChanged(),
        switchMap((v) => this.http.get('https://dummyjson.com/quotes/' + v))
      );

      // use as Signal
      c = computed(() => this.a() * 3);

      quote = toSignal(this.b);

      increment() {
        // "a" will not stop being a Signal after 
        // we used it as an Observable
        this.a.update((v) => v + 1);
      }

      decrement() {
        this.a.update((v) => Math.max(1, v - 1));
      }
    }
Enter fullscreen mode Exit fullscreen mode

You can find this function in the NG Extension Platform: documentation.

This function can be improved in multiple ways, so your pull requests are welcomed!

You can extend Signals with any functions you want, your fantasy here has almost no limitations: just don’t use names set, update, and asReadonly.

Overriding existing methods

Let’s say that we want to intercept writes to our Signal to transform input values, duplicate information somewhere else, or just for debugging.

    function skipNonEvenNumbers(s: WritableSignal<number>) {
      const srcSet = s.set; // we need the source method to avoid recursion

      s.set = (value: number) => {
        if (value % 2 !== 0) {
          console.warn('[set] skipping:', value);
          return;
        }
        console.log('[set]:', value);
        srcSet(value);
      };
      s.update = (updateFn: (value: number) => number) => {
        const value = updateFn(s());
        if (value % 2 !== 0) {
          console.warn('[update] skipping:', value);
          return;
        }
        console.log('[update]:', value);
        srcSet(value);
      };
    }
Enter fullscreen mode Exit fullscreen mode

Usage example.

This trick is being used in Reactive Storage: getWritableSignal().

Inside a Signal

Angular Signal is not just a function, it is an object. This object has a hidden field, SIGNAL, which contains some interesting data and functions. I’m glad we have it, and I hope you will not abuse it. Because the tricks above were “a little bit hacky”, and the tricks below are too filthy to use for anything but debugging, creating dev tools, and fun.

Every Angular Signal extends ReactiveNode:

    // packages/core/primitives/signals/src/graph.ts

    export const REACTIVE_NODE: ReactiveNode = {
      version: 0 as Version,
      lastCleanEpoch: 0 as Version,
      dirty: false,
      producerNode: undefined,
      producerLastReadVersion: undefined,
      producerIndexOfThis: undefined,
      nextProducerIndex: 0,
      liveConsumerNode: undefined,
      liveConsumerIndexOfThis: undefined,
      consumerAllowSignalWrites: false,
      consumerIsAlwaysLive: false,
      producerMustRecompute: () => false,
      producerRecomputeValue: () => {},
      consumerMarkedDirty: () => {},
      consumerOnSignalRead: () => {},
    };
Enter fullscreen mode Exit fullscreen mode

this way:

    // packages/core/primitives/signals/src/signal.ts

    export const SIGNAL_NODE = {
      ...REACTIVE_NODE,
      equal: defaultEquals,
      value: undefined,
    }
Enter fullscreen mode Exit fullscreen mode

But an instantiated Signal object does not contain all of this directly. All of them are hidden under the field that uses a Symbol as a name:

    // packages/core/primitives/signals/src/signal.ts

    export function createSignal<T>(initialValue: T): SignalGetter<T> {
      const node: SignalNode<T> = Object.create(SIGNAL_NODE);
      node.value = initialValue;
      const getter = (() => {
                       producerAccessed(node);
                       return node.value;
                     }) as SignalGetter<T>;

      // next line adds a SignalNode to the field SIGNAL:
      (getter as any)[SIGNAL] = node;
      return getter;
    }
Enter fullscreen mode Exit fullscreen mode

So if you have a signal $value and you access SIGNAL field, then you’ll get all of the fields that SIGNAL_NODE has.

How can we use it?

We can read fields and override methods to intercept access and use it for debugging or to create fancy tools that illustrate what is going on inside a Signal and render the Dependency Graph.

We can even convert some fields to accessors:

    function getSignalVersion<T>(s: WritableSignal<T>): Signal<number> {
      const node = s[SIGNAL];
      const $version = signal(0);

      Object.defineProperty(node, 'version', {
        get: () => {
          const v = untracked($version);
          console.log('🟢 reading:', v);
          return v;
        },
        set: (v) => {
          untracked(() => $version.set(v));
          console.log('🔴 writing:', v);
        },
      });

      return $version.asReadonly();
    }
Enter fullscreen mode Exit fullscreen mode

StackBlitz.

Or create a proxy that can watch Signal reads without effect():

    function watchSignalReads<T, M extends Signal<T> | WritableSignal<T>>(s: M): M {
      const node = s[SIGNAL];
      const newGetter = () => {
        const value = s();
        console.log('Read:', value);
        return value;
      };
      (newGetter as any)[SIGNAL] = node;
      if (s.hasOwnProperty('set')) {
        const w = s as WritableSignal<T>;
        newGetter.set = w.set;
        newGetter.update = w.update;
        newGetter.asReadonly = w.asReadonly;
      }

      return newGetter as M;
    }
Enter fullscreen mode Exit fullscreen mode

Usage example.

Again, I hope you won’t even consider using this in “production,” and instead, I hope you’ll utilize it to create some amazing tools, gain recognition, and contribute to enriching the Angular ecosystem 😎


🪽 Do you like this article? Share it and let it fly! 🛸

💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.


💖 💪 🙅 🚩
oz
Evgeniy OZ

Posted on February 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related