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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,1050 @@
1
+ module Funicular
2
+ class Component
3
+ class StateAccessor
4
+ def initialize(state_hash)
5
+ @state = state_hash
6
+ end
7
+
8
+ # Support dynamic key access: state[:key] or state[variable_key]
9
+ def [](key)
10
+ @state[key]
11
+ end
12
+ def method_missing(method, *args)
13
+ if method.to_s.end_with?('=')
14
+ key = method.to_s[0..-2] || method
15
+ raise "Use patch(#{key}: value) to update state"
16
+ else
17
+ @state[method]
18
+ end
19
+ end
20
+
21
+ def respond_to_missing?(method, include_private = false)
22
+ return false if method.to_s.end_with?('=')
23
+ @state.key?(method) || super
24
+ end
25
+ end
26
+
27
+ attr_accessor :props, :vdom, :dom_element, :mounted
28
+ attr_reader :refs
29
+
30
+ def initialize(props = {})
31
+ @props = props
32
+ @state = initialize_state || {}
33
+ @state_accessor = nil
34
+ @style_accessor = nil
35
+ @vdom = nil
36
+ @dom_element = nil
37
+ @refs = {}
38
+ @event_listeners = []
39
+ @mounted = false
40
+ @updating = false
41
+ @child_components = []
42
+
43
+ # Initialize suspense state
44
+ @suspense_data = {}
45
+ @suspense_states = {} # :pending, :loading, :resolved, :rejected
46
+ @suspense_errors = {}
47
+ @suspense_pending_timers = [] # Track pending setTimeout IDs for cleanup
48
+ self.class.suspense_definitions.each_key do |name|
49
+ @suspense_states[name] = :pending
50
+ end
51
+
52
+ # Register component for debugging in development mode
53
+ @__debug_id__ = Funicular::Debug.register_component(self) if Funicular::Debug.enabled?
54
+ end
55
+
56
+ def state
57
+ @state_accessor ||= StateAccessor.new(@state)
58
+ end
59
+
60
+ # Override this method in subclasses to define initial state
61
+ def initialize_state
62
+ {}
63
+ end
64
+
65
+ # Merge externally provided state into the component before rendering.
66
+ # Used by SSR to inject server data, and by client hydration to restore
67
+ # the same state from window.__FUNICULAR_STATE__ so the first client
68
+ # render matches the server HTML. Top-level keys are symbolized so they
69
+ # are reachable via state.foo (StateAccessor uses symbol keys); nested
70
+ # values are left untouched (components read them with string keys).
71
+ def seed_state(state_hash)
72
+ return self if state_hash.nil?
73
+ return self if state_hash.respond_to?(:empty?) && state_hash.empty?
74
+
75
+ symbolized = {} #: Hash[Symbol, untyped]
76
+ state_hash.each do |key, value|
77
+ symbolized[key.to_sym] = value
78
+ end
79
+ @state = @state.merge(symbolized)
80
+ @state_accessor = nil
81
+ self
82
+ end
83
+
84
+ # Load all registered suspense data
85
+ # Called automatically in component_mounted if suspense definitions exist
86
+ def load_suspense_data
87
+ self.class.suspense_definitions.each do |name, loader|
88
+ load_single_suspense(name, loader)
89
+ end
90
+ end
91
+
92
+ # Load a single suspense data by name
93
+ def load_single_suspense(name, definition = nil)
94
+ definition ||= self.class.suspense_definitions[name]
95
+ return unless definition
96
+ return if @suspense_states[name] == :loading
97
+
98
+ # Support both old format (just loader) and new format (hash with loader and on_resolve)
99
+ if definition.is_a?(Hash)
100
+ loader = definition[:loader]
101
+ on_resolve = definition[:on_resolve]
102
+ min_delay = definition[:min_delay]
103
+ else
104
+ loader = definition
105
+ on_resolve = nil
106
+ min_delay = nil
107
+ end
108
+
109
+ @suspense_states[name] = :loading
110
+ start_time = Time.now.to_f * 1000 # Convert to milliseconds
111
+
112
+ # Helper to finalize resolve
113
+ do_resolve = ->(data) {
114
+ @suspense_data[name] = data
115
+ @suspense_states[name] = :resolved
116
+ @suspense_errors[name] = nil
117
+ if on_resolve
118
+ # on_resolve callback is expected to call patch() which triggers re-render
119
+ instance_exec(data, &on_resolve)
120
+ else
121
+ re_render if @mounted
122
+ end
123
+ }
124
+
125
+ resolve = ->(data) {
126
+ if min_delay
127
+ elapsed = (Time.now.to_f * 1000) - start_time
128
+ remaining = min_delay - elapsed
129
+ if remaining > 0
130
+ # Delay resolve to ensure minimum loading time
131
+ timer_id = JS.global.setTimeout(remaining.to_i) do
132
+ do_resolve.call(data) if @mounted
133
+ end
134
+ @suspense_pending_timers << timer_id
135
+ else
136
+ do_resolve.call(data)
137
+ end
138
+ else
139
+ do_resolve.call(data)
140
+ end
141
+ }
142
+
143
+ reject = ->(error) {
144
+ @suspense_data[name] = nil
145
+ @suspense_states[name] = :rejected
146
+ @suspense_errors[name] = error
147
+ re_render if @mounted
148
+ }
149
+
150
+ # Execute loader with resolve/reject callbacks
151
+ instance_exec(resolve, reject, &loader)
152
+ end
153
+
154
+ # Reload suspense data (useful for retry or refresh)
155
+ def reload_suspense(name)
156
+ @suspense_states[name] = :pending
157
+ load_single_suspense(name)
158
+ end
159
+
160
+ # Check if suspense data is loading
161
+ def suspense_loading?(*names)
162
+ names = self.class.suspense_definitions.keys if names.empty?
163
+ names.any? { |name| @suspense_states[name] == :pending || @suspense_states[name] == :loading }
164
+ end
165
+
166
+ # Check if suspense data has error
167
+ def suspense_error?(name)
168
+ @suspense_states[name] == :rejected
169
+ end
170
+
171
+ # Get suspense error
172
+ def suspense_error(name)
173
+ @suspense_errors[name]
174
+ end
175
+
176
+ # Suspense helper for render method
177
+ #
178
+ # @param fallback [Proc] Content to show while loading
179
+ # @param error [Proc] Optional content to show on error (receives error as argument)
180
+ # @yield Block to render when data is loaded
181
+ #
182
+ # @example
183
+ # suspense(fallback: -> { div { "Loading..." } }) do
184
+ # div { user.name }
185
+ # end
186
+ #
187
+ # @example with error handling
188
+ # suspense(
189
+ # fallback: -> { div { "Loading..." } },
190
+ # error: ->(e) { div { "Error: #{e}" } }
191
+ # ) do
192
+ # div { user.name }
193
+ # end
194
+ def suspense(fallback:, error: nil, &block)
195
+ current_children = @current_children
196
+ child_count_before = current_children&.size
197
+ result = nil
198
+
199
+ # Check for any rejected suspense
200
+ rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
201
+ if rejected_name
202
+ result = if error
203
+ error.call(@suspense_errors[rejected_name])
204
+ else
205
+ fallback.call
206
+ end
207
+ elsif suspense_loading?
208
+ # Check if any suspense is still loading
209
+ # Note: Loading is started in mount(), not here
210
+ result = fallback.call
211
+ else
212
+ # All suspense data loaded, render content
213
+ result = block.call
214
+ end
215
+
216
+ if current_children && current_children.size == child_count_before
217
+ add_child(result)
218
+ end
219
+
220
+ result
221
+ end
222
+
223
+ # Class methods for styles DSL
224
+ def self.styles(&block)
225
+ builder = StyleBuilder.new
226
+ builder.instance_eval(&block)
227
+ @styles_definitions = builder.to_definitions
228
+ end
229
+
230
+ def self.styles_definitions
231
+ @styles_definitions ||= {}
232
+ end
233
+
234
+ # Suspense DSL - register async data loaders
235
+ #
236
+ # @param name [Symbol] Name of the suspense data (becomes accessible as method)
237
+ # @param loader [Proc] Lambda that receives resolve and reject callbacks
238
+ # @param on_resolve [Proc] Optional callback called with data after resolve, before re-render
239
+ # @param min_delay [Integer] Minimum time in ms to show loading state (prevents flickering)
240
+ #
241
+ # @example
242
+ # use_suspense :user, ->(resolve, reject) {
243
+ # User.find(props[:id]) do |user, error|
244
+ # error ? reject.call(error) : resolve.call(user)
245
+ # end
246
+ # }
247
+ #
248
+ # @example with on_resolve callback and min_delay
249
+ # use_suspense :current_user,
250
+ # ->(resolve, reject) { Session.current_user { |u, e| ... } },
251
+ # on_resolve: ->(user) { patch(user: { username: user.username }) },
252
+ # min_delay: 300 # Show loading spinner for at least 300ms
253
+ def self.use_suspense(name, loader, on_resolve: nil, min_delay: nil)
254
+ @suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
255
+ @suspense_definitions[name] = { loader: loader, on_resolve: on_resolve, min_delay: min_delay }
256
+ end
257
+
258
+ def self.suspense_definitions
259
+ @suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
260
+ end
261
+
262
+ # Instance method to access styles
263
+ def s
264
+ @style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
265
+ end
266
+
267
+ # Update state and trigger re-render
268
+ def patch(new_state)
269
+ return unless @mounted
270
+ return if @updating
271
+
272
+ begin
273
+ @updating = true
274
+ component_will_update if respond_to?(:component_will_update)
275
+
276
+ # Convert JS::Object values to Ruby native types automatically
277
+ normalized_state = {} #: Hash[Symbol, untyped]
278
+ new_state.each do |key, value|
279
+ normalized_state[key] = normalize_state_value(value)
280
+ end
281
+
282
+ @state = @state.merge(normalized_state)
283
+ @state_accessor = nil # Invalidate accessor to reflect new state
284
+ re_render
285
+
286
+ component_updated if respond_to?(:component_updated)
287
+ rescue => e
288
+ component_raised(e) if respond_to?(:component_raised)
289
+ raise e
290
+ ensure
291
+ @updating = false
292
+ end
293
+ end
294
+
295
+ # Mount component to a DOM container
296
+ def mount(container)
297
+ return if @mounted
298
+
299
+ begin
300
+ component_will_mount if respond_to?(:component_will_mount)
301
+
302
+ @container = container
303
+ new_vdom = build_vdom
304
+ @dom_element = VDOM::Renderer.new.render(new_vdom)
305
+ bind_events(@dom_element, new_vdom)
306
+ collect_refs(@dom_element, new_vdom)
307
+ collect_child_components(new_vdom)
308
+ container.appendChild(@dom_element)
309
+ @vdom = new_vdom
310
+ @mounted = true
311
+
312
+ # Start loading suspense data after mounted
313
+ load_suspense_data if self.class.suspense_definitions.any?
314
+
315
+ # Mark child components as mounted and call their lifecycle hooks
316
+ @child_components.each do |child|
317
+ child.mounted = true
318
+ child.component_mounted if child.respond_to?(:component_mounted)
319
+ end
320
+
321
+ component_mounted if respond_to?(:component_mounted)
322
+ rescue => e
323
+ component_raised(e) if respond_to?(:component_raised)
324
+ raise e
325
+ end
326
+ end
327
+
328
+ # Hydrate this component against server-rendered DOM.
329
+ #
330
+ # Unlike mount, which builds a fresh DOM tree via VDOM::Renderer, hydrate
331
+ # reuses the existing DOM produced by the server: it builds the VDOM,
332
+ # associates it with the existing nodes, and only attaches event listeners
333
+ # and refs (plus wiring child components). The first client render must
334
+ # match the server HTML, which is why state is seeded from
335
+ # window.__FUNICULAR_STATE__ before calling this.
336
+ def hydrate(dom_element)
337
+ return if @mounted
338
+ raise "hydrate: missing server DOM element" unless dom_element
339
+
340
+ begin
341
+ component_will_mount if respond_to?(:component_will_mount)
342
+
343
+ # The router/start sets the container; without it, unmount could not
344
+ # detach this subtree later. Derive it from the existing DOM.
345
+ @container ||= dom_element.parentNode
346
+ @dom_element = dom_element
347
+
348
+ new_vdom = build_vdom
349
+
350
+ if hydration_match?(new_vdom, dom_element)
351
+ # Wire child components first so their instances exist for collection.
352
+ hydrate_child_components(new_vdom, dom_element)
353
+
354
+ # Reuse the same positional walks as mount: they skip Component vnodes
355
+ # (children manage their own events/refs during their own hydrate).
356
+ bind_events(dom_element, new_vdom)
357
+ collect_refs(dom_element, new_vdom)
358
+ collect_child_components(new_vdom)
359
+ else
360
+ # Server and client disagree on structure (nondeterministic render or
361
+ # stale state). Recover by discarding the server DOM and rendering a
362
+ # fresh tree, the same way mount does. The page stays usable; only the
363
+ # first-paint reuse is lost for this subtree.
364
+ warn_hydration_mismatch(new_vdom, dom_element)
365
+ @dom_element = full_render_fallback(new_vdom, dom_element)
366
+ end
367
+
368
+ @vdom = new_vdom
369
+ @mounted = true
370
+
371
+ load_suspense_data if self.class.suspense_definitions.any?
372
+
373
+ @child_components.each do |child|
374
+ unless child.mounted
375
+ child.mounted = true
376
+ child.component_mounted if child.respond_to?(:component_mounted)
377
+ end
378
+ end
379
+
380
+ component_mounted if respond_to?(:component_mounted)
381
+ rescue => e
382
+ component_raised(e) if respond_to?(:component_raised)
383
+ raise e
384
+ end
385
+ end
386
+
387
+ # Unmount component from DOM
388
+ def unmount
389
+ return unless @mounted
390
+ # puts "==> Unmounting: #{self.class} id: #{@__debug_id__}"
391
+
392
+ begin
393
+ component_will_unmount if respond_to?(:component_will_unmount)
394
+
395
+ # Unmount child components first
396
+ # puts " > Unmounting children: #{@child_components.map(&:class).join(', ')}"
397
+ @child_components.each do |child|
398
+ child.unmount if child.respond_to?(:unmount)
399
+ end
400
+ @child_components = []
401
+
402
+ cleanup_events
403
+ cleanup_suspense_timers
404
+ @container.removeChild(@dom_element) if @container && @dom_element
405
+ @mounted = false
406
+ @dom_element = nil
407
+ @vdom = nil
408
+ @refs = {}
409
+
410
+ # Unregister component from debugging in development mode
411
+ Funicular::Debug.unregister_component(@__debug_id__) if @__debug_id__
412
+
413
+ component_unmounted if respond_to?(:component_unmounted)
414
+ rescue => e
415
+ component_raised(e) if respond_to?(:component_raised)
416
+ raise e
417
+ end
418
+ end
419
+
420
+ # Override this method in subclasses to define render logic
421
+ def render
422
+ raise "Subclasses must implement the render method"
423
+ end
424
+
425
+ # Build VDOM tree from render method
426
+ # Called by VDOM::Renderer, Differ, and Patcher
427
+ def build_vdom
428
+ @rendering = true
429
+ @current_children = nil
430
+ result = render
431
+ @rendering = false
432
+
433
+ # Convert render result to VNode
434
+ vnode = normalize_vnode(result)
435
+
436
+ # Add data-component attribute to the root element
437
+ if Funicular.env.development? && vnode
438
+ add_data_component_attribute(vnode)
439
+ end
440
+
441
+ vnode
442
+ end
443
+
444
+ # Bind event handlers to DOM elements
445
+ # Called by VDOM::Renderer and Patcher
446
+ def bind_events(dom_element, vnode)
447
+ # Skip Component vnodes - they manage their own events
448
+ return if vnode.is_a?(VDOM::Component)
449
+ return unless vnode.is_a?(VDOM::Element)
450
+
451
+ event_types = [] #: Array[String]
452
+
453
+ vnode.props.each do |key, value|
454
+ key_str = key.to_s
455
+ next unless key_str.start_with?('on')
456
+
457
+ event_name = key_str[2..-1]&.downcase || ""
458
+ event_types << event_name
459
+
460
+ # addEventListener expects a block, not a Proc
461
+ callback_id = case value
462
+ when Symbol
463
+ result = dom_element.addEventListener(event_name) do |event|
464
+ begin
465
+ self.send(value, event)
466
+ rescue => e
467
+ component_raised(e) if respond_to?(:component_raised)
468
+ raise e
469
+ end
470
+ end
471
+ result
472
+ when Method
473
+ result = dom_element.addEventListener(event_name) do |event|
474
+ begin
475
+ # @type var value: Method
476
+ # Check if Method expects arguments (arity)
477
+ if value.arity == 0
478
+ value.call
479
+ else
480
+ value.call(event)
481
+ end
482
+ rescue => e
483
+ component_raised(e) if respond_to?(:component_raised)
484
+ raise e
485
+ end
486
+ end
487
+ result
488
+ when Proc
489
+ result = dom_element.addEventListener(event_name) do |event|
490
+ begin
491
+ # @type var value: Proc
492
+ # Check if Proc expects arguments (arity)
493
+ if value.arity == 0
494
+ value.call
495
+ else
496
+ value.call(event)
497
+ end
498
+ rescue => e
499
+ component_raised(e) if respond_to?(:component_raised)
500
+ raise e
501
+ end
502
+ end
503
+ result
504
+ else
505
+ raise "Invalid event handler: #{value.class}. Must be Symbol, Method, or Proc."
506
+ end
507
+
508
+ @event_listeners << callback_id
509
+ end
510
+
511
+ # Add debug attribute for DevTools
512
+ if Funicular::Debug.enabled? && event_types.length > 0
513
+ dom_element.setAttribute("data-event-listeners", event_types.join(","))
514
+ end
515
+
516
+ # Recursively bind events for children
517
+ if vnode.children && dom_element.children
518
+ children = dom_element.children.to_a
519
+ vnode.children.each_with_index do |child_vnode, index|
520
+ if child_vnode.is_a?(VDOM::Element)
521
+ child_element = children[index]
522
+ bind_events(child_element, child_vnode) if child_element
523
+ elsif child_vnode.is_a?(VDOM::Component)
524
+ # Component vnodes handle their own events in render_component
525
+ # Skip them here to avoid duplicate event binding
526
+ end
527
+ end
528
+ end
529
+ end
530
+
531
+ # Collect ref elements from VDOM
532
+ # Called by VDOM::Renderer and Patcher
533
+ def collect_refs(dom_element, vnode, refs_map = {})
534
+ # Skip Component vnodes - they manage their own refs
535
+ return refs_map if vnode.is_a?(VDOM::Component)
536
+ return refs_map unless vnode.is_a?(VDOM::Element)
537
+
538
+ if vnode.props[:ref]
539
+ ref_name = vnode.props[:ref].to_sym
540
+ @refs[ref_name] = dom_element
541
+ refs_map[ref_name] = dom_element
542
+ end
543
+
544
+ if vnode.children && dom_element.children
545
+ children = dom_element.children.to_a
546
+ vnode.children.each_with_index do |child_vnode, index|
547
+ if child_vnode.is_a?(VDOM::Element)
548
+ child_element = children[index]
549
+ collect_refs(child_element, child_vnode, refs_map) if child_element
550
+ elsif child_vnode.is_a?(VDOM::Component)
551
+ # Component vnodes handle their own refs in render_component
552
+ # Skip them here to avoid duplicate processing
553
+ end
554
+ end
555
+ end
556
+
557
+ refs_map
558
+ end
559
+
560
+ # Cleanup event listeners
561
+ # Called by VDOM::Patcher
562
+ def cleanup_events
563
+ @event_listeners.each do |callback_id|
564
+ JS::Object.removeEventListener(callback_id)
565
+ end
566
+ @event_listeners = []
567
+
568
+ # NOTE: Do NOT cleanup child component events here!
569
+ # Child components manage their own events and will cleanup
570
+ # when they themselves re-render or unmount
571
+ end
572
+
573
+ # Cleanup pending suspense timers
574
+ def cleanup_suspense_timers
575
+ return unless @suspense_pending_timers
576
+ @suspense_pending_timers.each do |timer_id|
577
+ JS.global.clearTimeout(timer_id)
578
+ end
579
+ @suspense_pending_timers = []
580
+ end
581
+
582
+ private
583
+
584
+ # Normalize state value for storage. Primitives are already auto-
585
+ # converted to Ruby native values by picoruby-wasm, so only composite
586
+ # JS::Object values (array / plain object / function / symbol / bigint)
587
+ # and nested Ruby collections need handling here.
588
+ def normalize_state_value(value)
589
+ case value
590
+ when Hash
591
+ normalized = {} #: Hash[untyped, untyped]
592
+ value.each { |k, v| normalized[k] = normalize_state_value(v) }
593
+ normalized
594
+ when Array
595
+ value.map { |v| normalize_state_value(v) }
596
+ when JS::Object
597
+ if value.typeof == :array
598
+ value.to_a.map { |v| normalize_state_value(v) }
599
+ else
600
+ value
601
+ end
602
+ else
603
+ value
604
+ end
605
+ end
606
+
607
+ # Re-render component (called by update)
608
+ def re_render
609
+ return unless @mounted
610
+
611
+ new_vdom = build_vdom
612
+ patches = VDOM::Differ.diff(@vdom, new_vdom)
613
+
614
+ # Always cleanup and rebind events to avoid stale event listeners
615
+ cleanup_events
616
+
617
+ unless patches.empty?
618
+ new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
619
+ # apply returns JS::Object (it must accept text-node patches), but
620
+ # the component's root is always an Element. Narrow to JS::Element.
621
+ @dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
622
+ end
623
+
624
+ bind_events(@dom_element, new_vdom)
625
+ collect_refs(@dom_element, new_vdom)
626
+ collect_child_components(new_vdom)
627
+
628
+ # Mark child components as mounted and call their lifecycle hooks
629
+ @child_components.each do |child|
630
+ unless child.mounted
631
+ child.mounted = true
632
+ child.component_mounted if child.respond_to?(:component_mounted)
633
+ end
634
+ end
635
+
636
+ @vdom = new_vdom
637
+ end
638
+
639
+ # Add data-component attribute to the root element
640
+ def add_data_component_attribute(vnode)
641
+ return unless vnode.is_a?(VDOM::Element)
642
+ vnode.props[:'data-component'] = self.class.to_s
643
+ vnode.props[:'data-component-id'] = @__debug_id__.to_s if @__debug_id__
644
+ end
645
+
646
+ # Normalize render result to VNode
647
+ def normalize_vnode(value)
648
+ case value
649
+ when VDOM::VNode
650
+ _ = value
651
+ when String
652
+ VDOM::Text.new(value)
653
+ when Integer, Float
654
+ VDOM::Text.new(value.to_s)
655
+ when Array
656
+ # Arrays are typically return values from iterators like .each or .map
657
+ # The elements have already been added to @current_children during iteration
658
+ # Return nil to avoid duplicate rendering
659
+ nil
660
+ when nil
661
+ VDOM::Text.new("")
662
+ when Class
663
+ # If it's a component class, create a component VNode
664
+ if value.ancestors.include?(Funicular::Component)
665
+ VDOM::Component.new(value, {})
666
+ else
667
+ VDOM::Text.new(value.to_s)
668
+ end
669
+ else
670
+ VDOM::Text.new(value.to_s)
671
+ end
672
+ end
673
+
674
+ # Lightweight structural check: the root tag of the freshly built VDOM
675
+ # must match the server-rendered element. A mismatch means server and
676
+ # client disagree (nondeterministic render or stale state); the caller
677
+ # falls back to a full client render.
678
+ def hydration_match?(vnode, dom_element)
679
+ return true unless vnode.is_a?(VDOM::Element)
680
+ actual = dom_element[:tagName]
681
+ return true unless actual # non-element node; let later steps surface issues
682
+ vnode.tag.to_s.downcase == actual.to_s.downcase
683
+ end
684
+
685
+ # Emit a development-only warning describing a hydration mismatch. Uses
686
+ # puts so the message reaches the browser console (same idiom as the
687
+ # ErrorBoundary logger), and is silent in production.
688
+ def warn_hydration_mismatch(vnode, dom_element)
689
+ return unless Funicular.env.development?
690
+ expected = vnode.is_a?(VDOM::Element) ? vnode.tag.to_s.downcase : vnode.class.to_s
691
+ got = dom_element[:tagName].to_s.downcase
692
+ puts "[Funicular] Hydration mismatch: expected <#{expected}>, found <#{got}>; " \
693
+ "falling back to full client render"
694
+ end
695
+
696
+ # Recover from a hydration mismatch by rendering a fresh DOM tree and
697
+ # swapping it in for the server-rendered node. Mirrors mount, minus the
698
+ # appendChild: the stale node already has a place in the document, so we
699
+ # replaceChild instead.
700
+ def full_render_fallback(new_vdom, server_dom)
701
+ fresh = VDOM::Renderer.new.render(new_vdom)
702
+ parent = server_dom.parentNode
703
+ parent.replaceChild(fresh, server_dom) if parent
704
+ bind_events(fresh, new_vdom)
705
+ collect_refs(fresh, new_vdom)
706
+ collect_child_components(new_vdom)
707
+ fresh
708
+ end
709
+
710
+ # Walk the VDOM and existing DOM in parallel to hydrate nested components.
711
+ # Uses the same positional indexing as bind_events/collect_refs so the
712
+ # three walks agree on which DOM node maps to which vnode.
713
+ def hydrate_child_components(vnode, dom_element)
714
+ return unless vnode.is_a?(VDOM::Element)
715
+ return unless dom_element
716
+
717
+ dom_children = dom_element.children.to_a
718
+ vnode.children.each_with_index do |child, index|
719
+ child_dom = dom_children[index]
720
+ next unless child_dom
721
+
722
+ if child.is_a?(VDOM::Component)
723
+ instance = child.component_class.new(child.props)
724
+ child.instance = instance
725
+ instance.hydrate(child_dom)
726
+ elsif child.is_a?(VDOM::Element)
727
+ hydrate_child_components(child, child_dom)
728
+ end
729
+ end
730
+ end
731
+
732
+ # Collect child component instances from VDOM tree
733
+ def collect_child_components(vnode)
734
+ @child_components = []
735
+ collect_child_components_recursive(vnode, @child_components)
736
+ end
737
+
738
+ def collect_child_components_recursive(vnode, components)
739
+ if vnode.is_a?(VDOM::Component)
740
+ components << vnode.instance if vnode.instance
741
+ # Recursively collect from child component's vdom
742
+ if vnode.instance && vnode.instance.vdom
743
+ collect_child_components_recursive(vnode.instance.vdom, components)
744
+ end
745
+ elsif vnode.is_a?(VDOM::Element)
746
+ vnode.children&.each do |child|
747
+ # @type var child: VDOM::VNode
748
+ collect_child_components_recursive(child, components)
749
+ end
750
+ end
751
+ end
752
+
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
+ # Collect form data from state
858
+ model_data = state.send(model_key)
859
+ form_data = if model_data.is_a?(Hash)
860
+ model_data
861
+ elsif model_data.respond_to?(:instance_variables)
862
+ data = {} #: Hash[Symbol, untyped]
863
+ model_data.instance_variables.each do |var|
864
+ key = var.to_s.sub('@', '').to_sym
865
+ data[key] = model_data.instance_variable_get(var)
866
+ end
867
+ data
868
+ else
869
+ {} #: Hash[Symbol, untyped]
870
+ end
871
+
872
+ # Call the submit handler (Symbol, Method, or Proc)
873
+ case on_submit
874
+ when Symbol
875
+ send(on_submit, form_data)
876
+ when Method
877
+ on_submit.call(form_data)
878
+ when Proc
879
+ on_submit.call(form_data)
880
+ else
881
+ raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
882
+ end
883
+ end
884
+ else
885
+ ->(event) { event.preventDefault }
886
+ end
887
+
888
+ # Render form
889
+ form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
890
+ builder = Funicular::FormBuilder.new(self, model_key, options)
891
+ block.call(builder)
892
+ end
893
+ end
894
+
895
+ # Rails-style link_to helper
896
+ # Default behavior: Fetch API for same-page actions (SPA-friendly)
897
+ # Use navigate: true for router navigation (different component tree)
898
+ def link_to(path, method: :get, navigate: false, **options, &block)
899
+ if navigate
900
+ # Navigation: Use <a> tag with real href for browser features
901
+ # (right-click -> open in new tab, link preview, etc.)
902
+ # Note: preventDefault is automatically called by js_add_event_listener
903
+ # for <a> tags to enable SPA navigation
904
+ merged_options = options.merge(href: path)
905
+ merged_options[:onclick] = ->(event) {
906
+ event.preventDefault # Called for clarity, but already handled by JS layer
907
+ handle_link_click(path)
908
+ }
909
+ a(merged_options, &block)
910
+ else
911
+ # Action: Use <div> tag (semantically more appropriate for actions)
912
+ # No href needed, purely onclick-driven
913
+ merged_options = options.merge(
914
+ onclick: -> { handle_link_with_method(path, method) }
915
+ )
916
+ div(merged_options, &block)
917
+ end
918
+ end
919
+
920
+ # Handle router navigation (navigate using History API)
921
+ def handle_link_click(path)
922
+ Funicular.router&.navigate(path)
923
+ end
924
+
925
+ # Handle link action via Fetch API
926
+ def handle_link_with_method(path, method)
927
+ # Call appropriate HTTP method
928
+ case method.to_s.downcase.to_sym
929
+ when :get
930
+ HTTP.get(path) { |response| handle_link_response(response, path, method) }
931
+ when :post
932
+ HTTP.post(path) { |response| handle_link_response(response, path, method) }
933
+ when :put
934
+ HTTP.put(path) { |response| handle_link_response(response, path, method) }
935
+ when :patch
936
+ HTTP.patch(path) { |response| handle_link_response(response, path, method) }
937
+ when :delete
938
+ HTTP.delete(path) { |response| handle_link_response(response, path, method) }
939
+ else
940
+ raise "Unsupported HTTP method: #{method}"
941
+ end
942
+ end
943
+
944
+ # Handle response from link action (can be overridden by subclasses)
945
+ def handle_link_response(response, path, method)
946
+ if response.error?
947
+ puts "Link action failed (#{method.to_s.upcase} #{path}): #{response.error_message}"
948
+ end
949
+ end
950
+
951
+ # Enable URL helpers from RouteHelpers module and suspense data accessors
952
+ def method_missing(method, *args)
953
+ # Check for suspense data accessor
954
+ if self.class.suspense_definitions.key?(method)
955
+ return @suspense_data[method]
956
+ end
957
+
958
+ if Funicular.const_defined?(:RouteHelpers)
959
+ helpers = Funicular::RouteHelpers
960
+ if helpers.instance_methods.include?(method)
961
+ # Include helpers module and retry
962
+ self.class.include(helpers) unless self.class.include?(helpers)
963
+ return send(method, *args)
964
+ end
965
+ end
966
+ super
967
+ end
968
+
969
+ def respond_to_missing?(method, include_private = false)
970
+ # Check for suspense data accessor
971
+ return true if self.class.suspense_definitions.key?(method)
972
+
973
+ if Funicular.const_defined?(:RouteHelpers)
974
+ Funicular::RouteHelpers.instance_methods.include?(method) || super
975
+ else
976
+ super
977
+ end
978
+ end
979
+
980
+ # Transition Helpers
981
+ # These methods provide a declarative way to animate element removal/addition
982
+ # using CSS transitions, inspired by Vue.js and Alpine.js transition systems
983
+
984
+ # Remove an element via CSS transition animation
985
+ #
986
+ # @param element_id [String] DOM element ID (without '#' prefix)
987
+ # @param from [String] CSS classes to remove before animation
988
+ # @param to [String] CSS classes to add for leave animation
989
+ # @param duration [Integer] Animation duration in milliseconds (default: 300)
990
+ # @param callback [Proc] Block called after animation completes
991
+ #
992
+ # @example Remove message with fade out
993
+ # remove_via("message-123",
994
+ # "opacity-100 max-h-screen"
995
+ # "opacity-0 max-h-0",
996
+ # duration: 500,
997
+ # ) do
998
+ # patch(messages: updated_messages)
999
+ # end
1000
+ def remove_via(element_id, from, to, duration: 300, &callback)
1001
+ element = JS.document.getElementById(element_id)
1002
+
1003
+ unless element
1004
+ callback.call if callback
1005
+ return
1006
+ end
1007
+
1008
+ element.classList.remove(*from.split(" ")) unless from.empty?
1009
+ element.classList.add(*to.split(" ")) unless to.empty?
1010
+
1011
+ JS.global.setTimeout(duration) do
1012
+ callback&.call
1013
+ end
1014
+ end
1015
+
1016
+ # Add an element via CSS transition animation
1017
+ #
1018
+ # @param element_id [String] DOM element ID (without '#' prefix)
1019
+ # @param from [String] CSS classes to remove before animation
1020
+ # @param to [String] CSS classes to add for leave animation
1021
+ # @param duration [Integer] Animation duration in milliseconds (default: 300)
1022
+ # @param callback [Proc] Block called after animation completes
1023
+ #
1024
+ # @example Add message with fade in
1025
+ # add_via("message-456",
1026
+ # "opacity-0 scale-95",
1027
+ # "opacity-100 scale-100",
1028
+ # duration: 300
1029
+ # )
1030
+ def add_via(element_id, from, to, duration: 300, &callback)
1031
+ element = JS.document.getElementById(element_id)
1032
+
1033
+ unless element
1034
+ callback.call if callback
1035
+ return
1036
+ end
1037
+
1038
+ element.classList.add(*from.split(" ")) unless from.empty?
1039
+
1040
+ sleep_ms 10
1041
+
1042
+ element.classList.remove(*from.split(" ")) unless from.empty?
1043
+ element.classList.add(*to.split(" ")) unless to.empty?
1044
+
1045
+ JS.global.setTimeout(duration) do
1046
+ callback&.call
1047
+ end
1048
+ end
1049
+ end
1050
+ end