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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68d12a0c95c1bc55b68b6fe1377a2ca6bcd9bf9116a2ced78de0d6748dd40abb
4
- data.tar.gz: 5288e41ed33db0976d15b17bac1aac827344207841921467b25c0e9eef76da8d
3
+ metadata.gz: d473629248429556ab9410edc059933072d1a6c6f462fd9fa225c25684d45c79
4
+ data.tar.gz: 025ac97891ad5933eecd82201323f3c329e3dedc392f6407e6004175ee7c316a
5
5
  SHA512:
6
- metadata.gz: c36067422510591e66e4d244d4a27dd1ae0c6c9f6f33ef4e8d82f340a12b5f7ec456c272855ee8c425df9bdb2f98f8062899169b2c97155f976e076aaf8431bc
7
- data.tar.gz: d69a165a500e5355709d98c079f0b026dfe2eea9f09de14b20378513274f02c9f8790021992a8ce9a4e738bd4d5a8feeb89e0e9b3bc3418eb56394edd4579571
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.1.0] - TBD
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`/`$derived`/`$effect` as class fields; `Hibiki::Reactive`
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
- Browsable documentation site: <https://planetaska.github.io/hibiki/>
189
-
190
- More detail in [docs-md/](docs-md/):
115
+ Documentation site: <https://planetaska.github.io/hibiki/>
191
116
 
192
- - [Why no transparent signals?](docs-md/why-no-transparent-signals.md) — the two
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
@@ -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
- def sources = (@sources ||= Set.new)
83
- def add_source(source) = sources << source
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.each { |source| source.unsubscribe(self) }
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 ---------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hibiki
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska