Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
95db6ce
Updated CHANGELOG and package.json
hexplus Mar 28, 2026
56080d8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
7eeec49
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
14a9cd4
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
9487727
ci: use npm install instead of npm ci
hexplus Mar 29, 2026
6b4bd83
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0b9a0cc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0777184
trusted-publisher
hexplus Mar 29, 2026
4d46e82
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
bea9788
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
825a8dc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
55c4436
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0d2c7e0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
8da81e8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
325ce5d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0cad329
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 1, 2026
aea6787
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 4, 2026
00e5e88
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
b10a2c5
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
639eae0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 9, 2026
405e4fe
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
ee7cf48
Updated main
hexplus Apr 11, 2026
8c77fca
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
da6d752
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
c047837
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
724c0e2
fix(framework): fix 30 bugs across core reactivity, data fetching, ro…
hexplus Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,103 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## [1.5.0] — 2026-04-11

Comprehensive bug-fix and hardening release. **30 bugs fixed across 29 files**, covering the reactive core, data fetching, state management, routing, rendering, lifecycle, forms, UI utilities, browser composables, and devtools. Full framework audit with 2178/2178 tests passing, zero regressions.

### Breaking

- **`optimistic()` return shape changed** — previously returned a `[getter, setter]` tuple; now returns a named object `{ value, pending, update }`. The `pending` signal was created internally but never exposed (Bug: users had no way to show loading indicators). The `update` method now uses a version counter to prevent stale reverts from concurrent operations. Migration:
```ts
// before
const [value, addOptimistic] = optimistic(0);

// after
const { value, pending, update } = optimistic(0);
```

- **`optimisticList()` method names shortened** — `addOptimistic` → `add`, `removeOptimistic` → `remove`, `updateOptimistic` → `update`. The old names are kept as deprecated aliases so existing code keeps working.

### Fixed — Core Reactivity

- **`deepEqual` shared-reference false positive** — the `seen` set tracked only `a`, not `(a, b)` pairs. Shared sub-objects compared against different partners were incorrectly treated as equal. Now tracks `Map<object, Set<object>>` pairs.
- **`deepEqual` constructor mismatch** — `deepEqual(new Date(), {})` returned `true` because Date has no enumerable keys. Added constructor guard before falling through to key comparison.
- **`deepEqual` Map/Set not compared** — `Map` and `Set` contents were invisible to `Object.keys`. Added explicit Map (deep value equality) and Set (shallow membership) branches, plus ArrayBuffer and TypedArray support.
- **`deepEqual` self-referential Map/Set** — cycle detection was placed after the Map/Set branches, causing infinite recursion on self-referential containers. Moved cycle detection before all container comparisons.
- **`derived` circular dependency** — circular derived chains caused silent stack overflow. Added an `evaluating` re-entrance flag that throws a clear `"Circular dependency detected"` error with the signal's debug name.
- **`drainNotificationQueue` infinite loop** — an effect writing to a signal it reads could loop forever. Added a `MAX_DRAIN_ITERATIONS = 1000` cap with a console error diagnostic.
- **`deferredValue` never updated** — had no reactive subscription on the source getter (no `effect`/`track`). Rewrote to use `effect()` for source tracking, scheduling LOW-priority updates via the scheduler.

### Fixed — Data Fetching

- **`resource.abort()` left `loading()` stuck at `true`** — the `AbortError` catch returned without resetting the loading signal. Now calls `setLoading(false)` in the abort path.
- **`query` subscriber leak on same-key re-run** — effect re-runs with an unchanged key double-counted `entry.subscribers`, preventing cache GC. Now only increments when the key actually changed or the entry has zero subscribers.
- **`mutation` concurrent state clobbering** — rapid `mutate()` calls raced without guard. Added a `runId` version counter; stale responses are silently ignored.
- **`withRetry` abort listener leak** — the `abort` event listener on `AbortSignal` was never removed when the delay timer resolved normally. Added `removeEventListener` in the timer resolve path.

