diff --git a/CHANGELOG.md b/CHANGELOG.md index e815eda..da99059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### 0.0.6 (unreleased) +- 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, ...)` diff --git a/package.json b/package.json index 5656a26..6136293 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,28 @@ "homepage": "https://uneventful.js.org", "license": "ISC", "type": "module", - "files": ["./dist/*"], + "types": "./dist/mod.d.ts", + "files": [ + "./dist/*" + ], "exports": { - "import": { - "types": "./dist/mod.d.ts", - "default": "./dist/mod.mjs" + ".": { + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.mjs" + } + }, + "./signals": { + "import": { + "types": "./dist/signals.d.ts", + "default": "./dist/signals.mjs" + } + }, + "./utils": { + "import": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.mjs" + } } }, "scripts": { diff --git a/specs/cells.spec.ts b/specs/cells.spec.ts index f29f1e5..e1154c0 100644 --- a/specs/cells.spec.ts +++ b/specs/cells.spec.ts @@ -1,5 +1,5 @@ import { log, see, describe, expect, it, useRoot, spy } from "./dev_deps.ts"; -import { runRules, value, cached, rule, CircularDependency, WriteConflict } from "../mod.ts"; +import { runRules, value, cached, rule, CircularDependency, WriteConflict } from "../src/signals.ts"; import { defer } from "../src/defer.ts"; import { current } from "../src/ambient.ts"; diff --git a/specs/jobs.spec.ts b/specs/jobs.spec.ts index 24c1a8c..87108b8 100644 --- a/specs/jobs.spec.ts +++ b/specs/jobs.spec.ts @@ -1,9 +1,10 @@ import { log, see, describe, expect, it, useClock, clock, useRoot, noClock, logUncaught } from "./dev_deps.ts"; import { - start, Suspend, Request, to, resolve, reject, resolver, rejecter, Yielding, must, until, fromIterable, - IsStream, value, cached, runRules, backpressure, sleep, isHandled, Connection, detached, makeJob, + start, Suspend, Request, to, resolve, reject, resolver, rejecter, Yielding, must, fromIterable, + IsStream, backpressure, sleep, isHandled, Connection, detached, makeJob, CancelError, throttle, next } from "../src/mod.ts"; +import { value, cached, runRules, until } from "../src/signals.ts"; import { runPulls } from "../src/scheduling.ts"; import { catchers, defaultCatch } from "../src/internals.ts"; diff --git a/specs/rules.spec.ts b/specs/rules.spec.ts index c7dd059..10e4c02 100644 --- a/specs/rules.spec.ts +++ b/specs/rules.spec.ts @@ -1,5 +1,6 @@ import { log, see, describe, it, useRoot, msg, expect } from "./dev_deps.ts"; -import { runRules, value, rule, must, DisposeFn, detached, SchedulerFn } from "../mod.ts"; +import { must, DisposeFn, detached } from "../mod.ts"; +import { runRules, value, rule, SchedulerFn } from "../src/signals.ts"; describe("@rule.method", () => { useRoot(); diff --git a/specs/signals.spec.ts b/specs/signals.spec.ts index 7ee2c7b..b44d698 100644 --- a/specs/signals.spec.ts +++ b/specs/signals.spec.ts @@ -1,9 +1,9 @@ import { log, see, describe, expect, it, useRoot, useClock, clock } from "./dev_deps.ts"; import { - runRules, value, cached, rule, peek, WriteConflict, Signal, Writable, must, recalcWhen, - DisposeFn, RecalcSource, mockSource, lazy, detached, each, sleep, - SignalImpl, ConfigurableImpl, action -} from "../mod.ts"; + runRules, value, cached, rule, peek, WriteConflict, Signal, Writable, SignalImpl, ConfigurableImpl, action +} from "../src/signals.ts"; +import { recalcWhen } from "../src/sinks.ts"; +import { must, DisposeFn, RecalcSource, mockSource, lazy, detached, each, sleep } from "../src/mod.ts"; import { current } from "../src/ambient.ts"; import { nullCtx } from "../src/internals.ts"; import { defaultQ } from "../src/scheduling.ts"; diff --git a/specs/tracking.spec.ts b/specs/tracking.spec.ts index 77636b6..6731b9a 100644 --- a/specs/tracking.spec.ts +++ b/specs/tracking.spec.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, clock, describe, expect, it, log, see, spy, useClock, useRoot } from "./dev_deps.ts"; import { current, freeCtx, makeCtx, swapCtx } from "../src/ambient.ts"; -import { rule, runRules, noop, CleanupFn, Job, start, isJobActive, must, detached, makeJob, getJob, isCancel, isValue, restarting, isHandled, JobResult, nativePromise, Suspend, getResult } from "../mod.ts"; +import { + CleanupFn, Job, JobResult, Suspend, detached, getJob, getResult, isCancel, isHandled, isJobActive, isValue, makeJob, + must, nativePromise, noop, restarting, start +} from "../mod.ts"; +import { rule, runRules } from "../src/signals.ts"; import { Cell } from "../src/cells.ts"; describe("makeJob()", () => { diff --git a/src/call-or-wait.ts b/src/call-or-wait.ts new file mode 100644 index 0000000..b36db39 --- /dev/null +++ b/src/call-or-wait.ts @@ -0,0 +1,22 @@ +import { Job, Yielding } from "./types.ts"; +import { start } from "./jobutils.ts"; +import { isValue, isError, markHandled } from "./results.ts"; +import { isFunction } from "./utils.ts"; +import { connect, Source } from "./streams.ts"; + +export function callOrWait( + source: any, method: string, handler: (job: Job, val: T) => void, noArgs: (f?: any) => Yielding | void +) { + if (source && isFunction(source[method])) return source[method]() as Yielding; + if (isFunction(source)) return ( + source.length === 0 ? noArgs(source) : false + ) || start(job => { + connect(source as Source, v => handler(job, v)).do(r => { + if (isValue(r)) job.throw(new Error("Stream ended")); + else if (isError(r)) job.throw(markHandled(r)); + }); + }); + mustBeSourceOrSignal(); +} + +export function mustBeSourceOrSignal() { throw new TypeError("not a source or signal"); } diff --git a/src/mod.ts b/src/mod.ts index b20a845..6bb7bae 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,20 @@ +/** + * This is the default export of uneventful, which contains the API for jobs and + * streams, as well as any signals-related APIs that don't depend on the signals + * framework (e.g. {@link recalcWhen}, which does nothing if the signals framework + * isn't in use, and doesn't cause it to be imported). + * + * For the rest of the signals API, see the + * [uneventful/signals](uneventful_signals.html) export. + * + * @module uneventful + */ + export { defer } from "./defer.ts"; export * from "./types.ts"; export * from "./results.ts"; export * from "./tracking.ts"; export * from "./async.ts"; -export * from "./signals.ts"; export * from "./streams.ts" export * from "./sources.ts"; export * from "./sinks.ts"; diff --git a/src/signals.ts b/src/signals.ts index a5cd329..2424025 100644 --- a/src/signals.ts +++ b/src/signals.ts @@ -1,12 +1,20 @@ +/** + * The Signals API for uneventful. + * + * @module uneventful/signals + */ + import { current, freeCtx, makeCtx, swapCtx } from "./ambient.ts"; -import { PlainFunction, Yielding, RecalcSource, AnyFunction } from "./types.ts"; +import { PlainFunction, Yielding, AnyFunction, Job } from "./types.ts"; import { Cell } from "./cells.ts"; import { rule } from "./rules.ts"; import { reject, resolve } from "./results.ts"; import { UntilMethod } from "./sinks.ts"; -import { SignalSource, Source } from "./streams.ts"; +import { SignalSource, Source, Stream } from "./streams.ts"; +import { callOrWait } from "./call-or-wait.ts"; import { CallableObject, apply } from "./utils.ts"; import { defer } from "./defer.ts"; +import { next } from "./sinks.ts"; // needed for documentation link export type * from "./rules.ts" export { rule, runRules } from "./rules.ts" @@ -241,58 +249,6 @@ export function peek(fn: F, ...args: Parameters): Re try { return fn(...args); } finally { freeCtx(swapCtx(old)); } } -/** - * Arrange for the current signal or rule to recalculate on demand - * - * This lets you interop with systems that have a way to query a value and - * subscribe to changes to it, but not directly produce a signal. (Such as - * querying the DOM state and using a MutationObserver.) - * - * By calling this with a {@link Source} or {@link RecalcSource}, you arrange - * for it to be subscribed, if and when the call occurs in a rule or a cached - * function that's in use by a rule (directly or indirectly). When the source - * emits a value, the signal machinery will invalidate the caching of the - * function or rule, forcing a recalculation and subsequent rule reruns, if - * applicable. - * - * Note: you should generally only call the 1-argument version of this function - * with "static" sources - i.e. ones that won't change on every call. Otherwise, - * you will end up creating new signals each time, subscribing and unsubscribing - * on every call to recalcWhen(). - * - * If the source needs to reference some object, it's best to use the 2-argument - * version (i.e. `recalcWhen(someObj, factory)`, where `factory` is a function - * that takes `someObj` and returns a suitable {@link RecalcSource}.) - * - * @remarks - * recalcWhen is specifically designed so that using it does not pull in any - * part of Uneventful's signals framework, in the event a program doesn't - * already use it. This means you can use it in library code to provide signal - * compatibility, without adding bundle bloat to code that doesn't use signals. - * - * @category Signals - */ -export function recalcWhen(src: RecalcSource): void; -/** - * Two-argument variant of recalcWhen - * - * In certain circumstances, you may wish to use recalcWhen with a source - * related to some object. You could call recalcWhen with a closure, but that - * would create and discard signals on every call. So this 2-argument version - * lets you avoid that by allowing the use of an arbitrary object as a key, - * along with a factory function to turn the key into a {@link RecalcSource}. - * - * @param key an object to be used as a key - * - * @param factory a function that will be called with the key to obtain a - * {@link RecalcSource}. (Note that this factory function must also be a static - * function, not a closure, or the same memory thrash issue will occur!) - */ -export function recalcWhen(key: T, factory: (key: T) => RecalcSource): void; -export function recalcWhen(fnOrKey: T | RecalcSource, fn?: (key: T) => RecalcSource) { - current.cell?.recalcWhen(fnOrKey as T, fn); -} - /** * Wrap a function (or decorate a method) so that signals it reads are not added * as dependencies to the current rule (if any). (Basically, it's shorthand for @@ -352,3 +308,40 @@ export function action(fn: F, _ctx try { return apply(fn, this, arguments); } finally { freeCtx(swapCtx(old)); } } } + +/** + * Wait for and return the next truthy value (or error) from a data source (when + * processed with `yield *` within a {@link Job}). + * + * This differs from {@link next}() in that it waits for the next "truthy" value + * (i.e., not null, false, zero, empty string, etc.), and when used with signals + * or a signal-using function, it can resume *immediately* if the result is + * already truthy. (It also supports zero-argument signal-using functions, + * automatically wrapping them with {@link cached}(), as the common use case for + * until() is to wait for an arbitrary condition to be satisfied.) + * + * @param source The source to wait on, which can be: + * - An object with an `"uneventful.until"` method returning a {@link Yielding} + * (in which case the result will be the the result of calling that method) + * - A {@link Signal}, or a zero-argument function returning a value based on + * signals (in which case the job resumes as soon as the result is truthy, + * perhaps immediately) + * - A {@link Source} (in which case the job resumes on the next truthy value + * it produces + * + * (Note: if the supplied source is a function with a non-zero `.length`, it is + * assumed to be a {@link Source}.) + * + * @returns a Yieldable that when processed with `yield *` in a job, will return + * the triggered event, or signal value. An error is thrown if event stream + * throws or closes early, or the signal throws. + * + * @category Signals + * @category Scheduling + */ + +export function until(source: UntilMethod | Stream | (() => T)): Yielding { + return callOrWait(source, "uneventful.until", waitTruthy, recache); +} +function recache(s: () => T) { return until(cached(s)); } +function waitTruthy(job: Job, v: T) { v && job.return(v); } diff --git a/src/sinks.ts b/src/sinks.ts index b1fe3f5..d2436c2 100644 --- a/src/sinks.ts +++ b/src/sinks.ts @@ -1,10 +1,12 @@ -import { Job, Request, Suspend, Yielding } from "./types.ts" +import { Job, RecalcSource, Request, Suspend, Yielding } from "./types.ts" import { defer } from "./defer.ts"; -import { Connection, Inlet, Source, Sink, Stream, connect, pipe, throttle } from "./streams.ts"; -import { resolve, isError, markHandled, isValue, fulfillPromise, rejecter, resolver } from "./results.ts"; +import { Connection, Inlet, Sink, Stream, connect, pipe, throttle } from "./streams.ts"; +import { resolve, isError, markHandled, fulfillPromise, rejecter, resolver } from "./results.ts"; import { restarting, start } from "./jobutils.ts"; -import { isFunction } from "./tracking.ts"; -import { Signal, cached } from "./signals.ts"; +import { isFunction } from "./utils.ts"; +import { Signal, until } from "./signals.ts"; // the until is needed for documentation link +import { callOrWait, mustBeSourceOrSignal } from "./call-or-wait.ts"; +import { current } from "./ambient.ts"; /** * The result type returned from calls to {@link Each}.next() @@ -122,42 +124,6 @@ export interface NextMethod { "uneventful.next"(): Yielding }; -/** - * Wait for and return the next truthy value (or error) from a data source (when - * processed with `yield *` within a {@link Job}). - * - * This differs from {@link next}() in that it waits for the next "truthy" value - * (i.e., not null, false, zero, empty string, etc.), and when used with signals - * or a signal-using function, it can resume *immediately* if the result is - * already truthy. (It also supports zero-argument signal-using functions, - * automatically wrapping them with {@link cached}(), as the common use case for - * until() is to wait for an arbitrary condition to be satisfied.) - * - * @param source The source to wait on, which can be: - * - An object with an `"uneventful.until"` method returning a {@link Yielding} - * (in which case the result will be the the result of calling that method) - * - A {@link Signal}, or a zero-argument function returning a value based on - * signals (in which case the job resumes as soon as the result is truthy, - * perhaps immediately) - * - A {@link Source} (in which case the job resumes on the next truthy value - * it produces - * - * (Note: if the supplied source is a function with a non-zero `.length`, it is - * assumed to be a {@link Source}.) - * - * @returns a Yieldable that when processed with `yield *` in a job, will return - * the triggered event, or signal value. An error is thrown if event stream - * throws or closes early, or the signal throws. - * - * @category Signals - * @category Scheduling - */ -export function until(source: UntilMethod | Stream | (() => T)): Yielding { - return callOrWait(source, "uneventful.until", waitTruthy, recache); -} -function recache(s: () => T) { return until(cached(s)); } -function waitTruthy(job: Job, v: T) { v && job.return(v); } - /** * Wait for and return the next value (or error) from a data source (when * processed with `yield *` within a {@link Job}). @@ -189,23 +155,6 @@ export function next(source: NextMethod | Stream): Yielding { function waitAny(job: Job, v: T) { job.return(v); } -function callOrWait( - source: any, method: string, handler: (job: Job, val: T) => void, noArgs: (f?: any) => Yielding|void -) { - if (source && isFunction(source[method])) return source[method]() as Yielding; - if (isFunction(source)) return ( - source.length === 0 ? noArgs(source) : false - ) || start(job => { - connect(source as Source, v => handler(job, v)).do(r => { - if(isValue(r)) job.throw(new Error("Stream ended")); - else if (isError(r)) job.throw(markHandled(r)); - }); - }) - mustBeSourceOrSignal(); -} - -function mustBeSourceOrSignal() { throw new TypeError("not a source or signal"); } - /** * Run a {@link restarting}() callback for each value produced by a source. * @@ -247,3 +196,56 @@ export function forEach( inlet = sink as Inlet; sink = src as Sink; return (src: Stream) => forEach(src, sink as Sink, inlet); } + +/** + * Arrange for the current signal or rule to recalculate on demand + * + * This lets you interop with systems that have a way to query a value and + * subscribe to changes to it, but not directly produce a signal. (Such as + * querying the DOM state and using a MutationObserver.) + * + * By calling this with a {@link Source} or {@link RecalcSource}, you arrange + * for it to be subscribed, if and when the call occurs in a rule or a cached + * function that's in use by a rule (directly or indirectly). When the source + * emits a value, the signal machinery will invalidate the caching of the + * function or rule, forcing a recalculation and subsequent rule reruns, if + * applicable. + * + * Note: you should generally only call the 1-argument version of this function + * with "static" sources - i.e. ones that won't change on every call. Otherwise, + * you will end up creating new signals each time, subscribing and unsubscribing + * on every call to recalcWhen(). + * + * If the source needs to reference some object, it's best to use the 2-argument + * version (i.e. `recalcWhen(someObj, factory)`, where `factory` is a function + * that takes `someObj` and returns a suitable {@link RecalcSource}.) + * + * @remarks + * recalcWhen is specifically designed so that using it does not pull in any + * part of Uneventful's signals framework, in the event a program doesn't + * already use it. This means you can use it in library code to provide signal + * compatibility, without adding bundle bloat to code that doesn't use signals. + * + * @category Signals + */ + +export function recalcWhen(src: RecalcSource): void; +/** + * Two-argument variant of recalcWhen + * + * In certain circumstances, you may wish to use recalcWhen with a source + * related to some object. You could call recalcWhen with a closure, but that + * would create and discard signals on every call. So this 2-argument version + * lets you avoid that by allowing the use of an arbitrary object as a key, + * along with a factory function to turn the key into a {@link RecalcSource}. + * + * @param key an object to be used as a key + * + * @param factory a function that will be called with the key to obtain a + * {@link RecalcSource}. (Note that this factory function must also be a static + * function, not a closure, or the same memory thrash issue will occur!) + */ +export function recalcWhen(key: T, factory: (key: T) => RecalcSource): void; +export function recalcWhen(fnOrKey: T | RecalcSource, fn?: (key: T) => RecalcSource) { + current.cell?.recalcWhen(fnOrKey as T, fn); +} diff --git a/src/tracking.ts b/src/tracking.ts index 957d3e2..ca4af97 100644 --- a/src/tracking.ts +++ b/src/tracking.ts @@ -7,15 +7,7 @@ import { rejecter, resolver, getResult, fulfillPromise } from "./results.ts"; import { Chain, chain, isEmpty, pop, push, pushCB, qlen, recycle, unshift } from "./chains.ts"; import { Stream, Sink, Inlet, Connection } from "./streams.ts"; import { apply } from "./utils.ts"; - -/** - * Is the given value a function? - * - * @category Types and Interfaces - */ -export function isFunction(f: any): f is Function { - return typeof f === "function"; -} +import { isFunction } from "./utils.ts"; /** * Return the currently-active Job, or throw an error if none is active. diff --git a/src/utils.ts b/src/utils.ts index 5b20541..b5a5204 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,16 +1,65 @@ +/** + * Utilities that aren't part of Uneventful's core feature set, but are exposed + * anyway, because they're boilerplate that can save duplication elsewhere if + * you happen to need them. + * + * @module uneventful/utils + */ import { AnyFunction } from "./types.ts"; +/** + * Set a value in a Map or WeakMap, and return the value. + * + * Commonly used with constructions like `return map.get(key) ?? setMap(map, + * key, calculateDefault())`. + * + * @template K The type of key accepted by the map + * @template V The type of value accepted by the map + */ export function setMap(map: { set(key: K, val: V): void; }, key: K, val: V) { map.set(key, val); return val; } +/** + * Is the given value a function? (Shorthand for `typeof f === "function"`) + */ +export function isFunction(f: any): f is Function { + return typeof f === "function"; +} + /** * This class hides the implementation details of inheriting from Function in * the documentation. (By default, typedoc exposes all the inherited properties * and members, which we don't want. By inheriting from it instead of from * Function, we keep the documentation free of unimportant details.) + * + * The way this works is that you subclass CallableObject and define a + * constructor that calls `super(someClosure)` where `someClosure` is a unique + * function object, which will then pick up any properties or methods defined by + * the subclass. + * + * @template T The call/return signature that instances of the class will + * implement. */ +//@ts-ignore not really a duplicate +export declare class CallableObject extends Function { + /** + * @param fn A unique function or closure, to be passed to super() in a + * subclass. The function object will gain a prototype from `new.target`, + * thereby picking up any properties or methods defined by the class, + * and becoming `this` for the calling constructor. + * + * (Note that calling the constructor by any means other than super() from + * a constructor will result in an error or some other unhelpful result.) + */ + constructor(fn: T); + /** @internal */ declare length: number; + /** @internal */ declare arguments: any; + /** @internal */ declare caller: Function; + /** @internal */ declare prototype: any; + /** @internal */ declare name: string; +} //@ts-ignore CallableObject adheres to the constraint of T in its *implementation* export interface CallableObject extends T { /** @internal */ [Symbol.hasInstance](value: any): boolean; @@ -19,19 +68,10 @@ export interface CallableObject extends T { /** @internal */ call(this: Function, thisArg: any, ...argArray: any[]): any; /** @internal */ toString(): string; } - -export declare class CallableObject extends Function { - /** @internal */ protected constructor(fn: T); - /** @internal */ declare length: number; - /** @internal */ declare arguments: any; - /** @internal */ declare caller: Function; - /** @internal */ declare prototype: any; - /** @internal */ declare name: string; -} - -export function CallableObject(fn: T) { return Object.setPrototypeOf(fn, new.target.prototype); } - -// No need to have extra prototypes in the chain -CallableObject.prototype = Function.prototype as any; +//@ts-ignore This is the real implementation of the above declarations +export const CallableObject = /* @__PURE__ */ ( () => Object.assign( + function CallableObject(fn: T) { return Object.setPrototypeOf(fn, new.target.prototype); }, + {prototype: Function.prototype } // No need to have extra prototypes in the chain +)); export const {apply} = Reflect; diff --git a/typedoc.config.cjs b/typedoc.config.cjs index 6a54fa6..d3a6d37 100644 --- a/typedoc.config.cjs +++ b/typedoc.config.cjs @@ -4,7 +4,12 @@ if (OS==="Windows_NT" && TYPEDOC_GIT_DIR !== undefined) process.env.PATH=`${TYPE /** @type {import('typedoc').TypeDocOptions} */ module.exports = { - entryPoints: ["./src/mod.ts"], + entryPoints: [ + "./src/mod.ts", + "./src/signals.ts", + "./src/utils.ts", + ], + name: "Uneventful", customCss: ["./typedoc/custom.css"], categorizeByGroup: false, categoryOrder: [