Skip to content

Commit 62bed44

Browse files
authored
Add watched and unwatched (#634)
* Add watched and unwatched * Add changeset * Add in integrations * Add in computed * Add tests * Add to mangle * Refactors * Wrap in untracked and add readme entry * Add extra test
1 parent fae3d1e commit 62bed44

File tree

7 files changed

+170
-39
lines changed

7 files changed

+170
-39
lines changed

.changeset/tough-rice-cover.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@preact/signals-core": minor
3+
"@preact/signals": minor
4+
"@preact/signals-react": minor
5+
---
6+
7+
Add an option to specify a watched/unwatched callback to a signal

README.md

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,16 @@ npm install @preact/signals-react
2020
npm install @preact/signals-core
2121
```
2222

23-
- [Guide / API](#guide--api)
24-
- [`signal(initialValue)`](#signalinitialvalue)
25-
- [`signal.peek()`](#signalpeek)
26-
- [`computed(fn)`](#computedfn)
27-
- [`effect(fn)`](#effectfn)
28-
- [`batch(fn)`](#batchfn)
29-
- [`untracked(fn)`](#untrackedfn)
30-
- [Preact Integration](./packages/preact/README.md#preact-integration)
31-
- [Hooks](./packages/preact/README.md#hooks)
32-
- [Rendering optimizations](./packages/preact/README.md#rendering-optimizations)
33-
- [Attribute optimization (experimental)](./packages/preact/README.md#attribute-optimization-experimental)
34-
- [React Integration](./packages/react/README.md#react-integration)
35-
- [Hooks](./packages/react/README.md#hooks)
36-
- [Rendering optimizations](./packages/react/README.md#rendering-optimizations)
37-
- [License](#license)
23+
- [Signals](#signals)
24+
- [Installation:](#installation)
25+
- [Guide / API](#guide--api)
26+
- [`signal(initialValue)`](#signalinitialvalue)
27+
- [`signal.peek()`](#signalpeek)
28+
- [`computed(fn)`](#computedfn)
29+
- [`effect(fn)`](#effectfn)
30+
- [`batch(fn)`](#batchfn)
31+
- [`untracked(fn)`](#untrackedfn)
32+
- [License](#license)
3833

3934
## Guide / API
4035

@@ -58,6 +53,21 @@ counter.value = 1;
5853

5954
Writing to a signal is done by setting its `.value` property. Changing a signal's value synchronously updates every [computed](#computedfn) and [effect](#effectfn) that depends on that signal, ensuring your app state is always consistent.
6055

56+
You can also pass options to `signal()` and `computed()` to be notified when the signal gains its first subscriber and loses its last subscriber:
57+
58+
```js
59+
const counter = signal(0, {
60+
watched: function () {
61+
console.log("Signal has its first subscriber");
62+
},
63+
unwatched: function () {
64+
console.log("Signal lost its last subscriber");
65+
},
66+
});
67+
```
68+
69+
These callbacks are useful for managing resources or side effects that should only be active when the signal has subscribers. For example, you might use them to start/stop expensive background operations or subscribe/unsubscribe from external event sources.
70+
6171
#### `signal.peek()`
6272

6373
In the rare instance that you have an effect that should write to another signal based on the previous value, but you _don't_ want the effect to be subscribed to that signal, you can read a signals's previous value via `signal.peek()`.

mangle.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"cname": 6,
3030
"props": {
3131
"core: Node": "",
32+
"$_watched": "W",
33+
"$_unwatched": "Z",
3234
"$_source": "S",
3335
"$_prevSource": "p",
3436
"$_nextSource": "n",

packages/core/src/index.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ declare class Signal<T = any> {
239239
/** @internal */
240240
_targets?: Node;
241241

242-
constructor(value?: T);
242+
constructor(value?: T, options?: SignalOptions<T>);
243243

244244
/** @internal */
245245
_refresh(): boolean;
@@ -250,6 +250,12 @@ declare class Signal<T = any> {
250250
/** @internal */
251251
_unsubscribe(node: Node): void;
252252

253+
/** @internal */
254+
_watched?(this: Signal<T>): void;
255+
256+
/** @internal */
257+
_unwatched?(this: Signal<T>): void;
258+
253259
subscribe(fn: (value: T) => void): () => void;
254260

255261
valueOf(): T;
@@ -266,6 +272,11 @@ declare class Signal<T = any> {
266272
set value(value: T);
267273
}
268274

275+
export interface SignalOptions<T = any> {
276+
watched: (this: Signal<T>) => void;
277+
unwatched: (this: Signal<T>) => void;
278+
}
279+
269280
/** @internal */
270281
// @ts-ignore: "Cannot redeclare exported variable 'Signal'."
271282
//
@@ -274,11 +285,13 @@ declare class Signal<T = any> {
274285
//
275286
// The previously declared class is implemented here with ES5-style prototypes.
276287
// This enables better control of the transpiled output size.
277-
function Signal(this: Signal, value?: unknown) {
288+
function Signal(this: Signal, value?: unknown, options?: SignalOptions) {
278289
this._value = value;
279290
this._version = 0;
280291
this._node = undefined;
281292
this._targets = undefined;
293+
this._watched = options?.watched;
294+
this._unwatched = options?.unwatched;
282295
}
283296

284297
Signal.prototype.brand = BRAND_SYMBOL;
@@ -288,12 +301,18 @@ Signal.prototype._refresh = function () {
288301
};
289302

290303
Signal.prototype._subscribe = function (node) {
291-
if (this._targets !== node && node._prevTarget === undefined) {
292-
node._nextTarget = this._targets;
293-
if (this._targets !== undefined) {
294-
this._targets._prevTarget = node;
295-
}
304+
const targets = this._targets;
305+
if (targets !== node && node._prevTarget === undefined) {
306+
node._nextTarget = targets;
296307
this._targets = node;
308+
309+
if (targets !== undefined) {
310+
targets._prevTarget = node;
311+
} else {
312+
untracked(() => {
313+
this._watched?.call(this);
314+
});
315+
}
297316
}
298317
};
299318

@@ -306,12 +325,19 @@ Signal.prototype._unsubscribe = function (node) {
306325
prev._nextTarget = next;
307326
node._prevTarget = undefined;
308327
}
328+
309329
if (next !== undefined) {
310330
next._prevTarget = prev;
311331
node._nextTarget = undefined;
312332
}
333+
313334
if (node === this._targets) {
314335
this._targets = next;
336+
if (next === undefined) {
337+
untracked(() => {
338+
this._unwatched?.call(this);
339+
});
340+
}
315341
}
316342
}
317343
};
@@ -392,10 +418,10 @@ Object.defineProperty(Signal.prototype, "value", {
392418
* @param value The initial value for the signal.
393419
* @returns A new signal.
394420
*/
395-
export function signal<T>(value: T): Signal<T>;
421+
export function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
396422
export function signal<T = undefined>(): Signal<T | undefined>;
397-
export function signal<T>(value?: T): Signal<T> {
398-
return new Signal(value);
423+
export function signal<T>(value?: T, options?: SignalOptions<T>): Signal<T> {
424+
return new Signal(value, options);
399425
}
400426

401427
function needsToRecompute(target: Computed | Effect): boolean {
@@ -515,19 +541,21 @@ declare class Computed<T = any> extends Signal<T> {
515541
_globalVersion: number;
516542
_flags: number;
517543

518-
constructor(fn: () => T);
544+
constructor(fn: () => T, options?: SignalOptions<T>);
519545

520546
_notify(): void;
521547
get value(): T;
522548
}
523549

524-
function Computed(this: Computed, fn: () => unknown) {
550+
function Computed(this: Computed, fn: () => unknown, options?: SignalOptions) {
525551
Signal.call(this, undefined);
526552

527553
this._fn = fn;
528554
this._sources = undefined;
529555
this._globalVersion = globalVersion - 1;
530556
this._flags = OUTDATED;
557+
this._watched = options?.watched;
558+
this._unwatched = options?.unwatched;
531559
}
532560

533561
Computed.prototype = new Signal() as Computed;
@@ -677,8 +705,11 @@ interface ReadonlySignal<T = any> {
677705
* @param fn The effect callback.
678706
* @returns A new read-only signal.
679707
*/
680-
function computed<T>(fn: () => T): ReadonlySignal<T> {
681-
return new Computed(fn);
708+
function computed<T>(
709+
fn: () => T,
710+
options?: SignalOptions<T>
711+
): ReadonlySignal<T> {
712+
return new Computed(fn, options);
682713
}
683714

684715
function cleanupEffect(effect: Effect) {

packages/core/test/signal.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,45 @@ describe("signal", () => {
182182
});
183183
});
184184

185+
describe(".(un)watched()", () => {
186+
it("should call watched when first subscription occurs", () => {
187+
const watched = sinon.spy();
188+
const unwatched = sinon.spy();
189+
const s = signal(1, { watched, unwatched });
190+
expect(watched).to.not.be.called;
191+
const unsubscribe = s.subscribe(() => {});
192+
expect(watched).to.be.calledOnce;
193+
const unsubscribe2 = s.subscribe(() => {});
194+
expect(watched).to.be.calledOnce;
195+
unsubscribe();
196+
unsubscribe2();
197+
expect(unwatched).to.be.calledOnce;
198+
});
199+
200+
it("should allow updating the signal from watched", async () => {
201+
const calls: number[] = [];
202+
const watched = sinon.spy(() => {
203+
setTimeout(() => {
204+
s.value = 2;
205+
});
206+
});
207+
const unwatched = sinon.spy();
208+
const s = signal(1, { watched, unwatched });
209+
expect(watched).to.not.be.called;
210+
const unsubscribe = s.subscribe(() => {
211+
calls.push(s.value);
212+
});
213+
expect(watched).to.be.calledOnce;
214+
const unsubscribe2 = s.subscribe(() => {});
215+
expect(watched).to.be.calledOnce;
216+
await new Promise(resolve => setTimeout(resolve));
217+
unsubscribe();
218+
unsubscribe2();
219+
expect(unwatched).to.be.calledOnce;
220+
expect(calls).to.deep.equal([1, 2]);
221+
});
222+
});
223+
185224
it("signals should be identified with a symbol", () => {
186225
const a = signal(0);
187226
expect(a.brand).to.equal(Symbol.for("preact-signals"));
@@ -1064,6 +1103,37 @@ describe("computed()", () => {
10641103
expect(spy).not.to.be.called;
10651104
});
10661105

1106+
describe(".(un)watched()", () => {
1107+
it("should call watched when first subscription occurs", () => {
1108+
const watched = sinon.spy();
1109+
const unwatched = sinon.spy();
1110+
const s = computed(() => 1, { watched, unwatched });
1111+
expect(watched).to.not.be.called;
1112+
const unsubscribe = s.subscribe(() => {});
1113+
expect(watched).to.be.calledOnce;
1114+
const unsubscribe2 = s.subscribe(() => {});
1115+
expect(watched).to.be.calledOnce;
1116+
unsubscribe();
1117+
unsubscribe2();
1118+
expect(unwatched).to.be.calledOnce;
1119+
});
1120+
1121+
it("should call watched when first subscription occurs w/ nested signal", () => {
1122+
const watched = sinon.spy();
1123+
const unwatched = sinon.spy();
1124+
const s = signal(1, { watched, unwatched });
1125+
const c = computed(() => s.value + 1, { watched, unwatched });
1126+
expect(watched).to.not.be.called;
1127+
const unsubscribe = c.subscribe(() => {});
1128+
expect(watched).to.be.calledTwice;
1129+
const unsubscribe2 = s.subscribe(() => {});
1130+
expect(watched).to.be.calledTwice;
1131+
unsubscribe2();
1132+
unsubscribe();
1133+
expect(unwatched).to.be.calledTwice;
1134+
});
1135+
});
1136+
10671137
it("should consider undefined value separate from uninitialized value", () => {
10681138
const a = signal(0);
10691139
const spy = sinon.spy(() => undefined);

packages/preact/src/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Signal,
99
type ReadonlySignal,
1010
untracked,
11+
SignalOptions,
1112
} from "@preact/signals-core";
1213
import {
1314
VNode,
@@ -382,17 +383,20 @@ Component.prototype.shouldComponentUpdate = function (
382383
return false;
383384
};
384385

385-
export function useSignal<T>(value: T): Signal<T>;
386+
export function useSignal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
386387
export function useSignal<T = undefined>(): Signal<T | undefined>;
387-
export function useSignal<T>(value?: T) {
388-
return useMemo(() => signal<T | undefined>(value), []);
388+
export function useSignal<T>(value?: T, options?: SignalOptions<T>) {
389+
return useMemo(
390+
() => signal<T | undefined>(value, options as SignalOptions),
391+
[]
392+
);
389393
}
390394

391-
export function useComputed<T>(compute: () => T) {
395+
export function useComputed<T>(compute: () => T, options?: SignalOptions<T>) {
392396
const $compute = useRef(compute);
393397
$compute.current = compute;
394398
(currentComponent as AugmentedComponent)._updateFlags |= HAS_COMPUTEDS;
395-
return useMemo(() => computed<T>(() => $compute.current()), []);
399+
return useMemo(() => computed<T>(() => $compute.current(), options), []);
396400
}
397401

398402
function safeRaf(callback: () => void) {

packages/react/runtime/src/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
effect,
55
Signal,
66
ReadonlySignal,
7+
SignalOptions,
78
} from "@preact/signals-core";
89
import {
910
useRef,
@@ -384,16 +385,22 @@ export function useSignals(usage?: EffectStoreUsage): EffectStore {
384385
return _useSignalsImplementation(usage);
385386
}
386387

387-
export function useSignal<T>(value: T): Signal<T>;
388+
export function useSignal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
388389
export function useSignal<T = undefined>(): Signal<T | undefined>;
389-
export function useSignal<T>(value?: T) {
390-
return useMemo(() => signal<T | undefined>(value), Empty);
390+
export function useSignal<T>(value?: T, options?: SignalOptions<T>) {
391+
return useMemo(
392+
() => signal<T | undefined>(value, options as SignalOptions),
393+
Empty
394+
);
391395
}
392396

393-
export function useComputed<T>(compute: () => T): ReadonlySignal<T> {
397+
export function useComputed<T>(
398+
compute: () => T,
399+
options?: SignalOptions<T>
400+
): ReadonlySignal<T> {
394401
const $compute = useRef(compute);
395402
$compute.current = compute;
396-
return useMemo(() => computed<T>(() => $compute.current()), Empty);
403+
return useMemo(() => computed<T>(() => $compute.current(), options), Empty);
397404
}
398405

399406
export function useSignalEffect(cb: () => void | (() => void)) {

0 commit comments

Comments
 (0)