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

Add signals as a container to store state #1

Merged
merged 5 commits into from
May 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"prepublishOnly": "npm test",
"test": "jest"
},
"sideEffects": false,
"repository": {
"type": "git",
"url": "git+https://github.com/Contargo/flyps.git"
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { signal, signalFn } from "./signal";
160 changes: 160 additions & 0 deletions src/signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* A signal is a container used to store state information. A signal can be made
* to change state by calling `reset` or `update`.
* Outputs can be connected to signals. Whenever the state of a signal changes,
* all connected outputs will be triggered.
*/
export function signal(state) {
let outputs = [];

return {
value() {
if (context) {
context.inputs = [...(context.inputs || []), this];
}
return state;
},
reset(next) {
let prev = state;
state = next;

if (prev !== next) {
outputs.forEach(fn => fn(this, prev, next));
}
},
update(fn, ...args) {
this.reset(fn.call(null, state, ...args));
},
connect(fn) {
outputs = [...outputs, fn];

let disconnect = () => {
outputs = outputs.filter(s => s !== fn);
};
return disconnect;
},
};
}

/**
* A signalFn is a signal that computes its state by running `fn`. It keeps
* track of and connects to all referenced input signals during the function
* call. If the state of any of the connected input signals changes, the state
* of signalFn gets re-computed (which means re-running `fn`). The state held by
* the signalFn is the return value of `fn` and can be preset using `state`.
* Like with signals, outputs can be connected. Whenever the state of a signalFn
* changes, all connected outputs will be triggered.
*/
export function signalFn(fn, state) {
let inputs = [];
let outputs = [];
let disconnectors = new WeakMap();
let freeWatchers = [];
let dirty = true;

return {
value() {
if (context) {
context.inputs = [...(context.inputs || []), this];
}
if (dirty) {
this.run();
}
return state;
},
run() {
let [context, next] = trackInputs(fn);
dirty = false;

let trackedInputs = context.inputs || [];
let connectingInputs = arrayDiff(trackedInputs, inputs);
let disconnectingInputs = arrayDiff(inputs, trackedInputs);
inputs = [...trackedInputs];
connectingInputs.forEach(s => {
let disconnect = s.connect(this.strobe.bind(this));
disconnectors.set(s, disconnect);
});
disconnectingInputs.forEach(s => {
let disconnect = disconnectors.get(s);
disconnectors.delete(s);
disconnect();
});

let prev = state;
state = next;

if (prev !== next) {
outputs.forEach(fn => fn(this, prev, next));
}
},
dirty() {
return dirty;
},
strobe() {
dirty = true;
this.run();
},
connect(fn) {
if (dirty) {
this.run();
}

outputs = [...outputs, fn];

let disconnect = () => {
outputs = outputs.filter(s => s !== fn);

if (outputs.length === 0) {
this.free();
}
};
return disconnect;
},
free() {
inputs.forEach(s => {
let disconnect = disconnectors.get(s);
disconnectors.delete(s);
disconnect();
});
inputs = [];
dirty = true;
state = undefined;

freeWatchers.forEach(fn => fn());
freeWatchers = [];
},
onFree(fn) {
freeWatchers = [...freeWatchers, fn];
},
inputs() {
if (dirty) {
this.run();
}
return [...inputs];
},
};
}

/**
* Holds the current, global context for a signalFn. A context urges referenced
* signals to register as input signals. signalFns can therefore use a context
* for tracking and book keeping of referenced input signals.
*/
let context = undefined;

/**
* Tracks all referenced signals while running `fn` by setting a new global
* context. The return value is a tuple of the used context and return value of
* `fn`. After running `fn`, the previous context gets restored.
*/
function trackInputs(fn) {
let prevContext = context;
context = {};
let res = [context, fn()];
context = prevContext;
return res;
}

function arrayDiff(arr, other) {
return arr.filter(v => other.indexOf(v) < 0);
}
172 changes: 172 additions & 0 deletions src/signal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { signal, signalFn } from "./signal";

describe("signal", () => {
it("returns its current value", () => {
let s = signal("foo");
expect(s.value()).toBe("foo");
});
it("resets its value", () => {
let s = signal("foo");
s.reset("bar");
expect(s.value()).toBe("bar");
});
it("updates its value", () => {
let s = signal("foo");
s.update(state => state + "bar");
expect(s.value()).toBe("foobar");
});
it("triggers connected outputs for new values", () => {
let updates = 0;
let s = signal("foo");
s.connect(() => updates++);
s.reset("bar");

expect(updates).toBe(1);
});
it("ignores outputs for equal values", () => {
let updates = 0;
let s = signal("foo");
s.connect(() => updates++);
s.reset("foo");

expect(updates).toBe(0);
});
it("passes information when triggering connected outputs", () => {
let updates = [];
let triggerFn = (signal, prev, next) => {
updates = [...updates, { signal, prev, next }];
};
let s = signal("foo");
s.connect(triggerFn);
s.reset("bar");

expect(updates.length).toBe(1);
expect(updates[0].signal).toBe(s);
expect(updates[0].prev).toBe("foo");
expect(updates[0].next).toBe("bar");
});
it("disconnects a connected output", () => {
let outputs = [0, 0];
let s = signal("foo");
s.connect(() => outputs[0]++);
let disconnect = s.connect(() => outputs[1]++);
s.reset("bar");
disconnect();
s.reset("baz");

expect(outputs[0]).toBe(2);
expect(outputs[1]).toBe(1);
});
});

describe("signalFn", () => {
it("returns the result of fn as its current value", () => {
let runs = 0;
let s = signalFn(() => ++runs);
expect(s.value()).toBe(1);
});
it("connects to input signals", () => {
let s1 = signal("foo");
let s2 = signalFn(() => s1.value());
let s3 = signalFn(() => s2.value());

expect(s3.inputs()).toEqual(expect.arrayContaining([s2]));
expect(s2.inputs()).toEqual(expect.arrayContaining([s1]));
});
it("disconnects from unused input signals", () => {
let s1 = signal("foo");
let s2 = signal("bar");
let s3 = signalFn(() => (s1.value() === "foo" ? s2.value() : s1.value()));

expect(s3.inputs()).toEqual(expect.arrayContaining([s1, s2]));
s1.reset("baz");
expect(s3.inputs()).toEqual(expect.arrayContaining([s1]));
});
it("tracks chain of input signals properly (restores context)", () => {
let s1 = signalFn(() => "s1");
let s2 = signalFn(() => "s2");
let s3 = signalFn(() => s1.value() + s2.value());

expect(s3.inputs()).toEqual(expect.arrayContaining([s1, s2]));
expect(s2.inputs()).toEqual([]);
expect(s1.inputs()).toEqual([]);
});
it("triggers connected outputs for new values", () => {
let updates = 0;
let s1 = signal("foo");
let s2 = signalFn(() => s1.value());
s2.connect(() => updates++);
s1.reset("bar");

expect(updates).toBe(1);
});
it("ignores outputs for equal values", () => {
let updates = 0;
let s1 = signal("foo");
let s2 = signalFn(() => {
s1.value();
return "baz";
});
s2.connect(() => updates++);
s1.reset("bar");

expect(s2.value()).toBe("baz");
expect(updates).toBe(0);
});
it("passes information when triggering connected outputs", () => {
let updates = [];
let triggerFn = (signal, prev, next) => {
updates = [...updates, { signal, prev, next }];
};
let s1 = signal("foo");
let s2 = signalFn(() => s1.value());
s2.connect(triggerFn);
s1.reset("bar");

expect(updates.length).toBe(1);
expect(updates[0].signal).toBe(s2);
expect(updates[0].prev).toBe("foo");
expect(updates[0].next).toBe("bar");
});
it("disconnects a connected output", () => {
let outputs = [0, 0];
let s1 = signal("foo");
let s2 = signalFn(() => s1.value());
s2.connect(() => outputs[0]++);
let disconnect = s2.connect(() => outputs[1]++);
s1.reset("bar");
disconnect();
s1.reset("baz");

expect(outputs[0]).toBe(2);
expect(outputs[1]).toBe(1);
});
it("frees itself if there are no more connected outputs", () => {
let runs = 0;
let s1 = signal("foo");
let s2 = signalFn(() => {
runs++;
return s1.value();
});
let disconnect = s2.connect(() => {});

expect(runs).toBe(1);
expect(s2.dirty()).toBeFalsy();

disconnect();
s1.reset("bar");

expect(runs).toBe(1);
expect(s2.dirty()).toBeTruthy();
});
it("notifies watchers when freeing itself", () => {
let freed = 0;
let s = signalFn(() => "foo");
let disconnect = s.connect(() => {});
s.onFree(() => freed++);

expect(freed).toBe(0);
disconnect();
expect(freed).toBe(1);
});
});