From 38eafd9ab9a2af72cde4d0e306793aa6bedf1770 Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:30:06 +0530 Subject: [PATCH 1/7] grow dirty signal list and bitmap with single clear fill --- packages/core/src/signal.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index b0a0790..3d3b511 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -323,12 +323,26 @@ let _jsDirtyBitmap = new Uint32Array(_JS_BITMAP_WORDS); let _jsDirtyList = new Int32Array(8192); let _jsDirtyCount = 0; let _jsBatchDepth = 0; +let _jsMaxDirtyWord = 0; function _jsMarkDirty(id: number): void { const word = id >>> 5; + if (word >= _jsDirtyBitmap.length) { + let newLen = _jsDirtyBitmap.length; + while (newLen <= word) newLen *= 2; + const nb = new Uint32Array(newLen); + nb.set(_jsDirtyBitmap); + _jsDirtyBitmap = nb; + } + if (_jsDirtyCount >= _jsDirtyList.length) { + const nl = new Int32Array(_jsDirtyList.length * 2); + nl.set(_jsDirtyList); + _jsDirtyList = nl; + } const mask = 1 << (id & 31); const wasClean = (_jsDirtyBitmap[word] & mask) === 0; _jsDirtyBitmap[word] |= mask; + if (word > _jsMaxDirtyWord) _jsMaxDirtyWord = word; if (wasClean) { _jsDirtyList[_jsDirtyCount++] = id; } @@ -492,10 +506,8 @@ function _syncDirty(): void { if (count === 0) return; const list = _jsDirtyList; _jsDirtyCount = 0; - - for (let i = 0; i < count; i++) { - _jsDirtyBitmap[list[i] >>> 5] = 0; - } + _jsDirtyBitmap.fill(0, 0, _jsMaxDirtyWord + 1); + _jsMaxDirtyWord = 0; _batchGen++; const gen = _batchGen; @@ -886,6 +898,7 @@ export const _resetSignals = (): void => { _jsDirtyList = new Int32Array(8192); _jsDirtyCount = 0; _jsBatchDepth = 0; + _jsMaxDirtyWord = 0; _dirtyEffBuf = new Int32Array(2048); // Direct-effect storage (v8) _directEff = new Array(2048); From 8ea1c2083aa1ebb35424ba87fc43b913ce2bf44d Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:30:55 +0530 Subject: [PATCH 2/7] grow effect dedup and buffer with effects --- packages/core/src/signal.ts | 47 ++++++++++++++----------------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index 3d3b511..fa44e56 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -390,6 +390,15 @@ function _ensureEffects(id: number): void { const newDisposed = new Uint8Array(capped); newDisposed.set(_effectDisposed.subarray(0, oldLen)); _effectDisposed = newDisposed; + + if (capped > _batchSeenGen.length) { + const ns = new Uint32Array(capped); + ns.set(_batchSeenGen.subarray(0, _batchSeenGen.length)); + _batchSeenGen = ns; + } + if (capped > _dirtyEffBuf.length) { + _dirtyEffBuf = new Int32Array(capped); + } } function _ensureManualSubSlots(id: number): void { @@ -484,12 +493,8 @@ function _runEffectList(ids: number[], count: number): void { for (let i = 0; i < count; i++) { const eid = ids[i]; - if (eid < 8192) { - if (seen[eid] !== gen) { - seen[eid] = gen; - _runEffect(eid); - } - } else { + if (seen[eid] !== gen) { + seen[eid] = gen; _runEffect(eid); } } @@ -523,19 +528,9 @@ function _syncDirty(): void { const dirFn = _directEff[sigId]; if (dirFn !== undefined) { const effId = _directEffFirst[sigId]; - if (effId >= 0) { - if (effId < 8192) { - if (seen[effId] !== gen) { - seen[effId] = gen; - if (effCount < 2048) { - effBuf[effCount++] = effId; - } - } - } else { - if (effCount < 2048) { - effBuf[effCount++] = effId; - } - } + if (effId >= 0 && seen[effId] !== gen) { + seen[effId] = gen; + effBuf[effCount++] = effId; } continue; } @@ -546,17 +541,9 @@ function _syncDirty(): void { const data = _subsData; for (let j = 0; j < len; j++) { const effId = data[ptr + j]; - if (effId < 8192) { - if (seen[effId] !== gen) { - seen[effId] = gen; - if (effCount < 2048) { - effBuf[effCount++] = effId; - } - } - } else { - if (effCount < 2048) { - effBuf[effCount++] = effId; - } + if (seen[effId] !== gen) { + seen[effId] = gen; + effBuf[effCount++] = effId; } } } From dd2fc0a4afee3772bf39b458d63588d5966a1989 Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:31:23 +0530 Subject: [PATCH 3/7] add change detection to signal array set values --- packages/core/src/signal.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index fa44e56..2f94dc0 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -1008,27 +1008,15 @@ export function signalArray(count: number, initialValue: number = 0): SignalArra setValues(values: Float32Array | Float64Array | number[]): void { const len = values.length < count ? values.length : count; - let changed = false; - - if (values instanceof Float64Array) { - _f64.set(values.subarray(0, len), baseId); - changed = true; - } else { - for (let i = 0; i < len; i++) { - const id = baseId + i; - const val = values[i]; - if (_f64[id] !== val) { - _f64[id] = val; - changed = true; - } - } - } - - if (!changed) return; _jsBatchDepth++; for (let i = 0; i < len; i++) { - _jsMarkDirty(baseId + i); + const id = baseId + i; + const val = values[i]; + if (_f64[id] !== val) { + _f64[id] = val; + _jsMarkDirty(id); + } } _jsBatchDepth--; From 535bb266b0715570355b99947b53b3c2ac886638 Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:32:39 +0530 Subject: [PATCH 4/7] rebind wasm views without resetting core --- packages/core/src/signal.ts | 10 +++++++-- packages/core/src/wasm-glue.ts | 38 +++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index 2f94dc0..fac6275 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -17,6 +17,7 @@ import { getCore, getU32View, getF64View, + onViewRefresh, } from './wasm-glue'; import { @@ -39,11 +40,16 @@ let _u32!: Uint32Array; let _f64: Float64Array; let _initialized = false; +function _rebindViews(): void { + _u32 = getU32View(); + _f64 = getF64View(); +} + function _ensureCore(): void { if (_initialized) return; _core = getCore(); - _u32 = getU32View(); - _f64 = getF64View(); + _rebindViews(); + onViewRefresh(_rebindViews); _initialized = true; } diff --git a/packages/core/src/wasm-glue.ts b/packages/core/src/wasm-glue.ts index 6c64335..7c3dbf6 100644 --- a/packages/core/src/wasm-glue.ts +++ b/packages/core/src/wasm-glue.ts @@ -79,10 +79,11 @@ const _encoder = new TextEncoder(); // Reusable buffer for string writing (grows if needed) let _strWriteBuf: Uint8Array = new Uint8Array(256); -function _setupViews(): void { +// View refresh listeners — modules that cache typed views re-bind after growth +let _viewListeners: (() => void)[] = []; + +function _bindViews(): void { if (!_core || !_memory) return; - // Must init() first to initialize the dynamic heap pointer - _core.init(); const buf = _memory.buffer; const offset = _core.heap_base(); _f64View = new Float64Array(buf, offset); @@ -91,6 +92,13 @@ function _setupViews(): void { _i32View = new Int32Array(buf, offset); } +// Must init() once before binding — initializes the dynamic heap pointer +function _initAndBindViews(): void { + if (!_core || !_memory) return; + _core.init(); + _bindViews(); +} + function _trySyncLoad(): boolean { try { const fs = require('node:fs') as typeof import('node:fs'); @@ -104,7 +112,7 @@ function _trySyncLoad(): boolean { _memory = new WebAssembly.Memory({ initial: 1024, maximum: 8192 }); const instance = new WebAssembly.Instance(wasmModule, { env: { memory: _memory } }); _core = instance.exports as unknown as CoreExports; - _setupViews(); + _initAndBindViews(); return true; } catch { return false; @@ -122,7 +130,7 @@ export function getCore(): CoreExports { if (!_memory) { _memory = (globalThis as any).__DOMINATOR_WASM_MEMORY__; } - _setupViews(); + _initAndBindViews(); return _core!; } @@ -177,7 +185,7 @@ export async function initCore(source?: WebAssembly.Module | string | URL): Prom const imports = { env: { memory: _memory } }; const instance = await WebAssembly.instantiate(wasmModule, imports); _core = instance.exports as unknown as CoreExports; - _setupViews(); + _initAndBindViews(); return _core!; } @@ -190,15 +198,29 @@ export function initCoreSync(instance: WebAssembly.Instance, memory?: WebAssembl if (!_memory) { _memory = (globalThis as any).__DOMINATOR_WASM_MEMORY__ ?? null; } - _setupViews(); + _initAndBindViews(); return _core!; } /** * Refresh typed views after memory growth. + * + * Re-binds the typed views to the (possibly grown) WASM memory buffer WITHOUT + * calling init(), so arena and effect state is preserved. */ export function refreshViews(): void { - if (_core && _memory) _setupViews(); + if (!_core || !_memory) return; + _bindViews(); + for (let i = 0; i < _viewListeners.length; i++) { + _viewListeners[i](); + } +} + +/** + * Register a callback invoked after views are re-bound following memory growth. + */ +export function onViewRefresh(fn: () => void): void { + _viewListeners.push(fn); } /** From 1d54ea7806b5a115879c6a0e9222b4116d59c2d6 Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:35:37 +0530 Subject: [PATCH 5/7] register view refresh listener once --- packages/core/src/signal.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index fac6275..24df4a2 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -39,6 +39,7 @@ let _core: ReturnType; let _u32!: Uint32Array; let _f64: Float64Array; let _initialized = false; +let _viewRefreshRegistered = false; function _rebindViews(): void { _u32 = getU32View(); @@ -49,7 +50,10 @@ function _ensureCore(): void { if (_initialized) return; _core = getCore(); _rebindViews(); - onViewRefresh(_rebindViews); + if (!_viewRefreshRegistered) { + onViewRefresh(_rebindViews); + _viewRefreshRegistered = true; + } _initialized = true; } From 9cec9208c828518ae0a703dbc109dec1e0c0c8d5 Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:37:23 +0530 Subject: [PATCH 6/7] make signal array getters reactive --- packages/core/src/signal.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index 24df4a2..2d6fe1b 100644 --- a/packages/core/src/signal.ts +++ b/packages/core/src/signal.ts @@ -959,7 +959,11 @@ export function signalArray(count: number, initialValue: number = 0): SignalArra baseId, get(i: number): number { - return _f64[baseId + i]; + const id = baseId + i; + if (_activeEffect >= 0) { + _trackSignal(id); + } + return _f64[id]; }, set(i: number, value: number): void { From b69674af719daf13bbb3d3abd95cdc34c918847c Mon Sep 17 00:00:00 2001 From: pavanscales Date: Sat, 1 Aug 2026 20:37:23 +0530 Subject: [PATCH 7/7] add hundred thousand signal scale tests --- packages/core/src/__tests__/scale.test.ts | 159 ++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/core/src/__tests__/scale.test.ts diff --git a/packages/core/src/__tests__/scale.test.ts b/packages/core/src/__tests__/scale.test.ts new file mode 100644 index 0000000..3733133 --- /dev/null +++ b/packages/core/src/__tests__/scale.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { signal, effect, batch, signalArray, _resetSignals } from '../signal'; +import { refreshViews } from '../wasm-glue'; + +beforeEach(() => { + _resetSignals(); +}); + +describe('scale', () => { + it('runs every effect when more than 8192 signals are dirty in one batch', () => { + const N = 20_000; + const sigs = Array.from({ length: N }, (_, i) => signal(0)); + const runs = new Int32Array(N); + const scopes = new Array(N); + + for (let i = 0; i < N; i++) { + const idx = i; + scopes[i] = effect(() => { + sigs[idx]!(); + runs[idx]!++; + }); + } + + batch(() => { + for (let i = 0; i < N; i++) { + sigs[i]!.set(i + 1); + } + }); + + for (let i = 0; i < N; i++) { + expect(runs[i]).toBe(2); + } + + for (let i = 0; i < N; i++) { + scopes[i]!.dispose(); + } + }); + + it('runs more than 2048 distinct effects from one batch', () => { + const N = 5_000; + const sigs = Array.from({ length: N }, (_, i) => signal(0)); + let runs = 0; + + for (let i = 0; i < N; i++) { + const idx = i; + effect(() => { + sigs[idx]!(); + runs++; + }); + } + + batch(() => { + for (let i = 0; i < N; i++) { + sigs[i]!.set(i + 1); + } + }); + + expect(runs).toBe(N * 2); + }); + + it('dedups effects above id 8192 subscribed to multiple signals', () => { + const N = 9_000; + const filler = Array.from({ length: N }, () => signal(0)); + void filler; + + const a = signal(0); + const b = signal(0); + let runCount = 0; + + effect(() => { + a(); + b(); + runCount++; + }); + + expect(runCount).toBe(1); + + batch(() => { + a.set(1); + b.set(2); + }); + + expect(runCount).toBe(2); + }); + + it('setValues does not notify when values are unchanged', () => { + const arr = signalArray(1_000, 7); + let runCount = 0; + effect(() => { + arr.get(0); + runCount++; + }); + + const same = new Float64Array(1_000); + same.fill(7); + + arr.setValues(same); + expect(runCount).toBe(1); + }); + + it('setValues notifies once when values change', () => { + const arr = signalArray(1_000, 0); + let runCount = 0; + effect(() => { + arr.get(0); + runCount++; + }); + + const next = new Float64Array(1_000); + next.fill(1); + + arr.setValues(next); + expect(runCount).toBe(2); + expect(arr.get(999)).toBe(1); + }); + + it('setValues with more than 8192 changed elements notifies every subscriber', () => { + const N = 50_000; + const arr = signalArray(N, 0); + let firstRuns = 0; + let lastRuns = 0; + + effect(() => { + arr.get(0); + firstRuns++; + }); + effect(() => { + arr.get(N - 1); + lastRuns++; + }); + + const next = new Float64Array(N); + next.fill(1); + + arr.setValues(next); + + expect(firstRuns).toBe(2); + expect(lastRuns).toBe(2); + expect(arr.get(25_000)).toBe(1); + }); + + it('refreshViews preserves arena state and subscriptions', () => { + const s = signal(42); + let value = 0; + effect(() => { + value = s(); + }); + + expect(value).toBe(42); + s.set(100); + expect(value).toBe(100); + + refreshViews(); + + expect(s()).toBe(100); + s.set(200); + expect(value).toBe(200); + }); +});