### Fixed — State Patterns

- **`optimistic` concurrent stale reverts** — each operation now gets a version number; reverts only fire if no newer operation has started. Prevents stale snapshots from overwriting fresher optimistic state.
- **`optimistic` `pending` never exposed** — the `pending` signal was created but never returned. Now exposed in the return object for both `optimistic` and `optimisticList`.
- **`optimisticList.updateOptimistic` predicate failure after patch** — the success-path predicate re-ran against the already-mutated item. If the patch changed the matched property, the server result was silently dropped. Now captures patched references during the optimistic phase and matches by identity in the success path.
- **`persisted` effect not stopped by `dispose()`** — the persisting effect's return value was discarded, so `dispose()` only removed the storage listener but left the effect running. Now captured and called in `dispose()`.
- **`globalStore` shallow initial copy** — `reset()` could fail to fully restore nested objects if they were mutated in-place. Changed to `JSON.parse(JSON.stringify(...))` for a deep copy of initial state.

### Fixed — Routing

- **Wildcard route too permissive** — `/admin/*` incorrectly matched `/admin-panel` because the check used `path.startsWith(basePath)` without a segment boundary. Now requires `path === basePath || path.startsWith(basePath + "/")`.
- **Guard timeout/abort listener leak** — when `next()` was called asynchronously, the microtask-based cleanup had already run and missed it. Moved `clearTimeout` + `removeEventListener` into the `next()` callback itself. The abort handler now also clears the timeout timer.

### Fixed — Rendering & Lifecycle

- **`dispose()` one throwing disposer aborted entire subtree cleanup** — wrapped each disposer call in try/catch with a dev-mode warning.
- **`onMount` cleanup return discarded** — the type signature accepted a cleanup return function but `safeCall` discarded it. Now captured and registered via `registerDisposer(element, cleanup)`.
- **`onMount` MutationObserver leaked** — if an element was disposed before ever connecting to the DOM, the observer on `document.body` ran forever. Now registered for cleanup via `registerDisposer`.
- **`onUnmount` observer ran for element's entire lifetime** — the MutationObserver on `document.body` fired on every DOM mutation globally. Now registered for cleanup via `registerDisposer` and the callback itself is also wired through `registerDisposer` as the primary teardown path.
- **`Portal` cleanup via MutationObserver only** — didn't integrate with `dispose()`/`when()`/`match()`/`each()`. Replaced with `registerDisposer(anchor, ...)` so portal content is properly disposed and removed through the standard dispose system.
- **`lazy` stale load** — if the container was removed before the dynamic import resolved, the rendered component leaked subscriptions. Added a `disposed` guard that silently drops stale `.then()`/`.catch()` callbacks. Removed dead `_status`/`_error` signals that were created but never read.

### Fixed — UI Utilities

- **`bindField` merge order** — `{...fieldOn, ...extraOn}` let extras clobber field handlers (input/change/blur). Contradicted the 1.0.4 fix intent. Flipped to `{...extraOn, ...fieldOn}` so field handlers always win.
- **`form.handleSubmit` double-submit** — no guard against concurrent async submissions. Added a `submitting` signal; `handleSubmit` checks it before calling the callback and resets on resolve/reject. Exposed as `form.submitting()` on `FormReturn`.
- **`inputMask` cursor jump** — no cursor position restoration after mask application; cursor jumped to end on every keystroke. Added cursor tracking that counts raw chars before the old cursor position and places the cursor after that many filled slots in the masked output.
- **`inputMask` strip regex too aggressive** — `/[^a-zA-Z0-9]/g` stripped all special characters, making `*` mask slots unable to accept non-alphanumeric input. Now builds a pattern-aware strip regex: patterns with `*` only strip literal mask characters.
- **`transition` rapid enter/leave** — stale `setTimeout` callbacks from a previous enter/leave fired during the opposite animation, corrupting class state. Added `activeTimer` tracking with `cancelPending()` at the start of each enter/leave.
- **`scopedStyle` pseudo-element scoping** — scope attribute was appended after `::before`/`::after` pseudo-elements, producing invalid CSS selectors. Now splits at `::` and inserts `[attr]` before the pseudo-element.
- **`VirtualList` scroll listener leak** — the scroll event listener was never cleaned up. Added `registerDisposer` with `removeEventListener`.
- **`dialog` no dispose** — the global keydown listener leaked if the dialog was open when the component was destroyed. Added `dispose()` method that detaches the listener and resets state.
- **`FocusTrap` observer scope** — MutationObserver watched only the direct parent; ancestor removal leaked the observer and missed focus restore. Changed to `document.body` with `subtree: true`. Added `registerDisposer` integration for SPA cleanup. Zero-focusable-elements case now calls `e.preventDefault()` to prevent Tab from escaping the trap.

