diff --git a/package.json b/package.json index 821304b0e..1b12c7992 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "esbuild": "^0.14.54", "eslint": "^8.21.0", "eslint-config-prettier": "^8.5.0", + "fast-deep-equal": "^3.1.3", "husky": "^8.0.1", "karma": "6.3.16", "karma-chai-sinon": "^0.1.5", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ac1622ab..265b1410e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -206,6 +206,9 @@ declare class Signal { peek(): T; + /** if true, triggers an update with the new incoming value */ + shouldUpdate(oldValue: T, newValue: T): boolean; + get value(): T; set value(value: T); } @@ -280,6 +283,10 @@ Signal.prototype.peek = function () { return this._value; }; +Signal.prototype.shouldUpdate = (oldValue, newValue) => { + return newValue !== oldValue; +}; + Object.defineProperty(Signal.prototype, "value", { get() { const node = addDependency(this); @@ -289,7 +296,9 @@ Object.defineProperty(Signal.prototype, "value", { return this._value; }, set(value) { - if (value !== this._value) { + const shouldUpdate = this.shouldUpdate(this._value, value); + + if (shouldUpdate) { if (batchIteration > 100) { cycleDetected(); } diff --git a/packages/core/test/signal.test.tsx b/packages/core/test/signal.test.tsx index aeb8691a6..107ae5a8c 100644 --- a/packages/core/test/signal.test.tsx +++ b/packages/core/test/signal.test.tsx @@ -1,4 +1,5 @@ import { signal, computed, effect, batch, Signal } from "@preact/signals-core"; +import fastEqual from "fast-deep-equal/es6"; describe("signal", () => { it("should return value", () => { @@ -1711,4 +1712,47 @@ describe("batch/transaction", () => { }); expect(callCount).to.equal(1); }); + + it("allows customizing the default shouldUpdate compare method", () => { + const mapA = new Map(); + const a = signal(mapA); + const spy1 = sinon.spy(() => a.value); + effect(spy1); + + // before: re-renders every time, even if the value doesn't change + a.value = new Map(a.peek()).set("foo", "bar"); + a.value = new Map(a.peek()).set("foo", "baz"); + a.value = new Map(a.peek()).set("foo", "baz"); + a.value = new Map(a.peek()).set("foo", "baz"); + + // should have been called twice but instead re-renders regardless on value pass in (without specifying a custom shouldUpdate method) + expect(spy1.callCount).to.equal(5); + + Signal.prototype.shouldUpdate = (oldValue, newValue) => { + if (oldValue instanceof Map && newValue instanceof Map) { + return fastEqual(oldValue, newValue) === false; + } + return oldValue !== newValue; + }; + + function signal2(value: T): Signal { + return new Signal(value); + } + + const mapB = new Map(); + const b = signal2(mapB); + const spy2 = sinon.spy(() => b.value); + effect(spy2); + + // after: only re-renders if the value changes (via custom shouldUpdate method) + b.value = new Map(b.peek()).set("foo", "bar"); + b.value = new Map(b.peek()).set("foo", "baz"); + b.value = new Map(b.peek()).set("foo", "baz"); + b.value = new Map(b.peek()).set("foo", "baz"); + + // setting up the initial empty Map --> update #1 + // adding foo, bar to empty map --> update #2 + // updating foo, bar to foo, baz --> update #3 + expect(spy2.callCount).to.equal(3); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb6a27947..9c56d815d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,7 @@ importers: esbuild: ^0.14.54 eslint: ^8.21.0 eslint-config-prettier: ^8.5.0 + fast-deep-equal: ^3.1.3 husky: ^8.0.1 karma: 6.3.16 karma-chai-sinon: ^0.1.5 @@ -75,6 +76,7 @@ importers: esbuild: 0.14.54 eslint: 8.21.0 eslint-config-prettier: 8.5.0_eslint@8.21.0 + fast-deep-equal: 3.1.3 husky: 8.0.1 karma: 6.3.16 karma-chai-sinon: 0.1.5_4327255nuu22k6utwpuk2rzg54