hibiki 0.1.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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/lib/hibiki/batch.rb +84 -0
- data/lib/hibiki/derived.rb +48 -0
- data/lib/hibiki/dsl.rb +15 -0
- data/lib/hibiki/effect.rb +65 -0
- data/lib/hibiki/reactive.rb +84 -0
- data/lib/hibiki/root.rb +46 -0
- data/lib/hibiki/state.rb +41 -0
- data/lib/hibiki/tracking.rb +122 -0
- data/lib/hibiki/version.rb +5 -0
- data/lib/hibiki.rb +27 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 68d12a0c95c1bc55b68b6fe1377a2ca6bcd9bf9116a2ced78de0d6748dd40abb
|
|
4
|
+
data.tar.gz: 5288e41ed33db0976d15b17bac1aac827344207841921467b25c0e9eef76da8d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c36067422510591e66e4d244d4a27dd1ae0c6c9f6f33ef4e8d82f340a12b5f7ec456c272855ee8c425df9bdb2f98f8062899169b2c97155f976e076aaf8431bc
|
|
7
|
+
data.tar.gz: d69a165a500e5355709d98c079f0b026dfe2eea9f09de14b20378513274f02c9f8790021992a8ce9a4e738bd4d5a8feeb89e0e9b3bc3418eb56394edd4579571
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - TBD
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial signal core: `Hibiki::State` (writable signal), `Hibiki::Derived`
|
|
15
|
+
(lazy computed signal with runtime dependency tracking), `Hibiki::Effect`
|
|
16
|
+
(eager side effects).
|
|
17
|
+
- Opt-in `Hibiki::DSL` providing bare `state` / `derived` / `effect` helpers.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 planetaska
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Hibiki (響き) — Svelte-5-style signals for Ruby
|
|
2
|
+
|
|
3
|
+
Svelte-5-style signals for Ruby: `state`, `derived`, `effect`.
|
|
4
|
+
|
|
5
|
+
Hibiki is a fine-grained reactivity library modeled on the signal systems in [Svelte 5](https://svelte.dev/docs/svelte/what-are-runes) and [SolidJS](https://www.solidjs.com/guides/reactivity). Dependency tracking is done at **runtime** (not by static AST analysis): while a derived value or effect is computing, it sits on an observer stack, and any signal read during that window subscribes it.
|
|
6
|
+
|
|
7
|
+
No runtime dependencies. Requires Ruby >= 3.4.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "hibiki"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or `gem install hibiki`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
You can use Hibiki in two flavors:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# 1. With DSL
|
|
24
|
+
require "hibiki"
|
|
25
|
+
include Hibiki::DSL
|
|
26
|
+
|
|
27
|
+
x = state(0)
|
|
28
|
+
y = derived { x.value + 1 }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or if you wish to avoid using DSL:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# 2. Without DSL
|
|
35
|
+
require "hibiki"
|
|
36
|
+
|
|
37
|
+
x = Hibiki::State.new(0)
|
|
38
|
+
y = Hibiki::Derived.new { x.value + 1 }
|
|
39
|
+
|
|
40
|
+
x.value = 10
|
|
41
|
+
y.value # => 11
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### The three primitives
|
|
45
|
+
|
|
46
|
+
**`state(v)`** — a writable signal. Reading `.value` registers a dependency; writing notifies subscribers. Writing an `==`-equal value is a no-op.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
counter = state(0)
|
|
50
|
+
counter.value += 1
|
|
51
|
+
counter.update { it + 1 } # in-place sugar
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**`derived { }`** — a lazy computed signal. It recomputes on read when marked dirty, never on write, and caches its value until a dependency changes.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
doubled = derived { counter.value * 2 }
|
|
58
|
+
doubled.value # => 4
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**`effect { }`** — an eager side effect. It runs immediately and re-runs whenever a dependency changes.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
name = state("world")
|
|
65
|
+
effect { puts "hello, #{name.value}!" } # prints "hello, world!"
|
|
66
|
+
|
|
67
|
+
# Effect triggers whenever any of the subscribed values changes
|
|
68
|
+
name.value = "Ruby" # prints "hello, Ruby!"
|
|
69
|
+
```
|
|
70
|
+
|
|
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
|
+
### Dynamic dependencies
|
|
92
|
+
|
|
93
|
+
Dependencies are re-collected on every recompute, so conditional reads work:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
flag = state(true)
|
|
97
|
+
a = state("A")
|
|
98
|
+
b = state("B")
|
|
99
|
+
picked = derived { flag.value ? a.value : b.value }
|
|
100
|
+
|
|
101
|
+
picked.value # => "A"
|
|
102
|
+
b.value = "B2" # picked doesn't depend on b right now — no recompute
|
|
103
|
+
flag.value = false
|
|
104
|
+
picked.value # => "B2" (deps re-collected)
|
|
105
|
+
```
|
|
106
|
+
|
|
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
|
+
### Class-based reactivity
|
|
139
|
+
|
|
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:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class Counter
|
|
146
|
+
include Hibiki::Reactive
|
|
147
|
+
|
|
148
|
+
state :count, 0
|
|
149
|
+
state(:history) { [] } # block form: fresh default per instance
|
|
150
|
+
derived(:doubled) { count * 2 }
|
|
151
|
+
effect { puts "count is now #{count}" } # starts on Counter.new
|
|
152
|
+
|
|
153
|
+
def increment = self.count += 1
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
counter = Counter.new # prints "count is now 0"
|
|
157
|
+
counter.increment # prints "count is now 1"
|
|
158
|
+
counter.doubled # => 2
|
|
159
|
+
```
|
|
160
|
+
|
|
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).
|
|
185
|
+
|
|
186
|
+
## Documentation
|
|
187
|
+
|
|
188
|
+
Browsable documentation site: <https://planetaska.github.io/hibiki/>
|
|
189
|
+
|
|
190
|
+
More detail in [docs-md/](docs-md/):
|
|
191
|
+
|
|
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.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
bundle install
|
|
206
|
+
bundle exec rake # specs + rubocop
|
|
207
|
+
ruby demo.rb # quick smoke demo
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
[MIT](LICENSE.txt)
|
data/lib/hibiki/batch.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- batching ---------------------------------------------------------------
|
|
5
|
+
# Coalesces effect runs. Writes inside the block apply immediately — reads
|
|
6
|
+
# always see fresh values, and deriveds stay lazy dirty-flags — but effects
|
|
7
|
+
# invalidated during the block are queued, deduplicated, and run once when
|
|
8
|
+
# the outermost batch exits. Same semantics as Solid's `batch`.
|
|
9
|
+
#
|
|
10
|
+
# Depth and queue are per-execution-context in Thread.current[] — which is
|
|
11
|
+
# fiber-local, not thread-local, despite the name. Deliberately NOT Fiber[]
|
|
12
|
+
# (unlike the observer in tracking.rb): Fiber storage is inherited at fiber
|
|
13
|
+
# creation, and a fiber or thread spawned mid-batch would inherit a nonzero
|
|
14
|
+
# depth it can never unwind — batching? true forever, its effects deferred
|
|
15
|
+
# into a queue nobody flushes. The flush belongs to the exact fiber whose
|
|
16
|
+
# ensure runs it.
|
|
17
|
+
class << self
|
|
18
|
+
def batch
|
|
19
|
+
Thread.current[:hibiki_batch_depth] = batch_depth + 1
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
# Flush even when the block raises: writes before the raise have
|
|
23
|
+
# already landed, so effects must catch up with them.
|
|
24
|
+
depth = batch_depth - 1
|
|
25
|
+
Thread.current[:hibiki_batch_depth] = depth
|
|
26
|
+
flush_effects if depth.zero?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def batching? = batch_depth.positive?
|
|
30
|
+
|
|
31
|
+
def schedule(effect) = (Thread.current[:hibiki_pending_effects] ||= Set.new) << effect
|
|
32
|
+
|
|
33
|
+
# Where effect errors raised during a flush go (Solid's handleError):
|
|
34
|
+
# a callable receiving (error, effect). With a handler set the flush
|
|
35
|
+
# never raises; a raising handler propagates — we don't swallow it.
|
|
36
|
+
# Ractor-local, not a module ivar (IsolationError off the main Ractor)
|
|
37
|
+
# and not Thread.current[] (configuration set once, e.g. in an
|
|
38
|
+
# initializer, must be visible to threads spawned later). Per-Ractor
|
|
39
|
+
# fits the one-independent-world-per-Ractor model.
|
|
40
|
+
def error_handler = Ractor.current[:hibiki_error_handler]
|
|
41
|
+
|
|
42
|
+
def error_handler=(handler)
|
|
43
|
+
Ractor.current[:hibiki_error_handler] = handler
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def batch_depth = Thread.current[:hibiki_batch_depth] || 0
|
|
49
|
+
|
|
50
|
+
# Swap the queue out before running: an effect may write states and
|
|
51
|
+
# invalidate further effects, and with the batch over those run eagerly.
|
|
52
|
+
#
|
|
53
|
+
# A raising effect must not take the rest of the queue down with it
|
|
54
|
+
# (Solid's runUpdates completes the queue and routes errors to a
|
|
55
|
+
# handler): rescue per effect, finish the flush, then re-raise the
|
|
56
|
+
# first error unless an error_handler took it. StandardError only —
|
|
57
|
+
# Interrupt and friends should still abort the flush.
|
|
58
|
+
def flush_effects
|
|
59
|
+
pending = Thread.current[:hibiki_pending_effects]
|
|
60
|
+
return if pending.nil? || pending.empty?
|
|
61
|
+
|
|
62
|
+
Thread.current[:hibiki_pending_effects] = Set.new
|
|
63
|
+
first_error = nil
|
|
64
|
+
pending.each do |effect|
|
|
65
|
+
error = invalidate_isolated(effect)
|
|
66
|
+
first_error ||= error
|
|
67
|
+
end
|
|
68
|
+
raise first_error if first_error
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# One queue entry: run it, and route or return its error instead of
|
|
72
|
+
# raising, so the flush loop always reaches every pending effect.
|
|
73
|
+
def invalidate_isolated(effect)
|
|
74
|
+
effect.invalidate
|
|
75
|
+
nil
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
handler = error_handler
|
|
78
|
+
return e unless handler
|
|
79
|
+
|
|
80
|
+
handler.call(e, effect)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- derived / computed -----------------------------------------------------
|
|
5
|
+
class Derived
|
|
6
|
+
include Trackable # observed by downstream deriveds/effects
|
|
7
|
+
include Observer # observes its own dependencies
|
|
8
|
+
|
|
9
|
+
def initialize(&block)
|
|
10
|
+
@block = block
|
|
11
|
+
@dirty = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def value
|
|
15
|
+
recompute if @dirty
|
|
16
|
+
register_dependency
|
|
17
|
+
@value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read without subscribing the reader. A dirty derived still recomputes
|
|
21
|
+
# (collecting its own deps as usual) — peek only skips the outward edge.
|
|
22
|
+
def peek
|
|
23
|
+
recompute if @dirty
|
|
24
|
+
@value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Solid signals are getter functions; `sig.()` reads (and registers).
|
|
28
|
+
def call = value
|
|
29
|
+
|
|
30
|
+
def invalidate
|
|
31
|
+
return if @dirty
|
|
32
|
+
|
|
33
|
+
@dirty = true
|
|
34
|
+
notify # propagate dirtiness downstream (derived-of-derived, effects)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_s = value.to_s
|
|
38
|
+
def inspect = "#<Hibiki::Derived #{@dirty ? 'dirty' : @value.inspect}>"
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def recompute
|
|
43
|
+
clear_sources
|
|
44
|
+
@value = Hibiki.track(self) { @block.call }
|
|
45
|
+
@dirty = false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/hibiki/dsl.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- top-level DSL ----------------------------------------------------------
|
|
5
|
+
# Opt-in: `include Hibiki::DSL` where you want the bare helpers.
|
|
6
|
+
# The gem never includes it for you (no polluting Object/main).
|
|
7
|
+
module DSL
|
|
8
|
+
def state(value) = State.new(value)
|
|
9
|
+
def derived(&) = Derived.new(&)
|
|
10
|
+
def effect(scheduler: nil, &) = Effect.new(scheduler:, &)
|
|
11
|
+
def batch(&) = Hibiki.batch(&)
|
|
12
|
+
def root(&) = Hibiki.root(&)
|
|
13
|
+
def on_cleanup(&) = Hibiki.on_cleanup(&)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- effect -----------------------------------------------------------------
|
|
5
|
+
class Effect
|
|
6
|
+
include Observer # observes, but is never observed (no Trackable)
|
|
7
|
+
include Owner # owns effects and cleanups registered while running
|
|
8
|
+
|
|
9
|
+
# scheduler: hands re-runs to the caller instead of running inline
|
|
10
|
+
# (Vue's ReactiveEffect scheduler): a callable receiving the effect,
|
|
11
|
+
# which calls #run whenever it's ready — on the graph's own execution
|
|
12
|
+
# context. The initial run is never scheduled: dependency collection
|
|
13
|
+
# must happen at creation, and Vue's constructor runs directly too.
|
|
14
|
+
def initialize(scheduler: nil, &block)
|
|
15
|
+
@block = block
|
|
16
|
+
@scheduler = scheduler
|
|
17
|
+
@disposed = false
|
|
18
|
+
# Effects created while another effect (or root) runs are owned by it
|
|
19
|
+
# and disposed when the owner re-runs or is disposed.
|
|
20
|
+
Hibiki.current_owner&.adopt(self)
|
|
21
|
+
run
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def disposed? = @disposed
|
|
25
|
+
|
|
26
|
+
# Sever every subscription and take owned children/cleanups down with
|
|
27
|
+
# us. A disposed effect never runs again — including a pending batch
|
|
28
|
+
# flush.
|
|
29
|
+
def dispose
|
|
30
|
+
return if @disposed
|
|
31
|
+
|
|
32
|
+
@disposed = true
|
|
33
|
+
dispose_owned
|
|
34
|
+
clear_sources
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Under a batch, defer: Hibiki queues us (deduplicated) and re-runs the
|
|
38
|
+
# block once at flush instead of once per write. With a scheduler, the
|
|
39
|
+
# re-run is handed to it right where it would have happened — after the
|
|
40
|
+
# batch dedup, so the flush's error isolation covers a raising scheduler
|
|
41
|
+
# too, and N batched writes mean one scheduler call.
|
|
42
|
+
def invalidate
|
|
43
|
+
return if @disposed
|
|
44
|
+
return Hibiki.schedule(self) if Hibiki.batching?
|
|
45
|
+
return @scheduler.call(self) if @scheduler
|
|
46
|
+
|
|
47
|
+
run
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Public for scheduled effects: the scheduler (or whoever it handed us
|
|
51
|
+
# to) calls this when it's time to re-run. No-op once disposed — a
|
|
52
|
+
# debounced run firing late must lose to dispose, like a pending flush
|
|
53
|
+
# does. A raise propagates to the caller: a deferred run is outside any
|
|
54
|
+
# flush, so the integration owns rescue there.
|
|
55
|
+
def run
|
|
56
|
+
return if @disposed
|
|
57
|
+
|
|
58
|
+
dispose_owned
|
|
59
|
+
clear_sources
|
|
60
|
+
Hibiki.own(self) do
|
|
61
|
+
Hibiki.track(self) { @block.call }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- class-level reactivity -------------------------------------------------
|
|
5
|
+
# Svelte 5 allows $state/$derived/$effect as class fields; Reactive is the
|
|
6
|
+
# Ruby analogue — declare signals with class macros, use them as plain
|
|
7
|
+
# attributes:
|
|
8
|
+
#
|
|
9
|
+
# class Counter
|
|
10
|
+
# include Hibiki::Reactive
|
|
11
|
+
# state :count, 0
|
|
12
|
+
# derived(:doubled) { count * 2 }
|
|
13
|
+
# effect { puts "count is now #{count}" } # starts on initialize
|
|
14
|
+
#
|
|
15
|
+
# def increment = self.count += 1
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Readers and writers are ordinary methods over per-instance signals, so
|
|
19
|
+
# usage sites never touch `.value` and dependency tracking flows through
|
|
20
|
+
# plain method calls. Signals are created lazily on first touch (read or
|
|
21
|
+
# write): no initialize hook is needed for state/derived, and subclasses
|
|
22
|
+
# inherit declarations because the generated methods inherit.
|
|
23
|
+
module Reactive
|
|
24
|
+
def self.included(base)
|
|
25
|
+
base.extend(ClassMethods)
|
|
26
|
+
base.prepend(Initializer)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module ClassMethods
|
|
30
|
+
# state :count, 0
|
|
31
|
+
# state(:items) { [] }
|
|
32
|
+
# A positional default is shared across instances (it's one object) —
|
|
33
|
+
# use the block form for mutable defaults. The block is evaluated per
|
|
34
|
+
# instance, instance_exec'd, and untracked: first touch may happen
|
|
35
|
+
# inside some effect's tracking window, and a default that reads other
|
|
36
|
+
# signals must not subscribe that outer observer.
|
|
37
|
+
def state(name, default = nil, &default_block)
|
|
38
|
+
init = proc do
|
|
39
|
+
State.new(default_block ? Hibiki.untrack { instance_exec(&default_block) } : default)
|
|
40
|
+
end
|
|
41
|
+
define_method(name) { __hibiki_signal(name, init).value }
|
|
42
|
+
define_method(:"#{name}=") { |new_value| __hibiki_signal(name, init).value = new_value }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def derived(name, &)
|
|
46
|
+
init = proc { Derived.new { instance_exec(&) } }
|
|
47
|
+
define_method(name) { __hibiki_signal(name, init).value }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Anonymous, so unlike state/derived it can't ride on method
|
|
51
|
+
# inheritance: blocks are collected per class and gathered up the
|
|
52
|
+
# ancestor chain at initialize.
|
|
53
|
+
def effect(&block) = hibiki_effect_blocks << block
|
|
54
|
+
|
|
55
|
+
def hibiki_effect_blocks = (@hibiki_effect_blocks ||= [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Prepended so declared effects start after the user's initialize ran
|
|
59
|
+
# (they usually read state the constructor is expected to have set up).
|
|
60
|
+
module Initializer
|
|
61
|
+
def initialize(...)
|
|
62
|
+
super
|
|
63
|
+
blocks = self.class.ancestors.reverse.flat_map do |mod|
|
|
64
|
+
mod.respond_to?(:hibiki_effect_blocks) ? mod.hibiki_effect_blocks : []
|
|
65
|
+
end
|
|
66
|
+
@__hibiki_effects = blocks.map { |block| Effect.new { instance_exec(&block) } }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Dispose every effect this instance started. Instances created inside a
|
|
71
|
+
# running effect don't need this — Effect.new adopts them into the owner
|
|
72
|
+
# tree and the owner's rerun/dispose takes them down. Explicitly needed
|
|
73
|
+
# only for long-lived instances whose effects read signals *outside* the
|
|
74
|
+
# instance (effects reading only the instance's own signals form a
|
|
75
|
+
# self-contained island that garbage-collects with it).
|
|
76
|
+
def dispose = @__hibiki_effects.each(&:dispose)
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def __hibiki_signal(name, init)
|
|
81
|
+
(@__hibiki_signals ||= {})[name] ||= instance_exec(&init)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/hibiki/root.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- root -------------------------------------------------------------------
|
|
5
|
+
# Solid's createRoot: an ownership scope that is not an effect. Effects
|
|
6
|
+
# and cleanups created inside the block belong to the root and live until
|
|
7
|
+
# #dispose — the lifecycle anchor for long-lived graphs (a session, a
|
|
8
|
+
# connection) whose teardown is an external event, not a rerun.
|
|
9
|
+
#
|
|
10
|
+
# Deliberately detached: a root created inside a running effect is NOT
|
|
11
|
+
# adopted, so the enclosing effect's rerun/disposal leaves it alone
|
|
12
|
+
# (Solid's escape hatch from the owner tree). Whoever holds the root
|
|
13
|
+
# disposes it.
|
|
14
|
+
#
|
|
15
|
+
# The block runs untracked (Solid clears the Listener in createRoot): a
|
|
16
|
+
# root built mid-effect must not subscribe that effect to signals it
|
|
17
|
+
# reads.
|
|
18
|
+
class Root
|
|
19
|
+
include Owner
|
|
20
|
+
|
|
21
|
+
def initialize(&block)
|
|
22
|
+
@disposed = false
|
|
23
|
+
Hibiki.own(self) do
|
|
24
|
+
Hibiki.untrack { block.call(self) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def disposed? = @disposed
|
|
29
|
+
|
|
30
|
+
def dispose
|
|
31
|
+
return if @disposed
|
|
32
|
+
|
|
33
|
+
@disposed = true
|
|
34
|
+
dispose_owned
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Hibiki.root { |root| ... } — build a graph inside an ownership scope;
|
|
40
|
+
# tear the whole thing down later with root.dispose. Unlike Solid
|
|
41
|
+
# (which returns the block's result and yields a dispose function) the
|
|
42
|
+
# root itself is yielded AND returned: in Ruby the caller, not the
|
|
43
|
+
# block, typically owns teardown.
|
|
44
|
+
def root(&) = Root.new(&)
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/hibiki/state.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- writable signal --------------------------------------------------------
|
|
5
|
+
class State
|
|
6
|
+
include Trackable
|
|
7
|
+
|
|
8
|
+
def initialize(value)
|
|
9
|
+
@value = value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def value
|
|
13
|
+
register_dependency
|
|
14
|
+
@value
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Read without subscribing (per-signal untrack), for read-modify-write
|
|
18
|
+
# effects that must not depend on what they write.
|
|
19
|
+
def peek = @value
|
|
20
|
+
|
|
21
|
+
# Solid signals are getter functions; `sig.()` reads (and registers).
|
|
22
|
+
def call = value
|
|
23
|
+
|
|
24
|
+
def value=(new_value)
|
|
25
|
+
return if new_value == @value
|
|
26
|
+
|
|
27
|
+
@value = new_value
|
|
28
|
+
# Solid wraps every write in runUpdates; mirroring that, an unbatched
|
|
29
|
+
# write becomes an implicit batch of one. The whole invalidation wave
|
|
30
|
+
# (all diamond branches) lands before effects flush, so an effect runs
|
|
31
|
+
# once per write and never sees a half-updated graph.
|
|
32
|
+
Hibiki.batch { notify }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# sugar for in-place updates: counter.update { it + 1 }
|
|
36
|
+
def update = self.value = yield(@value)
|
|
37
|
+
|
|
38
|
+
def to_s = value.to_s
|
|
39
|
+
def inspect = "#<Hibiki::State #{@value.inspect}>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
# ---- observer / owner context (the heart of runtime dependency tracking) --
|
|
5
|
+
# Per-execution-context, in Fiber storage (Fiber[], Ruby 3.2+), not module
|
|
6
|
+
# ivars: concurrent contexts must not see each other's tracking windows, and
|
|
7
|
+
# module ivars raise Ractor::IsolationError off the main Ractor. Fiber[] over
|
|
8
|
+
# Thread.current[] (which is fiber-local too, despite the name) because it is
|
|
9
|
+
# inherited at fiber creation — reads inside an Enumerator's internal fiber
|
|
10
|
+
# still register, where uninherited storage would silently drop the edge.
|
|
11
|
+
# The explicit stacks are gone: save/restore around the block makes the call
|
|
12
|
+
# stack the stack.
|
|
13
|
+
class << self
|
|
14
|
+
def current_observer = Fiber[:hibiki_observer]
|
|
15
|
+
|
|
16
|
+
def track(observer)
|
|
17
|
+
prev = Fiber[:hibiki_observer]
|
|
18
|
+
Fiber[:hibiki_observer] = observer
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
Fiber[:hibiki_observer] = prev
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Solid's untrack: reads inside the block register nothing. Only the
|
|
25
|
+
# listener is suppressed — the owner slot is left alone, so effects
|
|
26
|
+
# created under untrack are still adopted.
|
|
27
|
+
def untrack(&) = track(nil, &)
|
|
28
|
+
|
|
29
|
+
# Solid keeps Owner separate from Listener: only effects own, and a lazy
|
|
30
|
+
# derived computing mid-effect must not steal ownership of effects its
|
|
31
|
+
# block creates. Hence a second slot rather than reusing the observer's.
|
|
32
|
+
def current_owner = Fiber[:hibiki_owner]
|
|
33
|
+
|
|
34
|
+
def own(owner)
|
|
35
|
+
prev = Fiber[:hibiki_owner]
|
|
36
|
+
Fiber[:hibiki_owner] = owner
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
Fiber[:hibiki_owner] = prev
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Solid's onCleanup: registers on the current OWNER, not the listener —
|
|
43
|
+
# a lazy derived computing mid-effect registers cleanups on the effect,
|
|
44
|
+
# matching how effects created there are adopted. The block runs before
|
|
45
|
+
# the owner's next rerun and on dispose. Outside any owner it can never
|
|
46
|
+
# run; mirror Solid and warn rather than raise.
|
|
47
|
+
def on_cleanup(&block)
|
|
48
|
+
owner = current_owner
|
|
49
|
+
return warn("Hibiki.on_cleanup: no current owner (effect or root); the cleanup can never run") unless owner
|
|
50
|
+
|
|
51
|
+
owner.add_cleanup(block)
|
|
52
|
+
block
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ---- shared subscription behaviour -----------------------------------------
|
|
57
|
+
module Trackable
|
|
58
|
+
def subscribers = (@subscribers ||= Set.new)
|
|
59
|
+
|
|
60
|
+
# Called on every read: if someone reactive is currently computing,
|
|
61
|
+
# they now depend on us — record both directions of the edge, so the
|
|
62
|
+
# observer can sever it before its next rerun.
|
|
63
|
+
def register_dependency
|
|
64
|
+
observer = Hibiki.current_observer
|
|
65
|
+
return unless observer
|
|
66
|
+
|
|
67
|
+
subscribers << observer
|
|
68
|
+
observer.add_source(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def unsubscribe(observer) = subscribers.delete(observer)
|
|
72
|
+
|
|
73
|
+
def notify
|
|
74
|
+
# dup: invalidation may mutate the set while we iterate
|
|
75
|
+
subscribers.dup.each(&:invalidate)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ---- shared observer behaviour ----------------------------------------------
|
|
80
|
+
# The reverse edges of Trackable: what an observer read on its last run.
|
|
81
|
+
module Observer
|
|
82
|
+
def sources = (@sources ||= Set.new)
|
|
83
|
+
def add_source(source) = sources << source
|
|
84
|
+
|
|
85
|
+
# Solid clears deps before rerun (cleanNode); we mirror that, so stale
|
|
86
|
+
# branches of dynamic deps (flag ? a : b) stop invalidating us.
|
|
87
|
+
def clear_sources
|
|
88
|
+
sources.each { |source| source.unsubscribe(self) }
|
|
89
|
+
sources.clear
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ---- shared owner behaviour ---------------------------------------------------
|
|
94
|
+
# Solid's Owner half of a computation: effects and roots own the child
|
|
95
|
+
# effects and cleanups registered while their block runs, and tear them
|
|
96
|
+
# down together on rerun/dispose.
|
|
97
|
+
module Owner
|
|
98
|
+
# Effects created while our block runs become ours: when we re-run or
|
|
99
|
+
# are disposed, they are disposed too (Solid's owner tree). Otherwise a
|
|
100
|
+
# re-creating rerun would leak live duplicates.
|
|
101
|
+
def adopt(child) = (@children ||= []) << child
|
|
102
|
+
|
|
103
|
+
def add_cleanup(block) = (@cleanups ||= []) << block
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Solid's cleanNode: children go down before our own cleanups run (a
|
|
108
|
+
# child's cleanup may still need a resource ours tears down), cleanups
|
|
109
|
+
# in LIFO order like nested ensure blocks. Both lists are swapped out
|
|
110
|
+
# first, so a rerun adopts fresh entries into a clean slate while the
|
|
111
|
+
# previous generation is being torn down.
|
|
112
|
+
def dispose_owned
|
|
113
|
+
children = @children || []
|
|
114
|
+
@children = []
|
|
115
|
+
children.each(&:dispose)
|
|
116
|
+
|
|
117
|
+
cleanups = @cleanups || []
|
|
118
|
+
@cleanups = []
|
|
119
|
+
cleanups.reverse_each(&:call)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/hibiki.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hibiki — a minimal Svelte-5-style signal system in Ruby.
|
|
4
|
+
#
|
|
5
|
+
# Three primitives, same as Solid/Svelte 5 internals:
|
|
6
|
+
# state(v) -> writable signal
|
|
7
|
+
# derived { } -> lazy computed signal with runtime dependency tracking
|
|
8
|
+
# effect { } -> side effect that re-runs when its dependencies change
|
|
9
|
+
#
|
|
10
|
+
# Plus `batch { }`, which coalesces effect runs across multiple writes, and
|
|
11
|
+
# lifecycle scoping: `root { }` (an ownership scope disposed by its holder)
|
|
12
|
+
# and `on_cleanup { }` (per-run teardown on the owning effect or root).
|
|
13
|
+
#
|
|
14
|
+
# Dependency tracking works exactly like the JS frameworks: while a derived
|
|
15
|
+
# or effect is (re)computing, it is pushed onto an "observer stack". Any
|
|
16
|
+
# signal whose value is READ during that window registers the observer as a
|
|
17
|
+
# subscriber. Writes then invalidate subscribers transitively.
|
|
18
|
+
|
|
19
|
+
require_relative "hibiki/version"
|
|
20
|
+
require_relative "hibiki/tracking"
|
|
21
|
+
require_relative "hibiki/batch"
|
|
22
|
+
require_relative "hibiki/state"
|
|
23
|
+
require_relative "hibiki/derived"
|
|
24
|
+
require_relative "hibiki/effect"
|
|
25
|
+
require_relative "hibiki/root"
|
|
26
|
+
require_relative "hibiki/reactive"
|
|
27
|
+
require_relative "hibiki/dsl"
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hibiki
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- planetaska
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'A fine-grained reactivity library modeled on the signal systems in Svelte
|
|
13
|
+
5 and SolidJS. Runtime dependency tracking, not static analysis: while a derived
|
|
14
|
+
or effect computes, any signal read during that window subscribes it. Writable state,
|
|
15
|
+
lazy derived values, and eager effects.'
|
|
16
|
+
email:
|
|
17
|
+
- planetaska@gmail.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/hibiki.rb
|
|
26
|
+
- lib/hibiki/batch.rb
|
|
27
|
+
- lib/hibiki/derived.rb
|
|
28
|
+
- lib/hibiki/dsl.rb
|
|
29
|
+
- lib/hibiki/effect.rb
|
|
30
|
+
- lib/hibiki/reactive.rb
|
|
31
|
+
- lib/hibiki/root.rb
|
|
32
|
+
- lib/hibiki/state.rb
|
|
33
|
+
- lib/hibiki/tracking.rb
|
|
34
|
+
- lib/hibiki/version.rb
|
|
35
|
+
homepage: https://github.com/planetaska/hibiki
|
|
36
|
+
licenses:
|
|
37
|
+
- MIT
|
|
38
|
+
metadata:
|
|
39
|
+
homepage_uri: https://github.com/planetaska/hibiki
|
|
40
|
+
source_code_uri: https://github.com/planetaska/hibiki
|
|
41
|
+
changelog_uri: https://github.com/planetaska/hibiki/blob/main/CHANGELOG.md
|
|
42
|
+
rubygems_mfa_required: 'true'
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '3.4'
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 4.0.16
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: 'Svelte-5-style signals for Ruby: state, derived, effect.'
|
|
60
|
+
test_files: []
|