super-mithril-stream
v0.0.0-next.22
Published
> ⚠️ Warning, this library is very new, and while it is does have a lot of tests, we plan to iterate a bit on the internals near term so do not use this in production yet. > If you still do decide to use it, make sure you pin to a specific version.
Downloads
9
Readme
super-mithril-stream
⚠️ Warning, this library is very new, and while it is does have a lot of tests, we plan to iterate a bit on the internals near term so do not use this in production yet. If you still do decide to use it, make sure you pin to a specific version.
A rewrite of mithril's stream library with more stuff.
- Read only streams
- Track stream creation / references
- Less memory usage
- Explicit
get
/set
/update
methods - Lots of new built in methods/operations
- Compatible with Sin.js renderer
- Better typescript support
- Linear update with discrete transactions (Like S.js)
Quick Start
npm install super-mithril-stream
import Stream from 'super-mithril-stream'
let a = Stream(4)
let b = a.map( x => x * 2 )
a.get()
// 4
b.get()
// 8
a.set()
Why
At harth we already had our own fork of mithril-stream
inlined into our codebase. This library aims to formalize these extensions.
More stuff 🎉
Read only streams
Any dependent stream is treated as a readonly
stream both by typescript and at runtime.
Stream Tracking
You can manually track the creation and referencing of stream values within a function call. It is a bit like S.js
or other similar signal libraries except the tracking is made explicit.
import Stream from 'super-mithril-stream'
let a = Stream(1)
let b = Stream(1)
let c = Stream(1)
let d = Stream(1)
let inner = new Set<Stream>()
let outer = new Set<Stream>()
let answer = Stream.track(() => {
return c() + d() + Stream.track(() => {
return a() + b()
}, inner)
}, outer)
answer
// => 4
outer
// => Set(2) {c,d}
inner
// => Set(2) {a,b}
As you can see, the tracking contexts nest, and the inner contexts own the tracking of referenced streams exclusively. outer
captured streams c
and d
because they were referenced in the outer track context. And inner
captured streams a
and b
.
If you want tracking to cascade, you can share sets:
let answer = Stream.track(() => {
return c() + d() + Stream.track(() => {
return a() + b()
}, sharedSet)
}, sharedSet)
This feature is deliberately low level to allow framework code to control exactly how tracking behaves external to the stream library. There is no atomic clock, or setTimeout
considerations within super-mithril-stream
so you can decide to model the tracking sets however you like.
You can also trackCreated
and trackReferenced
separately.
Because tracking is manual, you have a lot of power, e.g. you can pause and resume the same tracking set in between calls to await
.
Less memory usage
This library's InternalStream
is a class. For backwards compatiblity we do some black magic with setPrototypeOf
to allow it to also act as a function getter/setter. But each instance of a stream is very cheap as all methods (and even some state) are inhertied from the prototype.
Explicit get
/ set
/ update
methods
Sin.js compatibility
super-mithril-stream
supports Sin.js
observable protocol. This means you can use super-mithril-stream
directly in sin's view and css definition inplace of s.live
if you need a bit more builtin stream functionality or would just prefer the familiar interface of mithril-stream
's API.
API
Extensions
Bacta streams are an API superset of mithril streams, but with some very slight changes in behaviour.
We've also added quite a few new methods to stream.
New accessors:
.get
.set
.update
New combinators:
.filter
.reject
.dropRepeats
.dropRepeatsWith
.debounce
.throttle
Tracking:
track
trackCreated
trackReferenced
sample
sampleCreated
sampleReferenced
untrack
New behaviour:
- combined streams end when any dependency ends
.get
returns a read only stream
stream.update
One minor frustation when working with mithril streams is incrementing values requires this little dance:
let count = v.useStream(0);
let inc = () => count(count() + 1);
We would like to provide this alternative:
let count = v.useStream(0);
let inc = () => count((x) => x + 1);
But this would prevent some very useful patterns where we pass a function into a stream (often used by a community favourite meiosis)
So we instead add an explicit update
api which only accepts a visitor
function, this allows us to increment a count like so:
let count = v.useStream(0);
let inc = () => count.update((x) => x + 1);
This more explicit API also ties in well with our new .get
and .set
stream
APIs.
stream.get
/ stream.set
Streams can be read via the traditional getter / setter API used in mithril
and flyd
const a = v.useStream(0);
a(); // => 0
a(1);
a(); // => 1
But we've found in practice it can be beneficial to be explicit when getting or setting. Lets say we have a list of streams and we want to turn it into a list of values:
let streams = [a, b, c, d];
// works fine
let values = streams.map((x) => x.get());
// works fine
let values = streams.map((x) => x());
// uh oh, accidentally updated the stream
let values = streams.map(x);
Another scenario: you want to let a component read from a stream, but not write to it.
// can only read
m(UntrustedComponent, { getValue: value.get });
// can read and write
m(TrustedComponent, { getValue: value });
stream.get
is also its own stream, so you can subscribe to changes without
having the ability to write back to the source.
someStream.get.map((x) =>
console.log('someStream changed', x)
);
It is also just a lot easier to grep for, you can more easily distinguish an arbitrary function call from a stream get/set.
stream.filter
interface Filter<T> {
(predicate: (value: T) => boolean): Stream<T>;
}
Only emits when the user provided predicate function returns a truthy value.
const person = useStream({ name: 'Barney', age: 15 });
setInterval(() => {
person.update((x) => ({ ...x, age: x.age + 1 }));
}, 86400 * 365 * 1000);
const isAdult = (x) => x.age > 18;
const adult = person.filter(isAdult);
stream.reject
interface Reject<T> {
(predicate: (value: T) => boolean): Stream<T>;
}
Only emits when the user provided predicate function returns a falsy value.
const child = person.reject(isAdult);
stream.dropRepeats
and stream.dropRepeatsWith
Prevents stream emission if the new value is the same as the prior value. You can specify custom equality with dropRepeatsWith
.
interface DropRepeats<T> {
(): Stream<T>;
}
interface DropRepeatsWith<T> {
(equality: (a: T, b: T) => boolean): Stream<T>;
}
// only send consecutively unique requests to the API
const results =
searchValue.dropRepeats().awaitLatest(async (q) => {
return m.request('/api/search?q=' + q);
});
You can also specify equality:
const equality = (a, b) =>
a.toLowerCase() == b.toLowerCase()
const results =
searchValue
.dropRepeatsWith(equality)
.awaitLatest(async (q) => {
return m.request('/api/search?q=' + q);
});
stream.afterSilence
and stream.throttle
interface AfterSilence<T> {
(ms?: number): Stream<T>;
}
interface Throttle<T> {
(ms?: number): Stream<T>;
}
// hit the API at most every 300ms
const results1 =
searchValue
.throttle(300)
.awaitLatest(async (q) => {
return m.request('/api/search?q=' + q);
});
// hit the API when searchValue has not emitted any values for at least 300ms
const results2 =
searchValue
.afterSilence(300)
.awaitLatest(async (q) => {
return m.request('/api/search?q=' + q);
});
Compatibility with mithril-stream
Passes all mithril-stream
tests (except for deliberate intentional behavioural changes)
Breaking changes from mithril-stream
Class methods
All methods on the stream instance are unbound class methods. This dramatically decreases memory usage but also means it is possible for a stream method to lose its this
context if passed around as a first class function.
The only exception to this is the getter/setters:
stream()
stream(newValue)
stream.get()
stream.set(newValue)
All these functions are bound to the stream instance as it is a common pattern to use them as a callback directly.
Behaviour of combine/lift/merge
If any dependency stream ends it will also end any child streams including combinations of streams using operators such as combine, merge, lift etc.
mithril-stream
will not end a combined stream if one of the dependencies ends.
In traditional mithril.js, if one of the dependencies of that combined stream ends, then the combined stream does not end. If other dependencies continue to emit the derived stream continues to emit as well.
This also makes component scoped streams much safer, as you can combine a local scoped stream with a global stream and know you won't have the derived stream continuing to emit once your component unmounts.
End stream propagation
In mithril-stream
if you end a parent stream, the dependent streams don't end, The stream is unregistered from its parent but the end streams are never written to.
So you get stuff like this:
const a = m.stream(1)
const b = a.map( x => x * 2 )
a.end(true)
console.log(a.end(), b.end())
// true undefined
In practice b
is ended because if you write to a
, it won't propagate, and in theory if a
and b
are dereferenced they will be GC'd.
All the same super-mithril-stream
will mark dependent streams as ended when a parent stream ends.
This let's you subscribe to .end
on any stream in the dependency tree and it will reliably trigger.
We take this further with behaviour changes to merged/combined streams.
In mithril-stream, and flyd, if a dependency stream ends the combined stream doesn't necessarily end. This was picked up in @porsager's stream rewrite here
In Harth's usage of streams, we often scoped streams by having a single component stream that ends. This scoped stream was merged with all component created streams automatically guaranteeing a component's streams will be cleaned up when the component unmounts.
We highly associate stream ends with component lifecycles. A component never ends a stream it doesn't own, and a stream created by a component should always end when the component ends. If a component combines with a global stream we definitely don't want unmounted components to still have live combined streams emitting forever leading to memory leaks because they weren't explicitly ended.
We also feel it aligns better with related functionality in the wild e.g.
- Many applicative librarys (e.g. include an
Either.Left
orMaybe.Nothing
in asequence
ortraverse
and you wil getLeft
orNothing
even if oother dependencies areJust
/Right
) Promise.all
(ifRejected
is compared toended
)- SQL
null
(comparenull
with any value and you getnull
).
There is no real correct answer to this: it is always up for interpretation. But in short, for this library, stream ending is contagious.
Some warnings removed
mithril-stream
warns you if you do the wrong thing in a few places. E.g. if you use HALT
instead of SKIP
, or if you pass a non stream value to combine
, or if you pass in a dependency that maybe is a stream but wasn't created by this library etc.
This library removes those warnings. The thinking is, if we are going to warn against using the library in undocumented ways then we'd have to write an infinite amount of warnings. We also now have the benefit of typescript which will provide the same sort of warning in each of these cases.
Also this library has no users, and mithril had to be more careful naturally.
Readonly streams
mithril-stream
(like flyd
) has no distinction between read/sink and write/source streams. super-mithril-stream
explicitly forbids writing to a dependent stream.
Operations like map
/ filter
etc all create a read only stream and will throw an error (and fail type checking) if you write to them.
const a = Stream()
a(1) // all good
const b = a.map( x => x * 2)
b(1)
// Type error and
// throws an error
This means when you look at the definition const b = a.map( x => x * 2 )
you know with complete confidence that the value of b
is defined by the expression: x * 2
, and nothing can interfere with that.
Propagation
mithril-stream
(like flyd
) has a recursive update cycle. When you write to a stream, all its dependencies are updated, and the dependencies of those streams are updated and this all happens immediately and depth first.
super-mithril-stream
instead behaves more like database transaction and other stream libraries like S.js.
Within this library, when you write to a stream, all its recursive dependencies are first gathered up without actually updating them.
Then all these dependencies are updated in a single pass, serially.
If any of these dependencies write to another stream, we schedule that update to happen at the end of the current update. This is all synchronous but these nested updates are treated as distinct transactions and are scheduled to happen in order.
This makes for far easier debugging when you have lots of nested updates and potential cyclic dependencies. Each write is scheduled as a discrete transaction, and you can watch all this happen in a single function in the source code instead of jumping through nested stream dispatches.
But because these transactions are scheduled, during an update, dependent read only values will not have the latest value until the update completes. The stream you write to will, and the streams that are written to within an update will also immediately have the latest value. But other streams have to wait their turn to update.
This shouldn't impact general usage unless if you are doing some very interesting checks to prevent infinite loops.
Here is an example:
const a = Stream(0)
const b = Stream(0)
a.map( x => {
b( x * 2 )
// at this point:
// a = 1, 2
// b = 2, 4
// c = undefined, 4
})
let c = b.map(
x => x * 2
)
a(1)
// a=1, b=2, c=4
a(2)
// a=2, b=4, c=8
We write to a
, and this triggers a.map( ... )
to run. Within this map
we write to b
. Both these stream values will be immediately available. But the value of c
will be undefined until b
's transaction completes. In mithril-stream
, writing to b
would immediately trigger the update to c
which would make the current value of c
available immediately.
This is a definite trade off. We are favouring a more debuggable / traceable update cycle over architectural simplicity.
Browser / Node Compatibility
super-mithril-stream
uses a lot of recent native features, and there is only an ESM build with no down compiling, so it may not work in all browsers.
We're happy to offer alternative builds if it is needed, but evergreen browsers have led to us to considering modern/native JS as a sensible default.