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); + }); +}); diff --git a/packages/core/src/signal.ts b/packages/core/src/signal.ts index b0a0790..2d6fe1b 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 { @@ -38,12 +39,21 @@ let _core: ReturnType; let _u32!: Uint32Array; let _f64: Float64Array; let _initialized = false; +let _viewRefreshRegistered = false; + +function _rebindViews(): void { + _u32 = getU32View(); + _f64 = getF64View(); +} function _ensureCore(): void { if (_initialized) return; _core = getCore(); - _u32 = getU32View(); - _f64 = getF64View(); + _rebindViews(); + if (!_viewRefreshRegistered) { + onViewRefresh(_rebindViews); + _viewRefreshRegistered = true; + } _initialized = true; } @@ -323,12 +333,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; } @@ -376,6 +400,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 { @@ -470,12 +503,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); } } @@ -492,10 +521,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; @@ -511,19 +538,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; } @@ -534,17 +551,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; } } } @@ -886,6 +895,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); @@ -949,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 { @@ -1008,27 +1022,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--; 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); } /**