Skip to content

Commit

Permalink
Merge pull request #1 from Contargo/add-signal
Browse files Browse the repository at this point in the history
Add signals as a container to store state
  • Loading branch information
fheft committed May 8, 2019
2 parents b571017 + 287ff6c commit d8429d4
Show file tree
Hide file tree
Showing 4 changed files with 334 additions and 0 deletions.
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);
});
});

0 comments on commit d8429d4

Please sign in to comment.