funicular 0.1.0 → 0.2.1

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +12 -4
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +25 -18
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/form_for_test.rb +106 -0
  45. data/minitest/hydration_test.rb +87 -0
  46. data/minitest/plugin_test.rb +51 -0
  47. data/minitest/schema_test.rb +106 -0
  48. data/minitest/ssr_test.rb +94 -0
  49. data/minitest/validations_test.rb +183 -0
  50. data/mrbgem.rake +1 -0
  51. data/mrblib/0_validations.rb +206 -0
  52. data/mrblib/1_validators.rb +180 -0
  53. data/mrblib/cable.rb +24 -9
  54. data/mrblib/component.rb +246 -47
  55. data/mrblib/debug.rb +3 -0
  56. data/mrblib/differ.rb +47 -37
  57. data/mrblib/file_upload.rb +9 -1
  58. data/mrblib/form_builder.rb +28 -6
  59. data/mrblib/funicular.rb +97 -8
  60. data/mrblib/html_serializer.rb +121 -0
  61. data/mrblib/http.rb +123 -29
  62. data/mrblib/model.rb +50 -0
  63. data/mrblib/patcher.rb +78 -8
  64. data/mrblib/router.rb +40 -3
  65. data/mrblib/store.rb +304 -0
  66. data/mrblib/store_collection.rb +171 -0
  67. data/mrblib/store_singleton.rb +79 -0
  68. data/mrblib/vdom.rb +2 -0
  69. data/sig/cable.rbs +1 -0
  70. data/sig/component.rbs +13 -5
  71. data/sig/funicular.rbs +14 -1
  72. data/sig/html_serializer.rbs +20 -0
  73. data/sig/http.rbs +21 -6
  74. data/sig/model.rbs +6 -1
  75. data/sig/patcher.rbs +4 -1
  76. data/sig/router.rbs +3 -2
  77. data/sig/store.rbs +89 -0
  78. data/sig/store_collection.rbs +43 -0
  79. data/sig/store_singleton.rbs +19 -0
  80. data/sig/validations.rbs +103 -0
  81. data/sig/vdom.rbs +6 -6
  82. metadata +48 -12
  83. data/docs/README.md +0 -419
  84. data/docs/advanced-features.md +0 -632
  85. data/docs/components-and-state.md +0 -539
  86. data/docs/data-fetching.md +0 -528
  87. data/docs/forms.md +0 -446
  88. data/docs/rails-integration.md +0 -426
  89. data/docs/realtime.md +0 -543
  90. data/docs/routing-and-navigation.md +0 -427
  91. data/docs/styling.md +0 -285
data/mrblib/component.rb CHANGED
@@ -62,6 +62,25 @@ module Funicular
62
62
  {}
63
63
  end
64
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
+
65
84
  # Load all registered suspense data
66
85
  # Called automatically in component_mounted if suspense definitions exist
67
86
  def load_suspense_data
@@ -173,10 +192,14 @@ module Funicular
173
192
  # div { user.name }
174
193
  # end
175
194
  def suspense(fallback:, error: nil, &block)
195
+ current_children = @current_children
196
+ child_count_before = current_children&.size
197
+ result = nil
198
+
176
199
  # Check for any rejected suspense
177
200
  rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
178
201
  if rejected_name
179
- if error
202
+ result = if error
180
203
  error.call(@suspense_errors[rejected_name])
181
204
  else
182
205
  fallback.call
@@ -184,13 +207,17 @@ module Funicular
184
207
  elsif suspense_loading?
185
208
  # Check if any suspense is still loading
186
209
  # Note: Loading is started in mount(), not here
187
- fallback.call
210
+ result = fallback.call
188
211
  else
189
212
  # All suspense data loaded, render content
190
- block.call
213
+ result = block.call
191
214
  end
192
- # Note: We don't add result to @current_children because
193
- # the DSL methods inside fallback/block already do that
215
+
216
+ if current_children && current_children.size == child_count_before
217
+ add_child(result)
218
+ end
219
+
220
+ result
194
221
  end
195
222
 
196
223
  # Class methods for styles DSL
@@ -298,6 +325,65 @@ module Funicular
298
325
  end
299
326
  end
300
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
+
301
387
  # Unmount component from DOM
302
388
  def unmount
