Giter VIP home page Giter VIP logo

signals's Introduction

Signals

package-badge license-badge

πŸ† The goal of this library is to provide a lightweight reactivity API for other UI libraries to be built on top of. It follows the "lazy principle" that Svelte adheres to - don't do any unnecessary work and don't place the burden of figuring it out on the developer.

This is a tiny (~1kB minzipped) library for creating reactive observables via functions called signals. You can use signals to store state, create computed properties (y = mx + b), and subscribe to updates as its value changes.

  • πŸͺΆ Light (~1kB minzipped)
  • πŸ’½ Works in both browsers and Node.js
  • 🌎 All types are observable (i.e., string, array, object, etc.)
  • πŸ•΅οΈβ€β™€οΈ Only updates when value has changed
  • ⏱️ Batched updates via microtask scheduler
  • 😴 Lazy by default - efficiently re-computes only what's needed
  • πŸ”¬ Computations via computed
  • πŸ“ž Effect subscriptions via effect
  • πŸ› Debugging identifiers
  • πŸ’ͺ Strongly typed - built with TypeScript

⏭️ Skip to API

⏭️ Skip to TypeScript

⏭️ Skip to Benchmarks

Here's a simple demo to see how it works:

Open in StackBlitz

import { root, signal, computed, effect, tick } from '@maverick-js/signals';

root((dispose) => {
  // Create - all types supported (string, array, object, etc.)
  const $m = signal(1);
  const $x = signal(1);
  const $b = signal(0);

  // Compute - only re-computed when `$m`, `$x`, or `$b` changes.
  const $y = computed(() => $m() * $x() + $b());

  // Effect - this will run whenever `$y` is updated.
  const stop = effect(() => {
    console.log($y());

    // Called each time `effect` ends and when finally disposed.
    return () => {};
  });

  $m.set(10); // logs `10` inside effect

  // Flush queue synchronously so effect is run.
  // Otherwise, effects will be batched and run on the microtask queue.
  tick();

  $b.set((prev) => prev + 5); // logs `15` inside effect

  tick();

  // Nothing has changed - no re-compute.
  $y();

  // Stop running effect.
  stop();

  // ...

  // Dispose of all signals inside `root`.
  dispose();
});

Installation

$: npm i @maverick-js/signals

$: pnpm i @maverick-js/signals

$: yarn add @maverick-js/signals

API

root

Computations are generally child computations. When their respective parent scope is destroyed so are they. You can create orphan computations (i.e., no parent). Orphans will live in memory until their internal object references are garbage collected (GC) (i.e., dropped from memory):

import { computed } from '@maverick-js/signals';

const obj = {};

// This is an orphan - GC'd when `obj` is.
const $b = computed(() => obj);

Orphans can make it hard to determine when a computation is disposed so you'll generally want to ensure you only create child computations. The root function stores all inner computations as a child and provides a function to easily dispose of them all:

import { root, signal, computed, effect } from '@maverick-js/signals';

root((dispose) => {
  const $a = signal(10);
  const $b = computed(() => $a());

  effect(() => console.log($b()));

  // Disposes of `$a`, $b`, and `effect`.
  dispose();
});
// `root` returns the result of the given function.
const result = root(() => 10);

console.log(result); // logs `10`

signal

Wraps the given value into a signal. The signal will return the current value when invoked fn(), and provide a simple write API via set(). The value can now be observed when used inside other computations created with computed and effect.

import { signal } from '@maverick-js/signals';

const $a = signal(10);

$a(); // read
$a.set(20); // write (1)
$a.set((prev) => prev + 10); // write (2)

Warning Read the tick section below to understand batched updates.

computed

Creates a new signal whose value is computed and returned by the given function. The given compute function is only re-run when one of it's dependencies are updated. Dependencies are are all signals that are read during execution.

import { signal, computed, tick } from '@maverick-js/signals';

const $a = signal(10);
const $b = signal(10);
const $c = computed(() => $a() + $b());

console.log($c()); // logs 20

$a.set(20);
tick();
console.log($c()); // logs 30

$b.set(20);
tick();
console.log($c()); // logs 40

// Nothing changed - no re-compute.
console.log($c()); // logs 40
import { signal, computed } from '@maverick-js/signals';

