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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- 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
|