From a9ea553ca1c44f7f09ea007207099bbfec574ab0 Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Sun, 3 Dec 2023 23:09:18 +0100 Subject: [PATCH] Improved DOM node damage repair --- packages/html2/src/html.ts | 314 +++++++++++++++++++----------------- packages/html2/src/index.ts | 143 +++++++--------- 2 files changed, 227 insertions(+), 230 deletions(-) diff --git a/packages/html2/src/html.ts b/packages/html2/src/html.ts index 446b26b..cbc3e8e 100644 --- a/packages/html2/src/html.ts +++ b/packages/html2/src/html.ts @@ -1,7 +1,7 @@ /// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2016-2023 Tony Garnock-Jones -let nextId = 1; +import { randomId } from "@syndicate-lang/core"; export function escape(s: string): string { return s @@ -12,169 +12,185 @@ export function escape(s: string): string { .replace(/'/g, "'"); } -export type PlaceholderNodeMap = { [id: string]: Node }; +export type HtmlFragment = string | number | Node | Array; -export type HtmlFragment = string | number | Array | Node | FlattenInto; - -export interface FlattenInto { - flattenInto(acc: Array, options?: FlattenIntoOptions): void; +const tag = randomId(8, true); +const placeholderRe = new RegExp(`X-${tag}-(\\d+)-${tag}-X`, 'g'); +function placeholder(n: number): string { + return `X-${tag}-${n}-${tag}-X`; } -export interface FlattenIntoOptions { - nodeMap?: PlaceholderNodeMap; - escapeStrings?: boolean; +function splitByPlaceholders(s: string): { constantParts: string[], placeholders: number[] } { + let match: RegExpExecArray | null = null; + let lastConstantStart = 0; + const constantParts: string[] = []; + const placeholders: number[] = []; + while ((match = placeholderRe.exec(s)) !== null) { + constantParts.push(s.substring(lastConstantStart, match.index)); + placeholders.push(parseInt(match[1], 10)); + lastConstantStart = placeholderRe.lastIndex; + } + constantParts.push(s.substring(lastConstantStart)); + return { constantParts, placeholders }; } -export function isFlattenInto(x: any): x is FlattenInto { - return typeof x === 'object' && x !== null && typeof x.flattenInto === 'function'; -} - -export class HtmlFragments implements FlattenInto { - readonly contextElementName: string | undefined; - readonly pieces: Array; - - constructor(pieces: Array = [], contextElementName?: string) { - this.contextElementName = contextElementName; - this.pieces = pieces; - } - - appendTo(n: ParentNode): Array { - const ns = [... this.nodes()]; - n.append(... ns); - return ns; - } - - replaceContentOf(n: Element) { - n.innerHTML = ''; - return this.appendTo(n); - } - - node(): ChildNode { - return this.nodes()[0]; - } - - nodes(): Array { - let n = document.createElement('template'); - const nodeMap: PlaceholderNodeMap = {}; - const source = this.toString(nodeMap); - if (this.contextElementName !== void 0) { - n.innerHTML = `<${this.contextElementName}>${source}`; +function renderFragment(f: HtmlFragment, escapeStrings: boolean): string[] { + const result: string[] = []; + function walk(f: HtmlFragment) { + if (Array.isArray(f)) { + f.forEach(walk); } else { - n.innerHTML = source; - } - for (const p of Array.from(n.content.querySelectorAll('placeholder'))) { - const e = nodeMap[p.id]; - if (e) { - p.parentNode!.insertBefore(e, p); - p.remove(); + switch (typeof f) { + case 'string': result.push(escapeStrings? escape(f) : f); break; + case 'number': result.push('' + f); break; + default: throw new Error("Cannot render Node in attribute context"); } } - if (this.contextElementName !== void 0) { - return Array.from(n.content.firstElementChild!.childNodes); - } else { - return Array.from(n.content.childNodes); - } - } - - toString(nodeMap?: PlaceholderNodeMap) { - const allPieces: Array = []; - this.flattenInto(allPieces, { nodeMap }); - return allPieces.join(''); - } - - flattenInto(acc: Array, options: FlattenIntoOptions) { - flattenInto(acc, this.pieces, { ... options, escapeStrings: false }); - } - - join(pieces: Array): Array { - return join(pieces, this); } + walk(f); + return result; } -export function flattenInto(acc: Array, - p: HtmlFragment, - options: FlattenIntoOptions = {}) -{ - switch (typeof p) { - case 'string': acc.push((options.escapeStrings ?? true) ? escape(p) : p); break; - case 'number': acc.push('' + p); break; - case 'object': - if (isFlattenInto(p)) { - p.flattenInto(acc, { nodeMap: options.nodeMap }); - } else if (Array.isArray(p)) { - p.forEach(q => flattenInto(acc, q, options)); - } else if (typeof Node !== 'undefined' && p instanceof Node) { - if (options.nodeMap !== void 0) { - const id = `__SYNDICATE__html__${nextId++}`; - options.nodeMap[id] = p; - acc.push(``); - } else { - acc.push(p); +function followPath(topNodes: ChildNode[], path: number[]): Node { + let n = topNodes[path[0]]; + for (let j = 1; j < path.length; j++) n = n.childNodes[path[j]]; + return n; +} + +export class HtmlFragmentBuilder { + template: HTMLTemplateElement = document.createElement('template'); + placeholderActions: Array<(variableParts: HtmlFragment[], topNodes: ChildNode[]) => void> = []; + + constructor(constantParts: TemplateStringsArray) { + const pieces: string[] = []; + constantParts.raw.forEach((r, i) => { + if (i > 0) pieces.push(placeholder(i - 1)); + pieces.push(r); + }); + this.template.innerHTML = pieces.join(''); + this.indexPlaceholders(); + } + + private indexPlaceholders() { + const path: number[] = []; + const bump = (n: number) => path[path.length - 1] += n; + const walk = (parentNode: ParentNode) => { + path.push(0); + let nextN = parentNode.firstChild; + while (nextN !== null) { + const n = nextN; + switch (n.nodeType) { + case Node.TEXT_NODE: { + const { constantParts, placeholders } = splitByPlaceholders(n.textContent ?? ''); + constantParts.forEach((c, i) => { + if (i > 0) n.parentNode?.insertBefore(document.createElement('placeholder'), n); + n.parentNode?.insertBefore(document.createTextNode(c), n); + }); + nextN = n.nextSibling; + n.parentNode?.removeChild(n); + placeholders.forEach((n, i) => { + const currentPath = path.slice(); + currentPath[currentPath.length - 1] = i * 2 + 1; + this.placeholderActions.push((vs, topNodes) => { + const node = followPath(topNodes, currentPath); + function walk(f: HtmlFragment) { + if (Array.isArray(f)) { + f.forEach(walk); + } else { + switch (typeof f) { + case 'string': node.parentNode?.insertBefore(document.createTextNode(f), node); break; + case 'number': node.parentNode?.insertBefore(document.createTextNode('' + f), node); break; + default: node.parentNode?.insertBefore(f, node); break; + } + } + } + walk(vs[n]); + node.parentNode?.removeChild(node); + }); + }); + bump(constantParts.length + placeholders.length); + break; + } + case Node.ELEMENT_NODE: { + const currentPath = path.slice(); + const e = n as Element; + // TODO: hoist all actions for this node into a single action + for (let i = 0; i < e.attributes.length; i++) { + const attr = e.attributes[i]; + const attrName = attr.name; + const nameIsPlaceholder = attrName.match(placeholderRe); + if (nameIsPlaceholder !== null) { + e.removeAttributeNode(attr); + i--; + const n = parseInt(nameIsPlaceholder[1], 10); + this.placeholderActions.push((vs, topNodes) => { + const node = followPath(topNodes, currentPath); + const e = document.createElement('template'); + e.innerHTML = ``; + Array.from(e.attributes).forEach(a => + (node as Element).setAttribute(a.name, a.value)); + }); + } else { + const { constantParts, placeholders } = splitByPlaceholders(attr.value); + if (constantParts.length !== 1) { + this.placeholderActions.push((vs, topNodes) => { + const node = followPath(topNodes, currentPath); + const pieces = [constantParts[0]]; + placeholders.forEach((n, i) => { + pieces.push(...renderFragment(vs[n], false)); + pieces.push(constantParts[i + 1]); + }); + (node as Element).setAttribute(attrName, pieces.join('')); + }); + } + } + } + walk(e); + nextN = e.nextSibling; + bump(1); + break; + } + default: + nextN = n.nextSibling; + bump(1); + break; } } - break; - default: - ((_n: never) => {})(p); - } -} - -export function join(pieces: Array, separator: HtmlFragment): Array { - if (pieces.length <= 1) { - return []; - } else { - const result = [pieces[0]]; - for (let i = 1; i < pieces.length; i++) { - result.push(separator); - result.push(pieces[i]); - } - return result; - } -} - -export type HtmlFragmentsTemplate = - (constantParts: TemplateStringsArray, ... variableParts: Array) => HtmlFragments; - -export type NodeTemplate = - (constantParts: TemplateStringsArray, ... variableParts: Array) => N; - -export interface TagTemplate { - tag(): NodeTemplate; - tag(c: { new (...args: any): N }): NodeTemplate; -}; - -export function makeTemplate( - contextElementName?: string, -): HtmlFragmentsTemplate & TagTemplate { - const templater = (constantParts: TemplateStringsArray, ... variableParts: Array) => { - const pieces: Array = []; - function pushConst(i: number) { - const r0 = constantParts.raw[i]; - const r = i === 0 ? r0.trimLeft() : r0; - if (r) pieces.push(r); - } - pushConst(0); - variableParts.forEach((vp, vpIndex) => { - flattenInto(pieces, vp, { escapeStrings: true }); - pushConst(vpIndex + 1); - }); - return new HtmlFragments(pieces, contextElementName); - }; - templater.tag = (c?: { new (...args: any): N }) => - (constantParts: TemplateStringsArray, ... variableParts: Array): any => { - const n = templater(constantParts, ... variableParts).node(); - if (n instanceof (c ?? Element)) { - return n; - } else { - throw new TypeError(`Template generated ${n}, but instance of ${c} was expected`); - } + path.pop(); }; - return templater; + walk(this.template.content); + } + + clone(): ChildNode[] { + return Array.from( + (this.template.cloneNode(true) as HTMLTemplateElement).content.childNodes); + } + + update(template: ChildNode[], variableParts: Array) { + this.placeholderActions.forEach(a => a(variableParts, template)); + } } -export const template = makeTemplate(); +// Nifty trick: TemplateStringsArray instances are interned so it makes sense to key a cache +// based on their object identity! +const templateCache = new WeakMap(); -export function raw(str: string, contextElementName?: string) { - return new HtmlFragments([str], contextElementName); +export type HtmlTemplater = + (constantParts: TemplateStringsArray, ... variableParts: Array) + => ChildNode[]; + +export function template(): HtmlTemplater { + let container: ChildNode[] | null = null; + return (constantParts, ... variableParts) => { + let b = templateCache.get(constantParts); + if (b === void 0) { + b = new HtmlFragmentBuilder(constantParts); + templateCache.set(constantParts, b); + } + if (container === null) { + container = b.clone(); + } + b.update(container, variableParts); + return container; + }; } - -export default template; diff --git a/packages/html2/src/index.ts b/packages/html2/src/index.ts index 4b37dae..3604222 100644 --- a/packages/html2/src/index.ts +++ b/packages/html2/src/index.ts @@ -2,8 +2,8 @@ /// SPDX-FileCopyrightText: Copyright © 2023 Tony Garnock-Jones import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core"; -import { HtmlFragmentsTemplate, HtmlFragment, template } from "./html"; -export * from "./html"; +import { HtmlTemplater, template } from "./html"; +export { HtmlTemplater, template, HtmlFragment } from "./html"; export assertion type LocationHash(hash: string); export type LocationHash = ReturnType; @@ -17,82 +17,55 @@ type Wrapped = { options?: AddEventListenerOptions | boolean, }; -export type NodeGenerator = (t: HtmlFragmentsTemplate) => HtmlFragment; +export type NodeGenerator = (t: HtmlTemplater) => ReturnType; export class Widget implements EventTarget { - facet: Facet; - node: Dataflow.Field; - callbacks = - new Map>(); - _parent: Dataflow.Field; + readonly facet: Facet; + private _node: ChildNode | null = null; + parentField: Dataflow.Field; + callbacks = new Map>(); + + get node(): ChildNode { + return this._node!; + } constructor (private nodeGenerator: NodeGenerator) { this.facet = Turn.activeFacet; on stop { - this.node.value?.remove(); + this.node.remove(); } - field node: ChildNode | null = null; - this.node = node; - - field parent: ParentNode | null = null; - this._parent = parent; + field parentField: ParentNode | null = null; + this.parentField = parentField; + const thisTemplate = template(); dataflow { - const oldNode = this.node.value; - - const fragments = template`${this.nodeGenerator(template)}`; - const newNodes = fragments.nodes(); - if (newNodes.length > 1) { - throw new Error(`@syndicate-lang/html2: Multiple nodes returned from template: ${fragments.toString()}`); + const nodes = nodeGenerator(thisTemplate); + if (nodes.length !== 1) { + throw new Error(`@syndicate-lang/html2: Expected exactly one node from template`); } - const newNode = newNodes[0]; - - if (oldNode !== newNode) { - if (oldNode !== null) { - for (const [type, cbs] of this.callbacks.entries()) { - for (const entry of cbs.values()) { - oldNode.removeEventListener(type, entry.wrapped, entry.options); - } - } - } - - if (newNode !== null) { - for (const [type, cbs] of this.callbacks.entries()) { - for (const entry of cbs.values()) { - newNode.addEventListener(type, entry.wrapped, entry.options); - } - } - } - - const wasFocus = oldNode && document.activeElement === oldNode; - oldNode?.parentNode?.replaceChild(newNode, oldNode); - if (wasFocus) { - if (newNode && newNode instanceof HTMLElement) { - newNode.focus(); - } - } - - this.node.value = newNode; + if (this._node === null) { + this._node = nodes[0]; + } else if (this._node !== nodes[0]) { + throw new Error(`@syndicate-lang/html2: Node generator is not stable`); } } dataflow { - if (this.node.value === null) return; - const p = this._parent.value; - if (this.node.value.parentNode !== p) { + const p = this.parentField.value; + if (this.node.parentNode !== p) { if (p === null) { - this.node.value.remove(); + this.node.remove(); } else { - p.appendChild(this.node.value); + p.appendChild(this.node); } } } } get parent(): ParentNode | null { - return this._parent.value; + return this.parentField.value; } set parent(p: string | ParentNode | null) { @@ -103,7 +76,7 @@ export class Widget implements EventTarget { if (typeof p === 'string') { p = wrt.querySelector(p); } - this._parent.value = p; + this.parentField.value = p; return this; } @@ -145,11 +118,11 @@ export class Widget implements EventTarget { }; cbs.set(callback, entry); - this.node.value?.addEventListener(type, entry.wrapped, options); + this.node.addEventListener(type, entry.wrapped, options); } dispatchEvent(event: Event): boolean { - return !this.node.value || this.node.value.dispatchEvent(event); + return this.node.dispatchEvent(event); } removeEventListener( @@ -165,7 +138,7 @@ export class Widget implements EventTarget { const r = cbs.get(callback); if (r === void 0) return; - this.node.value?.removeEventListener(type, r.wrapped, options); + this.node.removeEventListener(type, r.wrapped, options); cbs.delete(callback); if (cbs.size === 0) this.callbacks.delete(type); @@ -185,35 +158,43 @@ export class ValueWidget extends Widget { field valueAsNumber: number = NaN; this._valueAsNumber = valueAsNumber; - const readValues = (n: any) => { - this._value.value = n?.value ?? ''; - this._valueAsNumber.value = n?.valueAsNumber ?? NaN; - }; + if ('value' in this.node) { + const readValues = (n: any) => { + this.suppressCycleWarning(); + this._value.value = n?.value ?? ''; + this._valueAsNumber.value = n?.valueAsNumber ?? NaN; + }; - this.on('change', e => readValues(e.target)); + this.on('change', e => readValues(e.target)); + readValues(this.node); - dataflow { - if (this.node.value && 'value' in this.node.value) { - readValues(this.node.value); - } - } - dataflow { - if (this.node.value && 'value' in this.node.value) { - (this.node.value as any).value = '' + this._valueAsNumber.value; - } - } - dataflow { - if (this.node.value && 'value' in this.node.value) { - this.node.value.value = this._value.value; - } + dataflow { this.valueAsNumber = this._valueAsNumber.value; } + dataflow { this.value = this._value.value; } } } - get value(): string { return this._value.value; } - set value(v: string) { this._value.value = v; } + get value(): string { + return this._value.value; + } - get valueAsNumber(): number { return this._valueAsNumber.value; } - set valueAsNumber(v: number) { this._valueAsNumber.value = v; } + set value(v: string) { + (this.node as any).value = v; + this._value.value = v; + } + + get valueAsNumber(): number { + return this._valueAsNumber.value; + } + + set valueAsNumber(v: number) { + (this.node as any).value = Number.isNaN(v) ? '' : '' + v; + this._valueAsNumber.value = v; + } + + suppressCycleWarning(): void { + this._value.suppressCycleWarning(); + this._valueAsNumber.suppressCycleWarning(); + } } function spawnLocationHashTracker(ds: Ref) {