303
389
  return unless @mounted
@@ -495,42 +581,25 @@ module Funicular
495
581
 
496
582
  private
497
583
 
498
- # Normalize state value by converting JS::Object to Ruby native types
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.
499
588
  def normalize_state_value(value)
500
- if value.is_a?(Hash)
501
- # Recursively normalize hash values
589
+ case value
590
+ when Hash
502
591
  normalized = {} #: Hash[untyped, untyped]
503
- value.each do |k, v|
504
- normalized[k] = normalize_state_value(v)
505
- end
592
+ value.each { |k, v| normalized[k] = normalize_state_value(v) }
506
593
  normalized
507
- elsif value.is_a?(Array)
508
- # Recursively normalize array elements
594
+ when Array
509
595
  value.map { |v| normalize_state_value(v) }
510
- elsif value.is_a?(JS::Object)
511
- # Convert JS::Object to appropriate Ruby type
512
- case value.type
513
- when :string
514
- value.to_s
515
- when :number
516
- # Check if it's an integer or float
517
- num = value.to_f
518
- num == num.to_i ? num.to_i : num
519
- when :boolean
520
- # JS::Object#== now supports direct comparison with Ruby true/false
521
- value == true
522
- when :null, :undefined
523
- nil
524
- when :array
596
+ when JS::Object
597
+ if value.typeof == :array
525
598
  value.to_a.map { |v| normalize_state_value(v) }
526
- when :object
527
- # For plain objects, keep as JS::Object or convert to hash if needed
528
- value
529
599
  else
530
600
  value
531
601
  end
532
602
  else
533
- # Return as-is for Ruby native types
534
603
  value
535
604
  end
536
605
  end
@@ -546,7 +615,10 @@ module Funicular
546
615
  cleanup_events
547
616
 
548
617
  unless patches.empty?
549
- @dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
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)
550
622
  end
551
623
 
552
624
  bind_events(@dom_element, new_vdom)
@@ -599,6 +671,64 @@ module Funicular
599
671
  end
600
672
  end
601
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
+
602
732
  # Collect child component instances from VDOM tree
603
733
  def collect_child_components(vnode)
604
734
  @child_components = []
@@ -632,6 +762,13 @@ module Funicular
632
762
  br hr
633
763
  ]
634
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
+
635
772
  HTML_TAGS.each do |tag|
636
773
  define_method(tag) do |props = {}, &block|
637
774
  # @type self: Component
@@ -674,6 +811,8 @@ module Funicular
674
811
  end
675
812
  end
676
813
 
814
+ private
815
+
677
816
  # Helper to add children in DSL blocks
678
817
  def add_child(child)
679
818
  return unless @rendering && @current_children
@@ -715,20 +854,7 @@ module Funicular
715
854
  ->(event) do
716
855
  event.preventDefault
717
856
 
718
- # Collect form data from state
719
- model_data = state.send(model_key)
720
- form_data = if model_data.is_a?(Hash)
721
- model_data
722
- elsif model_data.respond_to?(:instance_variables)
723
- data = {} #: Hash[Symbol, untyped]
724
- model_data.instance_variables.each do |var|
725
- key = var.to_s.sub('@', '').to_sym
726
- data[key] = model_data.instance_variable_get(var)
727
- end
728
- data
729
- else
730
- {} #: Hash[Symbol, untyped]
731
- end
857
+ form_data = collect_form_data(event, model_key)
732
858
 
733
859
  # Call the submit handler (Symbol, Method, or Proc)
734
860
  case on_submit
@@ -753,6 +879,79 @@ module Funicular
753
879
  end
754
880
  end
755
881
 