const $a = signal(10);
const $b = signal(10);
const $c = computed(() => $a() + $b());

// Computed signals can be deeply nested.
const $d = computed(() => $a() + $b() + $c());
const $e = computed(() => $d());

effect

Invokes the given function each time any of the signals that are read inside are updated (i.e., their value changes). The effect is immediately invoked on initialization.

import { signal, computed, effect } from '@maverick-js/signals';

const $a = signal(10);
const $b = signal(20);
const $c = computed(() => $a() + $b());

// This effect will run each time `$a` or `$b` is updated.
const stop = effect(() => console.log($c()));

// Stop observing.
stop();

You can optionally return a function from inside the effect that will be run each time the effect re-runs and when it's finally stopped/disposed of:

effect(() => {
  return () => {
    // Called each time effect re-runs and when disposed of.
  };
});

peek

Returns the current value stored inside the given compute function whilst disabling observer tracking, i.e. without triggering any dependencies. Use untrack if you want to also disable scope tracking.

import { signal, computed, peek } from '@maverick-js/signals';

const $a = signal(10);

const $b = computed(() => {
  // `$a` will not trigger updates on `$b`.
  const value = peek($a);
});

untrack

Returns the current value inside a signal whilst disabling both scope and observer tracking. Use peek if only observer tracking should be disabled.

import { signal, effect, untrack } from '@maverick-js/signals';

effect(() => {
  untrack(() => {
    // `$a` is now an orphan and also not tracked by the outer effect.
    const $a = signal(10);
  });
});

readonly

Takes in the given signal and makes it read only by removing access to write operations (i.e., set()).

import { signal, readonly } from '@maverick-js/signals';

const $a = signal(10);
const $b = readonly($a);

console.log($b()); // logs 10

// We can still update value through `$a`.
$a.set(20);

console.log($b()); // logs 20

tick

By default, signal updates are batched on the microtask queue which is an async process. You can flush the queue synchronously to get the latest updates by calling tick().

Note You can read more about microtasks on MDN.

import { signal } from '@maverick-js/signals';

const $a = signal(10);

$a.set(10);
$a.set(20);
$a.set(30); // only this write is applied
import { signal, tick } from '@maverick-js/signals';

const $a = signal(10);

// All writes are applied.
$a.set(10);
tick();
$a.set(20);
tick();
$a.set(30);

computedMap

Note Same implementation as indexArray in Solid JS. Prefer computedKeyedMap when referential checks are required.

Reactive map helper that caches each item by index to reduce unnecessary mapping on updates. It only runs the mapping function once per item and adds/removes as needed. In a non-keyed map like this the index is fixed but value can change (opposite of a keyed map).

import { signal, tick } from '@maverick-js/signals';
import { computedMap } from '@maverick-js/signals/map';

const source = signal([1, 2, 3]);

const map = computedMap(source, (value, index) => {
  return {
    i: index,
    get id() {
      return value() * 2;
    },
  };
});

console.log(map()); // logs `[{ i: 0, id: $2 }, { i: 1, id: $4 }, { i: 2, id: $6 }]`

source.set([3, 2, 1]);
tick();

// Notice the index `i` remains fixed but `id` has updated.
console.log(map()); // logs `[{ i: 0, id: $6 }, { i: 1, id: $4 }, { i: 2, id: $2 }]`

computedKeyedMap

Note Same implementation as mapArray in Solid JS. Prefer computedMap when working with primitives to avoid unnecessary re-renders.

Reactive map helper that caches each list item by reference to reduce unnecessary mapping on updates. It only runs the mapping function once per item and then moves or removes it as needed. In a keyed map like this the value is fixed but the index changes (opposite of non-keyed map).

import { signal, tick } from '@maverick-js/signals';
import { computedKeyedMap } from '@maverick-js/signals/map';

const source = signal([{ id: 0 }, { id: 1 }, { id: 2 }]);

const nodes = computedKeyedMap(source, (value, index) => {
  const div = document.createElement('div');

  div.setAttribute('id', String(value.id));
  Object.defineProperty(div, 'i', {
    get() {
      return index();
    },
  });

  return div;
});

console.log(nodes()); // [{ id: 0, i: $0 }, { id: 1, i: $1 }, { id: 2, i: $2 }];

