hibiki 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +9 -92
- data/lib/hibiki/effect.rb +12 -0
- data/lib/hibiki/tracking.rb +29 -5
- data/lib/hibiki/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d473629248429556ab9410edc059933072d1a6c6f462fd9fa225c25684d45c79
|
|
4
|
+
data.tar.gz: 025ac97891ad5933eecd82201323f3c329e3dedc392f6407e6004175ee7c316a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 897c70917201523c25cd459fe84ee402cd25a92268729c505fff5109bf4f51e462f69791cc4f3204343d06193983d1f50498d5e0ea6dfadeffd0260721f6fbbb
|
|
7
|
+
data.tar.gz: 693c6e0cae17d37c3c1b1eeea8b94ee5323ecdf103f89690c34389779c37f426fd49fc4141640e6666ba3d8f1bbd78856a1b6399ec92a166dca307daa8269c59
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.2.0] - 2026-07-29
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **BREAKING: effects re-run only when a value they read actually changed.**
|
|
15
|
+
Every read now remembers the value it saw, and at the batch flush an effect
|
|
16
|
+
compares each of its dependencies against what it last read; if everything
|
|
17
|
+
compares `==`, the effect does not run and its scheduler is not called.
|
|
18
|
+
Svelte's `$derived` compares, and now so does Hibiki — on the reading side,
|
|
19
|
+
because a derived has already notified its subscribers by the time it knows
|
|
20
|
+
its new value. Measured motivation: a write that rippled through a derived to
|
|
21
|
+
a structurally identical value pushed a 159 KB re-render with nothing
|
|
22
|
+
changed anywhere.
|
|
23
|
+
|
|
24
|
+
Two behaviour changes to be aware of:
|
|
25
|
+
|
|
26
|
+
- an effect kept for a side effect *per write* (a heartbeat, a log line, a
|
|
27
|
+
counter) must read the value that genuinely changes rather than a derived
|
|
28
|
+
summary that often doesn't;
|
|
29
|
+
- a derived that returns the same object it mutated compares equal to itself,
|
|
30
|
+
so the gate swallows the update — return a new object. `Effect#run` still
|
|
31
|
+
bypasses the gate entirely.
|
|
32
|
+
|
|
33
|
+
A batch that nets to no change (`batch { a.value = 2; a.value = 1 }`) also
|
|
34
|
+
stops re-running effects. `Derived`'s laziness is unchanged: it still
|
|
35
|
+
recomputes on read, never on write.
|
|
36
|
+
|
|
37
|
+
## [0.1.0] - 2026-07-18
|
|
11
38
|
|
|
12
39
|
### Added
|
|
13
40
|
|
data/README.md
CHANGED
|
@@ -26,6 +26,9 @@ include Hibiki::DSL
|
|
|
26
26
|
|
|
27
27
|
x = state(0)
|
|
28
28
|
y = derived { x.value + 1 }
|
|
29
|
+
|
|
30
|
+
x.value = 10
|
|
31
|
+
y.value # => 11
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
Or if you wish to avoid using DSL:
|
|
@@ -51,14 +54,14 @@ counter.value += 1
|
|
|
51
54
|
counter.update { it + 1 } # in-place sugar
|
|
52
55
|
```
|
|
53
56
|
|
|
54
|
-
**`derived { }`** — a lazy computed signal. It recomputes on read when marked dirty, never on write, and caches its value until a dependency changes.
|
|
57
|
+
**`derived { }`** — a lazy computed signal. It recomputes on read when marked dirty (when any state it depends on changes), never on write, and caches its value until a dependency changes.
|
|
55
58
|
|
|
56
59
|
```ruby
|
|
57
60
|
doubled = derived { counter.value * 2 }
|
|
58
61
|
doubled.value # => 4
|
|
59
62
|
```
|
|
60
63
|
|
|
61
|
-
**`effect { }`** — an eager side effect. It runs immediately and re-runs whenever a dependency changes.
|
|
64
|
+
**`effect { }`** — an eager side effect. It *runs immediately* and re-runs whenever a dependency (any state inside the effect block) changes.
|
|
62
65
|
|
|
63
66
|
```ruby
|
|
64
67
|
name = state("world")
|
|
@@ -68,26 +71,6 @@ effect { puts "hello, #{name.value}!" } # prints "hello, world!"
|
|
|
68
71
|
name.value = "Ruby" # prints "hello, Ruby!"
|
|
69
72
|
```
|
|
70
73
|
|
|
71
|
-
### Untracked reads
|
|
72
|
-
|
|
73
|
-
Sometimes an effect should *sample* a signal without depending on it.
|
|
74
|
-
`Hibiki.untrack { }` suppresses dependency registration for a block, and
|
|
75
|
-
`#peek` is the per-signal shorthand — the classic use is read-modify-write,
|
|
76
|
-
where an effect must not depend on the signal it writes:
|
|
77
|
-
|
|
78
|
-
```ruby
|
|
79
|
-
count = state(0)
|
|
80
|
-
history = state([])
|
|
81
|
-
|
|
82
|
-
# Log every count change — without peek, writing history would re-trigger
|
|
83
|
-
# this effect forever (it would depend on its own output).
|
|
84
|
-
effect { history.value = history.peek + [count.value] }
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
A dirty derived still recomputes on `peek`; only the reader's subscription is
|
|
88
|
-
skipped. Signals also respond to `#call`, mirroring Solid's
|
|
89
|
-
signals-as-getter-functions: `count.call` (or `count.()`) reads and registers.
|
|
90
|
-
|
|
91
74
|
### Dynamic dependencies
|
|
92
75
|
|
|
93
76
|
Dependencies are re-collected on every recompute, so conditional reads work:
|
|
@@ -104,42 +87,9 @@ flag.value = false
|
|
|
104
87
|
picked.value # => "B2" (deps re-collected)
|
|
105
88
|
```
|
|
106
89
|
|
|
107
|
-
### Lifecycle: `root` and `on_cleanup`
|
|
108
|
-
|
|
109
|
-
Effects created while another effect runs are *owned* by it and disposed
|
|
110
|
-
automatically when the owner re-runs or is disposed. For everything else
|
|
111
|
-
there is `Hibiki.root` (Solid's `createRoot`): an ownership scope you tear
|
|
112
|
-
down yourself — the anchor for long-lived graphs (a session, a connection)
|
|
113
|
-
whose teardown is an external event, not a rerun.
|
|
114
|
-
|
|
115
|
-
`Hibiki.on_cleanup` (Solid's `onCleanup`) registers teardown on the owning
|
|
116
|
-
effect or root; it runs before each re-run and on dispose — the place to
|
|
117
|
-
release timers, sockets, subscriptions an effect sets up:
|
|
118
|
-
|
|
119
|
-
```ruby
|
|
120
|
-
interval = state(1)
|
|
121
|
-
|
|
122
|
-
ticker = Hibiki.root do
|
|
123
|
-
effect do
|
|
124
|
-
timer = start_timer(every: interval.value)
|
|
125
|
-
Hibiki.on_cleanup { timer.cancel } # runs before each re-run, and on dispose
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
interval.value = 5 # old timer cancelled, new one started
|
|
130
|
-
ticker.dispose # tears down every effect in the scope, cleanups included
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
A root's block runs untracked, and a root created inside an effect is *not*
|
|
134
|
-
adopted by it — it deliberately escapes the automatic owner tree, so its
|
|
135
|
-
lifetime is exactly `Hibiki.root` … `root.dispose`. Individual effects can
|
|
136
|
-
still be disposed directly with `Effect#dispose`.
|
|
137
|
-
|
|
138
90
|
### Class-based reactivity
|
|
139
91
|
|
|
140
|
-
Svelte 5 allows `$state
|
|
141
|
-
is the Ruby analogue. Declare signals with class macros and use them as plain
|
|
142
|
-
attributes — no `.value` at usage sites:
|
|
92
|
+
Svelte 5 allows `$state` / `$derived` / `$effect` as class fields; `Hibiki::Reactive` is the Ruby analogue. Declare signals with class macros and use them as plain attributes — no more `.value` in code:
|
|
143
93
|
|
|
144
94
|
```ruby
|
|
145
95
|
class Counter
|
|
@@ -158,46 +108,13 @@ counter.increment # prints "count is now 1"
|
|
|
158
108
|
counter.doubled # => 2
|
|
159
109
|
```
|
|
160
110
|
|
|
161
|
-
Signals are per-instance and created lazily; subclasses inherit all
|
|
162
|
-
declarations. Use the block form for mutable defaults (a positional default
|
|
163
|
-
is one shared object, the same gotcha as Rails attribute defaults).
|
|
164
|
-
|
|
165
|
-
Effect lifecycle: an instance created *inside* a running effect is adopted by
|
|
166
|
-
the owner tree and cleaned up automatically when that owner re-runs or is
|
|
167
|
-
disposed. For long-lived instances whose effects read signals *outside* the
|
|
168
|
-
instance, call `#dispose` — effects that only read the instance's own signals
|
|
169
|
-
form a self-contained island that garbage-collects with it.
|
|
170
|
-
|
|
171
|
-
### Why no transparent signals?
|
|
172
|
-
|
|
173
|
-
Two designs were evaluated and rejected, so they don't need relitigating:
|
|
174
|
-
|
|
175
|
-
- **Transparent value wrappers** (`method_missing` forwarding to `.value`):
|
|
176
|
-
Ruby object truthiness cannot be overridden, so `if flag` on a wrapper is
|
|
177
|
-
always true — it silently breaks conditionals, the exact thing dynamic
|
|
178
|
-
dependency tracking is best at. `nil?` and `==` lie similarly.
|
|
179
|
-
- **A `reactive do ... end` block DSL**: Ruby has no hook for bare local
|
|
180
|
-
variable reads or writes, so writes need `self.x =` anyway — at which point
|
|
181
|
-
a class (above) wears the same design better.
|
|
182
|
-
|
|
183
|
-
The full walkthrough with examples lives in
|
|
184
|
-
[docs-md/why-no-transparent-signals.md](docs-md/why-no-transparent-signals.md).
|
|
111
|
+
Signals are per-instance and created lazily; subclasses inherit all declarations. Use the block form for mutable defaults — a positional default is one object shared by every instance; see [Mutable state defaults](docs-md/mutable-defaults.md) for the details.
|
|
185
112
|
|
|
186
113
|
## Documentation
|
|
187
114
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
More detail in [docs-md/](docs-md/):
|
|
115
|
+
Documentation site: <https://planetaska.github.io/hibiki/>
|
|
191
116
|
|
|
192
|
-
|
|
193
|
-
rejected transparency designs, with the failure cases spelled out.
|
|
194
|
-
- [Threading model](docs-md/threading-model.md) — fiber-confined bookkeeping,
|
|
195
|
-
what is and isn't isolated across threads, fibers, and Ractors.
|
|
196
|
-
- [Status & limitations](docs-md/status-and-limitations.md) — what the signal
|
|
197
|
-
core already guarantees.
|
|
198
|
-
- [Fragment-level render effects](docs-md/fragment-level-render-effects.md) —
|
|
199
|
-
a deferred design note: collapsing per-fragment partials/components into
|
|
200
|
-
methods, each wrapped in its own effect.
|
|
117
|
+
More detail in plain markdown: [docs-md/](docs-md/)
|
|
201
118
|
|
|
202
119
|
## Development
|
|
203
120
|
|
data/lib/hibiki/effect.rb
CHANGED
|
@@ -39,9 +39,21 @@ module Hibiki
|
|
|
39
39
|
# re-run is handed to it right where it would have happened — after the
|
|
40
40
|
# batch dedup, so the flush's error isolation covers a raising scheduler
|
|
41
41
|
# too, and N batched writes mean one scheduler call.
|
|
42
|
+
#
|
|
43
|
+
# Past the batching? check we are at the flush, which is where the equality
|
|
44
|
+
# gate belongs: every write in the wave has landed, so asking the sources
|
|
45
|
+
# whether anything changed reads a consistent graph (a diamond validates
|
|
46
|
+
# once, against both legs). Nothing changed means nothing to do — not even
|
|
47
|
+
# a scheduler call, so a debounced broadcast never fires for a no-op.
|
|
48
|
+
#
|
|
49
|
+
# own(self) because validation may recompute a derived whose block
|
|
50
|
+
# registers an on_cleanup or creates an effect: this effect's own run is
|
|
51
|
+
# where that would otherwise have landed. Deriveds are values, not owners —
|
|
52
|
+
# a block with side effects was already at the mercy of whoever reads first.
|
|
42
53
|
def invalidate
|
|
43
54
|
return if @disposed
|
|
44
55
|
return Hibiki.schedule(self) if Hibiki.batching?
|
|
56
|
+
return unless Hibiki.own(self) { sources_changed? }
|
|
45
57
|
return @scheduler.call(self) if @scheduler
|
|
46
58
|
|
|
47
59
|
run
|
data/lib/hibiki/tracking.rb
CHANGED
|
@@ -60,12 +60,18 @@ module Hibiki
|
|
|
60
60
|
# Called on every read: if someone reactive is currently computing,
|
|
61
61
|
# they now depend on us — record both directions of the edge, so the
|
|
62
62
|
# observer can sever it before its next rerun.
|
|
63
|
+
#
|
|
64
|
+
# The VALUE travels with the edge: an observer that remembers what it saw
|
|
65
|
+
# can decide later whether anything it reads actually changed (Svelte's
|
|
66
|
+
# $derived compares; see Observer#sources_changed?). `peek` is the
|
|
67
|
+
# documented read-without-an-edge, and we're already clean here — a
|
|
68
|
+
# Derived recomputes before it registers — so it costs a cache read.
|
|
63
69
|
def register_dependency
|
|
64
70
|
observer = Hibiki.current_observer
|
|
65
71
|
return unless observer
|
|
66
72
|
|
|
67
73
|
subscribers << observer
|
|
68
|
-
observer.add_source(self)
|
|
74
|
+
observer.add_source(self, peek)
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
def unsubscribe(observer) = subscribers.delete(observer)
|
|
@@ -77,17 +83,35 @@ module Hibiki
|
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
# ---- shared observer behaviour ----------------------------------------------
|
|
80
|
-
# The reverse edges of Trackable: what an observer read on its last run
|
|
86
|
+
# The reverse edges of Trackable: what an observer read on its last run, and
|
|
87
|
+
# the value each one had when it read it.
|
|
81
88
|
module Observer
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
# source => the value we saw for it. A Hash rather than a Set because the
|
|
90
|
+
# remembered value is what makes the equality gate possible; last read in
|
|
91
|
+
# a run wins, which is the value the run actually acted on.
|
|
92
|
+
def sources = (@sources ||= {})
|
|
93
|
+
def add_source(source, seen) = sources[source] = seen
|
|
84
94
|
|
|
85
95
|
# Solid clears deps before rerun (cleanNode); we mirror that, so stale
|
|
86
96
|
# branches of dynamic deps (flag ? a : b) stop invalidating us.
|
|
87
97
|
def clear_sources
|
|
88
|
-
sources.
|
|
98
|
+
sources.each_key { |source| source.unsubscribe(self) }
|
|
89
99
|
sources.clear
|
|
90
100
|
end
|
|
101
|
+
|
|
102
|
+
# The equality gate, asked at flush time (see Effect#invalidate): did any
|
|
103
|
+
# value we read actually change? Svelte's $derived compares; we compare on
|
|
104
|
+
# the observer's side instead of the producer's, because Derived#invalidate
|
|
105
|
+
# has already notified downstream by the time it knows its new value.
|
|
106
|
+
#
|
|
107
|
+
# `peek` resolves a dirty source without subscribing us to it — no untrack
|
|
108
|
+
# needed, since a Derived#recompute makes itself the observer. `any?`
|
|
109
|
+
# short-circuits in the useful direction: sources are in read order, so a
|
|
110
|
+
# changed first source spares the rest a validation recompute, and the run
|
|
111
|
+
# recomputes them only if it reads them this time.
|
|
112
|
+
def sources_changed?
|
|
113
|
+
sources.any? { |source, seen| source.peek != seen }
|
|
114
|
+
end
|
|
91
115
|
end
|
|
92
116
|
|
|
93
117
|
# ---- shared owner behaviour ---------------------------------------------------
|
data/lib/hibiki/version.rb
CHANGED