Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
c7bee73
fix(reactivity): harden core for convergence, stale-dep pruning, and …
hexplus Apr 18, 2026
fdb6b40
Linter fixes
hexplus Apr 18, 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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## [2.1.0] — 2026-04-17

Reactivity-core hardening release. Closes correctness gaps around effect re-entry, derived stale deps, sibling-effect consistency, and cycle detection. **201/201 test files, 2187/2187 tests passing — no behavior changes to user code that was already correct.**

### Fixed

- **Effects that write to a signal they subscribe to no longer silently drop the update.** Previously the re-entrant invocation was dropped with a dev-only warning, leaving the effect's observed state out of sync with reality. Now the update is flagged as `rerunPending` and the effect re-runs after its current body completes, converging on consistent state. A 100-iteration safety cap breaks legitimate write-reads-self cycles with a loud `console.error` instead of hanging.

- **`derived()` no longer accumulates stale dependencies on conditional code paths.** A getter like `() => flag() ? a() : b()` used to keep both `a` and `b` subscribed forever once both had been read, causing spurious re-evaluations whenever the untaken branch fired. The `retrack()` pull path now tags each dependency with a per-evaluation epoch and unsubscribes any edge whose epoch is stale at end of run — bounded memory, no spurious work.

- **Sibling effects now converge to consistent state through the outermost notification.** Previously two paths of `notifySubscribers` diverged: the pure-effect fast path allowed re-enqueue (effects could run twice, final state consistent), while the mixed-computed slow path forbade it (effects ran once, possibly observing stale downstream state). Both paths now share a single drain with at-most-once enqueue dedup cleared before invoke — sibling effects that cross-write converge rather than one losing to the other.

- **Unbounded empty-`__s` allocation per signal.** Signals whose last subscriber disposed kept an empty subscriber `Set` on the signal object for the process lifetime. The set is now cleared when size drops to zero.

- **`subscriberStack` never released memory after a one-off nesting spike.** A transient deep-nesting excursion (e.g. a debug-mode traversal) could double the stack and retain it forever. The stack now shrinks lazily at end-of-`track()` when idle and over-allocated.

### Changed

- **Cycle detection is now per-subscriber repeat-counted instead of total-iteration-capped.** The previous 100 000-iteration cap conflated "infinite cycle" with "legitimate large fan-out" — apps with 100k+ effects in one batch flirted with false positives while real tight cycles could burn the full budget before tripping. The new detector counts per-subscriber firings within a drain and bails when any single subscriber exceeds `maxSubscriberRepeats` (default 50) — accurate, cheap, and tolerant of arbitrary legitimate fan-out. The absolute iteration cap is retained as a safety net at 1 000 000.

- **`setMaxDrainIterations(n)`** is now the safety-net knob rather than the primary cycle check; semantics unchanged for callers, default raised from 100 000 → 1 000 000.

### Added

- **`setMaxSubscriberRepeats(n)`** — raise/lower the per-subscriber repeat cap used for cycle detection. Returns the previous value.

### Internal

- Subscriber dep storage in the reactivity core migrated from `Set<signal>` to `Map<signal, epoch>` to carry per-edge epoch tags for `retrack()` pruning. Public API unchanged; the single-dep fast path still avoids `Map` allocation entirely.

- `__f` / `__s` fast-path invariant centralized in a `syncFastPath()` helper — same performance, simpler to reason about across add/remove sites.

- Devtools `introspect.getDependencies()` updated for the new `Map` layout; return type unchanged.

---

## [2.0.0] — 2026-04-14

Major hardening + features release. Spans reactivity, rendering, SSR, widgets, security, and build tooling. **2187/2187 tests passing, zero lint errors, zero type errors.**
Expand Down
2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export * from "./src/core/ssr-context";
export * from "./src/reactivity/batch";
export * from "./src/reactivity/nextTick";
export * from "./src/reactivity/concurrent";
export { untracked } from "./src/reactivity/track";
export { untracked, retrack, setMaxDrainIterations } from "./src/reactivity/track";
export { bindDynamic } from "./src/reactivity/bindAttribute";