882
+ def collect_form_data(event, model_key)
883
+ form_data = collect_dom_form_data(event)
884
+ return form_data unless form_data.empty?
885
+
886
+ collect_state_form_data(model_key)
887
+ end
888
+
889
+ def collect_dom_form_data(event)
890
+ form = event_target(event)
891
+ return {} unless form
892
+
893
+ elements = form[:elements]
894
+ return {} unless elements
895
+
896
+ data = {} #: Hash[Symbol, untyped]
897
+ length_value = elements[:length]
898
+ length = length_value ? length_value.to_i : 0
899
+ i = 0
900
+ while i < length
901
+ field = elements[i]
902
+ add_form_field_value(data, field) if field
903
+ i += 1
904
+ end
905
+ data
906
+ end
907
+
908
+ def event_target(event)
909
+ target = nil
910
+ begin
911
+ target = event[:target]
912
+ rescue
913
+ target = nil
914
+ end
915
+ return target if target
916
+
917
+ event.target if event.respond_to?(:target)
918
+ end
919
+
920
+ def add_form_field_value(data, field)
921
+ name = field[:name].to_s
922
+ return if name.empty?
923
+
924
+ type = field[:type].to_s.downcase
925
+ return if %w[submit button reset].include?(type)
926
+ return if type == "radio" && !field[:checked]
927
+
928
+ value = if type == "checkbox"
929
+ field[:checked]
930
+ elsif type == "file"
931
+ field[:files]
932
+ else
933
+ field[:value]
934
+ end
935
+
936
+ data[name.to_sym] = value
937
+ end
938
+
939
+ def collect_state_form_data(model_key)
940
+ model_data = state.send(model_key)
941
+ if model_data.is_a?(Hash)
942
+ model_data
943
+ elsif model_data.respond_to?(:instance_variables)
944
+ data = {} #: Hash[Symbol, untyped]
945
+ model_data.instance_variables.each do |var|
946
+ key = var.to_s.sub('@', '').to_sym
947
+ data[key] = model_data.instance_variable_get(var)
948
+ end
949
+ data
950
+ else
951
+ {} #: Hash[Symbol, untyped]
952
+ end
953
+ end
954
+
756
955
  # Rails-style link_to helper
757
956
  # Default behavior: Fetch API for same-page actions (SPA-friendly)
758
957
  # Use navigate: true for router navigation (different component tree)
data/mrblib/debug.rb CHANGED
@@ -4,6 +4,9 @@ module Funicular
4
4
  attr_accessor :enabled
5
5
 
6
6
  def enabled?
7
+ # Disabled on the server: SSR instantiates components per request and
8
+ # must not accumulate them in the debug registry.
9
+ return false if Funicular.server?
7
10
  @enabled ||= Funicular.env.development?
8
11
  end
9
12
 
data/mrblib/differ.rb CHANGED
@@ -90,9 +90,22 @@ module Funicular
90
90
  end
91
91
  end
92
92
 
93
+ # Produce a single composite patch describing a keyed-children diff.
94
+ #
95
+ # The earlier implementation emitted independent `[index, ...]` patches
96
+ # for inserts, removes and updates. The patcher consumed them as
97
+ # `replaceChild`, which is unsafe whenever insert and remove indices
98
+ # overlap (e.g. switching between two equal-length sets of keyed
99
+ # children with disjoint keys): inserts overwrite old nodes in place,
100
+ # then the descending removes drop the just-inserted new nodes and
101
+ # the DOM ends up empty.
102
+ #
103
+ # The new shape is `[[:keyed_children, ops, removes]]`, applied as
104
+ # three phases by the patcher:
105
+ # 1. removes (descending old_index, against the original DOM snapshot)
106
+ # 2. content updates for kept children (against the snapshot, no move)
107
+ # 3. inserts in ascending new_index using insertBefore on the live DOM
93
108
  def self.diff_children_with_keys(old_children, new_children)
94
- patches = [] #: Array[patch_t]
95
-
96
109
  # 1. Build key map from old children
97
110
  old_key_map = {} #: Hash[untyped, [Integer, child_t]]
98
111
  old_children.each_with_index do |child, index|
@@ -101,63 +114,60 @@ module Funicular
101
114
  end
102
115
  end
103
116
 
104
- # 2. Track matched old indices
105
117
  matched_old_indices = {} #: Hash[Integer, bool]
118
+ ops = [] #: Array[Array[untyped]]
119
+ has_change = false
106
120
 
107
- # 3. Process new children
121
+ # 2. Process new children, recording one op per new position
108
122
  new_children.each_with_index do |new_child, new_index|
109
123
  if new_child.is_a?(VNode) && new_child.respond_to?(:key) && new_child.key
110
124
  key = new_child.key
111
-
112
125
  if old_key_map[key]
113
126
  old_index, old_child = old_key_map[key]
114
127
  matched_old_indices[old_index] = true
