diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ac1622ab..da7b0052b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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; @@ -134,6 +135,7 @@ function addDependency(signal: Signal): Node | undefined { _prevTarget: undefined, _nextTarget: undefined, _rollbackNode: node, + _fields: 0, }; evalContext._sources = node; signal._node = node; @@ -176,7 +178,21 @@ function addDependency(signal: Signal): Node | undefined { declare class Signal { /** @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; @@ -187,7 +203,7 @@ declare class Signal { /** @internal */ _targets?: Node; - constructor(value?: T); + constructor(value?: T, ...other: any[]); /** @internal */ _refresh(): boolean; @@ -211,8 +227,8 @@ declare class Signal { } /** @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; @@ -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(); @@ -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(value: T): Signal { return new Signal(value); } +interface Reader { + readonly _signals: Signal[]; + readonly _size: number; +} +function reader>(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 @@ -401,7 +478,7 @@ declare class Computed extends Signal { } function Computed(this: Computed, compute: () => unknown) { - Signal.call(this, undefined); + (Signal.call as any)(this, undefined); this._compute = compute; this._sources = undefined; @@ -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++; } @@ -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", { @@ -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]; }, }); @@ -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 }; diff --git a/packages/core/test/signal.test.tsx b/packages/core/test/signal.test.tsx index aeb8691a6..ba0e5ee4b 100644 --- a/packages/core/test/signal.test.tsx +++ b/packages/core/test/signal.test.tsx @@ -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", () => { @@ -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 = 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"); diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index a3398ceda..d783bc74c 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -6,6 +6,7 @@ import { batch, effect, Signal, + reader, type ReadonlySignal, } from "@preact/signals-core"; import { @@ -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; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eb048eb14..2d1c91d76 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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.