From 9e7af60a3a0ccca5117a4f588b7ab1e45ce49651 Mon Sep 17 00:00:00 2001 From: pearmini Date: Fri, 19 Sep 2025 23:25:09 -0400 Subject: [PATCH 1/6] Add mutable --- runtime/index.js | 2 ++ runtime/mutable.js | 33 +++++++++++++++++++++++++++++++++ runtime/observe.js | 36 ++++++++++++++++++++++++++++++++++++ test/js/index.js | 1 + test/js/mutable.js | 17 +++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 runtime/mutable.js create mode 100644 runtime/observe.js create mode 100644 test/js/mutable.js diff --git a/runtime/index.js b/runtime/index.js index 831c1c7..f7c7318 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -6,11 +6,13 @@ import {group} from "d3-array"; import {dispatch as d3Dispatch} from "d3-dispatch"; import * as stdlib from "./stdlib.js"; import {OUTPUT_MARK} from "./constant.js"; +import {Mutable} from "./mutable.js"; const PREFIX = `//${OUTPUT_MARK}`; const BUILTINS = { recho: () => stdlib, + Mutable: () => Mutable, }; function uid() { diff --git a/runtime/mutable.js b/runtime/mutable.js new file mode 100644 index 0000000..b7f4221 --- /dev/null +++ b/runtime/mutable.js @@ -0,0 +1,33 @@ +// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/mutable.ts +import {observe} from "./observe.js"; + +// Mutable returns a generator with a value getter/setting that allows the +// generated value to be mutated. Therefore, direct mutation is only allowed +// within the defining cell, but the cell can also export functions that allows +// other cells to mutate the value as desired. +export function Mutable(value) { + let change = undefined; + const mutable = observe((_) => { + change = _; + if (value !== undefined) change(value); + }); + return Object.defineProperty(mutable, "value", { + get: () => value, + set: (x) => ((value = x), void change?.(value)), + }); +} + +export function Mutator(value) { + const mutable = Mutable(value); + return [ + mutable, + { + get value() { + return mutable.value; + }, + set value(v) { + mutable.value = v; + }, + }, + ]; +} diff --git a/runtime/observe.js b/runtime/observe.js new file mode 100644 index 0000000..5a2f985 --- /dev/null +++ b/runtime/observe.js @@ -0,0 +1,36 @@ +// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/generators/observe.ts + +export async function* observe(initialize) { + let resolve = undefined; + let value = undefined; + let stale = false; + + const dispose = initialize((x) => { + value = x; + if (resolve) { + resolve(x); + resolve = undefined; + } else { + stale = true; + } + return x; + }); + + if (dispose != null && typeof dispose !== "function") { + throw new Error( + typeof dispose === "object" && "then" in dispose && typeof dispose.then === "function" + ? "async initializers are not supported" + : "initializer returned something, but not a dispose function", + ); + } + + try { + while (true) { + yield stale ? ((stale = false), value) : new Promise((_) => (resolve = _)); + } + } finally { + if (dispose != null) { + dispose(); + } + } +} diff --git a/test/js/index.js b/test/js/index.js index 69a728f..1fd9be5 100644 --- a/test/js/index.js +++ b/test/js/index.js @@ -14,3 +14,4 @@ export {mandelbrotSet} from "./mandelbrot-set.js"; export {matrixRain} from "./matrix-rain.js"; export {jsDocString} from "./js-doc-string.js"; export {commentLink} from "./comment-link.js"; +export {mutable} from "./mutable.js"; diff --git a/test/js/mutable.js b/test/js/mutable.js new file mode 100644 index 0000000..84c89d7 --- /dev/null +++ b/test/js/mutable.js @@ -0,0 +1,17 @@ +export const mutable = `const {a, getA, setA} = (() => { + const a = Mutable(0); + return {a, getA: () => a.value, setA: (value) => a.value = value}; +})(); + +setA(a + 1); + +echo(a);`; + +// export const mutable = `const {a, getA, setA} = (() => { +// const a = Mutable(0); +// return {a, getA: () => a.value, setA: (value) => a.value = value}; +// })(); + +// setA(getA() + 1); + +// echo(a);`; From 7c1148fa8b9df9342fcaabe5f657cdafa5de1f3f Mon Sep 17 00:00:00 2001 From: pearmini Date: Sat, 20 Sep 2025 09:26:01 -0400 Subject: [PATCH 2/6] Expose __Mutator__ --- runtime/index.js | 4 ++-- runtime/{mutable.js => mutator.js} | 2 +- test/js/mutable.js | 30 +++++++++++++----------------- test/main.js | 22 ---------------------- 4 files changed, 16 insertions(+), 42 deletions(-) rename runtime/{mutable.js => mutator.js} (96%) diff --git a/runtime/index.js b/runtime/index.js index f7c7318..7b1903c 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -6,13 +6,13 @@ import {group} from "d3-array"; import {dispatch as d3Dispatch} from "d3-dispatch"; import * as stdlib from "./stdlib.js"; import {OUTPUT_MARK} from "./constant.js"; -import {Mutable} from "./mutable.js"; +import {Mutator} from "./mutator.js"; const PREFIX = `//${OUTPUT_MARK}`; const BUILTINS = { recho: () => stdlib, - Mutable: () => Mutable, + __Mutator__: () => Mutator, }; function uid() { diff --git a/runtime/mutable.js b/runtime/mutator.js similarity index 96% rename from runtime/mutable.js rename to runtime/mutator.js index b7f4221..ac8091b 100644 --- a/runtime/mutable.js +++ b/runtime/mutator.js @@ -5,7 +5,7 @@ import {observe} from "./observe.js"; // generated value to be mutated. Therefore, direct mutation is only allowed // within the defining cell, but the cell can also export functions that allows // other cells to mutate the value as desired. -export function Mutable(value) { +function Mutable(value) { let change = undefined; const mutable = observe((_) => { change = _; diff --git a/test/js/mutable.js b/test/js/mutable.js index 84c89d7..ce51cba 100644 --- a/test/js/mutable.js +++ b/test/js/mutable.js @@ -1,17 +1,13 @@ -export const mutable = `const {a, getA, setA} = (() => { - const a = Mutable(0); - return {a, getA: () => a.value, setA: (value) => a.value = value}; -})(); - -setA(a + 1); - -echo(a);`; - -// export const mutable = `const {a, getA, setA} = (() => { -// const a = Mutable(0); -// return {a, getA: () => a.value, setA: (value) => a.value = value}; -// })(); - -// setA(getA() + 1); - -// echo(a);`; +export const mutable = `const [a, mutator$$a] = __Mutator__(0); + +new Promise((resolve) => { + setTimeout(() => { + for (let i = 0; i < 10; i++) { + mutator$$a.value += 1; + } + }, 1000); +}); + +{ + echo(a); +}`; diff --git a/test/main.js b/test/main.js index 5b46ec3..f0b60cf 100644 --- a/test/main.js +++ b/test/main.js @@ -48,28 +48,6 @@ function createSelect(onchange) { select.style.height = "20px"; select.style.marginBottom = "10px"; select.onchange = onchange; - document.onkeydown = (event) => { - if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } - switch (event.key) { - case "ArrowLeft": { - if (select.selectedIndex > 0) { - select.selectedIndex--; - select.onchange(); - } else alert("This is the first test case."); - break; - } - case "ArrowRight": { - if (select.selectedIndex < select.options.length - 1) { - select.selectedIndex++; - select.onchange(); - } else alert("This is the last test case."); - break; - } - } - }; - return select; } From dcd1c0b9edd791feac33e612625cd1667ece23e4 Mon Sep 17 00:00:00 2001 From: pearmini Date: Sat, 20 Sep 2025 10:29:48 -0400 Subject: [PATCH 3/6] Add Observable Notebook Kit attribution --- LICENCE | 21 ++++++++++++++++++++- runtime/index.js | 3 ++- runtime/mutator.js | 3 ++- runtime/observe.js | 1 + 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/LICENCE b/LICENCE index c4cdc4f..35fbb02 100644 --- a/LICENCE +++ b/LICENCE @@ -12,4 +12,23 @@ 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. \ No newline at end of file +THIS SOFTWARE. + +--- + +Portions of this software are derived from Observable Notebook Kit, which is +released under the ISC license. + +Copyright 2025 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/runtime/index.js b/runtime/index.js index 7b1903c..fce1cef 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -250,7 +250,8 @@ export function createRuntime(initialCode) { for (const variable of variables) variable.delete(); } - // @ref https://github.com/observablehq/notebook-kit/blob/02914e034fd21a50ebcdca08df57ef5773864125/src/runtime/define.ts#L33 + // Derived from Observable Notebook Kit's define. + // https://github.com/observablehq/notebook-kit/blob/02914e034fd21a50ebcdca08df57ef5773864125/src/runtime/define.ts#L33 for (const node of enter) { const vid = uid(); const state = {values: [], variables: [], error: null, doc: false}; diff --git a/runtime/mutator.js b/runtime/mutator.js index ac8091b..24de99d 100644 --- a/runtime/mutator.js +++ b/runtime/mutator.js @@ -1,4 +1,5 @@ -// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/mutable.ts +// Derived from Observable Notebook Kit's mutable and mutator. +// https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/mutable.ts import {observe} from "./observe.js"; // Mutable returns a generator with a value getter/setting that allows the diff --git a/runtime/observe.js b/runtime/observe.js index 5a2f985..57d37fc 100644 --- a/runtime/observe.js +++ b/runtime/observe.js @@ -1,3 +1,4 @@ +// Derived from Observable Notebook Kit's observe. // https://github.com/observablehq/notebook-kit/blob/main/src/runtime/stdlib/generators/observe.ts export async function* observe(initialize) { From 366a24c5805e2e7da55fb17c5d3ba9846c7667aa Mon Sep 17 00:00:00 2001 From: pearmini Date: Sat, 8 Nov 2025 16:08:22 -0500 Subject: [PATCH 4/6] Rename to recho.state --- runtime/index.js | 2 -- runtime/stdlib/index.js | 1 + runtime/{ => stdlib}/observe.js | 0 runtime/{mutator.js => stdlib/state.js} | 18 +++++------------- test/js/mutable.js | 17 ++++++----------- test/stdlib.spec.js | 1 + 6 files changed, 13 insertions(+), 26 deletions(-) rename runtime/{ => stdlib}/observe.js (100%) rename runtime/{mutator.js => stdlib/state.js} (75%) diff --git a/runtime/index.js b/runtime/index.js index 99efb1f..0d593aa 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -7,7 +7,6 @@ import * as stdlib from "./stdlib/index.js"; import {Inspector} from "./stdlib/inspect.js"; import {OUTPUT_MARK, ERROR_MARK} from "./constant.js"; import {transpileRechoJavaScript} from "./transpile.js"; -import {Mutator} from "./mutator.js"; const OUTPUT_PREFIX = `//${OUTPUT_MARK}`; @@ -15,7 +14,6 @@ const ERROR_PREFIX = `//${ERROR_MARK}`; const BUILTINS = { recho: () => stdlib, - __Mutator__: () => Mutator, }; function uid() { diff --git a/runtime/stdlib/index.js b/runtime/stdlib/index.js index 2bcdea0..c11d150 100644 --- a/runtime/stdlib/index.js +++ b/runtime/stdlib/index.js @@ -3,3 +3,4 @@ export {now} from "./now.js"; export {interval} from "./interval.js"; export {inspect, Inspector} from "./inspect.js"; export * from "../controls/index.js"; +export {state} from "./state.js"; diff --git a/runtime/observe.js b/runtime/stdlib/observe.js similarity index 100% rename from runtime/observe.js rename to runtime/stdlib/observe.js diff --git a/runtime/mutator.js b/runtime/stdlib/state.js similarity index 75% rename from runtime/mutator.js rename to runtime/stdlib/state.js index 24de99d..b9e0177 100644 --- a/runtime/mutator.js +++ b/runtime/stdlib/state.js @@ -18,17 +18,9 @@ function Mutable(value) { }); } -export function Mutator(value) { - const mutable = Mutable(value); - return [ - mutable, - { - get value() { - return mutable.value; - }, - set value(v) { - mutable.value = v; - }, - }, - ]; +export function state(value) { + const state = Mutable(value); + const setState = (x) => (typeof x === "function" ? (state.value = x(state.value)) : (state.value = x)); + const getState = () => state.value; + return [state, setState, getState]; } diff --git a/test/js/mutable.js b/test/js/mutable.js index ce51cba..640cf0f 100644 --- a/test/js/mutable.js +++ b/test/js/mutable.js @@ -1,13 +1,8 @@ -export const mutable = `const [a, mutator$$a] = __Mutator__(0); +export const mutable = `const [a, setA] = recho.state(0); -new Promise((resolve) => { - setTimeout(() => { - for (let i = 0; i < 10; i++) { - mutator$$a.value += 1; - } - }, 1000); -}); +setTimeout(() => { + setA((a) => a + 1); +}, 1000); -{ - echo(a); -}`; +echo(a); +`; diff --git a/test/stdlib.spec.js b/test/stdlib.spec.js index 48d5916..af1abff 100644 --- a/test/stdlib.spec.js +++ b/test/stdlib.spec.js @@ -8,4 +8,5 @@ it("should export expected functions from stdlib", () => { expect(stdlib.toggle).toBeDefined(); expect(stdlib.number).toBeDefined(); expect(stdlib.radio).toBeDefined(); + expect(stdlib.state).toBeDefined(); }); From f98860a4925fd386fcf1abd36dcaec8bab1d9c1d Mon Sep 17 00:00:00 2001 From: pearmini Date: Sat, 8 Nov 2025 16:18:54 -0500 Subject: [PATCH 5/6] Add docs --- app/docs/api-reference.recho.js | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/app/docs/api-reference.recho.js b/app/docs/api-reference.recho.js index 9f9e4a5..497db67 100644 --- a/app/docs/api-reference.recho.js +++ b/app/docs/api-reference.recho.js @@ -286,3 +286,71 @@ const temperature = recho.number(24, {min: -10, max: 40, step: 0.5}); const fahrenheit = (temperature * 9) / 5 + 32; echo(`The room temperature is ${temperature} °C (${fahrenheit} °F).`); } + +/** + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * recho.state(value) + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Creates a reactive state variable that can be mutated over time. This is + * similar to React's useState hook and enables mutable reactive values that + * automatically trigger re-evaluation of dependent blocks when changed. + * + * @param {any} value - The initial state value. + * @returns {[any, Function, Function]} A tuple containing: + * - state: The reactive state value that can be read directly + * - setState: Function to update the state (accepts value or updater function) + * - getState: Function to get the current state value + */ + +// Basic counter that increments after 1 second +const [count1, setCount1] = recho.state(0); + +setTimeout(() => { + setCount1(count1 => count1 + 1); +}, 1000); + +//➜ 1 +echo(count1); + +// Timer that counts down from 10 +const [timer, setTimer] = recho.state(10); + +{ + const interval = setInterval(() => { + setTimer(t => { + if (t <= 0) { + clearInterval(interval); + return 0; + } + return t - 1; + }); + }, 1000); + + invalidation.then(() => clearInterval(interval)); +} + +//➜ 8 +echo(`Time remaining: ${timer}s`); + +// State can be updated with a direct value +const [message, setMessage] = recho.state("Hello"); + +setTimeout(() => { + setMessage("Hello, World!"); +}, 2000); + +//➜ "Hello, World!" +echo(message); + +// Multiple states can be used together +const [firstName, setFirstName] = recho.state("John"); +const [lastName, setLastName] = recho.state("Doe"); + +setTimeout(() => { + setFirstName("Jane"); + setLastName("Smith"); +}, 1500); + +//➜ "Jane Smith" +echo(`${firstName} ${lastName}`); From 5ce6bda53c9d92a97d36f50e57263b1b4b674761 Mon Sep 17 00:00:00 2001 From: pearmini Date: Sun, 9 Nov 2025 08:20:53 -0500 Subject: [PATCH 6/6] Update state order --- app/docs/nav.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/docs/nav.config.js b/app/docs/nav.config.js index 5473629..b8cc091 100644 --- a/app/docs/nav.config.js +++ b/app/docs/nav.config.js @@ -61,11 +61,11 @@ export const docsNavConfig = [ }, { type: "page", - slug: "api-inspect", + slug: "api-state", }, { type: "page", - slug: "api-state", + slug: "api-inspect", }, { type: "page",