hibiki 0.1.0 → 0.3.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: 62668f45023a6d59c07b6f414b0c6ec50a7ff264b5dc9d2b6ad0bedfec9510cc
4
+ data.tar.gz: 9391c0a9b3865c61a0c6cc7f2463e3a377cac91b89820fd3cb3c34dffcc52ddc
5
5
  SHA512:
6
- metadata.gz: c36067422510591e66e4d244d4a27dd1ae0c6c9f6f33ef4e8d82f340a12b5f7ec456c272855ee8c425df9bdb2f98f8062899169b2c97155f976e076aaf8431bc
7
- data.tar.gz: d69a165a500e5355709d98c079f0b026dfe2eea9f09de14b20378513274f02c9f8790021992a8ce9a4e738bd4d5a8feeb89e0e9b3bc3418eb56394edd4579571
6
+ metadata.gz: e425aa81829367bd15d732dd2dd3a2f7fe854199a03df742c4d3e764f0a01e756fc2af81c8d14582630884fc4314d98b3750851554c9da34c0b3b9825da1bf45
7
+ data.tar.gz: dd3e43c2bb9e9f6b399feb2da484913837646ec81bb9d5d55c2f41c31883270e2996d062a59c01497376f10005c49c76c5fa54a2ba0eac683afdea15d33f9a75
data/CHANGELOG.md CHANGED
@@ -7,7 +7,49 @@ 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.3.0] - 2026-08-09
11
+
12
+ ### Added
13
+
14
+ - **`equals:` — a per-signal equality override** on `State` and `Derived`, and
15
+ passed through by the `state`/`derived` helpers in `Hibiki::DSL` and
16
+ `Hibiki::Reactive`. Solid's `createSignal(value, { equals })` shape:
17
+ omitted/`nil` keeps `==`; a callable is a custom comparator, called with
18
+ `(prev, next)`, truthy meaning "unchanged"; `equals: false` means every write
19
+ notifies. The override is honored at both places equality guards the graph —
20
+ the write gate (`State#value=`) and the effect equality gate at the batch
21
+ flush (new `Trackable#changed_from?`, consulted by
22
+ `Observer#sources_changed?`). Signals that don't pass `equals:` behave
23
+ exactly as before.
24
+
25
+ ## [0.2.0] - 2026-07-29
26
+
27
+ ### Changed
28
+
29
+ - **BREAKING: effects re-run only when a value they read actually changed.**
30
+ Every read now remembers the value it saw, and at the batch flush an effect
31
+ compares each of its dependencies against what it last read; if everything
32
+ compares `==`, the effect does not run and its scheduler is not called.
33
+ Svelte's `$derived` compares, and now so does Hibiki — on the reading side,
34
+ because a derived has already notified its subscribers by the time it knows
35
+ its new value. Measured motivation: a write that rippled through a derived to
36
+ a structurally identical value pushed a 159 KB re-render with nothing
37
+ changed anywhere.
38
+
39
+ Two behaviour changes to be aware of:
40
+
41
+ - an effect kept for a side effect *per write* (a heartbeat, a log line, a
42
+ counter) must read the value that genuinely changes rather than a derived
43
+ summary that often doesn't;
44
+ - a derived that returns the same object it mutated compares equal to itself,
45
+ so the gate swallows the update — return a new object. `Effect#run` still
46
+ bypasses the gate entirely.
47
+
48
+ A batch that nets to no change (`batch { a.value = 2; a.value = 1 }`) also
49
+ stops re-running effects. `Derived`'s laziness is unchanged: it still
50
+ recomputes on read, never on write.
51
+
52
+ ## [0.1.0] - 2026-07-18
11
53
 
12
54
  ### Added
13
55
 
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:
@@ -43,7 +46,7 @@ y.value # => 11
43
46
 
44
47
  ### The three primitives
45
48
 
