funicular 0.2.1 → 0.3.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 +54 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +17 -17
- data/demo/test_component.html +15 -15
- data/demo/test_error_boundary.html +41 -41
- data/demo/test_router.html +57 -57
- data/demo/tic-tac-toe.html +29 -29
- data/docs/architecture.md +43 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +2 -0
- data/lib/funicular/ssr.rb +2 -1
- data/lib/funicular/testing/node_runner.rb +1 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -0
- data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +14 -1
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +16 -10
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +85 -84
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +47 -46
- data/lib/tasks/funicular.rake +1 -1
- data/minitest/commands_routes_test.rb +97 -0
- data/minitest/compiler_test.rb +195 -0
- data/minitest/configuration_test.rb +64 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +6 -6
- data/minitest/form_for_test.rb +2 -2
- data/minitest/funicular_test.rb +28 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/middleware_test.rb +154 -0
- data/minitest/picoruby_helper_test.rb +139 -0
- data/minitest/route_parser_test.rb +139 -0
- data/minitest/schema_test.rb +23 -0
- data/minitest/ssr_test.rb +57 -0
- data/minitest/support/rails_stub.rb +59 -0
- data/minitest/test_helper.rb +20 -0
- data/minitest/testing_test.rb +267 -0
- data/minitest/view_context_test.rb +101 -0
- data/mrblib/component.rb +158 -237
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +19 -27
- data/mrblib/form_builder.rb +28 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +12 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +13 -17
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +135 -0
- data/sig/component.rbs +34 -85
- data/sig/error_boundary.rbs +4 -4
- data/sig/form_builder.rbs +2 -1
- data/sig/html_serializer.rbs +2 -1
- data/sig/http.rbs +1 -0
- data/sig/patcher.rbs +1 -1
- data/sig/router.rbs +2 -13
- data/sig/runtime.rbs +13 -0
- data/sig/styles.rbs +3 -4
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +47 -0
- metadata +18 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/mrblib/component.rb
CHANGED
|
@@ -5,46 +5,77 @@ module Funicular
|
|
|
5
5
|
@state = state_hash
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
# Support dynamic key access: state[:key] or state[variable_key]
|
|
9
8
|
def [](key)
|
|
10
9
|
@state[key]
|
|
11
10
|
end
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
|
|
12
|
+
def fetch(key, default = nil)
|
|
13
|
+
return @state.fetch(key) if default.nil? && @state.key?(key)
|
|
14
|
+
@state.fetch(key, default)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def key?(key)
|
|
18
|
+
@state.key?(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
@state
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ResourceAccessor
|
|
27
|
+
def initialize(data, states, errors)
|
|
28
|
+
@data = data
|
|
29
|
+
@states = states
|
|
30
|
+
@errors = errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](key)
|
|
34
|
+
@data[key]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(key, default = nil)
|
|
38
|
+
return @data.fetch(key) if default.nil? && @data.key?(key)
|
|
39
|
+
@data.fetch(key, default)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def loading?(key)
|
|
43
|
+
@states[key] == :pending || @states[key] == :loading
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def error?(key)
|
|
47
|
+
@states[key] == :rejected
|
|
19
48
|
end
|
|
20
49
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
@state.key?(method) || super
|
|
50
|
+
def error(key)
|
|
51
|
+
@errors[key]
|
|
24
52
|
end
|
|
25
53
|
end
|
|
26
54
|
|
|
27
|
-
attr_accessor :props, :vdom, :dom_element, :mounted
|
|
55
|
+
attr_accessor :props, :vdom, :dom_element, :mounted, :runtime, :children, :current_children
|
|
28
56
|
attr_reader :refs
|
|
29
57
|
|
|
30
58
|
def initialize(props = {})
|
|
31
59
|
@props = props
|
|
32
60
|
@state = initialize_state || {}
|
|
33
61
|
@state_accessor = nil
|
|
62
|
+
@resource_accessor = nil
|
|
34
63
|
@style_accessor = nil
|
|
64
|
+
@runtime = Funicular::Runtime.new
|
|
65
|
+
@children = [] #: Array[Funicular::VDOM::child_t]
|
|
35
66
|
@vdom = nil
|
|
36
67
|
@dom_element = nil
|
|
37
|
-
@refs = {}
|
|
38
|
-
@event_listeners = []
|
|
68
|
+
@refs = {} #: Hash[Symbol, JS::Element]
|
|
69
|
+
@event_listeners = [] #: Array[untyped]
|
|
39
70
|
@mounted = false
|
|
40
71
|
@updating = false
|
|
41
|
-
@child_components = []
|
|
72
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
42
73
|
|
|
43
74
|
# Initialize suspense state
|
|
44
|
-
@suspense_data = {}
|
|
45
|
-
@suspense_states = {}
|
|
46
|
-
@suspense_errors = {}
|
|
47
|
-
@suspense_pending_timers = []
|
|
75
|
+
@suspense_data = {} #: Hash[Symbol, untyped]
|
|
76
|
+
@suspense_states = {} #: Hash[Symbol, Symbol]
|
|
77
|
+
@suspense_errors = {} #: Hash[Symbol, untyped]
|
|
78
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
48
79
|
self.class.suspense_definitions.each_key do |name|
|
|
49
80
|
@suspense_states[name] = :pending
|
|
50
81
|
end
|
|
@@ -57,6 +88,14 @@ module Funicular
|
|
|
57
88
|
@state_accessor ||= StateAccessor.new(@state)
|
|
58
89
|
end
|
|
59
90
|
|
|
91
|
+
def resources
|
|
92
|
+
@resource_accessor ||= ResourceAccessor.new(@suspense_data, @suspense_states, @suspense_errors)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def styles
|
|
96
|
+
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
97
|
+
end
|
|
98
|
+
|
|
60
99
|
# Override this method in subclasses to define initial state
|
|
61
100
|
def initialize_state
|
|
62
101
|
{}
|
|
@@ -66,7 +105,7 @@ module Funicular
|
|
|
66
105
|
# Used by SSR to inject server data, and by client hydration to restore
|
|
67
106
|
# the same state from window.__FUNICULAR_STATE__ so the first client
|
|
68
107
|
# render matches the server HTML. Top-level keys are symbolized so they
|
|
69
|
-
# are reachable via state
|
|
108
|
+
# are reachable via state[:foo] (StateAccessor uses symbol keys); nested
|
|
70
109
|
# values are left untouched (components read them with string keys).
|
|
71
110
|
def seed_state(state_hash)
|
|
72
111
|
return self if state_hash.nil?
|
|
@@ -191,30 +230,25 @@ module Funicular
|
|
|
191
230
|
# ) do
|
|
192
231
|
# div { user.name }
|
|
193
232
|
# end
|
|
194
|
-
def
|
|
233
|
+
def render_suspense(h, name, fallback:, error: nil, &block)
|
|
195
234
|
current_children = @current_children
|
|
196
235
|
child_count_before = current_children&.size
|
|
197
236
|
result = nil
|
|
198
237
|
|
|
199
|
-
|
|
200
|
-
rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
|
|
201
|
-
if rejected_name
|
|
238
|
+
if @suspense_states[name] == :rejected
|
|
202
239
|
result = if error
|
|
203
|
-
error.call(@suspense_errors[
|
|
240
|
+
error.call(h, @suspense_errors[name])
|
|
204
241
|
else
|
|
205
|
-
fallback.call
|
|
242
|
+
fallback.call(h)
|
|
206
243
|
end
|
|
207
|
-
elsif suspense_loading?
|
|
208
|
-
|
|
209
|
-
# Note: Loading is started in mount(), not here
|
|
210
|
-
result = fallback.call
|
|
244
|
+
elsif suspense_loading?(name)
|
|
245
|
+
result = fallback.call(h)
|
|
211
246
|
else
|
|
212
|
-
|
|
213
|
-
result = block.call
|
|
247
|
+
result = block.call(h, resources)
|
|
214
248
|
end
|
|
215
249
|
|
|
216
250
|
if current_children && current_children.size == child_count_before
|
|
217
|
-
|
|
251
|
+
add_child_from_view(result)
|
|
218
252
|
end
|
|
219
253
|
|
|
220
254
|
result
|
|
@@ -223,7 +257,7 @@ module Funicular
|
|
|
223
257
|
# Class methods for styles DSL
|
|
224
258
|
def self.styles(&block)
|
|
225
259
|
builder = StyleBuilder.new
|
|
226
|
-
|
|
260
|
+
block.call(builder)
|
|
227
261
|
@styles_definitions = builder.to_definitions
|
|
228
262
|
end
|
|
229
263
|
|
|
@@ -256,12 +290,7 @@ module Funicular
|
|
|
256
290
|
end
|
|
257
291
|
|
|
258
292
|
def self.suspense_definitions
|
|
259
|
-
@suspense_definitions ||= {}
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Instance method to access styles
|
|
263
|
-
def s
|
|
264
|
-
@style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
|
|
293
|
+
@suspense_definitions ||= {}
|
|
265
294
|
end
|
|
266
295
|
|
|
267
296
|
# Update state and trigger re-render
|
|
@@ -301,7 +330,7 @@ module Funicular
|
|
|
301
330
|
|
|
302
331
|
@container = container
|
|
303
332
|
new_vdom = build_vdom
|
|
304
|
-
@dom_element = VDOM::Renderer.new.render(new_vdom)
|
|
333
|
+
@dom_element = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
305
334
|
bind_events(@dom_element, new_vdom)
|
|
306
335
|
collect_refs(@dom_element, new_vdom)
|
|
307
336
|
collect_child_components(new_vdom)
|
|
@@ -397,7 +426,7 @@ module Funicular
|
|
|
397
426
|
@child_components.each do |child|
|
|
398
427
|
child.unmount if child.respond_to?(:unmount)
|
|
399
428
|
end
|
|
400
|
-
@child_components = []
|
|
429
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
401
430
|
|
|
402
431
|
cleanup_events
|
|
403
432
|
cleanup_suspense_timers
|
|
@@ -418,17 +447,23 @@ module Funicular
|
|
|
418
447
|
end
|
|
419
448
|
|
|
420
449
|
# Override this method in subclasses to define render logic
|
|
421
|
-
def render
|
|
422
|
-
raise "Subclasses must implement
|
|
450
|
+
def render(h)
|
|
451
|
+
raise "Subclasses must implement render(h)"
|
|
423
452
|
end
|
|
424
453
|
|
|
425
454
|
# Build VDOM tree from render method
|
|
426
455
|
# Called by VDOM::Renderer, Differ, and Patcher
|
|
427
456
|
def build_vdom
|
|
457
|
+
previous_rendering = @rendering
|
|
458
|
+
previous_children = @current_children
|
|
428
459
|
@rendering = true
|
|
429
460
|
@current_children = nil
|
|
430
|
-
|
|
431
|
-
|
|
461
|
+
begin
|
|
462
|
+
result = render(ViewContext.new(self))
|
|
463
|
+
ensure
|
|
464
|
+
@rendering = previous_rendering
|
|
465
|
+
@current_children = previous_children
|
|
466
|
+
end
|
|
432
467
|
|
|
433
468
|
# Convert render result to VNode
|
|
434
469
|
vnode = normalize_vnode(result)
|
|
@@ -452,7 +487,7 @@ module Funicular
|
|
|
452
487
|
|
|
453
488
|
vnode.props.each do |key, value|
|
|
454
489
|
key_str = key.to_s
|
|
455
|
-
next unless
|
|
490
|
+
next unless VDOM.event_attribute?(key_str)
|
|
456
491
|
|
|
457
492
|
event_name = key_str[2..-1]&.downcase || ""
|
|
458
493
|
event_types << event_name
|
|
@@ -563,7 +598,7 @@ module Funicular
|
|
|
563
598
|
@event_listeners.each do |callback_id|
|
|
564
599
|
JS::Object.removeEventListener(callback_id)
|
|
565
600
|
end
|
|
566
|
-
@event_listeners = []
|
|
601
|
+
@event_listeners = [] #: Array[untyped]
|
|
567
602
|
|
|
568
603
|
# NOTE: Do NOT cleanup child component events here!
|
|
569
604
|
# Child components manage their own events and will cleanup
|
|
@@ -576,7 +611,74 @@ module Funicular
|
|
|
576
611
|
@suspense_pending_timers.each do |timer_id|
|
|
577
612
|
JS.global.clearTimeout(timer_id)
|
|
578
613
|
end
|
|
579
|
-
@suspense_pending_timers = []
|
|
614
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
private
|
|
618
|
+
|
|
619
|
+
public
|
|
620
|
+
|
|
621
|
+
def normalize_vnode_for_view(value)
|
|
622
|
+
normalize_vnode(value)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def add_child_from_view(child)
|
|
626
|
+
current_children = @current_children
|
|
627
|
+
return unless @rendering && current_children
|
|
628
|
+
|
|
629
|
+
normalized = normalize_vnode(child)
|
|
630
|
+
current_children << normalized if normalized
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def build_form_for(h, model_key, options = {}, &block)
|
|
634
|
+
on_submit = options.delete(:on_submit)
|
|
635
|
+
form_class = options.delete(:class)
|
|
636
|
+
|
|
637
|
+
submit_handler = if on_submit
|
|
638
|
+
->(event) do
|
|
639
|
+
event.preventDefault
|
|
640
|
+
form_data = collect_form_data(event, model_key)
|
|
641
|
+
|
|
642
|
+
case on_submit
|
|
643
|
+
when Symbol
|
|
644
|
+
send(on_submit, form_data)
|
|
645
|
+
when Method
|
|
646
|
+
on_submit.call(form_data)
|
|
647
|
+
when Proc
|
|
648
|
+
on_submit.call(form_data)
|
|
649
|
+
else
|
|
650
|
+
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
else
|
|
654
|
+
->(event) { event.preventDefault }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do |hh|
|
|
658
|
+
builder = Funicular::FormBuilder.new(self, hh, model_key, options)
|
|
659
|
+
block.call(builder)
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def build_link_to(h, path, method: :get, **options, &block)
|
|
664
|
+
merged_options = options.merge(href: path)
|
|
665
|
+
merged_options[:onclick] = ->(event) {
|
|
666
|
+
event.preventDefault
|
|
667
|
+
if method.to_s.downcase.to_sym == :get
|
|
668
|
+
handle_link_click(path)
|
|
669
|
+
else
|
|
670
|
+
handle_link_with_method(path, method)
|
|
671
|
+
end
|
|
672
|
+
}
|
|
673
|
+
h.a(merged_options, &block)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def build_button_to(h, path, method: :post, **options, &block)
|
|
677
|
+
merged_options = options.merge(
|
|
678
|
+
type: options[:type] || "button",
|
|
679
|
+
onclick: -> { handle_link_with_method(path, method) }
|
|
680
|
+
)
|
|
681
|
+
h.button(merged_options, &block)
|
|
580
682
|
end
|
|
581
683
|
|
|
582
684
|
private
|
|
@@ -615,7 +717,7 @@ module Funicular
|
|
|
615
717
|
cleanup_events
|
|
616
718
|
|
|
617
719
|
unless patches.empty?
|
|
618
|
-
new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
|
|
720
|
+
new_dom_element = VDOM::Patcher.new(nil, @runtime).apply(@dom_element, patches)
|
|
619
721
|
# apply returns JS::Object (it must accept text-node patches), but
|
|
620
722
|
# the component's root is always an Element. Narrow to JS::Element.
|
|
621
723
|
@dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
|
|
@@ -698,7 +800,7 @@ module Funicular
|
|
|
698
800
|
# appendChild: the stale node already has a place in the document, so we
|
|
699
801
|
# replaceChild instead.
|
|
700
802
|
def full_render_fallback(new_vdom, server_dom)
|
|
701
|
-
fresh = VDOM::Renderer.new.render(new_vdom)
|
|
803
|
+
fresh = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
702
804
|
parent = server_dom.parentNode
|
|
703
805
|
parent.replaceChild(fresh, server_dom) if parent
|
|
704
806
|
bind_events(fresh, new_vdom)
|
|
@@ -721,6 +823,8 @@ module Funicular
|
|
|
721
823
|
|
|
722
824
|
if child.is_a?(VDOM::Component)
|
|
723
825
|
instance = child.component_class.new(child.props)
|
|
826
|
+
instance.runtime = child.runtime || @runtime
|
|
827
|
+
instance.children = child.children
|
|
724
828
|
child.instance = instance
|
|
725
829
|
instance.hydrate(child_dom)
|
|
726
830
|
elsif child.is_a?(VDOM::Element)
|
|
@@ -731,7 +835,7 @@ module Funicular
|
|
|
731
835
|
|
|
732
836
|
# Collect child component instances from VDOM tree
|
|
733
837
|
def collect_child_components(vnode)
|
|
734
|
-
@child_components = []
|
|
838
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
735
839
|
collect_child_components_recursive(vnode, @child_components)
|
|
736
840
|
end
|
|
737
841
|
|
|
@@ -750,135 +854,6 @@ module Funicular
|
|
|
750
854
|
end
|
|
751
855
|
end
|
|
752
856
|
|
|
753
|
-
# DSL methods for HTML elements
|
|
754
|
-
HTML_TAGS = %w[
|
|
755
|
-
div span p a
|
|
756
|
-
h1 h2 h3 h4 h5 h6
|
|
757
|
-
ul ol li
|
|
758
|
-
table thead tbody tr th td
|
|
759
|
-
form input textarea button select option label
|
|
760
|
-
header footer nav section article aside
|
|
761
|
-
img video audio canvas
|
|
762
|
-
br hr
|
|
763
|
-
]
|
|
764
|
-
|
|
765
|
-
# The HTML tag DSL methods are public: besides being called inside render
|
|
766
|
-
# (implicit self), they are invoked by collaborators such as FormBuilder
|
|
767
|
-
# with an explicit receiver (@component.div). A private method would forbid
|
|
768
|
-
# that under CRuby (it is tolerated under mruby), which would break SSR of
|
|
769
|
-
# any component using form_for. Keep them public on both VMs.
|
|
770
|
-
public
|
|
771
|
-
|
|
772
|
-
HTML_TAGS.each do |tag|
|
|
773
|
-
define_method(tag) do |props = {}, &block|
|
|
774
|
-
# @type self: Component
|
|
775
|
-
children = [] #: Array[Funicular::VDOM::child_t]
|
|
776
|
-
|
|
777
|
-
if block
|
|
778
|
-
prev_children = @current_children
|
|
779
|
-
@current_children = children
|
|
780
|
-
# @type var block: Proc
|
|
781
|
-
result = block.call
|
|
782
|
-
@current_children = prev_children
|
|
783
|
-
|
|
784
|
-
# Add block result to children if not already added via add_child
|
|
785
|
-
if result && !result.equal?(children) && children.empty?
|
|
786
|
-
normalized = normalize_vnode(result)
|
|
787
|
-
children << normalized if normalized
|
|
788
|
-
end
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
# Normalize props (convert StyleValue to String for :class)
|
|
792
|
-
normalized_props = {}
|
|
793
|
-
# @type var props: Hash[Symbol, String]
|
|
794
|
-
props.each do |key, value|
|
|
795
|
-
if key == :class && value.is_a?(StyleValue)
|
|
796
|
-
normalized_props[key] = value.to_s
|
|
797
|
-
else
|
|
798
|
-
normalized_props[key] = value
|
|
799
|
-
end
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# @type var normalized_props: Hash[Symbol, String]
|
|
803
|
-
element = VDOM::Element.new(tag, normalized_props, children)
|
|
804
|
-
|
|
805
|
-
# If we're inside another element's block, add this element to parent's children
|
|
806
|
-
if @rendering && @current_children
|
|
807
|
-
@current_children << element
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
element
|
|
811
|
-
end
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
private
|
|
815
|
-
|
|
816
|
-
# Helper to add children in DSL blocks
|
|
817
|
-
def add_child(child)
|
|
818
|
-
return unless @rendering && @current_children
|
|
819
|
-
|
|
820
|
-
normalized = normalize_vnode(child)
|
|
821
|
-
@current_children << normalized if normalized
|
|
822
|
-
end
|
|
823
|
-
|
|
824
|
-
# Helper method to render child components in DSL
|
|
825
|
-
# Accepts optional block for passing children to the component
|
|
826
|
-
def component(component_class, props = {}, &block)
|
|
827
|
-
unless component_class.is_a?(Class) && component_class.ancestors.include?(Funicular::Component)
|
|
828
|
-
raise "component() expects a Funicular::Component class"
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
# If a block is provided, store the children builder in props
|
|
832
|
-
# This allows components like ErrorBoundary to control child rendering
|
|
833
|
-
if block
|
|
834
|
-
props = props.merge(children_block: block)
|
|
835
|
-
end
|
|
836
|
-
|
|
837
|
-
vnode = VDOM::Component.new(component_class, props)
|
|
838
|
-
|
|
839
|
-
# If we're inside another element's block, add this component to parent's children
|
|
840
|
-
if @rendering && @current_children
|
|
841
|
-
@current_children << vnode
|
|
842
|
-
end
|
|
843
|
-
|
|
844
|
-
vnode
|
|
845
|
-
end
|
|
846
|
-
|
|
847
|
-
# Rails-style form_for helper
|
|
848
|
-
def form_for(model_key, options = {}, &block)
|
|
849
|
-
on_submit = options.delete(:on_submit)
|
|
850
|
-
form_class = options.delete(:class)
|
|
851
|
-
|
|
852
|
-
# Build submit handler
|
|
853
|
-
submit_handler = if on_submit
|
|
854
|
-
->(event) do
|
|
855
|
-
event.preventDefault
|
|
856
|
-
|
|
857
|
-
form_data = collect_form_data(event, model_key)
|
|
858
|
-
|
|
859
|
-
# Call the submit handler (Symbol, Method, or Proc)
|
|
860
|
-
case on_submit
|
|
861
|
-
when Symbol
|
|
862
|
-
send(on_submit, form_data)
|
|
863
|
-
when Method
|
|
864
|
-
on_submit.call(form_data)
|
|
865
|
-
when Proc
|
|
866
|
-
on_submit.call(form_data)
|
|
867
|
-
else
|
|
868
|
-
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
869
|
-
end
|
|
870
|
-
end
|
|
871
|
-
else
|
|
872
|
-
->(event) { event.preventDefault }
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
# Render form
|
|
876
|
-
form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
|
|
877
|
-
builder = Funicular::FormBuilder.new(self, model_key, options)
|
|
878
|
-
block.call(builder)
|
|
879
|
-
end
|
|
880
|
-
end
|
|
881
|
-
|
|
882
857
|
def collect_form_data(event, model_key)
|
|
883
858
|
form_data = collect_dom_form_data(event)
|
|
884
859
|
return form_data unless form_data.empty?
|
|
@@ -937,7 +912,7 @@ module Funicular
|
|
|
937
912
|
end
|
|
938
913
|
|
|
939
914
|
def collect_state_form_data(model_key)
|
|
940
|
-
model_data = state
|
|
915
|
+
model_data = state[model_key]
|
|
941
916
|
if model_data.is_a?(Hash)
|
|
942
917
|
model_data
|
|
943
918
|
elsif model_data.respond_to?(:instance_variables)
|
|
@@ -952,34 +927,9 @@ module Funicular
|
|
|
952
927
|
end
|
|
953
928
|
end
|
|
954
929
|
|
|
955
|
-
# Rails-style link_to helper
|
|
956
|
-
# Default behavior: Fetch API for same-page actions (SPA-friendly)
|
|
957
|
-
# Use navigate: true for router navigation (different component tree)
|
|
958
|
-
def link_to(path, method: :get, navigate: false, **options, &block)
|
|
959
|
-
if navigate
|
|
960
|
-
# Navigation: Use <a> tag with real href for browser features
|
|
961
|
-
# (right-click -> open in new tab, link preview, etc.)
|
|
962
|
-
# Note: preventDefault is automatically called by js_add_event_listener
|
|
963
|
-
# for <a> tags to enable SPA navigation
|
|
964
|
-
merged_options = options.merge(href: path)
|
|
965
|
-
merged_options[:onclick] = ->(event) {
|
|
966
|
-
event.preventDefault # Called for clarity, but already handled by JS layer
|
|
967
|
-
handle_link_click(path)
|
|
968
|
-
}
|
|
969
|
-
a(merged_options, &block)
|
|
970
|
-
else
|
|
971
|
-
# Action: Use <div> tag (semantically more appropriate for actions)
|
|
972
|
-
# No href needed, purely onclick-driven
|
|
973
|
-
merged_options = options.merge(
|
|
974
|
-
onclick: -> { handle_link_with_method(path, method) }
|
|
975
|
-
)
|
|
976
|
-
div(merged_options, &block)
|
|
977
|
-
end
|
|
978
|
-
end
|
|
979
|
-
|
|
980
930
|
# Handle router navigation (navigate using History API)
|
|
981
931
|
def handle_link_click(path)
|
|
982
|
-
|
|
932
|
+
@runtime.router&.navigate(path)
|
|
983
933
|
end
|
|
984
934
|
|
|
985
935
|
# Handle link action via Fetch API
|
|
@@ -1008,35 +958,6 @@ module Funicular
|
|
|
1008
958
|
end
|
|
1009
959
|
end
|
|
1010
960
|
|
|
1011
|
-
# Enable URL helpers from RouteHelpers module and suspense data accessors
|
|
1012
|
-
def method_missing(method, *args)
|
|
1013
|
-
# Check for suspense data accessor
|
|
1014
|
-
if self.class.suspense_definitions.key?(method)
|
|
1015
|
-
return @suspense_data[method]
|
|
1016
|
-
end
|
|
1017
|
-
|
|
1018
|
-
if Funicular.const_defined?(:RouteHelpers)
|
|
1019
|
-
helpers = Funicular::RouteHelpers
|
|
1020
|
-
if helpers.instance_methods.include?(method)
|
|
1021
|
-
# Include helpers module and retry
|
|
1022
|
-
self.class.include(helpers) unless self.class.include?(helpers)
|
|
1023
|
-
return send(method, *args)
|
|
1024
|
-
end
|
|
1025
|
-
end
|
|
1026
|
-
super
|
|
1027
|
-
end
|
|
1028
|
-
|
|
1029
|
-
def respond_to_missing?(method, include_private = false)
|
|
1030
|
-
# Check for suspense data accessor
|
|
1031
|
-
return true if self.class.suspense_definitions.key?(method)
|
|
1032
|
-
|
|
1033
|
-
if Funicular.const_defined?(:RouteHelpers)
|
|
1034
|
-
Funicular::RouteHelpers.instance_methods.include?(method) || super
|
|
1035
|
-
else
|
|
1036
|
-
super
|
|
1037
|
-
end
|
|
1038
|
-
end
|
|
1039
|
-
|
|
1040
961
|
# Transition Helpers
|
|
1041
962
|
# These methods provide a declarative way to animate element removal/addition
|
|
1042
963
|
# using CSS transitions, inspired by Vue.js and Alpine.js transition systems
|
data/mrblib/debug.rb
CHANGED
|
@@ -152,16 +152,17 @@ module Funicular
|
|
|
152
152
|
|
|
153
153
|
result = {} #: Hash[String, String]
|
|
154
154
|
component.instance_variables.each do |var|
|
|
155
|
-
|
|
156
|
-
next if
|
|
157
|
-
if
|
|
158
|
-
|
|
155
|
+
name = var.to_s
|
|
156
|
+
next if name == '@state'
|
|
157
|
+
next if name.start_with?('@__debug')
|
|
158
|
+
if name == '@vdom' || name == '@child_components'
|
|
159
|
+
result[name] = "<omitted>"
|
|
159
160
|
next
|
|
160
161
|
end
|
|
161
162
|
begin
|
|
162
|
-
result[
|
|
163
|
+
result[name] = component.instance_variable_get(var).inspect
|
|
163
164
|
rescue
|
|
164
|
-
result[
|
|
165
|
+
result[name] = "<error inspecting value>"
|
|
165
166
|
end
|
|
166
167
|
end
|
|
167
168
|
JSON.generate(result)
|
data/mrblib/differ.rb
CHANGED
|
@@ -213,11 +213,13 @@ module Funicular
|
|
|
213
213
|
# If only Proc props changed, update props but skip VDOM rebuild
|
|
214
214
|
unless data_props_changed
|
|
215
215
|
instance.props = new_node.props
|
|
216
|
+
instance.children = new_node.children
|
|
216
217
|
return []
|
|
217
218
|
end
|
|
218
219
|
|
|
219
220
|
old_internal_vdom = instance.vdom
|
|
220
221
|
instance.props = new_node.props
|
|
222
|
+
instance.children = new_node.children
|
|
221
223
|
new_internal_vdom = instance.build_vdom
|
|
222
224
|
internal_patches = diff(old_internal_vdom, new_internal_vdom)
|
|
223
225
|
|
|
@@ -227,7 +229,7 @@ module Funicular
|
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
# Original logic: If props changed, replace the entire component
|
|
230
|
-
if old_node.props != new_node.props
|
|
232
|
+
if old_node.props != new_node.props || old_node.children != new_node.children
|
|
231
233
|
return [[:replace, new_node, old_node]]
|
|
232
234
|
end
|
|
233
235
|
|