source.set((prev) => {
  // Swap index 0 and 1
  const tmp = prev[1];
  prev[1] = prev[0];
  prev[0] = tmp;
  return [...prev]; // new array
});

tick();

// No nodes were created/destroyed, simply nodes at index 0 and 1 switched.
console.log(nodes()); // [{ id: 1, i: $0 }, { id: 0, i: $1 }, { id: 2, i: $2 }];

onError

Runs the given function when an error is thrown in a child scope. If the error is thrown again inside the error handler, it will trigger the next available parent scope handler.

import { effect, onError } from '@maverick-js/signals';

effect(() => {
  onError((error) => {
    // ...
  });
});

onDispose

Runs the given function when the parent scope computation is being disposed of.

import { effect, onDispose } from '@maverick-js/signals';

const listen = (type, callback) => {
  window.addEventListener(type, callback);
  // Called when the effect is re-run or finally disposed.
  onDispose(() => window.removeEventListener(type, callback));
};

const stop = effect(
  listen('click', () => {
    // ...
  }),
);

stop(); // `onDispose` is called

The onDispose callback will return a function to clear the disposal early if it's no longer required:

effect(() => {
  const dispose = onDispose(() => {});
  // ...
  // Call early if it's no longer required.
  dispose();
});

isReadSignal

Whether the given value is a readonly signal.

// True
isReadSignal(10);
isReadSignal(() => {});
isReadSignal(signal(10));
isReadSignal(computed(() => 10));
isReadSignal(readonly(signal(10)));

// False
isReadSignal(false);
isReadSignal(null);
isReadSignal(undefined);

isWriteSignal

Whether the given value is a write signal (i.e., can produce new values via write API).

// True
isWriteSignal(signal(10));

// False
isWriteSignal(false);
isWriteSignal(null);
isWriteSignal(undefined);
isWriteSignal(() => {});
isWriteSignal(computed(() => 10));
isWriteSignal(readonly(signal(10)));

getScope

Returns the currently executing parent scope.

root(() => {
  const scope = getScope(); // returns `root` scope.

  effect(() => {
    const $a = signal(0);
    getScope(); // returns `effect` scope.
  });
});

scoped

Runs the given function in the given scope so context and error handling continue to work.

import { root, getScope, scoped } from '@maverick-js/signals';

root(() => {
  const scope = getScope();

  // Timeout will lose tracking of the current scope.
  setTimeout(() => {
    scoped(() => {
      // Code here will run with root scope.
    }, scope);
  }, 0);
});

getContext

Attempts to get a context value for the given key. It will start from the parent scope and walk up the computation tree trying to find a context record and matching key. If no value can be found undefined will be returned. This is intentionally low-level so you can design a context API in your library as desired.

In your implementation make sure to check if a parent scope exists via getScope(). If one does not exist log a warning that this function should not be called outside a computation or render function.

Note See the setContext code example below for a demo of this function.

setContext

Attempts to set a context value on the parent scope with the given key. This will be a no-op if no parent scope is defined. This is intentionally low-level so you can design a context API in your library as desired.

In your implementation make sure to check if a parent scope exists via getScope(). If one does not exist log a warning that this function should not be called outside a computation or render function.

import { root, getContext, setContext } from '@maverick-js/signals';

const key = Symbol();

root(() => {
  setContext(key, 100);
  // ...
  root(() => {
    const value = getContext(key); // 100
  });
});

Debugging

The signal, computed, and effect functions accept a debugging ID (string) as part of their options.

import { signal, computed } from '@maverick-js/signals';

const $foo = signal(10, { id: 'foo' });

Note This feature is only available in a development or testing Node environment (i.e., NODE_ENV).

TypeScript

import {
  isReadSignal,
  isWriteSignal,
  type Effect,
  type ReadSignal,
  type WriteSignal,
  type MaybeSignal,
} from '@maverick-js/signals';

// Types
const signal: ReadSignal<number>;
const computed: ReadSignal<string>;
const effect: Effect;

// Provide generic if TS fails to infer correct type.
const $a = computed<string>(() => /* ... */);

const $b: MaybeSignal<number>;

if (isReadSignal($b)) {
  $b(); // ReadSignal<number>
}

