Skip to content

Commit

Permalink
feat: signal effects (#1043)
Browse files Browse the repository at this point in the history
Closes: #945
  • Loading branch information
aarthificial committed May 16, 2024
1 parent 8944959 commit 00fa967
Show file tree
Hide file tree
Showing 18 changed files with 415 additions and 8 deletions.
26 changes: 26 additions & 0 deletions packages/core/src/signals/DeferredEffectContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {useThread} from '../utils';
import {DependencyContext} from './DependencyContext';

export class DeferredEffectContext extends DependencyContext {
private readonly unsubscribe;
public constructor(private readonly callback: () => void) {
super();
this.unsubscribe = useThread().onDeferred.subscribe(this.update);
this.markDirty();
}

private update = () => {
if (this.event.isRaised()) {
this.clearDependencies();
this.startCollecting();
this.callback();
this.finishCollecting();
this.event.reset();
}
};

public dispose() {
super.dispose();
this.unsubscribe();
}
}
17 changes: 17 additions & 0 deletions packages/core/src/signals/EffectContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {DependencyContext} from './DependencyContext';

export class EffectContext extends DependencyContext {
public constructor(private readonly callback: () => void) {
super();
this.event.subscribe(this.update);
this.markDirty();
}

private update = () => {
this.clearDependencies();
this.startCollecting();
this.callback();
this.finishCollecting();
this.event.reset();
};
}
2 changes: 1 addition & 1 deletion packages/core/src/signals/SignalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ export class SignalContext<
}

this.current = value;
this.markDirty();
this.clearDependencies();

if (!isReactive(value)) {
this.last = this.parse(value);
}

this.markDirty();
return this.owner;
}

Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/signals/createDeferredEffect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {afterAll, beforeAll, describe, expect, test, vi} from 'vitest';
import {PlaybackManager, PlaybackStatus} from '../app';
import {run} from '../flow';
import {threads} from '../threading';
import {endPlayback, startPlayback} from '../utils';
import {createDeferredEffect} from './createDeferredEffect';
import {createSignal} from './createSignal';

describe('createDeferredEffect()', () => {
const status = new PlaybackStatus(new PlaybackManager());
beforeAll(() => startPlayback(status));
afterAll(() => endPlayback(status));

test('Deferred till the end of the frame', () => {
const task = threads(function* () {
const a = createSignal(1);
const b = createSignal(2);
const callback = vi.fn(() => {
a();
b();
});
const unsub = createDeferredEffect(callback);

expect(callback).toBeCalledTimes(0);
yield;
expect(callback).toBeCalledTimes(1);
yield;
expect(callback).toBeCalledTimes(1);
a(2);
a(3);
b(3);
expect(callback).toBeCalledTimes(1);
yield;
expect(callback).toBeCalledTimes(2);

unsub();
yield;
expect(callback).toBeCalledTimes(2);
});

[...task];
});

test('Executed after all threads', () => {
const order: number[] = [];
const task = threads(function* () {
const signal = createSignal(0);
order.push(0);

yield run(function* () {
createDeferredEffect(() => {
signal();
order.push(-1);
});

order.push(1);
yield;
order.push(4);
yield;
});

yield run(function* () {
order.push(2);
yield;
order.push(5);
yield;
});

order.push(3);
yield;
signal(1);
order.push(6);
yield;
});

[...task];

expect(order).toEqual([0, 1, 2, 3, -1, 4, 5, 6, -1]);
});
});
12 changes: 12 additions & 0 deletions packages/core/src/signals/createDeferredEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {DeferredEffectContext} from './DeferredEffectContext';

/**
* Invoke the callback at the end of each frame if any of its dependencies
* changed.
*
* @param callback - The callback to invoke.
*/
export function createDeferredEffect(callback: () => void): () => void {
const context = new DeferredEffectContext(callback);
return () => context.dispose();
}
50 changes: 50 additions & 0 deletions packages/core/src/signals/createEffect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {describe, expect, test, vi} from 'vitest';
import {createSignal} from './createSignal';

import {createEffect} from './createEffect';

