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.
-[](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml)
-
-
+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.
-[](https://github.com/shgysk8zer0/npm-template/blob/master/LICENSE)
-[](https://github.com/shgysk8zer0/npm-template/commits/master)
-[](https://github.com/shgysk8zer0/npm-template/releases)
+[](https://github.com/AegisJSProject/iota/actions/workflows/codeql-analysis.yml)
+
+
+
+[](https://github.com/AegisJSProject/iota/blob/master/LICENSE)
+[](https://github.com/AegisJSProject/iota/commits/master)
+[](https://github.com/AegisJSProject/iota/releases)
[](https://github.com/sponsors/shgysk8zer0)
-[](https://www.npmjs.com/package/@shgysk8zer0/npm-template)
-
-
-[](https://www.npmjs.com/package/@shgysk8zer0/npm-template)
+[](https://www.npmjs.com/package/@aegisjsproject/iota)
+
+
+[](https://www.npmjs.com/package/@aegisjsproject/iota)
[](https://github.com/shgysk8zer0)
-
-
+
+
[](https://twitter.com/shgysk8zer0)
[](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;