diff --git a/.npmignore b/.npmignore index eb76fa7..037ba2f 100644 --- a/.npmignore +++ b/.npmignore @@ -8,6 +8,7 @@ node_modules/ .gitignore .nvmrc index.html +index.js importmap.json importmap.yaml *.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2296da8..303b729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,93 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Add `@aegisjsproject/dev-server` - -### Changed -- Update CSP, template, etc - -## [v1.1.3] - 2025-11-21 - -### Added -- Add `npm start` script - -### Changed -- Update npm publishing - -## [v1.1.2] - 2025-05-01 - -### Changed -- Use `eslint` & `rollup` directly instead of by other packages -- Update node version via `.npmrc` -- Update Node CI workflow -- Install & use `@shgysk8zer0/eslint-config` -- Add support for `node --test`, including ignoring tests for publishing -- Update ESLint & super-linter -- Switch to more basic Rollup config -- Update `exports` and `main` accordingly - -### Fixed -- Fix missed renaming in README - -## Removed -- Remove old ESLint config files - -## [v1.1.1] - 2023-09-24 - -### Added -- Add `unpkg` to `package.json` -- Add badges in README - -### Changed -- Update `exports` to `package.json` to handle wider variety - -### Fixed -- Fix typo in `fix:js` script - -### [v1.1.0] - 2023-07-03 - -### Changed -- Update to node 20 -- Update npm publishing GH Action - -## [v1.0.5] - 2023-07-02 - -### Added -- Add `funding` - -### Changed -- Updated GitHub Actions workflows -- Update versioning & lock-file scripts -- Update `.npmignore` & `.gitignore` - -## [v1.0.4] - 2023-06-08 - -### Added -- Install `@shgysk8zer0/npm-utils` -- Add `exports` to package config - -### Removed -- Uninstall `rollup`, `eslint` - -### Changed -- Use `getConfig()` from `@shgysk8zer0/js-utils/rollup` for rollup config - -## [v1.0.3] - 2023-06-01 - -### Fixed -- Revert to old Release Action, now with permissions & link to changelog - -## [v1.0.2] - 2023-06-01 - -### Fixed -- Fix `changelog-entry` to match `[$version]` instead of `$version` - -## [v1.0.1] - 2023-05-31 - -### Fixed -- Update GitHub Release workflow to use [Auto Release](https://github.com/marketplace/actions/auto-release) - -## [v1.0.0] - 2023-05-31 +## [v1.0.0] - 2026-03-11 Initial Release diff --git a/README.md b/README.md index d7fbe2f..dd63e84 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# npm-template +# @aegisjsproject/iota -A template repo for npm packages +A zero-build, fine-grained reactivity library leveraging the TC39 Signals proposal. -[![CodeQL](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml) -![Node CI](https://github.com/shgysk8zer0/npm-template/workflows/Node%20CI/badge.svg) -![Lint Code Base](https://github.com/shgysk8zer0/npm-template/workflows/Lint%20Code%20Base/badge.svg) +Iota delivers the microscopic DOM mutation performance of compiled frameworks (like Solid or Svelte) entirely at runtime. It bypasses the Virtual DOM by surgically targeting individual `Text` and `Attr` nodes, utilizing Explicit Resource Management (`DisposableStack`) for deterministic memory cleanup. -[![GitHub license](https://img.shields.io/github/license/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/blob/master/LICENSE) -[![GitHub last commit](https://img.shields.io/github/last-commit/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/commits/master) -[![GitHub release](https://img.shields.io/github/release/shgysk8zer0/npm-template?logo=github)](https://github.com/shgysk8zer0/npm-template/releases) +[![CodeQL](https://github.com/AegisJSProject/iota/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/AegisJSProject/iota/actions/workflows/codeql-analysis.yml) +![Node CI](https://github.com/AegisJSProject/iota/workflows/Node%20CI/badge.svg) +![Lint Code Base](https://github.com/AegisJSProject/iota/workflows/Lint%20Code%20Base/badge.svg) + +[![GitHub license](https://img.shields.io/github/license/AegisJSProject/iota.svg)](https://github.com/AegisJSProject/iota/blob/master/LICENSE) +[![GitHub last commit](https://img.shields.io/github/last-commit/AegisJSProject/iota.svg)](https://github.com/AegisJSProject/iota/commits/master) +[![GitHub release](https://img.shields.io/github/release/AegisJSProject/iota?logo=github)](https://github.com/AegisJSProject/iota/releases) [![GitHub Sponsors](https://img.shields.io/github/sponsors/shgysk8zer0?logo=github)](https://github.com/sponsors/shgysk8zer0) -[![npm](https://img.shields.io/npm/v/@shgysk8zer0/npm-template)](https://www.npmjs.com/package/@shgysk8zer0/npm-template) -![node-current](https://img.shields.io/node/v/@shgysk8zer0/npm-template) -![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@shgysk8zer0/npm-template) -[![npm](https://img.shields.io/npm/dw/@shgysk8zer0/npm-template?logo=npm)](https://www.npmjs.com/package/@shgysk8zer0/npm-template) +[![npm](https://img.shields.io/npm/v/@aegisjsproject/iota)](https://www.npmjs.com/package/@aegisjsproject/iota) +![node-current](https://img.shields.io/node/v/@aegisjsproject/iota) +![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@aegisjsproject/iota) +[![npm](https://img.shields.io/npm/dw/@aegisjsproject/iota?logo=npm)](https://www.npmjs.com/package/@aegisjsproject/iota) [![GitHub followers](https://img.shields.io/github/followers/shgysk8zer0.svg?style=social)](https://github.com/shgysk8zer0) -![GitHub forks](https://img.shields.io/github/forks/shgysk8zer0/npm-template.svg?style=social) -![GitHub stars](https://img.shields.io/github/stars/shgysk8zer0/npm-template.svg?style=social) +![GitHub forks](https://img.shields.io/github/forks/AegisJSProject/iota.svg?style=social) +![GitHub stars](https://img.shields.io/github/stars/AegisJSProject/iota.svg?style=social) [![Twitter Follow](https://img.shields.io/twitter/follow/shgysk8zer0.svg?style=social)](https://twitter.com/shgysk8zer0) [![Donate using Liberapay](https://img.shields.io/liberapay/receives/shgysk8zer0.svg?logo=liberapay)](https://liberapay.com/shgysk8zer0/donate "Donate using Liberapay") @@ -27,3 +29,88 @@ A template repo for npm packages - [Code of Conduct](./.github/CODE_OF_CONDUCT.md) - [Contributing](./.github/CONTRIBUTING.md) + +## Features + +* **Zero-Build:** Runs natively in the browser. No compilers, no bundlers required. +* **Micro-Updates:** Mutates specific `Text` and `Attr` nodes. Re-renders are physically impossible. +* **Deterministic Cleanup:** Built-in memory management via `Symbol.dispose` and `DisposableStack` prevents zombie listeners. +* **Security by Default:** Bypasses `innerHTML` during reactive updates. By assigning directly to `Node.textContent` and `Attr.value`, markup injection is neutralized by the platform. +* **Web Component Native:** Designed to cleanly hydrate Shadow DOMs and standard elements alike. + +## Installation + +### NPM +```bash +npm install @aegisjsproject/iota +``` + +### ` +``` +## Usage + +Iota uses string placeholders (HTML comments and data-attributes) to position signals in your markup, which are then hydrated via `$observe()`. + +```javascript +import { $text, $attr, $observe } from '@aegisjsproject/iota'; +import { html } from '@aegisjsproject/core/parsers/html.js'; +import { onInput, observeEvents } from '@aegisjsproject/callback-registry'; +import { sanitizer as sanitizerConfig } from '@aegisjsproject/sanitizer/config/base.js'; + +// Manage lifecycle natively +const stack = new DisposableStack(); + +// Initialize signals and bind them to the stack +const $name = stack.use($text('World')); +const $value = stack.use($attr('value', () => $name.get())); + +document.body.append(html` +