if (isWriteSignal($b)) {
  $b.set(10); // WriteSignal<number>
}

Benchmarks

Layers

This benchmark was taken from cellx. It tests how long it takes for an n deeply layered computation to update. The benchmark can be found here.

Each column represents how deep computations were layered. The average time taken to update the computation out of a 100 runs is used for each library.

Layers benchmark table

Notes

  • Nearly all computations in a real world app are going to be less than 10 layers deep, so only the first column really matters.
  • This benchmark favours eagerly scheduling computations and aggresive caching in a single long computation subtree. This is not a great benchmark for signals libraries as it doesn't measure what really matters such as dynamic graph updates, source/observer changes, and scope disposals.

Reactively

This benchmark was taken from reactively. It sets up various computation graphs with a set number of sources (e.g., 1000x5 is 1000 computations with a tree depth of 5). The benchmark measures how long it takes for changes to be applied after static or dynamic updates are made to the graph (i.e., pick a node and update its value).

Reactively benchmark charts

Notes

  • This assumes Solid JS is in batch-only mode which is not realistic as a real world app won't have batch applied everywhere.

Inspiration

@maverick-js/signals was made possible based on code and learnings from:

Special thanks to Modderme, Wesley, Julien, and Solid/Svelte contributors for all their work πŸŽ‰

signals's People

Contributors

artalar avatar jviide avatar mihar-22 avatar webreflection avatar yurivish avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

signals's Issues

Array reconciliation like `mapArray` in Solid.js

Like this:

let $a = $observable([1,2,3])

