パッケージの詳細

uneventful

pjeby38ISC0.0.12

Declarative, event-driven reactivity: signals, streams, structured concurrency, and easy resource cleanup

signals, reactive streams, streams, cancellation

readme

NPM Version

uneventful: signals plus streams, minus the seams

The Problem

Event-driven programming creates a lot of garbage. Whether you're using raw event handlers or some kind of functional abstraction (like streams or signals or channels), the big issue with creating complex interactivity is that at some point, you have to clean it all up.

Handlers need to be removed, requests need to be canceled, streams unsusbcribed or channels closed, and a whole bunch more. And if you don't do it just right, you get bugs, hiding in your leftover garbage.

Worse, the need to keep track of what garbage to get rid of and when to do it breaks functional composition and information hiding. You can't just write functions that do things, because they need to either return disposal information or have it passed into them.

Sure, reactive stream and signal libraries help with this some, by giving you fewer things to dispose of, or giving you some tools to dispose of them with. But both paradigms have their limits: when you start doing more complex interactions, you usually end up needing ever-more complex stream operators, or signal-based state machines.

And so, while your code is a bit cleaner, the complexity and clutter hasn't really gone away: it's just moved to the mind of the person reading your code. (Like you, six months later!)

The Solution

Enter Uneventful: a seamless, declarative, and composable blend of signals, streams, and CSP-like, cancelable asynchronous jobs (aka structured concurrency) with automatic resource management.

Uneventful does for event-driven interaction what async functions did for promises: it lets you build things out of functions, instead of spaghetti and garbage. It's a system for composable interactivity, unifying and composing all of the current reactive paradigms in a way that hides the seams and keeps garbage collection where it belongs: hidden in utility functions, not cluttering up your code and your brain.

And it does all this by letting your program structure reflect its interactivity:

import { start, pipe, into, fromDomEvent, must, Job } from "uneventful";

function drag(node: HTMLElement): Job<HTMLElement> {
    return start(job => {
        // The dragged item needs a dragging class during the operation
        addDragClass(node);

        // The item position needs to track the mouse movement
        trackMousePosition(node);

        // The job ends when the mouse button goes up,
        // returning the DOM node it happens over
        pipe(fromDomEvent(document, "mouseup"), into(e => {
            // Exit the job, removing all the listeners (and the .dragging class)
            job.return(e.target);
        }));
    });
}

function addDragClass(node: HTMlElement) {
    node.classList.add("dragging");  // Add a class now
    must(() => node.classList.remove("dragging"));  // Remove it when the job is over
}

function trackMousePosition(node: HTMLElement) {
    pipe(fromDomEvent(document, "mousemove"), into(e => {
        // ... assign node.style.x/.y from event
    }));
}

