diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4c89f..e10f201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v1.0.4] - 2026-03-15 + +### Changed +- Update `toString()` on signals to avoid need for hydration step + ## [v1.0.3] - 2026-03-14 ### Added diff --git a/attr.js b/attr.js index 08c90b2..f16cc9d 100644 --- a/attr.js +++ b/attr.js @@ -1,7 +1,6 @@ +import { escapeHTML } from '@aegisjsproject/escape/html.js'; import { DisposableComputed, DisposableState } from './disposable.js'; - -export const SIGNAL_DATA_ATTR = 'data-attr-signal'; -export const SIGNAL_DATA_ATTR_SELECTOR = `[${SIGNAL_DATA_ATTR}]`; +import { SIGNAL_DATA_ATTR } from './consts.js'; export class AttrState extends DisposableState { #name; @@ -25,7 +24,15 @@ export class AttrState extends DisposableState { } toString() { - return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + const val = this.get(); + + if (Array.isArray(val)) { + return `${SIGNAL_DATA_ATTR}="${this.ref}" ${this.#name}="${escapeHTML(val.join(' '))}"`; + } else if (val === false) { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } else { + return `${SIGNAL_DATA_ATTR}="${this.ref}" ${this.#name}="${escapeHTML(val)}"`; + } } } @@ -50,7 +57,15 @@ export class AttrComputed extends DisposableComputed { } toString() { - return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + const val = this.get(); + + if (Array.isArray(val)) { + return `${SIGNAL_DATA_ATTR}="${this.ref}" ${this.#name}="${escapeHTML(val.join(' '))}"`; + } else if (val === false) { + return `${SIGNAL_DATA_ATTR}="${this.ref}"`; + } else { + return `${SIGNAL_DATA_ATTR}="${this.ref}" ${this.#name}="${escapeHTML(val)}"`; + } } } diff --git a/consts.js b/consts.js new file mode 100644 index 0000000..d990a9f --- /dev/null +++ b/consts.js @@ -0,0 +1,4 @@ +export const NOOP = () => undefined; +export const ZERO_WIDTH_SPACE = '\u200B'; +export const SIGNAL_DATA_ATTR = 'data-attr-signal'; +export const SIGNAL_DATA_ATTR_SELECTOR = `[${SIGNAL_DATA_ATTR}]`; diff --git a/index.js b/index.js index 9b5d2b6..58f8ccc 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,7 @@ document.adoptedStyleSheets = [properties, theme, misc, forms, btn]; const stack = new DisposableStack(); const controller = stack.adopt(new AbortController(), controller => controller.abort()); const signal = registerSignal(controller.signal); -const $name = stack.use($text('Silly person')); +const $name = stack.use($text('Silly person ')); const $nameAttr = stack.use($data('user-name', () => $name.get())); const $isHidden = stack.use($hidden(false)); const $desc = stack.use($aria('description', () => `Description for ${$name.get()}.`)); diff --git a/list.js b/list.js new file mode 100644 index 0000000..8c1d20c --- /dev/null +++ b/list.js @@ -0,0 +1,53 @@ +import { DisposableState, DisposableComputed } from './disposable.js'; + +const ZERO_WIDTH_SPACE = '\u200B'; + +export class ListComputed extends DisposableComputed { + static REF_PREFIX = '__list_signal'; + + constructor(callback, config) { + super(() => { + const value = callback(); + + if (Array.isArray(value)) { + return value; + } else if (typeof value[Symbol.iterator] === 'function') { + return Array.from(value); + } else { + return [value]; + } + }, config); + } + + map(cb) { + return new ListComputed(cb); + } + + toString() { + return `${ZERO_WIDTH_SPACE}`; + } +} + +export class ListState extends DisposableState { + constructor(value, config) { + if (Array.isArray(value)) { + super(value, config); + } else if (typeof value[Symbol.iterator] === 'function') { + super(Array.from(value), config); + } else { + super([value], config); + } + } + + map(cb) { + return new ListComputed(cb); + } + + toString() { + return `${ZERO_WIDTH_SPACE}`; + } +} + +export const $list = (val, config) => typeof val === 'function' + ? new ListComputed(val, config) + : new ListState(val, config); diff --git a/package-lock.json b/package-lock.json index 8bfc64b..82ce94c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aegisjsproject/iota", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aegisjsproject/iota", - "version": "1.0.3", + "version": "1.0.4", "funding": [ { "type": "librepay", @@ -19,6 +19,7 @@ ], "license": "MIT", "dependencies": { + "@aegisjsproject/escape": "^1.0.4", "@shgysk8zer0/signals": "^0.0.3" }, "devDependencies": { @@ -27,7 +28,7 @@ "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.8.2", + "@shgysk8zer0/importmap": "^1.8.3", "eslint": "^10.0.3", "rollup": "^4.59.0" }, @@ -58,6 +59,25 @@ "node": ">=24.10.0" } }, + "node_modules/@aegisjsproject/escape": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@aegisjsproject/escape/-/escape-1.0.4.tgz", + "integrity": "sha512-xC5F2gVrqsJABEhrQQM1NHVfqLF92McuYDZLVdHBkJz5lo2jaHoPYVIK+x4T7irx07wG/l72ZYlS2/Lf4RvDKw==", + "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/@aegisjsproject/http-utils": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@aegisjsproject/http-utils/-/http-utils-1.0.4.tgz", @@ -887,9 +907,9 @@ } }, "node_modules/@shgysk8zer0/importmap": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@shgysk8zer0/importmap/-/importmap-1.8.2.tgz", - "integrity": "sha512-JNEeuDuCndcHmr8VhmsyPy9Nrm4S//kd8iOfjN+L2QatIjzzAZylslVBBTJrJmuhA7t0wcnkCBShsgDqMogZ8g==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/importmap/-/importmap-1.8.3.tgz", + "integrity": "sha512-tbeziosHVvCIqqX3LEYH1KZxSZ1xniT3gVj2XJRAQRxUPknnZgT0+CeyO0PnXGnGjETbmtad7T0RmYqMgZNZmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1894,6 +1914,11 @@ "@shgysk8zer0/importmap": "^1.8.1" } }, + "@aegisjsproject/escape": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@aegisjsproject/escape/-/escape-1.0.4.tgz", + "integrity": "sha512-xC5F2gVrqsJABEhrQQM1NHVfqLF92McuYDZLVdHBkJz5lo2jaHoPYVIK+x4T7irx07wG/l72ZYlS2/Lf4RvDKw==" + }, "@aegisjsproject/http-utils": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@aegisjsproject/http-utils/-/http-utils-1.0.4.tgz", @@ -2339,9 +2364,9 @@ } }, "@shgysk8zer0/importmap": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@shgysk8zer0/importmap/-/importmap-1.8.2.tgz", - "integrity": "sha512-JNEeuDuCndcHmr8VhmsyPy9Nrm4S//kd8iOfjN+L2QatIjzzAZylslVBBTJrJmuhA7t0wcnkCBShsgDqMogZ8g==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@shgysk8zer0/importmap/-/importmap-1.8.3.tgz", + "integrity": "sha512-tbeziosHVvCIqqX3LEYH1KZxSZ1xniT3gVj2XJRAQRxUPknnZgT0+CeyO0PnXGnGjETbmtad7T0RmYqMgZNZmQ==", "dev": true, "requires": { "@shgysk8zer0/npm-utils": "^1.1.4", diff --git a/package.json b/package.json index 346473c..3b41295 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aegisjsproject/iota", - "version": "1.0.3", + "version": "1.0.4", "description": "A Signals-based reactivity library", "keywords": [ "signals", @@ -76,6 +76,7 @@ }, "homepage": "https://github.com/AegisJSProject/iota#readme", "dependencies": { + "@aegisjsproject/escape": "^1.0.4", "@shgysk8zer0/signals": "^0.0.3" }, "devDependencies": { @@ -84,7 +85,7 @@ "@rollup/plugin-terser": "^1.0.0", "@shgysk8zer0/eslint-config": "^1.0.7", "@shgysk8zer0/http-server": "^1.1.1", - "@shgysk8zer0/importmap": "^1.8.2", + "@shgysk8zer0/importmap": "^1.8.3", "eslint": "^10.0.3", "rollup": "^4.59.0" } diff --git a/reactive-element.js b/reactive-element.js new file mode 100644 index 0000000..a7e198b --- /dev/null +++ b/reactive-element.js @@ -0,0 +1,208 @@ +import { createHTMLParser } from '@aegisjsproject/core/parsers/html.js'; +import { css } from '@aegisjsproject/core/parsers/css.js'; +import { sanitizer as sanitizerConfig } from '@aegisjsproject/sanitizer/config/base.js'; +import { $render, $text } from '@aegisjsproject/iota'; + + +// Need to force-allow comments for text signals until change is made in lib +const html = createHTMLParser({ + ...sanitizerConfig, + comments: true, +}); + +export class ReactiveElement extends HTMLElement { + #shadow = this.attachShadow({ mode: 'open' }); + #internals = this.attachInternals(); + #stack = new DisposableStack(); + #controller = this.#stack.adopt(new AbortController(), controller => controller.abort()); + + constructor() { + super(); + + if (this.styles instanceof CSSStyleSheet) { + this.#shadow.adoptedStyleSheets = [this.styles]; + } else if (Array.isArray(this.styles)) { + this.#shadow.adoptedStyleSheets = this.styles; + } else if (typeof this.styles === 'string') { + this.#shadow.adoptedStyleSheets = [css`${this.styles}`]; + } + + if (typeof this.render === 'function') { + this.render('constructed', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + $render, html, css, + }); + } + } + + connectedCallback() { + if (this.#stack.disposed) { + this.#stack = new DisposableStack(); + } + + if (this.#controller.signal.aborted) { + this.#controller = this.#stack.adopt(new AbortController(), controller => controller.abort()); + } + + if (this.html instanceof Node) { + $render(this.html, this.#shadow); + } else if (typeof this.html === 'string') { + $render(html`${this.html}`, this.#shadow); + } + + if (typeof this.render === 'function') { + this.render('connected', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + $render, html, css, + }); + } + } + + adoptedCallback() { + if (typeof this.render === 'function') { + this.render('adopted', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + }); + } + } + + disconnectedCallback() { + if (typeof this.render === 'function') { + this.render('disconnected', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + }); + } + + this.#stack.dispose(); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (typeof this.render === 'function') { + this.render('attributeChanged', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + attribute: { name, oldValue, newValue }, + }); + } + } + + handleEvent(event) { + if (typeof this.render === 'function') { + this.render('eventDispatched', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + event, + }); + } + } + + [Symbol.dispose]() { + if (typeof this.render === 'function') { + this.render('dispose', { + stack: this.#stack, + signal: this.#controller.signal, + shadow: this.#shadow, + internals: this.#internals, + }); + } + this.#stack.dispose(); + } + + adopt(what, onDispose) { + return this.#stack.adopt(what, onDispose); + } + + use(what) { + return this.#stack.use(what); + } + + get disposed() { + return this.#stack.disposed; + } + + get signal() { + return this.#controller.signal; + } + + static register(tagName) { + if (typeof tagName !== 'string' || ! tagName.includes('-')) { + throw new TypeError('Tag name must be a non-empty string with a "-".'); + } else if (! customElements.get(tagName)) { + customElements.define(tagName, this); + } + } +} + +export class FancyCounter extends ReactiveElement { + #count = $text(0); + + get count() { return Number(this.#count.get()); } + + set count(val) { + this.#count.set(val); + } + + increment(by = 1) { + this.count += by; + } + + get styles() { + return css` + :host { display: inline-block; padding: 1rem; border: 1px solid #ccc; border-radius: 8px; } + button { cursor: pointer; padding: 0.5rem 1rem; } + button:disabled { opacity: 0.5; cursor: not-allowed; } + :host([inert]) button { opacity: 0.5; cursor: not-allowed; } + `; + } + + get html() { + return html` +

