From 95db6cebccc65caa4cacc43c0ad1647a55d7ffef Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 15:11:54 -0600 Subject: [PATCH 1/5] Updated CHANGELOG and package.json --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c767a55..64c424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [1.0.3] — 2026-03-28 + +### Added + +- **Wider `NodeChild` / `NodeChildren` types** — `NodeChild` now accepts `boolean`; `NodeChildren` accepts nested arrays and full reactive functions. Conditional patterns like `condition && element` work without `as any` casts. Boolean values are filtered out in `appendChildren`, `bindChildNode`, `Fragment()`, `htm.ts`, and `resolveChild`. +- **`onCleanup()` lifecycle hook** — `onCleanup(callback, element)` registers teardown logic (closing sockets, clearing timers, removing listeners) tied to an element's disposal. Integrates with the existing `dispose()` system so cleanup runs automatically when `when()`, `match()`, or `each()` swap content. +- **`query()` `select` option** — Optional `select` function that transforms cached data before returning it to consumers. Raw response stays in cache; `select` runs on read, enabling derived views without extra signals. +- **`formatNumber()` and `formatCurrency()`** — `Intl`-based formatting utilities exported from `sibujs/browser`. `formatNumber` wraps `Intl.NumberFormat`; `formatCurrency` is a convenience shorthand that sets `style: "currency"`. + +### Fixed + +- **Boolean values no longer render as text** — `false`, `true` are filtered in all rendering paths (`tagFactory`, `bindChildNode`, `Fragment`, `htm.ts`, `resolveChild`) preventing visible `"false"` text nodes. +- **Lint fixes** — Resolved unused variable in `router.basic.test.ts` and formatting issues flagged by Biome. + +--- + ## [1.0.2] — 2026-03-27 ### Fixed diff --git a/package.json b/package.json index 4a30d20..a3cd741 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "1.0.2", + "version": "1.0.3", "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", From 9487727c338809848170d361ea8775a3fa149ad9 Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:30:29 -0600 Subject: [PATCH 2/5] ci: use npm install instead of npm ci --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aab4d99..e156d9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm ci + run: npm install - name: Run tests run: npm test From 077718418208d14423f9aeddb63876ce57f6454c Mon Sep 17 00:00:00 2001 From: hexplus Date: Sat, 28 Mar 2026 22:51:26 -0600 Subject: [PATCH 3/5] trusted-publisher --- .github/workflows/publish.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cdf4e5b..f25d1f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,21 @@ on: release: types: [published] +permissions: + id-token: write + contents: read + jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout código + - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" - name: Install dependencies @@ -28,5 +32,3 @@ jobs: - name: Publish to npm run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 0fde908c00cb99fddafdb423906086f6855384f8 Mon Sep 17 00:00:00 2001 From: hexplus Date: Thu, 9 Apr 2026 09:18:26 -0600 Subject: [PATCH 4/5] fix: reconciler cached element corruption, boolean attr handling, lint disable comments (v1.2.0) - Fix bindChildNode identity-aware reconciliation: reused nodes are no longer removed and re-inserted, preventing DOM corruption when reactive nodes functions return the same HTMLElement instance across re-evaluations - Fix tagFactory boolean attribute handling: false now calls removeAttribute() instead of being silently skipped; IDL properties (checked, disabled, selected) set as DOM properties directly, matching bindAttribute behavior - Add inline disable comments for no-direct-dom-mutation lint rule: // sibujs-disable-next-line and // sibujs-disable on same line - Bump version to 1.2.0 --- CHANGELOG.md | 15 ++++++++ package.json | 2 +- src/build/linting.ts | 14 +++++++ src/core/rendering/tagFactory.ts | 13 +++++-- src/reactivity/bindChildNode.ts | 64 +++++++++++++++++++++++--------- 5 files changed, 87 insertions(+), 21 deletions(-) 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..c0c0eb1 100644 --- a/src/build/linting.ts +++ b/src/build/linting.ts @@ -263,6 +263,20 @@ 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..a033ab1 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 && 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 && 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 From 681c54dab602e9005a5da07e80b9ddb925668965 Mon Sep 17 00:00:00 2001 From: hexplus Date: Thu, 9 Apr 2026 09:24:23 -0600 Subject: [PATCH 5/5] Linter fixes --- src/build/linting.ts | 3 ++- src/reactivity/bindChildNode.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/build/linting.ts b/src/build/linting.ts index c0c0eb1..bdee451 100644 --- a/src/build/linting.ts +++ b/src/build/linting.ts @@ -268,7 +268,8 @@ export const lintRules = { 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)) + (prevTrimmed.includes("no-direct-dom-mutation") || + !prevTrimmed.includes(" ", prevTrimmed.indexOf("sibujs-disable-next-line") + 25)) ) { continue; } diff --git a/src/reactivity/bindChildNode.ts b/src/reactivity/bindChildNode.ts index a033ab1..b4b45d1 100644 --- a/src/reactivity/bindChildNode.ts +++ b/src/reactivity/bindChildNode.ts @@ -70,7 +70,7 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No // Remove old nodes that are NOT reused for (let i = 0; i < lastNodes.length; i++) { const node = lastNodes[i]; - if (reused && reused.has(node)) continue; + if (reused?.has(node)) continue; if (node.parentNode) node.parentNode.removeChild(node); } @@ -80,7 +80,7 @@ export function bindChildNode(placeholder: Comment, getter: () => NodeChild | No // 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 && reused.has(node) && node.parentNode === parent) { + 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);