├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── _guard-core ├── console ├── guard ├── rake └── setup ├── lib ├── signalize.rb └── signalize │ ├── struct.rb │ └── version.rb ├── signalize.gemspec └── test ├── test_helper.rb ├── test_signalize.rb └── test_struct.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.0.0' 18 | - '3.1.0' 19 | - '3.2.0' 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run the default task 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [1.3.0] - 2023-10-04 6 | 7 | - Provide `Signalize::Struct`, a struct-like object to hold multiple signals (including computed) 8 | (optional via `require "signalize/struct"`) 9 | 10 | ## [1.2.0] - 2023-10-03 11 | 12 | - Add `untracked` method (implements #5) 13 | - Add `mutation_detected` check for `computed` 14 | 15 | Gem now roughly analogous to `@preact/signals-core` v1.5 16 | 17 | ## [1.1.0] - 2023-03-25 18 | 19 | - Provide better signal/computed inspect strings (fixes #1) 20 | - Use Concurrent::Map for thread-safe globals (fixes #3) 21 | 22 | ## [1.0.1] - 2023-03-08 23 | 24 | - Prevent early returns in effect blocks 25 | - Use gem's error class (fixes #2) 26 | 27 | ## [1.0.0] - 2023-03-07 28 | 29 | - Initial release 30 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at jared@jaredwhite.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in signalize.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "minitest", "~> 5.0" 11 | 12 | gem "guard" 13 | gem "guard-minitest" 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | signalize (1.3.0) 5 | concurrent-ruby (~> 1.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | coderay (1.1.3) 11 | concurrent-ruby (1.2.2) 12 | ffi (1.15.5) 13 | formatador (1.1.0) 14 | guard (2.18.0) 15 | formatador (>= 0.2.4) 16 | listen (>= 2.7, < 4.0) 17 | lumberjack (>= 1.0.12, < 2.0) 18 | nenv (~> 0.1) 19 | notiffany (~> 0.0) 20 | pry (>= 0.13.0) 21 | shellany (~> 0.0) 22 | thor (>= 0.18.1) 23 | guard-compat (1.2.1) 24 | guard-minitest (2.4.6) 25 | guard-compat (~> 1.2) 26 | minitest (>= 3.0) 27 | listen (3.8.0) 28 | rb-fsevent (~> 0.10, >= 0.10.3) 29 | rb-inotify (~> 0.9, >= 0.9.10) 30 | lumberjack (1.2.8) 31 | method_source (1.0.0) 32 | minitest (5.18.0) 33 | nenv (0.3.0) 34 | notiffany (0.1.3) 35 | nenv (~> 0.1) 36 | shellany (~> 0.0) 37 | pry (0.14.2) 38 | coderay (~> 1.1) 39 | method_source (~> 1.0) 40 | rake (13.0.6) 41 | rb-fsevent (0.11.2) 42 | rb-inotify (0.10.1) 43 | ffi (~> 1.0) 44 | shellany (0.0.1) 45 | thor (1.2.1) 46 | 47 | PLATFORMS 48 | arm64-darwin-21 49 | x86_64-linux 50 | 51 | DEPENDENCIES 52 | guard 53 | guard-minitest 54 | minitest (~> 5.0) 55 | rake (~> 13.0) 56 | signalize! 57 | 58 | BUNDLED WITH 59 | 2.3.14 60 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | ## Uncomment and set this to only include directories you want to watch 5 | # directories %w(app lib config test spec features) \ 6 | # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")} 7 | 8 | ## Note: if you are using the `directories` clause above and you are not 9 | ## watching the project directory ('.'), then you will want to move 10 | ## the Guardfile to a watched dir and symlink it back, e.g. 11 | # 12 | # $ mkdir config 13 | # $ mv Guardfile config/ 14 | # $ ln -s config/Guardfile . 15 | # 16 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 17 | 18 | guard :minitest do 19 | # with Minitest::Unit 20 | watch(%r{^test/(.*)\/?test_(.*)\.rb$}) 21 | watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" } 22 | watch(%r{^test/test_helper\.rb$}) { 'test' } 23 | 24 | # with Minitest::Spec 25 | # watch(%r{^spec/(.*)_spec\.rb$}) 26 | # watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 27 | # watch(%r{^spec/spec_helper\.rb$}) { 'spec' } 28 | 29 | # Rails 4 30 | # watch(%r{^app/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" } 31 | # watch(%r{^app/controllers/application_controller\.rb$}) { 'test/controllers' } 32 | # watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" } 33 | # watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" } 34 | # watch(%r{^lib/(.+)\.rb$}) { |m| "test/lib/#{m[1]}_test.rb" } 35 | # watch(%r{^test/.+_test\.rb$}) 36 | # watch(%r{^test/test_helper\.rb$}) { 'test' } 37 | 38 | # Rails < 4 39 | # watch(%r{^app/controllers/(.*)\.rb$}) { |m| "test/functional/#{m[1]}_test.rb" } 40 | # watch(%r{^app/helpers/(.*)\.rb$}) { |m| "test/helpers/#{m[1]}_test.rb" } 41 | # watch(%r{^app/models/(.*)\.rb$}) { |m| "test/unit/#{m[1]}_test.rb" } 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Preact Team & ported to Ruby by Jared White 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signalize 2 | 3 | Signalize is a Ruby port of the JavaScript-based [core Signals package](https://github.com/preactjs/signals) by the Preact project. Signals provides reactive variables, derived computed state, side effect callbacks, and batched updates. 4 | 5 | Additional context as provided by the original documentation: 6 | 7 | > Signals is a performant state management library with two primary goals: 8 | > 9 | > Make it as easy as possible to write business logic for small up to complex apps. No matter how complex your logic is, your app updates should stay fast without you needing to think about it. Signals automatically optimize state updates behind the scenes to trigger the fewest updates necessary. They are lazy by default and automatically skip signals that no one listens to. 10 | > Integrate into frameworks as if they were native built-in primitives. You don't need any selectors, wrapper functions, or anything else. Signals can be accessed directly and your component will automatically re-render when the signal's value changes. 11 | 12 | While a lot of what we tend to write in Ruby is in the form of repeated, linear processing cycles (aka HTTP requests/responses on the web), there is increasingly a sense that we can look at concepts which make a lot of sense on the web frontend in the context of UI interactions and data flows and apply similar principles to the backend as well. Signalize helps you do just that. 13 | 14 | **NOTE:** read the Contributing section below before submitting a bug report or PR. 15 | 16 | ## Installation 17 | 18 | Install the gem and add to the application's Gemfile by executing: 19 | 20 | $ bundle add signalize 21 | 22 | If bundler is not being used to manage dependencies, install the gem by executing: 23 | 24 | $ gem install signalize 25 | 26 | ## Usage 27 | 28 | Signalize's public API consists of five methods (you can think of them almost like functions): `signal`, `untracked`, `computed`, `effect`, and `batch`. 29 | 30 | ### `signal(initial_value)` 31 | 32 | The first building block is the `Signalize::Signal` class. You can think of this as a reactive value object which wraps an underlying primitive like String, Integer, Array, etc. 33 | 34 | ```ruby 35 | require "signalize" 36 | 37 | counter = Signalize.signal(0) 38 | 39 | # Read value from signal, logs: 0 40 | puts counter.value 41 | 42 | # Write to a signal 43 | counter.value = 1 44 | ``` 45 | 46 | You can include the `Signalize::API` mixin to access these methods directly in any context: 47 | 48 | ```ruby 49 | require "signalize" 50 | include Signalize::API 51 | 52 | counter = signal(0) 53 | 54 | counter.value += 1 55 | ``` 56 | 57 | ### `untracked { }` 58 | 59 | In case when you're receiving a callback that can read some signals, but you don't want to subscribe to them, you can use `untracked` to prevent any subscriptions from happening. 60 | 61 | ```ruby 62 | require "signalize" 63 | include Signalize::API 64 | 65 | counter = signal(0) 66 | effect_count = signal(0) 67 | fn = proc { effect_count.value + 1 } 68 | 69 | effect do 70 | # Logs the value 71 | puts counter.value 72 | 73 | # Whenever this effect is triggered, run `fn` that gives new value 74 | effect_count.value = untracked(&fn) 75 | end 76 | ``` 77 | 78 | ### `computed { }` 79 | 80 | You derive computed state by accessing a signal's value within a `computed` block and returning a new value. Every time that signal value is updated, a computed value will likewise be updated. Actually, that's not quite accurate — the computed value only computes when it's read. In this sense, we can call computed values "lazily-evaluated". 81 | 82 | ```ruby 83 | require "signalize" 84 | include Signalize::API 85 | 86 | name = signal("Jane") 87 | surname = signal("Doe") 88 | 89 | full_name = computed do 90 | name.value + " " + surname.value 91 | end 92 | 93 | # Logs: "Jane Doe" 94 | puts full_name.value 95 | 96 | name.value = "John" 97 | name.value = "Johannes" 98 | # name.value = "..." 99 | # Setting value multiple times won't trigger a computed value refresh 100 | 101 | # NOW we get a refreshed computed value: 102 | puts full_name.value 103 | ``` 104 | 105 | ### `effect { }` 106 | 107 | Effects are callbacks which are executed whenever values which the effect has "subscribed" to by referencing them have changed. An effect callback is run immediately when defined, and then again for any future mutations. 108 | 109 | ```ruby 110 | require "signalize" 111 | include Signalize::API 112 | 113 | name = signal("Jane") 114 | surname = signal("Doe") 115 | full_name = computed { name.value + " " + surname.value } 116 | 117 | # Logs: "Jane Doe" 118 | effect { puts full_name.value } 119 | 120 | # Updating one of its dependencies will automatically trigger 121 | # the effect above, and will print "John Doe" to the console. 122 | name.value = "John" 123 | ``` 124 | 125 | You can dispose of an effect whenever you want, thereby unsubscribing it from signal notifications. 126 | 127 | ```ruby 128 | require "signalize" 129 | include Signalize::API 130 | 131 | name = signal("Jane") 132 | surname = signal("Doe") 133 | full_name = computed { name.value + " " + surname.value } 134 | 135 | # Logs: "Jane Doe" 136 | dispose = effect { puts full_name.value } 137 | 138 | # Destroy effect and subscriptions 139 | dispose.() 140 | 141 | # Update does nothing, because no one is subscribed anymore. 142 | # Even the computed `full_name` signal won't change, because it knows 143 | # that no one listens to it. 144 | surname.value = "Doe 2" 145 | ``` 146 | 147 | **IMPORTANT:** you cannot use `return` or `break` within an effect block. Doing so will raise an exception (due to it breaking the underlying execution model). 148 | 149 | ```ruby 150 | def my_method(signal_obj) 151 | effect do 152 | return if signal_obj.value > 5 # DON'T DO THIS! 153 | 154 | puts signal_obj.value 155 | end 156 | 157 | # more code here 158 | end 159 | ``` 160 | 161 | Instead, try to resolve it using more explicit logic: 162 | 163 | ```ruby 164 | def my_method(signal_obj) 165 | should_exit = false 166 | 167 | effect do 168 | should_exit = true && next if signal_obj.value > 5 169 | 170 | puts signal_obj.value 171 | end 172 | 173 | return if should_exit 174 | 175 | # more code here 176 | end 177 | ``` 178 | 179 | However, there's no issue if you pass in a method proc directly: 180 | 181 | ```ruby 182 | def my_method(signal_obj) 183 | @signal_obj = signal_obj 184 | 185 | effect &method(:an_effect_method) 186 | 187 | # more code here 188 | end 189 | 190 | def an_effect_method 191 | return if @signal_obj.value > 5 192 | 193 | puts @signal_obj.value 194 | end 195 | ``` 196 | 197 | ### `batch { }` 198 | 199 | You can write to multiple signals within a batch, and flush the updates at all once (thereby notifying computed refreshes and effects). 200 | 201 | ```ruby 202 | require "signalize" 203 | include Signalize::API 204 | 205 | name = signal("Jane") 206 | surname = signal("Doe") 207 | full_name = computed { name.value + " " + surname.value } 208 | 209 | # Logs: "Jane Doe" 210 | dispose = effect { puts full_name.value } 211 | 212 | batch do 213 | name.value = "Foo" 214 | surname.value = "Bar" 215 | end 216 | ``` 217 | 218 | ### `signal.subscribe { }` 219 | 220 | You can explicitly subscribe to a signal signal value and be notified on every change. (Essentially the Observable pattern.) In your block, the new signal value will be supplied as an argument. 221 | 222 | ```ruby 223 | require "signalize" 224 | include Signalize::API 225 | 226 | counter = signal(0) 227 | 228 | counter.subscribe do |new_value| 229 | puts "The new value is #{new_value}" 230 | end 231 | 232 | counter.value = 1 # logs the new value 233 | ``` 234 | 235 | ### `signal.peek` 236 | 237 | If you need to access a signal's value inside an effect without subscribing to that signal's updates, use the `peek` method instead of `value`. 238 | 239 | ```ruby 240 | require "signalize" 241 | include Signalize::API 242 | 243 | counter = signal(0) 244 | effect_count = signal(0) 245 | 246 | effect do 247 | puts counter.value 248 | 249 | # Whenever this effect is triggered, increase `effect_count`. 250 | # But we don't want this signal to react to `effect_count` 251 | effect_count.value = effect_count.peek + 1 252 | end 253 | ``` 254 | 255 | ## Signalize Struct 256 | 257 | An optional add-on to Signalize, the `Singalize::Struct` class lets you define multiple signal or computed variables to hold in struct-like objects. You can even add custom methods to your classes with a simple DSL. (The API is intentionally similar to `Data` in Ruby 3.2+, although these objects are of course mutable.) 258 | 259 | Here's what it looks like: 260 | 261 | ```ruby 262 | require "signalize/struct" 263 | 264 | include Signalize::API 265 | 266 | TestSignalsStruct = Signalize::Struct.define( 267 | :str, 268 | :int, 269 | :multiplied_by_10 270 | ) do # optional block for adding methods 271 | def increment! 272 | self.int += 1 273 | end 274 | end 275 | 276 | struct = TestSignalsStruct.new( 277 | int: 0, 278 | str: "Hello World", 279 | multiplied_by_10: computed { struct.int * 10 } 280 | ) 281 | 282 | effect do 283 | puts struct.multiplied_by_10 # 0 284 | end 285 | 286 | effect do 287 | puts struct.str # "Hello World" 288 | end 289 | 290 | struct.increment! # above effect will now output 10 291 | struct.str = "Goodbye!" # above effect will now output "Goodbye!" 292 | ``` 293 | 294 | If you ever need to get at the actual `Signal` object underlying a value, just call `*_signal`. For example, you could call `int_signal` for the above example to get a signal object for `int`. 295 | 296 | Signalize structs require all of their members to be present when initializing…you can't pass only some keyword arguments. 297 | 298 | Signalize structs support `to_h` as well as `deconstruct_keys` which is used for pattern matching and syntax like `struct => { str: }` to set local variables. 299 | 300 | You can call `members` (as both object/class methods) to get a list of the value names in the struct. 301 | 302 | Finally, both `inspect` and `to_s` let you debug the contents of a struct. 303 | 304 | ## Development 305 | 306 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rake test` to run the tests, or `bin/guard` or run them continuously in watch mode. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 307 | 308 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 309 | 310 | ## Contributing 311 | 312 | Signalize is considered a direct port of the [original Signals JavaScript library](https://github.com/preactjs/signals). This means we are unlikely to accept any additional features other than what's provided by Signals (unless it's completely separate, like our `Signalize::Struct` add-on). If Signals adds new functionality in the future, we will endeavor to replicate it in Signalize. Furthermore, if there's some unwanted behavior in Signalize that's also present in Signals, we are unlikely to modify that behavior. 313 | 314 | However, if you're able to supply a bugfix or performance optimization which will help bring Signalize _more_ into alignment with its Signals counterpart, we will gladly accept your PR! 315 | 316 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/whitefusionhq/signalize/blob/main/CODE_OF_CONDUCT.md). 317 | 318 | ## License 319 | 320 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 321 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/test_*.rb"] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/_guard-core: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application '_guard-core' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("guard", "_guard-core") 28 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "signalize" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/guard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'guard' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("guard", "guard") 28 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/signalize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "concurrent" 4 | require_relative "signalize/version" 5 | 6 | module Signalize 7 | class Error < StandardError; end 8 | 9 | class << self 10 | def global_map_accessor(name) 11 | define_singleton_method "#{name}" do 12 | GLOBAL_MAP[name] 13 | end 14 | define_singleton_method "#{name}=" do |value| 15 | GLOBAL_MAP[name] = value 16 | end 17 | end 18 | end 19 | 20 | def self.cycle_detected 21 | raise Signalize::Error, "Cycle detected" 22 | end 23 | 24 | def self.mutation_detected 25 | raise Signalize::Error, "Computed cannot have side-effects" 26 | end 27 | 28 | RUNNING = 1 << 0 29 | NOTIFIED = 1 << 1 30 | OUTDATED = 1 << 2 31 | DISPOSED = 1 << 3 32 | HAS_ERROR = 1 << 4 33 | TRACKING = 1 << 5 34 | 35 | GLOBAL_MAP = Concurrent::Map.new 36 | 37 | # Computed | Effect | nil 38 | global_map_accessor :eval_context 39 | self.eval_context = nil 40 | 41 | # Used by `untracked` method 42 | global_map_accessor :untracked_depth 43 | self.untracked_depth = 0 44 | 45 | # Effects collected into a batch. 46 | global_map_accessor :batched_effect 47 | self.batched_effect = nil 48 | global_map_accessor :batch_depth 49 | self.batch_depth = 0 50 | global_map_accessor :batch_iteration 51 | self.batch_iteration = 0 52 | 53 | # NOTE: we have removed the global version optimization for Ruby, due to 54 | # the possibility of long-running server processes and the number reaching 55 | # a dangerously high integer value. 56 | # 57 | # global_map_accessor :global_version 58 | # self.global_version = 0 59 | 60 | Node = Struct.new( 61 | :_version, 62 | :_source, 63 | :_prev_source, 64 | :_next_source, 65 | :_target, 66 | :_prev_target, 67 | :_next_target, 68 | :_rollback_node, 69 | keyword_init: true 70 | ) 71 | 72 | class << self 73 | ## Batch-related helpers ## 74 | 75 | def start_batch 76 | self.batch_depth += 1 77 | end 78 | 79 | def end_batch 80 | if batch_depth > 1 81 | self.batch_depth -= 1 82 | return 83 | end 84 | error = nil 85 | hasError = false 86 | 87 | while batched_effect.nil?.! 88 | effect = batched_effect 89 | self.batched_effect = nil 90 | 91 | self.batch_iteration += 1 92 | while effect.nil?.! 93 | nxt = effect._next_batched_effect 94 | effect._next_batched_effect = nil 95 | effect._flags &= ~NOTIFIED 96 | unless (effect._flags & DISPOSED).nonzero? && needs_to_recompute(effect) 97 | begin 98 | effect._callback 99 | rescue StandardError => err 100 | unless hasError 101 | error = err 102 | hasError = true 103 | end 104 | end 105 | end 106 | 107 | effect = nxt 108 | end 109 | end 110 | 111 | self.batch_iteration = 0 112 | self.batch_depth -= 1 113 | 114 | raise error if hasError 115 | end 116 | 117 | def batch 118 | return yield unless batch_depth.zero? 119 | 120 | start_batch 121 | 122 | begin 123 | return yield 124 | ensure 125 | end_batch 126 | end 127 | end 128 | 129 | ## Signal-related helpers ## 130 | 131 | def add_dependency(signal) 132 | return nil if eval_context.nil? 133 | 134 | node = signal._node 135 | if node.nil? || node._target != eval_context 136 | # /** 137 | # * `signal` is a new dependency. Create a new dependency node, and set it 138 | # * as the tail of the current context's dependency list. e.g: 139 | # * 140 | # * { A <-> B } 141 | # * ↑ ↑ 142 | # * tail node (new) 143 | # * ↓ 144 | # * { A <-> B <-> C } 145 | # * ↑ 146 | # * tail (evalContext._sources) 147 | # */ 148 | node = Node.new( 149 | _version: 0, 150 | _source: signal, 151 | _prev_source: eval_context._sources, 152 | _next_source: nil, 153 | _target: eval_context, 154 | _prev_target: nil, 155 | _next_target: nil, 156 | _rollback_node: node, 157 | ) 158 | 159 | unless eval_context._sources.nil? 160 | eval_context._sources._next_source = node 161 | end 162 | eval_context._sources = node 163 | signal._node = node 164 | 165 | # Subscribe to change notifications from this dependency if we're in an effect 166 | # OR evaluating a computed signal that in turn has subscribers. 167 | if (eval_context._flags & TRACKING).nonzero? 168 | signal._subscribe(node) 169 | end 170 | return node 171 | elsif node._version == -1 172 | # `signal` is an existing dependency from a previous evaluation. Reuse it. 173 | node._version = 0 174 | 175 | # /** 176 | # * If `node` is not already the current tail of the dependency list (i.e. 177 | # * there is a next node in the list), then make the `node` the new tail. e.g: 178 | # * 179 | # * { A <-> B <-> C <-> D } 180 | # * ↑ ↑ 181 | # * node ┌─── tail (evalContext._sources) 182 | # * └─────│─────┐ 183 | # * ↓ ↓ 184 | # * { A <-> C <-> D <-> B } 185 | # * ↑ 186 | # * tail (evalContext._sources) 187 | # */ 188 | unless node._next_source.nil? 189 | node._next_source._prev_source = node._prev_source 190 | 191 | unless node._prev_source.nil? 192 | node._prev_source._next_source = node._next_source 193 | end 194 | 195 | node._prev_source = eval_context._sources 196 | node._next_source = nil 197 | 198 | eval_context._sources._next_source = node 199 | eval_context._sources = node 200 | end 201 | 202 | # We can assume that the currently evaluated effect / computed signal is already 203 | # subscribed to change notifications from `signal` if needed. 204 | return node 205 | end 206 | 207 | nil 208 | end 209 | 210 | ## Computed/Effect-related helpers ## 211 | 212 | def needs_to_recompute(target) 213 | # Check the dependencies for changed values. The dependency list is already 214 | # in order of use. Therefore if multiple dependencies have changed values, only 215 | # the first used dependency is re-evaluated at this point. 216 | node = target._sources 217 | while node.nil?.! 218 | # If there's a new version of the dependency before or after refreshing, 219 | # or the dependency has something blocking it from refreshing at all (e.g. a 220 | # dependency cycle), then we need to recompute. 221 | if node._source._version != node._version || !node._source._refresh || node._source._version != node._version 222 | return true 223 | end 224 | node = node._next_source 225 | end 226 | # If none of the dependencies have changed values since last recompute then 227 | # there's no need to recompute. 228 | false 229 | end 230 | 231 | def prepare_sources(target) 232 | # /** 233 | # * 1. Mark all current sources as re-usable nodes (version: -1) 234 | # * 2. Set a rollback node if the current node is being used in a different context 235 | # * 3. Point 'target._sources' to the tail of the doubly-linked list, e.g: 236 | # * 237 | # * { undefined <- A <-> B <-> C -> undefined } 238 | # * ↑ ↑ 239 | # * │ └──────┐ 240 | # * target._sources = A; (node is head) │ 241 | # * ↓ │ 242 | # * target._sources = C; (node is tail) ─┘ 243 | # */ 244 | node = target._sources 245 | while node.nil?.! 246 | rollbackNode = node._source._node 247 | node._rollback_node = rollbackNode unless rollbackNode.nil? 248 | node._source._node = node 249 | node._version = -1 250 | 251 | if node._next_source.nil? 252 | target._sources = node 253 | break 254 | end 255 | 256 | node = node._next_source 257 | end 258 | end 259 | 260 | def cleanup_sources(target) 261 | node = target._sources 262 | head = nil 263 | 264 | # /** 265 | # * At this point 'target._sources' points to the tail of the doubly-linked list. 266 | # * It contains all existing sources + new sources in order of use. 267 | # * Iterate backwards until we find the head node while dropping old dependencies. 268 | # */ 269 | while node.nil?.! 270 | prev = node._prev_source 271 | 272 | # /** 273 | # * The node was not re-used, unsubscribe from its change notifications and remove itself 274 | # * from the doubly-linked list. e.g: 275 | # * 276 | # * { A <-> B <-> C } 277 | # * ↓ 278 | # * { A <-> C } 279 | # */ 280 | if node._version == -1 281 | node._source._unsubscribe(node) 282 | 283 | unless prev.nil? 284 | prev._next_source = node._next_source 285 | end 286 | unless node._next_source.nil? 287 | node._next_source._prev_source = prev 288 | end 289 | else 290 | # /** 291 | # * The new head is the last node seen which wasn't removed/unsubscribed 292 | # * from the doubly-linked list. e.g: 293 | # * 294 | # * { A <-> B <-> C } 295 | # * ↑ ↑ ↑ 296 | # * │ │ └ head = node 297 | # * │ └ head = node 298 | # * └ head = node 299 | # */ 300 | head = node 301 | end 302 | 303 | node._source._node = node._rollback_node 304 | unless node._rollback_node.nil? 305 | node._rollback_node = nil 306 | end 307 | 308 | node = prev 309 | end 310 | 311 | target._sources = head 312 | end 313 | 314 | ## Effect-related helpers ## 315 | 316 | def cleanup_effect(effect) 317 | cleanup = effect._cleanup 318 | effect._cleanup = nil 319 | 320 | if cleanup.is_a?(Proc) 321 | start_batch 322 | 323 | # Run cleanup functions always outside of any context. 324 | prev_context = eval_context 325 | self.eval_context = nil 326 | begin 327 | cleanup.() 328 | rescue StandardError => err 329 | effect._flags &= ~RUNNING 330 | effect._flags |= DISPOSED 331 | dispose_effect(effect) 332 | raise err 333 | ensure 334 | self.eval_context = prev_context 335 | end_batch 336 | end 337 | end 338 | end 339 | 340 | def dispose_effect(effect) 341 | node = effect._sources 342 | while node.nil?.! 343 | node._source._unsubscribe(node) 344 | node = node._next_source 345 | end 346 | effect._compute = nil 347 | effect._sources = nil 348 | 349 | cleanup_effect(effect) 350 | end 351 | 352 | def end_effect(effect, prev_context, *_) # allow additional args for currying 353 | raise Signalize::Error, "Out-of-order effect" if eval_context != effect 354 | 355 | cleanup_sources(effect) 356 | self.eval_context = prev_context 357 | 358 | effect._flags &= ~RUNNING 359 | dispose_effect(effect) if (effect._flags & DISPOSED).nonzero? 360 | end_batch 361 | end 362 | end 363 | 364 | class Signal 365 | attr_accessor :_version, :_node, :_targets 366 | 367 | def initialize(value) 368 | @value = value 369 | @_version = 0; 370 | @_node = nil 371 | @_targets = nil 372 | end 373 | 374 | def _refresh = true 375 | 376 | def _subscribe(node) 377 | if _targets != node && node._prev_target.nil? 378 | node._next_target = _targets 379 | _targets._prev_target = node if !_targets.nil? 380 | self._targets = node 381 | end 382 | end 383 | 384 | def _unsubscribe(node) 385 | # Only run the unsubscribe step if the signal has any subscribers to begin with. 386 | if !_targets.nil? 387 | prev = node._prev_target 388 | nxt = node._next_target 389 | if !prev.nil? 390 | prev._next_target = nxt 391 | node._prev_target = nil 392 | end 393 | if !nxt.nil? 394 | nxt._prev_target = prev 395 | node._next_target = nil 396 | end 397 | self._targets = nxt if node == _targets 398 | end 399 | end 400 | 401 | def subscribe(&fn) 402 | signal = self 403 | this = Effect.allocate 404 | this.send(:initialize, -> { 405 | value = signal.value 406 | flag = this._flags & TRACKING 407 | this._flags &= ~TRACKING; 408 | begin 409 | fn.(value) 410 | ensure 411 | this._flags |= flag 412 | end 413 | }) 414 | 415 | Signalize.effect(this) 416 | end 417 | 418 | def value 419 | node = Signalize.add_dependency(self) 420 | node._version = _version unless node.nil? 421 | @value 422 | end 423 | 424 | def value=(value) 425 | Signalize.mutation_detected if Signalize.eval_context.is_a?(Signalize::Computed) 426 | 427 | if value != @value 428 | Signalize.cycle_detected if Signalize.batch_iteration > 100 429 | 430 | @value = value; 431 | @_version += 1 432 | # Signalize.global_version += 1 433 | 434 | Signalize.start_batch 435 | begin 436 | node = _targets 437 | while node.nil?.! 438 | node._target._notify 439 | node = node._next_target 440 | end 441 | ensure 442 | Signalize.end_batch 443 | end 444 | end 445 | end 446 | 447 | def to_s 448 | @value.to_s 449 | end 450 | 451 | def peek = @value 452 | 453 | def inspect 454 | "#<#{self.class} value: #{peek.inspect}>" 455 | end 456 | end 457 | 458 | class Computed < Signal 459 | attr_accessor :_compute, :_sources, :_flags 460 | 461 | def initialize(compute) 462 | super(nil) 463 | 464 | @_compute = compute 465 | @_sources = nil 466 | # @_global_version = Signalize.global_version - 1 467 | @_flags = OUTDATED 468 | end 469 | 470 | def _refresh 471 | @_flags &= ~NOTIFIED 472 | 473 | return false if (@_flags & RUNNING).nonzero? 474 | 475 | # If this computed signal has subscribed to updates from its dependencies 476 | # (TRACKING flag set) and none of them have notified about changes (OUTDATED 477 | # flag not set), then the computed value can't have changed. 478 | return true if (@_flags & (OUTDATED | TRACKING)) == TRACKING 479 | 480 | @_flags &= ~OUTDATED 481 | 482 | # NOTE: performance optimization removed. 483 | # 484 | # if @_global_version == Signalize.global_version 485 | # return true 486 | # end 487 | # @_global_version = Signalize.global_version 488 | 489 | # Mark this computed signal running before checking the dependencies for value 490 | # changes, so that the RUNNING flag can be used to notice cyclical dependencies. 491 | @_flags |= RUNNING 492 | if @_version > 0 && !Signalize.needs_to_recompute(self) 493 | @_flags &= ~RUNNING 494 | return true 495 | end 496 | 497 | prev_context = Signalize.eval_context 498 | begin 499 | Signalize.prepare_sources(self) 500 | Signalize.eval_context = self 501 | value = @_compute.() 502 | if (@_flags & HAS_ERROR).nonzero? || @value != value || @_version == 0 503 | @value = value 504 | @_flags &= ~HAS_ERROR 505 | @_version += 1 506 | end 507 | rescue StandardError => err 508 | @value = err; 509 | @_flags |= HAS_ERROR 510 | @_version += 1 511 | end 512 | Signalize.eval_context = prev_context 513 | Signalize.cleanup_sources(self) 514 | @_flags &= ~RUNNING 515 | 516 | true 517 | end 518 | 519 | def _subscribe(node) 520 | if @_targets.nil? 521 | @_flags |= OUTDATED | TRACKING 522 | 523 | # A computed signal subscribes lazily to its dependencies when the it 524 | # gets its first subscriber. 525 | 526 | # RUBY NOTE: if we redefine `node`` here, it messes with `node` top method scope! 527 | # So we'll use a new variable name `snode` 528 | snode = @_sources 529 | while snode.nil?.! 530 | snode._source._subscribe(snode) 531 | snode = snode._next_source 532 | end 533 | end 534 | super(node) 535 | end 536 | 537 | def _unsubscribe(node) 538 | # Only run the unsubscribe step if the computed signal has any subscribers. 539 | unless @_target.nil? 540 | super(node) 541 | 542 | # Computed signal unsubscribes from its dependencies when it loses its last subscriber. 543 | # This makes it possible for unreferences subgraphs of computed signals to get garbage collected. 544 | if @_targets.nil? 545 | @_flags &= ~TRACKING 546 | 547 | node = @_sources 548 | 549 | while node.nil?.! 550 | node._source._unsubscribe(node) 551 | node = node._next_source 552 | end 553 | end 554 | end 555 | end 556 | 557 | def _notify 558 | unless (@_flags & NOTIFIED).nonzero? 559 | @_flags |= OUTDATED | NOTIFIED 560 | 561 | node = @_targets 562 | while node.nil?.! 563 | node._target._notify 564 | node = node._next_target 565 | end 566 | end 567 | end 568 | 569 | def peek 570 | Signalize.cycle_detected unless _refresh 571 | 572 | raise @value if (@_flags & HAS_ERROR).nonzero? 573 | 574 | @value 575 | end 576 | 577 | def value 578 | Signalize.cycle_detected if (@_flags & RUNNING).nonzero? 579 | 580 | node = Signalize.add_dependency(self) 581 | _refresh 582 | 583 | node._version = @_version unless node.nil? 584 | 585 | raise @value if (@_flags & HAS_ERROR).nonzero? 586 | 587 | @value 588 | end 589 | end 590 | 591 | class Effect 592 | attr_accessor :_compute, :_cleanup, :_sources, :_next_batched_effect, :_flags 593 | 594 | def initialize(compute) 595 | @_compute = compute 596 | @_cleanup = nil 597 | @_sources = nil 598 | @_next_batched_effect = nil 599 | @_flags = TRACKING 600 | end 601 | 602 | def _callback 603 | finis = _start 604 | 605 | begin 606 | compute_executed = false 607 | @_cleanup = _compute.() if (@_flags & DISPOSED).zero? && @_compute.nil?.! 608 | compute_executed = true 609 | ensure 610 | unless compute_executed 611 | raise Signalize::Error, "Early return or break detected in effect block" 612 | end 613 | finis.(nil) # TODO: figure out this weird shit 614 | end 615 | end 616 | 617 | def _start 618 | Signalize.cycle_detected if (@_flags & RUNNING).nonzero? 619 | 620 | @_flags |= RUNNING 621 | @_flags &= ~DISPOSED 622 | Signalize.cleanup_effect(self) 623 | Signalize.prepare_sources(self) 624 | 625 | Signalize.start_batch 626 | prev_context = Signalize.eval_context 627 | Signalize.eval_context = self 628 | 629 | Signalize.method(:end_effect).curry(3).call(self, prev_context) # HUH 630 | end 631 | 632 | def _notify 633 | unless (@_flags & NOTIFIED).nonzero? 634 | @_flags |= NOTIFIED 635 | @_next_batched_effect = Signalize.batched_effect 636 | Signalize.batched_effect = self 637 | end 638 | end 639 | 640 | def _dispose 641 | @_flags |= DISPOSED 642 | 643 | Signalize.dispose_effect(self) unless (@_flags & RUNNING).nonzero? 644 | end 645 | end 646 | 647 | module API 648 | def signal(value) 649 | Signal.new(value) 650 | end 651 | 652 | def computed(&block) 653 | Computed.new(block) 654 | end 655 | 656 | def effect(effect_instance = nil, &block) 657 | effect = effect_instance || Effect.new(block) 658 | 659 | begin 660 | effect._callback 661 | rescue StandardError => err 662 | effect._dispose 663 | raise err 664 | end 665 | 666 | effect.method(:_dispose) 667 | end 668 | 669 | def batch 670 | return yield unless Signalize.batch_depth.zero? 671 | 672 | Signalize.start_batch 673 | 674 | begin 675 | return yield 676 | ensure 677 | Signalize.end_batch 678 | end 679 | end 680 | 681 | def untracked 682 | return yield unless Signalize.untracked_depth.zero? 683 | 684 | prev_context = Signalize.eval_context 685 | Signalize.eval_context = nil 686 | Signalize.untracked_depth += 1 687 | 688 | begin 689 | return yield 690 | ensure 691 | Signalize.untracked_depth -= 1 692 | Signalize.eval_context = prev_context 693 | end 694 | end 695 | end 696 | 697 | extend API 698 | end 699 | -------------------------------------------------------------------------------- /lib/signalize/struct.rb: -------------------------------------------------------------------------------- 1 | require "signalize" 2 | 3 | module Signalize 4 | class Struct 5 | module Accessors 6 | def members 7 | @members ||= [] 8 | end 9 | 10 | def signal_accessor(*names) 11 | names.each do |name| 12 | members.push(name.to_sym) unless members.find { _1 == name.to_sym } 13 | signal_getter_name = "#{name}_signal".freeze 14 | ivar_name = "@#{name}".freeze 15 | 16 | define_method "#{name}_signal" do 17 | instance_variable_get(ivar_name) 18 | end 19 | 20 | define_method name do 21 | send(signal_getter_name)&.value 22 | end 23 | 24 | define_method "#{name}=" do |val| 25 | if instance_variable_defined?(ivar_name) 26 | raise Signalize::Error, "Cannot assign a signal to a signal value" if val.is_a?(Signalize::Signal) 27 | 28 | sig = instance_variable_get(ivar_name) 29 | if sig.is_a?(Signalize::Computed) 30 | raise Signalize::Error, "Cannot set value of computed signal `#{ivar_name.delete_prefix("@")}'" 31 | end 32 | 33 | sig.value = val 34 | else 35 | val = Signalize.signal(val) unless val.is_a?(Signalize::Computed) 36 | instance_variable_set(ivar_name, val) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | 43 | extend Accessors 44 | 45 | def self.define(*names, &block) 46 | Class.new(self).tap do |struct| 47 | struct.signal_accessor(*names) 48 | struct.class_eval(&block) if block 49 | end 50 | end 51 | 52 | def initialize(**data) 53 | # The below code is all to replicate native Ruby ergonomics 54 | unknown_keys = data.keys - members 55 | unless unknown_keys.empty? 56 | plural_suffix = unknown_keys.length > 1 ? "s" : "" 57 | raise ArgumentError, "unknown keyword#{plural_suffix}: #{unknown_keys.map { ":#{_1}" }.join(", ")}" 58 | end 59 | 60 | missing_keys = members - data.keys 61 | unless missing_keys.empty? 62 | plural_suffix = missing_keys.length > 1 ? "s" : "" 63 | raise ArgumentError, "missing keyword#{plural_suffix}: #{missing_keys.map { ":#{_1}" }.join(", ")}" 64 | end 65 | 66 | # Initialize with keyword arguments 67 | data.each do |k, v| 68 | send("#{k}=", v) 69 | end 70 | end 71 | 72 | def members = self.class.members 73 | 74 | def deconstruct_keys(...) = to_h.deconstruct_keys(...) 75 | 76 | def to_h = members.each_with_object({}) { _2[_1] = send("#{_1}_signal").peek } 77 | 78 | def inspect 79 | var_peeks = instance_variables.filter_map do |var_name| 80 | var = instance_variable_get(var_name) 81 | "#{var_name.to_s.delete_prefix("@")}=#{var.peek.inspect}" if var.is_a?(Signalize::Signal) 82 | end.join(", ") 83 | 84 | "#<#{self.class}#{var_peeks.empty? ? nil : " #{var_peeks}"}>" 85 | end 86 | 87 | def to_s 88 | inspect 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/signalize/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Signalize 4 | VERSION = "1.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /signalize.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/signalize/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "signalize" 7 | spec.version = Signalize::VERSION 8 | spec.authors = ["Jared White", "Preact Team"] 9 | spec.email = ["jared@whitefusion.studio"] 10 | 11 | spec.summary = "A Ruby port of Signals, providing reactive variables, derived computed state, side effect callbacks, and batched updates." 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/whitefusionhq/signalize" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = spec.homepage 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(__dir__) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 25 | end 26 | end 27 | spec.require_paths = ["lib"] 28 | 29 | # Uncomment to register a new dependency of your gem 30 | spec.add_dependency "concurrent-ruby", "~> 1.2" 31 | 32 | # For more information and examples about making a new gem, check out our 33 | # guide at: https://bundler.io/guides/creating_gem.html 34 | end 35 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "signalize" 5 | 6 | require "minitest/autorun" 7 | 8 | def in_words(int) 9 | numbers_to_name = { 10 | 1000000 => "million", 11 | 1000 => "thousand", 12 | 100 => "hundred", 13 | 90 => "ninety", 14 | 80 => "eighty", 15 | 70 => "seventy", 16 | 60 => "sixty", 17 | 50 => "fifty", 18 | 40 => "forty", 19 | 30 => "thirty", 20 | 20 => "twenty", 21 | 19=>"nineteen", 22 | 18=>"eighteen", 23 | 17=>"seventeen", 24 | 16=>"sixteen", 25 | 15=>"fifteen", 26 | 14=>"fourteen", 27 | 13=>"thirteen", 28 | 12=>"twelve", 29 | 11 => "eleven", 30 | 10 => "ten", 31 | 9 => "nine", 32 | 8 => "eight", 33 | 7 => "seven", 34 | 6 => "six", 35 | 5 => "five", 36 | 4 => "four", 37 | 3 => "three", 38 | 2 => "two", 39 | 1 => "one" 40 | } 41 | str = "" 42 | numbers_to_name.each do |num, name| 43 | if int == 0 44 | return str 45 | elsif int.to_s.length == 1 && int/num > 0 46 | return str + "#{name}" 47 | elsif int < 100 && int/num > 0 48 | return str + "#{name}" if int%num == 0 49 | return str + "#{name} " + in_words(int%num) 50 | elsif int/num > 0 51 | return str + in_words(int/num) + " #{name} " + in_words(int%num) 52 | end 53 | end 54 | end 55 | 56 | class ProcessingTester 57 | def initialize 58 | @_stop = false 59 | @results = Signalize.signal([]) 60 | end 61 | 62 | def results = @results.value 63 | 64 | def process 65 | 10.times do |i| 66 | return if @_stop 67 | 68 | sleep 0.1 69 | @results.value = [*results, i + 1].freeze 70 | end 71 | end 72 | 73 | def stop 74 | @_stop = true 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/test_signalize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestSignalize < Minitest::Test 6 | include Signalize::API 7 | 8 | def test_that_it_has_a_version_number 9 | refute_nil ::Signalize::VERSION 10 | end 11 | 12 | def test_it_does_something_useful 13 | processing = ProcessingTester.new 14 | 15 | computed_ran_once = 0 16 | 17 | words_array = computed do 18 | computed_ran_once += 1 19 | processing.results.map { in_words(_1) }.freeze 20 | end 21 | 22 | effect do 23 | # Computed variables are lazily evalutated, that is, they don't compute until 24 | # their `.value` is read. But if we add the following statement, every effect 25 | # run will force a computation: 26 | # 27 | # puts "in an effect! #{words_array.value}" 28 | 29 | processing.stop if processing.results.length > 5 30 | end 31 | 32 | processing.process 33 | 34 | assert_equal 0, computed_ran_once 35 | assert_equal "one, two, three, four, five, six", words_array.value.join(", ") 36 | assert_equal 1, computed_ran_once 37 | end 38 | 39 | def test_basic_signal 40 | counter = signal(0) 41 | 42 | # Read value from signal, logs: 0 43 | assert_equal 0, counter.value 44 | 45 | # Write to a signal 46 | counter.value = 1 47 | 48 | assert_equal 1, counter.value 49 | assert_equal 1, counter.peek 50 | end 51 | 52 | def test_computed 53 | name = signal("Jane") 54 | surname = signal("Doe") 55 | 56 | computed_ran_once = 0 57 | 58 | full_name = computed do 59 | computed_ran_once += 1 60 | name.value + " " + surname.value 61 | end 62 | 63 | assert_equal "Jane Doe", full_name.value 64 | 65 | name.value = "John" 66 | name.value = "Johannes" 67 | # name.value = "..." 68 | # Setting value multiple times won't trigger a computed value refresh 69 | 70 | # NOW we get a refreshed computed value: 71 | assert_equal "Johannes Doe", full_name.value 72 | assert_equal 2, computed_ran_once 73 | end 74 | 75 | def test_effect 76 | name = signal("Jane") 77 | surname = signal("Doe") 78 | full_name = computed { name.value + " " + surname.value } 79 | 80 | effect_ran_twice = 0 81 | effect_output = "" 82 | 83 | dispose = effect do 84 | effect_output = full_name.value 85 | effect_ran_twice += 1 86 | end 87 | 88 | # Updating one of its dependencies will automatically trigger 89 | # the effect above, and will print "John Doe" to the console. 90 | name.value = "John" 91 | 92 | assert_equal "John Doe", effect_output 93 | assert_equal 2, effect_ran_twice 94 | 95 | dispose.() 96 | 97 | name.value = "Jack" 98 | assert_equal 2, effect_ran_twice 99 | end 100 | 101 | def multiple_effect_run 102 | x = Signalize.signal(1) 103 | results = nil 104 | Signalize.effect do 105 | results = "done" if x.value == 3 106 | end; x.value = 2; x.value = 3; x.value = 4 107 | results || "oops" 108 | end 109 | 110 | def test_multiple_runs 111 | multiple_effect_run 112 | 113 | assert_equal "done", multiple_effect_run 114 | end 115 | 116 | def test_batch 117 | name = signal("Jane") 118 | surname = signal("Doe") 119 | full_name = computed { name.value + " " + surname.value } 120 | 121 | effect_output = "" 122 | effect_ran_twice = 0 123 | 124 | effect do 125 | effect_output = full_name.value 126 | effect_ran_twice += 1 127 | end 128 | 129 | batch do 130 | name.value = "Foo" 131 | surname.value = "Bar" 132 | end 133 | 134 | assert_equal "Foo Bar", effect_output 135 | assert_equal 2, effect_ran_twice 136 | end 137 | 138 | def test_subscribe 139 | test_value = 0 140 | counter = signal(test_value) 141 | 142 | counter.subscribe do |new_value| 143 | assert_equal test_value, new_value 144 | end 145 | 146 | test_value = 10 147 | counter.value = test_value # logs the new value 148 | end 149 | 150 | def test_disallow_setting_signal_in_computed 151 | v = 123 152 | a = signal(v) 153 | c = computed { a.value += 1 } 154 | error = assert_raises(Signalize::Error) do 155 | c.value 156 | end 157 | 158 | assert_equal "Computed cannot have side-effects", error.message 159 | assert_equal v, a.value 160 | end 161 | 162 | def test_run_untracked_callback_once 163 | calls = 0 164 | a = signal(1); 165 | b = signal(2); 166 | spy = proc do 167 | calls += 1 168 | a.value + b.value 169 | end 170 | effect { untracked(&spy) } 171 | a.value = 10 172 | b.value = 20 173 | 174 | assert_equal 1, calls 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /test/test_struct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "signalize/struct" 5 | 6 | class TestStruct < Minitest::Test 7 | include Signalize::API 8 | 9 | TestSignalsStruct = Signalize::Struct.define( 10 | :str, 11 | :int 12 | ) do 13 | def increment! 14 | self.int += 1 15 | end 16 | end 17 | 18 | def test_int_value 19 | struct = TestSignalsStruct.new(int: 0, str: "") 20 | 21 | assert_equal 0, struct.int 22 | assert_equal 0, struct.int_signal.value 23 | 24 | # Write to a signal 25 | struct.int = 1 26 | 27 | assert_equal 1, struct.int 28 | 29 | struct.increment! 30 | 31 | assert_equal 2, struct.int 32 | end 33 | 34 | def test_str_computed 35 | struct = TestSignalsStruct.new(str: "Doe", int: 0) 36 | name = signal("Jane") 37 | 38 | computed_ran_once = 0 39 | 40 | full_name = computed do 41 | computed_ran_once += 1 42 | name.value + " " + struct.str 43 | end 44 | 45 | assert_equal "Jane Doe", full_name.value 46 | 47 | name.value = "John" 48 | name.value = "Johannes" 49 | # name.value = "..." 50 | # Setting value multiple times won't trigger a computed value refresh 51 | 52 | # NOW we get a refreshed computed value: 53 | assert_equal "Johannes Doe", full_name.value 54 | assert_equal 2, computed_ran_once 55 | 56 | # Test deconstructing 57 | struct => { str: } 58 | assert_equal "Doe", str 59 | end 60 | end 61 | --------------------------------------------------------------------------------