funicular 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,171 @@
1
+ module Funicular
2
+ class Store
3
+ # Collection store: an ordered Array per scope, with bounded size and a
4
+ # key proc that supports remove(id) / same_tail? semantics.
5
+ #
6
+ # class MessageCache < Funicular::Store::Collection
7
+ # database "funicular_message_cache"
8
+ # scope :channel_id
9
+ # limit 100
10
+ # key ->(m) { m["id"] }
11
+ # cleared_on :logout
12
+ #
13
+ # subscribes_to "ChatChannel",
14
+ # params: ->(s) { { channel: "ChatChannel", channel_id: s.channel_id } } do |data, _scope|
15
+ # case data["type"]
16
+ # when "initial_messages" then replace(data["messages"] || [])
17
+ # when "new_message" then append(data["message"])
18
+ # when "delete_message" then remove(data["message_id"])
19
+ # end
20
+ # end
21
+ # end
22
+ class Collection < Store
23
+ DEFAULT_KEY_PROC = ->(item) {
24
+ item.is_a?(Hash) ? item["id"] : nil
25
+ }
26
+
27
+ class << self
28
+ attr_reader :__limit, :__order, :__key_proc
29
+
30
+ def limit(n)
31
+ @__limit = n
32
+ end
33
+
34
+ # :append (default) keeps the most recent items at the tail and caps
35
+ # by truncating the head; :prepend caps by truncating the tail.
36
+ def order(direction)
37
+ @__order = direction.to_sym
38
+ end
39
+
40
+ def key(proc)
41
+ @__key_proc = proc
42
+ end
43
+
44
+ def scope_class
45
+ Funicular::Store::Collection::Scope
46
+ end
47
+ end
48
+
49
+ class Scope < Funicular::Store::Scope
50
+ def all
51
+ rec = read
52
+ return [] unless rec.is_a?(Hash)
53
+ if expired_record?(rec)
54
+ erase
55
+ return []
56
+ end
57
+ items = rec["items"]
58
+ items.is_a?(Array) ? items : []
59
+ end
60
+
61
+ def replace(arr)
62
+ new_arr = cap(arr.is_a?(Array) ? arr : [])
63
+ # Skip IndexedDB write if the cached snapshot already matches by
64
+ # tail. Always fire callback so subscribers know replace completed
65
+ # (e.g. to clear loading state).
66
+ unless same_tail?(new_arr)
67
+ write(new_arr)
68
+ end
69
+ fire_change(new_arr)
70
+ new_arr
71
+ end
72
+
73
+ def append(item)
74
+ new_arr = cap(append_to(all, item))
75
+ write(new_arr)
76
+ fire_change(new_arr)
77
+ new_arr
78
+ end
79
+
80
+ def remove(id)
81
+ cur = all
82
+ kp = key_proc
83
+ new_arr = cur.reject { |m| kp.call(m) == id }
84
+ return cur if new_arr.size == cur.size
85
+ write(new_arr)
86
+ fire_change(new_arr)
87
+ new_arr
88
+ end
89
+
90
+ def last
91
+ arr = all
92
+ arr.empty? ? nil : arr[arr.size - 1]
93
+ end
94
+
95
+ def last_id
96
+ l = last
97
+ return nil unless l
98
+ key_proc.call(l)
99
+ end
100
+
101
+ def size
102
+ all.size
103
+ end
104
+
105
+ def clear
106
+ erase
107
+ fire_change([])
108
+ nil
109
+ end
110
+
111
+ def expired?
112
+ rec = read
113
+ expired_record?(rec)
114
+ end
115
+
116
+ # True iff `other` matches the current cached snapshot by size and
117
+ # last-item key. Cheap staleness probe used by callers that already
118
+ # have a fresh server response and want to skip a redundant
119
+ # state-replace re-render.
120
+ def same_tail?(other)
121
+ return false unless other.is_a?(Array)
122
+ cur = all
123
+ return false if cur.size != other.size
124
+ return true if cur.empty? && other.empty?
125
+ kp = key_proc
126
+ kp.call(cur[cur.size - 1]) == kp.call(other[other.size - 1])
127
+ end
128
+
129
+ private
130
+
131
+ def key_proc
132
+ @store_class.__key_proc || Funicular::Store::Collection::DEFAULT_KEY_PROC
133
+ end
134
+
135
+ def cap(arr)
136
+ lim = @store_class.__limit
137
+ return arr unless lim.is_a?(Integer) && lim < arr.size
138
+ if @store_class.__order == :prepend
139
+ arr[0, lim] || arr
140
+ else
141
+ arr[arr.size - lim, lim] || arr
142
+ end
143
+ end
144
+
145
+ def append_to(arr, item)
146
+ if @store_class.__order == :prepend
147
+ [item] + arr
148
+ else
149
+ arr + [item]
150
+ end
151
+ end
152
+
153
+ def read
154
+ kvs[storage_key]
155
+ end
156
+
157
+ def write(items)
158
+ kvs[storage_key] = {
159
+ "items" => items,
160
+ "wrote_at" => now_seconds,
161
+ "expires_in" => @store_class.__expires_in
162
+ }
163
+ end
164
+
165
+ def erase
166
+ kvs.delete(storage_key)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,79 @@
1
+ module Funicular
2
+ class Store
3
+ # Singleton store: one value per scope. Suitable for things like a
4
+ # per-channel draft text or per-user preferences blob.
5
+ #
6
+ # class DraftStore < Funicular::Store::Singleton
7
+ # database "funicular_drafts"
8
+ # scope :channel_id
9
+ # cleared_on :logout
10
+ # end
11
+ #
12
+ # draft = DraftStore.where(channel_id: 1)
13
+ # draft.value = "hello"
14
+ # draft.value # => "hello"
15
+ # draft.delete
16
+ class Singleton < Store
17
+ def self.scope_class
18
+ Funicular::Store::Singleton::Scope
19
+ end
20
+
21
+ class Scope < Funicular::Store::Scope
22
+ def value
23
+ rec = read
24
+ return nil unless rec.is_a?(Hash)
25
+ if expired_record?(rec)
26
+ erase
27
+ return nil
28
+ end
29
+ rec["v"]
30
+ end
31
+
32
+ # Setting "" on a String-typed value deletes the entry, matching
33
+ # the semantics of the original DraftStore.
34
+ def value=(v)
35
+ if v.is_a?(String) && v.empty?
36
+ delete
37
+ return v
38
+ end
39
+ write(v)
40
+ fire_change(v)
41
+ v
42
+ end
43
+
44
+ def delete
45
+ erase
46
+ fire_change(nil)
47
+ nil
48
+ end
49
+
50
+ def present?
51
+ !value.nil?
52
+ end
53
+
54
+ def expired?
55
+ rec = read
56
+ expired_record?(rec)
57
+ end
58
+
59
+ private
60
+
61
+ def read
62
+ kvs[storage_key]
63
+ end
64
+
65
+ def write(v)
66
+ kvs[storage_key] = {
67
+ "v" => v,
68
+ "wrote_at" => now_seconds,
69
+ "expires_in" => @store_class.__expires_in
70
+ }
71
+ end
72
+
73
+ def erase
74
+ kvs.delete(storage_key)
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
data/mrblib/styles.rb ADDED
@@ -0,0 +1,83 @@
1
+ module Funicular
2
+ class StyleValue
3
+ attr_reader :value
4
+
5
+ def initialize(value)
6
+ @value = value.to_s
7
+ end
8
+
9
+ def |(other)
10
+ case other
11
+ when StyleValue
12
+ StyleValue.new("#{@value} #{other.value}".strip)
13
+ when String
14
+ StyleValue.new("#{@value} #{other}".strip)
15
+ when nil
16
+ self
17
+ else
18
+ other = other #: untyped
19
+ StyleValue.new("#{@value} #{other.to_s}".strip)
20
+ end
21
+ end
22
+
23
+ def to_s
24
+ @value
25
+ end
26
+ end
27
+
28
+ class StyleAccessor
29
+ def initialize(definitions)
30
+ @definitions = definitions
31
+ end
32
+
33
+ def method_missing(name, *args)
34
+ style = @definitions[name]
35
+ return StyleValue.new("") unless style
36
+
37
+ if args.empty?
38
+ # No arguments: return base or value
39
+ StyleValue.new(style[:base] || style[:value] || "")
40
+ elsif args[0] == true || args[0] == false
41
+ # Boolean argument: base + active (if true)
42
+ # JS::Object#== now supports direct comparison with Ruby true/false
43
+ base = style[:base] || ""
44
+ active_class = (args[0] == true) ? (style[:active] || "") : ""
45
+ StyleValue.new("#{base} #{active_class}".strip)
46
+ elsif args[0].is_a?(Symbol)
47
+ # Symbol argument: base + variants[symbol]
48
+ base = style[:base] || ""
49
+ variant_class = style[:variants] ? (style[:variants][args[0]] || "") : ""
50
+ StyleValue.new("#{base} #{variant_class}".strip)
51
+ else
52
+ # Other types: just return base
53
+ StyleValue.new(style[:base] || style[:value] || "")
54
+ end
55
+ end
56
+
57
+ def respond_to_missing?(name, include_private = false)
58
+ @definitions.key?(name) || super
59
+ end
60
+ end
61
+
62
+ class StyleBuilder
63
+ def initialize
64
+ @definitions = {}
65
+ end
66
+
67
+ def method_missing(name, *args)
68
+ if args.size == 1 && args[0].is_a?(String)
69
+ # Simple style: name "class-string"
70
+ @definitions[name] = { value: args[0] }
71
+ elsif args.size == 1 && args[0].is_a?(Hash)
72
+ # Complex style with base/active/variants
73
+ @definitions[name] = args[0]
74
+ else
75
+ raise ArgumentError, "Invalid style definition for #{name}"
76
+ end
77
+ end
78
+
79
+ def to_definitions
80
+ @definitions
81
+ end
82
+ end
83
+ end
data/mrblib/vdom.rb ADDED
@@ -0,0 +1,273 @@
1
+ module Funicular
2
+ module VDOM
3
+ BOOLEAN_ATTRIBUTES = %w[
4
+ disabled checked selected readonly required autofocus multiple
5
+ ]
6
+
7
+ URL_ATTRIBUTES = %w[href src action formaction data poster xlink:href]
8
+
9
+ class VNode
10
+ attr_reader :type, :key
11
+
12
+ def initialize(type)
13
+ @type = type
14
+ end
15
+ end
16
+
17
+ class Element < VNode
18
+ attr_reader :tag, :props, :children
19
+
20
+ def initialize(tag, props = {}, children = [])
21
+ super(:element)
22
+ @tag = tag.to_s
23
+ @key = props.delete(:key)
24
+ @props = props || {}
25
+ @children = normalize_children(children || [])
26
+ end
27
+
28
+ private
29
+
30
+ def normalize_children(children)
31
+ result = [] #: Array[child_t]
32
+ children.each do |child|
33
+ case child
34
+ when VNode
35
+ result << child
36
+ when String
37
+ result << child
38
+ when Array
39
+ # Flatten arrays (typically from .each or .map return values)
40
+ # Recursively normalize nested arrays
41
+ # @type var child: Array[Funicular::VDOM::child_t]
42
+ result.concat(normalize_children(child))
43
+ when nil
44
+ # Skip nil values
45
+ else
46
+ # Convert other types to strings
47
+ result << child.to_s
48
+ end
49
+ end
50
+ result
51
+ end
52
+
53
+ def ==(other)
54
+ return false unless other.is_a?(Element)
55
+ @tag == other.tag && @props == other.props && @children == other.children
56
+ end
57
+ end
58
+
59
+ class Text < VNode
60
+ attr_reader :content
61
+
62
+ def initialize(content)
63
+ super(:text)
64
+ @content = content.to_s
65
+ end
66
+
67
+ def ==(other)
68
+ return false unless other.is_a?(Text)
69
+ @content == other.content
70
+ end
71
+ end
72
+
73
+ class Component < VNode
74
+ attr_reader :component_class, :props
75
+ attr_accessor :instance
76
+
77
+ def initialize(component_class, props = {})
78
+ super(:component)
79
+ @component_class = component_class
80
+ @key = props.delete(:key)
81
+ @props = props
82
+ @instance = nil
83
+ end
84
+
85
+ def ==(other)
86
+ return false unless other.is_a?(Component)
87
+ @component_class == other.component_class && @props == other.props
88
+ end
89
+ end
90
+
91
+ class Renderer
92
+ def initialize(doc = nil)
93
+ @doc = doc || JS.document
94
+ @error_boundary_stack = []
95
+ end
96
+
97
+ def render(vnode, parent = nil)
98
+ case vnode&.type
99
+ when :element
100
+ # @type var vnode: Funicular::VDOM::Element
101
+ render_element(vnode, parent)
102
+ when :text
103
+ # @type var vnode: Funicular::VDOM::Text
104
+ render_text(vnode, parent)
105
+ when :component
106
+ # @type var vnode: Funicular::VDOM::Component
107
+ render_component(vnode, parent)
108
+ else
109
+ raise "Unknown vnode type: #{vnode&.type}"
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Find the nearest error boundary instance on the stack
116
+ def current_error_boundary
117
+ @error_boundary_stack.last
118
+ end
119
+
120
+ def render_element(element, parent)
121
+ dom_node = @doc.createElement(element.tag)
122
+
123
+ element.props.each do |key, value|
124
+ key_str = key.to_s
125
+ if key_str.start_with?('on')
126
+ # Event handlers are handled by Funicular::Component and should not be set as attributes.
127
+ # warn "Funicular: Attempted to set event handler '#{key_str}' as an attribute. This will be ignored."
128
+ elsif URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
129
+ # Prevent XSS attacks by blocking javascript: URIs in URL attributes
130
+ puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
131
+ elsif BOOLEAN_ATTRIBUTES.include?(key_str)
132
+ # Handle boolean attributes
133
+ if value.nil? || value.to_s == "false"
134
+ # Do not set attribute (leave it absent)
135
+ else
136
+ dom_node.setAttribute(key_str, key_str)
137
+ end
138
+ else
139
+ # Attribute
140
+ dom_node.setAttribute(key_str, value.to_s)
141
+ end
142
+ end
143
+
144
+ element.children.each do |child|
145
+ if child.is_a?(VNode)
146
+ child_dom = render(child)
147
+ dom_node.appendChild(child_dom)
148
+ elsif child.is_a?(String)
149
+ text_node = @doc.createTextNode(child)
150
+ dom_node.appendChild(text_node)
151
+ elsif child.is_a?(Array)
152
+ child.each do |c|
153
+ if c.is_a?(VNode)
154
+ child_dom = render(c)
155
+ dom_node.appendChild(child_dom)
156
+ elsif c.is_a?(String)
157
+ text_node = @doc.createTextNode(c)
158
+ dom_node.appendChild(text_node)
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ parent.appendChild(dom_node) if parent
165
+
166
+ dom_node
167
+ end
168
+
169
+ def render_text(text, parent)
170
+ dom_node = @doc.createTextNode(text.content)
171
+ parent.appendChild(dom_node) if parent
172
+ dom_node
173
+ end
174
+
175
+ def render_component(component_vnode, parent)
176
+ instance = component_vnode.component_class.new(component_vnode.props)
177
+ component_vnode.instance = instance
178
+
179
+ is_error_boundary = instance.is_a?(Funicular::ErrorBoundary)
180
+
181
+ # Push error boundary to stack if this component is one
182
+ @error_boundary_stack.push(instance) if is_error_boundary
183
+
184
+ begin
185
+ component_vdom = instance.build_vdom
186
+ dom_node = render(component_vdom, parent)
187
+
188
+ # Check if this ErrorBoundary caught an error during child rendering
189
+ # If so, its @vdom was already set to fallback in the rescue block
190
+ error_was_caught = is_error_boundary && instance.error_caught_during_render
191
+
192
+ if error_was_caught
193
+ # ErrorBoundary caught an error - use the fallback vdom/dom that were set in rescue
194
+ # Note: The div.error-boundary-content created during initial render
195
+ # will be orphaned, but that's acceptable as it's not attached to the DOM
196
+ fallback_vdom = instance.vdom
197
+ fallback_dom = instance.dom_element
198
+
199
+ # Bind events on the fallback DOM
200
+ instance.bind_events(fallback_dom, fallback_vdom)
201
+ instance.collect_refs(fallback_dom, fallback_vdom)
202
+
203
+ # Return the fallback DOM
204
+ fallback_dom
205
+ else
206
+ # Normal case - store VDOM and DOM element
207
+ instance.vdom = component_vdom
208
+ instance.dom_element = dom_node
209
+ instance.bind_events(dom_node, component_vdom)
210
+ instance.collect_refs(dom_node, component_vdom)
211
+ dom_node
212
+ end
213
+ rescue => e
214
+ # Pop error boundary from stack before handling
215
+ @error_boundary_stack.pop if is_error_boundary
216
+
217
+ # Try to find an error boundary to handle this error
218
+ boundary = current_error_boundary
219
+ if boundary && !is_error_boundary
220
+ error_info = {
221
+ component_class: component_vnode.component_class.to_s,
222
+ props: component_vnode.props
223
+ }
224
+
225
+ # Let the error boundary handle the error
226
+ boundary.catch_error(e, error_info)
227
+
228
+ # Re-render the error boundary with fallback UI
229
+ boundary_vdom = boundary.build_vdom
230
+ fallback_dom = render(boundary_vdom, nil)
231
+
232
+ # Update boundary's internal state
233
+ boundary.vdom = boundary_vdom
234
+ boundary.dom_element = fallback_dom
235
+ boundary.mounted = true
236
+ boundary.bind_events(fallback_dom, boundary_vdom)
237
+
238
+ fallback_dom
239
+ else
240
+ # No error boundary to catch this error, let it propagate
241
+ raise e
242
+ end
243
+ ensure
244
+ # Pop error boundary from stack after successful render
245
+ @error_boundary_stack.pop if is_error_boundary && @error_boundary_stack.last == instance
246
+ end
247
+ end
248
+ end
249
+
250
+ def self.create_element(tag, props = {}, *children)
251
+ Element.new(tag, props, children.flatten)
252
+ end
253
+
254
+ def self.create_text(content)
255
+ Text.new(content)
256
+ end
257
+
258
+ def self.render(vnode, container)
259
+ renderer = Renderer.new
260
+ container.innerHTML = ''
261
+ renderer.render(vnode, container)
262
+ end
263
+
264
+ def self.diff(old_vnode, new_vnode)
265
+ Differ.diff(old_vnode, new_vnode)
266
+ end
267
+
268
+ def self.patch(element, patches)
269
+ patcher = Patcher.new
270
+ patcher.apply(element, patches)
271
+ end
272
+ end
273
+ end
data/sig/cable.rbs ADDED
@@ -0,0 +1,66 @@
1
+ module Funicular
2
+ module Cable
3
+ STORAGE_KEY: String
4
+
5
+ def self.create_consumer: (String url) -> Consumer
6
+
7
+ class Consumer
8
+ attr_reader url: String
9
+ attr_reader subscriptions: Subscriptions
10
+ @reconnect_attempts: Integer
11
+
12
+ def initialize: (String url) -> void
13
+ def connect: () -> void
14
+ def send_command: (Hash[Symbol, untyped] command) -> void
15
+ def disconnect: () -> void
16
+ def cleanup: () -> void
17
+ def cleanup_event_listeners: () -> void
18
+
19
+ private def handle_message: (String data) -> void
20
+ private def flush_pending_commands: () -> void
21
+ private def schedule_reconnect: () -> void
22
+ private def calculate_backoff_delay: () -> Integer
23
+ private def setup_visibility_handler: () -> void
24
+ private def schedule_suspend: () -> void
25
+ private def cancel_suspend: () -> void
26
+ private def suspend_connection: () -> void
27
+ private def ensure_connected: () -> void
28
+ private def setup_beforeunload_handler: () -> void
29
+ private def save_pending_to_storage: () -> void
30
+ private def load_pending_from_storage: () -> Array[Hash[Symbol, untyped]]
31
+ private def clear_pending_storage: () -> void
32
+ end
33
+
34
+ class Subscriptions
35
+ @subscriptions: Hash[String, Subscription]
36
+
37
+ def initialize: (Consumer consumer) -> void
38
+ def create: (Hash[Symbol, untyped] params) ?{ (untyped message) -> void } -> Subscription
39
+ def find: (String identifier) -> Subscription?
40
+ def remove: (Subscription subscription) -> void
41
+ def notify_subscription_confirmed: (String identifier) -> void
42
+ def notify_subscription_rejected: (String identifier) -> void
43
+ def notify_message: (String identifier, untyped message) -> void
44
+ def resubscribe_all: () -> void
45
+ end
46
+
47
+ class Subscription
48
+ attr_reader consumer: Consumer
49
+ attr_reader identifier: String
50
+ attr_reader params: Hash[Symbol, untyped]
51
+
52
+ def initialize: (Consumer consumer, String identifier, Hash[Symbol, untyped] params) ?{ (untyped message) -> void } -> void
53
+ def subscribe: () -> void
54
+ def unsubscribe: () -> void
55
+ def perform: (String action, ?Hash[Symbol, untyped] data) -> void
56
+ def on_connected: () { () -> void } -> void
57
+ def on_disconnected: () { () -> void } -> void
58
+ def on_rejected: () { () -> void } -> void
59
+ def notify_connected: () -> void
60
+ def notify_rejected: () -> void
61
+ def notify_received: (untyped message) -> void
62
+
63
+ private def generate_idempotency_key: () -> String
64
+ end
65
+ end
66
+ end