a post-component library for building user-interfaces on the web.

de-classification (#46)

* class Root -> function createRoot

* class BoundTemplateInstance -> function html

* inline TemplateInstance

* class WhateverPart -> function createWhateverPart

* remove create from Part, make it a createPart param

* uncurry createPart

* unify part creation/update

* class Span -> function createSpan, standalone methods

* some assert magic

* jsdoc param -> jsdoc type on arg

* move childpart update into the return

authored by tombl.dev and committed by

GitHub a828c834 220a92f9

+312 -380
+3 -1
src/html.d.ts
··· 1 - import type { BoundTemplateInstance, Cleanup, Directive, Displayable, Key, Renderable } from './types.ts' 1 + import type { BoundTemplateInstance, Cleanup, Directive, Displayable, Key, Renderable, Span } from './types.ts' 2 2 3 3 export { Directive, Displayable, Renderable } 4 4 ··· 10 10 export function getParentNode(renderable: Renderable): Node 11 11 12 12 export interface Root { 13 + /* @internal */ _span: Span 14 + /* @internal */ _key: unknown 13 15 render(value: Displayable): void 14 16 detach(): void 15 17 }
+302 -373
src/html.js
··· 4 4 Directive, 5 5 Displayable, 6 6 Key, 7 - Part, 8 7 Renderable, 9 - Span as SpanInstance 8 + Span, 10 9 } from './types' */ 11 10 12 11 const DEV = typeof DHTML_PROD === 'undefined' || !DHTML_PROD ··· 33 32 /** @return {value is Iterable<unknown>} */ 34 33 const isIterable = value => typeof value === 'object' && value !== null && Symbol.iterator in value 35 34 36 - export const html = (statics, ...dynamics) => new BoundTemplateInstance(statics, dynamics) 35 + /** @return {value is ReturnType<typeof html>} */ 36 + const isHtml = value => value?.$ === html 37 + 38 + export function html(/** @type {TemplateStringsArray} */ statics, /** @type {unknown[]} */ ...dynamics) { 39 + /** @type {CompiledTemplate} */ let template 40 + 41 + if (DEV) { 42 + assert( 43 + compileTemplate(statics)._parts.length === dynamics.length, 44 + 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 45 + ) 46 + } 47 + 48 + return { 49 + $: html, 50 + _dynamics: dynamics, 51 + get _template() { 52 + return (template ??= compileTemplate(statics)) 53 + }, 54 + } 55 + } 37 56 38 57 const singlePartTemplate = part => html`${part}` 39 58 40 59 /* v8 ignore start */ 41 60 /** @return {asserts value} */ 42 - const assert = (value, message = 'assertion failed') => { 61 + function assert(value, message) { 43 62 if (!DEV) return 44 - if (!value) throw new Error(message) 63 + if (!value) throw new Error(message ?? 'assertion failed') 45 64 } 46 65 /* v8 ignore stop */ 47 66 48 - /** @implements {SpanInstance} */ 49 - class Span { 50 - /** 51 - * @param {Node} node the only node in the span 52 - */ 53 - constructor(node) { 54 - DEV: assert(node.parentNode !== null) 55 - this._parentNode = node.parentNode 56 - this._start = this._end = node 67 + /** @returns {Span} */ 68 + function createSpan(node) { 69 + DEV: assert(node.parentNode !== null) 70 + return { 71 + _parentNode: node.parentNode, 72 + _start: node, 73 + _end: node, 74 + _marker: null, 57 75 } 76 + } 58 77 59 - _deleteContents() { 60 - this._marker = new Text() 61 - this._parentNode.insertBefore(this._marker, this._start) 78 + function spanInsertNode(/** @type {Span} */ span, /** @type {Node} */ node) { 79 + const end = isDocumentFragment(node) ? node.lastChild : node 80 + if (end === null) return // empty fragment 81 + span._parentNode.insertBefore(node, span._end.nextSibling) 82 + span._end = end 62 83 63 - for (const node of this) this._parentNode.removeChild(node) 84 + if (span._start === span._marker) { 85 + DEV: assert(span._start.nextSibling) 86 + span._start = span._start.nextSibling 64 87 65 - this._start = this._end = this._marker 88 + span._parentNode.removeChild(span._marker) 89 + span._marker = null 66 90 } 67 - 68 - /** @param {Node} node */ 69 - _insertNode(node) { 70 - const end = isDocumentFragment(node) ? node.lastChild : node 71 - if (end === null) return // empty fragment 72 - this._parentNode.insertBefore(node, this._end.nextSibling) 73 - this._end = end 91 + } 74 92 75 - if (this._start === this._marker) { 76 - DEV: assert(this._start.nextSibling) 77 - this._start = this._start.nextSibling 78 - 79 - this._parentNode.removeChild(this._marker) 80 - this._marker = null 81 - } 93 + function* spanIterator(/** @type {Span} */ span) { 94 + let node = span._start 95 + for (;;) { 96 + const next = node.nextSibling 97 + yield node 98 + if (node === span._end) return 99 + assert(next, 'expected more siblings') 100 + node = next 82 101 } 83 - *[Symbol.iterator]() { 84 - let node = this._start 85 - for (;;) { 86 - const next = node.nextSibling 87 - yield node 88 - if (node === this._end) return 89 - assert(next, 'expected more siblings') 90 - node = next 91 - } 92 - } 93 - _extractContents() { 94 - this._marker = new Text() 95 - this._parentNode.insertBefore(this._marker, this._start) 102 + } 96 103 97 - const fragment = document.createDocumentFragment() 98 - for (const node of this) fragment.appendChild(node) 104 + function spanExtractContents(/** @type {Span} */ span) { 105 + span._marker = new Text() 106 + span._parentNode.insertBefore(span._marker, span._start) 99 107 100 - this._start = this._end = this._marker 101 - return fragment 102 - } 103 - } 108 + const fragment = document.createDocumentFragment() 109 + for (const node of spanIterator(span)) fragment.appendChild(node) 104 110 105 - /* v8 ignore start */ 106 - if (DEV) { 107 - Span.prototype.toString = function () { 108 - let result = '' 109 - for (const node of this) 110 - result += isElement(node) 111 - ? node.outerHTML 112 - : `${node.constructor.name}(${'data' in node ? JSON.stringify(node.data) : node})` 113 - return result 114 - } 111 + span._start = span._end = span._marker 112 + return fragment 115 113 } 116 - /* v8 ignore stop */ 117 - 118 - class BoundTemplateInstance { 119 - /** @type {CompiledTemplate | undefined} */ #template 120 - /** @type {TemplateStringsArray} */ #statics 121 114 122 - get _template() { 123 - return (this.#template ??= compileTemplate(this.#statics)) 124 - } 115 + function spanDeleteContents(/** @type {Span} */ span) { 116 + span._marker = new Text() 117 + span._parentNode.insertBefore(span._marker, span._start) 125 118 126 - constructor(statics, dynamics) { 127 - this.#statics = statics 128 - this._dynamics = dynamics 119 + for (const node of spanIterator(span)) span._parentNode.removeChild(node) 129 120 130 - // eagerly compile the template in DEV, plus some extra checks. 131 - DEV: assert( 132 - this._template._parts.length === dynamics.length, 133 - 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', 134 - ) 135 - } 121 + span._start = span._end = span._marker 136 122 } 137 123 138 - /** @param {ParentNode} parent */ 139 - export function createRoot(parent) { 124 + function createRootInto(/** @type {ParentNode} */ parent) { 140 125 const marker = new Text() 141 126 parent.appendChild(marker) 142 - return new Root(new Span(marker)) 127 + return createRoot(createSpan(marker)) 143 128 } 129 + export { createRootInto as createRoot } 144 130 145 - /** @param {Node} node */ 146 - function insertRootAfter(node) { 131 + function createRootAfter(/** @type {Node} */ node) { 147 132 DEV: assert(node.parentNode, 'expected a parent node') 148 133 const marker = new Text() 149 134 node.parentNode.insertBefore(marker, node.nextSibling) 150 - return new Root(new Span(marker)) 135 + return createRoot(createSpan(marker)) 151 136 } 152 137 153 - class Root { 154 - /** @type {Key | undefined} */ _key 138 + function createRoot(/** @type {Span} */ span) { 139 + let template, parts 155 140 156 - /** @param {Span} span */ 157 - constructor(span) { 158 - this._span = span 141 + function detach() { 142 + if (!parts) return 143 + // scan through all the parts of the previous tree, and clear any renderables. 144 + for (const [_idx, part] of parts) part.detach() 145 + parts = undefined 159 146 } 160 147 161 - render(value) { 162 - const t = value instanceof BoundTemplateInstance ? value : singlePartTemplate(value) 148 + return { 149 + _span: span, 150 + /** @type {Key | undefined} */ _key: undefined, 163 151 164 - if (this._instance?._template === t._template) { 165 - this._instance.update(t._dynamics) 166 - } else { 167 - this.detach() 168 - this._instance = new TemplateInstance(t._template, t._dynamics, this._span) 169 - } 170 - } 152 + render: value => { 153 + const html = isHtml(value) ? value : singlePartTemplate(value) 154 + 155 + if (template !== html._template) { 156 + detach() 157 + 158 + template = html._template 171 159 172 - detach() { 173 - if (!this._instance) return 174 - // scan through all the parts of the previous tree, and clear any renderables. 175 - for (const [_idx, part] of this._instance._parts) part.detach() 176 - delete this._instance 177 - } 178 - } 160 + const doc = /** @type {DocumentFragment} */ (template._content.cloneNode(true)) 179 161 180 - class TemplateInstance { 181 - /** 182 - * @param {CompiledTemplate} template 183 - * @param {Displayable[]} dynamics 184 - * @param {Span} span 185 - */ 186 - constructor(template, dynamics, span) { 187 - this._template = template 188 - const doc = /** @type {DocumentFragment} */ (template._content.cloneNode(true)) 162 + const nodeByPart = [] 163 + for (const node of doc.querySelectorAll('[data-dynparts]')) { 164 + const parts = node.getAttribute('data-dynparts') 165 + assert(parts) 166 + node.removeAttribute('data-dynparts') 167 + for (const part of parts.split(' ')) nodeByPart[part] = node 168 + } 189 169 190 - const nodeByPart = [] 191 - for (const node of doc.querySelectorAll('[data-dynparts]')) { 192 - const parts = node.getAttribute('data-dynparts') 193 - assert(parts) 194 - node.removeAttribute('data-dynparts') 195 - for (const part of parts.split(' ')) nodeByPart[part] = node 196 - } 170 + for (const part of template._rootParts) nodeByPart[part] = span 197 171 198 - for (const part of template._rootParts) nodeByPart[part] = span 172 + // the fragment must be inserted before the parts are constructed, 173 + // because they need to know their final location. 174 + // this also ensures that custom elements are upgraded before we do things 175 + // to them, like setting properties or attributes. 176 + spanDeleteContents(span) 177 + spanInsertNode(span, doc) 199 178 200 - // the fragment must be inserted before the parts are constructed, 201 - // because they need to know their final location. 202 - // this also ensures that custom elements are upgraded before we do things 203 - // to them, like setting properties or attributes. 204 - span._deleteContents() 205 - span._insertNode(doc) 179 + parts = html._template._parts.map(([dynamicIdx, createPart], elementIdx) => [ 180 + dynamicIdx, 181 + createPart(nodeByPart[elementIdx], span), 182 + ]) 183 + } 206 184 207 - this._parts = template._parts.map(([dynamicIdx, createPart], elementIdx) => { 208 - const part = createPart(span) 209 - part.create(nodeByPart[elementIdx], dynamics[dynamicIdx]) 210 - return /** @type {const} */ ([dynamicIdx, part]) 211 - }) 212 - } 185 + for (const [idx, part] of parts) part.update(html._dynamics[idx]) 186 + }, 213 187 214 - update(dynamics) { 215 - for (const [idx, part] of this._parts) part.update(dynamics[idx]) 188 + detach, 216 189 } 217 190 } 218 191 ··· 222 195 223 196 /** @type {Map<TemplateStringsArray, CompiledTemplate>} */ 224 197 const templates = new Map() 225 - /** @param {TemplateStringsArray} statics */ 226 - function compileTemplate(statics) { 198 + function compileTemplate(/** @type {TemplateStringsArray} */ statics) { 227 199 const cached = templates.get(statics) 228 200 if (cached) return cached 229 201 ··· 269 241 let siblings = [...node.parentNode.childNodes] 270 242 for (const [node, idx] of nodes) { 271 243 const child = siblings.indexOf(node) 272 - patch(node.parentNode, idx, span => new ChildPart(child, span)) 244 + patch(node.parentNode, idx, (node, span) => createChildPart(node, span, child)) 273 245 } 274 246 } 275 247 } else if (DEV && isComment(node)) { ··· 278 250 // issues with incorrect part counts. 279 251 // in production the check is skipped, so we can also skip this. 280 252 for (const _match of node.data.matchAll(DYNAMIC_GLOBAL)) { 281 - compiled._parts[nextPart++] = [parseInt(_match[1]), () => ({ create() {}, update() {}, detach() {} })] 253 + compiled._parts[nextPart++] = [parseInt(_match[1]), () => ({ update() {}, detach() {} })] 282 254 } 283 255 } else { 284 256 assert(isElement(node)) ··· 292 264 // directive: 293 265 toRemove.push(name) 294 266 DEV: assert(value === '', `directives must not have values`) 295 - patch(node, parseInt(match[1]), () => new DirectivePart()) 267 + patch(node, parseInt(match[1]), node => createDirectivePart(node)) 296 268 } else { 297 269 // properties: 298 270 match = DYNAMIC_WHOLE.exec(value) 299 271 if (match !== null) { 300 272 toRemove.push(name) 301 273 if (FORCE_ATTRIBUTES.test(name)) { 302 - patch(node, parseInt(match[1]), () => new AttributePart(name)) 274 + patch(node, parseInt(match[1]), node => createAttributePart(node, name)) 303 275 } else { 304 276 if (!(name in node)) { 305 277 for (const property in node) { ··· 309 281 } 310 282 } 311 283 } 312 - patch(node, parseInt(match[1]), () => new PropertyPart(name)) 284 + patch(node, parseInt(match[1]), node => createPropertyPart(node, name)) 313 285 } 314 286 } else if (DEV) { 315 287 assert(!DYNAMIC_GLOBAL.test(value), `expected a whole dynamic value for ${name}, got a partial one`) ··· 378 350 return renderable 379 351 } 380 352 381 - /** @implements {Part} */ 382 - class ChildPart { 383 - #childIndex 384 - #parentSpan 385 - constructor(idx, span) { 386 - this.#childIndex = idx 387 - this.#parentSpan = span 388 - } 389 - 390 - /** @type {Span | undefined} */ 391 - #span 392 - create(node, value) { 393 - if (node instanceof Span) { 394 - let child = node._start 395 - for (let i = 0; i < this.#childIndex; i++) { 396 - DEV: { 397 - assert(child.nextSibling !== null, 'expected more siblings') 398 - assert(child.nextSibling !== node._end, 'ran out of siblings before the end') 399 - } 400 - child = child.nextSibling 401 - } 402 - this.#span = new Span(child) 403 - } else { 404 - const child = node.childNodes[this.#childIndex] 405 - this.#span = new Span(child) 406 - } 407 - 408 - this.update(value) 409 - } 353 + function createChildPart( 354 + /** @type {Node | Span} */ parentNode, 355 + /** @type {Span} */ parentSpan, 356 + /** @type {number} */ childIndex, 357 + ) { 358 + let span 410 359 411 360 // for when we're rendering a renderable: 412 - /** @type {Renderable | null} */ #renderable = null 361 + /** @type {Renderable | null} */ let renderable = null 413 362 414 363 // for when we're rendering a template: 415 - /** @type {Root | undefined} */ #root 364 + /** @type {ReturnType<typeof createRoot> | undefined} */ let root 416 365 417 366 // for when we're rendering multiple values: 418 - /** @type {Root[] | undefined} */ #roots 367 + /** @type {ReturnType<typeof createRoot>[] | undefined} */ let roots 419 368 420 369 // for when we're rendering a string/single dom node: 421 370 /** undefined means no previous value, because a user-specified undefined is remapped to null */ 422 - #value 371 + let prevValue 423 372 424 - /** @param {Renderable | null} next */ 425 - #switchRenderable(next) { 426 - if (this.#renderable && this.#renderable !== next) { 427 - const controller = controllers.get(this.#renderable) 373 + function switchRenderable(/** @type {Renderable | null} */ next) { 374 + if (renderable && renderable !== next) { 375 + const controller = controllers.get(renderable) 428 376 if (controller?._unmountCallbacks) for (const callback of controller._unmountCallbacks) callback?.() 429 - controllers.delete(this.#renderable) 377 + controllers.delete(renderable) 430 378 } 431 - this.#renderable = next 379 + renderable = next 432 380 } 433 381 434 - #disconnectRoot() { 382 + function disconnectRoot() { 435 383 // root.detach and part.detach are mutually recursive, so this detaches children too. 436 - this.#root?.detach() 437 - this.#root = undefined 384 + root?.detach() 385 + root = undefined 386 + } 387 + 388 + if (parentNode instanceof Node) { 389 + const child = parentNode.childNodes[childIndex] 390 + span = createSpan(child) 391 + } else { 392 + let child = parentNode._start 393 + for (let i = 0; i < childIndex; i++) { 394 + DEV: { 395 + assert(child.nextSibling !== null, 'expected more siblings') 396 + assert(child.nextSibling !== parentNode._end, 'ran out of siblings before the end') 397 + } 398 + child = child.nextSibling 399 + } 400 + span = createSpan(child) 438 401 } 439 402 440 - /** @param {Displayable} value */ 441 - update(value) { 442 - DEV: assert(this.#span) 443 - const endsWereEqual = 444 - this.#span._parentNode === this.#parentSpan.parentNode && this.#span._end === this.#parentSpan._end 403 + return { 404 + update: function update(/** @type {Displayable} */ value) { 405 + DEV: assert(span) 406 + const endsWereEqual = span._parentNode === parentSpan._parentNode && span._end === parentSpan._end 407 + 408 + if (isRenderable(value)) { 409 + switchRenderable(value) 410 + 411 + const renderable = value 412 + 413 + if (!controllers.has(renderable)) 414 + controllers.set(renderable, { 415 + _mounted: false, 416 + _invalidateQueued: null, 417 + _invalidate: () => { 418 + DEV: assert(renderable === renderable, 'could not invalidate an outdated renderable') 419 + update(renderable) 420 + }, 421 + _unmountCallbacks: null, // will be upgraded to a Set if needed. 422 + _parentNode: span._parentNode, 423 + }) 424 + 425 + try { 426 + value = renderable.render() 427 + } catch (thrown) { 428 + if (isHtml(thrown)) { 429 + value = thrown 430 + } else { 431 + throw thrown 432 + } 433 + } 445 434 446 - if (isRenderable(value)) { 447 - this.#switchRenderable(value) 435 + // if render returned another renderable, we want to track/cache both renderables individually. 436 + // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables. 437 + if (isRenderable(value)) value = singlePartTemplate(value) 438 + } else switchRenderable(null) 448 439 449 - const renderable = value 440 + // if it's undefined, swap the value for null. 441 + // this means if the initial value is undefined, 442 + // it won't conflict with prevValue's default of undefined, 443 + // so it'll still render. 444 + if (value === undefined) value = null 450 445 451 - if (!controllers.has(renderable)) 452 - controllers.set(renderable, { 453 - _mounted: false, 454 - _invalidateQueued: null, 455 - _invalidate: () => { 456 - DEV: assert(this.#renderable === renderable, 'could not invalidate an outdated renderable') 457 - this.update(renderable) 458 - }, 459 - _unmountCallbacks: null, // will be upgraded to a Set if needed. 460 - _parentNode: this.#span._parentNode, 461 - }) 446 + // NOTE: we're explicitly not caching/diffing the value when it's an iterable, 447 + // given it can yield different values but have the same identity. (e.g. arrays) 448 + if (isIterable(value)) { 449 + if (!roots) { 450 + // we previously rendered a single value, so we need to clear it. 451 + disconnectRoot() 452 + spanDeleteContents(span) 462 453 463 - try { 464 - value = renderable.render() 465 - } catch (thrown) { 466 - if (thrown instanceof BoundTemplateInstance) { 467 - value = thrown 468 - } else { 469 - throw thrown 454 + roots = [] 470 455 } 471 - } 472 456 473 - // if render returned another renderable, we want to track/cache both renderables individually. 474 - // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables. 475 - if (isRenderable(value)) value = singlePartTemplate(value) 476 - } else this.#switchRenderable(null) 457 + // create or update a root for every item. 458 + let i = 0 459 + let end = span._start 460 + for (const item of value) { 461 + // @ts-expect-error -- WeakMap lookups of non-objects always return undefined, which is fine 462 + const key = keys.get(item) ?? item 463 + let root = (roots[i] ??= createRootAfter(end)) 477 464 478 - // if it's undefined, swap the value for null. 479 - // this means if the initial value is undefined, 480 - // it won't conflict with this.#value's default of undefined, 481 - // so it'll still render. 482 - if (value === undefined) value = null 465 + if (key !== undefined && root._key !== key) { 466 + const j = roots.findIndex(r => r._key === key) 467 + root._key = key 468 + if (j !== -1) { 469 + const root1 = root 470 + const root2 = roots[j] 483 471 484 - // NOTE: we're explicitly not caching/diffing the value when it's an iterable, 485 - // given it can yield different values but have the same identity. (e.g. arrays) 486 - if (isIterable(value)) { 487 - if (!this.#roots) { 488 - // we previously rendered a single value, so we need to clear it. 489 - this.#disconnectRoot() 490 - this.#span._deleteContents() 472 + // swap the contents of the spans 473 + const tmpContent = spanExtractContents(root1._span) 474 + spanInsertNode(root1._span, spanExtractContents(root2._span)) 475 + spanInsertNode(root2._span, tmpContent) 491 476 492 - this.#roots = [] 493 - } 477 + // swap the spans back 478 + const tmpSpan = root1._span 479 + root1._span = root2._span 480 + root2._span = tmpSpan 494 481 495 - // create or update a root for every item. 496 - let i = 0 497 - let end = this.#span._start 498 - for (const item of value) { 499 - // @ts-expect-error -- WeakMap lookups of non-objects always return undefined, which is fine 500 - const key = keys.get(item) ?? item 501 - let root = (this.#roots[i] ??= insertRootAfter(end)) 482 + // swap the roots 483 + roots[j] = root1 484 + root = roots[i] = root2 485 + } 486 + } 502 487 503 - if (key !== undefined && root._key !== key) { 504 - const j = this.#roots.findIndex(r => r._key === key) 505 - root._key = key 506 - if (j !== -1) { 507 - const root1 = root 508 - const root2 = this.#roots[j] 488 + root.render(item) 489 + end = root._span._end 490 + i++ 491 + } 509 492 510 - // swap the contents of the spans 511 - const tmpContent = root1._span._extractContents() 512 - root1._span._insertNode(root2._span._extractContents()) 513 - root2._span._insertNode(tmpContent) 493 + // and now remove excess roots if the iterable has shrunk. 494 + while (roots.length > i) { 495 + const root = roots.pop() 496 + assert(root) 497 + root.detach() 498 + spanDeleteContents(root._span) 499 + } 514 500 515 - // swap the spans back 516 - const tmpSpan = root1._span 517 - root1._span = root2._span 518 - root2._span = tmpSpan 501 + span._end = end 519 502 520 - // swap the roots 521 - this.#roots[j] = root1 522 - root = this.#roots[i] = root2 503 + const controller = controllers.get(renderable) 504 + if (controller) { 505 + controller._mounted = true 506 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 507 + for (const callback of mountCallbacks.get(renderable) ?? []) { 508 + ;(controller._unmountCallbacks ??= new Set()).add(callback()) 523 509 } 510 + // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 511 + mountCallbacks.delete(renderable) 524 512 } 525 513 526 - root.render(item) 527 - end = root._span._end 528 - i++ 514 + if (endsWereEqual) parentSpan._end = span._end 515 + 516 + return 517 + } else if (roots) { 518 + for (const root of roots) root.detach() 519 + roots = undefined 529 520 } 530 521 531 - // and now remove excess roots if the iterable has shrunk. 532 - while (this.#roots.length > i) { 533 - const root = this.#roots.pop() 534 - assert(root) 535 - root.detach() 536 - root._span._deleteContents() 522 + // now early return if the value hasn't changed. 523 + if (Object.is(prevValue, value)) return 524 + 525 + if (isHtml(value)) { 526 + root ??= createRoot(span) 527 + root.render(value) // root.render will detach the previous tree if the template has changed. 528 + } else { 529 + // if we previously rendered a tree that might contain renderables, 530 + // and the template has changed (or we're not even rendering a template anymore), 531 + // we need to clear the old renderables. 532 + disconnectRoot() 533 + 534 + if (prevValue != null && value !== null && !(prevValue instanceof Node) && !(value instanceof Node)) { 535 + // we previously rendered a string, and we're rendering a string again. 536 + DEV: assert(span._start === span._end && span._start instanceof Text) 537 + span._start.data = '' + value 538 + } else { 539 + spanDeleteContents(span) 540 + if (value !== null) spanInsertNode(span, value instanceof Node ? value : new Text('' + value)) 541 + } 537 542 } 538 543 539 - this.#span._end = end 544 + prevValue = value 540 545 541 - const controller = controllers.get(this.#renderable) 546 + const controller = controllers.get(renderable) 542 547 if (controller) { 543 548 controller._mounted = true 544 549 // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 545 - for (const callback of mountCallbacks.get(this.#renderable) ?? []) { 550 + for (const callback of mountCallbacks.get(renderable) ?? []) { 546 551 ;(controller._unmountCallbacks ??= new Set()).add(callback()) 547 552 } 548 553 // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 549 - mountCallbacks.delete(this.#renderable) 550 - } 551 - 552 - if (endsWereEqual) this.#parentSpan._end = this.#span._end 553 - 554 - return 555 - } else if (this.#roots) { 556 - for (const root of this.#roots) root.detach() 557 - this.#roots = undefined 558 - } 559 - 560 - // now early return if the value hasn't changed. 561 - if (Object.is(value, this.#value)) return 562 - 563 - if (value instanceof BoundTemplateInstance) { 564 - this.#root ??= new Root(this.#span) 565 - this.#root.render(value) // root.render will detach the previous tree if the template has changed. 566 - } else { 567 - // if we previously rendered a tree that might contain renderables, 568 - // and the template has changed (or we're not even rendering a template anymore), 569 - // we need to clear the old renderables. 570 - this.#disconnectRoot() 571 - 572 - if (this.#value != null && value !== null && !(this.#value instanceof Node) && !(value instanceof Node)) { 573 - // we previously rendered a string, and we're rendering a string again. 574 - DEV: assert(this.#span._start === this.#span._end && this.#span._start instanceof Text) 575 - this.#span._start.data = '' + value 576 - } else { 577 - this.#span._deleteContents() 578 - if (value !== null) this.#span._insertNode(value instanceof Node ? value : new Text('' + value)) 579 - } 580 - } 581 - 582 - this.#value = value 583 - 584 - const controller = controllers.get(this.#renderable) 585 - if (controller) { 586 - controller._mounted = true 587 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 588 - for (const callback of mountCallbacks.get(this.#renderable) ?? []) { 589 - ;(controller._unmountCallbacks ??= new Set()).add(callback()) 554 + mountCallbacks.delete(renderable) 590 555 } 591 - // @ts-expect-error -- WeakMap lookups of null always return undefined, which is fine 592 - mountCallbacks.delete(this.#renderable) 593 - } 594 556 595 - if (endsWereEqual) this.#parentSpan._end = this.#span._end 596 - } 597 - 598 - detach() { 599 - this.#switchRenderable(null) 600 - this.#disconnectRoot() 557 + if (endsWereEqual) parentSpan._end = span._end 558 + }, 559 + detach: () => { 560 + switchRenderable(null) 561 + disconnectRoot() 562 + }, 601 563 } 602 564 } 603 565 604 - /** @implements {Part} */ 605 - class PropertyPart { 606 - #name 607 - constructor(name) { 608 - this.#name = name 609 - } 610 - 611 - #node 612 - create(node, value) { 613 - this.#node = node 614 - this.update(value) 615 - } 616 - 617 - update(value) { 618 - this.#node[this.#name] = value 619 - } 620 - 621 - detach() { 622 - delete this.#node[this.#name] 566 + function createPropertyPart(node, name) { 567 + return { 568 + update: value => { 569 + node[name] = value 570 + }, 571 + detach: () => { 572 + delete node[name] 573 + }, 623 574 } 624 575 } 625 576 626 - /** @implements {Part} */ 627 - class AttributePart { 628 - #name 629 - constructor(name) { 630 - this.#name = name 631 - } 632 - 633 - #node 634 - create(node, value) { 635 - this.#node = node 636 - this.update(value) 637 - } 638 - 639 - update(value) { 640 - this.#node.setAttribute(this.#name, value) 641 - } 642 - 643 - detach() { 644 - this.#node.removeAttribute(this.#name) 577 + function createAttributePart(node, name) { 578 + return { 579 + update: value => node.setAttribute(name, value), 580 + detach: () => node.removeAttribute(name), 645 581 } 646 582 } 647 583 648 - /** @implements {Part} */ 649 - class DirectivePart { 650 - #node 651 - /** @type {Cleanup} */ 652 - #cleanup 653 - 654 - create(node, fn) { 655 - this.#node = node 656 - this.#cleanup = fn?.(this.#node) 657 - } 584 + function createDirectivePart(node) { 585 + /** @type {Cleanup} */ let cleanup 586 + return { 587 + update: fn => { 588 + cleanup?.() 589 + cleanup = fn?.(node) 590 + }, 658 591 659 - update(fn) { 660 - this.#cleanup?.() 661 - this.#cleanup = fn?.(this.#node) 662 - } 663 - 664 - detach() { 665 - this.#cleanup?.() 666 - this.#cleanup = null 592 + detach: () => { 593 + cleanup?.() 594 + cleanup = null 595 + }, 667 596 } 668 597 } 669 598
+7 -6
src/types.ts
··· 17 17 18 18 export type Key = string | number | bigint | boolean | symbol | object | null 19 19 20 - export declare class Span { 21 - _start: Node | null 22 - _end: Node | null 20 + export interface Span { 21 + _parentNode: Node 22 + _start: Node 23 + _end: Node 24 + _marker: Node | null 23 25 } 24 26 25 - export interface Part { 26 - create(node: Node | Span, value: unknown): void 27 + interface Part { 27 28 update(value: unknown): void 28 29 detach(): void 29 30 } ··· 33 34 34 35 export interface CompiledTemplate { 35 36 _content: DocumentFragment 36 - _parts: [idx: number, createPart: (span: Span) => Part][] 37 + _parts: [idx: number, createPart: (node: Node | Span, span: Span) => Part][] 37 38 _rootParts: number[] 38 39 }