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/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/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..65b582f --- /dev/null +++ b/lib/runtime/errors.ts @@ -0,0 +1,23 @@ +// 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, + public readonly input?: string, + ) { + super(message); + // Keep the property non-enumerable. + Object.defineProperties(this, { + name: { + value: "RuntimeError", + enumerable: false, + writable: true, + configurable: true, + }, + }); + } +} diff --git a/lib/runtime/index.ts b/lib/runtime/index.ts new file mode 100644 index 0000000..3bae25c --- /dev/null +++ b/lib/runtime/index.ts @@ -0,0 +1,11 @@ +// 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"; +export type {Module, InjectSpecifier, InjectInput} from "./module.ts"; +export type {Variable, Observer, VariableOptions, ObserverInput, VariableDefinition} from "./variable.ts"; diff --git a/lib/runtime/module.ts b/lib/runtime/module.ts new file mode 100644 index 0000000..aa52a71 --- /dev/null +++ b/lib/runtime/module.ts @@ -0,0 +1,269 @@ +// 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 { + Variable, + no_observer, + variable_stale, + type ObserverInput, + type VariableDefineParameters, + 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(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 as Parameters)); + } + + /** + * 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(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 as Parameters)); + } + + /** + * 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?: ObserverInput, 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..1b13b08 --- /dev/null +++ b/lib/runtime/runtime.ts @@ -0,0 +1,275 @@ +// 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"; +import {Variable, type ObserverInput} from "./variable.ts"; + +const frame: (callback: (value: unknown) => void) => void = + 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) => ObserverInput) => 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.decrementIndegree(); + 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) => ObserverInput): Module; + module(define?: ModuleDefinition, observer: (name?: string) => ObserverInput = noop as () => undefined): 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..712cd3f --- /dev/null +++ b/lib/runtime/utils.ts @@ -0,0 +1,66 @@ +// 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, + 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..59ecef5 --- /dev/null +++ b/lib/runtime/variable.ts @@ -0,0 +1,802 @@ +// 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"; + +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; +} + +/** + * A default observer that does nothing. + */ +const defaultObserver: Observer = { + pending: noop, + fulfilled: noop, + rejected: noop, +}; + +export interface VariableOptions { + shadow?: Record; +} + +export type ObserverInput = boolean | Observer | typeof no_observer | null | undefined; + +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 | typeof no_observer; + 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?: ObserverInput, options?: VariableOptions) { + if (observer === true) { + observer = defaultObserver; + } else if (!observer) { + observer = no_observer; + } + + this._observer = observer; + 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.pending === "function") { + this._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.fulfilled === "function") { + this._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.rejected === "function") { + this._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 += 1; + } + + /** + * Decrements the indegree counter. + * + * Used during topological sorting to track when all dependencies have resolved. + */ + decrementIndegree(): void { + this._indegree -= 1; + } + + /** + * 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..6275ffe 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,12 @@ ], "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", "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" }, @@ -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/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..0637fd1 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.js"; 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..4f62d70 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.js"; 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/test/playground/vite.config.js b/test/playground/vite.config.js new file mode 100644 index 0000000..25a2fdd --- /dev/null +++ b/test/playground/vite.config.js @@ -0,0 +1,19 @@ +import {defineConfig} from "vite"; +import react from "@vitejs/plugin-react"; +import {fileURLToPath} from "url"; + +export default defineConfig({ + root: "./test/playground", + plugins: [react()], + define: { + "process.env.NODE_ENV": JSON.stringify("test"), + }, + test: { + environment: "jsdom", + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("../../", import.meta.url)), + }, + }, +}); diff --git a/test/runtime/module/builtin.spec.ts b/test/runtime/module/builtin.spec.ts new file mode 100644 index 0000000..deb1059 --- /dev/null +++ b/test/runtime/module/builtin.spec.ts @@ -0,0 +1,30 @@ +// 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"; +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..ea39394 --- /dev/null +++ b/test/runtime/module/redefine.spec.ts @@ -0,0 +1,46 @@ +// 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"; +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(); + const _foo = module.variable(true).define("foo", [], () => 42); + assert.throws(() => module.redefine("bar", [], () => 43), /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..fd6e4f6 --- /dev/null +++ b/test/runtime/module/value.spec.ts @@ -0,0 +1,150 @@ +// 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"; + +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..aa42f91 --- /dev/null +++ b/test/runtime/runtime/builtins.spec.ts @@ -0,0 +1,45 @@ +// 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"; +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..6e6564f --- /dev/null +++ b/test/runtime/runtime/dispose.spec.ts @@ -0,0 +1,42 @@ +// 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"; +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..423c13f --- /dev/null +++ b/test/runtime/variable/define.spec.ts @@ -0,0 +1,685 @@ +// 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"; +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(); + 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"}); + 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..5ebf922 --- /dev/null +++ b/test/runtime/variable/delete.spec.ts @@ -0,0 +1,24 @@ +// 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"; +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..06b06a1 --- /dev/null +++ b/test/runtime/variable/derive.spec.ts @@ -0,0 +1,327 @@ +// 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"; +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..3e4e2fa --- /dev/null +++ b/test/runtime/variable/import.spec.ts @@ -0,0 +1,251 @@ +// 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"; +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..5918443 --- /dev/null +++ b/test/runtime/variable/shadow.spec.ts @@ -0,0 +1,71 @@ +// 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"; +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 6df60f3..b9d3e17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,7 +45,10 @@ { "name": "next" } - ] + ], + "paths": { + "@/*": ["./*"] + } }, "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] 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; - } -} diff --git a/vite.config.js b/vite.config.js index 63a5f88..af34dce 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,13 +1,14 @@ import {defineConfig} from "vite"; -import react from "@vitejs/plugin-react"; +import {fileURLToPath} from "url"; +import {dirname} from "path"; export default defineConfig({ - root: "test", - plugins: [react()], - define: { - "process.env.NODE_ENV": JSON.stringify("test"), - }, test: { environment: "jsdom", }, + resolve: { + alias: { + "@": dirname(fileURLToPath(import.meta.url)), + }, + }, });