Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
a52fffc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
43b5675
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 14, 2026
44df880
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 18, 2026
aba311a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
4bf3286
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
a086428
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
e316ae0
Missing update package.json
hexplus May 29, 2026
278bfc6
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
08bc9b8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 1, 2026
7b5557d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 5, 2026
226ae51
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 5, 2026
0d5c8de
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 12, 2026
b0053ef
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 12, 2026
1b60022
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
24659ad
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
539f29a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
1f9c493
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
9c4213c
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
3ff7e32
chore: non-core hardening pass — correctness, leak, SSR & a11y fixes …
hexplus Jun 26, 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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## [3.3.3] — 2026-06-26

A hardening pass across the non-core subsystems (`ui`, `widgets`, `components`, `plugins`, `data`, `platform`, `patterns`, `performance`, `devtools`): correctness fixes, memory-leak plugs, SSR-safety guards, and accessibility improvements. No breaking changes.

### Added

- **`bindField` reflects an array back to `<select multiple>`** — the field's array value is now written onto each option's `selected` state on render and on update. (3.3.2 added the read side; this completes the two-way binding.)
- **`captureSignalGraph()` returns a real node inventory** — the devtools hook now implements `getSignalNodes()`, so the snapshot reports live signals/derived/effects (id, name, kind, current value) instead of always being empty. Dependency edges are not yet tracked.
- **`prefersReducedMotion()`** is now exported from `reducedMotion` (the one-shot check was previously duplicated privately in the spring/preset helpers).
- **`RouterLink` accepts positional children** — `RouterLink(props, children)` matches the framework's children convention; the `nodes` prop is kept as a deprecated alias.

### Fixed

- **Teardown-tied memory leaks** — `Head()`, the router's route outlets (`Route` / `KeepAliveRoute` / `Outlet`), `transition()`, and `hover()` now release their injected elements / cached subtrees / timers / listeners when their element or subtree is disposed, instead of living for the page's lifetime.
- **SSR no longer crashes** in `persisted()`, the priority scheduler, `stream()` / `socket()`, the router constructor (`createMemoryRouter`), `scrollRestoration()`, and `battery()` — each guards the browser global it touches and degrades gracefully instead of throwing a `ReferenceError`.
- **Devtools trace profiler** — `stop()` / `stopTrace()` no longer throw; the hook's `on()` now returns a working unsubscribe, so listeners are removed instead of leaking.
- **`form().isDirty`** no longer reports a field as dirty when an array/object value is unchanged (e.g. a multi-select with `initial: []`).
- **`createAction().submit`** — a slow earlier submission can no longer overwrite the result of a newer one (run-sequenced state updates).
- **`inputMask`** caret restoration — fixed mis-counting of literal characters for `*` masks and patterns with a leading literal.
- **`pagination`** clamps the current page reactively when the item count shrinks, so indices stay in bounds.
- **`infiniteScroll`** re-checks intersection after loading, so it doesn't stall when appended content doesn't push the sentinel out of view.
- **`lazyModule`** caches a loader that resolves to `undefined`/falsy instead of re-invoking it on every `get()`.
- **`normalize`** — child relations default to `id` instead of inheriting the parent's custom `idKey` (which produced `String(undefined)` ids).
- **Route matching is specificity-ordered** — a broad parameter route can no longer shadow a more specific route registered after it.
- Smaller fixes: the test `mockFetch` tolerates non-JSON request bodies; the a11y checker no longer exempts the literal string `"undefined"` from ARIA value validation.

### Accessibility

- **`Loading`** exposes `role="status"` + `aria-live="polite"` and a default label, so it is announced to screen readers.
- **`datePicker`** gives its grid an accessible name (the displayed month) and moves real focus to follow the roving tabindex during keyboard navigation.
- **Widget `bind()` is reversible** — `Combobox`, `Select`, and `datePicker` restore the `role` / `aria-*` / `id` / `tabindex` they set when torn down (matching `Accordion` / `Tabs` / `Popover`).
- **`Combobox`** no longer races a blur-close against an option click (a pointer-down inside the listbox keeps the input focused).
- **`contentEditable`** can be scoped to an editor element so its formatting commands ignore selections elsewhere on the page.

### Changed

