-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
4 changed files
with
204 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
} | ||
} |