Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
159 changes: 159 additions & 0 deletions packages/core/src/__tests__/scale.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
112 changes: 57 additions & 55 deletions packages/core/src/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getCore,
getU32View,
getF64View,
onViewRefresh,
} from './wasm-glue';

import {
Expand All @@ -38,12 +39,21 @@ let _core: ReturnType<typeof getCore>;
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;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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--;

Expand Down
Loading
Loading