46
- **`state(v)`** — a writable signal. Reading `.value` registers a dependency; writing notifies subscribers. Writing an `==`-equal value is a no-op.
49
+ **`state(v)`** — a writable signal. Reading `.value` registers a dependency; writing notifies subscribers. Writing an `==`-equal value is a no-op — overridable per signal with `equals:`; see [Custom equality](https://planetaska.github.io/hibiki/advanced-usage/#custom-equality-equals).
47
50
 
48
51
  ```ruby
49
52
  counter = state(0)
@@ -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,15 @@ 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](https://planetaska.github.io/hibiki/mutable-defaults/) 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
+ Full documentation: <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
+ - [Guide](https://planetaska.github.io/hibiki/introduction/) — getting started, class-based reactivity, advanced usage
118
+ - [Rails](https://planetaska.github.io/hibiki/rails-introduction/) the `hibiki_rails` and `hibiki_phlex` integration, generators, CRUD scaffolding
119
+ - [Reference](https://planetaska.github.io/hibiki/threading-model/) — threading model, lifecycle in detail, status and limitations
201
120
 
202
121
  ## Development
203
122
 
@@ -6,8 +6,12 @@ module Hibiki
6
6
  include Trackable # observed by downstream deriveds/effects
7
7
  include Observer # observes its own dependencies
8
8
 
9
- def initialize(&block)
9
+ # equals: per-signal equality (Solid's createMemo takes it too). A derived
10
+ # has no write gate, so it matters only at the flush gate — observers ask
11
+ # changed_from?, which consults it (see Trackable).
12
+ def initialize(equals: nil, &block)
10
13
  @block = block
14
+ @equals = equals
11
15
  @dirty = true
12
16
  end
13
17
 
data/lib/hibiki/dsl.rb CHANGED
@@ -5,8 +5,8 @@ module Hibiki
5
5
  # Opt-in: `include Hibiki::DSL` where you want the bare helpers.
6
6
  # The gem never includes it for you (no polluting Object/main).
7
7
  module DSL
8
- def state(value) = State.new(value)
9
- def derived(&) = Derived.new(&)
8
+ def state(value, equals: nil) = State.new(value, equals:)
9
+ def derived(equals: nil, &) = Derived.new(equals:, &)
10
10
  def effect(scheduler: nil, &) = Effect.new(scheduler:, &)
11
11
  def batch(&) = Hibiki.batch(&)
12
12
  def root(&) = Hibiki.root(&)
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
@@ -34,16 +34,16 @@ module Hibiki
34
34
  # instance, instance_exec'd, and untracked: first touch may happen
35
35
  # inside some effect's tracking window, and a default that reads other
36
36
  # signals must not subscribe that outer observer.
37
- def state(name, default = nil, &default_block)
37
+ def state(name, default = nil, equals: nil, &default_block)
38
38
  init = proc do
39
- State.new(default_block ? Hibiki.untrack { instance_exec(&default_block) } : default)
39
+ State.new(default_block ? Hibiki.untrack { instance_exec(&default_block) } : default, equals:)
40
40
  end
41
41
  define_method(name) { __hibiki_signal(name, init).value }
42
42
  define_method(:"#{name}=") { |new_value| __hibiki_signal(name, init).value = new_value }
43
43
  end
44
44
 
45
- def derived(name, &)
46
- init = proc { Derived.new { instance_exec(&) } }
45
+ def derived(name, equals: nil, &)
46
+ init = proc { Derived.new(equals:) { instance_exec(&) } }
47
47
  define_method(name) { __hibiki_signal(name, init).value }
48
48
  end
49
49
 
data/lib/hibiki/state.rb CHANGED
@@ -5,8 +5,11 @@ module Hibiki
5
5
  class State
6
6
  include Trackable
7
7
 
8
- def initialize(value)
8
+ # equals: per-signal equality (Solid's createSignal(value, { equals })).
9
+ # nil → `==`; false → always notify; callable → comparator(prev, next).
10
+ def initialize(value, equals: nil)
9
11
  @value = value
12
+ @equals = equals
10
13
  end
11
14
 
12
15
  def value
@@ -22,7 +25,14 @@ module Hibiki
22
25
  def call = value
23
26
 
24
27
  def value=(new_value)
25
- return if new_value == @value
28
+ # The write gate: the signal's equality decides whether this write is a
29
+ # no-op. Its twin is the flush gate, Trackable#changed_from? — keep them
30
+ # answering the same way.
31
+ case @equals
32
+ when nil then return if new_value == @value
33
+ when false then nil # always notify
34
+ else return if @equals.call(@value, new_value)
35
+ end
26
36
 
27
37
  @value = new_value
28
38
  # Solid wraps every write in runUpdates; mirroring that, an unbatched
@@ -60,16 +60,36 @@ 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)
72
78
 
79
+ # The flush gate's question, answered by the source so its own equality
80
+ # decides (Solid's `equals` option, on createSignal and createMemo alike):
81
+ # nil → `==` as always, false → never equal (every write notifies),
82
+ # callable → comparator(prev, next). Both gates consult the same @equals —
83
+ # a comparator honored on write but not at flush would let the batch
84
+ # flush silently swallow the very change the write announced.
85
+ def changed_from?(seen)
86
+ case @equals
87
+ when nil then peek != seen
88
+ when false then true
89
+ else !@equals.call(seen, peek)
90
+ end
91
+ end
92
+
73
93
  def notify
74
94
  # dup: invalidation may mutate the set while we iterate
75
95
  subscribers.dup.each(&:invalidate)
@@ -77,17 +97,37 @@ module Hibiki
77
97
  end
78
98
 
79
99
  # ---- shared observer behaviour ----------------------------------------------
80
- # The reverse edges of Trackable: what an observer read on its last run.
100
+ # The reverse edges of Trackable: what an observer read on its last run, and
101
+ # the value each one had when it read it.
81
102
  module Observer
82
- def sources = (@sources ||= Set.new)
83
- def add_source(source) = sources << source
103
+ # source => the value we saw for it. A Hash rather than a Set because the
104
+ # remembered value is what makes the equality gate possible; last read in
105
+ # a run wins, which is the value the run actually acted on.
106
+ def sources = (@sources ||= {})
107
+ def add_source(source, seen) = sources[source] = seen
84
108
 
85
109
  # Solid clears deps before rerun (cleanNode); we mirror that, so stale
86
110
  # branches of dynamic deps (flag ? a : b) stop invalidating us.
87
111
  def clear_sources
88
- sources.each { |source| source.unsubscribe(self) }
112
+ sources.each_key { |source| source.unsubscribe(self) }
89
113
  sources.clear
90
114
  end
115
+
116
+ # The equality gate, asked at flush time (see Effect#invalidate): did any
117
+ # value we read actually change? Svelte's $derived compares; we compare on
118
+ # the observer's side instead of the producer's, because Derived#invalidate
119
+ # has already notified downstream by the time it knows its new value. The
120
+ # comparison itself is delegated to each source (changed_from?), so a
121
+ # per-signal `equals:` is honored here too.
122
+ #
123
+ # changed_from?'s `peek` resolves a dirty source without subscribing us to
124
+ # it — no untrack needed, since a Derived#recompute makes itself the
125
+ # observer. `any?` short-circuits in the useful direction: sources are in
126
+ # read order, so a changed first source spares the rest a validation
127
+ # recompute, and the run recomputes them only if it reads them this time.
128
+ def sources_changed?
129
+ sources.any? { |source, seen| source.changed_from?(seen) }
130
+ end
91
131
  end
92
132
 
93
133
  # ---- 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.3.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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska