From 56776160543b91117d93a02b60dadb6febd04c21 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sun, 4 Jan 2026 20:17:19 +0800 Subject: [PATCH 1/9] Localize and rewrite the package `@observablehq/runtime` --- lib/runtime/README.md | 3 + lib/runtime/errors.ts | 9 + lib/runtime/index.ts | 5 + lib/runtime/module.ts | 249 ++++++++++ lib/runtime/runtime.ts | 272 +++++++++++ lib/runtime/utils.ts | 60 +++ lib/runtime/variable.ts | 783 +++++++++++++++++++++++++++++++ package.json | 1 - pnpm-lock.yaml | 3 - runtime/index.js | 2 +- types/observablehq__runtime.d.ts | 115 ----- 11 files changed, 1382 insertions(+), 120 deletions(-) create mode 100644 lib/runtime/README.md create mode 100644 lib/runtime/errors.ts create mode 100644 lib/runtime/index.ts create mode 100644 lib/runtime/module.ts create mode 100644 lib/runtime/runtime.ts create mode 100644 lib/runtime/utils.ts create mode 100644 lib/runtime/variable.ts delete mode 100644 types/observablehq__runtime.d.ts diff --git a/lib/runtime/README.md b/lib/runtime/README.md new file mode 100644 index 0000000..0450e69 --- /dev/null +++ b/lib/runtime/README.md @@ -0,0 +1,3 @@ +# Runtime + +This directory contains a TypeScript rewrite of [`@observablehq/runtime`](https://github.com/observablehq/runtime), distributed under the same ISC license as the original work. diff --git a/lib/runtime/errors.ts b/lib/runtime/errors.ts new file mode 100644 index 0000000..5ba15bb --- /dev/null +++ b/lib/runtime/errors.ts @@ -0,0 +1,9 @@ +export class RuntimeError extends Error { + constructor( + message: string, + public readonly input?: string, + ) { + super(message); + this.name = "RuntimeError"; + } +} diff --git a/lib/runtime/index.ts b/lib/runtime/index.ts new file mode 100644 index 0000000..15486b2 --- /dev/null +++ b/lib/runtime/index.ts @@ -0,0 +1,5 @@ +export {RuntimeError} from "./errors.ts"; +export {Runtime} from "./runtime.ts"; +export type {Builtins, GlobalFunction, ModuleDefinition} from "./runtime.ts"; +export type {Module, InjectSpecifier, InjectInput} from "./module.ts"; +export type {Variable, Observer, VariableOptions, ObserverLike, VariableDefinition} from "./variable.ts"; diff --git a/lib/runtime/module.ts b/lib/runtime/module.ts new file mode 100644 index 0000000..e996702 --- /dev/null +++ b/lib/runtime/module.ts @@ -0,0 +1,249 @@ +import {constant, identity, rethrow} from "./utils.ts"; +import {RuntimeError} from "./errors.ts"; +import {Variable, no_observer, variable_stale, type ObserverLike, type VariableOptions} from "./variable.ts"; +import type {Runtime} from "./runtime.ts"; + +export interface InjectSpecifier { + name: string; + alias?: string; +} + +export type InjectInput = string | InjectSpecifier; + +/** + * A module represents a namespace for reactive variables. Variables within a + * module can reference each other and form a dependency graph. + */ +export class Module { + // Read-only cross-class access (getters only) + private _runtime: Runtime; + private _scope: Map; + private _builtins: Map; + private _source: Module | null; + + // Getters for read-only cross-class members + get runtime(): Runtime { + return this._runtime; + } + + get scope(): Map { + return this._scope; + } + + get builtins(): Map { + return this._builtins; + } + + get source(): Module | null { + return this._source; + } + + /** + * Creates a new module. + * @param runtime The runtime that manages this module + * @param builtins Optional builtin values to add to the module's scope + */ + constructor(runtime: Runtime, builtins: Iterable<[string, unknown]> = []) { + this._runtime = runtime; + this._scope = new Map(); + this._builtins = new Map([ + ["@variable", Variable.VARIABLE], + ["invalidation", Variable.INVALIDATION], + ["visibility", Variable.VISIBILITY], + ...builtins, + ]); + this._source = null; + } + + /** + * Resolves a variable by name, creating an implicit variable if needed. + * This method looks up variables in the module's scope, builtins, runtime builtins, or global scope. + * @param name The variable name to resolve + * @returns The resolved variable + * @internal + */ + _resolve(name: string): Variable { + let variable = this._scope.get(name); + let value: unknown; + + if (!variable) { + variable = new Variable(Variable.Type.IMPLICIT, this); + if (this._builtins.has(name)) { + variable.define(name, constant(this._builtins.get(name))); + } else if (this._runtime.builtin.scope.has(name)) { + variable.import(name, this._runtime.builtin); + } else { + try { + value = this._runtime.global(name); + } catch (error) { + return variable.define(name, rethrow(error)); + } + if (value === undefined) { + this._scope.set(name, variable); + variable.name = name; + } else { + variable.define(name, constant(value)); + } + } + } + return variable; + } + + /** + * Redefines an existing variable in the module. + * @param name The name of the variable to redefine + * @param args The definition arguments (same as Variable.define) + * @returns The redefined variable + * @throws {RuntimeError} If the variable is not defined or is defined multiple times + */ + redefine(name: string | null, definition: unknown): Variable; + redefine(name: string | null, inputs: ArrayLike, definition: unknown): Variable; + redefine(name: string, ...args: [unknown] | [ArrayLike, unknown]): Variable { + const v = this._scope.get(name); + if (!v) throw new RuntimeError(`${name} is not defined`); + if (v.type === Variable.Type.DUPLICATE) throw new RuntimeError(`${name} is defined more than once`); + const targs = [name, ...args] as Parameters; + return v.define(...targs); + } + + /** + * Defines a new variable in the module. + * @param args The definition arguments (name, inputs, definition) - see Variable.define for details + * @returns The newly created variable + */ + define(...args: Parameters): Variable { + const v = new Variable(Variable.Type.NORMAL, this); + return v.define(...args); + } + + /** + * Imports a variable from another module. + * @param args The import arguments (remote name, local name, module) - see Variable.import for details + * @returns The newly created import variable + */ + import(...args: Parameters): Variable { + const v = new Variable(Variable.Type.NORMAL, this); + return v.import(...args); + } + + /** + * Creates a new variable in the module with an optional observer. + * @param observer Optional observer to monitor the variable's state changes + * @param options Optional variable options (e.g., shadow variables) + * @returns The newly created variable + */ + variable(observer?: ObserverLike, options?: VariableOptions): Variable { + return new Variable(Variable.Type.NORMAL, this, observer, options); + } + + /** + * Gets the current value of a variable by name. + * This method waits for the runtime to compute all pending updates before returning the value. + * If the variable becomes stale during computation, it retries until a stable value is obtained. + * @param name The name of the variable + * @returns A promise that resolves to the variable's current value + * @throws {RuntimeError} If the variable is not defined + */ + async value(name: string): Promise { + let v = this._scope.get(name); + if (!v) throw new RuntimeError(`${name} is not defined`); + if (v.observer === no_observer) { + v = this.variable(true).define([name], identity); + try { + return await this._revalue(v); + } finally { + v.delete(); + } + } else { + return this._revalue(v); + } + } + + /** + * Creates a derived module that imports specified variables from another module. + * The derived module is a copy of this module with additional injected variables. + * All transitive dependencies are also copied to maintain the dependency graph. + * @param injects The variables to inject (can be strings or {name, alias} objects) + * @param injectModule The module to import the variables from + * @returns A new derived module with the injected variables + */ + derive(injects: Iterable, injectModule: Module): Module { + const map = new Map(); + const modules = new Set(); + const copies: Array<[Module, Module]> = []; + + const alias = (source: Module): Module => { + let target = map.get(source); + if (target) return target; + target = new Module(source._runtime, source._builtins); + target._source = source; + map.set(source, target); + copies.push([target, source]); + modules.add(source); + return target; + }; + + const derive = alias(this); + for (const inject of injects) { + const {alias: injectAlias, name} = typeof inject === "object" ? inject : {name: inject, alias: undefined}; + derive.import(name, injectAlias == null ? name : injectAlias, injectModule); + } + + for (const module of modules) { + for (const [name, variable] of module._scope) { + if (variable.definition === identity) { + if (module === this && derive._scope.has(name)) continue; + const importedModule = variable.inputs[0].module; + if (importedModule._source) alias(importedModule); + } + } + } + + for (const [target, source] of copies) { + for (const [name, sourceVariable] of source._scope) { + const targetVariable = target._scope.get(name); + if (targetVariable && targetVariable.type !== Variable.Type.IMPLICIT) continue; + if (sourceVariable.definition === identity) { + const sourceInput = sourceVariable.inputs[0]; + const sourceModule = sourceInput.module; + target.import(sourceInput.name!, name, map.get(sourceModule) || sourceModule); + } else { + target.define( + name, + sourceVariable.inputs.map((v) => v.name!), + sourceVariable.definition, + ); + } + } + } + + return derive; + } + + /** + * Adds or updates a builtin value in the module. + * Builtins are predefined values that can be referenced by variables in the module. + * @param name The name of the builtin + * @param value The value of the builtin + */ + builtin(name: string, value: unknown): void { + this._builtins.set(name, value); + } + + /** + * Retrieves the current value of a variable, retrying if it becomes stale during computation. + * This is an internal helper method used by the value() method. + * @param variable The variable to get the value from + * @returns A promise that resolves to the variable's current value + * @internal + */ + private async _revalue(variable: Variable): Promise { + await this._runtime.compute(); + try { + return await variable.promise; + } catch (error) { + if (error === variable_stale) return this._revalue(variable); + throw error; + } + } +} diff --git a/lib/runtime/runtime.ts b/lib/runtime/runtime.ts new file mode 100644 index 0000000..89af289 --- /dev/null +++ b/lib/runtime/runtime.ts @@ -0,0 +1,272 @@ +import {noop, defer, defaultGlobal} from "./utils.ts"; +import {RuntimeError} from "./errors.ts"; +import {Module} from "./module.ts"; +import {Variable, type Observer} from "./variable.ts"; + +const frame = + typeof requestAnimationFrame === "function" + ? requestAnimationFrame + : typeof setImmediate === "function" + ? setImmediate + : (f: FrameRequestCallback) => setTimeout(f, 0); + +export type Builtins = Record; +export type GlobalFunction = (name: string) => unknown; +export type ModuleDefinition = (runtime: Runtime, observer: (name?: string) => Observer) => Module; + +/** + * Runtime manages the reactive computation graph. + * + * The Runtime is responsible for: + * - Scheduling and executing variable computations in topological order + * - Tracking variable dependencies and reachability + * - Managing modules and their variables + * - Handling invalidation and recomputation + */ +export class Runtime { + // Read-only cross-class access (getters only) + private _builtin: Module; + private _global: GlobalFunction; + + // Modifiable cross-class access (getters and setters) + private _dirty: Set; + private _updates: Set; + private _precomputes: Array<() => void>; + private _computing: Promise | null; + private _init: Module | null; + private _modules: Map; + private _variables: Set; + private _disposed: boolean; + + // Getters for read-only cross-class members + get builtin(): Module { + return this._builtin; + } + + get global(): GlobalFunction { + return this._global; + } + + // Getters for modifiable cross-class members (collections accessed via .add(), .delete(), etc.) + get dirty(): Set { + return this._dirty; + } + + get updates(): Set { + return this._updates; + } + + get variables(): Set { + return this._variables; + } + + /** + * Creates a new Runtime instance. + * + * @param builtins - Optional object containing builtin variables to make available to all modules + * @param global - Function to resolve global variable names (defaults to window/globalThis lookup) + */ + constructor(builtins?: Builtins | null, global: GlobalFunction = defaultGlobal) { + const builtin = new Module(this); + + this._dirty = new Set(); + this._updates = new Set(); + this._precomputes = []; + this._computing = null; + this._init = null; + this._modules = new Map(); + this._variables = new Set(); + this._disposed = false; + this._builtin = builtin; + this._global = global; + + if (builtins) { + for (const name in builtins) { + new Variable(Variable.Type.IMPLICIT, builtin).define(name, [], builtins[name]); + } + } + } + + /** + * Schedules a callback to run before the next computation cycle. + * + * @param callback - Function to execute before computing variables + * @internal + */ + _precompute(callback: () => void): void { + this._precomputes.push(callback); + this.compute(); + } + + /** + * Triggers a computation cycle to update all dirty variables. + * + * The computation is scheduled asynchronously and returns a promise that resolves + * when the computation is complete. Multiple calls to compute() during the same + * frame will return the same promise. + * + * @returns Promise that resolves when computation is complete + */ + compute(): Promise { + return this._computing || (this._computing = this._computeSoon()); + } + + /** For compatibility of tests only. */ + private _compute(): Promise { + return this.compute(); + } + + /** + * Schedules computation to occur in the next animation frame. + * + * @returns Promise that resolves when computation is complete + */ + private async _computeSoon(): Promise { + await new Promise(frame); + if (!this._disposed) { + await this._computeNow(); + } + } + + /** + * Executes a complete computation cycle. + * + * This method: + * 1. Runs any pending precompute callbacks + * 2. Updates reachability status for all dirty variables + * 3. Computes variables in topological order based on dependencies + * 4. Detects and reports circular dependencies + * + * Variables are computed in dependency order using a queue with indegree tracking. + */ + private async _computeNow(): Promise { + const queue: Variable[] = []; + let variable: Variable | undefined; + const precomputes = this._precomputes; + + if (precomputes.length) { + this._precomputes = []; + for (const callback of precomputes) callback(); + await defer(3); + } + + const variables = new Set(this._dirty); + variables.forEach((variable) => { + variable.inputs.forEach(variables.add, variables); + const reachable = variable.isReachable(); + // Boolean comparison: true > false detects became reachable, false < true detects became unreachable + if (reachable > variable.reachable) { + // Variable became reachable: schedule for computation + this._updates.add(variable); + } else if (reachable < variable.reachable) { + // Variable became unreachable: invalidate to clean up pending computations + variable.invalidate(); + } + variable.reachable = reachable; + }); + + const updates = new Set(this._updates); + updates.forEach((variable) => { + if (variable.reachable) { + variable.indegree = 0; + variable.outputs.forEach(updates.add, updates); + } else { + variable.indegree = NaN; + updates.delete(variable); + } + }); + + this._computing = null; + this._updates.clear(); + this._dirty.clear(); + + updates.forEach((variable) => { + variable.outputs.forEach((v) => v.incrementIndegree()); + }); + + do { + updates.forEach((variable) => { + if (variable.indegree === 0) { + queue.push(variable); + } + }); + + while ((variable = queue.pop())) { + variable.compute(); + variable.outputs.forEach(postqueue); + updates.delete(variable); + } + + updates.forEach((variable) => { + if (variable.isCircular) { + variable.setError(new RuntimeError("circular definition")); + variable.outputs.forEach((v) => v.decrementIndegree()); + updates.delete(variable); + } + }); + } while (updates.size); + + function postqueue(variable: Variable): void { + variable.indegree = --variable.indegree; + if (variable.indegree === 0) { + queue.push(variable); + } + } + } + + /** + * Disposes the runtime and invalidates all variables. + * + * After disposal, no further computations will occur and all variables + * will be invalidated. Generators will be terminated. + */ + dispose(): void { + this._computing = Promise.resolve(); + this._disposed = true; + this._variables.forEach((v) => { + v.invalidate(); + v.version = NaN; + }); + } + + /** + * Creates or retrieves a module. + * + * When called with no arguments, creates a new empty module. + * + * When called with a module definition function, creates a new module and executes + * the definition. If the same definition has been used before, returns the existing module. + * + * @param define - Optional module definition function + * @param observer - Optional observer factory for variable notifications + * @returns The created or existing module + */ + module(): Module; + module(define: ModuleDefinition, observer?: (name?: string) => Observer): Module; + module( + define?: ModuleDefinition, + observer: (name?: string) => Observer = noop as unknown as (name?: string) => Observer, + ): Module { + let module: Module; + + if (define === undefined) { + if ((module = this._init!)) { + this._init = null; + return module; + } + return new Module(this); + } + + module = this._modules.get(define)!; + if (module) return module; + + this._init = module = new Module(this); + this._modules.set(define, module); + try { + define(this, observer); + } finally { + this._init = null; + } + return module; + } +} diff --git a/lib/runtime/utils.ts b/lib/runtime/utils.ts new file mode 100644 index 0000000..1fd7a0f --- /dev/null +++ b/lib/runtime/utils.ts @@ -0,0 +1,60 @@ +// Array utilities +export function map( + array: ArrayLike, + fn: (value: T, index: number, array: ArrayLike) => U, + thisArg?: unknown, +): Array { + return Array.prototype.map.call< + ArrayLike, + [(value: T, index: number, array: ArrayLike) => U, unknown], + Array + >(array, fn, thisArg); +} + +// Function utilities +export function noop(): void {} + +export function constant(x: T): () => T { + return () => x; +} + +export function identity(x: T): T { + return x; +} + +export function rethrow(error: unknown): () => never { + return () => { + throw error; + }; +} + +// Type checking utilities +export function generatorish(value: unknown): value is Generator | AsyncGenerator { + return ( + typeof value === "object" && + value !== null && + "next" in value && + typeof value.next === "function" && + "return" in value && + typeof value.return === "function" + ); +} + +// Async utilities +export function defer(depth = 0): Promise { + let p: Promise = Promise.resolve(); + for (let i = 0; i < depth; ++i) p = p.then(() => {}); + return p; +} + +// Generator utilities +export function generatorReturn(generator: Generator | AsyncGenerator): () => void { + return function () { + generator.return(undefined); + }; +} + +// Global resolution utilities +export function defaultGlobal(name: string): unknown { + return Reflect.get(globalThis, name); +} diff --git a/lib/runtime/variable.ts b/lib/runtime/variable.ts new file mode 100644 index 0000000..2c9aaee --- /dev/null +++ b/lib/runtime/variable.ts @@ -0,0 +1,783 @@ +import {map, constant, identity, noop, generatorish, generatorReturn} from "./utils.ts"; +import {RuntimeError} from "./errors.ts"; +import type {Module} from "./module.ts"; + +export enum VariableType { + NORMAL = 1, + IMPLICIT = 2, + DUPLICATE = 3, +} + +export const no_observer = Symbol("no-observer"); +export const no_value = Promise.resolve(); + +export interface Observer { + pending?(): void; + fulfilled?(value: unknown, name?: string | null): void; + rejected?(error: unknown, name?: string | null): void; + _node?: Element; +} + +export interface VariableOptions { + shadow?: Record; +} + +export type ObserverLike = boolean | Observer | symbol; + +export type VariableDefinition = (...args: unknown[]) => unknown; + +export type VariableDefineParameters = + | [definition: unknown] + | [name: string | null, definition: unknown] + | [inputs: ArrayLike, definition: unknown] + | [name: string | null, inputs: ArrayLike, definition: unknown]; + +function variable_undefined(): never { + throw variable_undefined; +} + +export function variable_stale(): never { + throw variable_stale; +} + +/** + * Variable represents a reactive cell in the computation graph. + * + * Variables track their dependencies (inputs), dependents (outputs), and manage + * their computation lifecycle. They can be normal variables, implicit variables + * (for imports), or duplicate markers (for name conflicts). + * + * Key features: + * - Lazy evaluation: only computes when reachable (has observers) + * - Automatic invalidation and recomputation when inputs change + * - Support for synchronous values, promises, generators, and async generators + * - Dependency tracking for topological ordering + */ +export class Variable { + /** Variable type enumeration (NORMAL, IMPLICIT, DUPLICATE) */ + static readonly Type: typeof VariableType = VariableType; + /** Symbol used to access the variable instance itself */ + static readonly VARIABLE = Symbol("variable"); + /** Symbol used to access the invalidation promise */ + static readonly INVALIDATION = Symbol("invalidation"); + /** Symbol used to access visibility tracking */ + static readonly VISIBILITY = Symbol("visibility"); + + // Read-only cross-class access (getters only) + private _observer: Observer | symbol; + private _definition: VariableDefinition; + private _duplicate?: VariableDefinition; + private _duplicates?: Set; + private _inputs: Variable[]; + private _module: Module; + private _outputs: Set; + private _type: number; + private _rejector: (error: unknown) => never; + private _shadow: Map | null; + + // Modifiable cross-class access (getters and setters) + private _indegree: number; + private _invalidate: () => void; + private _name: string | null; + private _promise: Promise; + private _reachable: boolean; + private _value: unknown; + private _version: number; + + // Getters for read-only cross-class members + get observer(): Observer | symbol { + return this._observer; + } + + get definition(): VariableDefinition { + return this._definition; + } + + get duplicate(): VariableDefinition | undefined { + return this._duplicate; + } + + get duplicates(): Set | undefined { + return this._duplicates; + } + + get inputs(): Variable[] { + return this._inputs; + } + + get module(): Module { + return this._module; + } + + get outputs(): Set { + return this._outputs; + } + + get type(): number { + return this._type; + } + + get rejector(): (error: unknown) => never { + return this._rejector; + } + + // Getters and setters for modifiable cross-class members + get indegree(): number { + return this._indegree; + } + + set indegree(value: number) { + this._indegree = value; + } + + get invalidate(): () => void { + return this._invalidate; + } + + get name(): string | null { + return this._name; + } + + set name(value: string | null) { + this._name = value; + } + + get promise(): Promise { + return this._promise; + } + + get reachable(): boolean { + return this._reachable; + } + + set reachable(value: boolean) { + this._reachable = value; + } + + get value(): unknown { + return this._value; + } + + get version(): number { + return this._version; + } + + set version(value: number) { + this._version = value; + } + + /** + * Creates a new Variable instance. + * + * @param type - The type of variable (NORMAL, IMPLICIT, or DUPLICATE) + * @param module - The module this variable belongs to + * @param observer - Optional observer for notifications (true creates default observer, false/undefined means no observer) + * @param options - Optional configuration including shadow variables + */ + constructor(type: VariableType, module: Module, observer?: ObserverLike, options?: VariableOptions) { + if (!observer) observer = no_observer; + + this._observer = observer as Observer | symbol; + this._definition = variable_undefined; + this._duplicate = undefined; + this._duplicates = undefined; + this._indegree = NaN; + this._inputs = []; + this._invalidate = noop; + this._module = module; + this._name = null; + this._outputs = new Set(); + this._promise = no_value; + this._reachable = observer !== no_observer; + this._rejector = this._createRejector(); + this._shadow = this._initShadow(options); + this._type = type; + this._value = undefined; + this._version = 0; + } + + /** + * Notifies the observer that computation is pending. + * + * @internal + */ + _pending(): void { + if (this._observer !== no_observer && typeof (this._observer as Observer).pending === "function") { + (this._observer as Observer).pending!(); + } + } + + /** + * Notifies the observer that a value was successfully computed. + * + * @param value - The computed value + * @internal + */ + _fulfilled(value: unknown): void { + if (this._observer !== no_observer && typeof (this._observer as Observer).fulfilled === "function") { + (this._observer as Observer).fulfilled!(value, this._name); + } + } + + /** + * Notifies the observer that an error occurred during computation. + * + * @param error - The error that occurred + * @internal + */ + _rejected(error: unknown): void { + if (this._observer !== no_observer && typeof (this._observer as Observer).rejected === "function") { + (this._observer as Observer).rejected!(error, this._name); + } + } + + /** + * Resolves a variable name to a Variable instance. + * + * Resolution order: shadow variables, module scope, module resolver. + * + * @param name - The variable name to resolve + * @returns The resolved Variable instance + * @internal + */ + _resolve(name: string): Variable { + return this._shadow?.get(name) ?? this._module.scope.get(name) ?? this._module._resolve(name); + } + + /** + * Initializes shadow variables from options. + * + * Shadow variables override normal variable resolution for this variable's scope. + * + * @param options - Variable options containing shadow definitions + * @returns Map of shadow variables or null + */ + private _initShadow(options?: VariableOptions): Map | null { + if (!options?.shadow) return null; + return new Map( + Object.entries(options.shadow).map(([name, definition]) => [ + name, + new Variable(Variable.Type.IMPLICIT, this._module).define([], definition), + ]), + ); + } + + /** + * Attaches this variable as a dependent (output) of another variable. + * + * Marks the input variable as dirty and adds this variable to its outputs. + * + * @param variable - The input variable to attach to + */ + private _attach(variable: Variable): void { + variable.module.runtime.dirty.add(variable); + variable._outputs.add(this); + } + + /** + * Detaches this variable from being a dependent (output) of another variable. + * + * Marks the input variable as dirty and removes this variable from its outputs. + * + * @param variable - The input variable to detach from + */ + private _detach(variable: Variable): void { + variable.module.runtime.dirty.add(variable); + variable._outputs.delete(this); + } + + /** + * Creates a function that wraps errors into RuntimeError instances. + * + * @returns Function that converts errors to RuntimeError + */ + private _createRejector(): (error: unknown) => never { + return (error: unknown): never => { + if (error === variable_stale) throw error; + if (error === variable_undefined) throw new RuntimeError(`${this._name} is not defined`, this._name ?? undefined); + if (error instanceof Error && error.message) throw new RuntimeError(error.message, this._name ?? undefined); + throw new RuntimeError(`${this._name} could not be resolved`, this._name ?? undefined); + }; + } + + /** + * Creates a function that throws a duplicate definition error. + * + * @param name - The name that is duplicated + * @returns Function that throws the duplicate error + */ + private _createDuplicate(name: string): () => never { + return (): never => { + throw new RuntimeError(`${name} is defined more than once`); + }; + } + + /** + * Internal implementation of variable definition. + * + * This method handles: + * - Updating input dependencies + * - Managing variable naming and scope + * - Detecting and handling duplicate definitions + * - Creating implicit variables for missing references + * - Scheduling recomputation + * + * @param name - Variable name (null for anonymous) + * @param inputs - Array of input dependencies + * @param definition - The definition function + * @returns This variable instance for chaining + */ + private _defineImpl(name: string | null, inputs: Variable[], definition: VariableDefinition): this { + const scope = this._module.scope; + const runtime = this._module.runtime; + + this._inputs.forEach(this._detach, this); + inputs.forEach(this._attach, this); + this._inputs = inputs; + this._definition = definition; + this._value = undefined; + + if (definition === noop) runtime.variables.delete(this); + else runtime.variables.add(this); + + if (name !== this._name || scope.get(name!) !== this) { + let error: Variable | undefined; + let found: Variable | undefined; + + if (this._name) { + if (this._outputs.size) { + scope.delete(this._name); + found = this._module._resolve(this._name); + found._outputs = this._outputs; + this._outputs = new Set(); + found._outputs.forEach(function (this: Variable, output: Variable) { + output._inputs[output._inputs.indexOf(this)] = found!; + }, this); + found._outputs.forEach(runtime.updates.add, runtime.updates); + runtime.dirty.add(found).add(this); + scope.set(this._name, found); + } else if ((found = scope.get(this._name)) === this) { + scope.delete(this._name); + } else if (found && found._type === Variable.Type.DUPLICATE) { + found._duplicates!.delete(this); + this._duplicate = undefined; + if (found._duplicates!.size === 1) { + const newFound = found._duplicates!.keys().next().value as Variable; + error = scope.get(this._name); + newFound._outputs = error!._outputs; + error!._outputs = new Set(); + newFound._outputs.forEach(function (output: Variable) { + output._inputs[output._inputs.indexOf(error!)] = newFound; + }); + newFound._definition = newFound._duplicate!; + newFound._duplicate = undefined; + runtime.dirty.add(error!).add(newFound); + runtime.updates.add(newFound); + scope.set(this._name, newFound); + } + } else { + throw new Error("Unexpected state"); + } + } + + if (this._outputs.size) throw new Error("Cannot rename variable with outputs"); + + if (name) { + found = scope.get(name); + if (found) { + if (found._type === Variable.Type.DUPLICATE) { + this._definition = this._createDuplicate(name); + this._duplicate = definition; + found._duplicates!.add(this); + } else if (found._type === Variable.Type.IMPLICIT) { + this._outputs = found._outputs; + found._outputs = new Set(); + this._outputs.forEach(function (this: Variable, output: Variable) { + output._inputs[output._inputs.indexOf(found!)] = this; + }, this); + runtime.dirty.add(found).add(this); + scope.set(name, this); + } else { + found._duplicate = found._definition; + this._duplicate = definition; + error = new Variable(Variable.Type.DUPLICATE, this._module); + error._name = name; + error._definition = this._definition = found._definition = this._createDuplicate(name); + error._outputs = found._outputs; + found._outputs = new Set(); + error._outputs.forEach(function (output: Variable) { + output._inputs[output._inputs.indexOf(found!)] = error!; + }); + error._duplicates = new Set([this, found]); + runtime.dirty.add(found).add(error); + runtime.updates.add(found).add(error); + scope.set(name, error); + } + } else { + scope.set(name, this); + } + } + + this._name = name; + } + + if (this._version > 0) ++this._version; + + runtime.updates.add(this); + runtime.compute(); + return this; + } + + /** + * Defines or redefines this variable with a new value or computation. + * + * This method supports multiple overloads: + * - `define(value)` - Anonymous constant or function + * - `define(name, value)` - Named constant or function + * - `define(inputs, definition)` - Anonymous variable with dependencies + * - `define(name, inputs, definition)` - Named variable with dependencies + * + * When a function is provided, it will be called with the resolved values of the inputs. + * When a non-function value is provided, it's treated as a constant. + * + * @param args - Variable arguments matching one of the overload signatures + * @returns This variable instance for chaining + */ + define(definition: unknown): this; + define(name: string | null, definition: unknown): this; + define(inputs: ArrayLike, definition: unknown): this; + define(name: string | null, inputs: ArrayLike, definition: unknown): this; + define(...args: VariableDefineParameters): this { + let name: string | null = null; + let inputs: Variable[] | null = null; + let def: unknown; + + if (args.length === 1) { + // define(definition) + def = args[0]; + } else if (args.length === 2) { + // define(name, definition) or define(inputs, definition) + def = args[1]; + if (typeof args[0] === "string" || args[0] === null) { + name = args[0]; + } else { + inputs = map(args[0], this._resolve, this) as Variable[]; + } + } else if (args.length === 3) { + // define(name, inputs, definition) + name = args[0] == null ? null : String(args[0]); + inputs = map(args[1], this._resolve, this) as Variable[]; + def = args[2]; + } else { + throw new Error("Invalid arguments"); + } + + if (name != null) name = String(name); + if (inputs == null) inputs = []; + + return this._defineImpl(name, inputs, typeof def === "function" ? (def as VariableDefinition) : constant(def)); + } + + /** + * Imports a variable from another module. + * + * Supports two forms: + * - `import(name, module)` - Import with same name + * - `import(remoteName, localName, module)` - Import with alias + * + * @param remote - The name of the variable in the remote module + * @param nameOrModule - Either the local alias name or the module to import from + * @param module - The module to import from (when using alias form) + * @returns This variable instance for chaining + */ + import(remote: string, module: Module): this; + import(remote: string, name: string, module: Module): this; + import(remote: string, nameOrModule: string | Module, module?: Module): this { + if (arguments.length < 3) { + module = nameOrModule as Module; + return this._defineImpl(String(remote), [module._resolve(String(remote))], identity); + } + const name = nameOrModule as string; + return this._defineImpl(String(name), [module!._resolve(String(remote))], identity); + } + + /** + * Deletes this variable, removing it from the computation graph. + * + * @returns This variable instance for chaining + */ + delete(): this { + return this._defineImpl(null, [], noop); + } + + /** + * Checks if this variable has a circular dependency. + * + * @returns True if this variable depends on itself (directly or indirectly) + */ + get isCircular(): boolean { + const inputs = new Set(this._inputs); + for (const i of inputs) { + if (i === this) return true; + i._inputs.forEach(inputs.add, inputs); + } + return false; + } + + /** + * Increments the indegree counter. + * + * Used during topological sorting to track how many dependencies need to resolve. + */ + incrementIndegree(): void { + this._indegree = ++this._indegree; + } + + /** + * Decrements the indegree counter. + * + * Used during topological sorting to track when all dependencies have resolved. + */ + decrementIndegree(): void { + this._indegree = --this._indegree; + } + + /** + * Gets the variable's value as a promise. + * + * If the variable has an error, the promise will reject with a RuntimeError. + * + * @returns Promise that resolves to the variable's value + */ + getValue(): Promise { + return this._promise.catch(this._rejector); + } + + /** + * Gets a promise that resolves when this variable is invalidated. + * + * Used by variables that depend on invalidation events (e.g., generators). + * + * @returns Promise that resolves when invalidated + */ + getInvalidator(): Promise { + return new Promise((resolve) => { + this._invalidate = resolve; + }); + } + + /** + * Sets this variable to an error state. + * + * Invalidates the variable, increments version, and notifies observers of the error. + * + * @param error - The error to set + */ + setError(error: Error): void { + this._invalidate(); + this._invalidate = noop; + this._pending(); + this._version = this._version + 1; + this._indegree = NaN; + this._promise = Promise.reject(error); + this._promise.catch(noop); + this._value = undefined; + this._rejected(error); + } + + /** + * Checks if this variable is reachable (has observers directly or transitively). + * + * A variable is reachable if it has an observer, or if any of its outputs + * (direct or transitive) have observers. Only reachable variables are computed. + * + * @returns True if the variable is reachable + */ + isReachable(): boolean { + if (this._observer !== no_observer) return true; + const outputs = new Set(this._outputs); + for (const output of outputs) { + if (output.observer !== no_observer) return true; + output.outputs.forEach(outputs.add, outputs); + } + return false; + } + + /** + * Creates an intersector function for visibility-based computation. + * + * The intersector delays value propagation until the associated DOM element + * becomes visible in the viewport, using IntersectionObserver. + * + * @param invalidation - Promise that resolves when the variable is invalidated + * @returns Function that takes a value and returns a promise that resolves when visible + */ + createIntersector(invalidation: Promise): (value: unknown) => Promise { + const node = + typeof IntersectionObserver === "function" && this._observer && (this._observer as unknown as Observer)._node; + let visible = !node; + let resolve: () => void = noop; + let reject: () => void = noop; + let promise: Promise | null = null; + let observer: IntersectionObserver | null = null; + + if (node) { + observer = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + if (visible) { + promise = null; + resolve(); + } + }); + observer.observe(node); + invalidation.then(() => { + if (observer) { + observer.disconnect(); + observer = null; + } + reject(); + }); + } + + return function (value: unknown): Promise { + if (visible) return Promise.resolve(value); + if (!observer) return Promise.reject(); + if (!promise) + promise = new Promise((y, n) => { + resolve = y; + reject = n; + }); + return promise.then(() => value); + }; + } + + /** + * Computes this variable's new value. + * + * This method: + * 1. Invalidates the current value + * 2. Waits for the previous computation to settle + * 3. Resolves all input dependencies + * 4. Calls the definition function with resolved inputs + * 5. Handles generators and promises appropriately + * 6. Notifies observers of the new value or error + */ + compute(): void { + this._invalidate(); + this._invalidate = noop; + this._pending(); + + const value0 = this._value; + const version = this._version + 1; + this._version = version; + const inputs = this._inputs; + const definition = this._definition; + + let invalidation: Promise | null = null; + + const init = (): Promise => { + return Promise.all(inputs.map((v) => v.getValue())); + }; + + const define = (inputs: unknown[]): unknown => { + if (this._version !== version) throw variable_stale; + + for (let i = 0, n = inputs.length; i < n; ++i) { + switch (inputs[i]) { + case Variable.INVALIDATION: { + inputs[i] = invalidation = this.getInvalidator(); + break; + } + case Variable.VISIBILITY: { + if (!invalidation) invalidation = this.getInvalidator(); + inputs[i] = this.createIntersector(invalidation); + break; + } + case Variable.VARIABLE: { + inputs[i] = this; + break; + } + } + } + + return definition.apply(value0, inputs); + }; + + const generate = (value: unknown): unknown => { + if (this._version !== version) throw variable_stale; + if (generatorish(value)) { + (invalidation || this.getInvalidator()).then(generatorReturn(value)); + return this.generate(version, value); + } + return value; + }; + + const promise = this._promise.then(init, init).then(define).then(generate); + this._promise = promise; + + promise.then( + (value) => { + this._value = value; + this._fulfilled(value); + }, + (error) => { + if (error === variable_stale || this._version !== version) return; + this._value = undefined; + this._rejected(error); + }, + ); + } + + /** + * Manages a generator or async generator to produce successive values. + * + * When a variable's definition returns a generator, this method handles the + * iteration loop. Each yielded value triggers observer notifications and + * schedules the next iteration. + * + * @param version - The version number to track staleness + * @param generator - The generator or async generator to iterate + * @returns Promise that resolves when the generator completes or is invalidated + */ + generate(version: number, generator: Generator | AsyncGenerator): Promise { + const runtime = this._module.runtime; + let currentValue: unknown; + + const compute = (onfulfilled: (value: unknown) => unknown): Promise => { + return new Promise>((resolve) => resolve(generator.next(currentValue))).then( + ({done, value}) => { + return done ? undefined : Promise.resolve(value).then(onfulfilled); + }, + ); + }; + + const recompute = (): void => { + const promise = compute((value: unknown) => { + if (this._version !== version) throw variable_stale; + currentValue = value; + postcompute(value, promise).then(() => runtime._precompute(recompute)); + this._fulfilled(value); + return value; + }); + promise.catch((error) => { + if (error === variable_stale || this._version !== version) return; + postcompute(undefined, promise); + this._rejected(error); + }); + }; + + const postcompute = (value: unknown, promise: Promise): Promise => { + this._value = value; + this._promise = promise; + this._outputs.forEach(runtime.updates.add, runtime.updates); + return runtime.compute(); + }; + + return compute((value: unknown) => { + if (this._version !== version) throw variable_stale; + currentValue = value; + runtime._precompute(recompute); + return value; + }); + } +} diff --git a/package.json b/package.json index 96882f8..c55463d 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "@lezer/highlight": "^1.2.3", "@lezer/javascript": "^1.5.4", "@observablehq/notebook-kit": "^1.5.0", - "@observablehq/runtime": "^6.0.0", "@uiw/codemirror-theme-github": "^4.25.4", "acorn": "^8.15.0", "acorn-walk": "^8.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2446958..3db388e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: '@observablehq/notebook-kit': specifier: ^1.5.0 version: 1.5.0(@types/markdown-it@14.1.2)(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) - '@observablehq/runtime': - specifier: ^6.0.0 - version: 6.0.0 '@uiw/codemirror-theme-github': specifier: ^4.25.4 version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8) diff --git a/runtime/index.js b/runtime/index.js index 1e24999..1b75c03 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -1,5 +1,5 @@ import {transpileJavaScript} from "@observablehq/notebook-kit"; -import {Runtime} from "@observablehq/runtime"; +import {Runtime} from "../lib/runtime"; import {parse} from "acorn"; import {group} from "d3-array"; import {dispatch as d3Dispatch} from "d3-dispatch"; diff --git a/types/observablehq__runtime.d.ts b/types/observablehq__runtime.d.ts deleted file mode 100644 index bba1d43..0000000 --- a/types/observablehq__runtime.d.ts +++ /dev/null @@ -1,115 +0,0 @@ -declare module "@observablehq/runtime" { - // Error types - export class RuntimeError extends Error { - constructor(message: string, input?: string); - input?: string; - } - - // Observer interface - export interface Observer { - pending?(): void; - fulfilled?(value: T, name?: string | null): void; - rejected?(error: Error, name?: string | null): void; - } - - // Variable options - export interface VariableOptions { - shadow?: Record; - } - - // Variable definition function type - export type DefinitionFunction = (...inputs: any[]) => T | Promise | Generator; - - // Special symbols for module builtins - export const variable_variable: unique symbol; - export const variable_invalidation: unique symbol; - export const variable_visibility: unique symbol; - - // Variable class - export class Variable { - constructor(type: number, module: Module, observer?: Observer | boolean, options?: VariableOptions); - - // Define a variable with optional name, inputs, and definition - define(definition: DefinitionFunction | T): this; - define(name: string, definition: DefinitionFunction | T): this; - define(inputs: string[], definition: DefinitionFunction): this; - define(name: string | null, inputs: string[], definition: DefinitionFunction): this; - - // Import a variable from another module - import(remote: string, module: Module): this; - import(remote: string, name: string, module: Module): this; - - // Delete the variable - delete(): this; - - // Internal properties (readonly from external perspective) - readonly _name: string | null; - readonly _module: Module; - readonly _promise: Promise; - readonly _value: T | undefined; - readonly _version: number; - } - - // Module class - export class Module { - constructor(runtime: Runtime, builtins?: Array<[string, any]>); - - // Define a new variable - define(): Variable; - define(definition: DefinitionFunction): Variable; - define(name: string, definition: DefinitionFunction): Variable; - define(inputs: string[], definition: DefinitionFunction): Variable; - define(name: string | null, inputs: string[], definition: DefinitionFunction): Variable; - - // Redefine an existing variable - redefine(name: string): Variable; - redefine(name: string, definition: DefinitionFunction): Variable; - redefine(name: string, inputs: string[], definition: DefinitionFunction): Variable; - - // Import a variable from another module - import(): Variable; - import(remote: string, module: Module): Variable; - import(remote: string, name: string, module: Module): Variable; - - // Create a variable with an observer - variable(observer?: Observer | boolean, options?: VariableOptions): Variable; - - // Derive a new module with injected variables - derive(injects: Array, injectModule: Module): Module; - - // Get the value of a variable by name - value(name: string): Promise; - - // Add a builtin to the module - builtin(name: string, value: any): void; - - // Internal properties - readonly _runtime: Runtime; - readonly _scope: Map; - } - - // Runtime builtins type - export type RuntimeBuiltins = Record; - - // Runtime global function type - export type RuntimeGlobal = (name: string) => any; - - // Runtime class - export class Runtime { - constructor(builtins?: RuntimeBuiltins, global?: RuntimeGlobal); - - // Create a new module - module(): Module; - module( - define: (runtime: Runtime, observer: (variable: Variable) => Observer) => void, - observer?: (variable: Variable) => Observer, - ): Module; - - // Dispose the runtime - dispose(): void; - - // Internal properties - readonly _builtin: Module; - readonly _modules: Map; - } -} From 2b000ea615347d997dc0fb5c8ee513389ee52d9d Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:30:36 +0800 Subject: [PATCH 2/9] Fix the snapshot test --- lib/runtime/errors.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/runtime/errors.ts b/lib/runtime/errors.ts index 5ba15bb..8a362b2 100644 --- a/lib/runtime/errors.ts +++ b/lib/runtime/errors.ts @@ -4,6 +4,14 @@ export class RuntimeError extends Error { public readonly input?: string, ) { super(message); - this.name = "RuntimeError"; + // Keep the property non-enumerable. + Object.defineProperties(this, { + name: { + value: "RuntimeError", + enumerable: false, + writable: true, + configurable: true, + }, + }); } } From 0bbfcafb2ce12ecdc69afd9a2524faa6358cf06c Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:59:30 +0800 Subject: [PATCH 3/9] Clean up the test directory --- app/cn.js | 6 ------ app/cn.ts | 6 ++++++ package.json | 2 +- test/{ => blocks}/blocks.spec.ts | 2 +- test/{ => containers}/IntervalTree.spec.js | 2 +- test/{ => playground}/components/App.tsx | 2 +- test/{ => playground}/components/BlockItem.tsx | 4 ++-- test/{ => playground}/components/BlockViewer.tsx | 0 test/{ => playground}/components/Editor.tsx | 4 ++-- test/{ => playground}/components/Remote.tsx | 2 +- .../components/SelectionGroupItem.tsx | 0 test/{ => playground}/components/TestSelector.tsx | 2 +- test/{ => playground}/components/TransactionItem.tsx | 2 +- .../{ => playground}/components/TransactionViewer.tsx | 0 test/{ => playground}/components/UserEvent.tsx | 2 +- test/{ => playground}/components/block-data.ts | 2 +- test/{ => playground}/components/transaction-data.ts | 4 ++-- test/{ => playground}/index.css | 0 test/{ => playground}/index.html | 0 test/{ => playground}/main.js | 0 test/{ => playground}/main.tsx | 0 test/{ => playground}/store.ts | 0 test/{ => playground}/styles.css | 2 +- test/{ => playground}/transactionViewer.css | 0 test/{ => playground}/transactionViewer.js | 0 test/{ => playground}/types/css.d.ts | 0 vite.config.js => test/playground/vite.config.js | 8 +++++++- tsconfig.json | 11 +++++++---- 28 files changed, 36 insertions(+), 27 deletions(-) delete mode 100644 app/cn.js create mode 100644 app/cn.ts rename test/{ => blocks}/blocks.spec.ts (99%) rename test/{ => containers}/IntervalTree.spec.js (99%) rename test/{ => playground}/components/App.tsx (98%) rename test/{ => playground}/components/BlockItem.tsx (96%) rename test/{ => playground}/components/BlockViewer.tsx (100%) rename test/{ => playground}/components/Editor.tsx (97%) rename test/{ => playground}/components/Remote.tsx (95%) rename test/{ => playground}/components/SelectionGroupItem.tsx (100%) rename test/{ => playground}/components/TestSelector.tsx (97%) rename test/{ => playground}/components/TransactionItem.tsx (99%) rename test/{ => playground}/components/TransactionViewer.tsx (100%) rename test/{ => playground}/components/UserEvent.tsx (98%) rename test/{ => playground}/components/block-data.ts (92%) rename test/{ => playground}/components/transaction-data.ts (94%) rename test/{ => playground}/index.css (100%) rename test/{ => playground}/index.html (100%) rename test/{ => playground}/main.js (100%) rename test/{ => playground}/main.tsx (100%) rename test/{ => playground}/store.ts (100%) rename test/{ => playground}/styles.css (95%) rename test/{ => playground}/transactionViewer.css (100%) rename test/{ => playground}/transactionViewer.js (100%) rename test/{ => playground}/types/css.d.ts (100%) rename vite.config.js => test/playground/vite.config.js (59%) diff --git a/app/cn.js b/app/cn.js deleted file mode 100644 index 219465a..0000000 --- a/app/cn.js +++ /dev/null @@ -1,6 +0,0 @@ -import {clsx} from "clsx"; -import {twMerge} from "tailwind-merge"; - -export function cn(...inputs) { - return twMerge(clsx(inputs)); -} diff --git a/app/cn.ts b/app/cn.ts new file mode 100644 index 0000000..a50f380 --- /dev/null +++ b/app/cn.ts @@ -0,0 +1,6 @@ +import {clsx, type ClassValue} from "clsx"; +import {twMerge} from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/package.json b/package.json index c55463d..dd37d5e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ ], "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --config test/playground/vite.config.js", "app:dev": "next dev", "app:build": "next build", "app:start": "next start", diff --git a/test/blocks.spec.ts b/test/blocks/blocks.spec.ts similarity index 99% rename from test/blocks.spec.ts rename to test/blocks/blocks.spec.ts index 46c1c51..a490abd 100644 --- a/test/blocks.spec.ts +++ b/test/blocks/blocks.spec.ts @@ -1,5 +1,5 @@ import {it, expect, describe} from "vitest"; -import {findAdjacentBlocks} from "../lib/blocks.js"; +import {findAdjacentBlocks} from "../../lib/blocks.ts"; describe("findAdjacentBlocks", () => { describe("with sorted non-continuous ranges", () => { diff --git a/test/IntervalTree.spec.js b/test/containers/IntervalTree.spec.js similarity index 99% rename from test/IntervalTree.spec.js rename to test/containers/IntervalTree.spec.js index 876c0a4..803e93d 100644 --- a/test/IntervalTree.spec.js +++ b/test/containers/IntervalTree.spec.js @@ -1,5 +1,5 @@ import {it, expect, describe, beforeEach} from "vitest"; -import {IntervalTree} from "../lib/IntervalTree.ts"; +import {IntervalTree} from "../../lib/IntervalTree.ts"; describe("IntervalTree", () => { let tree; diff --git a/test/components/App.tsx b/test/playground/components/App.tsx similarity index 98% rename from test/components/App.tsx rename to test/playground/components/App.tsx index 21e177a..d170ea3 100644 --- a/test/components/App.tsx +++ b/test/playground/components/App.tsx @@ -2,7 +2,7 @@ import {EditorView, ViewPlugin, ViewUpdate, type PluginValue} from "@codemirror/ import {useAtom} from "jotai"; import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {Panel, PanelGroup, PanelResizeHandle} from "react-resizable-panels"; -import * as testSamples from "../js/index.js"; +import * as testSamples from "@/test/js/index.js"; import {selectedTestAtom} from "../store.ts"; import {BlockViewer} from "./BlockViewer.tsx"; import {Editor} from "./Editor.tsx"; diff --git a/test/components/BlockItem.tsx b/test/playground/components/BlockItem.tsx similarity index 96% rename from test/components/BlockItem.tsx rename to test/playground/components/BlockItem.tsx index 03e28c4..859c676 100644 --- a/test/components/BlockItem.tsx +++ b/test/playground/components/BlockItem.tsx @@ -21,12 +21,12 @@ export function BlockItem({block, onLocate}: {block: BlockData; onLocate: (from: }`} > -
+
{block.index + 1} {block.hasError && } {hasOutput && !block.hasError && }
-
+
{block.name} {block.id}
diff --git a/test/components/BlockViewer.tsx b/test/playground/components/BlockViewer.tsx similarity index 100% rename from test/components/BlockViewer.tsx rename to test/playground/components/BlockViewer.tsx diff --git a/test/components/Editor.tsx b/test/playground/components/Editor.tsx similarity index 97% rename from test/components/Editor.tsx rename to test/playground/components/Editor.tsx index a3b204c..16ac6c0 100644 --- a/test/components/Editor.tsx +++ b/test/playground/components/Editor.tsx @@ -1,7 +1,7 @@ import {useEffect, useRef, useState} from "react"; import {Play, Square, RefreshCcw} from "lucide-react"; -import {createEditor} from "../../editor/index.js"; -import {cn} from "../../app/cn.js"; +import {createEditor} from "@/editor/index.js"; +import {cn} from "@/app/cn.js"; import type {PluginValue, ViewPlugin} from "@codemirror/view"; interface EditorProps { diff --git a/test/components/Remote.tsx b/test/playground/components/Remote.tsx similarity index 95% rename from test/components/Remote.tsx rename to test/playground/components/Remote.tsx index 87283f9..fa9bb3a 100644 --- a/test/components/Remote.tsx +++ b/test/playground/components/Remote.tsx @@ -1,5 +1,5 @@ import {BadgeQuestionMarkIcon, HashIcon, RefreshCcw, type LucideIcon} from "lucide-react"; -import {cn} from "../../app/cn.js"; +import {cn} from "@/app/cn.ts"; export function Remote({remoteValue}: {remoteValue: unknown}) { let Icon: LucideIcon; diff --git a/test/components/SelectionGroupItem.tsx b/test/playground/components/SelectionGroupItem.tsx similarity index 100% rename from test/components/SelectionGroupItem.tsx rename to test/playground/components/SelectionGroupItem.tsx diff --git a/test/components/TestSelector.tsx b/test/playground/components/TestSelector.tsx similarity index 97% rename from test/components/TestSelector.tsx rename to test/playground/components/TestSelector.tsx index 45a13a9..0d4f1fc 100644 --- a/test/components/TestSelector.tsx +++ b/test/playground/components/TestSelector.tsx @@ -1,6 +1,6 @@ import {useAtom} from "jotai"; import {selectedTestAtom, getTestSampleName} from "../store.ts"; -import * as testSamples from "../js/index.js"; +import * as testSamples from "@/test/js/index.js"; import {ChevronLeftIcon, ChevronRightIcon} from "lucide-react"; import {useEffect, useMemo} from "react"; diff --git a/test/components/TransactionItem.tsx b/test/playground/components/TransactionItem.tsx similarity index 99% rename from test/components/TransactionItem.tsx rename to test/playground/components/TransactionItem.tsx index 2f3e60f..7a96e27 100644 --- a/test/components/TransactionItem.tsx +++ b/test/playground/components/TransactionItem.tsx @@ -1,7 +1,7 @@ import {useState, type ReactNode} from "react"; import type {TransactionData} from "./transaction-data.ts"; import {ObjectInspector} from "react-inspector"; -import {cn} from "../../app/cn.js"; +import {cn} from "@/app/cn.ts"; import {UserEvent} from "./UserEvent.tsx"; import {PencilLineIcon} from "lucide-react"; import {Remote} from "./Remote.tsx"; diff --git a/test/components/TransactionViewer.tsx b/test/playground/components/TransactionViewer.tsx similarity index 100% rename from test/components/TransactionViewer.tsx rename to test/playground/components/TransactionViewer.tsx diff --git a/test/components/UserEvent.tsx b/test/playground/components/UserEvent.tsx similarity index 98% rename from test/components/UserEvent.tsx rename to test/playground/components/UserEvent.tsx index 0e451e6..154aa29 100644 --- a/test/components/UserEvent.tsx +++ b/test/playground/components/UserEvent.tsx @@ -17,7 +17,7 @@ import { UndoDotIcon, type LucideIcon, } from "lucide-react"; -import {cn} from "../../app/cn.js"; +import {cn} from "@/app/cn.js"; export function UserEvent({userEvent}: {userEvent: string}) { let Icon: LucideIcon; diff --git a/test/components/block-data.ts b/test/playground/components/block-data.ts similarity index 92% rename from test/components/block-data.ts rename to test/playground/components/block-data.ts index d876bfd..5658f0a 100644 --- a/test/components/block-data.ts +++ b/test/playground/components/block-data.ts @@ -1,4 +1,4 @@ -import {blockMetadataField} from "../../editor/blocks/state.ts"; +import {blockMetadataField} from "@/editor/blocks/state.ts"; import type {EditorView} from "@codemirror/view"; export interface BlockData { diff --git a/test/components/transaction-data.ts b/test/playground/components/transaction-data.ts similarity index 94% rename from test/components/transaction-data.ts rename to test/playground/components/transaction-data.ts index d85db63..fd1fadd 100644 --- a/test/components/transaction-data.ts +++ b/test/playground/components/transaction-data.ts @@ -1,5 +1,5 @@ -import {blockMetadataEffect} from "../../editor/blocks/effect.ts"; -import type {BlockMetadata} from "../../editor/blocks/BlockMetadata.ts"; +import {blockMetadataEffect} from "@/editor/blocks/effect.ts"; +import type {BlockMetadata} from "@/editor/blocks/BlockMetadata.ts"; import {Transaction as Tr} from "@codemirror/state"; export interface TransactionRange { diff --git a/test/index.css b/test/playground/index.css similarity index 100% rename from test/index.css rename to test/playground/index.css diff --git a/test/index.html b/test/playground/index.html similarity index 100% rename from test/index.html rename to test/playground/index.html diff --git a/test/main.js b/test/playground/main.js similarity index 100% rename from test/main.js rename to test/playground/main.js diff --git a/test/main.tsx b/test/playground/main.tsx similarity index 100% rename from test/main.tsx rename to test/playground/main.tsx diff --git a/test/store.ts b/test/playground/store.ts similarity index 100% rename from test/store.ts rename to test/playground/store.ts diff --git a/test/styles.css b/test/playground/styles.css similarity index 95% rename from test/styles.css rename to test/playground/styles.css index 37de1dd..0e0d9b0 100644 --- a/test/styles.css +++ b/test/playground/styles.css @@ -1,5 +1,5 @@ @import "tailwindcss"; -@import "../editor/index.css"; +@import "@/editor/index.css"; @import "@fontsource-variable/inter"; @import "@fontsource-variable/spline-sans-mono"; diff --git a/test/transactionViewer.css b/test/playground/transactionViewer.css similarity index 100% rename from test/transactionViewer.css rename to test/playground/transactionViewer.css diff --git a/test/transactionViewer.js b/test/playground/transactionViewer.js similarity index 100% rename from test/transactionViewer.js rename to test/playground/transactionViewer.js diff --git a/test/types/css.d.ts b/test/playground/types/css.d.ts similarity index 100% rename from test/types/css.d.ts rename to test/playground/types/css.d.ts diff --git a/vite.config.js b/test/playground/vite.config.js similarity index 59% rename from vite.config.js rename to test/playground/vite.config.js index 63a5f88..25a2fdd 100644 --- a/vite.config.js +++ b/test/playground/vite.config.js @@ -1,8 +1,9 @@ import {defineConfig} from "vite"; import react from "@vitejs/plugin-react"; +import {fileURLToPath} from "url"; export default defineConfig({ - root: "test", + root: "./test/playground", plugins: [react()], define: { "process.env.NODE_ENV": JSON.stringify("test"), @@ -10,4 +11,9 @@ export default defineConfig({ test: { environment: "jsdom", }, + resolve: { + alias: { + "@": fileURLToPath(new URL("../../", import.meta.url)), + }, + }, }); diff --git a/tsconfig.json b/tsconfig.json index 6df60f3..56f3c74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,10 +43,13 @@ "resolveJsonModule": true, "plugins": [ { - "name": "next" - } - ] + "name": "next", + }, + ], + "paths": { + "@/*": ["./*"], + }, }, "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "exclude": ["node_modules"], } From f4168d159c01fc0542ac03ba70e0a6c238afb710 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 5 Jan 2026 04:11:25 +0800 Subject: [PATCH 4/9] Add tests for the runtime --- lib/runtime/index.ts | 2 +- lib/runtime/module.ts | 26 +- lib/runtime/runtime.ts | 13 +- lib/runtime/variable.ts | 35 +- package.json | 2 +- test/runtime/module/builtin.spec.ts | 24 + test/runtime/module/redefine.spec.ts | 44 ++ test/runtime/module/value.spec.ts | 144 ++++++ test/runtime/runtime/builtins.spec.ts | 39 ++ test/runtime/runtime/dispose.spec.ts | 36 ++ test/runtime/utils.ts | 27 + test/runtime/variable/define.spec.ts | 683 ++++++++++++++++++++++++++ test/runtime/variable/delete.spec.ts | 18 + test/runtime/variable/derive.spec.ts | 321 ++++++++++++ test/runtime/variable/import.spec.ts | 245 +++++++++ test/runtime/variable/shadow.spec.ts | 65 +++ tsconfig.json | 10 +- vite.config.js | 14 + 18 files changed, 1716 insertions(+), 32 deletions(-) create mode 100644 test/runtime/module/builtin.spec.ts create mode 100644 test/runtime/module/redefine.spec.ts create mode 100644 test/runtime/module/value.spec.ts create mode 100644 test/runtime/runtime/builtins.spec.ts create mode 100644 test/runtime/runtime/dispose.spec.ts create mode 100644 test/runtime/utils.ts create mode 100644 test/runtime/variable/define.spec.ts create mode 100644 test/runtime/variable/delete.spec.ts create mode 100644 test/runtime/variable/derive.spec.ts create mode 100644 test/runtime/variable/import.spec.ts create mode 100644 test/runtime/variable/shadow.spec.ts create mode 100644 vite.config.js diff --git a/lib/runtime/index.ts b/lib/runtime/index.ts index 15486b2..dc8e6a7 100644 --- a/lib/runtime/index.ts +++ b/lib/runtime/index.ts @@ -2,4 +2,4 @@ export {RuntimeError} from "./errors.ts"; export {Runtime} from "./runtime.ts"; export type {Builtins, GlobalFunction, ModuleDefinition} from "./runtime.ts"; export type {Module, InjectSpecifier, InjectInput} from "./module.ts"; -export type {Variable, Observer, VariableOptions, ObserverLike, VariableDefinition} from "./variable.ts"; +export type {Variable, Observer, VariableOptions, ObserverInput, VariableDefinition} from "./variable.ts"; diff --git a/lib/runtime/module.ts b/lib/runtime/module.ts index e996702..9c8b367 100644 --- a/lib/runtime/module.ts +++ b/lib/runtime/module.ts @@ -1,6 +1,13 @@ import {constant, identity, rethrow} from "./utils.ts"; import {RuntimeError} from "./errors.ts"; -import {Variable, no_observer, variable_stale, type ObserverLike, type VariableOptions} from "./variable.ts"; +import { + Variable, + no_observer, + variable_stale, + type ObserverInput, + type VariableDefineParameters, + type VariableOptions, +} from "./variable.ts"; import type {Runtime} from "./runtime.ts"; export interface InjectSpecifier { @@ -111,9 +118,13 @@ export class Module { * @param args The definition arguments (name, inputs, definition) - see Variable.define for details * @returns The newly created variable */ - define(...args: Parameters): Variable { + define(definition: unknown): Variable; + define(name: string | null, definition: unknown): Variable; + define(inputs: ArrayLike, definition: unknown): Variable; + define(name: string | null, inputs: ArrayLike, definition: unknown): Variable; + define(...args: VariableDefineParameters): Variable { const v = new Variable(Variable.Type.NORMAL, this); - return v.define(...args); + return v.define(...(args as Parameters)); } /** @@ -121,9 +132,12 @@ export class Module { * @param args The import arguments (remote name, local name, module) - see Variable.import for details * @returns The newly created import variable */ - import(...args: Parameters): Variable { + import(remote: string, module: Module): Variable; + import(remote: string, name: string, module: Module): Variable; + import(remote: string, nameOrModule: string | Module, module?: Module): Variable; + import(...args: [string, Module] | [string, string, Module] | [string, string | Module, Module?]): Variable { const v = new Variable(Variable.Type.NORMAL, this); - return v.import(...args); + return v.import(...(args as Parameters)); } /** @@ -132,7 +146,7 @@ export class Module { * @param options Optional variable options (e.g., shadow variables) * @returns The newly created variable */ - variable(observer?: ObserverLike, options?: VariableOptions): Variable { + variable(observer?: ObserverInput, options?: VariableOptions): Variable { return new Variable(Variable.Type.NORMAL, this, observer, options); } diff --git a/lib/runtime/runtime.ts b/lib/runtime/runtime.ts index 89af289..bb912da 100644 --- a/lib/runtime/runtime.ts +++ b/lib/runtime/runtime.ts @@ -1,9 +1,9 @@ import {noop, defer, defaultGlobal} from "./utils.ts"; import {RuntimeError} from "./errors.ts"; import {Module} from "./module.ts"; -import {Variable, type Observer} from "./variable.ts"; +import {Variable, type ObserverInput} from "./variable.ts"; -const frame = +const frame: (callback: (value: unknown) => void) => void = typeof requestAnimationFrame === "function" ? requestAnimationFrame : typeof setImmediate === "function" @@ -12,7 +12,7 @@ const frame = export type Builtins = Record; export type GlobalFunction = (name: string) => unknown; -export type ModuleDefinition = (runtime: Runtime, observer: (name?: string) => Observer) => Module; +export type ModuleDefinition = (runtime: Runtime, observer: (name?: string) => ObserverInput) => Module; /** * Runtime manages the reactive computation graph. @@ -242,11 +242,8 @@ export class Runtime { * @returns The created or existing module */ module(): Module; - module(define: ModuleDefinition, observer?: (name?: string) => Observer): Module; - module( - define?: ModuleDefinition, - observer: (name?: string) => Observer = noop as unknown as (name?: string) => Observer, - ): Module { + module(define: ModuleDefinition, observer?: (name?: string) => ObserverInput): Module; + module(define?: ModuleDefinition, observer: (name?: string) => ObserverInput = noop as () => undefined): Module { let module: Module; if (define === undefined) { diff --git a/lib/runtime/variable.ts b/lib/runtime/variable.ts index 2c9aaee..13ced74 100644 --- a/lib/runtime/variable.ts +++ b/lib/runtime/variable.ts @@ -18,11 +18,20 @@ export interface Observer { _node?: Element; } +/** + * A default observer that does nothing. + */ +const defaultObserver: Observer = { + pending: noop, + fulfilled: noop, + rejected: noop, +}; + export interface VariableOptions { shadow?: Record; } -export type ObserverLike = boolean | Observer | symbol; +export type ObserverInput = boolean | Observer | typeof no_observer | null | undefined; export type VariableDefinition = (...args: unknown[]) => unknown; @@ -64,7 +73,7 @@ export class Variable { static readonly VISIBILITY = Symbol("visibility"); // Read-only cross-class access (getters only) - private _observer: Observer | symbol; + private _observer: Observer | typeof no_observer; private _definition: VariableDefinition; private _duplicate?: VariableDefinition; private _duplicates?: Set; @@ -174,10 +183,14 @@ export class Variable { * @param observer - Optional observer for notifications (true creates default observer, false/undefined means no observer) * @param options - Optional configuration including shadow variables */ - constructor(type: VariableType, module: Module, observer?: ObserverLike, options?: VariableOptions) { - if (!observer) observer = no_observer; + constructor(type: VariableType, module: Module, observer?: ObserverInput, options?: VariableOptions) { + if (observer === true) { + observer = defaultObserver; + } else if (!observer) { + observer = no_observer; + } - this._observer = observer as Observer | symbol; + this._observer = observer; this._definition = variable_undefined; this._duplicate = undefined; this._duplicates = undefined; @@ -202,8 +215,8 @@ export class Variable { * @internal */ _pending(): void { - if (this._observer !== no_observer && typeof (this._observer as Observer).pending === "function") { - (this._observer as Observer).pending!(); + if (this._observer !== no_observer && typeof this._observer.pending === "function") { + this._observer.pending!(); } } @@ -214,8 +227,8 @@ export class Variable { * @internal */ _fulfilled(value: unknown): void { - if (this._observer !== no_observer && typeof (this._observer as Observer).fulfilled === "function") { - (this._observer as Observer).fulfilled!(value, this._name); + if (this._observer !== no_observer && typeof this._observer.fulfilled === "function") { + this._observer.fulfilled!(value, this._name); } } @@ -226,8 +239,8 @@ export class Variable { * @internal */ _rejected(error: unknown): void { - if (this._observer !== no_observer && typeof (this._observer as Observer).rejected === "function") { - (this._observer as Observer).rejected!(error, this._name); + if (this._observer !== no_observer && typeof this._observer.rejected === "function") { + this._observer.rejected!(error, this._name); } } diff --git a/package.json b/package.json index dd37d5e..6275ffe 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "app:build": "next build", "app:start": "next start", "test": "npm run test:lint && npm run test:format && npm run test:js", - "test:js": "TZ=America/New_York vitest", + "test:js": "TZ=America/New_York vitest --config vite.config.js", "test:format": "prettier --check editor runtime test app", "test:lint": "eslint" }, diff --git a/test/runtime/module/builtin.spec.ts b/test/runtime/module/builtin.spec.ts new file mode 100644 index 0000000..83c0fc2 --- /dev/null +++ b/test/runtime/module/builtin.spec.ts @@ -0,0 +1,24 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof} from "../utils.ts"; + +it("module.builtin(name, value) defines a module-specific builtin variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.builtin("foo", 42); + const bar = module.variable(true).define("bar", ["foo"], (foo: number) => foo + 1); + await new Promise(setImmediate); + assert.deepStrictEqual(await valueof(bar), {value: 43}); +}); + +it("module.builtin(name, value) can be overridden by a normal variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.builtin("foo", 0); + const foo = module.variable(true).define("foo", [], () => 42); + const bar = module.variable(true).define("bar", ["foo"], (foo: number) => foo + 1); + await new Promise(setImmediate); + assert.deepStrictEqual(await valueof(foo), {value: 42}); + assert.deepStrictEqual(await valueof(bar), {value: 43}); +}); diff --git a/test/runtime/module/redefine.spec.ts b/test/runtime/module/redefine.spec.ts new file mode 100644 index 0000000..53645bc --- /dev/null +++ b/test/runtime/module/redefine.spec.ts @@ -0,0 +1,44 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof} from "../utils.ts"; + +it("module.redefine(name, inputs, definition) can redefine a normal variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define("foo", [], () => 42); + assert.strictEqual( + module.redefine("foo", [], () => 43), + foo, + ); + assert.deepStrictEqual(await valueof(foo), {value: 43}); +}); + +it("module.redefine(name, inputs, definition) can redefine an implicit variable", async () => { + const runtime = new Runtime({foo: 42}); + const module = runtime.module(); + const bar = module.variable(true).define("bar", ["foo"], (foo: number) => foo + 1); + module.redefine("foo", [], () => 43); + assert.deepStrictEqual(await valueof(bar), {value: 44}); +}); + +it("module.redefine(name, inputs, definition) can’t redefine a duplicate definition", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo1 = module.variable(true).define("foo", [], () => 1); + const foo2 = module.variable(true).define("foo", [], () => 2); + assert.throws(() => module.redefine("foo", [], () => 3), /foo is defined more than once/); + assert.deepStrictEqual(await valueof(foo1), {error: "RuntimeError: foo is defined more than once"}); + assert.deepStrictEqual(await valueof(foo2), {error: "RuntimeError: foo is defined more than once"}); +}); + +it("module.redefine(name, inputs, definition) throws an error if the specified variable doesn’t exist", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const foo = module.variable(true).define("foo", [], () => 42); + assert.throws(() => module.redefine("bar", [], () => 43), /bar is not defined/); + // Note: The following line is the original test. But there is an additional + // argument 43. + // assert.throws(() => module.redefine("bar", [], () => 43, foo), /bar is not defined/); +}); diff --git a/test/runtime/module/value.spec.ts b/test/runtime/module/value.spec.ts new file mode 100644 index 0000000..1cdb35d --- /dev/null +++ b/test/runtime/module/value.spec.ts @@ -0,0 +1,144 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; + +it("module.value(name) returns a promise to the variable’s next value", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.variable(true).define("foo", [], () => 42); + assert.deepStrictEqual(await module.value("foo"), 42); +}); + +it("module.value(name) implicitly makes the variable reachable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], () => 42); + assert.deepStrictEqual(await module.value("foo"), 42); +}); + +it("module.value(name) supports errors", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], () => { + throw new Error("42"); + }); + await assert.rejects(() => module.value("foo"), /42/); +}); + +it("module.value(name) supports generators", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], function* () { + yield 1; + yield 2; + yield 3; + }); + assert.deepStrictEqual(await module.value("foo"), 1); + assert.deepStrictEqual(await module.value("foo"), 2); + assert.deepStrictEqual(await module.value("foo"), 3); + assert.deepStrictEqual(await module.value("foo"), 3); +}); + +it("module.value(name) supports generators that throw", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], function* () { + yield 1; + throw new Error("fooed"); + }); + module.define("bar", ["foo"], (foo: unknown) => foo); + const [foo1, bar1] = await Promise.all([module.value("foo"), module.value("bar")]); + assert.deepStrictEqual(foo1, 1); + assert.deepStrictEqual(bar1, 1); + await assert.rejects(() => module.value("foo"), /fooed/); + await assert.rejects(() => module.value("bar"), /fooed/); +}); + +it("module.value(name) supports async generators", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], async function* () { + yield 1; + yield 2; + yield 3; + }); + assert.deepStrictEqual(await module.value("foo"), 1); + assert.deepStrictEqual(await module.value("foo"), 2); + assert.deepStrictEqual(await module.value("foo"), 3); + assert.deepStrictEqual(await module.value("foo"), 3); +}); + +it("module.value(name) supports promises", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], async () => { + return await 42; + }); + assert.deepStrictEqual(await module.value("foo"), 42); +}); + +it("module.value(name) supports constants", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], 42); + assert.deepStrictEqual(await module.value("foo"), 42); +}); + +it("module.value(name) supports missing variables", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + await assert.rejects(() => module.value("bar"), /bar is not defined/); +}); + +it("module.value(name) returns a promise on error", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const promise = module.value("bar"); + await assert.rejects(promise, /bar is not defined/); +}); + +it("module.value(name) does not force recomputation", async () => { + let foo = 0; + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], () => ++foo); + assert.deepStrictEqual(await module.value("foo"), 1); + assert.deepStrictEqual(await module.value("foo"), 1); + assert.deepStrictEqual(await module.value("foo"), 1); +}); + +it("module.value(name) does not expose stale values", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + let resolve: unknown; + const variable = module.define("foo", [], new Promise((y) => (resolve = y))); + const value = module.value("foo"); + await new Promise((resolve) => setTimeout(resolve, 100)); + variable.define("foo", [], () => "fresh"); + (resolve as (value: unknown) => unknown)("stale"); + assert.strictEqual(await value, "fresh"); +}); + +it("module.value(name) does not continue observing", async () => { + const foos: number[] = []; + const runtime = new Runtime(); + const module = runtime.module(); + module.define("foo", [], async function* () { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + (foos.push(1), yield 1); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + (foos.push(2), yield 2); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + (foos.push(3), yield 3); + } finally { + foos.push(-1); + } + }); + assert.strictEqual(await module.value("foo"), 1); + assert.deepStrictEqual(foos, [1]); + await runtime.compute(); + assert.deepStrictEqual(foos, [1, 2, -1]); // 2 computed prior to being unobserved + await runtime.compute(); + assert.deepStrictEqual(foos, [1, 2, -1]); // any change would represent a leak +}); diff --git a/test/runtime/runtime/builtins.spec.ts b/test/runtime/runtime/builtins.spec.ts new file mode 100644 index 0000000..8c986d5 --- /dev/null +++ b/test/runtime/runtime/builtins.spec.ts @@ -0,0 +1,39 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof} from "../utils.ts"; + +it("new Runtime(builtins) allows builtins to be defined as promises", async () => { + const runtime = new Runtime({color: Promise.resolve("red")}); + const main = runtime.module(); + const foo = main.variable(true).define(null, ["color"], (color: string) => color); + assert.deepStrictEqual(await valueof(foo), {value: "red"}); +}); + +it("new Runtime(builtins) allows builtins to be defined as functions", async () => { + const runtime = new Runtime({color: () => "red"}); + const main = runtime.module(); + const foo = main.variable(true).define(null, ["color"], (color: string) => color); + assert.deepStrictEqual(await valueof(foo), {value: "red"}); +}); + +it("new Runtime(builtins) allows builtins to be defined as async functions", async () => { + const runtime = new Runtime({color: async () => "red"}); + const main = runtime.module(); + const foo = main.variable(true).define(null, ["color"], (color: string) => color); + assert.deepStrictEqual(await valueof(foo), {value: "red"}); +}); + +it("new Runtime(builtins) allows builtins to be defined as generators", async () => { + let i = 0; + const runtime = new Runtime({ + i: function* () { + while (i < 3) yield ++i; + }, + }); + const main = runtime.module(); + const foo = main.variable(true).define(null, ["i"], (i: number) => i); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + assert.deepStrictEqual(await valueof(foo), {value: 3}); +}); diff --git a/test/runtime/runtime/dispose.spec.ts b/test/runtime/runtime/dispose.spec.ts new file mode 100644 index 0000000..c5a41a3 --- /dev/null +++ b/test/runtime/runtime/dispose.spec.ts @@ -0,0 +1,36 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {describe, it} from "vitest"; +import {sleep} from "../utils.ts"; + +describe("runtime.dispose", () => { + it("invalidates all variables", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const log: unknown[] = []; + main.variable(true).define(["invalidation"], async (invalidation: Promise) => { + await invalidation; + log.push("invalidation"); + }); + await sleep(); + runtime.dispose(); + await sleep(); + assert.deepStrictEqual(log, ["invalidation"]); + }); + it("terminates generators", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const log: unknown[] = []; + main.variable(true).define([], function* () { + try { + while (true) yield; + } finally { + log.push("return"); + } + }); + await sleep(); + runtime.dispose(); + await sleep(); + assert.deepStrictEqual(log, ["return"]); + }); +}); diff --git a/test/runtime/utils.ts b/test/runtime/utils.ts new file mode 100644 index 0000000..868e15d --- /dev/null +++ b/test/runtime/utils.ts @@ -0,0 +1,27 @@ +import type {Variable} from "@/lib/runtime/variable.ts"; + +export async function valueof(variable: Variable) { + await variable.module.runtime.compute(); + try { + return {value: await variable.promise}; + } catch (error) { + return {error: error instanceof Error ? error.toString() : String(error)}; + } +} + +export function promiseInspector() { + let fulfilled: (value: unknown) => void, rejected: (reason: unknown) => void; + const promise = new Promise((resolve, reject) => { + fulfilled = resolve; + rejected = reject; + }); + return Object.assign(promise, {fulfilled: fulfilled!, rejected: rejected!}); +} + +export function sleep(ms = 50) { + return delay(undefined, ms); +} + +export function delay(value: T, ms: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +} diff --git a/test/runtime/variable/define.spec.ts b/test/runtime/variable/define.spec.ts new file mode 100644 index 0000000..a386f17 --- /dev/null +++ b/test/runtime/variable/define.spec.ts @@ -0,0 +1,683 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {sleep, valueof} from "../utils.ts"; + +it("variable.define(name, inputs, definition) can define a variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define("foo", [], () => 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(inputs, function) can define an anonymous variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define([], () => 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(name, function) can define a named variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define("foo", () => 42); + const bar = module.variable(true).define("bar", ["foo"], (foo: unknown) => foo); + assert.deepStrictEqual(await valueof(foo), {value: 42}); + assert.deepStrictEqual(await valueof(bar), {value: 42}); +}); + +it("variable.define(function) can define an anonymous variable", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define(() => 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(null, inputs, value) can define an anonymous constant", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define(null, [], 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(inputs, value) can define an anonymous constant", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define([], 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(null, value) can define an anonymous constant", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define(null, 42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define(value) can define an anonymous constant", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define(42); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define detects missing inputs", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true); + const bar = module.variable(true).define("bar", ["foo"], (foo: unknown) => foo); + assert.deepStrictEqual(await valueof(foo), {value: undefined}); + assert.deepStrictEqual(await valueof(bar), {error: "RuntimeError: foo is not defined"}); + foo.define("foo", 1); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 1}); +}); + +it("variable.define detects duplicate names", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define("foo", 1); + const bar = module.variable(true).define("foo", 2); + assert.deepStrictEqual(await valueof(foo), {error: "RuntimeError: foo is defined more than once"}); + assert.deepStrictEqual(await valueof(bar), {error: "RuntimeError: foo is defined more than once"}); + bar.define("bar", 2); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 2}); +}); + +it("variable.define recomputes reachability as expected", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + const quux = module.define("quux", [], () => 42); + const baz = module.define("baz", ["quux"], (quux: unknown) => `baz-${quux}`); + const bar = module.define("bar", ["quux"], (quux: unknown) => `bar-${quux}`); + const foo = main + .variable(true) + .define("foo", ["bar", "baz", "quux"], (bar: unknown, baz: unknown, quux: unknown) => [bar, baz, quux]); + main.variable().import("bar", module); + main.variable().import("baz", module); + main.variable().import("quux", module); + await runtime.compute(); + assert.strictEqual(quux.reachable, true); + assert.strictEqual(baz.reachable, true); + assert.strictEqual(bar.reachable, true); + assert.strictEqual(foo.reachable, true); + assert.deepStrictEqual(await valueof(foo), {value: ["bar-42", "baz-42", 42]}); + foo.define("foo", [], () => "foo"); + await runtime.compute(); + assert.strictEqual(quux.reachable, false); + assert.strictEqual(baz.reachable, false); + assert.strictEqual(bar.reachable, false); + assert.strictEqual(foo.reachable, true); + assert.deepStrictEqual(await valueof(foo), {value: "foo"}); +}); + +it("variable.define correctly detects reachability for unreachable cycles", async () => { + let returned = false; + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + const bar = module.define("bar", ["baz"], (baz: unknown) => `bar-${baz}`); + const baz = module.define("baz", ["quux"], (quux: unknown) => `baz-${quux}`); + const quux = module.define("quux", ["zapp"], function* (zapp: unknown) { + try { + while (true) yield `quux-${zapp}`; + } finally { + returned = true; + } + }); + const zapp = module.define("zapp", ["bar"], (bar: unknown) => `zaap-${bar}`); + await runtime.compute(); + assert.strictEqual(bar.reachable, false); + assert.strictEqual(baz.reachable, false); + assert.strictEqual(quux.reachable, false); + assert.strictEqual(zapp.reachable, false); + assert.deepStrictEqual(await valueof(bar), {value: undefined}); + assert.deepStrictEqual(await valueof(baz), {value: undefined}); + assert.deepStrictEqual(await valueof(quux), {value: undefined}); + assert.deepStrictEqual(await valueof(zapp), {value: undefined}); + main.variable().import("bar", module); + const foo = main.variable(true).define("foo", ["bar"], (bar: unknown) => bar); + await runtime.compute(); + assert.strictEqual(foo.reachable, true); + assert.strictEqual(bar.reachable, true); + assert.strictEqual(baz.reachable, true); + assert.strictEqual(quux.reachable, true); + assert.strictEqual(zapp.reachable, true); + assert.deepStrictEqual(await valueof(bar), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(baz), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(quux), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(zapp), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(foo), {error: "RuntimeError: circular definition"}); + foo.define("foo", [], () => "foo"); + await runtime.compute(); + assert.strictEqual(foo.reachable, true); + assert.strictEqual(bar.reachable, false); + assert.strictEqual(baz.reachable, false); + assert.strictEqual(quux.reachable, false); + assert.strictEqual(zapp.reachable, false); + assert.deepStrictEqual(await valueof(bar), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(baz), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(quux), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(zapp), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(foo), {value: "foo"}); + assert.strictEqual(returned, false); // Generator is never finalized because it has never run. +}); + +it("variable.define terminates previously reachable generators", async () => { + let returned = false; + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const bar = module.define("bar", [], function* () { + try { + while (true) yield 1; + } finally { + returned = true; + } + }); + const foo = main.variable(true).define("foo", ["bar"], (bar: unknown) => bar); + main.variable().import("bar", module); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + foo.define("foo", [], () => "foo"); + assert.deepStrictEqual(await valueof(foo), {value: "foo"}); + // Note: The property `_generator` has never appeared in the original + // implementation. I guess it's not necessary to check it. + // assert.strictEqual(bar._generator, undefined); + assert.strictEqual(returned, true); +}); + +it("variable.define does not terminate reachable generators", async () => { + let returned = false; + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + const bar = module.define("bar", [], function* () { + try { + while (true) yield 1; + } finally { + returned = true; + } + }); + const baz = main.variable(true).define("baz", ["bar"], (bar: unknown) => bar); + const foo = main.variable(true).define("foo", ["bar"], (bar: unknown) => bar); + main.variable().import("bar", module); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(baz), {value: 1}); + foo.define("foo", [], () => "foo"); + assert.deepStrictEqual(await valueof(foo), {value: "foo"}); + assert.deepStrictEqual(await valueof(baz), {value: 1}); + assert.strictEqual(returned, false); + bar.invalidate(); + await runtime.compute(); + assert.strictEqual(returned, true); +}); + +it("variable.define detects duplicate declarations", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const v1 = main.variable(true).define("foo", [], () => 1); + const v2 = main.variable(true).define("foo", [], () => 2); + const v3 = main.variable(true).define(null, ["foo"], (foo: unknown) => foo); + assert.deepStrictEqual(await valueof(v1), {error: "RuntimeError: foo is defined more than once"}); + assert.deepStrictEqual(await valueof(v2), {error: "RuntimeError: foo is defined more than once"}); + assert.deepStrictEqual(await valueof(v3), {error: "RuntimeError: foo is defined more than once"}); +}); + +it("variable.define detects missing inputs and erroneous inputs", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const v1 = main.variable(true).define("foo", ["baz"], () => 1); + const v2 = main.variable(true).define("bar", ["foo"], () => 2); + assert.deepStrictEqual(await valueof(v1), {error: "RuntimeError: baz is not defined"}); + assert.deepStrictEqual(await valueof(v2), {error: "RuntimeError: baz is not defined"}); +}); + +it("variable.define allows masking of builtins", async () => { + const runtime = new Runtime({color: "red"}); + const main = runtime.module(); + const mask = main.define("color", "green"); + const foo = main.variable(true).define(null, ["color"], (color: unknown) => color); + assert.deepStrictEqual(await valueof(foo), {value: "green"}); + mask.delete(); + assert.deepStrictEqual(await valueof(foo), {value: "red"}); +}); + +it("variable.define supports promises", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], () => new Promise((resolve) => setImmediate(() => resolve(42)))); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define supports generator cells", async () => { + let i = 0; + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], function* () { + while (i < 3) yield ++i; + }); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + assert.deepStrictEqual(await valueof(foo), {value: 3}); +}); + +it("variable.define supports generator objects", async () => { + function* range(n: number) { + for (let i = 0; i < n; ++i) yield i; + } + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], () => range(3)); + assert.deepStrictEqual(await valueof(foo), {value: 0}); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); +}); + +it("variable.define supports a promise that resolves to a generator object", async () => { + function* range(n: number) { + for (let i = 0; i < n; ++i) yield i; + } + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], async () => range(3)); + assert.deepStrictEqual(await valueof(foo), {value: 0}); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); +}); + +it("variable.define supports generators that yield promises", async () => { + let i = 0; + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], function* () { + while (i < 3) yield Promise.resolve(++i); + }); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + assert.deepStrictEqual(await valueof(foo), {value: 3}); +}); + +it("variable.define allows a variable to be redefined", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], () => 1); + const bar = main + .variable(true) + .define("bar", ["foo"], (foo: unknown) => new Promise((resolve) => setImmediate(() => resolve(foo)))); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 1}); + foo.define("foo", [], () => 2); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + assert.deepStrictEqual(await valueof(bar), {value: 2}); +}); + +it("variable.define recomputes downstream values when a variable is renamed", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], () => 1); + const bar = main.variable(true).define("bar", [], () => 2); + const baz = main + .variable(true) + .define("baz", ["foo", "bar"], (foo: unknown, bar: unknown) => (foo as number) + (bar as number)); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 2}); + assert.deepStrictEqual(await valueof(baz), {value: 3}); + foo.define("quux", [], () => 10); + assert.deepStrictEqual(await valueof(foo), {value: 10}); + assert.deepStrictEqual(await valueof(bar), {value: 2}); + assert.deepStrictEqual(await valueof(baz), {error: "RuntimeError: foo is not defined"}); +}); + +it("variable.define ignores an asynchronous result from a redefined variable", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main + .variable(true) + .define("foo", [], () => new Promise((resolve) => setTimeout(() => resolve("fail"), 150))); + await new Promise(setImmediate); + foo.define("foo", [], () => "success"); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.deepStrictEqual(await valueof(foo), {value: "success"}); + assert.deepStrictEqual(foo.value, "success"); +}); + +it("variable.define ignores an asynchronous result from a redefined input", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const bar = main.variable().define("bar", [], () => new Promise((resolve) => setTimeout(() => resolve("fail"), 150))); + const foo = main.variable(true).define("foo", ["bar"], (bar: unknown) => bar); + await new Promise(setImmediate); + bar.define("bar", [], () => "success"); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.deepStrictEqual(await valueof(foo), {value: "success"}); + assert.deepStrictEqual(foo.value, "success"); +}); + +it("variable.define does not try to compute unreachable variables", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + let evaluated = false; + const foo = main.variable(true).define("foo", [], () => 1); + const bar = main.variable().define("bar", ["foo"], (foo: unknown) => (evaluated = foo as boolean)); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: undefined}); + assert.strictEqual(evaluated, false); +}); + +it("variable.define does not try to compute unreachable variables that are outputs of reachable variables", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + let evaluated = false; + const foo = main.variable(true).define("foo", [], () => 1); + const bar = main.variable(true).define("bar", [], () => 2); + const baz = main + .variable() + .define( + "baz", + ["foo", "bar"], + (foo: unknown, bar: unknown) => (evaluated = ((foo as number) + (bar as number)) as unknown as boolean), + ); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 2}); + assert.deepStrictEqual(await valueof(baz), {value: undefined}); + assert.strictEqual(evaluated, false); +}); + +it("variable.define can reference whitelisted globals", async () => { + const runtime = new Runtime(null, (name: string) => (name === "magic" ? 21 : undefined)); + const module = runtime.module(); + const foo = module.variable(true).define(["magic"], (magic: unknown) => (magic as number) * 2); + assert.deepStrictEqual(await valueof(foo), {value: 42}); +}); + +it("variable.define captures the value of whitelisted globals", async () => { + let magic = 0; + const runtime = new Runtime(null, (name: string) => (name === "magic" ? ++magic : undefined)); + const module = runtime.module(); + const foo = module.variable(true).define(["magic"], (magic: unknown) => (magic as number) * 2); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + assert.deepStrictEqual(await valueof(foo), {value: 2}); +}); + +it("variable.define can override whitelisted globals", async () => { + const runtime = new Runtime(null, (name: string) => (name === "magic" ? 1 : undefined)); + const module = runtime.module(); + module.variable().define("magic", [], () => 2); + const foo = module.variable(true).define(["magic"], (magic: unknown) => (magic as number) * 2); + assert.deepStrictEqual(await valueof(foo), {value: 4}); +}); + +it("variable.define can dynamically override whitelisted globals", async () => { + const runtime = new Runtime(null, (name: string) => (name === "magic" ? 1 : undefined)); + const module = runtime.module(); + const foo = module.variable(true).define(["magic"], (magic: unknown) => (magic as number) * 2); + assert.deepStrictEqual(await valueof(foo), {value: 2}); + module.variable().define("magic", [], () => 2); + assert.deepStrictEqual(await valueof(foo), {value: 4}); +}); + +it("variable.define cannot reference non-whitelisted globals", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const foo = module.variable(true).define(["magic"], (magic: unknown) => (magic as number) * 2); + assert.deepStrictEqual(await valueof(foo), {error: "RuntimeError: magic is not defined"}); +}); + +it("variable.define correctly handles globals that throw", async () => { + const runtime = new Runtime(null, (name: string) => { + if (name === "oops") throw new Error("oops"); + }); + const module = runtime.module(); + const foo = module.variable(true).define(["oops"], (oops: unknown) => oops); + assert.deepStrictEqual(await valueof(foo), {error: "RuntimeError: oops"}); +}); + +it("variable.define allows other variables to begin computation before a generator may resume", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const main = runtime.module(); + let i = 0; + let genIteration = 0; + let valIteration = 0; + const onGenFulfilled = (value: unknown) => { + if (genIteration === 0) { + assert.strictEqual(valIteration, 0); + assert.strictEqual(value, 1); + assert.strictEqual(i, 1); + } else if (genIteration === 1) { + assert.strictEqual(valIteration, 1); + assert.strictEqual(value, 2); + assert.strictEqual(i, 2); + } else if (genIteration === 2) { + assert.strictEqual(valIteration, 2); + assert.strictEqual(value, 3); + assert.strictEqual(i, 3); + } else { + assert.fail(); + } + genIteration++; + }; + const onValFulfilled = (value: unknown) => { + if (valIteration === 0) { + assert.strictEqual(genIteration, 1); + assert.strictEqual(value, 1); + assert.strictEqual(i, 1); + } else if (valIteration === 1) { + assert.strictEqual(genIteration, 2); + assert.strictEqual(value, 2); + assert.strictEqual(i, 2); + } else if (valIteration === 2) { + assert.strictEqual(genIteration, 3); + assert.strictEqual(value, 3); + assert.strictEqual(i, 3); + } else { + assert.fail(); + } + valIteration++; + }; + const gen = module.variable({fulfilled: onGenFulfilled}).define("gen", [], function* () { + i++; + yield i; + i++; + yield i; + i++; + yield i; + }); + main.variable().import("gen", module); + const val = main.variable({fulfilled: onValFulfilled}).define("val", ["gen"], (i: unknown) => i); + assert.strictEqual(await gen.promise, undefined, "gen cell undefined"); + assert.strictEqual(await val.promise, undefined, "val cell undefined"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 1, "gen cell 1"); + assert.strictEqual(await val.promise, 1, "val cell 1"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 2, "gen cell 2"); + assert.strictEqual(await val.promise, 2, "val cell 2"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 3, "gen cell 3"); + assert.strictEqual(await val.promise, 3, "val cell 3"); +}); + +it("variable.define allows other variables to begin computation before a generator may resume", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + let i = 0; + let j = 0; + const gen = main.variable().define("gen", [], function* () { + i++; + yield i; + i++; + yield i; + i++; + yield i; + }); + const val = main.variable(true).define("val", ["gen"], (gen: unknown) => { + j++; + assert.strictEqual(gen, j, "gen = j"); + assert.strictEqual(gen, i, "gen = i"); + return gen; + }); + assert.strictEqual(await gen.promise, undefined, "gen = undefined"); + assert.strictEqual(await val.promise, undefined, "val = undefined"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 1, "gen cell 1"); + assert.strictEqual(await val.promise, 1, "val cell 1"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 2, "gen cell 2"); + assert.strictEqual(await val.promise, 2, "val cell 2"); + await runtime.compute(); + assert.strictEqual(await gen.promise, 3, "gen cell 3"); + assert.strictEqual(await val.promise, 3, "val cell 3"); +}); + +it("variable.define does not report stale fulfillments", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const values: unknown[] = []; + const errors: unknown[] = []; + const variable = module.variable({ + fulfilled(value: unknown) { + values.push(value); + }, + rejected(error: unknown) { + errors.push(error); + }, + }); + const promise = new Promise((resolve) => setTimeout(() => resolve("value1"), 250)); + variable.define(() => promise); + await Reflect.get(variable, "_computing"); + variable.define(() => "value2"); + await promise; + assert.deepStrictEqual(await valueof(variable), {value: "value2"}); + assert.deepStrictEqual(values, ["value2"]); + assert.deepStrictEqual(errors, []); +}); + +it("variable.define does not report stale rejections", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + const values: unknown[] = []; + const errors: unknown[] = []; + const variable = module.variable({ + fulfilled(value: unknown) { + values.push(value); + }, + rejected(error: unknown) { + errors.push(error); + }, + }); + const promise = new Promise((resolve, reject) => setTimeout(() => reject("error1"), 250)); + variable.define(() => promise); + await Reflect.get(variable, "_computing"); + variable.define(() => Promise.reject("error2")); + await promise.catch(() => {}); + assert.deepStrictEqual(await valueof(variable), {error: "error2"}); + assert.deepStrictEqual(values, []); + assert.deepStrictEqual(errors, ["error2"]); +}); + +it("variable.define waits for the previous value to settle before computing", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const log: string[] = []; + let resolve1: ((value: unknown) => void) | undefined; + let resolve2: ((value: unknown) => void) | undefined; + const promise1 = new Promise((r) => (resolve1 = r)); + const promise2 = new Promise((r) => (resolve2 = r)); + const foo = main.variable(true); + foo.define("foo", [], () => { + log.push("1 start"); + promise1.then(() => log.push("1 end")); + return promise1; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start"]); + foo.define("foo", [], () => { + log.push("2 start"); + promise2.then(() => log.push("2 end")); + return promise2; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start"]); + resolve1!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "1 end", "2 start"]); + resolve2!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "1 end", "2 start", "2 end"]); +}); + +it("variable.define does not wait for other variables", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const log: string[] = []; + let resolve1: ((value: unknown) => void) | undefined; + let resolve2: ((value: unknown) => void) | undefined; + const promise1 = new Promise((r) => (resolve1 = r)); + const promise2 = new Promise((r) => (resolve2 = r)); + const foo = main.variable(true); + foo.define("foo", [], () => { + log.push("1 start"); + promise1.then(() => log.push("1 end")); + return promise1; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start"]); + const bar = main.variable(true); + foo.delete(); + bar.define("foo", [], () => { + log.push("2 start"); + promise2.then(() => log.push("2 end")); + return promise2; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "2 start"]); + resolve1!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "2 start", "1 end"]); + resolve2!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "2 start", "1 end", "2 end"]); +}); + +it("variable.define skips stale definitions", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const log: string[] = []; + let resolve1: ((value: unknown) => void) | undefined; + let resolve2: ((value: unknown) => void) | undefined; + let resolve3: ((value: unknown) => void) | undefined; + const promise1 = new Promise((r) => (resolve1 = r)); + const promise2 = new Promise((r) => (resolve2 = r)); + const promise3 = new Promise((r) => (resolve3 = r)); + const foo = main.variable(true); + foo.define("foo", [], () => { + log.push("1 start"); + promise1.then(() => log.push("1 end")); + return promise1; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start"]); + foo.define("foo", [], () => { + log.push("2 start"); + promise2.then(() => log.push("2 end")); + return promise2; + }); + await sleep(); + assert.deepStrictEqual(log, ["1 start"]); + foo.define("foo", [], () => { + log.push("3 start"); + promise3.then(() => log.push("3 end")); + return promise3; + }); + resolve1!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "1 end", "3 start"]); + resolve2!(undefined); + resolve3!(undefined); + await sleep(); + assert.deepStrictEqual(log, ["1 start", "1 end", "3 start", "3 end"]); +}); diff --git a/test/runtime/variable/delete.spec.ts b/test/runtime/variable/delete.spec.ts new file mode 100644 index 0000000..e1ce9a2 --- /dev/null +++ b/test/runtime/variable/delete.spec.ts @@ -0,0 +1,18 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof} from "../utils.ts"; + +it("variable.delete allows a variable to be deleted", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const foo = main.variable(true).define("foo", [], () => 1); + const bar = main + .variable(true) + .define("bar", ["foo"], (foo: unknown) => new Promise((resolve) => setImmediate(() => resolve(foo)))); + assert.deepStrictEqual(await valueof(foo), {value: 1}); + assert.deepStrictEqual(await valueof(bar), {value: 1}); + foo.delete(); + assert.deepStrictEqual(await valueof(foo), {value: undefined}); + assert.deepStrictEqual(await valueof(bar), {error: "RuntimeError: foo is not defined"}); +}); diff --git a/test/runtime/variable/derive.spec.ts b/test/runtime/variable/derive.spec.ts new file mode 100644 index 0000000..67e03bb --- /dev/null +++ b/test/runtime/variable/derive.spec.ts @@ -0,0 +1,321 @@ +import {Runtime, type Module, type ModuleDefinition} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {identity} from "@/lib/runtime/utils.ts"; +import {valueof, promiseInspector, sleep} from "../utils.ts"; +import type {ObserverInput, Variable} from "@/lib/runtime/variable.ts"; + +it("module.derive(overrides, module) injects variables into a copied module", async () => { + const runtime = new Runtime(); + const module0 = runtime.module(); + const a0 = module0.variable(true).define("a", [], () => 1); + const b0 = module0.variable(true).define("b", [], () => 2); + const c0 = module0.variable(true).define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + const module1 = runtime.module(); + const module1_0 = module0.derive([{name: "d", alias: "b"}], module1); + const c1 = module1_0.variable(true).define(null, ["c"], (c: unknown) => c); + const d1 = module1.define("d", [], () => 42); + assert.deepStrictEqual(await valueof(a0), {value: 1}); + assert.deepStrictEqual(await valueof(b0), {value: 2}); + assert.deepStrictEqual(await valueof(c0), {value: 3}); + assert.deepStrictEqual(await valueof(c1), {value: 43}); + assert.deepStrictEqual(await valueof(d1), {value: 42}); +}); + +it("module.derive(…) copies module-specific builtins", async () => { + const runtime = new Runtime(); + const module0 = runtime.module(); + module0.builtin("a", 1); + const b0 = module0.variable(true).define("b", ["a"], (a: unknown) => (a as number) + 1); + const module1_0 = module0.derive([], module0); + const c1 = module1_0.variable(true).define("c", ["a"], (a: unknown) => (a as number) + 2); + assert.deepStrictEqual(await valueof(b0), {value: 2}); + assert.deepStrictEqual(await valueof(c1), {value: 3}); +}); + +it("module.derive(…) can inject into modules that inject into modules", async () => { + const runtime = new Runtime(); + + // Module A + // a = 1 + // b = 2 + // c = a + b + const A = runtime.module(); + A.define("a", 1); + A.define("b", 2); + A.define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + + // Module B + // d = 3 + // import {c as e} with {d as b} from "A" + const B = runtime.module(); + B.define("d", 3); + const BA = A.derive([{name: "d", alias: "b"}], B); + B.import("c", "e", BA); + + // Module C + // f = 4 + // import {e as g} with {f as d} from "B" + const C = runtime.module(); + C.define("f", 4); + const CB = B.derive([{name: "f", alias: "d"}], C); + const g = C.variable(true).import("e", "g", CB); + + assert.deepStrictEqual(await valueof(g), {value: 5}); + assert.strictEqual(g.module, C); + assert.strictEqual(g.inputs[0].module, CB); + assert.strictEqual(g.inputs[0].inputs[0].module.source, BA); + assert.strictEqual(C.source, null); + assert.strictEqual(CB.source, B); + assert.strictEqual(BA.source, A); +}); + +it("module.derive(…) can inject into modules that inject into modules that inject into modules", async () => { + const runtime = new Runtime(); + + // Module A + // a = 1 + // b = 2 + // c = a + b + const A = runtime.module(); + A.define("a", 1); + A.define("b", 2); + A.define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + + // Module B + // d = 3 + // import {c as e} with {d as b} from "A" + const B = runtime.module(); + B.define("d", 3); + const BA = A.derive([{name: "d", alias: "b"}], B); + B.import("c", "e", BA); + + // Module C + // f = 4 + // import {e as g} with {f as d} from "B" + const C = runtime.module(); + C.define("f", 4); + const CB = B.derive([{name: "f", alias: "d"}], C); + C.import("e", "g", CB); + + // Module D + // h = 5 + // import {g as i} with {h as f} from "C" + const D = runtime.module(); + D.define("h", 5); + const DC = C.derive([{name: "h", alias: "f"}], D); + const i = D.variable(true).import("g", "i", DC); + + assert.deepStrictEqual(await valueof(i), {value: 6}); + assert.strictEqual(i.module, D); + assert.strictEqual(i.inputs[0].module, DC); + assert.strictEqual(i.inputs[0].module.source, C); + assert.strictEqual(i.inputs[0].inputs[0].module.source, CB); + assert.strictEqual(i.inputs[0].inputs[0].module.source.source, B); +}); + +it("module.derive(…) does not copy non-injected modules", async () => { + const runtime = new Runtime(); + + // Module A + // a = 1 + // b = 2 + // c = a + b + const A = runtime.module(); + A.define("a", 1); + A.define("b", 2); + A.define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + + // Module B + // import {c as e} from "A" + const B = runtime.module(); + B.import("c", "e", A); + + // Module C + // f = 4 + // import {e as g} with {f as d} from "B" + const C = runtime.module(); + C.define("f", 4); + const CB = B.derive([{name: "f", alias: "d"}], C); + const g = C.variable(true).import("e", "g", CB); + + assert.deepStrictEqual(await valueof(g), {value: 3}); + assert.strictEqual(g.module, C); + assert.strictEqual(g.inputs[0].module, CB); + assert.strictEqual(g.inputs[0].inputs[0].module, A); +}); + +it("module.derive(…) does not copy non-injected modules, again", async () => { + const runtime = new Runtime(); + const A = runtime.module(); + A.define("a", () => ({})); + const B = runtime.module(); + B.import("a", A); + const C = runtime.module(); + const CB = B.derive([], C); + const a1 = C.variable(true).import("a", "a1", CB); + const a2 = C.variable(true).import("a", "a2", A); + const {value: v1} = await valueof(a1); + const {value: v2} = await valueof(a2); + assert.deepStrictEqual(v1, {}); + assert.strictEqual(v1, v2); +}); + +it("module.derive() supports lazy import-with", async () => { + let resolve2: ((value: ModuleDefinition) => void) | undefined; + const promise2: Promise = new Promise((resolve) => (resolve2 = resolve)); + + function define1(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.define("module 1", ["@variable"], async (v: Variable) => + runtime.module(await promise2).derive([{name: "b"}], v.module), + ); + main.define("c", ["module 1", "@variable"], (_: unknown, v: unknown) => + (v as {import: (name: string, module: unknown) => unknown}).import("c", _), + ); + main.variable(observer("b")).define("b", [], () => 3); + main.variable(observer("imported c")).define("imported c", ["c"], (c: unknown) => c); + return main; + } + + function define2(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.variable(observer("a")).define("a", [], () => 1); + main.variable(observer("b")).define("b", [], () => 2); + main.variable(observer("c")).define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + return main; + } + + const runtime = new Runtime(); + const inspectorC = promiseInspector(); + runtime.module(define1, (name?: string) => { + if (name === "imported c") { + return inspectorC; + } + }); + + await sleep(); + resolve2!(define2); + assert.deepStrictEqual(await inspectorC, 4); +}); + +it("module.derive() supports lazy transitive import-with", async () => { + let resolve2: ((value: ModuleDefinition) => void) | undefined; + const promise2 = new Promise((resolve) => (resolve2 = resolve)); + let resolve3: ((value: ModuleDefinition) => void) | undefined; + const promise3 = new Promise((resolve) => (resolve3 = resolve)); + let module2_1: Module; + let module3_2: Module; + let variableC_1: Variable; + + // Module 1 + // b = 4 + // imported c = c + // import {c} with {b} from "2" + function define1(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.define( + "module 2", + ["@variable"], + async (v: Variable) => (module2_1 = runtime.module(await promise2).derive([{name: "b"}], v.module)), + ); + variableC_1 = main.define("c", ["module 2", "@variable"], (_: Module, v: Variable) => v.import("c", _)); + main.variable(observer("b")).define("b", [], () => 4); + main.variable(observer("imported c")).define("imported c", ["c"], (c: unknown) => c); + return main; + } + + // Module 2 + // b = 3 + // c + // import {c} with {b} from "3" + function define2(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.define( + "module 3", + ["@variable"], + async (v: Variable) => (module3_2 = runtime.module(await promise3).derive([{name: "b"}], v.module)), + ); + main.define("c", ["module 3", "@variable"], (_: unknown, v: unknown) => + (v as {import: (name: string, module: unknown) => unknown}).import("c", _), + ); + main.variable(observer("b")).define("b", [], () => 3); + main.variable(observer()).define(["c"], (c: unknown) => c); + return main; + } + + // Module 3 + // a = 1 + // b = 2 + // c = a + b + function define3(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.variable(observer("a")).define("a", [], () => 1); + main.variable(observer("b")).define("b", [], () => 2); + main.variable(observer("c")).define("c", ["a", "b"], (a: unknown, b: unknown) => (a as number) + (b as number)); + return main; + } + + const runtime = new Runtime(); + const inspectorC = promiseInspector(); + runtime.module(define1, (name?: string) => { + if (name === "imported c") { + return inspectorC; + } + }); + + // Initially c in module 1 is not an import; it's a placeholder that depends + // on an internal variable called "module 2". Also, only one module yet + // exists, because module 2 has not yet loaded. + await sleep(); + const module1 = runtime.module(define1); + const c1 = module1.scope.get("c"); + assert.strictEqual(c1, variableC_1!); + assert.deepStrictEqual( + c1.inputs.map((i) => i.name), + ["module 2", "@variable"], + ); + assert.strictEqual(Reflect.get(runtime, "_modules").size, 1); + + // After module 2 loads, the variable c in module 1 has been redefined; it is + // now an import of c from a derived copy of module 2, module 2'. In addition, + // the variable b in module 2' is now an import from module 1. + resolve2!(define2); + await sleep(); + const module2 = runtime.module(define2); + assert.deepStrictEqual( + c1.inputs.map((i) => i.name), + ["c"], + ); + assert.strictEqual(c1.definition, identity); + assert.strictEqual(c1.inputs[0].module, module2_1!); + assert.strictEqual(module2_1!.source, module2); + assert.strictEqual(Reflect.get(runtime, "_modules").size, 2); + const b2_1 = module2_1!.scope.get("b"); + assert.deepStrictEqual( + b2_1!.inputs.map((i) => i.name), + ["b"], + ); + assert.deepStrictEqual(b2_1!.definition, identity); + assert.deepStrictEqual(b2_1!.inputs[0].module, module1); + + // After module 3 loads, the variable c in module 2' has been redefined; it is + // now an import of c from a derived copy of module 3, module 3'. In addition, + // the variable b in module 3' is now an import from module 2'. + resolve3!(define3); + await sleep(); + const module3 = runtime.module(define3); + const c2_1 = module2_1!.scope.get("c"); + assert.strictEqual(c2_1!.module, module2_1!); + assert.strictEqual(c2_1!.definition, identity); + assert.strictEqual(c2_1!.inputs[0].module, module3_2!); + assert.strictEqual(module3_2!.source, module3); + const b3_2 = module3_2!.scope.get("b"); + assert.deepStrictEqual( + b3_2!.inputs.map((i) => i.name), + ["b"], + ); + assert.deepStrictEqual(b3_2!.definition, identity); + assert.deepStrictEqual(b3_2!.inputs[0].module, module2_1!); + assert.deepStrictEqual(await inspectorC, 5); +}); diff --git a/test/runtime/variable/import.spec.ts b/test/runtime/variable/import.spec.ts new file mode 100644 index 0000000..bbd1348 --- /dev/null +++ b/test/runtime/variable/import.spec.ts @@ -0,0 +1,245 @@ +import {Runtime, type Module, type ModuleDefinition, type Variable} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof, promiseInspector, sleep} from "../utils.ts"; +import type {ObserverInput} from "@/lib/runtime/variable.ts"; + +it("variable.import(name, module) imports a variable from another module", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + module.define("foo", [], () => 42); + main.import("foo", module); + const bar = main.variable(true).define("bar", ["foo"], (foo: unknown) => `bar-${foo}`); + assert.deepStrictEqual(await valueof(bar), {value: "bar-42"}); +}); + +it("variable.import(name, alias, module) imports a variable from another module under an alias", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + module.define("foo", [], () => 42); + main.import("foo", "baz", module); + const bar = main.variable(true).define("bar", ["baz"], (baz: unknown) => `bar-${baz}`); + assert.deepStrictEqual(await valueof(bar), {value: "bar-42"}); +}); + +it("variable.import(name, module) does not compute the imported variable unless referenced", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + const foo = module.define("foo", [], () => assert.fail()); + main.import("foo", module); + await Reflect.get(runtime, "_computing"); + assert.strictEqual(foo.reachable, false); +}); + +it("variable.import(name, module) can import a variable that depends on a mutable from another module", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + const module = runtime.module(); + module.define("mutable foo", [], () => 13); + module.define("bar", ["mutable foo"], (foo: unknown) => foo); + main.import("bar", module); + const baz = main.variable(true).define("baz", ["bar"], (bar: unknown) => `baz-${bar}`); + assert.deepStrictEqual(await valueof(baz), {value: "baz-13"}); +}); + +it("variable.import() allows non-circular imported values from circular imports", async () => { + const runtime = new Runtime(); + const a = runtime.module(); + const b = runtime.module(); + a.define("foo", [], () => "foo"); + b.define("bar", [], () => "bar"); + a.import("bar", b); + b.import("foo", a); + const afoobar = a.variable(true).define("foobar", ["foo", "bar"], (foo: unknown, bar: unknown) => "a" + foo + bar); + const bfoobar = b.variable(true).define("foobar", ["foo", "bar"], (foo: unknown, bar: unknown) => "b" + foo + bar); + assert.deepStrictEqual(await valueof(afoobar), {value: "afoobar"}); + assert.deepStrictEqual(await valueof(bfoobar), {value: "bfoobar"}); +}); + +it("variable.import() fails when importing creates a circular reference", async () => { + const runtime = new Runtime(); + const a = runtime.module(); + const b = runtime.module(); + a.import("bar", b); + a.define("foo", ["bar"], (bar: unknown) => `foo${bar}`); + b.import("foo", a); + b.define("bar", ["foo"], (foo: unknown) => `${foo}bar`); + const afoobar = a.variable(true).define("foobar", ["foo", "bar"], (foo: unknown, bar: unknown) => "a" + foo + bar); + const bbarfoo = b.variable(true).define("barfoo", ["bar", "foo"], (bar: unknown, foo: unknown) => "b" + bar + foo); + assert.deepStrictEqual(await valueof(afoobar), {error: "RuntimeError: circular definition"}); + assert.deepStrictEqual(await valueof(bbarfoo), {error: "RuntimeError: circular definition"}); +}); + +it("variable.import() allows direct circular import-with if the resulting variables are not circular", async () => { + const runtime = new Runtime(); + let a1: Variable, b1: Variable, a2: Variable, b2: Variable; + + // Module 1 + // a = 1 + // b + // import {b} with {a} from "2" + function define1() { + const main = runtime.module(); + a1 = main.variable(true).define("a", () => 1); + b1 = main.variable(true).define(["b"], (b: unknown) => b); + const child1 = runtime.module(define2).derive(["a"], main); + main.import("b", child1); + return main; + } + + // Module 2 + // b = 2 + // a + // import {a} with {b} from "1" + function define2() { + const main = runtime.module(); + b2 = main.variable(true).define("b", () => 2); + a2 = main.variable(true).define(["a"], (a: unknown) => a); + const child1 = runtime.module(define1).derive(["b"], main); + main.import("a", child1); + return main; + } + + define1(); + + assert.deepStrictEqual(await valueof(a1!), {value: 1}); + assert.deepStrictEqual(await valueof(b1!), {value: 2}); + assert.deepStrictEqual(await valueof(a2!), {value: 1}); + assert.deepStrictEqual(await valueof(b2!), {value: 2}); +}); + +it("variable.import() allows indirect circular import-with if the resulting variables are not circular", async () => { + const runtime = new Runtime(); + let a: Variable, b: Variable, c: Variable, importA: Variable, importB: Variable, importC: Variable; + + // Module 1 + // a = 1 + // c + // import {c} with {a} from "3" + function define1() { + const main = runtime.module(); + a = main.variable(true).define("a", () => 1); + importC = main.variable(true).define(["c"], (c: unknown) => c); + const child3 = runtime.module(define3).derive(["a"], main); + main.import("c", child3); + return main; + } + + // Module 2 + // b = 2 + // a + // import {a} with {b} from "1" + function define2() { + const main = runtime.module(); + b = main.variable(true).define("b", () => 2); + importA = main.variable(true).define(["a"], (a: unknown) => a); + const child1 = runtime.module(define1).derive(["b"], main); + main.import("a", child1); + return main; + } + + // Module 3 + // c = 3 + // b + // import {b} with {c} from "2" + function define3() { + const main = runtime.module(); + c = main.variable(true).define("c", () => 3); + importB = main.variable(true).define(["b"], (b: unknown) => b); + const child2 = runtime.module(define2).derive(["c"], main); + main.import("b", child2); + return main; + } + + define1(); + + assert.deepStrictEqual(await valueof(a!), {value: 1}); + assert.deepStrictEqual(await valueof(b!), {value: 2}); + assert.deepStrictEqual(await valueof(c!), {value: 3}); + assert.deepStrictEqual(await valueof(importA!), {value: 1}); + assert.deepStrictEqual(await valueof(importB!), {value: 2}); + assert.deepStrictEqual(await valueof(importC!), {value: 3}); +}); + +it("variable.import() supports lazy imports", async () => { + let resolve2: (value: ModuleDefinition) => void; + const promise2 = new Promise((resolve) => (resolve2 = resolve)); + + function define1(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.define("module 1", async () => runtime.module(await promise2)); + main.define("a", ["module 1", "@variable"], (_: unknown, v: unknown) => + (v as {import: (name: string, module: unknown) => unknown}).import("a", _), + ); + main.variable(observer("imported a")).define("imported a", ["a"], (a: unknown) => a); + return main; + } + + function define2(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.variable(observer("a")).define("a", [], () => 1); + return main; + } + + const runtime = new Runtime(); + const inspectorA = promiseInspector(); + runtime.module(define1, (name?: string) => { + if (name === "imported a") { + return inspectorA; + } + }); + + await sleep(); + resolve2!(define2); + assert.deepStrictEqual(await inspectorA, 1); +}); + +it("variable.import() supports lazy transitive imports", async () => { + let resolve2: (value: ModuleDefinition) => void; + const promise2 = new Promise((resolve) => (resolve2 = resolve)); + let resolve3: (value: ModuleDefinition) => void; + const promise3 = new Promise((resolve) => (resolve3 = resolve)); + + function define1(runtime: Runtime, observer: (name?: string) => ObserverInput): Module { + const main = runtime.module(); + main.define("module 1", async () => runtime.module(await promise2)); + main.define("b", ["module 1", "@variable"], (_: unknown, v: unknown) => + (v as {import: (name: string, module: unknown) => unknown}).import("b", _), + ); + main.variable(observer("a")).define("a", ["b"], (b: unknown) => (b as number) + 1); + return main; + } + + function define2(runtime: Runtime, observer: (name?: string) => ObserverInput): Module { + const main = runtime.module(); + main.define("module 1", async () => runtime.module(await promise3)); + main.define("c", ["module 1", "@variable"], (_: unknown, v: unknown) => + (v as {import: (name: string, module: unknown) => unknown}).import("c", _), + ); + main.variable(observer("b")).define("b", ["c"], (c: unknown) => (c as number) + 1); + return main; + } + + function define3(runtime: Runtime, observer: (name?: string) => ObserverInput) { + const main = runtime.module(); + main.variable(observer("c")).define("c", [], () => 1); + return main; + } + + const runtime = new Runtime(); + const inspectorA = promiseInspector(); + runtime.module(define1, (name?: string): ObserverInput => { + if (name === "a") { + return inspectorA; + } + }); + + await sleep(); + resolve2!(define2); + await sleep(); + resolve3!(define3); + assert.deepStrictEqual(await inspectorA, 3); +}); diff --git a/test/runtime/variable/shadow.spec.ts b/test/runtime/variable/shadow.spec.ts new file mode 100644 index 0000000..f496a43 --- /dev/null +++ b/test/runtime/variable/shadow.spec.ts @@ -0,0 +1,65 @@ +import {Runtime} from "@/lib/runtime/index.ts"; +import assert from "assert"; +import {it} from "vitest"; +import {valueof} from "../utils.ts"; + +it("module.variable(…, {shadow}) can define a shadow input", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + + module.define("val", [], 1000); + const a = module.variable(true, {shadow: {val: 100}}).define("a", ["val"], (val: unknown) => val); + + assert.deepStrictEqual(await valueof(a), {value: 100}); +}); + +it("module.variable(…, {shadow}) can define shadow inputs that differ between variables", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + + module.define("val", [], 1000); + const a = module.variable(true, {shadow: {val: 100}}).define("a", ["val"], (val: unknown) => val); + const b = module.variable(true, {shadow: {val: 200}}).define("b", ["val"], (val: unknown) => val); + assert.deepStrictEqual(await valueof(a), {value: 100}); + assert.deepStrictEqual(await valueof(b), {value: 200}); +}); + +it("module.variable(…, {shadow}) variables that are downstream will also use the shadow input", async () => { + const runtime = new Runtime(); + const module = runtime.module(); + + module.define("val", [], 1000); + const a = module.variable(true, {shadow: {val: 100}}).define("a", ["val"], (val: unknown) => val); + const b = module.variable(true, {shadow: {val: 200}}).define("b", ["val"], (val: unknown) => val); + const c = module.variable(true).define("c", ["a", "b"], (a: unknown, b: unknown) => `${a}, ${b}`); + + assert.deepStrictEqual(await valueof(a), {value: 100}); + assert.deepStrictEqual(await valueof(b), {value: 200}); + assert.deepStrictEqual(await valueof(c), {value: "100, 200"}); +}); + +it("module.variable(…, {shadow}) variables a->b->c that shadow the same inputs will each use their own shadows", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + + const a = main.variable(true, {shadow: {val: 100}}).define("a", ["val"], (val: unknown) => val); + const b = main + .variable(true, {shadow: {val: 200}}) + .define("b", ["val", "a"], (val: unknown, a: unknown) => `${val}, ${a}`); + const c = main + .variable(true, {shadow: {val: 300}}) + .define("c", ["val", "b", "a"], (val: unknown, b: unknown, a: unknown) => `${val}, (${b}), ${a}`); + + assert.deepStrictEqual(await valueof(a), {value: 100}); + assert.deepStrictEqual(await valueof(b), {value: "200, 100"}); + assert.deepStrictEqual(await valueof(c), {value: "300, (200, 100), 100"}); +}); + +it("module.variable(…, {shadow}) can shadow a non-existent variable", async () => { + const runtime = new Runtime(); + const main = runtime.module(); + + const a = main.variable(true, {shadow: {val: 100}}).define("a", ["val"], (val: unknown) => val); + + assert.deepStrictEqual(await valueof(a), {value: 100}); +}); diff --git a/tsconfig.json b/tsconfig.json index 56f3c74..b9d3e17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,13 +43,13 @@ "resolveJsonModule": true, "plugins": [ { - "name": "next", - }, + "name": "next" + } ], "paths": { - "@/*": ["./*"], - }, + "@/*": ["./*"] + } }, "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], + "exclude": ["node_modules"] } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..af34dce --- /dev/null +++ b/vite.config.js @@ -0,0 +1,14 @@ +import {defineConfig} from "vite"; +import {fileURLToPath} from "url"; +import {dirname} from "path"; + +export default defineConfig({ + test: { + environment: "jsdom", + }, + resolve: { + alias: { + "@": dirname(fileURLToPath(import.meta.url)), + }, + }, +}); From 926a94f5a3e75e79a5a3fd46c127c8e1d9ce7093 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:32:34 +0800 Subject: [PATCH 5/9] Revert file `cn.js` --- app/cn.js | 6 ++++++ app/cn.ts | 6 ------ test/playground/components/Remote.tsx | 2 +- test/playground/components/TransactionItem.tsx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 app/cn.js delete mode 100644 app/cn.ts diff --git a/app/cn.js b/app/cn.js new file mode 100644 index 0000000..219465a --- /dev/null +++ b/app/cn.js @@ -0,0 +1,6 @@ +import {clsx} from "clsx"; +import {twMerge} from "tailwind-merge"; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/app/cn.ts b/app/cn.ts deleted file mode 100644 index a50f380..0000000 --- a/app/cn.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {clsx, type ClassValue} from "clsx"; -import {twMerge} from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/test/playground/components/Remote.tsx b/test/playground/components/Remote.tsx index fa9bb3a..0637fd1 100644 --- a/test/playground/components/Remote.tsx +++ b/test/playground/components/Remote.tsx @@ -1,5 +1,5 @@ import {BadgeQuestionMarkIcon, HashIcon, RefreshCcw, type LucideIcon} from "lucide-react"; -import {cn} from "@/app/cn.ts"; +import {cn} from "@/app/cn.js"; export function Remote({remoteValue}: {remoteValue: unknown}) { let Icon: LucideIcon; diff --git a/test/playground/components/TransactionItem.tsx b/test/playground/components/TransactionItem.tsx index 7a96e27..4f62d70 100644 --- a/test/playground/components/TransactionItem.tsx +++ b/test/playground/components/TransactionItem.tsx @@ -1,7 +1,7 @@ import {useState, type ReactNode} from "react"; import type {TransactionData} from "./transaction-data.ts"; import {ObjectInspector} from "react-inspector"; -import {cn} from "@/app/cn.ts"; +import {cn} from "@/app/cn.js"; import {UserEvent} from "./UserEvent.tsx"; import {PencilLineIcon} from "lucide-react"; import {Remote} from "./Remote.tsx"; From 243f1e9c435e0b6d346a0824324b74ac93e2601c Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:46:05 +0800 Subject: [PATCH 6/9] Increase/decrease indegree in a less confusing way --- lib/runtime/runtime.ts | 2 +- lib/runtime/variable.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/runtime/runtime.ts b/lib/runtime/runtime.ts index bb912da..172e636 100644 --- a/lib/runtime/runtime.ts +++ b/lib/runtime/runtime.ts @@ -207,7 +207,7 @@ export class Runtime { } while (updates.size); function postqueue(variable: Variable): void { - variable.indegree = --variable.indegree; + variable.decrementIndegree(); if (variable.indegree === 0) { queue.push(variable); } diff --git a/lib/runtime/variable.ts b/lib/runtime/variable.ts index 13ced74..1597a88 100644 --- a/lib/runtime/variable.ts +++ b/lib/runtime/variable.ts @@ -543,7 +543,7 @@ export class Variable { * Used during topological sorting to track how many dependencies need to resolve. */ incrementIndegree(): void { - this._indegree = ++this._indegree; + this._indegree += 1; } /** @@ -552,7 +552,7 @@ export class Variable { * Used during topological sorting to track when all dependencies have resolved. */ decrementIndegree(): void { - this._indegree = --this._indegree; + this._indegree -= 1; } /** From 827bb9eaa4fdb0288e5122774599f01a3c672b3f Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:59:07 +0800 Subject: [PATCH 7/9] Remove invalid assertions --- test/runtime/module/redefine.spec.ts | 3 --- test/runtime/variable/define.spec.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/test/runtime/module/redefine.spec.ts b/test/runtime/module/redefine.spec.ts index 53645bc..e3bc4cb 100644 --- a/test/runtime/module/redefine.spec.ts +++ b/test/runtime/module/redefine.spec.ts @@ -38,7 +38,4 @@ it("module.redefine(name, inputs, definition) throws an error if the specified v // eslint-disable-next-line @typescript-eslint/no-unused-vars const foo = module.variable(true).define("foo", [], () => 42); assert.throws(() => module.redefine("bar", [], () => 43), /bar is not defined/); - // Note: The following line is the original test. But there is an additional - // argument 43. - // assert.throws(() => module.redefine("bar", [], () => 43, foo), /bar is not defined/); }); diff --git a/test/runtime/variable/define.spec.ts b/test/runtime/variable/define.spec.ts index a386f17..c806a1c 100644 --- a/test/runtime/variable/define.spec.ts +++ b/test/runtime/variable/define.spec.ts @@ -183,9 +183,6 @@ it("variable.define terminates previously reachable generators", async () => { assert.deepStrictEqual(await valueof(foo), {value: 1}); foo.define("foo", [], () => "foo"); assert.deepStrictEqual(await valueof(foo), {value: "foo"}); - // Note: The property `_generator` has never appeared in the original - // implementation. I guess it's not necessary to check it. - // assert.strictEqual(bar._generator, undefined); assert.strictEqual(returned, true); }); From fc5d772e9d4d78a9a30a522647cb58995b41d12e Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 5 Jan 2026 09:59:47 +0800 Subject: [PATCH 8/9] Add exceptions for unused variables --- eslint.config.mjs | 13 +++++++++++++ test/runtime/module/redefine.spec.ts | 3 +-- test/runtime/variable/define.spec.ts | 3 +-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f4a2295..cbaad20 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,6 +46,19 @@ export default defineConfig([ ...config, files: ["editor/**/*.{ts,tsx}", "runtime/**/*.ts", "test/**/*.{ts,tsx}", "app/**/*.{ts,tsx}", "lib/**/*.ts"], })), + { + files: ["editor/**/*.{ts,tsx}", "runtime/**/*.ts", "test/**/*.{ts,tsx}", "app/**/*.{ts,tsx}", "lib/**/*.ts"], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + }, + }, { ignores: ["**/*.recho.js", "test/output/**/*"], }, diff --git a/test/runtime/module/redefine.spec.ts b/test/runtime/module/redefine.spec.ts index e3bc4cb..30958c2 100644 --- a/test/runtime/module/redefine.spec.ts +++ b/test/runtime/module/redefine.spec.ts @@ -35,7 +35,6 @@ it("module.redefine(name, inputs, definition) can’t redefine a duplicate defin it("module.redefine(name, inputs, definition) throws an error if the specified variable doesn’t exist", async () => { const runtime = new Runtime(); const module = runtime.module(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const foo = module.variable(true).define("foo", [], () => 42); + const _foo = module.variable(true).define("foo", [], () => 42); assert.throws(() => module.redefine("bar", [], () => 43), /bar is not defined/); }); diff --git a/test/runtime/variable/define.spec.ts b/test/runtime/variable/define.spec.ts index c806a1c..9b24f2a 100644 --- a/test/runtime/variable/define.spec.ts +++ b/test/runtime/variable/define.spec.ts @@ -170,8 +170,7 @@ it("variable.define terminates previously reachable generators", async () => { const runtime = new Runtime(); const main = runtime.module(); const module = runtime.module(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const bar = module.define("bar", [], function* () { + const _bar = module.define("bar", [], function* () { try { while (true) yield 1; } finally { From df9e729b6cc3c9307570dbb4e34f476834c29d21 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:20:14 +0800 Subject: [PATCH 9/9] Update license and add copyright notice to vendored files --- LICENCE | 21 ++++++++++++++++++++- lib/runtime/errors.ts | 6 ++++++ lib/runtime/index.ts | 6 ++++++ lib/runtime/module.ts | 6 ++++++ lib/runtime/runtime.ts | 6 ++++++ lib/runtime/utils.ts | 6 ++++++ lib/runtime/variable.ts | 6 ++++++ test/runtime/module/builtin.spec.ts | 6 ++++++ test/runtime/module/redefine.spec.ts | 6 ++++++ test/runtime/module/value.spec.ts | 6 ++++++ test/runtime/runtime/builtins.spec.ts | 6 ++++++ test/runtime/runtime/dispose.spec.ts | 6 ++++++ test/runtime/variable/define.spec.ts | 6 ++++++ test/runtime/variable/delete.spec.ts | 6 ++++++ test/runtime/variable/derive.spec.ts | 6 ++++++ test/runtime/variable/import.spec.ts | 6 ++++++ test/runtime/variable/shadow.spec.ts | 6 ++++++ 17 files changed, 116 insertions(+), 1 deletion(-) diff --git a/LICENCE b/LICENCE index 35fbb02..a6a580b 100644 --- a/LICENCE +++ b/LICENCE @@ -1,6 +1,6 @@ ISC License -Copyright 2025 Recho +Copyright 2025-2026 Recho Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice @@ -32,3 +32,22 @@ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +This software includes a vendored copy of @observablehq/runtime, which is +released under the ISC license. + +Copyright 2018-2024 Observable, Inc. + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/lib/runtime/errors.ts b/lib/runtime/errors.ts index 8a362b2..65b582f 100644 --- a/lib/runtime/errors.ts +++ b/lib/runtime/errors.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + export class RuntimeError extends Error { constructor( message: string, diff --git a/lib/runtime/index.ts b/lib/runtime/index.ts index dc8e6a7..3bae25c 100644 --- a/lib/runtime/index.ts +++ b/lib/runtime/index.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + export {RuntimeError} from "./errors.ts"; export {Runtime} from "./runtime.ts"; export type {Builtins, GlobalFunction, ModuleDefinition} from "./runtime.ts"; diff --git a/lib/runtime/module.ts b/lib/runtime/module.ts index 9c8b367..aa52a71 100644 --- a/lib/runtime/module.ts +++ b/lib/runtime/module.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {constant, identity, rethrow} from "./utils.ts"; import {RuntimeError} from "./errors.ts"; import { diff --git a/lib/runtime/runtime.ts b/lib/runtime/runtime.ts index 172e636..1b13b08 100644 --- a/lib/runtime/runtime.ts +++ b/lib/runtime/runtime.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {noop, defer, defaultGlobal} from "./utils.ts"; import {RuntimeError} from "./errors.ts"; import {Module} from "./module.ts"; diff --git a/lib/runtime/utils.ts b/lib/runtime/utils.ts index 1fd7a0f..712cd3f 100644 --- a/lib/runtime/utils.ts +++ b/lib/runtime/utils.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + // Array utilities export function map( array: ArrayLike, diff --git a/lib/runtime/variable.ts b/lib/runtime/variable.ts index 1597a88..59ecef5 100644 --- a/lib/runtime/variable.ts +++ b/lib/runtime/variable.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {map, constant, identity, noop, generatorish, generatorReturn} from "./utils.ts"; import {RuntimeError} from "./errors.ts"; import type {Module} from "./module.ts"; diff --git a/test/runtime/module/builtin.spec.ts b/test/runtime/module/builtin.spec.ts index 83c0fc2..deb1059 100644 --- a/test/runtime/module/builtin.spec.ts +++ b/test/runtime/module/builtin.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/module/redefine.spec.ts b/test/runtime/module/redefine.spec.ts index 30958c2..ea39394 100644 --- a/test/runtime/module/redefine.spec.ts +++ b/test/runtime/module/redefine.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/module/value.spec.ts b/test/runtime/module/value.spec.ts index 1cdb35d..fd6e4f6 100644 --- a/test/runtime/module/value.spec.ts +++ b/test/runtime/module/value.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/runtime/builtins.spec.ts b/test/runtime/runtime/builtins.spec.ts index 8c986d5..aa42f91 100644 --- a/test/runtime/runtime/builtins.spec.ts +++ b/test/runtime/runtime/builtins.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/runtime/dispose.spec.ts b/test/runtime/runtime/dispose.spec.ts index c5a41a3..6e6564f 100644 --- a/test/runtime/runtime/dispose.spec.ts +++ b/test/runtime/runtime/dispose.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {describe, it} from "vitest"; diff --git a/test/runtime/variable/define.spec.ts b/test/runtime/variable/define.spec.ts index 9b24f2a..423c13f 100644 --- a/test/runtime/variable/define.spec.ts +++ b/test/runtime/variable/define.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/variable/delete.spec.ts b/test/runtime/variable/delete.spec.ts index e1ce9a2..5ebf922 100644 --- a/test/runtime/variable/delete.spec.ts +++ b/test/runtime/variable/delete.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/variable/derive.spec.ts b/test/runtime/variable/derive.spec.ts index 67e03bb..06b06a1 100644 --- a/test/runtime/variable/derive.spec.ts +++ b/test/runtime/variable/derive.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime, type Module, type ModuleDefinition} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/variable/import.spec.ts b/test/runtime/variable/import.spec.ts index bbd1348..3e4e2fa 100644 --- a/test/runtime/variable/import.spec.ts +++ b/test/runtime/variable/import.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime, type Module, type ModuleDefinition, type Variable} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest"; diff --git a/test/runtime/variable/shadow.spec.ts b/test/runtime/variable/shadow.spec.ts index f496a43..5918443 100644 --- a/test/runtime/variable/shadow.spec.ts +++ b/test/runtime/variable/shadow.spec.ts @@ -1,3 +1,9 @@ +// Originally developed by Observable, Inc. +// Adapted and modified by Recho from @observablehq/runtime v6.0.0 +// Copyright 2018-2024 Observable, Inc. +// Copyright 2025-2026 Recho +// ISC License + import {Runtime} from "@/lib/runtime/index.ts"; import assert from "assert"; import {it} from "vitest";