Skip to content

Commit 406a5e5

Browse files
authored
more docs tweaking (#34)
* clock terminology * more docs tweaks
1 parent aeeac8c commit 406a5e5

File tree

6 files changed

+138
-89
lines changed

6 files changed

+138
-89
lines changed

README.md

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,8 @@
55
</picture>
66
</div>
77

8-
**Signia** is a small, fast, and scaleable signals library for JavaScript and TypeScript.
8+
**Signia** is a minimal, fast, and [scalable](https://signia.tldraw.dev/docs/scalability) signals library for TypeScript.
99

10-
It uses an epochal pull-based (i.e. lazy) reactivity model that provides a lot of leverage to keep performance high and spaghetti low as the size and complexity of an application grows.
10+
It uses a new clock-based lazy reactivity system that allows signals to scale with complex data-intensive applications.
1111

12-
Check the [docs](https://tldraw.github.io/signia)
13-
14-
## What are signals?
15-
16-
## How is Signia different?
17-
18-
The key difference is scalability. Signia uses a unique reactivity model which allows signals to emit both ordinary values, and 'deltas' between successive values over time.
19-
20-
Imagine something like the following:
21-
22-
```ts
23-
const todos = atom([{ title: 'buy milk', completed: false}, ...])
24-
const incompleteTodos = computed(() => todos.value.filter(t => !t.completed))
25-
```
26-
27-
Every time you add a new todo item, `incompleteTodos` will be recomputed from scratch, running the filter predicate on all todo items regardless of whether they have changed.
28-
29-
With Signia, you can get only the changes since last time, and do with them what you like.
12+
Check the [docs](https://signia.tldraw.dev)

docs/docs/_intro.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import useBaseUrl from '@docusaurus/useBaseUrl'
99
}}
1010
/>
1111

12-
**Signia** is a minimal, fast, and scalable signals library for TypeScript. It is framework-agnostic, and has official React bindings.
12+
**Signia** is a minimal, fast, and [scalable](/docs/scalability) signals library for TypeScript. It is framework-agnostic, and has official React bindings.
1313

14-
It uses a new epochal pull-based reactivity system that allows signals to scale with complex data-intensive applications.
14+
It uses a new clock-based lazy reactivity system that allows signals to scale with complex data-intensive applications.
1515

1616
Signia was originally created for [tldraw](https://beta.tldraw.com), to meet performance demands that other reactive signals libraries could not.

docs/docs/getting-started.mdx

Lines changed: 0 additions & 35 deletions
This file was deleted.

docs/docs/incremental.mdx

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,44 @@ sidebar_position: 2
44

55
# Incrementally computed signals
66

7-
One of the things that sets Signia apart is that it supports incremental recomputation of derived values.
8-
This means that working with large derived collections can be extremely efficient, while still benefitting from the lazy evaluation and always-on-caching that Signia provides.
7+
One of Signia's superpowers is its support for incremental recomputation of derived values.
8+
This makes it possible to work with large derived collections extremely efficiently, while still benefitting from the lazy evaluation and always-on caching that Signia provides.
99

10-
This is achieved using an epochal reactivity system.
10+
This is achieved using a clock-based reactivity system.
1111

12-
## Epochs
12+
## Clocks and Epochs
1313

14-
Signia has a global 'epoch' value which is an integer that gets incremented every time any atom is updated.
14+
Signia has a global logical **clock**. This is an integer that gets incremented every time any atom is updated.
1515

16-
When a derived value is computed, its computing function is passed two arguments: the previous value, and the global epoch when the previous value was computed.
16+
An **epoch** is one specific value of the global clock. It is a virtual point in time.
1717

18-
This 'last computed' epoch can be used in conjunction with the `Signal.getDiffSince(epoch)` method to retrieve a list of changes, or 'diffs', since the last time the computed value was derived.
18+
You can access the epoch upon which a signal's value last changed using the `lastChangedEpoch` property:
19+
20+
```ts
21+
const firstName = atom('firstName', 'Brian')
22+
const startEpoch = firstName.lastChangedEpoch
23+
firstName.set('Steve')
24+
const endEpoch = firstName.lastChangedEpoch
25+
console.log(endEpoch - startEpoch) // 1
26+
```
27+
28+
When a derived value is computed, its computing function is passed two arguments:
29+
30+
- `previousValue` the last value returned by the computing function
31+
- `lastComputedEpoch` the value of the global clock when the previous value was last _computed_.
32+
33+
:::note
34+
Beware that 'last computed' is not the same as 'last changed', since a value can be recomputed and end up the same as it was before.
35+
:::
36+
37+
`lastComputedEpoch` can be used in conjunction with the `Signal.getDiffSince` method to retrieve a list of changes, or **diffs**, since the last time the computing function was invoked.
1938

2039
## Diffs don't come free
2140

22-
JavaScript's data types don't have built-in support for diffs, so it necessary to implement this functionality manually.
41+
JavaScript's datatypes don't have built-in support for diffs, so you need to implement this functionality manually.
2342

24-
For this tutorial, let's use [`immer`](https://immerjs.github.io/immer/), which is a library for working with immutable data. It also has the ability to extract diffs while making changes
43+
For this tutorial, let's use [`immer`](https://immerjs.github.io/immer/), which is a library for working with immutable data.
44+
It has the ability to extract diffs while making changes using its `produceWithPatches` function.
2545

2646
Here is an example of an Atom wrapper which uses `immer` to capture diffs:
2747

@@ -36,7 +56,7 @@ class ImmerAtom<T> {
3656
readonly atom: Atom<T, Patch[]>
3757
constructor(name: string, initialValue: T) {
3858
this.atom = atom(name, initialValue, {
39-
// In order to save diffs, we need to provide a historyLength argument
59+
// In order to store diffs, we need to provide the `historyLength` argument
4060
// to the atom constructor. Otherwise it will not allocate a history buffer.
4161
historyLength: 10,
4262
})
@@ -51,7 +71,7 @@ class ImmerAtom<T> {
5171

5272
## Using diffs in `computed`
5373

54-
Now that we have a way to capture diffs, we can use them in our `computed` functions.
74+
We can use the diffs emitted by our ImmerAtom in our `computed` functions.
5575

5676
Let's define an incremental version of Array.map:
5777

@@ -118,7 +138,7 @@ function map<T, U>(source: ImmerAtom<T[]>, fn: (value: T) => U): Computed<U[], P
118138

119139
You're probably thinking: "that's a whole lot of code just to map over an array!"
120140

121-
Alas, such is the nature of the beast. Incremental logic is _much_ trickier to write than non-incremental logic, but the payoff is worth it.
141+
Alas, incremental logic is _much_ trickier to write than non-incremental logic. But often the payoff is worth it.
122142

123143
## The payoff
124144

@@ -170,18 +190,6 @@ console.log(reversedNames.value) // [ 'linuS', 'xelA', 'uL', 'eimaJ', 'ajtiM' ]
170190
console.log(numReverseCalls) // 7
171191
```
172192

173-
:::note A small caveat
174-
175-
In this example, if the mapping function reads any other external signals, then those dependencies will not necessarily be captured on incremental runs. e.g. if popping an item off the list.
176-
This will prevent updates to external signals from propagating correctly through the mapped list.
177-
There are a couple of ways around this:
178-
179-
- Maintaining an array of computed signals, one for each item in the root list.
180-
- Adding an explicit dependencies array to the `map` function.
181-
182-
These are left as an exercise for the reader :) Feel free to reach out in our [Discord](https://discord.gg/3GJTuqay) if you need help.
183-
:::
184-
185193
## The `historyLength` option
186194

187195
The `historyLength` option is used to tell Signia how many diffs to store. Each time a value changes, a new diff is stored.
@@ -213,10 +221,70 @@ names.set(['Bob', 'Abhiti'])
213221
console.log(names.getDiffSince(startEpoch)) // [[ { op: 'replace', path: [1], value: 'Abhiti' } ]]
214222
```
215223

224+
## Testing
225+
226+
Applying incremental diffs correctly takes a lot of care and can be difficult in unexpected ways.
227+
228+
We suggest using generative testing to make sure your incremental logic matches your non-incremental logic.
229+
230+
An easy way to get started is by creating a seeded random number generator and using it to generate random 'update ops' for atoms.
231+
You can then run the same updates through both the incremental and non-incremental logic and compare the results.
232+
233+
```ts
234+
const seed = Math.random()
235+
test(`using seed ${seed}`, () => {
236+
const rng = new RandomNumberGenerator(seed)
237+
const names = new ImmerAtom('names', [], { historyLength: 10 })
238+
// if you set historyLength to 0 it will force your `map` incremental logic down the RESET_VALUE path
239+
const names_no_diff = new ImmerAtom('names_no_diff', [], { historyLength: 0 })
240+
241+
const updateBoth = (fn: (draft: string[]) => void) => {
242+
names.update(fn)
243+
names_no_diff.update(fn)
244+
}
245+
246+
const reversedNames = map(names, (name) => name.split('').reverse().join(''))
247+
const reversedNames_no_diff = map(names_no_diff, (name) => name.split('').reverse().join(''))
248+
249+
for (let i = 0; i < 1000; i++) {
250+
// getRandomNamesOp implementation left as an exercise for the reader
251+
const op = getRandomNamesOp(names.state.value, rng)
252+
if (op.type === 'add_name') {
253+
updateBoth((draft) => {
254+
draft.splice(op.index, 0, op.name)
255+
})
256+
} else if (op.type === 'remove_name') {
257+
updateBoth((draft) => {
258+
draft.splice(op.index, 1)
259+
})
260+
} else if (op.type === 'update_name') {
261+
updateBoth((draft) => {
262+
draft[op.index] = op.name
263+
})
264+
}
265+
266+
// don't check every time, to allow for some history buffer buildup and overflow
267+
if (rng.random() < 0.1) {
268+
expect(reversedNames.value).toEqual(reversedNames_no_diff.value)
269+
}
270+
}
271+
272+
expect(reversedNames.value).toEqual(reversedNames_no_diff.value)
273+
})
274+
```
275+
216276
## Conclusion
217277

218278
Most complex software systems do _something_ along these lines by necessity, usually ad-hoc. The nice thing about integrating it into Signia is that it's now a first-class citizen and it works seamlessly with other signals. There's no need to worry about cache invalidation or update ordering, everything just works.
219279

220280
At [tldraw](https://tldraw.com) we use incrementally computed signals for a handful of our core data structures, and it's been a huge win for performance. We're able to keep our canvas snappy and responsive even when we have thousands of shapes.
221281

222282
We also have a rudimentary reactive database based on `signia` which makes heavy use of incrementally computed signals for building reactive queries and indexes.
283+
284+
:::info Get involved!
285+
286+
This is an extremely new kind of tool with lots of sharp edges! There are probably lots of ways to improve it and address common problems.
287+
288+
We'd be happy to hear your feedback and suggestions on [GitHub](https://github.com/tldraw/signia/discussions) or in the [Discord channel](https://discord.gg/3GJTuqay).
289+
290+
:::

docs/docs/intro.mdx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,48 @@
11
---
22
sidebar_position: -1
3-
title: Introducing Signia
3+
title: Getting Started
44
hide_title: true
55
---
66

7-
import Intro from './_intro.mdx'
7+
import ThemedImage from '@theme/ThemedImage'
8+
import useBaseUrl from '@docusaurus/useBaseUrl'
89

9-
<Intro />
10+
<ThemedImage
11+
alt="Push signals"
12+
sources={{
13+
light: useBaseUrl('/img/[email protected]'),
14+
dark: useBaseUrl('/img/[email protected]'),
15+
}}
16+
/>
1017

11-
## Quick links
18+
# Getting started
1219

13-
- [Getting started](getting-started)
14-
- [What are signals](what-are-signals)?
15-
- [How is Signia special](scalability)?
20+
## Installation
21+
22+
import Tabs from '@theme/Tabs'
23+
import TabItem from '@theme/TabItem'
24+
25+
### Core library
26+
27+
<Tabs>
28+
<TabItem value="npm" label="npm">
29+
<pre>npm add signia</pre>
30+
</TabItem>
31+
<TabItem value="yarn" label="yarn">
32+
<pre>yarn add signia</pre>
33+
</TabItem>
34+
<TabItem value="pnpm" label="pnpm">
35+
<pre>pnpm add signia</pre>
36+
</TabItem>
37+
</Tabs>
38+
39+
### React bindings
40+
41+
`signia` has no dependencies, and is useful outside of react applications.
42+
However if you wish to use it in a react app, we have officially-supported bindings available.
43+
44+
Read the [installation instructions](react-bindings) to find out more.
45+
46+
## Next: Read the tutorial
47+
48+
The [Using Signals Tutorial](using-signals) will walk you through the basics of using `signia` in an application.

docs/src/pages/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ import Intro from '@site/docs/_intro.mdx'
44

55
import Link from '@docusaurus/Link'
66

7-
<Link className="button button--primary button--lg" to="/docs/getting-started">
7+
<Link className="button button--primary button--lg" to="/docs/intro">
88
Getting Started
99
</Link>

0 commit comments

Comments
 (0)