diff --git a/CHANGELOG.md b/CHANGELOG.md index 0095b56..2825a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.2.0] — 2026-04-09 + +### Added + +- **Inline lint disable comments** — The `no-direct-dom-mutation` rule (in both the build-system linter and `sibujs lint` CLI) now supports two inline disable forms: + - `// sibujs-disable-next-line no-direct-dom-mutation` on the line above + - `// sibujs-disable no-direct-dom-mutation` on the same line + +### Fixed + +- **Cached element DOM corruption in reactive `nodes`** — `bindChildNode` used a naive "remove all, insert all" strategy with no identity tracking. Returning the same `HTMLElement` instance from a reactive function across re-evaluations could cause duplicates or disappearing elements. The reconciler now builds a reuse set, skips removal of reused nodes, and computes the insertion anchor after cleanup to prevent stale references. +- **Boolean `false` silently ignored in tag factory attributes** — Passing `false` for an attribute (e.g., `textarea({ spellcheck: false })`) was silently skipped instead of removing the attribute. Boolean handling now matches the reactive `bindAttribute` behavior: `true` sets an empty attribute, `false` calls `removeAttribute()`, and IDL properties (`checked`, `disabled`, `selected`) are set as DOM properties directly. + +--- + ## [1.1.0] — 2026-04-06 ### Added diff --git a/package.json b/package.json index 2ee80ef..16da751 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.1.0", + "version": "1.2.0", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", diff --git a/src/build/linting.ts b/src/build/linting.ts index 465b7df..bdee451 100644 --- a/src/build/linting.ts +++ b/src/build/linting.ts @@ -263,6 +263,21 @@ export const lintRules = { // Skip comments if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue; + // Support inline disable: // sibujs-disable-next-line no-direct-dom-mutation + if (lineIdx > 0) { + const prevTrimmed = lines[lineIdx - 1].trim(); + if ( + prevTrimmed.includes("sibujs-disable-next-line") && + (prevTrimmed.includes("no-direct-dom-mutation") || + !prevTrimmed.includes(" ", prevTrimmed.indexOf("sibujs-disable-next-line") + 25)) + ) { + continue; + } + } + + // Support inline disable on same line: // sibujs-disable no-direct-dom-mutation + if (line.includes("sibujs-disable") && !line.includes("sibujs-disable-next-line")) continue; + for (const { pattern, name, suggestion } of mutationPatterns) { pattern.lastIndex = 0; const match = pattern.exec(line); diff --git a/src/core/rendering/tagFactory.ts b/src/core/rendering/tagFactory.ts index ff05934..03b5b01 100644 --- a/src/core/rendering/tagFactory.ts +++ b/src/core/rendering/tagFactory.ts @@ -228,12 +228,19 @@ export const tagFactory = continue; // already handled above / below default: { const value = props[key]; - if (value == null || value === false) continue; + if (value == null) continue; if (key[0] === "o" && key[1] === "n") continue; if (typeof value === "function") { registerDisposer(el, bindAttribute(el as HTMLElement, key, value as () => unknown)); - } else if (value === true) { - el.setAttribute(key, ""); + } else if (typeof value === "boolean") { + // For IDL properties (checked, disabled, selected), set the DOM property directly + if (key in el && (key === "checked" || key === "disabled" || key === "selected")) { + (el as unknown as Record)[key] = value; + } else if (value) { + el.setAttribute(key, ""); + } else { + el.removeAttribute(key); + } } else { const str = String(value); el.setAttribute(key, isUrlAttribute(key) ? sanitizeUrl(str) : str); diff --git a/src/reactivity/bindChildNode.ts b/src/reactivity/bindChildNode.ts index a05b221..b4b45d1 100644 --- a/src/reactivity/bindChildNode.ts +++ b/src/reactivity/bindChildNode.ts @@ -24,13 +24,12 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No return; } - // Remove previously inserted nodes - for (let i = 0; i < lastNodes.length; i++) { - const node = lastNodes[i]; - if (node.parentNode) node.parentNode.removeChild(node); - } - if (result == null || typeof result === "boolean") { + // Remove all previously inserted nodes + for (let i = 0; i < lastNodes.length; i++) { + const node = lastNodes[i]; + if (node.parentNode) node.parentNode.removeChild(node); + } lastNodes.length = 0; return; } @@ -40,27 +39,58 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No lastNodes.length = 0; return; } - const anchor = placeholder.nextSibling; - let count = 0; + // Build the new node list + let newNodes: Node[]; if (Array.isArray(result)) { - // Reuse lastNodes array if large enough - if (lastNodes.length < result.length) lastNodes = new Array(result.length); + newNodes = []; for (let i = 0; i < result.length; i++) { const item = result[i]; if (item == null || typeof item === "boolean") continue; - const node = item instanceof Node ? item : document.createTextNode(String(item)); - parent.insertBefore(node, anchor); - lastNodes[count++] = node; + newNodes.push(item instanceof Node ? item : document.createTextNode(String(item))); } } else { - if (lastNodes.length < 1) lastNodes = [null as unknown as Node]; const node = result instanceof Node ? result : document.createTextNode(String(result)); - parent.insertBefore(node, anchor); - lastNodes[count++] = node; + newNodes = [node]; + } + + // Build a set of nodes that will be reused (present in both old and new lists) + const reused: Set | undefined = lastNodes.length > 0 && newNodes.length > 0 ? new Set() : undefined; + if (reused) { + for (let i = 0; i < newNodes.length; i++) { + for (let j = 0; j < lastNodes.length; j++) { + if (newNodes[i] === lastNodes[j]) { + reused.add(newNodes[i]); + break; + } + } + } + } + + // Remove old nodes that are NOT reused + for (let i = 0; i < lastNodes.length; i++) { + const node = lastNodes[i]; + if (reused?.has(node)) continue; + if (node.parentNode) node.parentNode.removeChild(node); + } + + // Compute anchor AFTER removal so it's not stale + const anchor = placeholder.nextSibling; + + // Insert new nodes in order, skipping nodes already in the correct position + for (let i = 0; i < newNodes.length; i++) { + const node = newNodes[i]; + if (reused?.has(node) && node.parentNode === parent) { + // Reused node: only move if not already before the anchor + if (node.nextSibling !== anchor) { + parent.insertBefore(node, anchor); + } + } else { + parent.insertBefore(node, anchor); + } } - lastNodes.length = count; + lastNodes = newNodes; } // Initial render and reactive subscription