### Fixed — Browser Composables

- **`urlState` missing `hashchange` listener** — anchor clicks and `location.hash` assignments don't fire `popstate`, so `hash()` went stale. Added `hashchange` listener alongside `popstate`. Added deduplication guard to avoid unnecessary signal notifications. `setHash("#")` now clears the hash instead of keeping a bare `#`.
- **`scroll` non-reactive target** — the scroll target element was resolved once at creation and never re-evaluated. Rewrote to use `effect()` for reactive target tracking, re-attaching the listener when the element changes (same pattern as `resize`/`dragDrop`).
- **`socket.close()` auto-reconnected** — the `onclose` handler couldn't distinguish manual close from unexpected disconnect. Added a `manuallyClosed` flag set in `close()` and checked in `onclose` to suppress auto-reconnect.

### Fixed — DevTools

- **`createTraceProfiler` subscribed to non-existent events** — listened for `effect:start`/`effect:end`/`signal:set` but the core emits `effect:create`/`effect:destroy`/`signal:update`. Fixed event names and changed to instant (`"I"`) events since the core doesn't emit begin/end pairs.

### Changed

- **`optimistic()` returns a named object** — `{ value, pending, update }` instead of `[getter, setter]`. See Breaking section.
- **`optimisticList()` shorter method names** — `add`/`remove`/`update` with deprecated `addOptimistic`/`removeOptimistic`/`updateOptimistic` aliases.
- **`deepSignal` return type** — now infers from `signal()` directly, preserving the `Accessor<T>` brand on the getter.
- **`hotkey` `global` option removed** — was declared but never used (dead code).
- **`context` JSDoc updated** — accurately describes global reactive store semantics instead of falsely promising subtree-scoped DI.
- **JSDoc examples across 17 source files** — ~35 code examples converted from legacy `{ nodes: }` form to canonical positional shorthand.
- **README** — updated to canonical shorthand authoring style; `$(pattern matching)$` typo fixed.

### Tests

- **`deepSignal.test.ts`** — expanded from 4 → 52 tests covering Map, Set, TypedArray, shared refs, cycles, constructor mismatch.
- **`urlState.test.ts`** — expanded from 6 → 20 tests covering hashchange, dedup, edge cases, SSR.
- **`optimistic.test.ts`** — expanded from 5 → 17 tests covering pending, concurrent guards, predicate-after-mutation.
- Full suite: **2178 / 2178 passing** (up from 2105 in 1.4.0). Zero regressions.

---

## [1.4.0] — 2026-04-11

