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 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)
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ VERSION = "0.1.0"
5
+ 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: []