apprun
Version:
JavaScript library that has Elm inspired architecture, event pub-sub and components
287 lines (263 loc) • 10.4 kB
text/typescript
/** * VDOM Implementation for AppRun
*
* Notes for AppRun’s key prop
* Use the key prop only if you need to preserve browser-side DOM state (such as cursor
* position in an <input>, focus, or maintaining state in inline components).
*
* For most use cases, especially with stateless or purely data-driven UIs, key is not
* needed and may decrease performance by forcing DOM moves.
*
* AppRun aggressively updates DOM to match your vdom, so DOM node preservation is
* only valuable when you intentionally depend on browser-managed state.
*
* | Use Case | Should I use `key`? | Why |
* | ----------------------------- | ------------------- | --------------------- |
* | Preserve input cursor/focus | ✅ Yes | Keeps browser state |
* | Inline stateful component | ✅ Yes | Preserves component |
* | Regular data list (stateless) | 🚫 No | Unnecessary DOM moves |
* | Purely visual updates | 🚫 No | No state to preserve |
*
* Features:
* - Virtual DOM rendering and diffing for efficient DOM updates
* - JSX Fragment support for both Babel and TypeScript
* - Element creation with props, children, and event handling
* - SVG element support with proper namespace handling
* - Component lifecycle management and caching
* - Keyed element optimization with automatic cleanup for memory management
* - Safe HTML insertion and text node creation
* - Directive processing integration
*
* Implementation:
* - Uses plain JavaScript object for keyCache instead of Map for better performance
* - Implements automatic cleanup of disconnected elements from keyCache
* - Supports both string and function-based tags
* - Handles component mounting and state management
* - Optimized children updating with minimal DOM operations
* - Memory-efficient caching with configurable thresholds (500 ops, 1000 max size)
*
* Recent Changes:
* - 2025-07-15: Converted keyCache from Map to plain object ({}) for improved performance
* - Updated cleanup functions to use object property deletion instead of Map methods
* - Enhanced memory management with automatic cleanup of disconnected elements
* - Added comprehensive key prop usage documentation and guidelines
*/
import { VDOM, VNode } from './types';
import directive from './directive';
import { updateProps } from './vdom-my-prop-attr';
export type Element = any; //HTMLElement | SVGSVGElement | SVGElement;
export function Fragment(props, ...children): any[] {
return collect(children);
}
function collect(children) {
const ch = [];
const push = (c) => {
if (c !== null && c !== undefined && c !== '' && c !== false) {
ch.push((typeof c === 'function' || typeof c === 'object') ? c : `${c}`);
}
}
children && children.forEach(c => {
if (Array.isArray(c)) {
c.forEach(i => push(i));
} else {
push(c);
}
});
return ch;
}
const keyCache: { [key: string]: Element } = {};
let cleanupCounter = 0;
const CLEANUP_THRESHOLD = 500; // Cleanup every 500 operations
const MAX_CACHE_SIZE = 1000;
// Lightweight cleanup function - only runs when needed
function cleanupKeyCache() {
if (Object.keys(keyCache).length <= MAX_CACHE_SIZE) return; // Skip if under limit
for (const [key, element] of Object.entries(keyCache)) {
if (!element.isConnected) {
delete keyCache[key];
}
}
}
// Export cleanup function for manual cleanup if needed
export function clearKeyCache() {
for (const key in keyCache) {
delete keyCache[key];
}
cleanupCounter = 0;
}
export function createElement(tag: string | Function | [], props?: {}, ...children) {
const ch = collect(children);
if (typeof tag === 'string') return { tag, props, children: ch };
else if (Array.isArray(tag)) return tag; // JSX fragments - babel
else if (tag === undefined && children) return ch; // JSX fragments - typescript
else if (Object.getPrototypeOf(tag).__isAppRunComponent) return { tag, props, children: ch } // createComponent(tag, { ...props, children });
else if (typeof tag === 'function') return tag(props, ch);
else throw new Error(`Unknown tag in vdom ${tag}`);
};
export const updateElement = (element: Element | string, nodes: VDOM, component = {}) => {
// tslint:disable-next-line
if (nodes == null || nodes === false) return;
const el = (typeof element === 'string' && element) ?
document.getElementById(element) || document.querySelector(element) : element;
nodes = directive(nodes, component);
render(el, nodes, component);
}
function render(element: Element, nodes: VDOM, parent = {}) {
// tslint:disable-next-line
if (nodes == null || nodes === false) return;
nodes = createComponent(nodes, parent);
if (!element) return;
const isSvg = element.nodeName === "SVG";
if (Array.isArray(nodes)) {
updateChildren(element, nodes, isSvg);
} else {
updateChildren(element, [nodes], isSvg);
}
}
function same(el: Element, node: VNode) {
// if (!el || !node) return false;
const key1 = el.nodeName;
const key2 = `${node.tag || ''}`;
return key1.toUpperCase() === key2.toUpperCase();
}
function update(element: Element, node: VNode, isSvg: boolean) {
// console.assert(!!element);
isSvg = isSvg || node.tag === "svg";
if (!same(element, node)) {
element.parentNode.replaceChild(create(node, isSvg), element);
return;
}
updateChildren(element, node.children, isSvg);
updateProps(element, node.props, isSvg);
}
function updateChildren(element, children, isSvg: boolean) {
const old_len = element.childNodes?.length || 0;
const new_len = children?.length || 0;
const len = Math.min(old_len, new_len);
for (let i = 0; i < len; i++) {
const child = children[i];
const el = element.childNodes[i];
if (typeof child === 'string') {
if (el.textContent !== child) {
if (el.nodeType === 3) {
el.nodeValue = child
} else {
element.replaceChild(createText(child), el);
}
}
} else if (child instanceof HTMLElement || child instanceof SVGElement) {
element.insertBefore(child, el);
} else {
const key = child.props && child.props['key'];
if (key) {
if (el.key === key) {
update(element.childNodes[i], child, isSvg);
} else {
// console.log(el.key, key);
const old = keyCache[key];
if (old) {
// const temp = old.nextSibling;
element.insertBefore(old, el);
// temp ? element.insertBefore(el, temp) : element.appendChild(el);
update(element.childNodes[i], child, isSvg);
} else {
element.replaceChild(create(child, isSvg), el);
}
}
} else {
update(element.childNodes[i], child, isSvg);
}
}
}
let n = element.childNodes?.length || 0;
while (n > len) {
element.removeChild(element.lastChild);
n--;
}
if (new_len > len) {
const d = document.createDocumentFragment();
for (let i = len; i < children.length; i++) {
d.appendChild(create(children[i], isSvg));
}
element.appendChild(d);
}
}
export const safeHTML = (html: string) => {
const div = document.createElement('section');
div.insertAdjacentHTML('afterbegin', html)
return Array.from(div.children);
}
function createText(node) {
if (node?.indexOf('_html:') === 0) { // ?
const div = document.createElement('div');
div.insertAdjacentHTML('afterbegin', node.substring(6))
return div;
} else {
return document.createTextNode(node ?? '');
}
}
function create(node: VNode | string | HTMLElement | SVGElement, isSvg: boolean): Element {
// console.assert(node !== null && node !== undefined);
if ((node instanceof HTMLElement) || (node instanceof SVGElement)) return node;
if (typeof node === "string") return createText(node);
if (!node.tag || (typeof node.tag === 'function')) return createText(JSON.stringify(node));
isSvg = isSvg || node.tag === "svg";
const element = isSvg
? document.createElementNS("http://www.w3.org/2000/svg", node.tag)
: document.createElement(node.tag);
updateProps(element, node.props, isSvg);
if (node.children) node.children.forEach(child => element.appendChild(create(child, isSvg)));
if (node.props && (node.props as any).key !== undefined) {
(element as any).key = (node.props as any).key;
keyCache[(node.props as any).key] = element;
// Lightweight cleanup - only when counter reaches threshold
if (++cleanupCounter >= CLEANUP_THRESHOLD) {
cleanupKeyCache();
cleanupCounter = 0;
}
}
return element
}
function render_component(node, parent, idx) {
const { tag, props, children } = node;
let key = `_${idx}`;
let id = props && props['id'];
if (!id) id = `_${idx}${Date.now()}`;
else key = id;
let asTag = 'section';
if (props && props['as']) {
asTag = props['as'];
delete props['as'];
}
if (!parent.__componentCache) parent.__componentCache = {};
let component = parent.__componentCache[key];
if (!component || !(component instanceof tag) || !component.element) {
const element = document.createElement(asTag);
component = parent.__componentCache[key] = new tag({ ...props, children }).mount(element, { render: true });
} else {
component.renderState(component.state);
}
if (component.mounted) {
const new_state = component.mounted(props, children, component.state);
(typeof new_state !== 'undefined') && component.setState(new_state);
}
updateProps(component.element, props, false);
return component.element;
}
function createComponent(node, parent, idx = 0) {
if (typeof node === 'string') return node;
if (Array.isArray(node)) return node.map(child => createComponent(child, parent, idx++));
let vdom = node;
if (node && typeof node.tag === 'function' && Object.getPrototypeOf(node.tag).__isAppRunComponent) {
vdom = render_component(node, parent, idx);
}
if (vdom && Array.isArray(vdom.children)) {
const new_parent = vdom.props?._component;
if (new_parent) {
let i = 0;
vdom.children = vdom.children.map(child => createComponent(child, new_parent, i++));
} else {
vdom.children = vdom.children.map(child => createComponent(child, parent, idx++));
}
}
return vdom;
}