Skip to content

Commit

Permalink
Merge pull request #183 from preactjs/effect-cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister authored Sep 19, 2022
2 parents ce8b370 + 79ff1e7 commit e865a63
Show file tree
Hide file tree
Showing 5 changed files with 489 additions and 67 deletions.
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

0 comments on commit e865a63

Please sign in to comment.