The example above is a sketch of a drag-and-drop operation that can be called as a function. It returns a Job, which is basically a cancellable Promise. (With a bunch of extra superpowers we'll get to later.) Other jobs can wait for it to complete, or you can await it in a regular async function if you want.

You've probably noticed that there isn't any code here that unsubscribes from anything, and that the only explicit "cleanup" code present is the must() call in addDragClass(). That's because Uneventful keeps track of the "active" job, and has APIs like must() to register cleanup code that will run when that job is finished or canceled. This lets you move the garbage collection to precisely where it belongs in your code: the place where it's created.

If you're familiar with statecharts, you might notice that this code sample can easily be translated to one, and the same is true in reverse: if you use statecharts for design and uneventful for implementation, you can pretty much write down the chart as code. (A job definition function is a state, and each job instance at runtime represents one "run" of that state, from entry to exit. And of course job definitions can nest like states, and be named and abstracted away like states.)

But Uneventful is actually better than statecharts, even for design purposes: instead of following boxes and lines, your code is a straightforward list of substates, event handlers, or even sequential activities:

import { each } from "uneventful";

function supportDragDrop(parentNode: HTMLElement) {
    return start(function*(job) {
        const mouseDown = fromDomEvent(parentNode, "mousedown");
        for (const {item: event, next} of yield *each(mouseDown)) {
            if (event.target.matches(".drag-handle")) {
                const dropTarget = yield *drag(event.target.closest(".draggable"));
                // do something with the dropTarget here
            }
            yield next;  // wait for next mousedown
        }
    });
}

Where our previous job did a bunch of things in parallel, this one is serial. If the previous job was akin to a Promise constructor, this one is more like an async function. It loops over an event like it was an async iterator, but it does so semi-synchronously. (Specifically, each pass of the loop starts during the event being responded to, not in a later microtask!)

Then it starts a drag job, and waits for its completion, receiving the return value in much the same way as an await does -- but again, semi-synchronously, during the mouseup event that ends the drag() call. (Note: this pseudo-synchronous return-from-a-job is specific to using yield in another job function: if you await a job or call its .then() method to obtain the result, it'll happen in a later microtask as is normal for promise-based APIs.)

And though we haven't shown any details here of what's being done with the drop, it's possible that we'll kick off some additional jobs to do an animation or contact a server or something of that sort, and wait for those to finish before enabling drag again. (Unless of course we want them to be able to overlap with additional dragging, in which case we can spin off detached jobs.)

Context, Cancellation, and Cleanup

If you look closely, you might notice that our last example is an infinite loop. fromDomEvent returns a stream that will never end on its own, so we could in fact declare this function as returning Job<never> -- i.e. a promise that will never return a value. (But it can still throw an error, or be canceled.)

So how does it exit? When do the event handlers get cleaned up?

Well, that's up to the caller. If the calling job exits, then any unfinished jobs "inside" it are automatically canceled. (It can also explicitly cancel the job, of course.)

For jobs implemented via a setup function (like drag()) this just means that all must() callbacks registered with that job will be invoked, in reverse order. For a job implemented as a generator (like supportDragDrop()), it also means that the most recent yield will be resumed as if it had been a return instead, allowing any enclosing try /finally blocks to run.

In order for all this to work, of course, Uneventful has to keep track of the "active" job, so that must() callbacks and nested jobs can be linked to the correct owner. (You can also do this linking explicitly, e.g. by directly calling a specific job's .start() or .must() methods instead of the standalone versions.) The way it works is this:

  • If you're in the body of a start() function, that job is active
  • if you're in the body of a start()-ed generator function, the same applies, but also any generator functions you yield * to in the generator function will still have the job active.
  • Callbacks must be wrapped with restarting() or a job's .bind() method (or invoked via a job's .run() method) in order to have a job active.
  • If you're in a (non-async) function directly called from an any place where there's an active job, that job is still active.

Early versions of Uneventful also tried to automatically wrap event handlers to run in their owning jobs, but it turned out that this is fairly wasteful in practice! Most event handlers are defined inside of jobs, and so have easy access to their job instance in a variable (as provided by start()). So they can explicitly target job.start() or job.must() to create subjobs or register cleanups, etc., without needing an implicit current job.

(Also, as in our supportDragDrop() example, you can just loop over yield *each() and avoid callbacks entirely!)

So the main place where you're likely to want to wrap an event handler is when you want events to start an operation that might be superseded by a later event of the same kind. For example, if you want to make a folder open in your UI when a drag hovers over it for a certain amount of time:

import {restarting, sleep} from "uneventful";

start(job => {
    pipe(currentlyHoveredFolder, into(restarting(folder => {
        if (folder && !folder.isOpen()) start(function *(job) {
            yield *sleep(300);
            // ... open the folder here
        });
    })));
});

Let's say that currentlyHoveredFolder is a stream (or signal!) that sends events as the hover state changes: either a folder object or null if no hovering is happening. The restarting() API wraps the event handler with a "temp" job that is canceled and restarted each time the function is called.

With this setup, the "open the folder here" code will only be reached if the hover time on a given folder exceeds 300ms. Otherwise, the next change in the hovered folder will cancel the sleeping job (incidentally clearing the timeout allocated by the sleep() as it does so).

Now, in this simple example you could just directly do the debouncing by manipulating the stream. And for a lot of simple things, that might even be the best way to do it. Some event driven libraries might even have lots of handy built-in ways to do things like canceling your in-flight ajax requests when the user types in a search field.

But the key benefit to how Uneventful works is that you're not limited to whatever bag of tricks the framework itself provides: you can just write out what you want and it's easily cancellable by default, without you needing to try to twist your use case to fit a specific trick or tool.

Signals and Streams, Minus The Seams

So far our examples haven't really used anything "fancy": we've only imported eight functions and a type! But Uneventful also provides a collection of reactive stream operators roughly on par with Wonka.js, and a reactive signals API comparable to that of Maverick Signals. So you can pipe(), take(), skip(), map(), filter() or even switchMap() streams to your heart's content. (See the Stream Operators section of the docs for the full list.)

Uneventful's signals and effects are named and work slightly differently from most other frameworks, though. In particular, what other framework APIs usually call a "signal", we call a value. What others call "computed", we call a cached function. And what they call an "effect", we call a rule. (With the respective APIs being named value(), cached(), and rule(). We still call them "signals" as a category, though.)

Why the differences? Uneventful is all about making clear what your code is doing. A "signal" is just an observable value that you can change. A "computed" value is just a function whose value you don't want to recompute unless its dependencies change: that is, it's a cached function. And when you write an "effect" you're really defining a rule for synchronizing state.

(But of course, if you're migrating from another signal framework, or are just really attached to the more obscure terminology, you can still rename them in your code with import as!)

Beyond these superficial differences, though, there are some deeper ones.

First off, in Uneventful, signals are also streams. When signals are used in APIs that expect streams (including each()!), they send their current value on the initial subscription, followed by new values when their values change.

And they also support backpressure: if you iterate over a signal's values with each(), then the changes are based on sampling the value when the loop isn't busy (i.e. during the yield next). This makes it really easy to (for example) loop over the various values of an input field and do remote searches with them, while maintaining a desired search frequency or level of connection saturation by using sleep() delays in the loop.

Second, you can also turn streams into signals, by passing them to cached(). So if you want a signal that tracks the current mouse position or modifier keys' state, just use cached(fromDomEvent(...)) or pipe(fromDomEvent(...), map(...), cached), and off you go! As long as the resulting signal is observed by a rule (directly or indirectly) it subscribes to the stream and returns the most recent value. And as soon as all its observers go away, the underlying source is unsubscribed, so there are no dangling event listeners.

But wait, there's more: Unlike most libraries' "effects", Uneventful's rules start asynchronously and can be independently scheduled. This means, for example, that it's easy to make rules that run only in, say, animation frames:

import { rule } from "uneventful/signals";

/**
 * An alternate version of rule() that runs in animation frames
 * instead of microticks
 */
const animate = rule.factory(requestAnimationFrame);

animate(() => {
    // Code here will not run until the next animation frame.
    // After that, though, it'll be *rerun* in another animation frame,
    // any time there's a change to a `value()` or `cached()` it read
    // in its previous run.
    //
    // It's also run in a `restarting()` job, allowing it to register
    // must() functions that will be called on the next run, or when
    // the enclosing job ends.  (It can also define other rules or
    // start jobs, which will be similarly canceled and restarted if
    // dependencies change, or if the jobs/rules/etc. containing this
    // rule are finished, canceled, or restarted!)
});

As in most of the better signal frameworks, Uneventful rules can be nested inside of other rules. But they can also be nested in jobs, and vice versa: if a rule starts a job, it's contained in the rule's restarting job, and canceled/started over if any of the rule's dependencies change.

Also unlike other frameworks, you can have rules that run on different schedules, and nest and combine them to your heart's content. For example, you can use a default, microtask-based rule() that decides whether an animation rule inside it should be active, or does some of the heavier computation first so the actual animation rule has less to do during the animation frame.

Schedulers also let you appropriately debounce or sample changes for some of your rules so you can avoid unnecessary updates. Instead of requiring an immediate response to every change of an observable value, or explicit batching declarations, Uneventful just marks dependencies dirty, and queues affected rules to be run by their corresponding scheduler(s).

(This means, for example, that you can have rules that update data models immediately, other rules that update visible UI in the next animation frame, and still others that update a server or database every few seconds, without needing anything more complicated than using rule factories tied to different schedulers when creating them.)

What's Next

So far, we've highlighted just a handful of Uneventful's coolest and most impactful features, showing how you can:

  • Use the best-fit tools from every major reactive paradigm, from signals, streams, and CSP, to cancelable async processes and structured concurrency -- while still being interoperable with standard APIs like promises, async functions, and abort signals
  • Make your code's interactivity visible and composable, such that serial and parallel job flows are obvious in your code, or hidden away within functions, as required, while easily expressing interactions that would be challenging in other paradigms
  • Play well with state charts, or ignore the charts and just express interactivity directly in code!
  • Easily control the timing of operations, building advanced debouncing and sampling with basic async operators like sleep() or by defining rules tied to a scheduler

And at the same time, this has actually been a pretty superficial tour: we haven't gotten into a lot of things like how to actually use signals or abort jobs or any other details, really. For those, you'll currently have to dig through the API Reference, but there should be more tutorials and guides as time goes on.

(Also, at some point you'll be able to use the "Calibre Connect" Obsidian plugin I'm working on as an example of how to use these things to create responsive search and tame Electron WebFrames while keeping track of whether a connection to a remote server is available, handling logins and background processes and integrating a plugin to a larger application, not to mention controlling lots of features via settings.)

In the meantime, this library should now be available for installation and experimentation via npm, as an ESM-only package. Enjoy!

更新履歴


title: Changelog

Changelog

0.0.12

uneventful/ext

  • {@link uneventful/ext.Ext Ext}: refactored how instance construction works to eliminate some typing pitfalls and simplify overriding __new__

uneventful/signals

  • Replaced unchangedIf() with {@link uneventful/signals.stable stable()}, {@link uneventful/signals.stableArray stableArray()} and other {@link uneventful/signals.stabilizer stablilizer()} features. (The old function has been deprecated and will be removed in a later release.)

0.0.11

uneventful

  • Refactored internal context management to use less memory (and fewer objects) per signal, and to reduce the amount of pointer indirection on some common code paths.

uneventful/ext (NEW)

  • New module for easily adding on-the-fly extension properties and methods to arbitrary objects, inspired by the Python AddOns package.

uneventful/signals

  • Fixed an issue where signals polling external data using {@link uneventful.recalcWhen}() could become stale unless observed by a rule.
  • Reduced thrashing behavior of signals w/no deps that are executed purely for job side-effects. Previously, they would rollback and re-run every time they were called, but now they only roll back if they cease being observed. (And if called when unobserved, they will only start+rollback the first time they end up with no dependencies.) In terms of end results, the behavior is still the same: i.e., the signal job will end up active or rolled back during the same general periods, this just gets rid of temporary flip-flopping when the signal doesn't depend on any other signals.

uneventful/utils

  • Added {@link uneventful/utils.call call()} function as an IIFE replacement utility

0.0.10

uneventful

  • Added root job to replace detached (which is now deprecated). Creating root-based rather than detached jobs means there is a single point from which all resources can be cleaned up.

uneventful/shared (NEW)

  • {@link uneventful/shared.service service()}: wrap a factory function to create a singleton service accessor
  • {@link uneventful/shared.fork fork()}: wrap a generator, generator function, or generator method to run in parallel, and have a result that can be waited on in parallel as well
  • {@link uneventful/shared.expiring expiring()}: proxy an object so it cannot be accessed after the calling job ends

uneventful/signals

  • Added {@link uneventful/signals.rule.root rule.root} to replace rule.detached (which is now deprecated)
  • Added .edit() method to writable signals (to patch the existing value using a function)
  • Fixed code inside a {@link uneventful/signals.peek peek()} or {@link uneventful/signals.action action()} not being able to access the job of the enclosing rule, if it hadn't already been used

uneventful/utils

  • Added {@link uneventful/utils.decorateMethod decorateMethod()}: a helper for creating hybrid (TC39/legacy) decorator/function wrappers
  • Added {@link uneventful/utils.isGeneratorFunction isGeneratorFunction()} to check for native generator function

0.0.9

  • Fixed: task decorator was passing the job as an extra argument to the wrapped function

0.0.8

uneventful/signals

  • Any computed signal (i.e. a cached() function or a value() with a .setf()) can now start jobs or register cleanups for their side-effects. (Previously, only rules could do this.)

    The jobs are ended (or cleanups run) when the signal ceases to have subscribers, or when the values the signal depends on change. (Unobserved signals with jobs are also recalculated if they gain subscribers later, even if none of their dependencies have changed. This is so their side-effects will be restored without needing to wait for a change in their dependencies.)

    You can also use the new isObserved() function (from the uneventful main package) to test whether the current code is running inside of an observed signal or rule, with the side-effect that if the signal is not currently observed, then it will be recalculated when it becomes observed. (This lets you avoid setting up jobs or cleanup-needing effects that will be immediately discarded due to a lack of subscribers, but still set them up as soon as there is demand for them.)

  • Added unchangedIf(): allows reactive expressions (in cached() or value().setf()) to return their previous value if the new value is equivalent according to a custom comparison function (arrayEq() by default)

  • Backward incompatibility: Removed the stop parameter from rule functions, so that signals and zero-argument states can be used as rule actions. (Use rule.stop() instead.)

uneventful/utils

  • Expose batch() factory for creating generic batch processors
  • Add GeneratorBase for identifying generators with instanceof
  • Add arrayEq() for comparing array contents
    • Refactor scheduling internals to remove subclassing

0.0.7

  • Fix build process not running tests

0.0.6

  • Moved main signals API to a separate export (uneventful/signals) and exposed the utils module as an export (uneventful/utils).
  • Expanded and enhanced the RuleFactory interface:
    • rule.stop can now be saved and then called from outside a rule
    • rule.detached(...) is a new shorthand for detached.run(rule, ...)
    • rule.setScheduler() lets you change how a rule will be scheduled (from inside it)
  • Changed when streams-as-signals and recalcWhen() handle subscribes and unsubscribes so that they don't thrash on and off when there's a single subscriber that's synchronously removed and re-added.

0.0.5

  • Fix: next() and until() should not resume job with the rule still active
  • Fix: signal unsubscription causing future conditional re-subscriptions to fail

0.0.4

  • Added "constant folding" to signals: a cached function that has no dependencies (statically or dynamically) will become a constant and in turn omitted from the dependencies of its readers, which will then also become constants if they have no other dependencies.
  • Track "virtual reads" of signals for write conflict and cycle detection. (A signal is "virtually" read if its value is known to be unchanged, and that fact is relied upon to avoid recalculating other values. Such "virtual" reads must still be considered a write conflict if written to during the same timestamp.)
  • noDeps() has been renamed peek()
  • Signal and Writable are now interfaces instead of classes
  • The rules API has been overhauled:
    • Added the rule.if() API
    • Added the @rule.method decorator (with TC39/legacy decorator autodetection)
    • rule.factory() replaces RuleScheduler.for()
    • rule.stop() stops the active rule
    • runRules() can be given a scheduling function to flush a specific queue
  • The until() API has now been split: next() is used to get a signal or stream's next value, while until() yields the next truthy value, and auto-converts zero-argument functions to signals. until() also does not work on promises any more, since start(), to() and fromPromise() all accept promises already.
  • New wrapper/decorators: task(fn)/@task, and action(fn)/@action. Both support both the TC39 and legacy decorator protocols.
  • The type previously called Source is now Stream, and the type previously called Producer is now Source. This helps make the documentation clearer on some points.
  • value() objects now have a .setf() method that can be used to set them to a "formula" (callback expression), not unlike a spreadsheet cell. Regular .set() overwrites the formula with a value, and .setf() replaces the value with a formula. This makes it easier to implement components with configurable signal bindings.
  • Fix lazy() stream not forwarding its inlet
  • Fix misc. issues with the ending of streams implemented as signals

0.0.3

  • Dropped CJS export
  • Allow returning a cleanup callback from a start() callback
  • Added forEach() API
  • Improved Source and Signal type inference

0.0.2

  • Fix: signals used as streams should run sinks in a null context to prevent dependency tracking
  • Fix: signals' until() rules should be ended when the job resumes

0.0.1

  • Initial release