// Lazy loading & Suspense
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sibujs",
"version": "2.0.0",
"version": "2.1.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 Expand Up @@ -122,8 +122,7 @@
}
},
"publishConfig": {
"access": "public",
"provenance": true
"access": "public"
},
"browserslist": [
"Chrome >= 80",
Expand Down
70 changes: 54 additions & 16 deletions src/core/signals/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,38 +116,76 @@ export function effect(effectFn: EffectBody | (() => void), options?: EffectOpti

let cleanupHandle: () => void = () => {};
let running = false;
let rerunPending = false;

// Safety cap — if an effect keeps requesting re-runs, bail rather than
// loop forever. Matches the spirit of drainNotificationQueue's cap.
const MAX_RERUNS = 100;

const subscriber = () => {
if (running) {
// Effect wrote to a signal it depends on while still running. We
// can't re-enter without risking infinite recursion, so the update
// is dropped — surface it in dev so the developer can debug.
if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") {
console.warn(
"[SibuJS] effect re-entered itself while running — " +
"the triggering update will be ignored. Wrap mutual writes in `batch()` " +
"or split the effect to avoid this.",
);
}
// Effect wrote to a signal it depends on while still running.
// Instead of silently dropping the update (which leaves the effect's
// last-seen state out of sync with reality), flag a re-run request
// and run it after the current body finishes. This preserves the
// no-reentrant-recursion invariant while keeping state consistent.
rerunPending = true;
return;
}
running = true;
try {
// Run user onCleanup BEFORE cleanupHandle so user teardown observes
// the reactive state from the previous run (e.g. before subs are cut).
runUserCleanups();
cleanupHandle();
cleanupHandle = track(wrappedFn, subscriber);
let reruns = 0;
do {
rerunPending = false;
// Run user onCleanup BEFORE cleanupHandle so user teardown observes
// the reactive state from the previous run (e.g. before subs are cut).
runUserCleanups();
cleanupHandle();
cleanupHandle = track(wrappedFn, subscriber);
if (++reruns > MAX_RERUNS) {
if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") {
console.error(
`[SibuJS] effect re-requested itself ${MAX_RERUNS}+ times — ` +
"likely a write-reads-self cycle. Breaking to prevent infinite loop.",
);
}
rerunPending = false;
break;
}
} while (rerunPending);
} finally {
running = false;
rerunPending = false;
}
};

running = true;
try {
cleanupHandle = track(wrappedFn, subscriber);
let reruns = 0;
do {
rerunPending = false;
// On iterations > 1 we need to tear down the *previous* iteration's
// registrations before re-tracking, otherwise onCleanup callbacks
// accumulate across rerun passes and the prior track()'s
// subscriptions stay dangling until track()'s own cleanup() runs.
// No-ops on the first iteration (userCleanups empty, cleanupHandle noop).
runUserCleanups();
cleanupHandle();
cleanupHandle = track(wrappedFn, subscriber);
if (++reruns > MAX_RERUNS) {
if (_g.__SIBU_DEV_WARN__ !== false && typeof console !== "undefined") {
console.error(
`[SibuJS] effect re-requested itself ${MAX_RERUNS}+ times on initial run — ` +
"likely a write-reads-self cycle. Breaking to prevent infinite loop.",
);
}
rerunPending = false;
break;
}
} while (rerunPending);
} finally {
running = false;
rerunPending = false;
}

const hook = _g.__SIBU_DEVTOOLS_GLOBAL_HOOK__;
Expand Down
13 changes: 10 additions & 3 deletions src/devtools/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ export function getSubscriberCount(getter: () => unknown): number {
* Get the dependency list of an effect or computed subscriber function.
* Returns signal references that the subscriber depends on.
*
* Note: This reads the _deps Set that track.ts maintains on subscriber functions.
* Note: This reads the internal dep storage that track.ts maintains on
* subscriber functions. Handles both the single-dep fast path (`_dep`)
* and the multi-dep Map (`_deps`).
*/
export function getDependencies(subscriberFn: () => void): ReactiveSignal[] {
const deps = (subscriberFn as unknown as Record<string, unknown>)._deps as Set<ReactiveSignal> | undefined;
return deps ? Array.from(deps) : [];
const fn = subscriberFn as unknown as Record<string, unknown>;
const singleDep = fn._dep as ReactiveSignal | undefined;
if (singleDep !== undefined) return [singleDep];
const deps = fn._deps as Map<ReactiveSignal, number> | Set<ReactiveSignal> | undefined;
if (!deps) return [];
// Map exposes keys(); Set is iterable directly.
return deps instanceof Map ? Array.from(deps.keys()) : Array.from(deps);
}

/**
Expand Down
Loading
Loading