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
44df880
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 18, 2026
3205cbf
perf(reactivity): linked-list subscription graph, O(1) dup detection …
hexplus Apr 19, 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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,53 @@ This project follows [Semantic Versioning](https://semver.org/).

---

## [2.2.0] — 2026-04-18

Reactivity-core rewrite. Replaces the `Set<Subscriber>` / `Map<Signal, epoch>` subscription graph with doubly-linked `SubNode` edges, a node pool, and an `__activeNode` back-pointer for O(1) duplicate-dependency detection. Subscription is now O(1) on both add and remove, the hot path has no hash operations, and GC churn on create/destroy workloads drops sharply.

**Improvements over 2.1.0 on the reactivity stress-test suite (`benchmarks/`):**

- **Wide graph / 10k fan-out: ~73% faster** (56.8 ms → 15.4 ms)
- **Cascading effects: ~41% faster** (2.03 ms → 1.20 ms)
- **Memory & cleanup: ~21% faster** (51.3 ms → 40.6 ms)
- **Component tree propagation: ~10% faster** (23.0 ms → 20.6 ms)
- **Deep computed chain: ~7% faster** (3.87 ms → 3.60 ms)

**201/201 test files, 2187/2187 tests passing. No breaking changes to the documented public API** — `signal`, `derived`, `effect`, `batch`, `untracked`, `on`, `setMaxDrainIterations`, `setMaxSubscriberRepeats`, devtools introspection helpers, all behave identically.

### Added

- **`cleanup(subscriber)`** now exported from `sibujs/reactivity/track`. Disposes a subscriber directly without allocating an intermediate closure. Enables custom effect-like primitives to manage their own lifecycle without going through `track()`'s disposer.
- **`getSubscriberCount(signal)`** — O(1) count of active subscribers, read from the `__sc` counter maintained on every subscribe/unsubscribe.
- **`getSubscriberDeps(subscriber)`** — returns the signals a subscriber currently depends on, in record order. Replaces the previous `_dep` / `_deps` probe used by devtools.
- **`forEachSubscriber(signal, visit)`** — iterate a signal's subscriber list without exposing the internal linked-list structure to callers.

### Changed

- **Subscription storage migrated from Set + Map to doubly-linked `SubNode` edges.** Each `(signal, subscriber)` pair is one object linked into both the signal's subscriber list and the subscriber's dep list. O(1) subscribe / unsubscribe via pointer splice, no hash operations on the hot path, one allocation per edge instead of two.
- **Duplicate-dependency detection during tracking is now O(1)** via a `signal.__activeNode` back-pointer (Preact Signals' approach). A subscriber with 10 000 deps reading one signal twice no longer pays O(N²) in its inner loop.
- **Effects now re-run via `retrack()` instead of `track()`.** Stable-dep effects (the overwhelmingly common case) skip the cleanup-and-rebuild cycle entirely — epoch-based pruning at end of run handles any deps that were dropped this invocation. On the Cascading Effects benchmark this drops per-invocation cost by ~40 ns.
- **Effect internals consolidated behind an `EffectCtx` object.** Per-effect closure count went from six (`onCleanup`, `flushUserCleanups`, `wrappedFn`, `drainReruns`, `subscriber`, `dispose`) to three. `runSubscriber` and `runBody` are inlined directly into the per-effect closures, eliminating a function frame per invocation.
- **`track()` is stack-free.** The shared `subscriberStack` array is gone; `track()` uses a local `prev` / restore pattern, and `suspend/resumeTracking` capture `currentSubscriber` directly. ~5–10 ns saved per track call, universal improvement.
- **Signal state pre-initialises every internal slot** (`__v`, `__sc`, `subsHead`, `subsTail`, `__activeNode`, `__name`) at construction. V8 hidden classes stay monomorphic across all signals; inline caches in the reactivity hot paths don't transition on first subscribe.
- **Signal setter specialised at creation time** — one closure for the default `Object.is` equality path, one for custom `equals`, one dev-mode variant carrying the devtools-hook emission. No per-call branching on the hot path.
- **Cached `track()` disposer** via `sub._dispose ??= …` — allocated once per subscriber instead of once per `track()` call. Meaningful for high-churn workloads (large lists, create/destroy cycles).
- **Node pool** (cap 4 096) recycles freed `SubNode` objects. Shape-stable allocation keeps hidden classes monomorphic; a create/destroy cycle with 25 000 effects reuses edge nodes instead of allocating and freeing them.

### Removed

- **`signal.__s`** — the Set-based subscriber cache. Replaced by `subsHead` / `subsTail` linked-list anchors plus `__sc` (count). External consumers should read counts via `getSubscriberCount()`.
- **`signal.__f`** — the single-subscriber fast-path cache. A one-node linked-list walk is inherently as fast as the check it was avoiding.
- **Internal `subscriberStack`** — the shared push/pop array used by the old `track()` / `suspend/resume` pair. Not observable from user code.

### Internal

- `introspect.ts` delegates to the new `getSubscriberCount` / `getSubscriberDeps` / `forEachSubscriber` helpers. Public API surface (`ReactiveNodeInfo`, `getSignalName`, `getDependencies`, `inspectSignal`, `walkDependencyGraph`) unchanged.
- `devtools.ts` reads `node.ref?.__sc` instead of `node.ref?.__s.size`.
- A three-color CLEAN/CHECK/DIRTY propagation model was prototyped and reverted after benchmark regression (+122% on Deep Chain). The workloads in the current suite all produce new downstream values on every signal change, so the CHECK state has no work to skip — only overhead to add. A dedicated benchmark suite for stabilisation patterns needs to come first; re-introducing three-color propagation is parked for a future release.

---

## [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.**
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": "2.1.0",
"version": "2.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",
Expand Down
31 changes: 21 additions & 10 deletions src/core/signals/derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import type { Accessor } from "./signal";
/**
* derived creates a derived reactive signal whose value updates when dependencies change.
*
* Uses lazy pull-based evaluation with dirty flagging:
* Uses lazy pull-based evaluation with a single dirty flag:
* - When a dependency changes, the computed is marked dirty (no re-evaluation).
* - Dirtiness propagates downstream via propagateDirty.
* - The getter only re-evaluates when actually read (pull-based).
* - On re-evaluation, dependencies are re-tracked via track() so that
* derived-of-derived chains propagate correctly.
* - On re-evaluation, dependencies are re-tracked via retrack() so that
* derived-of-derived chains propagate correctly without paying the full
* Set-delete + re-add cost of track()'s cleanup phase.
*
* NOTE: a previous revision experimented with three-color (CLEAN/CHECK/DIRTY)
* state for read-side value-change short-circuiting. It regressed every
* benchmark except Memory (Deep Chain +122%, Component Tree +20%) because
* the workloads always produce a new downstream value and CHECK had no
* work to skip — only overhead to add. Keeping the simpler boolean flag
* here; revisit CHECK propagation when we have benchmarks that exercise
* stabilisation on diamond / conditional-branch patterns.
*/
export function derived<T>(
getter: () => T,
Expand All @@ -28,6 +37,10 @@ export function derived<T>(
const cs: any = {};
cs._d = false;
cs._g = getter;
// __v: monotonic version counter, bumped only when re-evaluation produces
// a value different from the previous (Object.is comparison). Kept on the
// computed so future read-side short-circuit work can compare against it.
cs.__v = 0;

const markDirty = (): void => {
if (cs._d) return;
Expand Down Expand Up @@ -66,14 +79,14 @@ export function derived<T>(
evaluating = true;
let threw = true;
try {
// Use retrack (epoch-based sweep) so steady-state chains skip
// Set.delete+add cycles on every pull — only deps that actually
// changed do Set mutations.
const prev = cs._v;
retrack(() => {
cs._v = getter();
const next = getter();
cs._v = equals && cs._v !== undefined ? (equals(cs._v, next) ? cs._v : next) : next;
cs._d = false;
threw = false;
}, markDirty);
if (!Object.is(prev, cs._v)) cs.__v++;
} finally {
evaluating = false;
if (threw) cs._d = true;
Expand All @@ -91,9 +104,6 @@ export function derived<T>(
evaluating = true;
let threw = true;
try {
// retrack (no cleanup, no stack array push) so new conditional
// deps acquired by the getter still subscribe markDirty. Steady-
// state chains skip the Set.delete+add cycle that track() incurs.
retrack(() => {
const next = getter();
// If caller provided a custom equality fn and the value didn't
Expand All @@ -104,6 +114,7 @@ export function derived<T>(
cs._d = false;
threw = false;
}, markDirty);
if (!Object.is(oldValue, cs._v)) cs.__v++;
} finally {
evaluating = false;
if (threw) cs._d = true;
Expand Down
Loading
Loading