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
data/mrblib/debug.rb ADDED
@@ -0,0 +1,208 @@
1
+ module Funicular
2
+ module Debug
3
+ class << self
4
+ attr_accessor :enabled
5
+
6
+ def enabled?
7
+ # Disabled on the server: SSR instantiates components per request and
8
+ # must not accumulate them in the debug registry.
9
+ return false if Funicular.server?
10
+ @enabled ||= Funicular.env.development?
11
+ end
12
+
13
+ def component_registry
14
+ @component_registry ||= {}
15
+ end
16
+
17
+ def error_registry
18
+ @error_registry ||= []
19
+ end
20
+
21
+ def register_component(component)
22
+ return nil unless enabled?
23
+ @component_counter ||= 0
24
+ id = (@component_counter += 1)
25
+ component_registry[id] = component
26
+ id
27
+ end
28
+
29
+ # Report an error caught by an ErrorBoundary
30
+ def report_error(boundary, error, error_info = nil)
31
+ return unless enabled?
32
+
33
+ error_entry = {
34
+ id: (error_registry.length + 1),
35
+ timestamp: Time.now.to_s,
36
+ boundary_id: boundary.instance_variable_get(:@__debug_id__),
37
+ boundary_class: boundary.class.to_s,
38
+ error_class: error.class.to_s,
39
+ error_message: error.message,
40
+ component_class: error_info&.dig(:component_class),
41
+ backtrace: error.backtrace&.first(10)
42
+ }
43
+
44
+ error_registry << error_entry
45
+
46
+ # Log to console
47
+ puts "[ErrorBoundary] Caught error in #{error_info&.dig(:component_class) || 'unknown'}: #{error.message}"
48
+
49
+ # Keep only last 50 errors
50
+ @error_registry = error_registry.last(50) if error_registry.length > 50
51
+
52
+ error_entry
53
+ end
54
+
55
+ # Clear all recorded errors
56
+ def clear_errors
57
+ @error_registry = []
58
+ end
59
+
60
+ # Get all recorded errors as JSON
61
+ def error_list
62
+ return "[]" unless enabled?
63
+ begin
64
+ JSON.generate(error_registry)
65
+ rescue => e
66
+ JSON.generate([{ "error" => e.message }])
67
+ end
68
+ end
69
+
70
+ # Get the most recent error
71
+ def last_error
72
+ return nil unless enabled?
73
+ error_registry.last
74
+ end
75
+
76
+ def unregister_component(id)
77
+ return unless enabled?
78
+ component_registry.delete(id)
79
+ end
80
+
81
+ def get_component(id)
82
+ return nil unless enabled?
83
+ component_registry[id]
84
+ end
85
+
86
+ def all_components
87
+ return [] unless enabled?
88
+ component_registry.values
89
+ end
90
+
91
+ def component_tree
92
+ return "[]" unless enabled?
93
+
94
+ begin
95
+ components = component_registry.map do |id, component|
96
+ begin
97
+ mounted = component.instance_variable_get(:@mounted)
98
+ state_keys = get_state_keys(component)
99
+ class_name = component.class.to_s
100
+ child_ids = get_child_ids(component)
101
+ is_error_boundary = component.is_a?(Funicular::ErrorBoundary)
102
+ has_error = is_error_boundary && component.instance_variable_get(:@state)&.dig(:has_error)
103
+ entry = {
104
+ "id" => id,
105
+ "class" => class_name,
106
+ "state_keys" => state_keys,
107
+ "mounted" => mounted,
108
+ "children" => child_ids
109
+ } #: Hash[String, untyped]
110
+ if is_error_boundary
111
+ entry["is_error_boundary"] = true
112
+ entry["has_error"] = has_error
113
+ end
114
+ entry
115
+ rescue => e
116
+ { "id" => id, "error" => e.message }
117
+ end
118
+ end
119
+ JSON.generate(components)
120
+ rescue => e
121
+ JSON.generate({ "error" => e.message })
122
+ end
123
+ end
124
+
125
+ # Get error count
126
+ def error_count
127
+ return 0 unless enabled?
128
+ error_registry.length
129
+ end
130
+
131
+ def get_component_state(id)
132
+ return "{}" unless enabled?
133
+ component = get_component(id)
134
+ return "{}" unless component
135
+
136
+ state = component.instance_variable_get(:@state) || {}
137
+ result = {} #: Hash[String, String]
138
+ state.each do |key, value|
139
+ begin
140
+ result[key.to_s] = value.inspect
141
+ rescue
142
+ result[key.to_s] = "<error inspecting value>"
143
+ end
144
+ end
145
+ JSON.generate(result)
146
+ end
147
+
148
+ def get_component_instance_variables(id)
149
+ return "{}" unless enabled?
150
+ component = get_component(id)
151
+ return "{}" unless component
152
+
153
+ result = {} #: Hash[String, String]
154
+ component.instance_variables.each do |var|
155
+ next if var == :@state
156
+ next if var.to_s.start_with?('@__debug')
157
+ if var == :@vdom || var == :@child_components
158
+ result[var.to_s] = "<omitted>"
159
+ next
160
+ end
161
+ begin
162
+ result[var.to_s] = component.instance_variable_get(var).inspect
163
+ rescue
164
+ result[var.to_s] = "<error inspecting value>"
165
+ end
166
+ end
167
+ JSON.generate(result)
168
+ end
169
+
170
+ def expose_to_global
171
+ return unless enabled?
172
+ # Export to global variable for DevTools access
173
+ $__funicular_debug__ = self
174
+ end
175
+
176
+ private
177
+
178
+ def get_state_keys(component)
179
+ state = component.instance_variable_get(:@state)
180
+ return [] unless state.is_a?(Hash)
181
+ state.keys.map(&:to_s)
182
+ end
183
+
184
+ def get_child_ids(component)
185
+ # Get only direct children by scanning component's vdom
186
+ vdom = component.instance_variable_get(:@vdom)
187
+ return [] unless vdom
188
+
189
+ direct_children = [] #: Array[Funicular::Component]
190
+ collect_direct_children(vdom, direct_children)
191
+ direct_children.map { |child| child.instance_variable_get(:@__debug_id__) }.compact
192
+ end
193
+
194
+ def collect_direct_children(vnode, children)
195
+ if vnode.is_a?(VDOM::Component)
196
+ # Found a direct child component, don't recurse further
197
+ children << vnode.instance if vnode.instance
198
+ elsif vnode.is_a?(VDOM::Element)
199
+ # Keep looking through elements
200
+ vnode.children&.each do |child|
201
+ # @type var child: VDOM::VNode
202
+ collect_direct_children(child, children)
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
data/mrblib/differ.rb ADDED
@@ -0,0 +1,254 @@
1
+ module Funicular
2
+ module VDOM
3
+ class Differ
4
+ def self.diff(old_node, new_node)
5
+ return [[:replace, new_node, nil]] if old_node.nil? # old_node is nil for new nodes (insertion)
6
+ return [[:remove, old_node]] if new_node.nil? # new_node is nil for removed nodes
7
+
8
+ if old_node.is_a?(VNode) && new_node.is_a?(VNode)
9
+ if old_node.type != new_node.type
10
+ return [[:replace, new_node, old_node]]
11
+ end
12
+
13
+ case old_node.type
14
+ when :text
15
+ # @type var old_node: Funicular::VDOM::Text
16
+ # @type var new_node: Funicular::VDOM::Text
17
+ diff_text(old_node, new_node)
18
+ when :element
19
+ # @type var old_node: Funicular::VDOM::Element
20
+ # @type var new_node: Funicular::VDOM::Element
21
+ diff_element(old_node, new_node)
22
+ when :component
23
+ # @type var old_node: Funicular::VDOM::Component
24
+ # @type var new_node: Funicular::VDOM::Component
25
+ diff_component(old_node, new_node)
26
+ else
27
+ [[:replace, new_node, old_node]]
28
+ end
29
+ else
30
+ if old_node == new_node
31
+ []
32
+ else
33
+ [[:replace, new_node, old_node]]
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def self.diff_text(old_node, new_node)
41
+ if old_node.content != new_node.content
42
+ [[:replace, new_node, old_node]]
43
+ else
44
+ []
45
+ end
46
+ end
47
+
48
+ def self.diff_element(old_node, new_node)
49
+ patches = [] #: Array[patch_t]
50
+
51
+ if old_node.tag != new_node.tag
52
+ return [[:replace, new_node, old_node]]
53
+ end
54
+
55
+ props_patch = diff_props(old_node.props, new_node.props)
56
+ patches << [:props, props_patch] unless props_patch.empty?
57
+
58
+ child_patches = diff_children(old_node.children, new_node.children)
59
+ patches += child_patches unless child_patches.empty?
60
+
61
+ patches
62
+ end
63
+
64
+ def self.diff_props(old_props, new_props)
65
+ patches = {} #: Hash[Symbol, untyped]
66
+ new_props.each do |key, value|
67
+ # Skip Proc/Lambda values as they are recreated on each render
68
+ # and should be handled by event binding, not props patching
69
+ next if value.is_a?(Proc)
70
+ patches[key] = value if old_props[key] != value
71
+ end
72
+ old_props.keys.each do |key|
73
+ # Skip Proc/Lambda values
74
+ next if old_props[key].is_a?(Proc)
75
+ patches[key] = nil unless new_props.key?(key)
76
+ end
77
+ patches
78
+ end
79
+
80
+ def self.diff_children(old_children, new_children)
81
+ # Check if any child has a key
82
+ has_keys = new_children.any? { |child|
83
+ child.is_a?(VNode) && child.respond_to?(:key) && child.key
84
+ }
85
+
86
+ if has_keys
87
+ diff_children_with_keys(old_children, new_children)
88
+ else
89
+ diff_children_by_index(old_children, new_children)
90
+ end
91
+ end
92
+
93
+ # Produce a single composite patch describing a keyed-children diff.
94
+ #
95
+ # The earlier implementation emitted independent `[index, ...]` patches
96
+ # for inserts, removes and updates. The patcher consumed them as
97
+ # `replaceChild`, which is unsafe whenever insert and remove indices
98
+ # overlap (e.g. switching between two equal-length sets of keyed
99
+ # children with disjoint keys): inserts overwrite old nodes in place,
100
+ # then the descending removes drop the just-inserted new nodes and
101
+ # the DOM ends up empty.
102
+ #
103
+ # The new shape is `[[:keyed_children, ops, removes]]`, applied as
104
+ # three phases by the patcher:
105
+ # 1. removes (descending old_index, against the original DOM snapshot)
106
+ # 2. content updates for kept children (against the snapshot, no move)
107
+ # 3. inserts in ascending new_index using insertBefore on the live DOM
108
+ def self.diff_children_with_keys(old_children, new_children)
109
+ # 1. Build key map from old children
110
+ old_key_map = {} #: Hash[untyped, [Integer, child_t]]
111
+ old_children.each_with_index do |child, index|
112
+ if child.is_a?(VNode) && child.respond_to?(:key) && child.key
113
+ old_key_map[child.key] = [index, child]
114
+ end
115
+ end
116
+
117
+ matched_old_indices = {} #: Hash[Integer, bool]
118
+ ops = [] #: Array[Array[untyped]]
119
+ has_change = false
120
+
121
+ # 2. Process new children, recording one op per new position
122
+ new_children.each_with_index do |new_child, new_index|
123
+ if new_child.is_a?(VNode) && new_child.respond_to?(:key) && new_child.key
124
+ key = new_child.key
125
+ if old_key_map[key]
126
+ old_index, old_child = old_key_map[key]
127
+ matched_old_indices[old_index] = true
128
+ child_patches = diff(old_child, new_child)
129
+ ops << [:keep, old_index, new_index, child_patches]
130
+ has_change = true unless child_patches.empty?
131
+ else
132
+ ops << [:insert, new_index, new_child]
133
+ has_change = true
134
+ end
135
+ else
136
+ # Unkeyed new child: positional fallback. Match it with the
137
+ # old child at the same index only when that old child is
138
+ # itself unkeyed (otherwise the keyed old child is matched
139
+ # separately via its key, and the unkeyed new is a fresh insert).
140
+ old_child = old_children[new_index]
141
+ old_is_keyed = old_child.is_a?(VNode) && old_child.respond_to?(:key) && old_child.key
142
+ if old_child.nil? || old_is_keyed
143
+ ops << [:insert, new_index, new_child]
144
+ has_change = true
145
+ else
146
+ matched_old_indices[new_index] = true
147
+ child_patches = diff(old_child, new_child)
148
+ ops << [:keep, new_index, new_index, child_patches]
149
+ has_change = true unless child_patches.empty?
150
+ end
151
+ end
152
+ end
153
+
154
+ # 3. Collect every unmatched old child to remove, keyed or not. An old
155
+ # child that no new child matched (by key, or positionally for
156
+ # unkeyed ones) is gone from the new render and must be removed.
157
+ # Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
158
+ # "Loading..." placeholder that never disappeared once the keyed
159
+ # list it was replaced by arrived.
160
+ removes = [] #: Array[[Integer, child_t]]
161
+ old_children.each_with_index do |old_child, old_index|
162
+ next unless old_child.is_a?(VNode)
163
+ next if matched_old_indices[old_index]
164
+ removes << [old_index, old_child]
165
+ has_change = true
166
+ end
167
+
168
+ return [] unless has_change
169
+
170
+ [[:keyed_children, ops, removes]]
171
+ end
172
+
173
+ def self.diff_children_by_index(old_children, new_children)
174
+ patches = [] #: Array[patch_t]
175
+ # Collect remove patches separately to apply at the end
176
+ remove_patches = [] #: Array[patch_t]
177
+ max_length = [old_children.length, new_children.length].max
178
+ max_length&.times do |i|
179
+ old_child = old_children[i]
180
+ new_child = new_children[i]
181
+ child_patches = diff(old_child, new_child)
182
+ unless child_patches.empty?
183
+ # Check if this is a simple remove patch
184
+ if child_patches.length == 1 && child_patches[0][0] == :remove
185
+ remove_patches << [i, child_patches]
186
+ else
187
+ patches << [i, child_patches]
188
+ end
189
+ end
190
+ end
191
+ # Add remove patches at the end, sorted by index in descending order to avoid index shifts
192
+ patches + remove_patches.sort { |a, b| b[0] <=> a[0] }
193
+ end
194
+
195
+ def self.diff_component(old_node, new_node)
196
+ if old_node.component_class != new_node.component_class
197
+ return [[:replace, new_node, old_node]]
198
+ end
199
+
200
+ # If the component is marked for preservation,
201
+ # update its internal VDOM instead of replacing the whole component.
202
+ if new_node.props[:preserve]
203
+ instance = old_node.instance
204
+ return [[:replace, new_node, old_node]] unless instance # Fallback if instance is missing
205
+
206
+ new_node.instance = instance # Preserve the instance
207
+
208
+ # Check if data props (excluding Proc/Lambda) have changed
209
+ # Proc/Lambda props are always considered different between renders,
210
+ # but we want to ignore them for preservation purposes
211
+ data_props_changed = props_changed_excluding_procs?(old_node.props, new_node.props)
212
+
213
+ # If only Proc props changed, update props but skip VDOM rebuild
214
+ unless data_props_changed
215
+ instance.props = new_node.props
216
+ return []
217
+ end
218
+
219
+ old_internal_vdom = instance.vdom
220
+ instance.props = new_node.props
221
+ new_internal_vdom = instance.build_vdom
222
+ internal_patches = diff(old_internal_vdom, new_internal_vdom)
223
+
224
+ # Return a special patch that instructs the patcher to re-bind events
225
+ # Pass new_internal_vdom so patcher can update @vdom after applying patches
226
+ return [[:update_and_rebind, instance, internal_patches, new_internal_vdom]]
227
+ end
228
+
229
+ # Original logic: If props changed, replace the entire component
230
+ if old_node.props != new_node.props
231
+ return [[:replace, new_node, old_node]]
232
+ end
233
+
234
+ # Copy instance reference so parent's @vdom keeps valid instance refs
235
+ # This is needed for debug.rb's collect_direct_children to work correctly
236
+ new_node.instance = old_node.instance
237
+ []
238
+ end
239
+
240
+ # Helper method to check if props changed, excluding Proc/Lambda values
241
+ def self.props_changed_excluding_procs?(old_props, new_props)
242
+ # Get keys excluding Proc/Lambda values
243
+ old_data_keys = old_props.keys.select { |k| !old_props[k].is_a?(Proc) }
244
+ new_data_keys = new_props.keys.select { |k| !new_props[k].is_a?(Proc) }
245
+
246
+ # Check if keys changed
247
+ return true if old_data_keys.sort != new_data_keys.sort
248
+
249
+ # Check if values changed for data keys
250
+ old_data_keys.any? { |k| old_props[k] != new_props[k] }
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,34 @@
1
+ module Funicular
2
+ class EnvironmentInquirer
3
+ def initialize(env)
4
+ @environment = env.to_s
5
+ end
6
+
7
+ def to_s
8
+ @environment
9
+ end
10
+
11
+ def inspect
12
+ @environment.inspect
13
+ end
14
+
15
+ def ==(other)
16
+ @environment == other.to_s
17
+ end
18
+
19
+ def method_missing(method_name, *args, &block) # steep:ignore MethodArityMismatch
20
+ if method_name.to_s.end_with?("?")
21
+ env_name = method_name.to_s.chomp("?")
22
+ return @environment == env_name
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ def respond_to_missing?(method_name, include_private = false)
29
+ method_name.to_s.end_with?("?") || super
30
+ end
31
+ end
32
+
33
+ end
34
+
@@ -0,0 +1,125 @@
1
+ module Funicular
2
+ # ErrorBoundary component catches errors from child components and displays
3
+ # a fallback UI instead of crashing the entire application.
4
+ #
5
+ # Usage:
6
+ # component(ErrorBoundary) do
7
+ # component(RiskyComponent)
8
+ # end
9
+ #
10
+ # With custom fallback:
11
+ # component(ErrorBoundary, fallback: ->(error) { div { "Error: #{error.message}" } }) do
12
+ # component(RiskyComponent)
13
+ # end
14
+ #
15
+ # Props:
16
+ # - fallback: Proc or Method that receives the error and returns VDOM
17
+ # - on_error: Optional callback when error is caught (for logging, reporting)
18
+ #
19
+ class ErrorBoundary < Component
20
+ attr_accessor :error_caught_during_render
21
+
22
+ def initialize_state
23
+ { has_error: false, error: nil, error_info: nil }
24
+ end
25
+
26
+ # Called when a child component raises an error during rendering
27
+ # Returns true to indicate the error was handled and should not propagate
28
+ def catch_error(error, error_info = nil)
29
+ # Update state to show fallback UI
30
+ @state[:has_error] = true
31
+ @state[:error] = error
32
+ @state[:error_info] = error_info
33
+ @state_accessor = nil
34
+
35
+ # Mark that we caught an error (used to prevent @vdom overwrite)
36
+ @error_caught_during_render = true
37
+
38
+ # Call on_error callback if provided
39
+ if props[:on_error]
40
+ begin
41
+ props[:on_error].call(error, error_info)
42
+ rescue => callback_error
43
+ puts "[ErrorBoundary] on_error callback failed: #{callback_error.message}"
44
+ end
45
+ end
46
+
47
+ # Report to debug module
48
+ Funicular::Debug.report_error(self, error, error_info) if Funicular::Debug.enabled?
49
+
50
+ true # Indicate error was handled
51
+ end
52
+
53
+ # Reset the error boundary to try rendering children again
54
+ def reset
55
+ begin
56
+ patch(has_error: false, error: nil, error_info: nil)
57
+ rescue => e
58
+ # Re-catch the error and show fallback again
59
+ catch_error(e, { component_class: "child component" })
60
+ end
61
+ end
62
+
63
+ def render
64
+ if state[:has_error]
65
+ render_fallback
66
+ else
67
+ render_children
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def render_fallback
74
+ if props[:fallback]
75
+ result = props[:fallback].call(state[:error])
76
+ if result.is_a?(VDOM::VNode)
77
+ result
78
+ else
79
+ div { result.to_s }
80
+ end
81
+ else
82
+ default_fallback
83
+ end
84
+ end
85
+
86
+ def default_fallback
87
+ div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do
88
+ h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
89
+ if state[:error]
90
+ div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
91
+ "#{state[:error].class}: #{state[:error].message}"
92
+ end
93
+ end
94
+ if Funicular.env.development? && state[:error_info]
95
+ div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
96
+ "Component: #{state[:error_info][:component_class]}"
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ def render_children
103
+ # Children are passed via children_block prop from the component() DSL
104
+ # Errors are caught by VDOM::Renderer.render_component
105
+ if props[:children_block]
106
+ div(class: 'error-boundary-content') do
107
+ props[:children_block].call
108
+ end
109
+ elsif props[:children]
110
+ props[:children]
111
+ else
112
+ div { "" }
113
+ end
114
+ end
115
+ end
116
+
117
+ # Make ErrorBoundary available at top level
118
+ def self.const_missing(name)
119
+ if name == :ErrorBoundary
120
+ Funicular::ErrorBoundary
121
+ else
122
+ super
123
+ end
124
+ end
125
+ end