From eb932926b01c993ac2649ae36eed417b2a53b157 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:43:06 -0800 Subject: [PATCH 01/17] refactor(components): use generic type params more consistently --- src/components/frame.ts | 14 ++++++-------- src/components/input.ts | 11 +++++------ src/components/label.ts | 4 +--- src/components/slider.ts | 3 +-- src/components/table.ts | 25 ++++++++++++------------- src/components/text.ts | 4 +--- src/components/textbox.ts | 2 +- 7 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/components/frame.ts b/src/components/frame.ts index ddd18ff..8603756 100644 --- a/src/components/frame.ts +++ b/src/components/frame.ts @@ -64,14 +64,12 @@ export interface FrameOptions extends ComponentOptions { * }); * ``` */ -export class Frame extends Component { - declare drawnObjects: { - top: TextObject; - bottom: TextObject; - left: BoxObject; - right: BoxObject; - }; - +export class Frame extends Component<{ + top: TextObject; + bottom: TextObject; + left: BoxObject; + right: BoxObject; +}> { charMap: Signal; constructor(options: FrameOptions) { diff --git a/src/components/input.ts b/src/components/input.ts index d2f074a..c8bfca5 100644 --- a/src/components/input.ts +++ b/src/components/input.ts @@ -91,12 +91,11 @@ export interface InputOptions extends Omit { * }); * ``` */ -export class Input extends Box { - declare drawnObjects: { - box: BoxObject; - text: TextObject; - cursor: TextObject; - }; +export class Input extends Box<{ + box: BoxObject; + text: TextObject; + cursor: TextObject; +}> { declare theme: InputTheme; text: Signal; diff --git a/src/components/label.ts b/src/components/label.ts index 7866442..03b5866 100644 --- a/src/components/label.ts +++ b/src/components/label.ts @@ -86,9 +86,7 @@ export interface LabelOptions extends Omit { * }) * ``` */ -export class Label extends Component { - declare drawnObjects: { texts: TextObject[] }; - +export class Label extends Component<{ texts: TextObject[] }> { #valueLines: Signal; text: Signal; diff --git a/src/components/slider.ts b/src/components/slider.ts index 70266b4..92eb790 100644 --- a/src/components/slider.ts +++ b/src/components/slider.ts @@ -58,8 +58,7 @@ export interface SliderOptions extends ComponentOptions { * }); * ``` */ -export class Slider extends Box { - declare drawnObjects: { box: BoxObject; thumb: BoxObject }; +export class Slider extends Box<{ box: BoxObject; thumb: BoxObject }> { declare theme: SliderTheme; min: Signal; diff --git a/src/components/table.ts b/src/components/table.ts index 79d472d..ba78180 100644 --- a/src/components/table.ts +++ b/src/components/table.ts @@ -100,20 +100,19 @@ export interface TableOptions extends Omit { * * ``` */ -export class Table extends Component { +export class Table extends Component<{ + frame: [ + top: TextObject, + bottom: TextObject, + spacer: TextObject, + left: BoxObject, + right: BoxObject, + ]; + + header: TextObject; + data: TextObject[]; +}> { declare theme: TableTheme; - declare drawnObjects: { - frame: [ - top: TextObject, - bottom: TextObject, - spacer: TextObject, - left: BoxObject, - right: BoxObject, - ]; - - header: TextObject; - data: TextObject[]; - }; data: Signal; headers: Signal[]>; diff --git a/src/components/text.ts b/src/components/text.ts index f4f630e..797f698 100644 --- a/src/components/text.ts +++ b/src/components/text.ts @@ -57,9 +57,7 @@ export interface TextOptions extends Omit { * }) * ``` */ -export class Text extends Component { - declare drawnObjects: { text: TextObject }; - +export class Text extends Component<{ text: TextObject }> { text: Signal; overwriteRectangle: Signal; multiCodePointSupport: Signal; diff --git a/src/components/textbox.ts b/src/components/textbox.ts index 9b5c1d1..8beaf32 100644 --- a/src/components/textbox.ts +++ b/src/components/textbox.ts @@ -9,7 +9,7 @@ import type { DeepPartial } from "../types.ts"; import { cropToWidth, insertAt } from "../utils/strings.ts"; import { clamp } from "../utils/numbers.ts"; import { Computed, Effect, Signal, signalify } from "../signals/mod.ts"; -import type { KeyPressEvent } from "../input_reader/types.ts"; +import type { KeyPressEvent } from "../input/types.ts"; export interface CursorPosition { x: number; From df01504fa72278865b936186278d162e6c3164bd Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:43:42 -0800 Subject: [PATCH 02/17] fix: update broken import path in component.ts --- src/component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component.ts b/src/component.ts index 085939c..74ae80b 100644 --- a/src/component.ts +++ b/src/component.ts @@ -8,7 +8,7 @@ import type { Rectangle } from "./types.ts"; import { SortedArray } from "./utils/sorted_array.ts"; import type { Renderable } from "./canvas/renderable.ts"; import type { View } from "./view.ts"; -import type { InputEventRecord } from "./input_reader/mod.ts"; +import type { InputEventRecord } from "./input/mod.ts"; import { Computed, Signal, type SignalOfObject } from "./signals/mod.ts"; import { signalify } from "./signals/signalify.ts"; From 80aab287a784854b419494e7bcbafc544dfa2b3d Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:44:26 -0800 Subject: [PATCH 03/17] refactor(events): use readonly array for EventEmitter type constraint --- src/event_emitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/event_emitter.ts b/src/event_emitter.ts index 088142c..689b652 100644 --- a/src/event_emitter.ts +++ b/src/event_emitter.ts @@ -13,7 +13,7 @@ export type EventListener< * Type for creating new arguments * - Required as a workaround for simple tuples and arrays types not working properly */ -export type EmitterEvent = { +export type EmitterEvent = { args: Args; }; From ff19e73327bcd60ff097aa9e55b71f80b504ecde Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:45:05 -0800 Subject: [PATCH 04/17] fix(tui): updating broken import in tui.ts --- src/tui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui.ts b/src/tui.ts index 37e77d2..64cdb43 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -2,7 +2,7 @@ import { BoxObject, Canvas } from "./canvas/mod.ts"; import type { Component } from "./component.ts"; import { type EmitterEvent, EventEmitter } from "./event_emitter.ts"; -import type { InputEventRecord } from "./input_reader/mod.ts"; +import type { InputEventRecord } from "./input/mod.ts"; import { Computed, type Signal } from "./signals/mod.ts"; import type { Style } from "./theme.ts"; import { type Rectangle, Stdin, Stdout } from "./types.ts"; From 4bc43a869a571fd227c0ac665e79743ae584ea88 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:45:48 -0800 Subject: [PATCH 05/17] feat(internal): support thisArg in SortedArray.sort method --- src/utils/sorted_array.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/sorted_array.ts b/src/utils/sorted_array.ts index 51ead72..be53934 100644 --- a/src/utils/sorted_array.ts +++ b/src/utils/sorted_array.ts @@ -51,8 +51,13 @@ export class SortedArray extends Array { return super.copyWithin(target, start, end).sort(this.compareFn); } - override sort(compareFn?: (a: T, b: T) => number): this { - return super.sort(compareFn ?? this.compareFn), this; + override sort( + compareFn?: (this: This, a: T, b: T) => number, + thisArg?: This, + ): this { + compareFn ??= this.compareFn; + if (thisArg) compareFn = compareFn?.bind(thisArg); + return super.sort(compareFn), this; } override fill(value: T, start?: number, end?: number): this { From f1728beed685fb40ff59c0c32d4f0efa1b7935b2 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:46:22 -0800 Subject: [PATCH 06/17] refactor(utils): use more robust global object resolution --- src/utils/runtime.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts index 017bc57..3920d4f 100644 --- a/src/utils/runtime.ts +++ b/src/utils/runtime.ts @@ -1,5 +1,13 @@ // deno-lint-ignore no-explicit-any -const _global: any = globalThis; +const _global: any = (() => { + try { + // deno-lint-ignore no-node-globals + return (0, eval)("this") || self || window || globalThis || global; + } catch (_) { + // deno-lint-ignore no-node-globals + return self || window || globalThis || global; + } +})(); export type Runtime = | "node" From 3b57fce9babdf6f0d88784e5296a44427dedc6b6 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:47:04 -0800 Subject: [PATCH 07/17] fix(canvas): add all missing override modifiers --- src/canvas/text.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/canvas/text.ts b/src/canvas/text.ts index 92f72dd..66701e0 100644 --- a/src/canvas/text.ts +++ b/src/canvas/text.ts @@ -141,19 +141,19 @@ export class TextObject extends Renderable<"text"> { }); } - draw(): void { + override draw(): void { this.#updateEffect.resume(); this.rectangle.subscribe(this.#rectangleSubscription); super.draw(); } - erase(): void { + override erase(): void { this.#updateEffect.pause(); this.rectangle.unsubscribe(this.#rectangleSubscription); super.erase(); } - updateMovement(): void { + override updateMovement(): void { const { objectsUnder, previousRectangle } = this; const rectangle = this.rectangle.peek(); @@ -204,7 +204,7 @@ export class TextObject extends Renderable<"text"> { } } - rerender(): void { + override rerender(): void { const { canvas, valueChars, omitCells, rerenderCells } = this; const { frameBuffer, rerenderQueue } = canvas; From 4fa52e674cb084d72c3e175fbe6220f4c122da60 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:48:03 -0800 Subject: [PATCH 08/17] fix(input): remove stray text from mouse.ts --- src/input/mouse.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/input/mouse.ts b/src/input/mouse.ts index e8053fe..e76f08f 100644 --- a/src/input/mouse.ts +++ b/src/input/mouse.ts @@ -29,7 +29,6 @@ export function decodeMouseSGR( const action = code.at(-1); if (!code.startsWith("\x1b[<") || (action !== "m" && action !== "M")) { return undefined; - SSS; } const release = action === "m"; From eb3189cc0240b8338fa5c47369093f0cf8c7432d Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:48:34 -0800 Subject: [PATCH 09/17] fix: broken import paths --- mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mod.ts b/mod.ts index 5a0a893..8bed586 100644 --- a/mod.ts +++ b/mod.ts @@ -12,4 +12,4 @@ export * from "./src/signals/mod.ts"; export * from "./src/layout/mod.ts"; export * from "./src/canvas/mod.ts"; export * from "./src/utils/mod.ts"; -export * from "./src/input_reader/mod.ts"; +export * from "./src/input/mod.ts"; From 1361a413bb4d5705f3df50fd5a9024686b4a4fe6 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 24 Feb 2026 18:48:41 -0800 Subject: [PATCH 10/17] chore: fmt --- src/layout/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layout/mod.ts b/src/layout/mod.ts index e290dd0..4db0a66 100644 --- a/src/layout/mod.ts +++ b/src/layout/mod.ts @@ -1,6 +1,6 @@ // Copyright 2023 Im-Beast. MIT license. export * from "./errors.ts"; +export * from "./grid_layout.ts"; export * from "./horizontal_layout.ts"; export * from "./layout.ts"; export * from "./vertical_layout.ts"; -export * from "./grid_layout.ts"; From fc00e7869c74a18ce1f58acebe884b491ae4582b Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Mon, 9 Mar 2026 18:24:13 -0700 Subject: [PATCH 11/17] refactor(signal): improve errors in signal submodules - [x] add a dedicated ./signals/errors submodule - [x] move error subclasses out of signal.ts and computed.ts - [x] improve some other minor things --- src/signals/computed.ts | 10 +--------- src/signals/effect.ts | 22 +++++++++++++--------- src/signals/errors.ts | 17 +++++++++++++++++ src/signals/mod.ts | 1 + src/signals/signal.ts | 14 +++++--------- src/signals/types.ts | 11 +++++++++++ 6 files changed, 48 insertions(+), 27 deletions(-) create mode 100644 src/signals/errors.ts diff --git a/src/signals/computed.ts b/src/signals/computed.ts index 88c7112..1becc3e 100644 --- a/src/signals/computed.ts +++ b/src/signals/computed.ts @@ -3,15 +3,7 @@ import { Signal } from "./signal.ts"; import type { Dependant, Dependency } from "./types.ts"; import { activeSignals, trackDependencies } from "./dependency_tracking.ts"; - -/** Thrown whenever someone tries to directly modify `Computed.value` */ -export class ComputedReadOnlyError extends Error { - constructor() { - super( - "Computed is read-only, you can't (and shouldn't!) directly modify its value", - ); - } -} +import { ComputedReadOnlyError } from "./errors.ts"; /** Function that's used to calculate `Computed`'s value */ export interface Computable { diff --git a/src/signals/effect.ts b/src/signals/effect.ts index 718a691..b531335 100644 --- a/src/signals/effect.ts +++ b/src/signals/effect.ts @@ -61,7 +61,7 @@ export class Effect implements Dependant, Disposable { update(cause: Dependency | Dependant): void { if (this.paused) { - throw "Something called update() on effect while being paused"; + throw new Error("Something called update() on effect while being paused"); } this.$effectable(cause); @@ -88,10 +88,12 @@ export class Effect implements Dependant, Disposable { * - Doesn't clear dependencies, can be resumed! */ pause(): void { - this.paused = true; - - for (const dependency of this.dependencies) { - dependency.dependants?.delete(this); + if (this.disposed) { + throw new ReferenceError("Effect already disposed"); + } + if (!this.paused) { + this.paused = true; + for (const dep of this.dependencies) dep.dependants?.delete(this); } } @@ -99,10 +101,12 @@ export class Effect implements Dependant, Disposable { * - Adds itself to all dependencies dependants */ resume(): void { - this.paused = false; - - for (const dependency of this.dependencies) { - dependency.depend(this); + if (this.disposed) { + throw new ReferenceError("Effect already disposed"); + } + if (this.paused) { + this.paused = false; + for (const dep of this.dependencies) dep.depend(this); } } diff --git a/src/signals/errors.ts b/src/signals/errors.ts new file mode 100644 index 0000000..d815707 --- /dev/null +++ b/src/signals/errors.ts @@ -0,0 +1,17 @@ +/** Thrown whenever someone tries to directly modify `Computed.value` */ +export class ComputedReadOnlyError extends Error { + constructor() { + super( + "Computed values are read-only and cannot be directly modified. If you need to change the value, change the signals it depends on instead." + ); + this.name = "ComputedReadOnlyError"; + } +} + +/** Thrown whenever `deepObserve` is set and `typeof value !== "object"` */ +export class SignalDeepObserveTypeofError extends Error { + constructor() { + super("You can only deeply observe value with typeof 'object'"); + this.name = "SignalDeepObserveTypeofError"; + } +} diff --git a/src/signals/mod.ts b/src/signals/mod.ts index cb6547a..0246adc 100644 --- a/src/signals/mod.ts +++ b/src/signals/mod.ts @@ -2,6 +2,7 @@ export * from "./computed.ts"; export * from "./dependency_tracking.ts"; export * from "./effect.ts"; +export * from "./errors.ts"; export * from "./flusher.ts"; export * from "./lazy_computed.ts"; export * from "./lazy_effect.ts"; diff --git a/src/signals/signal.ts b/src/signals/signal.ts index 87c0f9c..a77f62f 100644 --- a/src/signals/signal.ts +++ b/src/signals/signal.ts @@ -1,4 +1,5 @@ // Copyright 2023 Im-Beast. MIT license. +// deno-lint-ignore-file no-explicit-any import { activeSignals } from "./dependency_tracking.ts"; import { makeMapMethodsReactive, @@ -7,16 +8,12 @@ import { ORIGINAL_REF, type Reactive, } from "./reactivity.ts"; +import { SignalDeepObserveTypeofError } from "./errors.ts"; import type { Dependant, Dependency, Subscription } from "./types.ts"; // TODO: Make dispose revert reactive value modifications -/** Thrown whenever `deepObserve` is set and `typeof value !== "object"` */ -export class SignalDeepObserveTypeofError extends Error { - constructor() { - super("You can only deeply observe value with typeof 'object'"); - } -} +type Collection = Map | Set; export interface SignalOptions { /** @@ -36,8 +33,7 @@ export interface SignalOptions { * - When set to `false` it uses `Object.defineProperty` to watch properties * that existed at the time of creating signal. */ - watchObjectIndex?: T extends Map | Set ? never - : boolean; + watchObjectIndex?: boolean & (T extends Collection ? never : unknown); /** * @requires T to be `instanceof Map` * @@ -46,7 +42,7 @@ export interface SignalOptions { * - When set to `true` it checks whether value changed. * - When set to `false` it checks whether map size changed (default). */ - watchMapUpdates?: T extends Map ? boolean : never; + watchMapUpdates?: boolean & (T extends Map ? unknown : never); } /** diff --git a/src/signals/types.ts b/src/signals/types.ts index 504aa53..36e5d62 100644 --- a/src/signals/types.ts +++ b/src/signals/types.ts @@ -10,6 +10,7 @@ export interface Subscription { export interface Dependency { /** Attach dependant to this Dependency */ depend(dependant: Dependant): void; + /** All dependants that rely on this Dependency */ dependants?: Set; } @@ -31,3 +32,13 @@ export interface LazyDependant extends Dependant { flusher?: Flusher; lastFired: number; } + +export type Dependish = Dependency | (Dependency & Dependant); + +export interface Disposable { + [Symbol.dispose](): void; +} + +export interface AsyncDisposable { + [Symbol.asyncDispose](): void | Promise; +} From bb505fa41787d46f354617757c7a51ee421c94de Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Mon, 9 Mar 2026 18:25:47 -0700 Subject: [PATCH 12/17] feat(utils): add internal performance module for mocking --- src/utils/performance.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/utils/performance.ts diff --git a/src/utils/performance.ts b/src/utils/performance.ts new file mode 100644 index 0000000..ac29fa0 --- /dev/null +++ b/src/utils/performance.ts @@ -0,0 +1,11 @@ +import { $global } from "./primordials.ts"; + +export interface Performance { + now(): number; +} + +const globalTiming: Performance = $global.performance || $global.Date; + +export const performance: Performance = { + now: globalTiming.now.bind(globalTiming), +}; From f41fda111fa3750bbd10779bfa5eb15799cda383 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Mon, 9 Mar 2026 18:26:21 -0700 Subject: [PATCH 13/17] feat(internal): add internal types and primordials modules --- src/utils/primordials.ts | 27 +++++++++++++++++++++++++++ src/utils/types.ts | 1 + 2 files changed, 28 insertions(+) create mode 100644 src/utils/primordials.ts create mode 100644 src/utils/types.ts diff --git a/src/utils/primordials.ts b/src/utils/primordials.ts new file mode 100644 index 0000000..465c0f9 --- /dev/null +++ b/src/utils/primordials.ts @@ -0,0 +1,27 @@ +export const $global: typeof globalThis = (() => { + try { + return (0, eval)("this"); + } catch { /* nah */ } + if (typeof self !== "undefined") return self; + if (typeof window !== "undefined") return window; + if (typeof globalThis !== "undefined") return globalThis; + // deno-lint-ignore no-node-globals + if (typeof global !== "undefined") return global; + throw new Error("Unable to locate global object"); +})(); + +const $O = $global.Object, + $defs = $O.defineProperties, + $gpds = $O.getOwnPropertyDescriptors; + // $create = $O.create, + // $def = $O.defineProperty, + // $gpd = $O.getOwnPropertyDescriptor, + +export const SafeObject = $defs($O.bind($O), $gpds($O)); + +export const ObjectAssign = SafeObject.assign; +export const ObjectDefineProperty = SafeObject.defineProperty; +export const ObjectDefineProperties = SafeObject.defineProperties; +export const ObjectGetOwnPropertyDescriptor = SafeObject.getOwnPropertyDescriptor; +export const ObjectGetOwnPropertyDescriptors = SafeObject.getOwnPropertyDescriptors; +export const ObjectCreate = SafeObject.create; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..8b36935 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1 @@ +export type Reshape = Pick; From 57df2f437f13a970f2dbe951fa314ed26d36d9b6 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Mon, 9 Mar 2026 18:29:06 -0700 Subject: [PATCH 14/17] chore: fmt --- src/utils/primordials.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/primordials.ts b/src/utils/primordials.ts index 465c0f9..d14151b 100644 --- a/src/utils/primordials.ts +++ b/src/utils/primordials.ts @@ -13,15 +13,17 @@ export const $global: typeof globalThis = (() => { const $O = $global.Object, $defs = $O.defineProperties, $gpds = $O.getOwnPropertyDescriptors; - // $create = $O.create, - // $def = $O.defineProperty, - // $gpd = $O.getOwnPropertyDescriptor, +// $create = $O.create, +// $def = $O.defineProperty, +// $gpd = $O.getOwnPropertyDescriptor, export const SafeObject = $defs($O.bind($O), $gpds($O)); export const ObjectAssign = SafeObject.assign; export const ObjectDefineProperty = SafeObject.defineProperty; export const ObjectDefineProperties = SafeObject.defineProperties; -export const ObjectGetOwnPropertyDescriptor = SafeObject.getOwnPropertyDescriptor; -export const ObjectGetOwnPropertyDescriptors = SafeObject.getOwnPropertyDescriptors; +export const ObjectGetOwnPropertyDescriptor = + SafeObject.getOwnPropertyDescriptor; +export const ObjectGetOwnPropertyDescriptors = + SafeObject.getOwnPropertyDescriptors; export const ObjectCreate = SafeObject.create; From 501e914d9b22f5efd2f523a25742d14830c0d42e Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Mon, 9 Mar 2026 18:33:31 -0700 Subject: [PATCH 15/17] refactor(signals): clean various little things up --- src/signals/computed.ts | 11 ++++------- src/signals/dependency_tracking.ts | 2 +- src/signals/effect.ts | 4 +--- src/signals/errors.ts | 2 +- src/signals/flusher.ts | 4 +--- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/signals/computed.ts b/src/signals/computed.ts index 1becc3e..03a8369 100644 --- a/src/signals/computed.ts +++ b/src/signals/computed.ts @@ -67,18 +67,15 @@ export class Computed extends Signal implements Dependant, Dependency { if ( this.$value !== (this.$value = this.computable(cause)) || this.forceUpdateValue - ) { - this.propagate(cause); - } + ) this.propagate(cause); } override dispose(): void { super.dispose(); - const { dependencies } = this; - for (const dependency of dependencies) { - dependency.dependants!.delete(this); - dependencies.delete(dependency); + for (const dependency of this.dependencies) { + dependency.dependants?.delete(this); + this.dependencies.delete(dependency); } } } diff --git a/src/signals/dependency_tracking.ts b/src/signals/dependency_tracking.ts index eb7ead9..fcf4387 100644 --- a/src/signals/dependency_tracking.ts +++ b/src/signals/dependency_tracking.ts @@ -1,5 +1,5 @@ // Copyright 2023 Im-Beast. MIT license. -import type { Dependant, Dependency } from "./types.ts"; +import type { Dependency, Dependish } from "./types.ts"; export let activeSignals: Set | undefined; let incoming = 0; diff --git a/src/signals/effect.ts b/src/signals/effect.ts index b531335..a0806eb 100644 --- a/src/signals/effect.ts +++ b/src/signals/effect.ts @@ -75,9 +75,7 @@ export class Effect implements Dependant, Disposable { if (this.disposed) { throw new ReferenceError("Effect already disposed"); } else { - for (const { dependants } of this.dependencies) { - dependants?.delete(this); - } + for (const dep of this.dependencies) dep.dependants?.delete(this); this.dependencies.clear(); this.disposed = true; } diff --git a/src/signals/errors.ts b/src/signals/errors.ts index d815707..f013d54 100644 --- a/src/signals/errors.ts +++ b/src/signals/errors.ts @@ -2,7 +2,7 @@ export class ComputedReadOnlyError extends Error { constructor() { super( - "Computed values are read-only and cannot be directly modified. If you need to change the value, change the signals it depends on instead." + "Computed values are read-only and cannot be directly modified. If you need to change the value, change the signals it depends on instead.", ); this.name = "ComputedReadOnlyError"; } diff --git a/src/signals/flusher.ts b/src/signals/flusher.ts index 11105d5..ab0556c 100644 --- a/src/signals/flusher.ts +++ b/src/signals/flusher.ts @@ -36,9 +36,7 @@ export class Flusher implements Dependency { flush(): void { const { dependants } = this; - for (const dependant of dependants) { - dependant.update(this); - } + for (const dependant of dependants) dependant.update(this); dependants.clear(); } } From 2770675ec5903a7c55d6a7a1132f1dfc85d2d0a0 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Tue, 10 Mar 2026 20:22:16 -0700 Subject: [PATCH 16/17] Document flusher usage --- src/signals/flusher.ts | 6 ++++- src/signals/lazy_computed.ts | 4 +-- src/signals/lazy_effect.ts | 16 ++++++----- src/signals/reactivity.ts | 46 +++++++------------------------- src/signals/types.ts | 6 +++-- src/stdio/stderr.ts | 51 +++++++++++++++++++----------------- src/stdio/stdin.ts | 11 ++++---- src/stdio/stdout.ts | 50 +++++++++++++++++++---------------- 8 files changed, 87 insertions(+), 103 deletions(-) diff --git a/src/signals/flusher.ts b/src/signals/flusher.ts index ab0556c..1fa5583 100644 --- a/src/signals/flusher.ts +++ b/src/signals/flusher.ts @@ -2,7 +2,11 @@ import type { Dependant, Dependency, LazyDependant } from "./types.ts"; /** - * Flusher tracks + * Flusher tracks dependants and updates them when `flush()` gets called. + * + * It can be used to delay updates of `LazyComputed` dependants until some + * condition is met, for example until next animation frame or until some + * expensive calculations get done. * * @example * ```ts diff --git a/src/signals/lazy_computed.ts b/src/signals/lazy_computed.ts index 8b35bad..a8831be 100644 --- a/src/signals/lazy_computed.ts +++ b/src/signals/lazy_computed.ts @@ -79,9 +79,7 @@ export class LazyComputed extends Computed implements LazyDependant { return; } - if (flusher) { - flusher.depend(this); - } + flusher?.depend(this); if (interval) { const timeDifference = performance.now() - this.lastFired; diff --git a/src/signals/lazy_effect.ts b/src/signals/lazy_effect.ts index 6ce13ad..93bdd33 100644 --- a/src/signals/lazy_effect.ts +++ b/src/signals/lazy_effect.ts @@ -1,21 +1,23 @@ // Copyright 2023 Im-Beast. MIT license. +import { performance } from "../utils/performance.ts"; import { Effect, type Effectable } from "./effect.ts"; import { Flusher } from "./flusher.ts"; - import type { Dependant, Dependency, LazyDependant } from "./types.ts"; // TODO: Tests -interface LazyEffectOptions { +export interface LazyEffectOptions { interval: number; flusher: Flusher; } /** - * LazyEffect is an container for callback function, which runs every time any of its dependencies get updated. - * When initialized that functions gets ran and all dependencies for it are tracked. + * LazyEffect is an container for callback function, which runs every time any + * of its dependencies get updated. When initialized that functions gets ran + * and all dependencies for it are tracked. * - If time between updates is smaller than given interval it gets delayed - * - If given `Flusher` instead, it will update after `Flusher.flush()` gets called + * - If given `Flusher` instead, it will update after `Flusher.flush()` gets + * called * - Both interval and `Flusher` might be set at the same time. * * @example @@ -62,14 +64,14 @@ export class LazyEffect extends Effect implements LazyDependant { this.#updateCallback = () => this.update(this); } - [].slice(); this.lastFired = performance.now(); } override update(cause: Dependency | Dependant): void { const { flusher, interval } = this; - if (flusher) flusher.depend(this); + flusher?.depend(this); + if (interval) { const timeDifference = performance.now() - this.lastFired; if (timeDifference < interval) { diff --git a/src/signals/reactivity.ts b/src/signals/reactivity.ts index 6cfd555..9e19fc5 100644 --- a/src/signals/reactivity.ts +++ b/src/signals/reactivity.ts @@ -70,23 +70,15 @@ export function makeMapMethodsReactive, S>( if (watchMapUpdates) { _$set = function $set(key: MapKeyType, value: MapValueType) { const previousValue = map.get(key); - set(key, value); - - if (value !== previousValue) { - signal.propagate(); - } + if (value !== previousValue) signal.propagate(); return map; }; } else { _$set = function $step(key: MapKeyType, value: MapValueType) { const { size } = map; - set(key, value); - - if (map.size > size) { - signal.propagate(); - } + if (map.size > size) signal.propagate(); return map; }; } @@ -98,9 +90,7 @@ export function makeMapMethodsReactive, S>( const del = map.delete.bind(map); function $delete(key: MapKeyType) { const removed = del(key); - if (removed) { - signal.propagate(); - } + if (removed) signal.propagate(); return removed; } @@ -108,9 +98,7 @@ export function makeMapMethodsReactive, S>( function $clear() { const { size } = map; clear(); - if (size > 0) { - signal.propagate(); - } + if (size > 0) signal.propagate(); } return map; @@ -137,33 +125,23 @@ export function makeSetMethodsReactive, S>( const add = set.add.bind(set); function $add(value: Parameters[0]) { const { size } = set; - add(value); - - if (set.size > size) { - signal.propagate(); - } + if (set.size > size) signal.propagate(); return set; } const del = set.delete.bind(set); function $delete(value: Parameters[0]) { const removed = del(value); - if (removed) { - signal.propagate(); - } + if (removed) signal.propagate(); return removed; } const clear = set.clear.bind(set); function $clear() { const { size } = set; - clear(); - - if (size > 0) { - signal.propagate(); - } + if (size > 0) signal.propagate(); } return set; @@ -201,21 +179,15 @@ export function makeArrayMethodsReactive, S>( const pop = array.pop.bind(array); function $pop() { const { length } = array; - const output = pop(); - - if (length > 0) { - signal.propagate(); - } + if (length > 0) signal.propagate(); return output; } const splice = array.splice.bind(array); function $splice(start: number, deleteCount: number, ...items: unknown[]) { const output = splice(start, deleteCount, ...items); - if (output.length > 0) { - signal.propagate(); - } + if (output.length > 0) signal.propagate(); return output; } diff --git a/src/signals/types.ts b/src/signals/types.ts index 36e5d62..df537b7 100644 --- a/src/signals/types.ts +++ b/src/signals/types.ts @@ -19,10 +19,12 @@ export interface Dependency { export interface Dependant { /** Set of all dependencies Dependant relies on */ dependencies: Set; - /** Destroy dependant, clear its dependencies */ - dispose(): void; + /** Method which updates Dependants state/value */ update(cause: Dependency | Dependant): void; + + /** Destroy dependant, clear its dependencies */ + dispose(): void; } /** Element which relies on dependencies to function and updates either after specified interval or when flusher gets flushed */ diff --git a/src/stdio/stderr.ts b/src/stdio/stderr.ts index 0fcc838..56021c9 100644 --- a/src/stdio/stderr.ts +++ b/src/stdio/stderr.ts @@ -2,7 +2,7 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { isDeno, isNodeLike } from "../utils/runtime.ts"; - +import { ObjectAssign, ObjectDefineProperty } from "../utils/primordials.ts"; // @ts-types="npm:@types/node@22/process" import p from "node:process"; @@ -72,8 +72,6 @@ export interface Stderr { isTerminal(): boolean; } -const { assign, defineProperty } = Object; - export class Stderr { constructor(impl?: Stderr) { if (!impl) { @@ -93,7 +91,7 @@ export class Stderr { } } - assign(this, impl); + ObjectAssign(this, impl); this.writeSync = this.writeSync.bind(impl); this.write ??= (p) => Promise.resolve(this.writeSync(p)); this.write = this.write.bind(impl); @@ -102,25 +100,30 @@ export class Stderr { this.isTerminal ??= () => false; this.isTerminal = this.isTerminal.bind(impl); - defineProperty(this, "writable", { - get() { - if (impl && "writable" in impl) { - return impl.writable; - } else { - return new WritableStream({ - write: async (chunk) => { - for ( - let n = 0; - n < chunk.byteLength; - n += await this.write(chunk.subarray(n)) - ); - }, - }); - } - }, - set() {}, - configurable: true, - enumerable: true, - }); + ObjectDefineProperty( + this, + "writable", + { + __proto__: impl, + get() { + if (impl && "writable" in impl) { + return impl.writable; + } else { + return new WritableStream({ + write: async (chunk) => { + for ( + let n = 0; + n < chunk.byteLength; + n += await this.write(chunk.subarray(n)) + ); + }, + }); + } + }, + set() {}, + configurable: true, + enumerable: true, + } as PropertyDescriptor & ThisType, + ); } } diff --git a/src/stdio/stdin.ts b/src/stdio/stdin.ts index 6ad33f5..2f17579 100644 --- a/src/stdio/stdin.ts +++ b/src/stdio/stdin.ts @@ -2,6 +2,8 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { isDeno, isNodeLike } from "../utils/runtime.ts"; +import type { Reshape } from "../utils/types.ts"; +import { ObjectAssign, ObjectDefineProperty } from "../utils/primordials.ts"; import process from "node:process"; /** @category I/O */ @@ -10,6 +12,7 @@ export interface SetRawOptions { * The `cbreak` option can be used to indicate that characters that * correspond to a signal should still be generated. * + * @remarks * When disabling raw mode, this option is ignored. This functionality * currently only works on Linux and Mac OS. */ @@ -115,10 +118,6 @@ type StdinImpl = Reshape< Partial & Pick >; -type Reshape = Pick; - -const { assign, defineProperty } = Object; - export class Stdin { constructor(impl?: StdinImpl) { if (!impl) { @@ -137,14 +136,14 @@ export class Stdin { throw new Error("No Stdin implementation available"); } } - assign(this, impl); + ObjectAssign(this, impl); this.readSync = this.readSync.bind(impl); this.read = ( this.read ??= (p) => Promise.resolve(this.readSync(p)) ).bind(impl); this.close = (this.close ??= () => {}).bind(impl); this.isTerminal = (this.isTerminal ??= () => false).bind(impl); - defineProperty(this, "readable", { + ObjectDefineProperty(this, "readable", { get() { if (impl && "readable" in impl) { return impl.readable; diff --git a/src/stdio/stdout.ts b/src/stdio/stdout.ts index 29e1dff..6125dd8 100644 --- a/src/stdio/stdout.ts +++ b/src/stdio/stdout.ts @@ -2,6 +2,7 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { isDeno, isNodeLike } from "../utils/runtime.ts"; +import { ObjectAssign, ObjectDefineProperty } from "../utils/primordials.ts"; // @ts-types="npm:@types/node@22/process" import p from "node:process"; @@ -72,8 +73,6 @@ export interface Stdout { isTerminal(): boolean; } -const { assign, defineProperty } = Object; - export class Stdout { constructor(impl?: Stdout) { if (!impl) { @@ -93,7 +92,7 @@ export class Stdout { } } - assign(this, impl); + ObjectAssign(this, impl); this.writeSync = this.writeSync.bind(impl); this.write ??= (p) => Promise.resolve(this.writeSync(p)); this.write = this.write.bind(impl); @@ -102,25 +101,30 @@ export class Stdout { this.isTerminal ??= () => false; this.isTerminal = this.isTerminal.bind(impl); - defineProperty(this, "writable", { - get() { - if (impl && "writable" in impl) { - return impl.writable; - } else { - return new WritableStream({ - write: async (chunk) => { - for ( - let n = 0; - n < chunk.byteLength; - n += await this.write(chunk.subarray(n)) - ); - }, - }); - } - }, - set() {}, - configurable: true, - enumerable: true, - }); + ObjectDefineProperty( + this, + "writable", + { + __proto__: impl, + get() { + if (impl && "writable" in impl) { + return impl.writable; + } else { + return new WritableStream({ + write: async (chunk) => { + for ( + let n = 0; + n < chunk.byteLength; + n += await this.write(chunk.subarray(n)) + ); + }, + }); + } + }, + set() {}, + configurable: true, + enumerable: true, + } as PropertyDescriptor & ThisType, + ); } } From 176b64f573a76ca5cdaf3a597ae2c26e68180f24 Mon Sep 17 00:00:00 2001 From: Nicholas Berlette Date: Wed, 11 Mar 2026 11:31:08 -0700 Subject: [PATCH 17/17] refactor(signals): clean up formatting, use primordials more consistently --- src/signals/computed.ts | 4 +++ src/signals/dependency_tracking.ts | 24 +++++++------ src/signals/effect.ts | 4 +++ src/signals/lazy_computed.ts | 35 ++++++++++-------- src/signals/signal.ts | 58 +++++++++++++++++++----------- 5 files changed, 79 insertions(+), 46 deletions(-) diff --git a/src/signals/computed.ts b/src/signals/computed.ts index 03a8369..d0eb597 100644 --- a/src/signals/computed.ts +++ b/src/signals/computed.ts @@ -79,3 +79,7 @@ export class Computed extends Signal implements Dependant, Dependency { } } } + +export function computed(computable: Computable): Computed { + return new Computed(computable); +} diff --git a/src/signals/dependency_tracking.ts b/src/signals/dependency_tracking.ts index fcf4387..3cc08b8 100644 --- a/src/signals/dependency_tracking.ts +++ b/src/signals/dependency_tracking.ts @@ -7,42 +7,44 @@ let incoming = 0; /** * Asynchronously tracks used signals for provided function */ -export async function trackDependencies( - dependencies: Set, +export async function track( + dependencies: Set, thisArg: unknown, // this is supposed to mean every function // deno-lint-ignore ban-types func: Function, ): Promise { - while (incoming !== 0) { - await Promise.resolve(); - } + while (incoming) await Promise.resolve(); ++incoming; activeSignals = dependencies; + try { func.call(thisArg); } catch (error) { incoming = 0; throw error; + } finally { + activeSignals = undefined; + --incoming; } - activeSignals = undefined; - --incoming; } /** * Replaces all dependencies with root dependencies to prevent multiple updates caused by the same change. */ -export function optimizeDependencies( - into: Set, - from = into, +export function optimize( + into: Set, + from: Iterable = into, ): void { for (const dependency of from) { if ("dependencies" in dependency) { into.delete(dependency); - optimizeDependencies(into, dependency.dependencies); + optimize(into, dependency.dependencies); } else { into.add(dependency); } } } + +export { optimize as optimizeDependencies, track as trackDependencies }; diff --git a/src/signals/effect.ts b/src/signals/effect.ts index a0806eb..fdddebc 100644 --- a/src/signals/effect.ts +++ b/src/signals/effect.ts @@ -112,3 +112,7 @@ export class Effect implements Dependant, Disposable { this.dispose(); } } + +export function effect(effectable: Effectable): Effect { + return new Effect(effectable); +} diff --git a/src/signals/lazy_computed.ts b/src/signals/lazy_computed.ts index a8831be..2123e56 100644 --- a/src/signals/lazy_computed.ts +++ b/src/signals/lazy_computed.ts @@ -1,42 +1,47 @@ // Copyright 2023 Im-Beast. MIT license. +import { performance } from "../utils/performance.ts"; import { type Computable, Computed } from "./computed.ts"; -import type { Dependency } from "./types.ts"; import { Flusher } from "./flusher.ts"; - -import type { LazyDependant } from "./types.ts"; +import type { Dependency, LazyDependant } from "./types.ts"; // TODO: Tests -interface LazyComputedOptions { +export interface LazyComputedOptions { interval: number; flusher: Flusher; } /** * LazyComputed is a type of signal that depends on other signals and updates - * when any of them changes - * - If time between updates is smaller than given interval it gets delayed - * - If given `Flusher` instead, it will update after `Flusher.flush()` gets - * called + * when any of them changes. Be aware of its caveats, however, as it can delay + * updates to avoid too many of them in a short time. + * + * - If time between updates is smaller than given interval, it is delayed. + * - If given a `Flusher`, it will update after `Flusher.flush()` is called. * - Both interval and `Flusher` might be set at the same time. * * @example * ```ts * const multiplicand = new Signal(1); * const multiplier = new Signal(2); - * const product = new LazyComputed(() => multiplicand.value * multiplier.value, 16); + * const product = new LazyComputed( + * () => multiplicand.value * multiplier.value, + * 16, + * ); * * console.log(product.value); // 2 - * await Promise.resolve(); // Dependency tracking is asynchronous read more in `dependency_tracking.ts` + * + * // Dependency tracking is asynchronous (see `dependency_tracking.ts`) + * await Promise.resolve(); // wait for next tick * * multiplicand.value = 2; - * console.log(product.value); // 2 + * console.log(product.value); // => 2 + * * multiplier.value = 7; - * console.log(product.value); // 2 + * console.log(product.value); // => 2 * - * setTimeout(() => { - * console.log(product.value); // 14 - * }, 16) + * // wait until the timeout of LazyComputed gets executed + * setTimeout(() => console.log(product.value), 16); // => 14 * ``` */ export class LazyComputed extends Computed implements LazyDependant { diff --git a/src/signals/signal.ts b/src/signals/signal.ts index a77f62f..d9fd50d 100644 --- a/src/signals/signal.ts +++ b/src/signals/signal.ts @@ -45,6 +45,8 @@ export interface SignalOptions { watchMapUpdates?: boolean & (T extends Map ? unknown : never); } +let __is_signal = (it: unknown): boolean => (void it, false); + /** * Signal wraps value in a container. * @@ -59,7 +61,7 @@ export interface SignalOptions { * ``` */ export class Signal implements Dependency { - protected $value: T; + #value: T; // Dependant: something that depends on THIS dependants?: Set; @@ -84,7 +86,15 @@ export class Signal implements Dependency { ); } } - this.$value = value; + this.#value = value; + } + + protected get $value(): T { + return this.#value; + } + + protected set $value(value: T) { + this.#value = value; } /** Bind function to signal, it'll be called each time signal's value changes and is equal to {conditionValues} */ @@ -159,13 +169,13 @@ export class Signal implements Dependency { } } - if (!dependants?.size) return; - - for (const dependant of dependants) { - if ("forceUpdateValue" in dependant) { - dependant.forceUpdateValue = true; + if (dependants?.size) { + for (const dependant of dependants) { + if ("forceUpdateValue" in dependant) { + dependant.forceUpdateValue = true; + } + dependant.update(cause ?? this); } - dependant.update(cause ?? this); } } @@ -192,16 +202,15 @@ export class Signal implements Dependency { subscriptions?.clear(); - if (!dependants) return; - for (const dependant of dependants) { - dependants.delete(dependant); - dependant.dependencies.delete(this); + if (dependants?.size) { + for (const dependant of dependants) { + dependants.delete(dependant); + dependant.dependencies.delete(this); - // If dependant has no more dependencies then - // it means that it should be replaced with constant value, - // because nothing can update its value anymore - if (!dependant.dependencies) { - dependant.dispose(); + // If dependant has no more dependencies then + // it means that it should be replaced with constant value, + // because nothing can update its value anymore + if (!dependant.dependencies) dependant.dispose(); } } } @@ -244,9 +253,12 @@ export class Signal implements Dependency { [Symbol.toPrimitive](hint: "number"): number; [Symbol.toPrimitive](hint: "string" | "default"): string; [Symbol.toPrimitive](hint: "string" | "number" | "default"): string | number; - [Symbol.toPrimitive](hint: "string" | "number" | "default"): string | number { - if (hint === "number") return Number(this.$value); - return String(this.$value); + [Symbol.toPrimitive](hint: string): string | number { + return (hint === "number" ? Number : String)(this.$value); + } + + static { + __is_signal = (it) => #value in Object(it); } } @@ -274,3 +286,9 @@ export class Signal implements Dependency { * ``` */ export type SignalOfObject = Signal & { [key in keyof T]?: never }; + +export type SignalLike = T | Signal; + +export function isSignal(it: SignalLike): it is Signal { + return typeof it === "object" && it !== null && __is_signal(it); +}