Hello, ${$name}!

+
+ + +
+`); + +// Hydrate the DOM (replaces placeholders with live Text/Attr nodes) +$observe(); +observeEvents(); + +// Tie disposal to a DOM event (e.g., Invoker Commands or unmount lifecycle) +document.addEventListener('command', ({ command }) => { + if (command === '--dispose') stack.dispose(); +}); +``` + +## API Reference + +### Primitives +* **`$text(value | computeFn)`**: Creates a `TextState` or `TextComputed` signal. Returns an HTML comment placeholder `` when cast to a string. +* **`$attr(name, value | computeFn)`**: Creates an `AttrState` or `AttrComputed` signal. Returns a data-attribute placeholder `data-attr-signal="ref"` when cast to a string. +* **`$signal(value)`**: Creates a base `DisposableState`. Includes a `.handleEvent(e)` method that automatically updates the signal value from form inputs. +* **`$computed(fn)`**: Creates a base `DisposableComputed` signal. + +### Observers & Hydration +* **`$observe(target = document.body, { stack, signal, base } = {})`**: Walks the target DOM node, locates signal placeholders, and replaces them with live, reactive `Text` and `Attr` nodes. +* **`observeTextSignalRefs(...)`**: Hydrates only text nodes. +* **`observeAttrSignalRefs(...)`**: Hydrates only attribute nodes. + +### Watchers +* **`$watch(signal, callback)`**: Manually subscribe to a signal. +* **`$unwatch(signal)`**: Stop tracking a specific signal. +* **`unwatchSignalCallback(signal, callback)`**: Remove a specific callback from a signal. + +### Registry & Internal +* **`RegistryKey`**: An extended `String` representing the signal's internal ID. Implements `[Symbol.dispose]` to automatically unwatch and unregister the associated signal when the stack clears. +* **`registerSignal(ref, signal)`** / **`unregisterSignal(ref)`**: Manages the global Map of active signals used during hydration. diff --git a/attr.js b/attr.js new file mode 100644 index 0000000..8f84b26 --- /dev/null +++ b/attr.js @@ -0,0 +1,41 @@ +import { DisposableComputed, DisposableState } from './disposable.js'; + +export const SIGNAL_DATA_ATTR = 'data-attr-signal'; + +export class AttrState extends DisposableState { + #name; + + constructor(name, value, config) { + super(value, config); + this.#name = name; + } + + get name() { + return this.#name; + } + + toString() { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } +} + +export class AttrComputed extends DisposableComputed { + #name; + + constructor(name, callback, config) { + super(callback, config); + this.#name = name; + } + + get name() { + return this.#name; + } + + toString() { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } +} + +export const $attr = (name, val, config) => typeof val === 'function' + ? new AttrComputed(name, val, config) + : new AttrState(name, val, config); diff --git a/consts.js b/consts.js deleted file mode 100644 index fc3a79b..0000000 --- a/consts.js +++ /dev/null @@ -1 +0,0 @@ -export const MESSAGE = 'This is a template for npm projects.'; diff --git a/disposable.js b/disposable.js new file mode 100644 index 0000000..ffd379c --- /dev/null +++ b/disposable.js @@ -0,0 +1,95 @@ +import { Signal } from '@shgysk8zer0/signals'; +import { getRef } from './refs.js'; +import { registerSignal, unregisterSignal } from './registry.js'; + +export class DisposableState extends Signal.State { + #ref = getRef('__signal_ref'); + + constructor(value, { + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + equals = Object.is, + } = {}) { + super(value, { + equals: (a, b) => this.#ref.disposed ? true : equals(a, b), + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + }); + + registerSignal(this.#ref, this); + this.#ref.defer(() => unregisterSignal(this.#ref)); + this.handleEvent = this.handleEvent.bind(this); + } + + handleEvent(event) { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) { + this.set(event.target.value); + } + } + + get ref() { + return this.#ref; + } + + get disposed() { + return this.#ref.disposed; + } + + [Symbol.dispose]() { + if (! this.#ref.disposed) { + this.#ref[Symbol.dispose](); + + Signal.subtle.introspectSinks(this).forEach(source => { + if (source instanceof Signal.subtle.Watcher) { + source.unwatch(this); + } + }); + } + } +} + +export class DisposableComputed extends Signal.Computed { + #ref = getRef('__signal_ref'); + + constructor(callback, { + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + equals = Object.is, + } = {}) { + super(callback, { + equals: (a, b) => this.#ref.disposed ? true : equals(a, b), + [Signal.subtle.watched]: onWatched, + [Signal.subtle.unwatched]: onUnwatched, + }); + + registerSignal(this.#ref, this); + this.#ref.defer(() => unregisterSignal(this.#ref)); + } + + get ref() { + return this.#ref; + } + + get disposed() { + return this.#ref.disposed; + } + + dispose() { + this[Symbol.dispose](); + } + + [Symbol.dispose]() { + if (! this.#ref.disposed) { + this.#ref[Symbol.dispose](); + + Signal.subtle.introspectSinks(this).forEach(source => { + if (source instanceof Signal.subtle.Watcher) { + source.unwatch(this); + } + }); + } + } +} + +export const $signal = (initial, config) => new DisposableState(initial, config); +export const $computed = (callback, config) => new DisposableComputed(callback, config); diff --git a/eslint.config.js b/eslint.config.js index e567f3e..4ff7308 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,3 @@ -import { node } from '@shgysk8zer0/eslint-config'; +import { browser } from '@shgysk8zer0/eslint-config'; -export default node({ files: ['**/*.js'], ignores: ['**/*.min.js', '**/*.cjs', '**/*.mjs'] }); +export default browser({ files: ['**/*.js'], ignores: ['**/*.min.js', '**/*.cjs', '**/*.mjs'] }); diff --git a/http.config.js b/http.config.js index 43ca2ec..84610c1 100644 --- a/http.config.js +++ b/http.config.js @@ -1,12 +1,15 @@ -import { imports } from '@shgysk8zer0/importmap'; -import { checkCacheItem, setCacheItem } from '@aegisjsproject/http-utils/cache.js'; -import { addTrustedTypePolicy, addScriptSrc, useDefaultCSP } from '@aegisjsproject/http-utils/csp.js'; +import { imports, importmap } from '@shgysk8zer0/importmap'; +import { addTrustedTypePolicy, addScriptSrc, addStyleSrc, useDefaultCSP } from '@aegisjsproject/http-utils/csp.js'; addScriptSrc( 'https://unpkg.com/@aegisjsproject/', 'https://unpkg.com/@shgysk8zer0/', ); +addStyleSrc( + importmap.resolve('@aegisjsproject/styles/css/'), +); + addTrustedTypePolicy('aegis-sanitizer#html'); export default { @@ -14,10 +17,10 @@ export default { '/': '@aegisjsproject/dev-server', '/favicon.svg': '@aegisjsproject/dev-server/favicon', }, + port: 8023, open: true, requestPreprocessors: [ '@aegisjsproject/http-utils/request-id.js', - checkCacheItem, ], responsePostprocessors: [ '@aegisjsproject/http-utils/compression.js', @@ -28,6 +31,5 @@ export default { response.headers.append('Link', `<${imports['@shgysk8zer0/polyfills']}>; rel="preload"; as="script"; fetchpriority="high"; crossorigin="anonymous"; referrerpolicy="no-referrer"`); } }, - setCacheItem, ], }; diff --git a/index.html b/index.html index 014936b..3910122 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + NPM Template @@ -8,6 +8,7 @@ +
-
+
Request
 					{{ REQUEST }}
 				
