signalize 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3f252599b06ea9725abc4dd66d0b23562cd5b245dd970106c441b22a0890fe42
4
+ data.tar.gz: 1e46dc796f6bd28fc80292d0ef526ab5691642ec1fb34d213e547a9b9a0b3b22
5
+ SHA512:
6
+ metadata.gz: b4eb905112dc61711495cca9cca462f37a1a50751b7d0434fd15b916276af57c73e08ac055d14d0e45534e8d82e8a2ad1576197772ffd544036cccc27d548c72
7
+ data.tar.gz: 8e4bf5a24a1ede2912a1f0b9265598d616829d2d9b0ef28eece14dbb5859d1c1fd75c8a19bbe3eb6ee9bb5922b65a5079e7d578cc75788cac4d13f0f18aca247
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [1.0.0] - 2023-03-07
4
+
5
+ - Initial release
@@ -0,0 +1,84 @@
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.
data/Gemfile ADDED
@@ -0,0 +1,13 @@
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"
data/Gemfile.lock ADDED
@@ -0,0 +1,56 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ signalize (1.0.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ coderay (1.1.3)
10
+ ffi (1.15.5)
11
+ formatador (1.1.0)
12
+ guard (2.18.0)
13
+ formatador (>= 0.2.4)
14
+ listen (>= 2.7, < 4.0)
15
+ lumberjack (>= 1.0.12, < 2.0)
16
+ nenv (~> 0.1)
17
+ notiffany (~> 0.0)
18
+ pry (>= 0.13.0)
19
+ shellany (~> 0.0)
20
+ thor (>= 0.18.1)
21
+ guard-compat (1.2.1)
22
+ guard-minitest (2.4.6)
23
+ guard-compat (~> 1.2)
24
+ minitest (>= 3.0)
25
+ listen (3.8.0)
26
+ rb-fsevent (~> 0.10, >= 0.10.3)
27
+ rb-inotify (~> 0.9, >= 0.9.10)
28
+ lumberjack (1.2.8)
29
+ method_source (1.0.0)
30
+ minitest (5.18.0)
31
+ nenv (0.3.0)
32
+ notiffany (0.1.3)
33
+ nenv (~> 0.1)
34
+ shellany (~> 0.0)
35
+ pry (0.14.2)
36
+ coderay (~> 1.1)
37
+ method_source (~> 1.0)
38
+ rake (13.0.6)
39
+ rb-fsevent (0.11.2)
40
+ rb-inotify (0.10.1)
41
+ ffi (~> 1.0)
42
+ shellany (0.0.1)
43
+ thor (1.2.1)
44
+
45
+ PLATFORMS
46
+ arm64-darwin-21
47
+
48
+ DEPENDENCIES
49
+ guard
50
+ guard-minitest
51
+ minitest (~> 5.0)
52
+ rake (~> 13.0)
53
+ signalize!
54
+
55
+ BUNDLED WITH
56
+ 2.3.14
data/Guardfile ADDED
@@ -0,0 +1,42 @@
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
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
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.
data/README.md ADDED
@@ -0,0 +1,195 @@
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 four methods (you can think of them almost like functions): `signal`, `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
+ counter = Signalize.signal(0)
36
+
37
+ # Read value from signal, logs: 0
38
+ puts counter.value
39
+
40
+ # Write to a signal
41
+ counter.value = 1
42
+ ```
43
+
44
+ You can include the `Signalize::API` mixin to access these methods directly in any context:
45
+
46
+ ```ruby
47
+ include Signalize::API
48
+
49
+ counter = signal(0)
50
+
51
+ counter.value += 1
52
+ ```
53
+
54
+ ### `computed { }`
55
+
56
+ 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".
57
+
58
+ ```ruby
59
+ include Signalize::API
60
+
61
+ name = signal("Jane")
62
+ surname = signal("Doe")
63
+
64
+ full_name = computed do
65
+ name.value + " " + surname.value
66
+ end
67
+
68
+ # Logs: "Jane Doe"
69
+ puts full_name.value
70
+
71
+ name.value = "John"
72
+ name.value = "Johannes"
73
+ # name.value = "..."
74
+ # Setting value multiple times won't trigger a computed value refresh
75
+
76
+ # NOW we get a refreshed computed value:
77
+ puts full_name.value
78
+ ```
79
+
80
+ ### `effect { }`
81
+
82
+ 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.
83
+
84
+ ```ruby
85
+ include Signalize::API
86
+
87
+ name = signal("Jane")
88
+ surname = signal("Doe")
89
+ full_name = computed { name.value + " " + surname.value }
90
+
91
+ # Logs: "Jane Doe"
92
+ effect { puts full_name.value }
93
+
94
+ # Updating one of its dependencies will automatically trigger
95
+ # the effect above, and will print "John Doe" to the console.
96
+ name.value = "John"
97
+ ```
98
+
99
+ You can dispose of an effect whenever you want, thereby unsubscribing it from signal notifications.
100
+
101
+ ```ruby
102
+ include Signalize::API
103
+
104
+ name = signal("Jane")
105
+ surname = signal("Doe")
106
+ full_name = computed { name.value + " " + surname.value }
107
+
108
+ # Logs: "Jane Doe"
109
+ dispose = effect { puts full_name.value }
110
+
111
+ # Destroy effect and subscriptions
112
+ dispose.()
113
+
114
+ # Update does nothing, because no one is subscribed anymore.
115
+ # Even the computed `full_name` signal won't change, because it knows
116
+ # that no one listens to it.
117
+ surname.value = "Doe 2"
118
+ ```
119
+
120
+ ### `batch { }`
121
+
122
+ You can write to multiple signals within a batch, and flush the updates at all once (thereby notifying computed refreshes and effects).
123
+
124
+ ```ruby
125
+ include Signalize::API
126
+
127
+ name = signal("Jane")
128
+ surname = signal("Doe")
129
+ full_name = computed { name.value + " " + surname.value }
130
+
131
+ # Logs: "Jane Doe"
132
+ dispose = effect { puts full_name.value }
133
+
134
+ batch do
135
+ name.value = "Foo"
136
+ surname.value = "Bar"
137
+ end
138
+ ```
139
+
140
+ ### `signal.subscribe { }`
141
+
142
+ 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.
143
+
144
+ ```ruby
145
+ include Signalize::API
146
+
147
+ counter = signal(0)
148
+
149
+ counter.subscribe do |new_value|
150
+ puts "The new value is #{new_value}"
151
+ end
152
+
153
+ counter.value = 1 # logs the new value
154
+ ```
155
+
156
+ ### `peek`
157
+
158
+ 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`.
159
+
160
+ ```ruby
161
+ include Signalize::API
162
+
163
+ counter = signal(0)
164
+ effect_count = signal(0)
165
+
166
+ effect do
167
+ puts counter.value
168
+
169
+ # Whenever this effect is triggered, increase `effect_count`.
170
+ # But we don't want this signal to react to `effect_count`
171
+ effect_count.value = effect_count.peek
172
+ end
173
+ ```
174
+
175
+ ## Development
176
+
177
+ 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.
178
+
179
+ 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).
180
+
181
+ ## Contributing
182
+
183
+ 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. 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.
184
+
185
+ 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!
186
+
187
+ 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).
188
+
189
+ ## License
190
+
191
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
192
+
193
+ ## Code of Conduct
194
+
195
+ Everyone interacting in the Signalize project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/whitefusionhq/signalize/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Signalize
4
+ VERSION = "1.0.0"
5
+ end
data/lib/signalize.rb ADDED
@@ -0,0 +1,661 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "signalize/version"
4
+
5
+ module Signalize
6
+ class Error < StandardError; end
7
+
8
+ class << self
9
+ def class_variablize(name)
10
+ define_singleton_method "#{name}" do
11
+ class_variable_get("@@#{name}")
12
+ end
13
+ define_singleton_method "#{name}=" do |value|
14
+ class_variable_set("@@#{name}", value)
15
+ end
16
+ end
17
+ end
18
+
19
+ def self.cycle_detected
20
+ raise "Cycle detected"
21
+ end
22
+
23
+ RUNNING = 1 << 0
24
+ NOTIFIED = 1 << 1
25
+ OUTDATED = 1 << 2
26
+ DISPOSED = 1 << 3
27
+ HAS_ERROR = 1 << 4
28
+ TRACKING = 1 << 5
29
+
30
+ # Computed | Effect | nil
31
+ @@eval_context = nil
32
+ class_variablize :eval_context
33
+
34
+ # Effects collected into a batch.
35
+ @@batched_effect = nil
36
+ class_variablize :batched_effect
37
+ @@batch_depth = 0
38
+ class_variablize :batch_depth
39
+ @@batch_iteration = 0
40
+ class_variablize :batch_iteration
41
+
42
+ # NOTE: we have removed the global version optimization for Ruby, due to
43
+ # the possibility of long-running server processes and the number reaching
44
+ # a dangerously high integer value.
45
+ #
46
+ # @@global_version = 0
47
+ # class_variablize :global_version
48
+
49
+ Node = Struct.new(
50
+ :_version,
51
+ :_source,
52
+ :_prev_source,
53
+ :_next_source,
54
+ :_target,
55
+ :_prev_target,
56
+ :_next_target,
57
+ :_rollback_node,
58
+ keyword_init: true
59
+ )
60
+
61
+ class << self
62
+ ## Batch-related helpers ##
63
+
64
+ def start_batch
65
+ self.batch_depth += 1
66
+ end
67
+
68
+ def end_batch
69
+ if batch_depth > 1
70
+ self.batch_depth -= 1
71
+ return
72
+ end
73
+ error = nil
74
+ hasError = false
75
+
76
+ while batched_effect.nil?.!
77
+ effect = batched_effect
78
+ self.batched_effect = nil
79
+
80
+ self.batch_iteration += 1
81
+ while effect.nil?.!
82
+ nxt = effect._next_batched_effect
83
+ effect._next_batched_effect = nil
84
+ effect._flags &= ~NOTIFIED
85
+ unless (effect._flags & DISPOSED).nonzero? && needs_to_recompute(effect)
86
+ begin
87
+ effect._callback
88
+ rescue StandardError => err
89
+ unless hasError
90
+ error = err
91
+ hasError = true
92
+ end
93
+ end
94
+ end
95
+
96
+ effect = nxt
97
+ end
98
+ end
99
+
100
+ self.batch_iteration = 0
101
+ self.batch_depth -= 1
102
+
103
+ raise error if hasError
104
+ end
105
+
106
+ def batch
107
+ return yield unless batch_depth.zero?
108
+
109
+ start_batch
110
+
111
+ begin
112
+ return yield
113
+ ensure
114
+ end_batch
115
+ end
116
+ end
117
+
118
+ ## Signal-related helpers ##
119
+
120
+ def add_dependency(signal)
121
+ return nil if eval_context.nil?
122
+
123
+ node = signal._node
124
+ if node.nil? || node._target != eval_context
125
+ # /**
126
+ # * `signal` is a new dependency. Create a new dependency node, and set it
127
+ # * as the tail of the current context's dependency list. e.g:
128
+ # *
129
+ # * { A <-> B }
130
+ # * ↑ ↑
131
+ # * tail node (new)
132
+ # * ↓
133
+ # * { A <-> B <-> C }
134
+ # * ↑
135
+ # * tail (evalContext._sources)
136
+ # */
137
+ node = Node.new(
138
+ _version: 0,
139
+ _source: signal,
140
+ _prev_source: eval_context._sources,
141
+ _next_source: nil,
142
+ _target: eval_context,
143
+ _prev_target: nil,
144
+ _next_target: nil,
145
+ _rollback_node: node,
146
+ )
147
+
148
+ unless eval_context._sources.nil?
149
+ eval_context._sources._next_source = node
150
+ end
151
+ eval_context._sources = node
152
+ signal._node = node
153
+
154
+ # Subscribe to change notifications from this dependency if we're in an effect
155
+ # OR evaluating a computed signal that in turn has subscribers.
156
+ if (eval_context._flags & TRACKING).nonzero?
157
+ signal._subscribe(node)
158
+ end
159
+ return node
160
+ elsif node._version == -1
161
+ # `signal` is an existing dependency from a previous evaluation. Reuse it.
162
+ node._version = 0
163
+
164
+ # /**
165
+ # * If `node` is not already the current tail of the dependency list (i.e.
166
+ # * there is a next node in the list), then make the `node` the new tail. e.g:
167
+ # *
168
+ # * { A <-> B <-> C <-> D }
169
+ # * ↑ ↑
170
+ # * node ┌─── tail (evalContext._sources)
171
+ # * └─────│─────┐
172
+ # * ↓ ↓
173
+ # * { A <-> C <-> D <-> B }
174
+ # * ↑
175
+ # * tail (evalContext._sources)
176
+ # */
177
+ unless node._next_source.nil?
178
+ node._next_source._prev_source = node._prev_source
179
+
180
+ unless node._prev_source.nil?
181
+ node._prev_source._next_source = node._next_source
182
+ end
183
+
184
+ node._prev_source = eval_context._sources
185
+ node._next_source = nil
186
+
187
+ eval_context._sources._next_source = node
188
+ eval_context._sources = node
189
+ end
190
+
191
+ # We can assume that the currently evaluated effect / computed signal is already
192
+ # subscribed to change notifications from `signal` if needed.
193
+ return node
194
+ end
195
+
196
+ nil
197
+ end
198
+
199
+ ## Computed/Effect-related helpers ##
200
+
201
+ def needs_to_recompute(target)
202
+ # Check the dependencies for changed values. The dependency list is already
203
+ # in order of use. Therefore if multiple dependencies have changed values, only
204
+ # the first used dependency is re-evaluated at this point.
205
+ node = target._sources
206
+ while node.nil?.!
207
+ # If there's a new version of the dependency before or after refreshing,
208
+ # or the dependency has something blocking it from refreshing at all (e.g. a
209
+ # dependency cycle), then we need to recompute.
210
+ if node._source._version != node._version || !node._source._refresh || node._source._version != node._version
211
+ return true
212
+ end
213
+ node = node._next_source
214
+ end
215
+ # If none of the dependencies have changed values since last recompute then
216
+ # there's no need to recompute.
217
+ false
218
+ end
219
+
220
+ def prepare_sources(target)
221
+ # /**
222
+ # * 1. Mark all current sources as re-usable nodes (version: -1)
223
+ # * 2. Set a rollback node if the current node is being used in a different context
224
+ # * 3. Point 'target._sources' to the tail of the doubly-linked list, e.g:
225
+ # *
226
+ # * { undefined <- A <-> B <-> C -> undefined }
227
+ # * ↑ ↑
228
+ # * │ └──────┐
229
+ # * target._sources = A; (node is head) │
230
+ # * ↓ │
231
+ # * target._sources = C; (node is tail) ─┘
232
+ # */
233
+ node = target._sources
234
+ while node.nil?.!
235
+ rollbackNode = node._source._node
236
+ node._rollback_node = rollbackNode unless rollbackNode.nil?
237
+ node._source._node = node
238
+ node._version = -1
239
+
240
+ if node._next_source.nil?
241
+ target._sources = node
242
+ break
243
+ end
244
+
245
+ node = node._next_source
246
+ end
247
+ end
248
+
249
+ def cleanup_sources(target)
250
+ node = target._sources
251
+ head = nil
252
+
253
+ # /**
254
+ # * At this point 'target._sources' points to the tail of the doubly-linked list.
255
+ # * It contains all existing sources + new sources in order of use.
256
+ # * Iterate backwards until we find the head node while dropping old dependencies.
257
+ # */
258
+ while node.nil?.!
259
+ prev = node._prev_source
260
+
261
+ # /**
262
+ # * The node was not re-used, unsubscribe from its change notifications and remove itself
263
+ # * from the doubly-linked list. e.g:
264
+ # *
265
+ # * { A <-> B <-> C }
266
+ # * ↓
267
+ # * { A <-> C }
268
+ # */
269
+ if node._version == -1
270
+ node._source._unsubscribe(node)
271
+
272
+ unless prev.nil?
273
+ prev._next_source = node._next_source
274
+ end
275
+ unless node._next_source.nil?
276
+ node._next_source._prev_source = prev
277
+ end
278
+ else
279
+ # /**
280
+ # * The new head is the last node seen which wasn't removed/unsubscribed
281
+ # * from the doubly-linked list. e.g:
282
+ # *
283
+ # * { A <-> B <-> C }
284
+ # * ↑ ↑ ↑
285
+ # * │ │ └ head = node
286
+ # * │ └ head = node
287
+ # * └ head = node
288
+ # */
289
+ head = node
290
+ end
291
+
292
+ node._source._node = node._rollback_node
293
+ unless node._rollback_node.nil?
294
+ node._rollback_node = nil
295
+ end
296
+
297
+ node = prev
298
+ end
299
+
300
+ target._sources = head
301
+ end
302
+
303
+ ## Effect-related helpers ##
304
+
305
+ def cleanup_effect(effect)
306
+ cleanup = effect._cleanup
307
+ effect._cleanup = nil
308
+
309
+ if cleanup.is_a?(Proc)
310
+ start_batch
311
+
312
+ # Run cleanup functions always outside of any context.
313
+ prev_context = eval_context
314
+ self.eval_context = nil
315
+ begin
316
+ cleanup.()
317
+ rescue StandardError => err
318
+ effect._flags &= ~RUNNING
319
+ effect._flags |= DISPOSED
320
+ dispose_effect(effect)
321
+ raise err
322
+ ensure
323
+ self.eval_context = prev_context
324
+ end_batch
325
+ end
326
+ end
327
+ end
328
+
329
+ def dispose_effect(effect)
330
+ node = effect._sources
331
+ while node.nil?.!
332
+ node._source._unsubscribe(node)
333
+ node = node._next_source
334
+ end
335
+ effect._compute = nil
336
+ effect._sources = nil
337
+
338
+ cleanup_effect(effect)
339
+ end
340
+
341
+ def end_effect(effect, prev_context, *_) # allow additional args for currying
342
+ raise "Out-of-order effect" if eval_context != effect
343
+
344
+ cleanup_sources(effect)
345
+ self.eval_context = prev_context
346
+
347
+ effect._flags &= ~RUNNING
348
+ dispose_effect(effect) if (effect._flags & DISPOSED).nonzero?
349
+ end_batch
350
+ end
351
+ end
352
+
353
+ class Signal
354
+ attr_accessor :_version, :_node, :_targets
355
+
356
+ def initialize(value)
357
+ @value = value
358
+ @_version = 0;
359
+ @_node = nil
360
+ @_targets = nil
361
+ end
362
+
363
+ def _refresh = true
364
+
365
+ def _subscribe(node)
366
+ if _targets != node && node._prev_target.nil?
367
+ node._next_target = _targets
368
+ _targets._prev_target = node if !_targets.nil?
369
+ self._targets = node
370
+ end
371
+ end
372
+
373
+ def _unsubscribe(node)
374
+ # Only run the unsubscribe step if the signal has any subscribers to begin with.
375
+ if !_targets.nil?
376
+ prev = node._prev_target
377
+ nxt = node._next_target
378
+ if !prev.nil?
379
+ prev._next_target = nxt
380
+ node._prev_target = nil
381
+ end
382
+ if !nxt.nil?
383
+ nxt._prev_target = prev
384
+ node._next_target = nil
385
+ end
386
+ self._targets = nxt if node == _targets
387
+ end
388
+ end
389
+
390
+ def subscribe(&fn)
391
+ signal = self
392
+ this = Effect.allocate
393
+ this.send(:initialize, -> {
394
+ value = signal.value
395
+ flag = this._flags & TRACKING
396
+ this._flags &= ~TRACKING;
397
+ begin
398
+ fn.(value)
399
+ ensure
400
+ this._flags |= flag
401
+ end
402
+ })
403
+
404
+ Signalize.effect(this)
405
+ end
406
+
407
+ def value
408
+ node = Signalize.add_dependency(self)
409
+ node._version = _version unless node.nil?
410
+ @value
411
+ end
412
+
413
+ def value=(value)
414
+ if value != @value
415
+ Signalize.cycle_detected if Signalize.batch_iteration > 100
416
+
417
+ @value = value;
418
+ @_version += 1
419
+ # Signalize.global_version += 1
420
+
421
+ Signalize.start_batch
422
+ begin
423
+ node = _targets
424
+ while node.nil?.!
425
+ node._target._notify
426
+ node = node._next_target
427
+ end
428
+ ensure
429
+ Signalize.end_batch
430
+ end
431
+ end
432
+ end
433
+
434
+ def to_s
435
+ @value.to_s
436
+ end
437
+
438
+ def peek = @value
439
+ end
440
+
441
+ class Computed < Signal
442
+ attr_accessor :_compute, :_sources, :_flags
443
+
444
+ def initialize(compute)
445
+ super(nil)
446
+
447
+ @_compute = compute
448
+ @_sources = nil
449
+ # @_global_version = Signalize.global_version - 1
450
+ @_flags = OUTDATED
451
+ end
452
+
453
+ def _refresh
454
+ @_flags &= ~NOTIFIED
455
+
456
+ return false if (@_flags & RUNNING).nonzero?
457
+
458
+ # If this computed signal has subscribed to updates from its dependencies
459
+ # (TRACKING flag set) and none of them have notified about changes (OUTDATED
460
+ # flag not set), then the computed value can't have changed.
461
+ return true if (@_flags & (OUTDATED | TRACKING)) == TRACKING
462
+
463
+ @_flags &= ~OUTDATED
464
+
465
+ # NOTE: performance optimization removed.
466
+ #
467
+ # if @_global_version == Signalize.global_version
468
+ # return true
469
+ # end
470
+ # @_global_version = Signalize.global_version
471
+
472
+ # Mark this computed signal running before checking the dependencies for value
473
+ # changes, so that the RUNNING flag can be used to notice cyclical dependencies.
474
+ @_flags |= RUNNING
475
+ if @_version > 0 && !Signalize.needs_to_recompute(self)
476
+ @_flags &= ~RUNNING
477
+ return true
478
+ end
479
+
480
+ prevContext = Signalize.eval_context
481
+ begin
482
+ Signalize.prepare_sources(self)
483
+ Signalize.eval_context = self
484
+ value = @_compute.()
485
+ if (@_flags & HAS_ERROR).nonzero? || @value != value || @_version == 0
486
+ @value = value
487
+ @_flags &= ~HAS_ERROR
488
+ @_version += 1
489
+ end
490
+ rescue StandardError => err
491
+ @value = err;
492
+ @_flags |= HAS_ERROR
493
+ @_version += 1
494
+ end
495
+ Signalize.eval_context = prevContext
496
+ Signalize.cleanup_sources(self)
497
+ @_flags &= ~RUNNING
498
+
499
+ true
500
+ end
501
+
502
+ def _subscribe(node)
503
+ if @_targets.nil?
504
+ @_flags |= OUTDATED | TRACKING
505
+
506
+ # A computed signal subscribes lazily to its dependencies when the it
507
+ # gets its first subscriber.
508
+
509
+ # RUBY NOTE: if we redefine `node`` here, it messes with `node` top method scope!
510
+ # So we'll use a new variable name `snode`
511
+ snode = @_sources
512
+ while snode.nil?.!
513
+ snode._source._subscribe(snode)
514
+ snode = snode._next_source
515
+ end
516
+ end
517
+ super(node)
518
+ end
519
+
520
+ def _unsubscribe(node)
521
+ # Only run the unsubscribe step if the computed signal has any subscribers.
522
+ unless @_target.nil?
523
+ super(node)
524
+
525
+ # Computed signal unsubscribes from its dependencies when it loses its last subscriber.
526
+ # This makes it possible for unreferences subgraphs of computed signals to get garbage collected.
527
+ if @_targets.nil?
528
+ @_flags &= ~TRACKING
529
+
530
+ node = @_sources
531
+
532
+ while node.nil?.!
533
+ node._source._unsubscribe(node)
534
+ node = node._next_source
535
+ end
536
+ end
537
+ end
538
+ end
539
+
540
+ def _notify
541
+ unless (@_flags & NOTIFIED).nonzero?
542
+ @_flags |= OUTDATED | NOTIFIED
543
+
544
+ node = @_targets
545
+ while node.nil?.!
546
+ node._target._notify
547
+ node = node._next_target
548
+ end
549
+ end
550
+ end
551
+
552
+ def peek
553
+ Signalize.cycle_detected unless _refresh
554
+
555
+ raise @value if (@_flags & HAS_ERROR).nonzero?
556
+
557
+ @value
558
+ end
559
+
560
+ def value
561
+ Signalize.cycle_detected if (@_flags & RUNNING).nonzero?
562
+
563
+ node = Signalize.add_dependency(self)
564
+ _refresh
565
+
566
+ node._version = @_version unless node.nil?
567
+
568
+ raise @value if (@_flags & HAS_ERROR).nonzero?
569
+
570
+ @value
571
+ end
572
+ end
573
+
574
+ class Effect
575
+ attr_accessor :_compute, :_cleanup, :_sources, :_next_batched_effect, :_flags
576
+
577
+ def initialize(compute)
578
+ @_compute = compute
579
+ @_cleanup = nil
580
+ @_sources = nil
581
+ @_next_batched_effect = nil
582
+ @_flags = TRACKING
583
+ end
584
+
585
+ def _callback
586
+ finis = _start
587
+
588
+ begin
589
+ @_cleanup = _compute.() if (@_flags & DISPOSED).zero? && @_compute.nil?.!
590
+ ensure
591
+ finis.(nil) # TODO: figure out this weird shit
592
+ end
593
+ end
594
+
595
+ def _start
596
+ Signalize.cycle_detected if (@_flags & RUNNING).nonzero?
597
+
598
+ @_flags |= RUNNING
599
+ @_flags &= ~DISPOSED
600
+ Signalize.cleanup_effect(self)
601
+ Signalize.prepare_sources(self)
602
+
603
+ Signalize.start_batch
604
+ prev_context = Signalize.eval_context
605
+ Signalize.eval_context = self
606
+
607
+ Signalize.method(:end_effect).curry(3).call(self, prev_context) # HUH
608
+ end
609
+
610
+ def _notify
611
+ unless (@_flags & NOTIFIED).nonzero?
612
+ @_flags |= NOTIFIED
613
+ @_next_batched_effect = Signalize.batched_effect
614
+ Signalize.batched_effect = self
615
+ end
616
+ end
617
+
618
+ def _dispose
619
+ @_flags |= DISPOSED
620
+
621
+ Signalize.dispose_effect(self) unless (@_flags & RUNNING).nonzero?
622
+ end
623
+ end
624
+
625
+ module API
626
+ def signal(value)
627
+ Signal.new(value)
628
+ end
629
+
630
+ def computed(&block)
631
+ Computed.new(block)
632
+ end
633
+
634
+ def effect(effect_instance = nil, &block)
635
+ effect = effect_instance || Effect.new(block)
636
+
637
+ begin
638
+ effect._callback
639
+ rescue StandardError => err
640
+ effect._dispose
641
+ raise err
642
+ end
643
+
644
+ effect.method(:_dispose)
645
+ end
646
+
647
+ def batch
648
+ return yield unless Signalize.batch_depth.zero?
649
+
650
+ Signalize.start_batch
651
+
652
+ begin
653
+ return yield
654
+ ensure
655
+ Signalize.end_batch
656
+ end
657
+ end
658
+ end
659
+
660
+ extend API
661
+ end
data/signalize.gemspec ADDED
@@ -0,0 +1,34 @@
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 "example-gem", "~> 1.0"
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
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: signalize
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jared White
8
+ - Preact Team
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2023-03-08 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: A Ruby port of Signals, providing reactive variables, derived computed
15
+ state, side effect callbacks, and batched updates.
16
+ email:
17
+ - jared@whitefusion.studio
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CODE_OF_CONDUCT.md
24
+ - Gemfile
25
+ - Gemfile.lock
26
+ - Guardfile
27
+ - LICENSE.txt
28
+ - README.md
29
+ - Rakefile
30
+ - lib/signalize.rb
31
+ - lib/signalize/version.rb
32
+ - signalize.gemspec
33
+ homepage: https://github.com/whitefusionhq/signalize
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ homepage_uri: https://github.com/whitefusionhq/signalize
38
+ source_code_uri: https://github.com/whitefusionhq/signalize
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.0.0
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.3.3
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: A Ruby port of Signals, providing reactive variables, derived computed state,
58
+ side effect callbacks, and batched updates.
59
+ test_files: []