describe('createEffect()', () => {
test('Invoked after creation', () => {
const callback = vi.fn();
createEffect(callback);

expect(callback).toBeCalled();
});

test('Invoked when dependencies change', () => {
const a = createSignal(1);
const b = createSignal(2);
const callback = vi.fn(() => {
a();
b();
});
createEffect(callback);

a(2);
a(3);
b(3);
b(4);

expect(callback).toBeCalledTimes(5);
});

test('Not invoked after unsubscribing', () => {
const a = createSignal(1);
const b = createSignal(2);
const callback = vi.fn(() => {
a();
b();
});
const unsub = createEffect(callback);

a(2);
b(3);

unsub();

a(3);
b(4);

expect(callback).toBeCalledTimes(3);
});
});
11 changes: 11 additions & 0 deletions packages/core/src/signals/createEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {EffectContext} from './EffectContext';

/**
* Invoke the callback immediately after any of its dependencies change.
*
* @param callback - The callback to invoke.
*/
export function createEffect(callback: () => void): () => void {
const context = new EffectContext(callback);
return () => context.dispose();
}
4 changes: 4 additions & 0 deletions packages/core/src/signals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@

export * from './CompoundSignalContext';
export * from './ComputedContext';
export * from './DeferredEffectContext';
export * from './DependencyContext';
export * from './EffectContext';
export * from './SignalContext';
export * from './Vector2SignalContext';
export * from './createComputed';
export * from './createComputedAsync';
export * from './createDeferredEffect';
export * from './createEffect';
export * from './createSignal';
export * from './symbols';
export * from './types';
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/threading/Thread.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {EventDispatcher} from '../events';
import {noop} from '../flow';
import {createSignal} from '../signals';
import {endThread, startThread, useLogger} from '../utils';
Expand All @@ -15,6 +16,11 @@ import {getTaskName, setTaskName} from './names';
* If a parent finishes execution, all of its child threads are terminated.
*/
export class Thread {
public get onDeferred() {
return this.deferred.subscribable;
}
private deferred = new EventDispatcher<void>();

public children: Thread[] = [];
/**
* The next value to be passed to the wrapped generator.
Expand Down Expand Up @@ -53,6 +59,10 @@ export class Thread {
return this.isPaused || (this.parent?.paused ?? false);
}

public get root(): Thread {
return this.parent?.root ?? this;
}

public parent: Thread | null = null;
private isCanceled = false;
private isPaused = false;
Expand Down Expand Up @@ -134,6 +144,7 @@ export class Thread {
}

public cancel() {
this.deferred.clear();
this.runner.return();
this.isCanceled = true;
this.parent = null;
Expand All @@ -143,4 +154,10 @@ export class Thread {
public pause(value: boolean) {
this.isPaused = value;
}

public runDeferred() {
startThread(this);
this.deferred.dispatch();
endThread(this);
}
}
2 changes: 1 addition & 1 deletion packages/core/src/threading/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ import {ThreadGenerator} from './ThreadGenerator';
export function spawn(
task: ThreadGenerator | (() => ThreadGenerator),
): ThreadGenerator {
return useThread().spawn(task);
return useThread().root.spawn(task);
}
9 changes: 8 additions & 1 deletion packages/core/src/threading/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,14 @@ export function* threads(
}
}

threads = newThreads.filter(thread => !thread.canceled);
threads = [];
for (const thread of newThreads) {
if (!thread.canceled) {
threads.push(thread);
thread.runDeferred();
}
}

if (threads.length > 0) yield;
}
}
10 changes: 10 additions & 0 deletions packages/docs/docs/advanced/spawners.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ slug: /spawners

# Spawners

:::caution

Spawners are implemented using signals which makes them unpredictable due to
signals' lazy evaluation. You may want to consider using
[effects](/docs/effects#complex-example) instead.

In future versions, spawners will be reimplemented using effects.

:::

Sometimes we want the children of a given node to be reactive. In other words,
we want them to change according to some external state. Consider the following
example:
Expand Down

0 comments on commit 00fa967

Please sign in to comment.