let $b = $computed($mapArray($a, _ => { console.log(_); return { _ }))

// logs 1 2 3

$a.set([3,2,5])

// logs 5

Finally I want to know 1 is removed so some type of $onCleanup function inside $computed.

Also some type of notification to note that elements have reordered.

Signal in effect not triggering unless assigned to a variable

Hi, wondering if this is a known gotcha or otherwise I can try and make a repro:

Doesn't work (not called):

effect(() => {
  this.viewportEl?.scrollTo(this.$scrollX(), this.$scrollY())
})

Does work:

effect(() => {
  console.log(this.$scrollX(), this.$scrollY())
  this.viewportEl?.scrollTo(this.$scrollX(), this.$scrollY())
})

Or:

effect(() => {
    const scrollX = this.$scrollX()
    const scrollY = this.$scrollY()
    this.viewportEl?.scrollTo(scrollX, scrollY)
})

No reaction?

Don't feel obligated to respond to this at all. πŸ˜„

But I had this thing based on another reactive library:

https://codesandbox.io/s/reactive-dom-lifecycle-zhzw7

A basic, working Sinuous clone using dipole.

Well, your library looks awesome, so of course I had to try to port from dipole to that:

https://codesandbox.io/s/reactive-dom-maverick-njvhsw

Essentially, there's no effects happening anywhere, and I have no clue why. πŸ€·β€β™‚οΈ

As said, don't feel obligated to respond - this is most likely not due to any issue with your library, but just my lacking understanding of how it works. I would have posted this in Discussions if it were enabled. πŸ˜„

Docs for computed options

In playing with Maverick Signals I came across a few neat features related to computed signals that I didn't see covered by the current README. It would be great if these had a little more documentation, since they seem quite useful.

  • The dirty option to computed
  • The scoped option to computed
  • The initial argument to computed and when it applies. I played with it briefly and seems that if you construct a computed with computed(f, { initial }) then the value of f() is returned when peeking or reading the signal, and I wasn't sure when the initial value is used.

I was also wondering about the nested computed behavior mentioned in this issue, which might deserve a little "note" section in the docs.

In particular, I was wondering if this is something to watch out for when building a DOM library on top of Signals where each component is a function and you can't be sure whether a computed contains a nested call to computed.

For example, if a component has a computed array of children, and those children are also components that have computed values inside them, when will the nested computeds be disposed? Or more generally – if we want to ensure that child computed signals are disposed of at the appropriate time, should we always create computeds with { scoped: true } just in case the computed function might transitively contain calls to computed, and would not doing so potentially result in a memory leak?

bad benchmark in small sample

❯ node bench/layers.js
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          β”‚ 10    β”‚ 100   β”‚ 500    β”‚ 1000    β”‚ 2000    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ maverick β”‚ 24.72 β”‚ 47.93 β”‚ 263.59 β”‚ 593.43  β”‚ 1486.25 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ S        β”‚ 28.44 β”‚ 63.92 β”‚ 447.01 β”‚ 1046.29 β”‚ 2624.55 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ solid    β”‚ 50.47 β”‚ 92.48 β”‚ 712.09 β”‚ 1865.60 β”‚ 8069.42 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ solid2   β”‚ 8.89  β”‚ 94.50 β”‚ 764.52 β”‚ 1755.36 β”‚ 7806.48 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Seems like V8 JIT affect time in small layer tier.

solid2 is just solid

  report.solid = { fn: runSolid, runs: [] };
  report.solid2 = { fn: runSolid, runs: [] };

React integration

Hi,

I really appreciate such a great library.

Is it possible to use and integrate Signals with React 18+ too?
If yes how? any example?

effect doesn't work

Hi, thanks for the lib I like the API a lot, but I noticed that effects weren't working in my code and so I copied your example from the README but the effect also isn't called there:

https://codesandbox.io/s/loving-ace-vz195z?file=/src/index.js:0-989

import { root, signal, computed, effect, tick } from "@maverick-js/signals";

root((dispose) => {
  // Create - all types supported (string, array, object, etc.)
  const $m = signal(1);
  const $x = signal(1);
  const $b = signal(0);

  // Compute - only re-computed when `$m`, `$x`, or `$b` changes.
  const $y = computed(() => $m() * $x() + $b());

  // Effect - this will run whenever `$y` is updated.
  const stop = effect(() => {
    console.log("$y changed", $y());

    // Called each time `effect` ends and when finally disposed.
    return () => {};
  });

  $m.set(10); // logs `10` inside effect

  // Flush queue synchronously so effect is run.
  // Otherwise, effects will be batched and run on the microtask queue.
  tick();

  $b.set((prev) => prev + 5); // logs `15` inside effect

  tick();

  // Nothing has changed - no re-compute.
  $y();

  // Stop running effect.
  stop();

  // ...

  // Dispose of all signals inside `root`.
  dispose();
  console.log("end");
});

I thought that "$y changed" would be called if $m, $x or $b change?

tick() causes error

import { signal, effect, tick } from '@maverick-js/signals'

let res = []
const A = signal(0)
const H = effect( ()=> res.push( A() ) )

A.set(1); tick()

$mol_assert_like( res, [ 1 ] )

Sandbox

Corrupt npm package

 "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "test": "./dist/dev/index.js",
      "development": "./dist/dev/index.js",
      "default": "./dist/prod/index.js"
    },
    "./map": {
      "types": "./dist/types/map.d.ts",
      "test": "./dist/dev/map.js",
      "development": "./dist/dev/map.js",
      "default": "./dist/prod/map.js"
    },
    "./package.json": "./package.json"
  }

But inside npm package, there is no dist directory.

How to create an observable object/store

SolidJS has stores for deeper objects: https://www.solidjs.com/docs/latest/api#using-stores

I only need 1 level like: https://github.com/nanostores/nanostores#maps

The use case is a model which has an object of data (e.g. User) and I want to observe each prop inside it, and also compute derived values if some of those props change.

I think signal does a shallow check for changes and so what would you recommend to achieve 1 level deep observability?

const user = {
  name: 'John',
  age: 17,
}

$user = signal(user)

const $doubleAge = computed(() => $user().age * 2);

$doubleAge should only re-compute if age changes

Reduce bundle size

I believe using an array for _sources, _observers, _children, and _handlers, and an object for _context will reduce bundle size significantly, provides cleaner types and remove null checking conditionals that lie on the hot execution path since those values end up being an array, or object if it is context, almost always.

Now a simple push and pop will suffice for many of the actions. Setting a context value will be reduced to a simple assignment, no need to create new object when inserting a new value, and we can take advantage of recursion for scope related actions like disposal.

getContext inside untrack is undefined

I'm trying to implement maverick signals on https://pota.quack.uy/ . Solid and voby (oby) signals already work.

I'm having an issue with context, when using getContext inside untrack it will return undefined.

displays test

effect(()=>{
  const id = Symbol()
  setContext(id, "test")
  console.log(getContext(id))
})

