Skip to content

Commit

Permalink
Add Signalize::Struct add-on
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredcwhite committed Oct 4, 2023
1 parent f67f8fa commit 4152c91
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 5 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

## [1.3.0] - 2023-10-04

- Provide `Signalize::Struct`, a struct-like object to hold multiple signals (including computed)
(optional via `require "signalize/struct"`)

## [1.2.0] - 2023-10-03

- Add `untracked` method (implements #5)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
signalize (1.2.0)
signalize (1.3.0)
concurrent-ruby (~> 1.2)

GEM
Expand Down
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ If bundler is not being used to manage dependencies, install the gem by executin

## Usage

Signalize's public API consists of four methods (you can think of them almost like functions): `signal`, `computed`, `effect`, and `batch`.
Signalize's public API consists of five methods (you can think of them almost like functions): `signal`, `untracked`, `computed`, `effect`, and `batch`.

### `signal(initial_value)`

Expand Down Expand Up @@ -232,7 +232,7 @@ end
counter.value = 1 # logs the new value
```

### `peek`
### `signal.peek`

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`.

Expand All @@ -252,6 +252,55 @@ effect do
end
```

## Signalize Struct

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.)

Here's what it looks like:

```ruby
require "signalize/struct"

include Signalize::API

TestSignalsStruct = Signalize::Struct.define(
:str,
:int,
:multiplied_by_10
) do # optional block for adding methods
def increment!
self.int += 1
end
end

struct = TestSignalsStruct.new(
int: 0,
str: "Hello World",
multiplied_by_10: computed { struct.int * 10 }
)

effect do
puts struct.multiplied_by_10 # 0
end

effect do
puts struct.str # "Hello World"
end

struct.increment! # above effect will now output 10
struct.str = "Goodbye!" # above effect will now output "Goodbye!"
```

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`.

Signalize structs require all of their members to be present when initializing…you can't pass only some keyword arguments.

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.

You can call `members` (as both object/class methods) to get a list of the value names in the struct.

Finally, both `inspect` and `to_s` let you debug the contents of a struct.

## Development

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.
Expand All @@ -260,7 +309,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To

## Contributing

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.
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.

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!

Expand Down
91 changes: 91 additions & 0 deletions lib/signalize/struct.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
require "signalize"

module Signalize
class Struct
module Accessors
def members
@members ||= []
end

def signal_accessor(*names)
names.each do |name|
members.push(name.to_sym) unless members.find { _1 == name.to_sym }
signal_getter_name = "#{name}_signal".freeze
ivar_name = "@#{name}".freeze

define_method "#{name}_signal" do
instance_variable_get(ivar_name)
end

define_method name do
send(signal_getter_name)&.value
end

define_method "#{name}=" do |val|
if instance_variable_defined?(ivar_name)
raise Signalize::Error, "Cannot assign a signal to a signal value" if val.is_a?(Signalize::Signal)

sig = instance_variable_get(ivar_name)
if sig.is_a?(Signalize::Computed)
raise Signalize::Error, "Cannot set value of computed signal `#{ivar_name.delete_prefix("@")}'"
end

sig.value = val
else
val = Signalize.signal(val) unless val.is_a?(Signalize::Computed)
instance_variable_set(ivar_name, val)
end
end
end
end
end

extend Accessors

def self.define(*names, &block)
Class.new(self).tap do |struct|
struct.signal_accessor(*names)
struct.class_eval(&block) if block
end
end

def initialize(**data)
# The below code is all to replicate native Ruby ergonomics
unknown_keys = data.keys - members
unless unknown_keys.empty?
plural_suffix = unknown_keys.length > 1 ? "s" : ""
raise ArgumentError, "unknown keyword#{plural_suffix}: #{unknown_keys.map { ":#{_1}" }.join(", ")}"
end

missing_keys = members - data.keys
unless missing_keys.empty?
plural_suffix = missing_keys.length > 1 ? "s" : ""
raise ArgumentError, "missing keyword#{plural_suffix}: #{missing_keys.map { ":#{_1}" }.join(", ")}"
end

# Initialize with keyword arguments
data.each do |k, v|
send("#{k}=", v)
end
end

def members = self.class.members

def deconstruct_keys(...) = to_h.deconstruct_keys(...)

def to_h = members.each_with_object({}) { _2[_1] = send("#{_1}_signal").peek }

def inspect
var_peeks = instance_variables.filter_map do |var_name|
var = instance_variable_get(var_name)
"#{var_name.to_s.delete_prefix("@")}=#{var.peek.inspect}" if var.is_a?(Signalize::Signal)
end.join(", ")

"#<#{self.class}#{var_peeks.empty? ? nil : " #{var_peeks}"}>"
end

def to_s
inspect
end
end
end
2 changes: 1 addition & 1 deletion lib/signalize/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Signalize
VERSION = "1.2.0"
VERSION = "1.3.0"
end
60 changes: 60 additions & 0 deletions test/test_struct.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

require "test_helper"
require "signalize/struct"

class TestStruct < Minitest::Test
include Signalize::API

TestSignalsStruct = Signalize::Struct.define(
:str,
:int
) do
def increment!
self.int += 1
end
end

def test_int_value
struct = TestSignalsStruct.new(int: 0, str: "")

assert_equal 0, struct.int
assert_equal 0, struct.int_signal.value

# Write to a signal
struct.int = 1

assert_equal 1, struct.int

struct.increment!

assert_equal 2, struct.int
end

def test_str_computed
struct = TestSignalsStruct.new(str: "Doe", int: 0)
name = signal("Jane")

computed_ran_once = 0

full_name = computed do
computed_ran_once += 1
name.value + " " + struct.str
end

assert_equal "Jane Doe", full_name.value

name.value = "John"
name.value = "Johannes"
# name.value = "..."
# Setting value multiple times won't trigger a computed value refresh

# NOW we get a refreshed computed value:
assert_equal "Johannes Doe", full_name.value
assert_equal 2, computed_ran_once

# Test deconstructing
struct => { str: }
assert_equal "Doe", str
end
end

0 comments on commit 4152c91

Please sign in to comment.