funicular 0.0.1 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -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/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
data/mrblib/debug.rb ADDED
@@ -0,0 +1,205 @@
1
+ module Funicular
2
+ module Debug
3
+ class << self
4
+ attr_accessor :enabled
5
+
6
+ def enabled?
7
+ @enabled ||= Funicular.env.development?
8
+ end
9
+
10
+ def component_registry
11
+ @component_registry ||= {}
12
+ end
13
+
14
+ def error_registry
15
+ @error_registry ||= []
16
+ end
17
+
18
+ def register_component(component)
19
+ return nil unless enabled?
20
+ @component_counter ||= 0
21
+ id = (@component_counter += 1)
22
+ component_registry[id] = component
23
+ id
24
+ end
25
+
26
+ # Report an error caught by an ErrorBoundary
27
+ def report_error(boundary, error, error_info = nil)
28
+ return unless enabled?
29
+
30
+ error_entry = {
31
+ id: (error_registry.length + 1),
32
+ timestamp: Time.now.to_s,
33
+ boundary_id: boundary.instance_variable_get(:@__debug_id__),
34
+ boundary_class: boundary.class.to_s,
35
+ error_class: error.class.to_s,
36
+ error_message: error.message,
37
+ component_class: error_info&.dig(:component_class),
38
+ backtrace: error.backtrace&.first(10)
39
+ }
40
+
41
+ error_registry << error_entry
42
+
43
+ # Log to console
44
+ puts "[ErrorBoundary] Caught error in #{error_info&.dig(:component_class) || 'unknown'}: #{error.message}"
45
+
46
+ # Keep only last 50 errors
47
+ @error_registry = error_registry.last(50) if error_registry.length > 50
48
+
49
+ error_entry
50
+ end
51
+
52
+ # Clear all recorded errors
53
+ def clear_errors
54
+ @error_registry = []
55
+ end
56
+
57
+ # Get all recorded errors as JSON
58
+ def error_list
59
+ return "[]" unless enabled?
60
+ begin
61
+ JSON.generate(error_registry)
62
+ rescue => e
63
+ JSON.generate([{ "error" => e.message }])
64
+ end
65
+ end
66
+
67
+ # Get the most recent error
68
+ def last_error
69
+ return nil unless enabled?
70
+ error_registry.last
71
+ end
72
+
73
+ def unregister_component(id)
74
+ return unless enabled?
75
+ component_registry.delete(id)
76
+ end
77
+
78
+ def get_component(id)
79
+ return nil unless enabled?
80
+ component_registry[id]
81
+ end
82
+
83
+ def all_components
84
+ return [] unless enabled?
85
+ component_registry.values
86
+ end
87
+
88
+ def component_tree
89
+ return "[]" unless enabled?
90
+
91
+ begin
92
+ components = component_registry.map do |id, component|
93
+ begin
94
+ mounted = component.instance_variable_get(:@mounted)
95
+ state_keys = get_state_keys(component)
96
+ class_name = component.class.to_s
97
+ child_ids = get_child_ids(component)
98
+ is_error_boundary = component.is_a?(Funicular::ErrorBoundary)
99
+ has_error = is_error_boundary && component.instance_variable_get(:@state)&.dig(:has_error)
100
+ entry = {
101
+ "id" => id,
102
+ "class" => class_name,
103
+ "state_keys" => state_keys,
104
+ "mounted" => mounted,
105
+ "children" => child_ids
106
+ } #: Hash[String, untyped]
107
+ if is_error_boundary
108
+ entry["is_error_boundary"] = true
109
+ entry["has_error"] = has_error
110
+ end
111
+ entry
112
+ rescue => e
113
+ { "id" => id, "error" => e.message }
114
+ end
115
+ end
116
+ JSON.generate(components)
117
+ rescue => e
118
+ JSON.generate({ "error" => e.message })
119
+ end
120
+ end
121
+
122
+ # Get error count
123
+ def error_count
124
+ return 0 unless enabled?
125
+ error_registry.length
126
+ end
127
+
128
+ def get_component_state(id)
129
+ return "{}" unless enabled?
130
+ component = get_component(id)
131
+ return "{}" unless component
132
+
133
+ state = component.instance_variable_get(:@state) || {}
134
+ result = {} #: Hash[String, String]
135
+ state.each do |key, value|
136
+ begin
137
+ result[key.to_s] = value.inspect
138
+ rescue
139
+ result[key.to_s] = "<error inspecting value>"
140
+ end
141
+ end
142
+ JSON.generate(result)
143
+ end
144
+
145
+ def get_component_instance_variables(id)
146
+ return "{}" unless enabled?
147
+ component = get_component(id)
148
+ return "{}" unless component
149
+
150
+ result = {} #: Hash[String, String]
151
+ component.instance_variables.each do |var|
152
+ next if var == :@state
153
+ next if var.to_s.start_with?('@__debug')
154
+ if var == :@vdom || var == :@child_components
155
+ result[var.to_s] = "<omitted>"
156
+ next
157
+ end
158
+ begin
159
+ result[var.to_s] = component.instance_variable_get(var).inspect
160
+ rescue
161
+ result[var.to_s] = "<error inspecting value>"
162
+ end
163
+ end
164
+ JSON.generate(result)
165
+ end
166
+
167
+ def expose_to_global
168
+ return unless enabled?
169
+ # Export to global variable for DevTools access
170
+ $__funicular_debug__ = self
171
+ end
172
+
173
+ private
174
+
175
+ def get_state_keys(component)
176
+ state = component.instance_variable_get(:@state)
177
+ return [] unless state.is_a?(Hash)
178
+ state.keys.map(&:to_s)
179
+ end
180
+
181
+ def get_child_ids(component)
182
+ # Get only direct children by scanning component's vdom
183
+ vdom = component.instance_variable_get(:@vdom)
184
+ return [] unless vdom
185
+
186
+ direct_children = [] #: Array[Funicular::Component]
187
+ collect_direct_children(vdom, direct_children)
188
+ direct_children.map { |child| child.instance_variable_get(:@__debug_id__) }.compact
189
+ end
190
+
191
+ def collect_direct_children(vnode, children)
192
+ if vnode.is_a?(VDOM::Component)
193
+ # Found a direct child component, don't recurse further
194
+ children << vnode.instance if vnode.instance
195
+ elsif vnode.is_a?(VDOM::Element)
196
+ # Keep looking through elements
197
+ vnode.children&.each do |child|
198
+ # @type var child: VDOM::VNode
199
+ collect_direct_children(child, children)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
data/mrblib/differ.rb ADDED
@@ -0,0 +1,244 @@
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
+ def self.diff_children_with_keys(old_children, new_children)
94
+ patches = [] #: Array[patch_t]
95
+
96
+ # 1. Build key map from old children
97
+ old_key_map = {} #: Hash[untyped, [Integer, child_t]]
98
+ old_children.each_with_index do |child, index|
99
+ if child.is_a?(VNode) && child.respond_to?(:key) && child.key
100
+ old_key_map[child.key] = [index, child]
101
+ end
102
+ end
103
+
104
+ # 2. Track matched old indices
105
+ matched_old_indices = {} #: Hash[Integer, bool]
106
+
107
+ # 3. Process new children
108
+ new_children.each_with_index do |new_child, new_index|
109
+ if new_child.is_a?(VNode) && new_child.respond_to?(:key) && new_child.key
110
+ key = new_child.key
111
+
112
+ if old_key_map[key]
113
+ old_index, old_child = old_key_map[key]
114
+ matched_old_indices[old_index] = true
115
+
116
+ # Diff existing element
117
+ child_patches = diff(old_child, new_child)
118
+ unless child_patches.empty?
119
+ patches << [new_index, child_patches]
120
+ end
121
+ else
122
+ # Insert new element
123
+ patches << [new_index, [[:replace, new_child, nil]]] # old_node is nil for insertion
124
+ end
125
+ else
126
+ # Fallback to index-based for elements without keys
127
+ # Even if old_child.nil?, diff will handle it (insertion)
128
+ old_child = old_children[new_index]
129
+ child_patches = diff(old_child, new_child)
130
+ unless child_patches.empty?
131
+ patches << [new_index, child_patches]
132
+ end
133
+ end
134
+ end
135
+
136
+ # 4. Remove unmatched old elements
137
+ old_children.each_with_index do |old_child, old_index|
138
+ next unless old_child.is_a?(VNode)
139
+ next if matched_old_indices[old_index]
140
+
141
+ if old_child.respond_to?(:key) && old_child.key # Only remove keyed children
142
+ patches << [old_index, [[:remove, old_child]]]
143
+ end
144
+ end
145
+
146
+ # Sort patches: updates first, removes last (descending index)
147
+ patches.sort { |a, b|
148
+ a_is_remove = a[1].length == 1 && a[1][0][0] == :remove
149
+ b_is_remove = b[1].length == 1 && b[1][0][0] == :remove
150
+
151
+ if a_is_remove && b_is_remove
152
+ b[0] <=> a[0] # Sort remove patches by descending index
153
+ elsif a_is_remove
154
+ 1 # Remove patches come after non-remove patches
155
+ elsif b_is_remove
156
+ -1 # Non-remove patches come before remove patches
157
+ else
158
+ a[0] <=> b[0] # Sort non-remove patches by ascending index
159
+ end
160
+ }
161
+ end
162
+
163
+ def self.diff_children_by_index(old_children, new_children)
164
+ patches = [] #: Array[patch_t]
165
+ # Collect remove patches separately to apply at the end
166
+ remove_patches = [] #: Array[patch_t]
167
+ max_length = [old_children.length, new_children.length].max
168
+ max_length&.times do |i|
169
+ old_child = old_children[i]
170
+ new_child = new_children[i]
171
+ child_patches = diff(old_child, new_child)
172
+ unless child_patches.empty?
173
+ # Check if this is a simple remove patch
174
+ if child_patches.length == 1 && child_patches[0][0] == :remove
175
+ remove_patches << [i, child_patches]
176
+ else
177
+ patches << [i, child_patches]
178
+ end
179
+ end
180
+ end
181
+ # Add remove patches at the end, sorted by index in descending order to avoid index shifts
182
+ patches + remove_patches.sort { |a, b| b[0] <=> a[0] }
183
+ end
184
+
185
+ def self.diff_component(old_node, new_node)
186
+ if old_node.component_class != new_node.component_class
187
+ return [[:replace, new_node, old_node]]
188
+ end
189
+
190
+ # If the component is marked for preservation,
191
+ # update its internal VDOM instead of replacing the whole component.
192
+ if new_node.props[:preserve]
193
+ instance = old_node.instance
194
+ return [[:replace, new_node, old_node]] unless instance # Fallback if instance is missing
195
+
196
+ new_node.instance = instance # Preserve the instance
197
+
198
+ # Check if data props (excluding Proc/Lambda) have changed
199
+ # Proc/Lambda props are always considered different between renders,
200
+ # but we want to ignore them for preservation purposes
201
+ data_props_changed = props_changed_excluding_procs?(old_node.props, new_node.props)
202
+
203
+ # If only Proc props changed, update props but skip VDOM rebuild
204
+ unless data_props_changed
205
+ instance.props = new_node.props
206
+ return []
207
+ end
208
+
209
+ old_internal_vdom = instance.vdom
210
+ instance.props = new_node.props
211
+ new_internal_vdom = instance.build_vdom
212
+ internal_patches = diff(old_internal_vdom, new_internal_vdom)
213
+
214
+ # Return a special patch that instructs the patcher to re-bind events
215
+ # Pass new_internal_vdom so patcher can update @vdom after applying patches
216
+ return [[:update_and_rebind, instance, internal_patches, new_internal_vdom]]
217
+ end
218
+
219
+ # Original logic: If props changed, replace the entire component
220
+ if old_node.props != new_node.props
221
+ return [[:replace, new_node, old_node]]
222
+ end
223
+
224
+ # Copy instance reference so parent's @vdom keeps valid instance refs
225
+ # This is needed for debug.rb's collect_direct_children to work correctly
226
+ new_node.instance = old_node.instance
227
+ []
228
+ end
229
+
230
+ # Helper method to check if props changed, excluding Proc/Lambda values
231
+ def self.props_changed_excluding_procs?(old_props, new_props)
232
+ # Get keys excluding Proc/Lambda values
233
+ old_data_keys = old_props.keys.select { |k| !old_props[k].is_a?(Proc) }
234
+ new_data_keys = new_props.keys.select { |k| !new_props[k].is_a?(Proc) }
235
+
236
+ # Check if keys changed
237
+ return true if old_data_keys.sort != new_data_keys.sort
238
+
239
+ # Check if values changed for data keys
240
+ old_data_keys.any? { |k| old_props[k] != new_props[k] }
241
+ end
242
+ end
243
+ end
244
+ 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