From c696e00d4facfb595953e4b0a84dbff678613716 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 8 Sep 2026 16:56:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(component):=20add=20@choojs/component=20?= =?UTF-8?q?=E2=80=94=20nanocomponent=20+=20on-load=20as=20ES=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from nanocomponent 6.6.0 and on-load 3.4.1 (MIT) with attribution: render/update/morph lifecycle, proxy nodes with isSameNode identity, and the MutationObserver-driven load/unload hooks. This class is the intended island/hydration boundary for later phases. @choojs/core exposes ./timing as a subpath for the instrumentation calls. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd --- packages/component/index.js | 157 ++++++++++++++++++++++ packages/component/onload.js | 106 +++++++++++++++ packages/component/package.json | 29 ++++ packages/component/test/component.test.js | 72 ++++++++++ packages/core/package.json | 3 +- 5 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 packages/component/index.js create mode 100644 packages/component/onload.js create mode 100644 packages/component/package.json create mode 100644 packages/component/test/component.test.js diff --git a/packages/component/index.js b/packages/component/index.js new file mode 100644 index 0000000..bcaf2d9 --- /dev/null +++ b/packages/component/index.js @@ -0,0 +1,157 @@ +// Ported from nanocomponent 6.6.0 (MIT) — https://github.com/choojs/nanocomponent +// Native DOM components: render once, morph on update, lifecycle hooks via +// onload. In v8 this class is also the intended island/hydration boundary. + +import morph from '@choojs/html/morph' +import nanotiming from '@choojs/core/timing' +import onload, { KEY_ATTR } from './onload.js' + +function makeID () { + return 'ncid-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) +} + +export default class Component { + static makeID = makeID + + constructor (name) { + this._hasWindow = typeof window !== 'undefined' + this._id = null // represents the id of the root node + this._ncID = null // internal component id + this._olID = null + this._proxy = null + this._loaded = false // Used to debounce on-load when child-reordering + this._rootNodeName = null + this._name = name || 'component' + this._rerender = false + + this._handleLoad = this._handleLoad.bind(this) + this._handleUnload = this._handleUnload.bind(this) + + this._arguments = [] + } + + get element () { + if (!this._hasWindow) return undefined + const el = document.getElementById(this._id) + if (el) return el.dataset.nanocomponent === this._ncID ? el : undefined + } + + render (...args) { + const renderTiming = nanotiming(this._name + '.render') + let el + + if (!this._hasWindow) { + const createTiming = nanotiming(this._name + '.create') + el = this.createElement(...args) + createTiming() + renderTiming() + return el + } else if (this.element) { + el = this.element // retain reference, as the ID might change on render + const updateTiming = nanotiming(this._name + '.update') + const shouldUpdate = this._rerender || this.update(...args) + updateTiming() + if (this._rerender) this._rerender = false + if (shouldUpdate) { + const desiredHtml = this._handleRender(args) + const morphTiming = nanotiming(this._name + '.morph') + morph(el, desiredHtml) + morphTiming() + if (this.afterupdate) this.afterupdate(el) + } + if (!this._proxy) { this._proxy = this._createProxy() } + renderTiming() + return this._proxy + } else { + this._reset() + el = this._handleRender(args) + if (this.beforerender) this.beforerender(el) + if (this.load || this.unload || this.afterreorder) { + onload(el, this._handleLoad, this._handleUnload, this._ncID) + this._olID = el.getAttribute(KEY_ATTR) + } + renderTiming() + return el + } + } + + rerender () { + if (!this.element) throw new Error('component: cant rerender on an unmounted dom node') + this._rerender = true + this.render(...this._arguments) + } + + _handleRender (args) { + const createElementTiming = nanotiming(this._name + '.createElement') + const el = this.createElement(...args) + createElementTiming() + if (!this._rootNodeName) this._rootNodeName = el.nodeName + if (!(el instanceof window.Element)) { + throw new Error('component: createElement should return a single DOM node') + } + if (this._rootNodeName !== el.nodeName) { + throw new Error('component: root node types cannot differ between re-renders') + } + this._arguments = args + return this._brandNode(this._ensureID(el)) + } + + _createProxy () { + const proxy = document.createElement(this._rootNodeName) + const self = this + this._brandNode(proxy) + proxy.id = this._id + proxy.setAttribute('data-proxy', '') + proxy.isSameNode = function (el) { + return (el && el.dataset.nanocomponent === self._ncID) + } + return proxy + } + + _reset () { + this._ncID = makeID() + this._olID = null + this._id = null + this._proxy = null + this._rootNodeName = null + } + + _brandNode (node) { + node.setAttribute('data-nanocomponent', this._ncID) + if (this._olID) node.setAttribute(KEY_ATTR, this._olID) + return node + } + + _ensureID (node) { + if (node.id) this._id = node.id + else node.id = this._id = this._ncID + // Update proxy node ID if it changed + if (this._proxy && this._proxy.id !== this._id) this._proxy.id = this._id + return node + } + + _handleLoad (el) { + if (this._loaded) { + if (this.afterreorder) this.afterreorder(el) + return // Debounce child-reorders + } + this._loaded = true + if (this.load) this.load(el) + } + + _handleUnload (el) { + if (this.element) return // Debounce child-reorders + this._loaded = false + if (this.unload) this.unload(el) + } + + createElement () { + throw new Error('component: createElement should be implemented!') + } + + update () { + throw new Error('component: update should be implemented!') + } +} + +export { Component, makeID } diff --git a/packages/component/onload.js b/packages/component/onload.js new file mode 100644 index 0000000..b053785 --- /dev/null +++ b/packages/component/onload.js @@ -0,0 +1,106 @@ +// Ported from on-load 3.4.1 (MIT) — https://github.com/shama/on-load +// Fire load/unload callbacks when watched nodes enter or leave the DOM, +// driven by a single document-wide MutationObserver. + +const watch = Object.create(null) +const KEY_ID = 'onloadid' + Math.random().toString(36).slice(2, 8) +const KEY_ATTR = 'data-' + KEY_ID +let INDEX = 0 + +if (typeof window !== 'undefined' && window.MutationObserver) { + const observer = new MutationObserver(function (mutations) { + if (Object.keys(watch).length < 1) return + for (let i = 0; i < mutations.length; i++) { + if (mutations[i].attributeName === KEY_ATTR) { + eachAttr(mutations[i], turnon, turnoff) + continue + } + eachMutation(mutations[i].removedNodes, turnoff) + eachMutation(mutations[i].addedNodes, turnon) + } + }) + if (document.body) { + beginObserve(observer) + } else { + document.addEventListener('DOMContentLoaded', function () { + beginObserve(observer) + }) + } +} + +function beginObserve (observer) { + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true, + attributeFilter: [KEY_ATTR] + }) +} + +export default function onload (el, on, off, caller) { + if (!document.body) throw new Error('onload: will not work prior to DOMContentLoaded') + on = on || function () {} + off = off || function () {} + el.setAttribute(KEY_ATTR, 'o' + INDEX) + watch['o' + INDEX] = [on, off, 0, caller] + INDEX += 1 + return el +} + +onload.KEY_ATTR = KEY_ATTR +onload.KEY_ID = KEY_ID + +export { KEY_ATTR, KEY_ID } + +function turnon (index, el) { + if (watch[index][0] && watch[index][2] === 0) { + watch[index][0](el) + watch[index][2] = 1 + } +} + +function turnoff (index, el) { + if (watch[index][1] && watch[index][2] === 1) { + watch[index][1](el) + watch[index][2] = 0 + } +} + +function eachAttr (mutation, on, off) { + const newValue = mutation.target.getAttribute(KEY_ATTR) + if (sameOrigin(mutation.oldValue, newValue)) { + watch[newValue] = watch[mutation.oldValue] + return + } + if (watch[mutation.oldValue]) { + off(mutation.oldValue, mutation.target) + } + if (watch[newValue]) { + on(newValue, mutation.target) + } +} + +function sameOrigin (oldValue, newValue) { + if (!oldValue || !newValue) return false + if (!watch[oldValue] || !watch[newValue]) return false + return watch[oldValue][3] === watch[newValue][3] +} + +function eachMutation (nodes, fn) { + const keys = Object.keys(watch) + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node && node.getAttribute && node.getAttribute(KEY_ATTR)) { + const onloadid = node.getAttribute(KEY_ATTR) + keys.forEach(function (k) { + if (onloadid === k) { + fn(k, node) + } + }) + } + if (node.childNodes && node.childNodes.length > 0) { + eachMutation(node.childNodes, fn) + } + } +} diff --git a/packages/component/package.json b/packages/component/package.json new file mode 100644 index 0000000..f314454 --- /dev/null +++ b/packages/component/package.json @@ -0,0 +1,29 @@ +{ + "name": "@choojs/component", + "version": "8.0.0-dev", + "description": "Native DOM components with lifecycle hooks — the choo island boundary", + "type": "module", + "exports": { + ".": "./index.js", + "./onload": "./onload.js" + }, + "files": [ + "index.js", + "onload.js" + ], + "engines": { + "node": ">=24" + }, + "dependencies": { + "@choojs/core": "*", + "@choojs/html": "*" + }, + "repository": "choojs/choo", + "keywords": [ + "component", + "dom", + "lifecycle", + "choo" + ], + "license": "MIT" +} diff --git a/packages/component/test/component.test.js b/packages/component/test/component.test.js new file mode 100644 index 0000000..e646717 --- /dev/null +++ b/packages/component/test/component.test.js @@ -0,0 +1,72 @@ +// Component behavior in both worlds: plain string rendering without a DOM, +// and mount/update/morph/proxy semantics inside happy-dom. + +import { test, before } from 'node:test' +import assert from 'node:assert' +import { Window } from 'happy-dom' + +import serverHtml from '@choojs/html/server' + +test('renders via createElement on the server (no window)', async () => { + const { default: Component } = await import('../index.js') + + class Title extends Component { + createElement (text) { + return serverHtml`

