diff --git a/index.html b/index.html index 12fe5f6a7..80ece4eeb 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,12 @@

Behavioural

title="Inject history" >Inject historyunknown + Bindβ + pattern.replace("preact/|", "preact/|signal-polyfill/|") +); + module.exports = config; diff --git a/package.json b/package.json index 966a48954..0aab97aa4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "select2": "^3.5.1", "showdown": "^2.1.0", "showdown-prettify": "^1.3.0", + "signal-polyfill": "^0.2.2", "slick-carousel": "git+https://github.com/kenwheeler/slick.git#d0716f19aa730006ee80ab026625fb1107816a97", "spectrum-colorpicker": "^1.8.0", "tippy.js": "^6.3.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e9e4ba8f..455a79c41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: showdown-prettify: specifier: ^1.3.0 version: 1.3.0 + signal-polyfill: + specifier: ^0.2.2 + version: 0.2.2 slick-carousel: specifier: git+https://github.com/kenwheeler/slick.git#d0716f19aa730006ee80ab026625fb1107816a97 version: https://codeload.github.com/kenwheeler/slick/tar.gz/d0716f19aa730006ee80ab026625fb1107816a97(jquery@3.7.1) @@ -4902,6 +4905,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + signal-polyfill@0.2.2: + resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -10750,6 +10756,8 @@ snapshots: signal-exit@4.1.0: {} + signal-polyfill@0.2.2: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 diff --git a/src/core/signals.js b/src/core/signals.js new file mode 100644 index 000000000..c211046e3 --- /dev/null +++ b/src/core/signals.js @@ -0,0 +1,139 @@ +/** + * Patternslib helpers backed by the TC39 Signals proposal's official polyfill. + * Computed values are lazy; effects run immediately, then in microtask batches. + */ +import { Signal } from "signal-polyfill"; +import logging from "./logging"; + +const log = logging.getLogger("signals"); +const pending_effects = new Set(); +const MAX_RUNS_PER_EFFECT = 1000; +let flush_scheduled = false; +let batch_depth = 0; + +function schedule_flush() { + if (flush_scheduled || batch_depth > 0 || !pending_effects.size) { + return; + } + flush_scheduled = true; + queueMicrotask(flush_effects); +} + +function flush_effects() { + const run_counts = new Map(); + try { + for (const eff of pending_effects) { + pending_effects.delete(eff); + const runs = (run_counts.get(eff) || 0) + 1; + if (runs > MAX_RUNS_PER_EFFECT) { + pending_effects.clear(); + log.error( + "Aborting effect flush after too many iterations - likely a cyclic dependency." + ); + return; + } + run_counts.set(eff, runs); + eff.run(); + } + } finally { + flush_scheduled = false; + } +} + +/** Create a writable value with tracked reads and untracked peek(). */ +function signal(initial_value) { + const state = new Signal.State(initial_value); + return { + get value() { + return state.get(); + }, + set value(value) { + state.set(value); + }, + peek() { + return untracked(() => state.get()); + }, + }; +} + +/** + * Create a lazy, cached derivation. Reads always return the current value. + * dispose() freezes the last evaluated value (undefined if never evaluated). + * Disposal is optional for unused computeds, which are not kept live by sources. + */ +function computed(fn) { + const active = new Signal.State(true); + let value; + const derived = new Signal.Computed(() => { + if (active.get()) { + value = fn(); + } + return value; + }); + return { + get value() { + return derived.get(); + }, + peek() { + return untracked(() => derived.get()); + }, + dispose() { + active.set(false); + fn = null; + // Drop source dependencies now, even if nobody reads again. + untracked(() => derived.get()); + }, + }; +} + +/** Run immediately and after dependency changes; return a dispose function. */ +function effect(fn) { + let disposed = false; + const derived = new Signal.Computed(() => { + try { + fn(); + } catch (e) { + log.error("Error while running effect.", e); + } + }); + const eff = { + run() { + if (disposed) return; + // Re-arm before evaluation so writes can schedule other effects. + watcher.watch(); + derived.get(); + }, + }; + const watcher = new Signal.subtle.Watcher(() => { + // Watcher callbacks must not read or write signals. + pending_effects.add(eff); + schedule_flush(); + }); + watcher.watch(derived); + // Creating an effect inside another computation must not subscribe it. + untracked(() => eff.run()); + return () => { + disposed = true; + watcher.unwatch(derived); + pending_effects.delete(eff); + }; +} + +/** Group synchronous writes, deferring effect scheduling until the end. */ +function batch(fn) { + batch_depth++; + try { + return fn(); + } finally { + batch_depth--; + schedule_flush(); + } +} + +/** Run without subscribing the surrounding computation to signal reads. */ +function untracked(fn) { + return Signal.subtle.untrack(fn); +} + +export { signal, computed, effect, batch, untracked }; +export default { signal, computed, effect, batch, untracked }; diff --git a/src/core/signals.md b/src/core/signals.md new file mode 100644 index 000000000..1d6436b30 --- /dev/null +++ b/src/core/signals.md @@ -0,0 +1,68 @@ +# Signals + +Reactive values backed by the official [TC39 Signals polyfill](https://github.com/proposal-signals/signal-polyfill), +with automatic dependency tracking. Used by +[pat-bind](../pat/bind/documentation.md) to keep DOM elements in sync. + +## Usage + +```javascript +import { + signal, + computed, + effect, + batch, +} from "@patternslib/patternslib/src/core/signals"; + +const price = signal(10); +const quantity = signal(2); +const total = computed(() => price.value * quantity.value); +const dispose = effect(() => console.log(total.value)); // Logs 20 immediately. + +batch(() => { + price.value = 12; + quantity.value = 3; +}); + +await Promise.resolve(); // The queued update logs 36 once. + +// In a pattern, perform this cleanup in destroy(). +dispose(); +// Unused computed values need no disposal. +``` + +## API + +- `signal(value)` creates a writable value. Read and assign through `.value`. + Assigning an equal value (`Object.is`) does not trigger updates. +- `computed(fn)` creates a lazy, cached, read-only derived value. Keep `fn` + free of side effects. Unused computeds need no disposal. For compatibility, + `.dispose()` freezes the last evaluated value and releases its dependencies; + disposing before the first read freezes `undefined`. Computation errors are + thrown on reads and cached until a dependency changes. +- `effect(fn)` runs immediately and tracks signals read synchronously by `fn`. + It runs again when its dependencies change. The returned function stops it. +- `batch(fn)` groups synchronous writes, deferring scheduling until it returns. + Synchronous writes are also batched automatically. +- `untracked(fn)` reads without collecting dependencies. Signals and computed + values also provide `.peek()` for a single untracked read. + +Effects are batched in a microtask. Computed values evaluate only when read, +and both `.value` and `.peek()` return an up-to-date result immediately after +a write, including inside `batch()`. Computed reads update their dependencies +before returning, so consumers see consistent derived values. + +Effects may write signals, but should not rely on repeatedly writing their own +dependencies to schedule themselves. Use computed values for derivations. + +## Specifications + +This module wraps `signal-polyfill`, the official implementation of the +[TC39 Signals proposal](https://github.com/tc39/proposal-signals). +`signal()` and `computed()` adapt `Signal.State` and `Signal.Computed` to the +Patternslib `.value` / `.peek()` API. `untracked()` delegates to +`Signal.subtle.untrack()`. Effects use `Signal.subtle.Watcher`; their microtask +scheduling and the `batch()` helper are Patternslib APIs. + +Scheduling uses `queueMicrotask`, defined by the +[HTML Standard](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing). diff --git a/src/core/signals.test.js b/src/core/signals.test.js new file mode 100644 index 000000000..117c4adf4 --- /dev/null +++ b/src/core/signals.test.js @@ -0,0 +1,339 @@ +import { signal, computed, effect, batch, untracked } from "./signals"; + +// A microtask flush helper: effects are batched and run in a microtask. +const flush = () => Promise.resolve(); + +describe("core/signals", function () { + describe("1 - signal", function () { + it("1.1 - stores and returns its value", function () { + const count = signal(1); + expect(count.value).toBe(1); + count.value = 2; + expect(count.value).toBe(2); + }); + + it("1.2 - peek() reads without subscribing", async function () { + const count = signal(1); + let runs = 0; + effect(() => { + runs++; + count.peek(); + }); + await flush(); + expect(runs).toBe(1); + + count.value = 2; + await flush(); + // The effect did not subscribe via peek(), so it does not re-run. + expect(runs).toBe(1); + }); + }); + + describe("2 - effect", function () { + it("does not run an effect disposed while its update is pending", async function () { + const source = signal(0); + const read = jest.fn(() => source.value); + const dispose = effect(read); + source.value = 1; + dispose(); + dispose(); + await flush(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it("2.1 - runs immediately and on dependency change", async function () { + const count = signal(1); + const seen = []; + effect(() => seen.push(count.value)); + + // Effect runs synchronously on creation. + expect(seen).toEqual([1]); + + count.value = 2; + await flush(); + expect(seen).toEqual([1, 2]); + }); + + it("2.2 - coalesces multiple writes into one re-run", async function () { + const count = signal(0); + let runs = 0; + effect(() => { + runs++; + count.value; + }); + expect(runs).toBe(1); + + count.value = 1; + count.value = 2; + count.value = 3; + await flush(); + // The three writes are flushed together → a single re-run. + expect(runs).toBe(2); + expect(count.value).toBe(3); + }); + + it("2.3 - does not re-run on equal-value writes", async function () { + const count = signal(1); + let runs = 0; + effect(() => { + runs++; + count.value; + }); + await flush(); + expect(runs).toBe(1); + + count.value = 1; // same value (Object.is) → no notification + await flush(); + expect(runs).toBe(1); + }); + + it("2.4 - dispose() stops the effect", async function () { + const count = signal(1); + let runs = 0; + const dispose = effect(() => { + runs++; + count.value; + }); + await flush(); + expect(runs).toBe(1); + + dispose(); + count.value = 2; + await flush(); + expect(runs).toBe(1); + }); + + it("2.5 - re-tracks dependencies on each run", async function () { + const toggle = signal(true); + const a = signal("a"); + const b = signal("b"); + const seen = []; + effect(() => seen.push(toggle.value ? a.value : b.value)); + expect(seen).toEqual(["a"]); + + // While toggle is true, b is not a dependency. + b.value = "b2"; + await flush(); + expect(seen).toEqual(["a"]); + + toggle.value = false; + await flush(); + expect(seen).toEqual(["a", "b2"]); + + // Now a is no longer a dependency. + a.value = "a2"; + await flush(); + expect(seen).toEqual(["a", "b2"]); + }); + }); + + describe("3 - computed", function () { + it("is lazy and returns current values synchronously, including peek", async function () { + const source = signal(1); + const derive = jest.fn(() => source.value * 2); + const value = computed(derive); + expect(derive).not.toHaveBeenCalled(); + source.value = 2; + await flush(); + expect(derive).not.toHaveBeenCalled(); + expect(value.value).toBe(4); + expect(value.value).toBe(4); + expect(derive).toHaveBeenCalledTimes(1); + batch(() => { + source.value = 3; + expect(value.peek()).toBe(6); + }); + expect(derive).toHaveBeenCalledTimes(2); + }); + + it("does not subscribe an effect through computed peek", async function () { + const source = signal(1); + const value = computed(() => source.value * 2); + const read = jest.fn(() => value.peek()); + const dispose = effect(read); + source.value = 2; + await flush(); + expect(read).toHaveBeenCalledTimes(1); + expect(value.peek()).toBe(4); + dispose(); + }); + + it("skips effects when the computed result is unchanged", async function () { + const source = signal(1); + const parity = computed(() => source.value % 2); + const read = jest.fn(() => parity.value); + const dispose = effect(read); + source.value = 3; + await flush(); + expect(read).toHaveBeenCalledTimes(1); + source.value = 4; + await flush(); + expect(read).toHaveBeenCalledTimes(2); + dispose(); + }); + + it("evaluates overlapping dependency paths once with consistent values", async function () { + const source = signal(1); + const doubled = computed(() => source.value * 2); + const derive = jest.fn(() => source.value + doubled.value); + const sum = computed(derive); + const seen = []; + const dispose = effect(() => seen.push(sum.value)); + for (const value of [2, 3]) { + source.value = value; + await flush(); + } + expect(seen).toEqual([3, 6, 9]); + expect(derive).toHaveBeenCalledTimes(3); + dispose(); + }); + + it("caches computation errors and recovers after a dependency changes", function () { + const source = signal(0); + const derive = jest.fn(() => { + if (!source.value) throw new Error("missing value"); + return source.value; + }); + const value = computed(derive); + expect(() => value.value).toThrow("missing value"); + expect(() => value.peek()).toThrow("missing value"); + expect(derive).toHaveBeenCalledTimes(1); + source.value = 1; + expect(value.value).toBe(1); + }); + + it("freezes undefined when disposed before its first read", function () { + const derive = jest.fn(() => 42); + const value = computed(derive); + value.dispose(); + value.dispose(); + expect(value.value).toBeUndefined(); + expect(derive).not.toHaveBeenCalled(); + }); + + it("settles a computed chain before notifying consumers once", async function () { + const a = signal(1); + const b = computed(() => a.value * 2); + const c = computed(() => b.value * 2); + const seen = []; + const dispose = effect(() => seen.push([a.value, c.value])); + + a.value = 2; + await flush(); + expect(seen).toEqual([ + [1, 4], + [2, 8], + ]); + + a.value = 3; + await flush(); + expect(seen).toEqual([ + [1, 4], + [2, 8], + [3, 12], + ]); + dispose(); + b.dispose(); + c.dispose(); + }); + + it("settles derivations written by an effect before remaining consumers", async function () { + const trigger = signal(0); + const a = signal(1); + const b = computed(() => a.value * 2); + const write = effect(() => { + if (trigger.value) a.value = 2; + }); + const seen = []; + const read = effect(() => seen.push([trigger.value, a.value, b.value])); + + trigger.value = 1; + await flush(); + expect(seen).toEqual([ + [0, 1, 2], + [1, 2, 4], + ]); + write(); + read(); + b.dispose(); + }); + + it("does not run disposed computations that are pending", async function () { + const a = signal(1); + const derive = jest.fn(() => a.value * 2); + const b = computed(derive); + expect(b.value).toBe(2); + a.value = 2; + b.dispose(); + await flush(); + expect(derive).toHaveBeenCalledTimes(1); + expect(b.value).toBe(2); + }); + + it("3.1 - derives from other signals", async function () { + const first = signal("Jane"); + const last = signal("Doe"); + const full = computed(() => `${first.value} ${last.value}`); + expect(full.value).toBe("Jane Doe"); + + first.value = "John"; + await flush(); + expect(full.value).toBe("John Doe"); + }); + + it("3.2 - is reactive as an effect dependency", async function () { + const count = signal(2); + const doubled = computed(() => count.value * 2); + const seen = []; + effect(() => seen.push(doubled.value)); + expect(seen).toEqual([4]); + + count.value = 5; + await flush(); + expect(seen).toEqual([4, 10]); + }); + }); + + describe("4 - batch", function () { + it("4.1 - defers flushing until the batch ends", async function () { + const a = signal(1); + const b = signal(2); + let runs = 0; + effect(() => { + runs++; + a.value + b.value; + }); + expect(runs).toBe(1); + + batch(() => { + a.value = 10; + b.value = 20; + }); + await flush(); + expect(runs).toBe(2); + }); + }); + + describe("5 - untracked", function () { + it("5.1 - reads without creating a dependency", async function () { + const tracked = signal(1); + const hidden = signal(1); + let runs = 0; + effect(() => { + runs++; + tracked.value; + untracked(() => hidden.value); + }); + await flush(); + expect(runs).toBe(1); + + hidden.value = 2; + await flush(); + expect(runs).toBe(1); // not a dependency + + tracked.value = 2; + await flush(); + expect(runs).toBe(2); + }); + }); +}); diff --git a/src/pat/bind/bind.js b/src/pat/bind/bind.js new file mode 100644 index 000000000..c13170182 --- /dev/null +++ b/src/pat/bind/bind.js @@ -0,0 +1,371 @@ +/** + * Patterns bind - declarative data binding between DOM elements. + * + * A ``pat-bind`` element wires one or more of its accessors (a form value, an + * attribute, its text or HTML content) to a named *channel*. Elements sharing a + * channel stay in sync: change a source and every target updates. + * + * Bindings are reactive under the hood via ``core/signals``, but the page + * author only ever writes markup - in the spirit of Patternslib. + * + * For usage, see documentation.md + */ +import { BasePattern } from "../../core/basepattern"; +import { signal, effect } from "../../core/signals"; +import Parser from "../../core/parser"; +import dom from "../../core/dom"; +import events from "../../core/events"; +import logging from "../../core/logging"; +import registry from "../../core/registry"; + +const log = logging.getLogger("pat-bind"); + +export const parser = new Parser("bind"); +parser.addArgument("key"); // channel name +parser.addArgument("value"); // accessor: [attr], ::text, ::html or empty (inferred) +parser.addArgument("direction", null, ["source", "target", "both"]); + +// Channel registry: maps a scope-root element to its named signals. +// A ``WeakMap`` so detached scope roots can be garbage collected. +const channel_registry = new WeakMap(); +const radio_sources = new WeakMap(); + +function same_radio_group(a, b) { + return ( + b?.matches?.('input[type="radio"]') && + a.name !== "" && + a.name === b.name && + a.form === b.form && + a.getRootNode() === b.getRootNode() + ); +} + +// Setting checked also unchecks peers without emitting their change events. +function sync_radio_group(el) { + if (!el.name) { + // No name, no native radio group - nothing to sync. + return; + } + // Narrow the candidate set by name via the selector itself, rather than + // walking every radio in the root, since a root can hold many unrelated + // radio groups. + const peers = el + .getRootNode() + .querySelectorAll(`input[type="radio"][name="${CSS.escape(el.name)}"]`); + for (const peer of peers) { + if (same_radio_group(el, peer)) { + for (const update of radio_sources.get(peer) || []) { + update(); + } + } + } +} + +// Shared, ref-counted "change" listener: one capture-phase listener per +// document resyncs every radio-bound pat-bind element in that document, +// instead of each element registering (and having to keep registered on) +// its own listener. Listening on ``ownerDocument`` - rather than an +// element's ``getRootNode()`` captured at setup time - also means the +// listener stays valid even if the element is initialized while still +// detached (e.g. inside a cloned ``