-
+
Context
 					{{ CONTEXT }}
diff --git a/index.js b/index.js
index dce5927..e2054cf 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,53 @@
-import { MESSAGE } from 'npm-template/consts.js';
-console.log(import.meta);
-console.info(MESSAGE);
+import { $text, $attr, $observe } from '@aegisjsproject/iota';
+import { createHTMLParser } from '@aegisjsproject/core/parsers/html.js';
+import { onInput, observeEvents, createCallback } from '@aegisjsproject/callback-registry';
+import { sanitizer as sanitizerConfig } from '@aegisjsproject/sanitizer/config/base.js';
+// import pkg from '/package.json' with { type: 'json' }; // Wrong mime-type causes error
+import properties from '@aegisjsproject/styles/css/properties.css' with { type: 'css' };
+import theme from '@aegisjsproject/styles/css/theme.css' with { type: 'css' };
+import misc from '@aegisjsproject/styles/css/misc.css' with { type: 'css' };
+import forms from '@aegisjsproject/styles/css/forms.css' with { type: 'css' };
+import btn from '@aegisjsproject/styles/css/button.css' with { type: 'css' };
+
+// document.title = pkg.name;
+document.adoptedStyleSheets = [properties, theme, misc, forms, btn];
+
+// Need to force-allow comments for text signals
+const html = createHTMLParser({
+	...sanitizerConfig,
+	comments: true,
+});
+
+const stack = new DisposableStack();
+const controller = stack.adopt(new AbortController(), controller => controller.abort());
+const $name = stack.use($text('Silly person'));
+const $value = stack.use($attr('value', () => $name.get()));
+
+document.getElementById('main').prepend(html`

Hello, ${$name}!

+
+
+ + +
+
+ + +
+
+`); + +document.documentElement.addEventListener('command', ({ source, command }) => { + if (command === '--dispose') { + stack.dispose(); + source.disabled = true; + } +}, { signal: controller.signal }); + +stack.defer(() => { + for (const el of document.forms.container.elements) { + el.disabled = true; + } +}); + +$observe(); +observeEvents(); diff --git a/index.test.js b/index.test.js deleted file mode 100644 index 1afaf94..0000000 --- a/index.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { test, describe } from 'node:test'; -import assert from 'node:assert'; -import { MESSAGE } from './consts.js'; -const signal = AbortSignal.timeout(300); - -describe('An example test', () => { - test('Message is a string', { signal }, () => assert.equal(typeof MESSAGE, 'string', 'Message should be a string.')); -}); diff --git a/iota.js b/iota.js new file mode 100644 index 0000000..9710304 --- /dev/null +++ b/iota.js @@ -0,0 +1,9 @@ +export { getRef, RegistryKey, $ref } from './refs.js'; +export { registerSignal, getSignalFromRef, hasSignalRef, unregisterSignal } from './registry.js'; +export { DisposableComputed, DisposableState, $computed, $signal } from './disposable.js'; +export { + watchSignal, unwatchSignal, unwatchSignalCallback, observeAttrSignalRefs, observeSignalRefs, + observeTextSignalRefs, $watch, $unwatch, $observe, +} from './watcher.js'; +export { TextComputed, TextState, $text } from './text.js'; +export { AttrComputed, AttrState, $attr } from './attr.js'; diff --git a/package-lock.json b/package-lock.json index 29c5bc9..758c7b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,9 @@ "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.7.11", - "eslint": "^10.0.2", + "@shgysk8zer0/importmap": "^1.8.1", + "@shgysk8zer0/signals": "^0.0.3", + "eslint": "^10.0.3", "rollup": "^4.59.0" }, "engines": { @@ -174,14 +175,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -200,10 +202,11 @@ } }, "node_modules/@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -232,21 +235,23 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { @@ -941,11 +946,32 @@ "@aegisjsproject/trusted-types": "^1.0.2" } }, + "node_modules/@shgysk8zer0/signals": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/signals/-/signals-0.0.3.tgz", + "integrity": "sha512-mUSWKyZILwAoVIhwjM/QGVYfihEZgepO6TUV2BtOlHLqXkwhUMirEMDNOER/mkc+JnhpVDa0luiqbxpo+ZVG0g==", + "dev": true, + "funding": [ + { + "type": "librepay", + "url": "https://liberapay.com/shgysk8zer0" + }, + { + "type": "github", + "url": "https://github.com/sponsors/shgysk8zer0" + } + ], + "license": "MIT", + "engines": { + "node": ">=24.10.0" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -971,6 +997,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1014,15 +1041,17 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -1062,6 +1091,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1102,17 +1132,19 @@ } }, "node_modules/eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", + "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1121,7 +1153,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", @@ -1134,7 +1166,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1157,10 +1189,11 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", @@ -1220,6 +1253,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1512,6 +1546,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, @@ -1526,7 +1561,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -1658,6 +1694,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -1905,14 +1942,14 @@ } }, "@eslint/config-array": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz", - "integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "dev": true, "requires": { - "@eslint/object-schema": "^3.0.2", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^10.2.1" + "minimatch": "^10.2.4" } }, "@eslint/config-helpers": { @@ -1925,9 +1962,9 @@ } }, "@eslint/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", - "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.15" @@ -1941,18 +1978,18 @@ "requires": {} }, "@eslint/object-schema": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz", - "integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "dev": true }, "@eslint/plugin-kit": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", - "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "dev": true, "requires": { - "@eslint/core": "^1.1.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, @@ -2338,6 +2375,12 @@ "@aegisjsproject/trusted-types": "^1.0.2" } }, + "@shgysk8zer0/signals": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/signals/-/signals-0.0.3.tgz", + "integrity": "sha512-mUSWKyZILwAoVIhwjM/QGVYfihEZgepO6TUV2BtOlHLqXkwhUMirEMDNOER/mkc+JnhpVDa0luiqbxpo+ZVG0g==", + "dev": true + }, "@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2366,7 +2409,8 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -2400,9 +2444,9 @@ "dev": true }, "brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "requires": { "balanced-match": "^4.0.2" @@ -2459,17 +2503,18 @@ "dev": true }, "eslint": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", - "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.2", + "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.0", - "@eslint/plugin-kit": "^0.6.0", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2478,7 +2523,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.1", + "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", @@ -2491,15 +2536,15 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" } }, "eslint-scope": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", - "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "requires": { "@types/esrecurse": "^4.3.1", @@ -2859,6 +2904,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "peer": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", diff --git a/package.json b/package.json index da81520..f2a6ed8 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { - "name": "npm-template", - "version": "1.1.3", - "description": "A template repo for npm packages", - "keywords": [], + "name": "@aegisjsproject/iota", + "version": "1.0.0", + "description": "A Signals-based reactivity library", + "keywords": [ + "signals", + "tc39-signals", + "reactivity" + ], "type": "module", - "main": "./index.cjs", - "module": "./index.js", - "unpkg": "./index.min.js", + "main": "./iota.cjs", + "module": "./iota.js", + "unpkg": "./iota.min.js", "exports": { ".": { - "import": "./index.js", - "require": "./index.cjs" + "import": "./iota.js", + "require": "./iota.cjs" }, "./*.js": { "import": "./*.js", @@ -52,7 +56,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/shgysk8zer0/npm-template.git" + "url": "git+https://github.com/AegisJSProject/iota.git" }, "author": "Chris Zuber ", "license": "MIT", @@ -67,17 +71,20 @@ } ], "bugs": { - "url": "https://github.com/shgysk8zer0/npm-template/issues" + "url": "https://github.com/AegisJSProject/iota/issues" + }, + "homepage": "https://github.com/AegisJSProject/iota#readme", + "dependencies": { + "@shgysk8zer0/signals": "^0.0.3" }, - "homepage": "https://github.com/shgysk8zer0/npm-template#readme", "devDependencies": { "@aegisjsproject/dev-server": "^1.0.6", "@aegisjsproject/http-utils": "^1.0.4", "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.7.11", - "eslint": "^10.0.2", + "@shgysk8zer0/importmap": "^1.8.1", + "eslint": "^10.0.3", "rollup": "^4.59.0" } } diff --git a/refs.js b/refs.js new file mode 100644 index 0000000..f64df0a --- /dev/null +++ b/refs.js @@ -0,0 +1,34 @@ +let n = 0; + +export class RegistryKey extends String { + #stack = new DisposableStack(); + + get disposed() { + return this.#stack.diposed; + } + + adopt(what, onDiposed) { + return this.#stack.adopt(what, onDiposed); + } + + defer(onDiposed) { + return this.#stack.defer(onDiposed); + } + + use(what) { + return this.#stack.use(what); + } + + [Symbol.dispose]() { + this.#stack.dispose(); + } +} + +/** + * + * @param {string} [prefix="ref-"] + * @param {number} [suffix=Date.now()] + * @returns {RegistryKey} + */ +export const getRef = (prefix = 'ref', suffix = Date.now()) => new RegistryKey(`${prefix}-${n++}-${suffix.toString(16)}`); +export const $ref = getRef; diff --git a/registry.js b/registry.js new file mode 100644 index 0000000..9a788c2 --- /dev/null +++ b/registry.js @@ -0,0 +1,46 @@ +import { Signal } from '@shgysk8zer0/signals'; + +/** + * @type {Map} + */ +const signalReg = new Map(); + +/** + * + * @param {string|String} ref + * @param {Signal.Computed|Signal.State} signal + */ +export function registerSignal(ref, signal) { + if (! (signal instanceof Signal.State || signal instanceof Signal.Computed)) { + throw new TypeError('Signal must be a `Signal.State` or `Signal.Computed`.'); + } else if (signalReg.has(ref)) { + throw new TypeError(`${ref} is already registered.`); + } else if (typeof ref === 'string') { + signalReg.set(ref, signal); + } else if (ref instanceof String) { + signalReg.set(ref.toString(), signal); + } else { + throw new TypeError('Invalid registry key.'); + } +} + +/** + * + * @param {string|String} ref + * @returns {boolean} + */ +export const unregisterSignal = ref => ref instanceof String ? signalReg.delete(ref.toString()) : signalReg.delete(ref); + +/** + * + * @param {string|String} ref + * @returns {Signal.State|undefined} + */ +export const getSignalFromRef = ref => ref instanceof String ? signalReg.get(ref.toString()) : signalReg.get(ref); + +/** + * + * @param {string|String} ref + * @returns {boolean} + */ +export const hasSignalRef = ref => ref instanceof String ? signalReg.has(ref.toString()) : signalReg.has(ref); diff --git a/rollup.config.js b/rollup.config.js index 00e10f0..87010f5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,23 +1,17 @@ import terser from '@rollup/plugin-terser'; +import nodeResolve from '@rollup/plugin-node-resolve'; export default [{ - input: 'index.js', + input: 'iota.js', + plugins: [nodeResolve()], + external: ['@shgysk8zer0/signals'], output: [{ - file: 'index.cjs', + file: 'iota.cjs', format: 'cjs', }, { - file: 'index.min.js', - format: 'iife', + file: 'iota.min.js', + format: 'module', plugins: [terser()], sourcemap: true, - }, { - file: 'index.mjs', - format: 'module', }], -}, { - input: 'consts.js', - output: { - file: 'consts.cjs', - format: 'cjs', - } }]; diff --git a/text.js b/text.js new file mode 100644 index 0000000..660ab5c --- /dev/null +++ b/text.js @@ -0,0 +1,17 @@ +import { DisposableComputed, DisposableState } from './disposable.js'; + +export class TextState extends DisposableState { + toString() { + return ``; + } +} + +export class TextComputed extends DisposableComputed { + toString() { + return ``; + } +} + +export const $text = (val, config) => typeof val === 'function' + ? new TextComputed(val, config) + : new TextState(val, config); diff --git a/watcher.js b/watcher.js new file mode 100644 index 0000000..b73a790 --- /dev/null +++ b/watcher.js @@ -0,0 +1,201 @@ +import { Signal } from '@shgysk8zer0/signals'; +import { hasSignalRef, getSignalFromRef } from './registry.js'; +import { SIGNAL_DATA_ATTR } from './attr.js'; + +const noop = () => undefined; +/** + * @type {boolean} + */ +let updating = false; + +/** + * @typedef {Object} AsyncDisposableStackInterface + * @property {boolean} disposed + * @property {() => Promise} disposeAsync + * @property {(value: T) => T} use + * @property {(value: T, onDispose: (val: T) => void) => T} adopt + * @property {(onDispose: () => void) => void} defer + * @property {() => AsyncDisposableStackInterface} move + */ + +/** + * @template T + * @typedef {Signal.State|Signal.Computed} AnySignal + */ + +/** + * @typedef {Object} SignalObserverParams + * @property {AsyncDisposableStackInterface} stack + * @property {AbortSignal} signal + * @property {number} timeStamp + */ + +/** + * @template T + * @callback SignalObserverCallback + * @param {T} value + * @param {SignalObserverParams} params + * @returns {void} + */ + +/** + * @template T + * @type {WeakMap, Set>>} + */ +const registry = new WeakMap(); + +/** + * @type {Signal.subtle.Watcher} + */ +const watcher = new Signal.subtle.Watcher(function() { + if (! updating) { + updating = true; + + queueMicrotask(async () => { + /** + * @type {AsyncDisposableStackInterface} + */ + const stack = new AsyncDisposableStack(); + const { signal } = stack.adopt(new AbortController(), controller => controller.abort()); + const config = Object.freeze({ stack, signal, timeStamp: performance.now() }); + + await Promise.allSettled(watcher.getPending().flatMap(src => { + const val = src.get(); + + return Array.from( + registry.get(src), + (callback = noop) => Promise.try(callback, val, config) + .finally(() => registry.has(src) && watcher.watch(src)) + ); + })); + + stack.disposeAsync() + .catch(globalThis.reportError?.bind(globalThis)) + .finally(() => updating = false); + }); + } +}); + +/** + * Calls `callback` when `signal` is updated. + * + * @template T + * @param {AnySignal} signal + * @param {SignalObserverCallback} callback + */ +export function watchSignal(signal, callback) { + if (! (signal instanceof Signal.State || signal instanceof Signal.Computed)) { + throw new TypeError('Signal must be a `Signal.State` or `Signal.Computed.'); + } else if (typeof callback !== 'function') { + throw new TypeError('Callback must be a function.'); + } else if (registry.has(signal)) { + registry.get(signal).add(callback); + } else { + registry.set(signal, new Set([callback])); + watcher.watch(signal); + } +} + +export const $watch = watchSignal; + +/** + * Unregister `signal` from the signal registry + * + * @template T + * @param {AnySignal} signal + * @returns {boolean} True if the signal was successfully removed/unwatched + */ +export function unwatchSignal(signal) { + const result = registry.delete(signal); + watcher.unwatch(signal); + return result; +} + +export const $unwatch = unwatchSignal; + +/** + * Unregisters a callback assosciate with a `Signal.State` or `Signal.Computed` + * + * @template T + * @param {AnySignal} signal + * @param {SignalObserverCallback} callback + * @returns {boolean} Whether or not the callback was registered and was removed + */ +export function unwatchSignalCallback(signal, callback) { + if (registry.has(signal)) { + const callbacks = registry.get(signal); + const removed = callbacks.delete(callback); + + if (callbacks.size === 0) { + registry.delete(signal); + watcher.unwatch(signal); + } + + return removed; + } else { + return false; + } +} + + +export function observeTextSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeTextSignalRefs(base.getElementById(root), { stack, signal }); + } else { + const it = root.ownerDocument.createNodeIterator( + root, + NodeFilter.SHOW_COMMENT, + comment => hasSignalRef(comment.textContent.trim()) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + ); + + let comment; + + while ((comment = it.nextNode())) { + const sig = getSignalFromRef(comment.textContent.trim()); + const textNode = root.ownerDocument.createTextNode(sig.get()); + comment.parentElement.replaceChild(textNode, comment); + watchSignal(sig, text => textNode.textContent = text); + stack?.defer?.(sig[Symbol.dispose]?.bind?.(sig)); + signal?.addEventListener?.('abort',sig[Symbol.dispose]?.bind?.(sig), { once: true }); + } + + return root; + } +} + +export function observeAttrSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeAttrSignalRefs(base.getElementById(root), { stack, signal }); + } else { + const els = root.querySelectorAll(`[${SIGNAL_DATA_ATTR}]`); + + for (const el of els) { + const key = el.dataset.attrSignal; + + if (hasSignalRef(key)) { + const sig = getSignalFromRef(key); + const attr = root.ownerDocument.createAttribute(sig.name); + attr.value = sig.get(); + el.setAttributeNode(attr); + el.removeAttribute(SIGNAL_DATA_ATTR); + watchSignal(sig, newVal => attr.value = newVal); + stack?.defer?.(sig[Symbol.dispose]?.bind?.(sig)); + signal?.addEventListener?.('abort', sig[Symbol.dispose]?.bind?.(sig), { once: true }); + } + } + } + + return root; +} + +export function observeSignalRefs(root = document.body, { stack, signal, base = document } = {}) { + if (typeof root === 'string') { + return observeSignalRefs(base.getElementById(root), { stack, signal }); + } else { + observeTextSignalRefs(root, { stack, signal }); + observeAttrSignalRefs(root, { stack, signal }); + return root; + } +} + +export const $observe = observeSignalRefs;