Skip to content

Commit

Permalink
Adds ReactiveRoot.
Browse files Browse the repository at this point in the history
This interface describes an object which can schedule effects against a `Connectable` lifecycle. Practically speaking, this allows effects to be automatically bound to a component being connected to / disconnected from the document. The implementation and tests are mostly copied over from `ComponentRef`.

Having this as a standalone implementation allows this to be more effectively composed throughout the system.
  • Loading branch information
dgp1130 committed Sep 6, 2024
1 parent 55f3344 commit d96979b
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/signals/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { cached } from './cached.js';
export { effect } from './effect.js';
export { type ReactiveRoot } from './reactive-root.js';
export { type Equals, signal } from './signal.js';
export { MacrotaskScheduler } from './schedulers/macrotask-scheduler.js';
export { type Action, type CancelAction, type Scheduler } from './schedulers/scheduler.js';
Expand Down
8 changes: 8 additions & 0 deletions src/signals/reactive-root.test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title>`reactive-root` tests</title>
<meta charset="utf8">
</head>
<body></body>
</html>
138 changes: 138 additions & 0 deletions src/signals/reactive-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Connector } from '../connectable.js';
import { ReactiveRootImpl } from './reactive-root.js';
import { TestScheduler } from './schedulers/test-scheduler.js';
import { signal } from './signal.js';

describe('reactive-root', () => {
describe('ReactiveRootImpl', () => {
describe('from', () => {
it('provides a `ReactiveRootImpl`', () => {
const connectable = Connector.from(() => false);
const scheduler = TestScheduler.from();

expect(ReactiveRootImpl.from(connectable, scheduler))
.toBeInstanceOf(ReactiveRootImpl);
});
});

describe('effect', () => {
it('schedules the effect', () => {
const connector = Connector.from(() => false);
const scheduler = TestScheduler.from();
const root = ReactiveRootImpl.from(connector, scheduler);

const effect = jasmine.createSpy<() => void>('effect');

root.effect(effect);
expect(scheduler.isStable()).toBeTrue();
expect(effect).not.toHaveBeenCalled();

connector.connect();
expect(scheduler.isStable()).toBeFalse();
expect(effect).not.toHaveBeenCalled();

scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
});

it('reruns the effect when a signal changes', () => {
const connector = Connector.from(() => true);
const scheduler = TestScheduler.from();
const root = ReactiveRootImpl.from(connector, scheduler);

const value = signal(1);
const effect = jasmine.createSpy<() => void>('effect')
.and.callFake(() => { value(); });

root.effect(effect);
scheduler.flush();
expect(effect).toHaveBeenCalled();
effect.calls.reset();

expect(scheduler.isStable()).toBeTrue();

value.set(2);
expect(scheduler.isStable()).toBeFalse();
expect(effect).not.toHaveBeenCalled(); // Scheduled but not invoked yet.

scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
});

it('does not initialize the effect until connected', () => {
const connector = Connector.from(() => false);
const scheduler = TestScheduler.from();
const root = ReactiveRootImpl.from(connector, scheduler);

const effect = jasmine.createSpy<() => void>('effect');

// Not scheduled when disconnected.
root.effect(effect);
scheduler.flush();
expect(effect).not.toHaveBeenCalled();

// Scheduled when connected.
connector.connect();
scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
});

it('pauses the effect while disconnected', () => {
const connector = Connector.from(() => true);
const scheduler = TestScheduler.from();
const root = ReactiveRootImpl.from(connector, scheduler);

const value = signal(1);
const effect = jasmine.createSpy<() => void>('effect')
.and.callFake(() => { value(); });

root.effect(effect);
scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
effect.calls.reset();

// Don't really need to assert this, just making sure `value` is used
// correctly in this test.
value.set(2);
scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
effect.calls.reset();

connector.disconnect();
expect(scheduler.isStable()).toBeTrue();
expect(effect).not.toHaveBeenCalled();

value.set(3);
expect(scheduler.isStable()).toBeTrue();
expect(effect).not.toHaveBeenCalled();
});

// Effects *must* be re-executed when reconnected to the DOM. This is
// because signal dependencies might have changed while the effect was
// disabled. The alternative is to continue subscribing to signal changes,
// but doing so would prevent an unused component from being garbage
// collected.
it('resumes the effect when reconnected', () => {
const connector = Connector.from(() => true);
const scheduler = TestScheduler.from();
const root = ReactiveRootImpl.from(connector, scheduler);

const effect = jasmine.createSpy<() => void>('effect');

root.effect(effect);
scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
effect.calls.reset();

connector.disconnect();
expect(effect).not.toHaveBeenCalled();

// Even though no dependencies changed, effect should be re-invoked just
// to check if they have.
connector.connect();
scheduler.flush();
expect(effect).toHaveBeenCalledOnceWith();
});
});
});
});
57 changes: 57 additions & 0 deletions src/signals/reactive-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Connectable } from '../connectable.js';
import { effect } from './effect.js';
import { Scheduler } from './schedulers/scheduler.js';

/**
* Represents the "root" of reactive effects. This manages starting and
* stopping effects based on a component being attached to / detached from the
* document.
*/
export interface ReactiveRoot {
/**
* Create an effect which executes the given callback. The effect is
* automatically enabled / disabled when the associated component attaches to
* / disconnects from the document.
*
* @param callback The callback to invoke which executes a signal-based side
* effect.
*/
effect(callback: () => void): void;
}

/**
* Represents the "root" of reactive effects. This manages starting and
* stopping effects based on a component being attached to / detached from the
* document.
*
* We need this class to be independent of the interface because otherwise ES
* private variables leak into the type.
*/
export class ReactiveRootImpl implements ReactiveRoot {
readonly #connectable: Connectable;
readonly #scheduler: Scheduler;

private constructor(connectable: Connectable, scheduler: Scheduler) {
this.#connectable = connectable;
this.#scheduler = scheduler;
}

/**
* Provides a new {@link ReactiveRootImpl}.
*
* @param connectable The {@link Connectable} which tracks connectivity of the
* component these effects will be scheduled with.
* @param scheduler The {@link Scheduler} which will schedule effects.
* @returns A {@link ReactiveRootImpl}.
*/
public static from(connectable: Connectable, scheduler: Scheduler):
ReactiveRootImpl {
return new ReactiveRootImpl(connectable, scheduler);
}

public effect(callback: () => void): void {
this.#connectable.connected(() => {
return effect(callback, this.#scheduler);
});
}
}

0 comments on commit d96979b

Please sign in to comment.