115
-
116
- # Diff existing element
117
128
  child_patches = diff(old_child, new_child)
118
- unless child_patches.empty?
119
- patches << [new_index, child_patches]
120
- end
129
+ ops << [:keep, old_index, new_index, child_patches]
130
+ has_change = true unless child_patches.empty?
121
131
  else
122
- # Insert new element
123
- patches << [new_index, [[:replace, new_child, nil]]] # old_node is nil for insertion
132
+ ops << [:insert, new_index, new_child]
133
+ has_change = true
124
134
  end
125
135
  else
126
- # Fallback to index-based for elements without keys
127
- # Even if old_child.nil?, diff will handle it (insertion)
136
+ # Unkeyed new child: positional fallback. Match it with the
137
+ # old child at the same index only when that old child is
138
+ # itself unkeyed (otherwise the keyed old child is matched
139
+ # separately via its key, and the unkeyed new is a fresh insert).
128
140
  old_child = old_children[new_index]
129
- child_patches = diff(old_child, new_child)
130
- unless child_patches.empty?
131
- patches << [new_index, child_patches]
141
+ old_is_keyed = old_child.is_a?(VNode) && old_child.respond_to?(:key) && old_child.key
142
+ if old_child.nil? || old_is_keyed
143
+ ops << [:insert, new_index, new_child]
144
+ has_change = true
145
+ else
146
+ matched_old_indices[new_index] = true
147
+ child_patches = diff(old_child, new_child)
148
+ ops << [:keep, new_index, new_index, child_patches]
149
+ has_change = true unless child_patches.empty?
132
150
  end
133
151
  end
134
152
  end
135
153
 
136
- # 4. Remove unmatched old elements
154
+ # 3. Collect every unmatched old child to remove, keyed or not. An old
155
+ # child that no new child matched (by key, or positionally for
156
+ # unkeyed ones) is gone from the new render and must be removed.
157
+ # Skipping unkeyed olds here left stale nodes in the DOM, e.g. a
158
+ # "Loading..." placeholder that never disappeared once the keyed
159
+ # list it was replaced by arrived.
160
+ removes = [] #: Array[[Integer, child_t]]
137
161
  old_children.each_with_index do |old_child, old_index|
138
162
  next unless old_child.is_a?(VNode)
139
163
  next if matched_old_indices[old_index]
140
-
141
- if old_child.respond_to?(:key) && old_child.key # Only remove keyed children
142
- patches << [old_index, [[:remove, old_child]]]
143
- end
164
+ removes << [old_index, old_child]
165
+ has_change = true
144
166
  end
145
167
 
146
- # Sort patches: updates first, removes last (descending index)
147
- patches.sort { |a, b|
148
- a_is_remove = a[1].length == 1 && a[1][0][0] == :remove
149
- b_is_remove = b[1].length == 1 && b[1][0][0] == :remove
150
-
151
- if a_is_remove && b_is_remove
152
- b[0] <=> a[0] # Sort remove patches by descending index
153
- elsif a_is_remove
154
- 1 # Remove patches come after non-remove patches
155
- elsif b_is_remove
156
- -1 # Non-remove patches come before remove patches
157
- else
158
- a[0] <=> b[0] # Sort non-remove patches by ascending index
159
- end
160
- }
168
+ return [] unless has_change
169
+
170
+ [[:keyed_children, ops, removes]]
161
171
  end
162
172
 
163
173
  def self.diff_children_by_index(old_children, new_children)
@@ -1,4 +1,11 @@
1
- require 'rng'
1
+ # 'rng' is a PicoRuby gem available only in the wasm build. When the runtime
2
+ # is loaded under CRuby for SSR it is absent; FileUpload is client-only and
3
+ # its methods are never called on the server (see Funicular.server?).
4
+ begin
5
+ require 'rng'
6
+ rescue LoadError
7
+ # not available outside wasm environment
8
+ end
2
9
 
3
10
  module Funicular
4
11
  module FileUpload
@@ -31,6 +38,7 @@ module Funicular
31
38
  JAVASCRIPT
32
39
 
33
40
  def self.mount
41
+ return if Funicular.server?
34
42
  script = JS.document.createElement("script")
35
43
  script[:textContent] = JS_HELPER_CODE