on the other hand, this will display undefined

effect(()=>{
  const id = Symbol()
  setContext(id, "test")
  untrack(()=> console.log(getContext(id)))
})

Error Handling: Why not try parent's handlers before throwing

Earlier I had an idea of using ownership tree for error handling too, was not sure if you would like it, but decided to show it anyway. I am using it in my reactive implementation, it works OK.

Basically we climb the ownership tree and see if parent scope has a handler. If you are observing strict ownership as Solid does, assigning owners to effects, memos and components, it should have the same effect as re-throwing and catching it somewhere higher on the component tree.

Here is how I implemented it for my own reactive system:

function handleError(scope: Computation, error: unknown) {
  const handlers = scope.handlers;
  let handled = false;
  for (let i = 0, len = handlers.length; i < len; i++) {
    try {
      handlers[i](error);
      handled = true;
      break; // Error is handled.
    } catch (rethrown: any) {
      error = rethrown;
    }
  }
  if (!handled) {
    if (scope.owner) {
      handleError(scope.owner, error);
    } else {
      throw error;
    }
  }
}

Now, parent scope will capture any error that may be thrown anywhere down under, including effects and memos. The logic is same as context lookup.

This may provide a simpler mental model for tracking the error path in the application.

Here is how error boundary would look:

export const ErrorBoundary: Component<{
  children: JSX.Element,
  fallback: JSX.Element | ((props: { error: any, reset: () => void }) => JSX.Element),
}> = ({ children, fallback }) => {
  const noError = Symbol('no-error');
  const error = signal<any>(noError);
  onError((err) => error.set(err));
  const reset = () => error.set(noError);
  return memo(() => (error() !== noError) ?
    typeof fallback === 'function' ?  fallback({ error: error(), reset }) : fallback :
    children
  );
};

How to untrack a signal change

Hi, is there a way to set a signal without triggering observables? At first I thought I could use untrack but seems this is just for in an effect?

import { root, signal, untrack, effect, tick } from "@maverick-js/signals";

root((dispose) => {
  const $value = signal(0);

  effect(() => {
    console.log("$value changed to", $value());
  });

  // Still triggers the effect
  untrack(() => {
    $value.set(10);
  });

  tick()

  dispose()
});

Please do not coerce errors

function handleError(scope: Scope | null, error: unknown) {

Once we had a discussion on keeping the language semantics when handling errors in SolidJS. Solid did not allow throwing falsy values as errors even though the language permits it. I see you follow somewhat similar path, but coercing errors may not be good idea from the consumer's perspective. Why not keep them as is:

let handled = false;
for (let i = 0, len = handlers.length; i < len; i++) {
  try {
    handlers[i](error);
    handled = true;
    break; // Error is handled.
  } catch (rethrown: any) {
    error = rethrown;
  }
}
if (!handled) throw error;

Is there a valid reason for coercion?

bench: S.js tests creating 2 computations per node

Hi, I'm the author of S.js.. I learned about your framework and its benchmark when it was mentioned in a twitter conversation: https://twitter.com/RyanCarniato/status/1574305237933821953 .

I noticed that your benchmark for S.js has it doing 2x the work of the other implementations, as it's creating a second, unnecessary computation for each node. Specifically, this line is redundant and should be removed.

        S(props.a), S(props.b), S(props.c), S(props.d);

props.a/b/c/d are already computations. Wrapping them a second time with S(...) makes a second, unnecessary computation out of those computations.

I don't know the history of this benchmark and gather that you may have copied it from somewhere else. If the upstream source has the same issue, would you mind pointing me towards it, so that I can get it fixed there too? Thanks!

What is this.

This is the freshly installed clone, running pnpm test.

 ❯ tests/observables.test.ts (0)

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/observables.test.ts [ tests/observables.test.ts ]
SyntaxError: Unexpected token '??='
 ❯ new Script vm.js:102:7
 ❯ createScript vm.js:262:10
 ❯ Object.runInThisContext vm.js:310:10
 ❯ async /home/eguneys/js/observables/src/index.ts:1:256

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

Test Files  1 failed (1)
     Tests  no tests
      Time  2.87s (in thread 0ms, Infinity%)

 ELIFECYCLE  Test failed. See above for more details.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.