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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -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/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -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 +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -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 +6423 -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/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -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 +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- 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
|