${text}

` + } + + update () { return false } + } + + const res = new Title().render('hello') + assert.strictEqual(res.toString(), '

hello

') +}) + +test('browser: render, mount, update morphs in place, proxy stands in', async () => { + const win = new Window() + globalThis.window = win + globalThis.document = win.document + // module registry caches per specifier; component/index.js re-evaluates + // _hasWindow per instance, so importing after globals are set is enough + const { default: Component } = await import('../index.js?browser') + const { default: html } = await import('@choojs/html/browser') + const { default: morph } = await import('@choojs/html/morph') + + class Counter extends Component { + createElement (n) { + this.n = n + return html`
count ${n}
` + } + + update (n) { + return n !== this.n + } + } + + const counter = new Counter('counter') + + const el = counter.render(0) + assert.ok(el.getAttribute('data-nanocomponent'), 'root node branded') + document.body.appendChild(el) + assert.strictEqual(counter.element, el, 'element getter finds mounted node') + + // unchanged args: update() false, no morph, proxy returned + const proxy = counter.render(0) + assert.ok(proxy.hasAttribute('data-proxy'), 'returns proxy while mounted') + assert.ok(proxy.isSameNode(el), 'proxy claims identity of the mounted node') + + // changed args: update() true, mounted node morphs in place + counter.render(1) + assert.strictEqual(el.textContent, 'count 1', 'mounted node updated in place') + + // and the proxy plays correctly with nanomorph in a parent view + const parent = (n) => { + const inner = counter.render(n) + return html`
${inner}
` + } + const treeA = parent(1) + // simulate first full-page render: mounted component moves into the tree + assert.ok(treeA.querySelector('[data-proxy]') || treeA.contains(el)) + + document.body.removeChild(el) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 98c6d58..78720e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,7 +4,8 @@ "description": "A 4kb framework for creating sturdy frontend applications", "type": "module", "exports": { - ".": "./index.js" + ".": "./index.js", + "./timing": "./lib/timing.js" }, "files": [ "index.js",