Skip to content
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

Implement effect cleanups #183

Merged
merged 10 commits into from
Sep 19, 2022
16 changes: 16 additions & 0 deletions .changeset/tidy-buses-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@preact/signals-core": minor
"@preact/signals": minor
"@preact/signals-react": minor
---

Add ability to run custom cleanup logic when an effect is disposed.

```js
effect(() => {
console.log("This runs whenever a dependency changes");
return () => {
console.log("This runs when the effect is disposed");
});
});
```
145 changes: 98 additions & 47 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@ function cycleDetected(): never {
throw new Error("Cycle detected");
}

// Flags for Computed and Effect.
const RUNNING = 1 << 0;
const STALE = 1 << 1;
const NOTIFIED = 1 << 2;
const HAS_ERROR = 1 << 3;
const SHOULD_SUBSCRIBE = 1 << 4;
const SUBSCRIBED = 1 << 5;
const DISPOSED = 1 << 6;
const NOTIFIED = 1 << 1;
const OUTDATED = 1 << 2;
const DISPOSED = 1 << 3;
const HAS_ERROR = 1 << 4;
const IS_EFFECT = 1 << 5;
const AUTO_DISPOSE = 1 << 6;
const AUTO_SUBSCRIBE = 1 << 7;

// Flags for Nodes.
const NODE_FREE = 1 << 0;
const NODE_SUBSCRIBED = 1 << 1;