- Internal coordination state for several modules (the `plugin` default registry, the `devtools` session, and the WASM / microfrontend module caches) is now shared across duplicate runtime copies, extending the 3.3.1/3.3.2 first-copy-wins sweep.
- `ErrorDisplay` reads dev-mode state at render time rather than at module load, so a runtime-configured dev flag is respected.

---

## [3.3.2] — 2026-06-26

Continues the duplicate-runtime hardening from 3.3.1, plus two small additive enhancements. No breaking changes.
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": "3.3.2",
"version": "3.3.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",
Expand Down
5 changes: 4 additions & 1 deletion src/browser/animationFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function animationFrame(options: AnimationFrameOptions = {}): {
let id: number | null = null;
let prev = -1;
let start = -1;
let disposed = false;
const minFrameMs = options.fpsLimit ? 1000 / options.fpsLimit : 0;

const step = (now: number) => {
Expand All @@ -66,7 +67,8 @@ export function animationFrame(options: AnimationFrameOptions = {}): {
};

function resume() {
if (id !== null) return;
// `disposed` makes dispose() permanent — resume() can't restart the loop.
if (disposed || id !== null) return;
setRunning(true);
id = requestAnimationFrame(step);
}
Expand All @@ -82,6 +84,7 @@ export function animationFrame(options: AnimationFrameOptions = {}): {
}

function dispose() {
disposed = true;
pause();
}

Expand Down
41 changes: 24 additions & 17 deletions src/browser/battery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,34 @@ export function battery(): {
if (typeof navigator !== "undefined" && "getBattery" in navigator) {
setSupported(true);

(navigator as unknown as { getBattery(): Promise<BatteryManager> }).getBattery().then((bm: BatteryManager) => {
if (disposed) return;
const batteryPromise = (navigator as unknown as { getBattery(): Promise<BatteryManager> })
.getBattery()
.then((bm: BatteryManager) => {
if (disposed) return;

battery = bm;
battery = bm;

batch(() => {
setLevel(bm.level);
setCharging(bm.charging);
setChargingTime(bm.chargingTime);
setDischargingTime(bm.dischargingTime);
});
batch(() => {
setLevel(bm.level);
setCharging(bm.charging);
setChargingTime(bm.chargingTime);
setDischargingTime(bm.dischargingTime);
});

onLevelChange = () => setLevel(bm.level);
onChargingChange = () => setCharging(bm.charging);
onChargingTimeChange = () => setChargingTime(bm.chargingTime);
onDischargingTimeChange = () => setDischargingTime(bm.dischargingTime);
onLevelChange = () => setLevel(bm.level);
onChargingChange = () => setCharging(bm.charging);
onChargingTimeChange = () => setChargingTime(bm.chargingTime);
onDischargingTimeChange = () => setDischargingTime(bm.dischargingTime);

bm.addEventListener("levelchange", onLevelChange);
bm.addEventListener("chargingchange", onChargingChange);
bm.addEventListener("chargingtimechange", onChargingTimeChange);
bm.addEventListener("dischargingtimechange", onDischargingTimeChange);
bm.addEventListener("levelchange", onLevelChange);
bm.addEventListener("chargingchange", onChargingChange);
bm.addEventListener("chargingtimechange", onChargingTimeChange);
bm.addEventListener("dischargingtimechange", onDischargingTimeChange);
});
batteryPromise.catch(() => {
// getBattery() can reject (insecure context / permission denied) —
// degrade quietly instead of surfacing an unhandled rejection.
if (!disposed) setSupported(false);
});
}

Expand Down
6 changes: 6 additions & 0 deletions src/browser/bounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ export function bounds(target: Element): {
};
}

let disposed = false;

function refresh() {
// A queued observer/scroll callback or a manual refresh() after dispose()
// must not write to the torn-down instance's signal.
if (disposed) return;
setRect(readRect(target));
}

Expand All @@ -86,6 +91,7 @@ export function bounds(target: Element): {
window.addEventListener("scroll", onScroll, { passive: true, capture: true });

function dispose() {
disposed = true;
resizeObserver?.disconnect();
window.removeEventListener("scroll", onScroll, { capture: true });
}
Expand Down
12 changes: 11 additions & 1 deletion src/browser/wakeLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,19 @@ export function wakeLock(): {

const api = (navigator as unknown as { wakeLock: WakeLockApi }).wakeLock;
let sentinel: WakeLockSentinel | null = null;
let disposed = false;

async function request(): Promise<void> {
if (disposed) return;
try {
sentinel = await api.request("screen");
const s = await api.request("screen");
// If dispose() ran while the request was in flight, release the sentinel
// immediately and don't register it / its listener (which would leak).
if (disposed) {
s.release().catch(() => {});
return;
}
sentinel = s;
setActive(true);
sentinel.addEventListener("release", () => {
setActive(false);
Expand Down Expand Up @@ -84,6 +93,7 @@ export function wakeLock(): {
document.addEventListener("visibilitychange", onVisibility);

function dispose(): void {
disposed = true;
document.removeEventListener("visibilitychange", onVisibility);
release().catch((err) => {
if (typeof console !== "undefined") {
Expand Down
5 changes: 4 additions & 1 deletion src/components/ErrorBoundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ function getMemoizedFallback(
*
* Features:
* - Catches sync errors thrown by children
* - Catches async errors (Promise rejections) from children
* - Catches the rejection of a Promise *returned* by children (async components)
* — it does NOT observe fire-and-forget async errors (rejections from timers,
* event handlers, or detached/unawaited promises), which surface as global
* `unhandledrejection` events instead.
* - Supports nested ErrorBoundaries (inner catches first, outer catches propagation)
* - Retry functionality to clear error and re-render children
* - Memoized fallback to avoid re-creating fallback UI on every render
Expand Down
6 changes: 3 additions & 3 deletions src/components/ErrorDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ import { signal } from "../core/signals/signal";
// No dependencies, no JSX, no compilation — every element is built
// via the tag factories, and styles are injected once per page.

const _isDev = isDev();

export type ErrorSeverity = "error" | "warning" | "info";

export interface ErrorDisplayProps {
Expand Down Expand Up @@ -427,7 +425,9 @@ export function ErrorDisplay(props: ErrorDisplayProps): Element {

const severity = props.severity ?? "error";
const normalized = normalizeError(props.error);
const showDetails = props.alwaysShowDetails ?? _isDev;
// Read dev state at render time, not module-eval — otherwise a runtime-set
// dev config (configured after this module is imported) would be ignored.
const showDetails = props.alwaysShowDetails ?? isDev();
const headline = props.title ?? normalized.message;
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);

Expand Down
8 changes: 8 additions & 0 deletions src/components/Loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export function Loading(props: LoadingProps = {}): HTMLElement {
if (variant === "dots") {
return div({
class: `sibu-loading${sizeClass}`,
role: "status",
"aria-live": "polite",
// When there's no visible text, give the live region an accessible name
// so it isn't announced as an empty status.
"aria-label": text ? undefined : "Loading",
nodes: [
div({
class: "sibu-loading-dots",
Expand All @@ -108,6 +113,9 @@ export function Loading(props: LoadingProps = {}): HTMLElement {

return div({
class: `sibu-loading${sizeClass}`,
role: "status",
"aria-live": "polite",
"aria-label": text ? undefined : "Loading",
nodes: [
div({ class: "sibu-loading-spinner" }) as HTMLElement,
text ? (span({ class: "sibu-loading-text", nodes: text }) as HTMLElement) : null,
Expand Down
6 changes: 5 additions & 1 deletion src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { effect } from "../core/signals/effect";
import { signal } from "../core/signals/signal";
import { getRequestScopedCache } from "../core/ssr-context";
import { batch } from "../reactivity/batch";
import { globalSingleton } from "../utils/globalSingleton";
import type { RetryOptions } from "./retry";
import { withRetry } from "./retry";

Expand Down Expand Up @@ -66,7 +67,10 @@ interface CacheEntry {
// data (e.g. user A's profile under key "profile") bleeds into a concurrent
// request for user B that resolves the same key. `getActiveQueryCache()`
// returns the request-scoped map under SSR and this global otherwise.
const globalQueryCache = new Map<string, CacheEntry>();
//
// Shared via globalSingleton so a bundler that duplicates this module doesn't
// give `query()` and `invalidateQueries`/`setQueryData` two separate caches.
const globalQueryCache = globalSingleton(Symbol.for("sibujs.query.cache.v1"), () => new Map<string, CacheEntry>());

function getActiveQueryCache(): Map<string, CacheEntry> {
return getRequestScopedCache<CacheEntry>("query") ?? globalQueryCache;
Expand Down
Loading
Loading