Skip to content

Proposal: Multi-value Signal using bitmaps (bit arrays) #217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
139 changes: 108 additions & 31 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Node = {
_source: Signal;
_prevSource?: Node;
_nextSource?: Node;
_fields: number;

// A target that depends on the source and should be notified when the source changes.
_target: Computed | Effect;
Expand Down Expand Up @@ -134,6 +135,7 @@ function addDependency(signal: Signal): Node | undefined {
_prevTarget: undefined,
_nextTarget: undefined,
_rollbackNode: node,
_fields: 0,
};
evalContext._sources = node;
signal._node = node;
Expand Down Expand Up @@ -176,7 +178,21 @@ function addDependency(signal: Signal): Node | undefined {

declare class Signal<T = any> {
/** @internal */
_value: unknown;
_value: unknown[];
0: unknown;
1: unknown;
2: unknown;
3: unknown;
4: unknown;
5: unknown;
6: unknown;
7: unknown;
8: unknown;
9: unknown;
10: unknown;
11: unknown;
// ... up to 👇
31: unknown;

/** @internal */
_version: number;
Expand All @@ -187,7 +203,7 @@ declare class Signal<T = any> {
/** @internal */
_targets?: Node;

constructor(value?: T);
constructor(value?: T, ...other: any[]);

/** @internal */
_refresh(): boolean;
Expand All @@ -211,8 +227,8 @@ declare class Signal<T = any> {
}

/** @internal */
function Signal(this: Signal, value?: unknown) {
this._value = value;
function Signal(this: Signal) {
this._value = Array.from(arguments);
this._version = 0;
this._node = undefined;
this._targets = undefined;
Expand Down Expand Up @@ -269,33 +285,39 @@ Signal.prototype.subscribe = function (fn) {
};

Signal.prototype.valueOf = function () {
return this.value;
return this._value[0];
};

Signal.prototype.toString = function () {
return this.value + "";
return this[0] + "";
};

Signal.prototype.peek = function () {
return this._value;
return this._value[0];
};

Object.defineProperty(Signal.prototype, "value", {
get() {
const node = addDependency(this);
if (node !== undefined) {
node._version = this._version;
}
return this._value;
},
set(value) {
if (value !== this._value) {
for (let i = 0; i < 32; i++) {
Object.defineProperty(Signal.prototype, i, {
get(this: Signal) {
const node = addDependency(this);
if (node !== undefined) {
node._version = this._version;
node._fields |= 1 << i;
}
return this._value[i];
},
set(this: Signal, value) {
if (i > this._value.length)
throw RangeError("Some error here - disallow creating holey arrays");
if (value === this._value[i]) return;

if (batchIteration > 100) {
cycleDetected();
}

this._value = value;
this._value[i] = value;
this._version++;

globalVersion++;

/**@__INLINE__*/ startBatch();
Expand All @@ -305,19 +327,74 @@ Object.defineProperty(Signal.prototype, "value", {
node !== undefined;
node = node._nextTarget
) {
node._target._notify();
if (node._fields & (1 << i)) node._target._notify();
}
} finally {
endBatch();
}
}
},
});
},
});
}

Object.defineProperty(
Signal.prototype,
"value",
Object.getOwnPropertyDescriptor(Signal.prototype, 0)!
);

function signal<T>(value: T): Signal<T> {
return new Signal(value);
}

interface Reader {
readonly _signals: Signal[];
readonly _size: number;
}
function reader<T extends Record<string, any>>(obj: T): T & Reader {
const properties = Object.keys(obj);

let currentSignal = new Signal();
const signals = [currentSignal];
const reader = Object.create(null);

for (let i = 0; i < properties.length; i++) {
const currentIndex = i % 31;
const currentProp = properties[i];
const currentValue = obj[currentProp];

if (currentIndex === 0 && i !== 0) {
currentSignal = new Signal();
signals.push(currentSignal);
}

currentSignal._value[currentIndex] = currentValue;

Object.defineProperty(reader, currentProp, {
enumerable: true,
configurable: false,
get() {
return currentSignal[currentIndex as keyof Signal];
},
set(v) {
currentSignal[currentIndex as keyof Signal] = v;
},
});
}

Object.defineProperty(reader, "_signals", {
enumerable: false,
writable: false,
value: Object.freeze(signals),
});
Object.defineProperty(reader, "_size", {
enumerable: false,
writable: false,
value: properties.length,
});

return reader;
}

function needsToRecompute(target: Computed | Effect): boolean {
// Check the dependencies for changed values. The dependency list is already
// in order of use. Therefore if multiple dependencies have changed values, only
Expand Down Expand Up @@ -401,7 +478,7 @@ declare class Computed<T = any> extends Signal<T> {
}

function Computed(this: Computed, compute: () => unknown) {
Signal.call(this, undefined);
(Signal.call as any)(this, undefined);

this._compute = compute;
this._sources = undefined;
Expand Down Expand Up @@ -446,15 +523,15 @@ Computed.prototype._refresh = function () {
const value = this._compute();
if (
this._flags & HAS_ERROR ||
this._value !== value ||
this._value[0] !== value ||
this._version === 0
) {
this._value = value;
this._value[0] = value;
this._flags &= ~HAS_ERROR;
this._version++;
}
} catch (err) {
this._value = err;
this._value[0] = err;
this._flags |= HAS_ERROR;
this._version++;
}
Expand Down Expand Up @@ -517,9 +594,9 @@ Computed.prototype.peek = function () {
cycleDetected();
}
if (this._flags & HAS_ERROR) {
throw this._value;
throw this._value[0];
}
return this._value;
return this._value[0];
};

Object.defineProperty(Computed.prototype, "value", {
Expand All @@ -533,9 +610,9 @@ Object.defineProperty(Computed.prototype, "value", {
node._version = this._version;
}
if (this._flags & HAS_ERROR) {
throw this._value;
throw this._value[0];
}
return this._value;
return this._value[0];
},
});

Expand Down Expand Up @@ -673,4 +750,4 @@ function effect(compute: () => unknown): () => void {
return effect._dispose.bind(effect);
}

export { signal, computed, effect, batch, Signal, ReadonlySignal };
export { signal, computed, effect, batch, Signal, ReadonlySignal, reader };
101 changes: 100 additions & 1 deletion packages/core/test/signal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { signal, computed, effect, batch, Signal } from "@preact/signals-core";
import {
signal,
computed,
effect,
batch,
Signal,
reader,
} from "@preact/signals-core";

describe("signal", () => {
it("should return value", () => {
Expand Down Expand Up @@ -753,6 +760,98 @@ describe("effect()", () => {
});
});

describe("Some new API for Signal that can track multiple fields in a single instance", () => {
it("should return value", () => {
const g = reader({ foo: "a", bar: "b" });

expect(g).to.have.property("foo", "a");
expect(g).to.have.property("bar", "b");
expect(g).to.have.property("_size", 2);
expect(g._signals).to.have.lengthOf(1, "should create 1 single signal");
});
it("should create 1 signal per 31 properties", () => {
const obj: Record<number, number> = Object.create(null);
const expectedSignalCount = 3;

/**
* Create a big object with 93 properties
*/
for (let i = 0; i < 31 * expectedSignalCount; i++) {
obj[i] = i;
}

const g = reader(obj);

/**
* 3 signals are created to watch 93 properties
* - signal 0 -> 31 properties
* - signal 1 -> 31 properties
* - signal 2 -> 31 properties
*/
expect(g._signals).to.have.lengthOf(expectedSignalCount);
expect(g._size).to.equal(93);
});
it("should be computed only if the field is watched", () => {
const g = reader({
foo: 1,
bar: 2,
baz: 3,
});

/**
* Although it's 1 signal, the `computed` should only be called
* when the watched properties of a signal are accessed
*/
expect(g._signals).to.have.lengthOf(
1,
"One signal should watch all these 3 properties"
);

const fooSpy = sinon.spy(() => g.foo);
const barSpy = sinon.spy(() => g.bar);
const bazSpy = sinon.spy(() => g.baz);

/**
* All these `computed` subscribe to the same signal but watch different fields
* - foo -> ._field = 1 << 0
* - bar -> ._field = 1 << 1
* - baz -> ._field = 1 << 2
*/
const foo = computed(fooSpy);
const bar = computed(barSpy);
const baz = computed(bazSpy);

expect(fooSpy).to.not.be.called;
expect(barSpy).to.not.be.called;
expect(bazSpy).to.not.be.called;

foo.value;

expect(fooSpy).to.have.been.calledOnce;
expect(barSpy).to.not.be.called;
expect(bazSpy).to.not.be.called;

bar.value;

expect(fooSpy).to.have.been.calledOnce;
expect(barSpy).to.have.been.calledOnce;
expect(bazSpy).to.not.be.called;

baz.value;

expect(fooSpy).to.have.been.calledOnce;
expect(barSpy).to.have.been.calledOnce;
expect(bazSpy).to.have.been.calledOnce;

g.bar = 123;
bar.value;

expect(fooSpy).to.have.been.calledOnce;
expect(barSpy).to.have.been.calledTwice;
expect(bazSpy).to.have.been.calledOnce;
});
});

describe("computed()", () => {
it("should return value", () => {
const a = signal("a");
Expand Down
3 changes: 2 additions & 1 deletion packages/preact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
batch,
effect,
Signal,
reader,
type ReadonlySignal,
} from "@preact/signals-core";
import {
Expand All @@ -18,7 +19,7 @@ import {
AugmentedElement as Element,
} from "./internal";

export { signal, computed, batch, effect, Signal, type ReadonlySignal };
export { signal, computed, batch, effect, reader, Signal, type ReadonlySignal };

const HAS_PENDING_UPDATE = 1 << 0;
const HAS_HOOK_STATE = 1 << 1;
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import {
batch,
effect,
Signal,
reader,
type ReadonlySignal,
} from "@preact/signals-core";
import { Effect, ReactOwner, ReactDispatcher } from "./internal";

export { signal, computed, batch, effect, Signal, type ReadonlySignal };
export { signal, computed, batch, effect, Signal, reader, type ReadonlySignal };

/**
* Install a middleware into React.createElement to replace any Signals in props with their value.
Expand Down