// A linked list node used to track dependencies (sources) and dependents (targets).
// Also used to remember the source's last version number that the target saw.
type Node = {
// A node may have the following flags:
// SUBSCRIBED when the target has subscribed to listen change notifications from the source
// STALE when it's unclear whether the source is still a dependency of the target
// NODE_FREE when it's unclear whether the source is still a dependency of the target
// NODE_SUBSCRIBED when the target has subscribed to listen change notifications from the source
_flags: number;

// A source whose value the target depends on.
Expand Down Expand Up @@ -62,7 +68,7 @@ function endBatch() {
effect._nextBatchedEffect = undefined;
effect._flags &= ~NOTIFIED;

if (!(effect._flags & DISPOSED)) {
if (!(effect._flags & DISPOSED) && (effect._flags & OUTDATED)) {
try {
effect._callback();
} catch (err) {
Expand Down Expand Up @@ -132,14 +138,14 @@ function addDependency(signal: Signal): Node | undefined {

// Subscribe to change notifications from this dependency if we're in an effect
// OR evaluating a computed signal that in turn has subscribers.
if (evalContext._flags & SHOULD_SUBSCRIBE) {
if (evalContext._flags & AUTO_SUBSCRIBE) {
signal._subscribe(node);
}
return node;
} else if (node._flags & STALE) {
} else if (node._flags & NODE_FREE) {
// `signal` is an existing dependency from a previous evaluation. Reuse the dependency
// node and move it to the front of the evaluation context's dependency list.
node._flags &= ~STALE;
node._flags &= ~NODE_FREE;

const head = evalContext._sources;
if (node !== head) {
Expand Down Expand Up @@ -216,8 +222,8 @@ Signal.prototype._refresh = function() {
};

Signal.prototype._subscribe = function(node) {
if (!(node._flags & SUBSCRIBED)) {
node._flags |= SUBSCRIBED;
if (!(node._flags & NODE_SUBSCRIBED)) {
node._flags |= NODE_SUBSCRIBED;
node._nextTarget = this._targets;

if (this._targets !== undefined) {
Expand All @@ -228,8 +234,8 @@ Signal.prototype._subscribe = function(node) {
};

Signal.prototype._unsubscribe = function(node) {
if (node._flags & SUBSCRIBED) {
node._flags &= ~SUBSCRIBED;
if (node._flags & NODE_SUBSCRIBED) {
node._flags &= ~NODE_SUBSCRIBED;

const prev = node._prevTarget;
const next = node._nextTarget;
Expand Down Expand Up @@ -312,7 +318,7 @@ function prepareSources(target: Computed | Effect) {
node._rollbackNode = rollbackNode;
}
node._source._node = node;
node._flags |= STALE;
node._flags |= NODE_FREE;
}
}

Expand All @@ -327,7 +333,7 @@ function cleanupSources(target: Computed | Effect) {
let sources = undefined;
while (node !== undefined) {
const next = node._nextSource;
if (node._flags & STALE) {
if (node._flags & NODE_FREE) {
node._source._unsubscribe(node);
node._nextSource = undefined;
} else {
Expand All @@ -348,14 +354,51 @@ function cleanupSources(target: Computed | Effect) {
target._sources = sources;
}

function disposeNestedEffects(context: Computed | Effect) {
let effect = context._effects;
if (effect !== undefined) {
do {
effect._dispose();
effect = effect._nextNestedEffect;
} while (effect !== undefined);
function cleanupContext(context: Computed | Effect) {
let hasError = false;
let error: unknown;

let nested = context._effects;
if (nested !== undefined) {
context._effects = undefined;

while (nested !== undefined) {
try {
nested._dispose();
} catch (err) {
hasError = true;
error = err;
}
nested = nested._nextNestedEffect;
}
}

if (context._flags & IS_EFFECT) {
const cleanup = (context as Effect)._cleanup;
(context as Effect)._cleanup = undefined;

if (typeof cleanup === "function") {
/*@__INLINE__**/ startBatch();

// Run cleanup functions always outside of any context.
const prevContext = evalContext;
evalContext = undefined;

try {
cleanup();
} catch (err) {
hasError = true;
error = err;
context._flags &= ~RUNNING;
}

evalContext = prevContext;
endBatch();
}
}

if (hasError) {
throw error;
}
}

Expand Down Expand Up @@ -391,7 +434,7 @@ function Computed(this: Computed, compute: () => unknown) {
this._sources = undefined;
this._effects = undefined;
this._globalVersion = globalVersion - 1;
this._flags = STALE;
this._flags = OUTDATED;
}

Computed.prototype = new Signal() as Computed;
Expand All @@ -403,10 +446,12 @@ Computed.prototype._refresh = function() {
return false;
}

if (!(this._flags & STALE) && this._targets !== undefined) {
// Trust the OUTDATED flag only when the computed signal has subscribed
// to any notifications from dependencies.
if (this._targets !== undefined && !(this._flags & OUTDATED)) {
return true;
}
this._flags &= ~STALE;
this._flags &= ~OUTDATED;

if (this._globalVersion === globalVersion) {
return true;
Expand Down Expand Up @@ -436,7 +481,7 @@ Computed.prototype._refresh = function() {
}

prepareSources(this);
disposeNestedEffects(this);
cleanupContext(this);

evalContext = this;
const value = this._compute();
Expand All @@ -463,7 +508,7 @@ Computed.prototype._refresh = function() {

Computed.prototype._subscribe = function(node) {
if (this._targets === undefined) {
this._flags |= STALE | SHOULD_SUBSCRIBE;
this._flags |= OUTDATED | AUTO_SUBSCRIBE;

// A computed signal subscribes lazily to its dependencies when the it
// gets its first subscriber.
Expand All @@ -483,7 +528,7 @@ Computed.prototype._unsubscribe = function(node) {

// Computed signal unsubscribes from its dependencies from it loses its last subscriber.
if (this._targets === undefined) {
this._flags &= ~SHOULD_SUBSCRIBE;
this._flags &= ~AUTO_SUBSCRIBE;

for (
let node = this._sources;
Expand All @@ -497,7 +542,7 @@ Computed.prototype._unsubscribe = function(node) {

Computed.prototype._notify = function() {
if (!(this._flags & NOTIFIED)) {
this._flags |= STALE | NOTIFIED;
this._flags |= OUTDATED | NOTIFIED;

for (
let node = this._targets;
Expand Down Expand Up @@ -548,9 +593,9 @@ function disposeEffect(effect: Effect) {
) {
node._source._unsubscribe(node);
}
disposeNestedEffects(effect);
effect._sources = undefined;
effect._flags |= DISPOSED;

cleanupContext(effect);
}

function endEffect(this: Effect, prevContext?: Computed | Effect) {
Expand All @@ -559,39 +604,41 @@ function endEffect(this: Effect, prevContext?: Computed | Effect) {
}
cleanupSources(this);
evalContext = prevContext;
endBatch();

this._flags &= ~RUNNING;
if (this._flags & DISPOSED) {
disposeEffect(this);
}
endBatch();
}

declare class Effect {
_compute: () => void;
_compute: () => unknown;
_cleanup?: unknown;
_sources?: Node;
_effects?: Effect;
_nextNestedEffect?: Effect;
_nextBatchedEffect?: Effect;
_flags: number;

constructor(compute: () => void);
constructor(compute: () => void, flags: number);

_callback(): void;
_start(): () => void;
_notify(): void;
_dispose(): void;
}

function Effect(this: Effect, compute: () => void) {
function Effect(this: Effect, compute: () => void, flags: number) {
this._compute = compute;
this._cleanup = undefined;
this._sources = undefined;
this._effects = undefined;
this._nextNestedEffect = undefined;
this._nextBatchedEffect = undefined;
this._flags = SHOULD_SUBSCRIBE;
this._flags = IS_EFFECT | OUTDATED | flags;

if (evalContext !== undefined) {
if ((flags & AUTO_DISPOSE) && evalContext !== undefined) {
this._nextNestedEffect = evalContext._effects;
evalContext._effects = this;
}
Expand All @@ -600,7 +647,9 @@ function Effect(this: Effect, compute: () => void) {
Effect.prototype._callback = function() {
const finish = this._start();
try {
this._compute();
if (!(this._flags & DISPOSED)) {
this._cleanup = this._compute();
}
} finally {
finish();
}
Expand All @@ -612,32 +661,34 @@ Effect.prototype._start = function() {
}
this._flags |= RUNNING;
this._flags &= ~DISPOSED;
disposeNestedEffects(this);
prepareSources(this);
cleanupContext(this);

/*@__INLINE__**/ startBatch();
this._flags &= ~OUTDATED;
const prevContext = evalContext;
evalContext = this;

prepareSources(this);
return endEffect.bind(this, prevContext);
};

Effect.prototype._notify = function() {
if (!(this._flags & NOTIFIED)) {
this._flags |= NOTIFIED;
this._flags |= NOTIFIED | OUTDATED;
this._nextBatchedEffect = batchedEffect;
batchedEffect = this;
}
};

Effect.prototype._dispose = function() {
this._flags |= DISPOSED;

if (!(this._flags & RUNNING)) {
disposeEffect(this);
}
};

function effect(compute: () => void): () => void {
const effect = new Effect(compute);
function effect(compute: () => unknown): () => void {
const effect = new Effect(compute, AUTO_DISPOSE | AUTO_SUBSCRIBE);
effect._callback();
// Return a bound function instead of a wrapper like `() => effect._dispose()`,
// because bound functions seem to be just as fast and take up a lot less memory.
Expand Down
Loading