Native Counter

+ + + `; + } + + render(phase, { stack, shadow, signal, event }) { + if (phase === 'connected') { + stack.use(this.#count); + shadow.getElementById('inc-btn').commandForElement = this; + shadow.getElementById('del-btn').commandForElement = this; + this.addEventListener('command', this, { signal }); + } else if (phase === 'eventDispatched' && event.type === 'command') { + switch(event.command) { + case '--increment': + this.increment(); + break; + + case '--dispose': + this[Symbol.dispose](); + break; + } + } else if (phase === 'disposed') { + this.inert = true; + } + } +} + +FancyCounter.register('fancy-counter'); diff --git a/text.js b/text.js index 660ab5c..7a17d19 100644 --- a/text.js +++ b/text.js @@ -1,14 +1,16 @@ +import { escapeHTML } from '@aegisjsproject/escape/html.js'; import { DisposableComputed, DisposableState } from './disposable.js'; +import { ZERO_WIDTH_SPACE } from './consts.js'; export class TextState extends DisposableState { toString() { - return ``; + return `${escapeHTML(this.get() ?? ZERO_WIDTH_SPACE)}`; } } export class TextComputed extends DisposableComputed { toString() { - return ``; + return `${escapeHTML(this.get() ?? ZERO_WIDTH_SPACE)}`; } } diff --git a/watcher.js b/watcher.js index 57e1113..0c06cb3 100644 --- a/watcher.js +++ b/watcher.js @@ -1,6 +1,6 @@ import { Signal } from '@shgysk8zer0/signals'; import { hasSignalRef, getSignalFromRef } from './registry.js'; -import { SIGNAL_DATA_ATTR_SELECTOR, SIGNAL_DATA_ATTR } from './attr.js'; +import { SIGNAL_DATA_ATTR_SELECTOR, SIGNAL_DATA_ATTR } from './consts.js'; const ATTR_OWNER_KEY = Symbol('Attr:owner'); @@ -158,15 +158,21 @@ export function observeTextSignalRefs(root = document.body, { stack, signal, bas const it = root.ownerDocument.createNodeIterator( root, NodeFilter.SHOW_COMMENT, - comment => hasSignalRef(comment.textContent.trim()) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT + comment => hasSignalRef(comment.textContent) + && comment.nextSibling?.nodeType === Node.TEXT_NODE + && comment.nextSibling.nextSibling?.nodeType === Node.COMMENT_NODE + ? 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); + // Get the text node between the comments & remove the surrounding comments + const textNode = comment.nextSibling; + textNode.nextSibling.remove(); + comment.remove(); watchSignal(sig, text => textNode.textContent = text); stack?.defer?.(sig[Symbol.dispose]?.bind?.(sig)); signal?.addEventListener?.('abort',sig[Symbol.dispose]?.bind?.(sig), { once: true }); @@ -193,15 +199,7 @@ export function observeAttrSignalRefs(root = document.body, { stack, signal, bas if (hasSignalRef(key)) { const sig = getSignalFromRef(key); - const attr = root.ownerDocument.createAttribute(sig.name); - const val = sig.get(); - - if (val !== false) { - const val = sig.get(); - attr.value = Array.isArray(val) ? val.join(' ') : val; - console.log(attr); - el.setAttributeNode(attr); - } + const attr = el.hasAttribute(sig.name) ? el.getAttributeNode(sig.name) : root.ownerDocument.createAttribute(sig.name); Object.defineProperty(attr, ATTR_OWNER_KEY, { value: el, enumerable: false, writable: false, configurable: false }); el.removeAttribute(SIGNAL_DATA_ATTR);