Cleanup release. Removes six public aliases that contradicted the SibuJS philosophy — plain verbs, no framework ceremony, no redundant synonyms for the same primitive. All of the removed APIs were either one-line forwards to an existing primitive or identity wrappers; every existing example can be rewritten by deleting the wrapper and calling the underlying primitive directly.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sibujs",
"version": "1.4.0",
"version": "1.5.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",
Expand Down
8 changes: 4 additions & 4 deletions src/browser/fullscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import { signal } from "../core/signals/signal";
* @example
* ```ts
* const fs = fullscreen();
* button({
* nodes: () => (fs.isFullscreen() ? "Exit fullscreen" : "Enter fullscreen"),
* on: { click: () => fs.toggle(videoEl) },
* });
* button(
* { on: { click: () => fs.toggle(videoEl) } },
* () => (fs.isFullscreen() ? "Exit fullscreen" : "Enter fullscreen"),
* );
* ```
*/
export function fullscreen(): {
Expand Down
31 changes: 27 additions & 4 deletions src/browser/scroll.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { effect } from "../core/signals/effect";
import { signal } from "../core/signals/signal";
import { batch } from "../reactivity/batch";

Expand All @@ -6,6 +7,9 @@ import { batch } from "../reactivity/batch";
* Returns reactive x/y scroll positions and an isScrolling indicator
* that resets after 150ms of inactivity.
*
* If a reactive target getter is provided, the listener re-attaches
* whenever the target element changes (same pattern as resize/dragDrop).
*
* @param target Optional reactive getter for the scroll target element.
* If omitted or returns null, tracks window scroll.
* @returns Object with reactive x, y, isScrolling getters and a dispose function
Expand All @@ -20,6 +24,8 @@ export function scroll(target?: () => HTMLElement | null): {
const [y, setY] = signal(0);
const [isScrolling, setIsScrolling] = signal(false);
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let currentTarget: EventTarget | null = null;
let effectCleanup: (() => void) | null = null;

if (typeof window === "undefined") {
return { x, y, isScrolling, dispose: () => {} };
Expand All @@ -45,12 +51,29 @@ export function scroll(target?: () => HTMLElement | null): {
}, 150);
};

const scrollTarget = target ? target() : null;
const eventTarget = scrollTarget || window;
eventTarget.addEventListener("scroll", handler, { passive: true });
function attachListener(eventTarget: EventTarget): void {
if (currentTarget === eventTarget) return;
if (currentTarget) currentTarget.removeEventListener("scroll", handler);
currentTarget = eventTarget;
currentTarget.addEventListener("scroll", handler, { passive: true });
}

if (target) {
// Reactive target — re-attach listener when element changes
effectCleanup = effect(() => {
const el = target();
attachListener(el || window);
});
} else {
attachListener(window);
}

function dispose() {
eventTarget.removeEventListener("scroll", handler);
effectCleanup?.();
if (currentTarget) {
currentTarget.removeEventListener("scroll", handler);
currentTarget = null;
}
if (scrollTimer !== null) {
clearTimeout(scrollTimer);
scrollTimer = null;
Expand Down
8 changes: 4 additions & 4 deletions src/browser/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export interface SpeakOptions {
* @example
* ```ts
* const tts = speech();
* button({
* nodes: "Read it to me",
* on: { click: () => tts.speak("Hello, world!", { rate: 1.1 }) },
* });
* button(
* { on: { click: () => tts.speak("Hello, world!", { rate: 1.1 }) } },
* "Read it to me",
* );
* ```
*/
export function speech(): {
Expand Down
38 changes: 27 additions & 11 deletions src/browser/urlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { signal } from "../core/signals/signal";
* to sync a handful of UI state bits with the URL (filters, tabs, modals)
* without a full router setup.
*
* Listens to `popstate` so browser back/forward updates the signals.
* Listens to both `popstate` (back/forward) and `hashchange` (anchor clicks,
* direct `location.hash` assignments) so the signals stay in sync regardless
* of how the URL was changed.
*
* @example
* ```ts
Expand Down Expand Up @@ -48,36 +50,50 @@ export function urlState(): {
};
}

const [params, setParamsSignal] = signal(new URLSearchParams(window.location.search));
const [hash, setHashSignal] = signal(window.location.hash);
let lastSearch = window.location.search;
let lastHash = window.location.hash;

const syncFromLocation = () => {
setParamsSignal(new URLSearchParams(window.location.search));
setHashSignal(window.location.hash);
};
const [params, setParamsSignal] = signal(new URLSearchParams(lastSearch));
const [hash, setHashSignal] = signal(lastHash);

const onPopState = () => syncFromLocation();
window.addEventListener("popstate", onPopState);
function syncFromLocation() {
const currentSearch = window.location.search;
const currentHash = window.location.hash;
if (currentSearch !== lastSearch) {
lastSearch = currentSearch;
setParamsSignal(new URLSearchParams(currentSearch));
}
if (currentHash !== lastHash) {
lastHash = currentHash;
setHashSignal(currentHash);
}
}

window.addEventListener("popstate", syncFromLocation);
window.addEventListener("hashchange", syncFromLocation);

function setParams(next: URLSearchParams | Record<string, string>, opts: UrlStateOptions = {}) {
const p = next instanceof URLSearchParams ? next : new URLSearchParams(next);
const query = p.toString();
const newUrl = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
if (opts.replace) window.history.replaceState(null, "", newUrl);
else window.history.pushState(null, "", newUrl);
lastSearch = window.location.search;
setParamsSignal(new URLSearchParams(p));
}

function setHash(next: string, opts: UrlStateOptions = {}) {
const normalized = next.startsWith("#") ? next : next ? `#${next}` : "";
const normalized = next && next !== "#" ? (next.startsWith("#") ? next : `#${next}`) : "";
const newUrl = `${window.location.pathname}${window.location.search}${normalized}`;
if (opts.replace) window.history.replaceState(null, "", newUrl);
else window.history.pushState(null, "", newUrl);
lastHash = normalized;
setHashSignal(normalized);
}

function dispose() {
window.removeEventListener("popstate", onPopState);
window.removeEventListener("popstate", syncFromLocation);
window.removeEventListener("hashchange", syncFromLocation);
}

return { params, hash, setParams, setHash, dispose };
Expand Down
2 changes: 1 addition & 1 deletion src/build/staticAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const VOID_ELEMENTS = new Set([
* converted to template cloning at build time.
*
* Detects patterns like:
* div({ class: "card", id: "main", nodes: "Hello" })
* div({ class: "card", id: "main" }, "Hello")
*
* And identifies them as candidates for:
* staticTemplate('<div class="card" id="main">Hello</div>')
Expand Down
2 changes: 1 addition & 1 deletion src/components/ErrorBoundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface ErrorBoundaryProps {
* const [route, setRoute] = signal("/");
* ErrorBoundary({
* resetKeys: [route],
* nodes: () => div({ nodes: riskyPageFor(route()) }),
* nodes: () => div(riskyPageFor(route())),
* });
* ```
*/
Expand Down
10 changes: 5 additions & 5 deletions src/components/ErrorDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,13 +404,13 @@ function renderMetadata(meta: Record<string, string | number | boolean | null |
*
* @example
* ```ts
* button({
* on: { click: async () => {
* button(
* { on: { click: async () => {
* try { await save(); }
* catch (err) { mount(ErrorDisplay({ error: err, onRetry: save }), errorHost); }
* }},
* nodes: "Save",
* });
* }}},
* "Save",
* );
* ```
*/
export function ErrorDisplay(props: ErrorDisplayProps): Element {
Expand Down
3 changes: 1 addition & 2 deletions src/core/rendering/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ export type ActionFn<T = void> = (element: HTMLElement, param: T) => (() => void
* action(el, clickOutside, () => setOpen(false));
* action(el, longPress, { duration: 500, callback: onLongPress });
* },
* nodes: "Content",
* });
* }, "Content");
* ```
*/
export function action<T>(element: HTMLElement, actionFn: ActionFn<T>, param: T): void;
Expand Down
Loading
Loading