36
44
  JS.document.body.appendChild(script)
@@ -6,8 +6,15 @@ module Funicular
6
6
  @component = component
7
7
  @model_key = model_key
8
8
  @options = options
9
- @error_class = options[:error_class] || "text-red-600 text-sm mt-1"
10
- @field_error_class = options[:field_error_class] || "border-red-500"
9
+ # Per-form options win, then the global Funicular.configure_forms config,
10
+ # then the built-in defaults. The defaults are semantic class names whose
11
+ # CSS the gem ships and injects via picoruby_include_tag, so error styling
12
+ # works without depending on the host app's CSS pipeline (e.g. Tailwind,
13
+ # which never scans the gem and so would not generate utility classes
14
+ # emitted from here).
15
+ config = Funicular.form_builder_config || {}
16
+ @error_class = options[:error_class] || config[:error_class] || "funicular-error"
17
+ @field_error_class = options[:field_error_class] || config[:field_error_class] || "funicular-field-error"
11
18
  end
12
19
 
13
20
  # Generic field builder for input elements
@@ -26,7 +33,10 @@ module Funicular
26
33
 
27
34
  # Check for errors
28
35
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
29
- has_error = !error_message.nil?
36
+ # errors may be a single message (legacy) or an array of messages
37
+ # (Funicular::Model::Errors#messages). Show the first.
38
+ error_message = error_message.first if error_message.is_a?(Array)
39
+ has_error = !(error_message.nil? || error_message == "")
30
40
 
31
41
  # Merge CSS classes (add error class if error exists)
32
42
  css_class = field_options[:class]
@@ -43,6 +53,7 @@ module Funicular
43
53
 
44
54
  # Build field attributes
45
55
  attrs = {
56
+ name: field_options[:name] || field_key,
46
57
  type: field_type,
47
58
  value: value,
48
59
  oninput: on_input
@@ -89,7 +100,10 @@ module Funicular
89
100
  end
90
101
 
91
102
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
92
- has_error = !error_message.nil?
103
+ # errors may be a single message (legacy) or an array of messages
104
+ # (Funicular::Model::Errors#messages). Show the first.
105
+ error_message = error_message.first if error_message.is_a?(Array)
106
+ has_error = !(error_message.nil? || error_message == "")
93
107
 
94
108
  css_class = options[:class]
95
109
  css_class = css_class.to_s if css_class
@@ -104,13 +118,15 @@ module Funicular
104
118
  end
105
119
 
106
120
  attrs = {
121
+ name: options[:name] || field_key,
122
+ value: value,
107
123
  oninput: on_input
108
124
  }.merge(options.reject { |k, _| k == :class })
109
125
 
110
126
  attrs[:class] = css_class unless css_class.empty?
111
127
 
112
128
  @component.div do
113
- @component.textarea(attrs) { value }
129
+ @component.textarea(attrs)
114
130
  if has_error
115
131
  @component.div(class: @error_class) { error_message }
116
132
  end
@@ -129,6 +145,7 @@ module Funicular
129
145
  end
130
146
 
131
147
  attrs = {
148
+ name: options[:name] || field_key,
132
149
  type: "checkbox",
133
150
  checked: value,
134
151
  onchange: on_change
@@ -149,7 +166,10 @@ module Funicular
149
166
  end
150
167
 
151
168
  error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
152
- has_error = !error_message.nil?
169
+ # errors may be a single message (legacy) or an array of messages
170
+ # (Funicular::Model::Errors#messages). Show the first.
171
+ error_message = error_message.first if error_message.is_a?(Array)
172
+ has_error = !(error_message.nil? || error_message == "")
153
173
 
154
174
  css_class = options[:class]
155
175
  css_class = css_class.to_s if css_class
@@ -164,6 +184,7 @@ module Funicular
164
184
  end
165
185
 
166
186
  attrs = {
187
+ name: options[:name] || field_key,
167
188
  onchange: on_change
168
189
  }.merge(options.reject { |k, _| k == :class })
169
190
 
@@ -206,6 +227,7 @@ module Funicular
206
227
  on_change = custom_handler || default_handler
207
228
 
208
229
  attrs = {
230
+ name: options[:name] || field_key,
209
231
  type: "file",
210
232
  onchange: on_change
211
233
  }.merge(options)