funicular 0.2.1 → 0.4.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 +111 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +8 -8
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +8 -5
- data/demo/test_router.html +11 -11
- data/demo/tic-tac-toe.html +4 -4
- data/docs/architecture.md +80 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +3 -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} +626 -486
- 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 +675 -449
- 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 +2 -2
- 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 +860 -567
- 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 +25 -25
- 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/dsl_test.rb +237 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
- data/minitest/funicular_test.rb +28 -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/sig_tags_test.rb +30 -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/0_tags.rb +62 -0
- data/mrblib/component.rb +255 -244
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +11 -13
- 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 +107 -21
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +106 -0
- data/sig/component.rbs +47 -84
- 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 +19 -7
- data/sig/tags.rbs +54 -0
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +60 -0
- metadata +22 -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,128 @@ 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
|
-
|
|
55
|
+
include Tags
|
|
56
|
+
|
|
57
|
+
attr_accessor :props, :vdom, :dom_element, :mounted, :runtime, :children, :current_children
|
|
28
58
|
attr_reader :refs
|
|
29
59
|
|
|
60
|
+
# Opt out of DSL collision detection for the given method names. The
|
|
61
|
+
# shadowed tag remains reachable through tag(:name, ...).
|
|
62
|
+
def self.allow_dsl_override(*names)
|
|
63
|
+
@dsl_overrides ||= [] #: Array[Symbol]
|
|
64
|
+
@dsl_overrides.concat(names)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.dsl_overrides
|
|
68
|
+
@dsl_overrides ||= [] #: Array[Symbol]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Layer 1: catches `def`, `define_method`, and `alias` in subclasses at
|
|
72
|
+
# class-definition time. attr_* does not fire this hook on mruby, and
|
|
73
|
+
# included modules never do; validate_dsl_conflicts! covers those.
|
|
74
|
+
def self.method_added(name)
|
|
75
|
+
if self != Funicular::Component && Funicular::Tags::RESERVED_DSL[name] && !dsl_overrides.include?(name)
|
|
76
|
+
kind = Funicular::Tags::RESERVED_DSL[name] == :tag ? "<#{name}> tag helper" : "##{name} helper"
|
|
77
|
+
raise Funicular::DSLCollisionError,
|
|
78
|
+
"#{self}##{name} collides with the Funicular DSL (#{kind}). " \
|
|
79
|
+
"Rename it (e.g. `#{name}_value`), or declare `allow_dsl_override :#{name}` " \
|
|
80
|
+
"and use `tag(:#{name}, ...)` to emit the element."
|
|
81
|
+
end
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Layer 2: once per class, sweep methods the method_added hook cannot
|
|
86
|
+
# see (attr_* on mruby, user-included modules).
|
|
87
|
+
def self.validate_dsl_conflicts!
|
|
88
|
+
return if @dsl_validated
|
|
89
|
+
if instance_method(:render).arity != 0
|
|
90
|
+
raise Funicular::DSLCollisionError,
|
|
91
|
+
"#{self}#render must not take parameters as of Funicular 0.4.0: " \
|
|
92
|
+
"delete the parameter (`def render`) and drop the `h.` receivers " \
|
|
93
|
+
"inside; tags are bareword methods on the component now."
|
|
94
|
+
end
|
|
95
|
+
ancestors.each do |mod|
|
|
96
|
+
break if mod == Funicular::Component || mod == Funicular::Tags
|
|
97
|
+
next unless mod.is_a?(Module)
|
|
98
|
+
methods = mod.instance_methods(false) + mod.private_instance_methods(false)
|
|
99
|
+
offenders = methods.select { |m| Funicular::Tags::RESERVED_DSL[m] } - dsl_overrides
|
|
100
|
+
next if offenders.empty?
|
|
101
|
+
raise Funicular::DSLCollisionError,
|
|
102
|
+
"#{mod} defines methods that collide with the Funicular DSL: " \
|
|
103
|
+
"#{offenders.map { |m| m.to_s }.join(', ')}. Rename them, or declare " \
|
|
104
|
+
"`allow_dsl_override` and use `tag(...)` to emit the element."
|
|
105
|
+
end
|
|
106
|
+
@dsl_validated = true
|
|
107
|
+
end
|
|
108
|
+
|
|
30
109
|
def initialize(props = {})
|
|
31
110
|
@props = props
|
|
32
111
|
@state = initialize_state || {}
|
|
33
112
|
@state_accessor = nil
|
|
113
|
+
@resource_accessor = nil
|
|
34
114
|
@style_accessor = nil
|
|
115
|
+
@runtime = Funicular::Runtime.new
|
|
116
|
+
@children = [] #: Array[Funicular::VDOM::child_t]
|
|
35
117
|
@vdom = nil
|
|
36
118
|
@dom_element = nil
|
|
37
|
-
@refs = {}
|
|
38
|
-
@event_listeners = []
|
|
119
|
+
@refs = {} #: Hash[Symbol, JS::Element]
|
|
120
|
+
@event_listeners = [] #: Array[untyped]
|
|
39
121
|
@mounted = false
|
|
40
122
|
@updating = false
|
|
41
|
-
@child_components = []
|
|
123
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
42
124
|
|
|
43
125
|
# Initialize suspense state
|
|
44
|
-
@suspense_data = {}
|
|
45
|
-
@suspense_states = {}
|
|
46
|
-
@suspense_errors = {}
|
|
47
|
-
@suspense_pending_timers = []
|
|
126
|
+
@suspense_data = {} #: Hash[Symbol, untyped]
|
|
127
|
+
@suspense_states = {} #: Hash[Symbol, Symbol]
|
|
128
|
+
@suspense_errors = {} #: Hash[Symbol, untyped]
|
|
129
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
48
130
|
self.class.suspense_definitions.each_key do |name|
|
|
49
131
|
@suspense_states[name] = :pending
|
|
50
132
|
end
|
|
@@ -57,6 +139,14 @@ module Funicular
|
|
|
57
139
|
@state_accessor ||= StateAccessor.new(@state)
|
|
58
140
|
end
|
|
59
141
|
|
|
142
|
+
def resources
|
|
143
|
+
@resource_accessor ||= ResourceAccessor.new(@suspense_data, @suspense_states, @suspense_errors)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def styles
|
|
147
|
+
@style_accessor ||= self.class.style_accessor_class.new(self.class.styles_definitions)
|
|
148
|
+
end
|
|
149
|
+
|
|
60
150
|
# Override this method in subclasses to define initial state
|
|
61
151
|
def initialize_state
|
|
62
152
|
{}
|
|
@@ -66,7 +156,7 @@ module Funicular
|
|
|
66
156
|
# Used by SSR to inject server data, and by client hydration to restore
|
|
67
157
|
# the same state from window.__FUNICULAR_STATE__ so the first client
|
|
68
158
|
# render matches the server HTML. Top-level keys are symbolized so they
|
|
69
|
-
# are reachable via state
|
|
159
|
+
# are reachable via state[:foo] (StateAccessor uses symbol keys); nested
|
|
70
160
|
# values are left untouched (components read them with string keys).
|
|
71
161
|
def seed_state(state_hash)
|
|
72
162
|
return self if state_hash.nil?
|
|
@@ -191,46 +281,49 @@ module Funicular
|
|
|
191
281
|
# ) do
|
|
192
282
|
# div { user.name }
|
|
193
283
|
# end
|
|
194
|
-
def
|
|
284
|
+
def render_suspense(name, fallback:, error: nil, &block)
|
|
195
285
|
current_children = @current_children
|
|
196
286
|
child_count_before = current_children&.size
|
|
197
287
|
result = nil
|
|
198
288
|
|
|
199
|
-
|
|
200
|
-
rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
|
|
201
|
-
if rejected_name
|
|
289
|
+
if @suspense_states[name] == :rejected
|
|
202
290
|
result = if error
|
|
203
|
-
error.call(@suspense_errors[
|
|
291
|
+
error.call(@suspense_errors[name])
|
|
204
292
|
else
|
|
205
293
|
fallback.call
|
|
206
294
|
end
|
|
207
|
-
elsif suspense_loading?
|
|
208
|
-
# Check if any suspense is still loading
|
|
209
|
-
# Note: Loading is started in mount(), not here
|
|
295
|
+
elsif suspense_loading?(name)
|
|
210
296
|
result = fallback.call
|
|
211
297
|
else
|
|
212
|
-
|
|
213
|
-
result = block.call
|
|
298
|
+
result = block.call(resources)
|
|
214
299
|
end
|
|
215
300
|
|
|
216
301
|
if current_children && current_children.size == child_count_before
|
|
217
|
-
|
|
302
|
+
add_child_from_view(result)
|
|
218
303
|
end
|
|
219
304
|
|
|
220
305
|
result
|
|
221
306
|
end
|
|
222
307
|
|
|
223
|
-
# Class methods for styles DSL
|
|
308
|
+
# Class methods for styles DSL. The block is instance_exec'd on a
|
|
309
|
+
# StyleBuilder cleanroom so barewords define styles; it also receives
|
|
310
|
+
# the builder for the explicit `styles { |css| css.define(...) }` form.
|
|
224
311
|
def self.styles(&block)
|
|
225
312
|
builder = StyleBuilder.new
|
|
226
|
-
builder.
|
|
313
|
+
builder.instance_exec(builder, &block) # steep:ignore
|
|
227
314
|
@styles_definitions = builder.to_definitions
|
|
315
|
+
@style_accessor_class = nil
|
|
228
316
|
end
|
|
229
317
|
|
|
230
318
|
def self.styles_definitions
|
|
231
319
|
@styles_definitions ||= {}
|
|
232
320
|
end
|
|
233
321
|
|
|
322
|
+
# Per-class accessor with one real method per declared style name.
|
|
323
|
+
def self.style_accessor_class
|
|
324
|
+
@style_accessor_class ||= StyleAccessor.accessor_for(styles_definitions)
|
|
325
|
+
end
|
|
326
|
+
|
|
234
327
|
# Suspense DSL - register async data loaders
|
|
235
328
|
#
|
|
236
329
|
# @param name [Symbol] Name of the suspense data (becomes accessible as method)
|
|
@@ -256,12 +349,7 @@ module Funicular
|
|
|
256
349
|
end
|
|
257
350
|
|
|
258
351
|
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)
|
|
352
|
+
@suspense_definitions ||= {}
|
|
265
353
|
end
|
|
266
354
|
|
|
267
355
|
# Update state and trigger re-render
|
|
@@ -301,7 +389,7 @@ module Funicular
|
|
|
301
389
|
|
|
302
390
|
@container = container
|
|
303
391
|
new_vdom = build_vdom
|
|
304
|
-
@dom_element = VDOM::Renderer.new.render(new_vdom)
|
|
392
|
+
@dom_element = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
305
393
|
bind_events(@dom_element, new_vdom)
|
|
306
394
|
collect_refs(@dom_element, new_vdom)
|
|
307
395
|
collect_child_components(new_vdom)
|
|
@@ -397,7 +485,7 @@ module Funicular
|
|
|
397
485
|
@child_components.each do |child|
|
|
398
486
|
child.unmount if child.respond_to?(:unmount)
|
|
399
487
|
end
|
|
400
|
-
@child_components = []
|
|
488
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
401
489
|
|
|
402
490
|
cleanup_events
|
|
403
491
|
cleanup_suspense_timers
|
|
@@ -419,16 +507,23 @@ module Funicular
|
|
|
419
507
|
|
|
420
508
|
# Override this method in subclasses to define render logic
|
|
421
509
|
def render
|
|
422
|
-
raise "Subclasses must implement
|
|
510
|
+
raise "Subclasses must implement render"
|
|
423
511
|
end
|
|
424
512
|
|
|
425
513
|
# Build VDOM tree from render method
|
|
426
514
|
# Called by VDOM::Renderer, Differ, and Patcher
|
|
427
515
|
def build_vdom
|
|
516
|
+
self.class.validate_dsl_conflicts!
|
|
517
|
+
previous_rendering = @rendering
|
|
518
|
+
previous_children = @current_children
|
|
428
519
|
@rendering = true
|
|
429
520
|
@current_children = nil
|
|
430
|
-
|
|
431
|
-
|
|
521
|
+
begin
|
|
522
|
+
result = render
|
|
523
|
+
ensure
|
|
524
|
+
@rendering = previous_rendering
|
|
525
|
+
@current_children = previous_children
|
|
526
|
+
end
|
|
432
527
|
|
|
433
528
|
# Convert render result to VNode
|
|
434
529
|
vnode = normalize_vnode(result)
|
|
@@ -452,7 +547,7 @@ module Funicular
|
|
|
452
547
|
|
|
453
548
|
vnode.props.each do |key, value|
|
|
454
549
|
key_str = key.to_s
|
|
455
|
-
next unless
|
|
550
|
+
next unless VDOM.event_attribute?(key_str)
|
|
456
551
|
|
|
457
552
|
event_name = key_str[2..-1]&.downcase || ""
|
|
458
553
|
event_types << event_name
|
|
@@ -563,7 +658,7 @@ module Funicular
|
|
|
563
658
|
@event_listeners.each do |callback_id|
|
|
564
659
|
JS::Object.removeEventListener(callback_id)
|
|
565
660
|
end
|
|
566
|
-
@event_listeners = []
|
|
661
|
+
@event_listeners = [] #: Array[untyped]
|
|
567
662
|
|
|
568
663
|
# NOTE: Do NOT cleanup child component events here!
|
|
569
664
|
# Child components manage their own events and will cleanup
|
|
@@ -576,7 +671,110 @@ module Funicular
|
|
|
576
671
|
@suspense_pending_timers.each do |timer_id|
|
|
577
672
|
JS.global.clearTimeout(timer_id)
|
|
578
673
|
end
|
|
579
|
-
@suspense_pending_timers = []
|
|
674
|
+
@suspense_pending_timers = [] #: Array[untyped]
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
private
|
|
678
|
+
|
|
679
|
+
public
|
|
680
|
+
|
|
681
|
+
def normalize_vnode_for_view(value)
|
|
682
|
+
normalize_vnode(value)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Internal element factory shared by the Tags mixin, FormBuilder, and
|
|
686
|
+
# the framework helpers. One instance per component; ViewContext itself
|
|
687
|
+
# is stateless (the render cursor lives on the component).
|
|
688
|
+
def __view__
|
|
689
|
+
@__view__ ||= ViewContext.new(self)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def routes
|
|
693
|
+
@runtime.routes
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Bareword DSL helpers available inside render (self is the component).
|
|
697
|
+
def component(component_class, props = {}, &block)
|
|
698
|
+
__view__.component(component_class, props, &block)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def form_for(model_key, options = {}, &block)
|
|
702
|
+
build_form_for(__view__, model_key, options, &block)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def link_to(path, **options, &block)
|
|
706
|
+
build_link_to(__view__, path, **options, &block)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def button_to(path, method: :post, **options, &block)
|
|
710
|
+
build_button_to(__view__, path, method: method, **options, &block)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def suspense(name, fallback:, error: nil, &block)
|
|
714
|
+
render_suspense(name, fallback: fallback, error: error, &block)
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def add_child_from_view(child)
|
|
718
|
+
unless @rendering
|
|
719
|
+
raise Funicular::RenderContextError,
|
|
720
|
+
"view DSL called outside render (tags may only be used while the component is rendering)"
|
|
721
|
+
end
|
|
722
|
+
current_children = @current_children
|
|
723
|
+
return unless current_children
|
|
724
|
+
|
|
725
|
+
normalized = normalize_vnode(child)
|
|
726
|
+
current_children << normalized if normalized
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def build_form_for(h, model_key, options = {}, &block)
|
|
730
|
+
on_submit = options.delete(:on_submit)
|
|
731
|
+
form_class = options.delete(:class)
|
|
732
|
+
|
|
733
|
+
submit_handler = if on_submit
|
|
734
|
+
->(event) do
|
|
735
|
+
event.preventDefault
|
|
736
|
+
form_data = collect_form_data(event, model_key)
|
|
737
|
+
|
|
738
|
+
case on_submit
|
|
739
|
+
when Symbol
|
|
740
|
+
send(on_submit, form_data)
|
|
741
|
+
when Method
|
|
742
|
+
on_submit.call(form_data)
|
|
743
|
+
when Proc
|
|
744
|
+
on_submit.call(form_data)
|
|
745
|
+
else
|
|
746
|
+
raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
else
|
|
750
|
+
->(event) { event.preventDefault }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
|
|
754
|
+
builder = Funicular::FormBuilder.new(self, h, model_key, options)
|
|
755
|
+
block.call(builder)
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def build_link_to(h, path, method: :get, **options, &block)
|
|
760
|
+
merged_options = options.merge(href: path)
|
|
761
|
+
merged_options[:onclick] = ->(event) {
|
|
762
|
+
event.preventDefault
|
|
763
|
+
if method.to_s.downcase.to_sym == :get
|
|
764
|
+
handle_link_click(path)
|
|
765
|
+
else
|
|
766
|
+
handle_link_with_method(path, method)
|
|
767
|
+
end
|
|
768
|
+
}
|
|
769
|
+
h.a(merged_options, &block)
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def build_button_to(h, path, method: :post, **options, &block)
|
|
773
|
+
merged_options = options.merge(
|
|
774
|
+
type: options[:type] || "button",
|
|
775
|
+
onclick: -> { handle_link_with_method(path, method) }
|
|
776
|
+
)
|
|
777
|
+
h.button(merged_options, &block)
|
|
580
778
|
end
|
|
581
779
|
|
|
582
780
|
private
|
|
@@ -615,7 +813,7 @@ module Funicular
|
|
|
615
813
|
cleanup_events
|
|
616
814
|
|
|
617
815
|
unless patches.empty?
|
|
618
|
-
new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
|
|
816
|
+
new_dom_element = VDOM::Patcher.new(nil, @runtime).apply(@dom_element, patches)
|
|
619
817
|
# apply returns JS::Object (it must accept text-node patches), but
|
|
620
818
|
# the component's root is always an Element. Narrow to JS::Element.
|
|
621
819
|
@dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
|
|
@@ -698,7 +896,7 @@ module Funicular
|
|
|
698
896
|
# appendChild: the stale node already has a place in the document, so we
|
|
699
897
|
# replaceChild instead.
|
|
700
898
|
def full_render_fallback(new_vdom, server_dom)
|
|
701
|
-
fresh = VDOM::Renderer.new.render(new_vdom)
|
|
899
|
+
fresh = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
|
|
702
900
|
parent = server_dom.parentNode
|
|
703
901
|
parent.replaceChild(fresh, server_dom) if parent
|
|
704
902
|
bind_events(fresh, new_vdom)
|
|
@@ -721,6 +919,8 @@ module Funicular
|
|
|
721
919
|
|
|
722
920
|
if child.is_a?(VDOM::Component)
|
|
723
921
|
instance = child.component_class.new(child.props)
|
|
922
|
+
instance.runtime = child.runtime || @runtime
|
|
923
|
+
instance.children = child.children
|
|
724
924
|
child.instance = instance
|
|
725
925
|
instance.hydrate(child_dom)
|
|
726
926
|
elsif child.is_a?(VDOM::Element)
|
|
@@ -731,7 +931,7 @@ module Funicular
|
|
|
731
931
|
|
|
732
932
|
# Collect child component instances from VDOM tree
|
|
733
933
|
def collect_child_components(vnode)
|
|
734
|
-
@child_components = []
|
|
934
|
+
@child_components = [] #: Array[Funicular::Component]
|
|
735
935
|
collect_child_components_recursive(vnode, @child_components)
|
|
736
936
|
end
|
|
737
937
|
|
|
@@ -750,135 +950,6 @@ module Funicular
|
|
|
750
950
|
end
|
|
751
951
|
end
|
|
752
952
|
|
|
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
953
|
def collect_form_data(event, model_key)
|
|
883
954
|
form_data = collect_dom_form_data(event)
|
|
884
955
|
return form_data unless form_data.empty?
|
|
@@ -906,15 +977,9 @@ module Funicular
|
|
|
906
977
|
end
|
|
907
978
|
|
|
908
979
|
def event_target(event)
|
|
909
|
-
target
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
rescue
|
|
913
|
-
target = nil
|
|
914
|
-
end
|
|
915
|
-
return target if target
|
|
916
|
-
|
|
917
|
-
event.target if event.respond_to?(:target)
|
|
980
|
+
event[:target]
|
|
981
|
+
rescue
|
|
982
|
+
nil
|
|
918
983
|
end
|
|
919
984
|
|
|
920
985
|
def add_form_field_value(data, field)
|
|
@@ -937,7 +1002,7 @@ module Funicular
|
|
|
937
1002
|
end
|
|
938
1003
|
|
|
939
1004
|
def collect_state_form_data(model_key)
|
|
940
|
-
model_data = state
|
|
1005
|
+
model_data = state[model_key]
|
|
941
1006
|
if model_data.is_a?(Hash)
|
|
942
1007
|
model_data
|
|
943
1008
|
elsif model_data.respond_to?(:instance_variables)
|
|
@@ -952,34 +1017,9 @@ module Funicular
|
|
|
952
1017
|
end
|
|
953
1018
|
end
|
|
954
1019
|
|
|
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
1020
|
# Handle router navigation (navigate using History API)
|
|
981
1021
|
def handle_link_click(path)
|
|
982
|
-
|
|
1022
|
+
@runtime.router&.navigate(path)
|
|
983
1023
|
end
|
|
984
1024
|
|
|
985
1025
|
# Handle link action via Fetch API
|
|
@@ -1008,35 +1048,6 @@ module Funicular
|
|
|
1008
1048
|
end
|
|
1009
1049
|
end
|
|
1010
1050
|
|
|
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
1051
|
# Transition Helpers
|
|
1041
1052
|
# These methods provide a declarative way to animate element removal/addition
|
|
1042
1053
|
# using CSS transitions, inspired by Vue.js and Alpine.js transition systems
|