feat(component): add @choojs/component — nanocomponent + on-load as ES classes
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
co-authored by
Claude Fable 5
parent
84230b1770
commit
c696e00d4f
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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`<h1>${text}</h1>`
|
||||||
|
}
|
||||||
|
|
||||||
|
update () { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = new Title().render('hello')
|
||||||
|
assert.strictEqual(res.toString(), '<h1>hello</h1>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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`<div>count ${n}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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`<main>${inner}</main>`
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "A 4kb framework for creating sturdy frontend applications",
|
"description": "A 4kb framework for creating sturdy frontend applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.js"
|
".": "./index.js",
|
||||||
|
"./timing": "./lib/timing.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user